diff --git a/src/QueryEngine.ts b/src/QueryEngine.ts index c9d67d382..c91c01f14 100644 --- a/src/QueryEngine.ts +++ b/src/QueryEngine.ts @@ -41,7 +41,11 @@ import { type Tools, type ToolUseContext, toolMatchesName } from './Tool.js' import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js' import { SYNTHETIC_OUTPUT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SyntheticOutputTool/SyntheticOutputTool.js' import type { APIError } from '@anthropic-ai/sdk' -import type { CompactMetadata, Message, SystemCompactBoundaryMessage } from './types/message.js' +import type { + CompactMetadata, + Message, + SystemCompactBoundaryMessage, +} from './types/message.js' import type { OrphanedPermission } from './types/textInputTypes.js' import { createAbortController } from './utils/abortController.js' import type { AttributionState } from './utils/commitAttribution.js' @@ -708,7 +712,8 @@ export class QueryEngine { message.subtype === 'compact_boundary' ) { const compactMsg = message as SystemCompactBoundaryMessage - const tailUuid = compactMsg.compactMetadata?.preservedSegment?.tailUuid + const tailUuid = + compactMsg.compactMetadata?.preservedSegment?.tailUuid if (tailUuid) { const tailIdx = this.mutableMessages.findLastIndex( m => m.uuid === tailUuid, @@ -768,7 +773,10 @@ export class QueryEngine { // streamed responses, this is null at content_block_stop time; // the real value arrives via message_delta (handled below). const msg = message as Message - const stopReason = msg.message?.stop_reason as string | null | undefined + const stopReason = msg.message?.stop_reason as + | string + | null + | undefined if (stopReason != null) { lastStopReason = stopReason } @@ -798,11 +806,15 @@ export class QueryEngine { break } case 'stream_event': { - const event = (message as unknown as { event: Record }).event + const event = ( + message as unknown as { event: Record } + ).event if (event.type === 'message_start') { // Reset current message usage for new message currentMessageUsage = EMPTY_USAGE - const eventMessage = event.message as { usage: BetaMessageDeltaUsage } + const eventMessage = event.message as { + usage: BetaMessageDeltaUsage + } currentMessageUsage = updateUsage( currentMessageUsage, eventMessage.usage, @@ -851,7 +863,15 @@ export class QueryEngine { void recordTranscript(messages) } - const attachment = msg.attachment as { type: string; data?: unknown; turnCount?: number; maxTurns?: number; prompt?: string; source_uuid?: string; [key: string]: unknown } + const attachment = msg.attachment as { + type: string + data?: unknown + turnCount?: number + maxTurns?: number + prompt?: string + source_uuid?: string + [key: string]: unknown + } // Extract structured output from StructuredOutput tool calls if (attachment.type === 'structured_output') { @@ -892,10 +912,7 @@ export class QueryEngine { return } // Yield queued_command attachments as SDK user message replays - else if ( - replayUserMessages && - attachment.type === 'queued_command' - ) { + else if (replayUserMessages && attachment.type === 'queued_command') { yield { type: 'user', message: { @@ -923,10 +940,7 @@ export class QueryEngine { // never shrinks (memory leak in long SDK sessions). The subtype // check lives inside the injected callback so feature-gated strings // stay out of this file (excluded-strings check). - const snipResult = this.config.snipReplay?.( - msg, - this.mutableMessages, - ) + const snipResult = this.config.snipReplay?.(msg, this.mutableMessages) if (snipResult !== undefined) { if (snipResult.executed) { this.mutableMessages.length = 0 @@ -936,10 +950,7 @@ export class QueryEngine { } this.mutableMessages.push(msg) // Yield compact boundary messages to SDK - if ( - msg.subtype === 'compact_boundary' && - msg.compactMetadata - ) { + if (msg.subtype === 'compact_boundary' && msg.compactMetadata) { const compactMsg = msg as SystemCompactBoundaryMessage // Release pre-compaction messages for GC. The boundary was just // pushed so it's the last element. query.ts already uses @@ -959,11 +970,18 @@ export class QueryEngine { subtype: 'compact_boundary' as const, session_id: getSessionId(), uuid: msg.uuid, - compact_metadata: toSDKCompactMetadata(compactMsg.compactMetadata), + compact_metadata: toSDKCompactMetadata( + compactMsg.compactMetadata, + ), } } if (msg.subtype === 'api_error') { - const apiErrorMsg = msg as Message & { retryAttempt: number; maxRetries: number; retryInMs: number; error: APIError } + const apiErrorMsg = msg as Message & { + retryAttempt: number + maxRetries: number + retryInMs: number + error: APIError + } yield { type: 'system', subtype: 'api_retry' as const, @@ -980,7 +998,10 @@ export class QueryEngine { break } case 'tool_use_summary': { - const msg = message as Message & { summary: unknown; precedingToolUseIds: unknown } + const msg = message as Message & { + summary: unknown + precedingToolUseIds: unknown + } // Yield tool use summary messages to SDK yield { type: 'tool_use_summary' as const, @@ -1089,7 +1110,10 @@ export class QueryEngine { const edeResultType = result?.type ?? 'undefined' const edeLastContentType = result?.type === 'assistant' - ? (last(result.message!.content as import('@anthropic-ai/sdk/resources/beta/messages/messages.js').BetaContentBlock[])?.type ?? 'none') + ? (last( + result.message! + .content as import('@anthropic-ai/sdk/resources/beta/messages/messages.js').BetaContentBlock[], + )?.type ?? 'none') : 'n/a' // Flush buffered transcript writes before yielding result. @@ -1147,7 +1171,10 @@ export class QueryEngine { let isApiError = false if (result.type === 'assistant') { - const lastContent = last(result.message!.content as import('@anthropic-ai/sdk/resources/beta/messages/messages.js').BetaContentBlock[]) + const lastContent = last( + result.message! + .content as import('@anthropic-ai/sdk/resources/beta/messages/messages.js').BetaContentBlock[], + ) if ( lastContent?.type === 'text' && !SYNTHETIC_MESSAGES.has(lastContent.text) diff --git a/src/__tests__/context.baseline.test.ts b/src/__tests__/context.baseline.test.ts index c40632cbc..6f24ade24 100644 --- a/src/__tests__/context.baseline.test.ts +++ b/src/__tests__/context.baseline.test.ts @@ -10,7 +10,11 @@ import { setSystemPromptInjection, } from '../context' import { clearMemoryFileCaches } from '../utils/claudemd' -import { cleanupTempDir, createTempDir, writeTempFile } from '../../tests/mocks/file-system' +import { + cleanupTempDir, + createTempDir, + writeTempFile, +} from '../../tests/mocks/file-system' let tempDir = '' let projectClaudeMdContent = '' diff --git a/src/bootstrap/src/entrypoints/agentSdkTypes.ts b/src/bootstrap/src/entrypoints/agentSdkTypes.ts index 8491988f8..dee28fdbe 100644 --- a/src/bootstrap/src/entrypoints/agentSdkTypes.ts +++ b/src/bootstrap/src/entrypoints/agentSdkTypes.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type HookEvent = any; -export type ModelUsage = any; +export type HookEvent = any +export type ModelUsage = any diff --git a/src/bootstrap/src/tools/AgentTool/agentColorManager.ts b/src/bootstrap/src/tools/AgentTool/agentColorManager.ts index b1a565c12..7c86a3adc 100644 --- a/src/bootstrap/src/tools/AgentTool/agentColorManager.ts +++ b/src/bootstrap/src/tools/AgentTool/agentColorManager.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type AgentColorName = any; +export type AgentColorName = any diff --git a/src/bootstrap/src/types/hooks.ts b/src/bootstrap/src/types/hooks.ts index ee7a626db..41408a820 100644 --- a/src/bootstrap/src/types/hooks.ts +++ b/src/bootstrap/src/types/hooks.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type HookCallbackMatcher = any; +export type HookCallbackMatcher = any diff --git a/src/bootstrap/src/types/ids.ts b/src/bootstrap/src/types/ids.ts index 34291796d..a30f93950 100644 --- a/src/bootstrap/src/types/ids.ts +++ b/src/bootstrap/src/types/ids.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type SessionId = any; +export type SessionId = any diff --git a/src/bootstrap/src/utils/crypto.ts b/src/bootstrap/src/utils/crypto.ts index 61e51b7c0..269d7c171 100644 --- a/src/bootstrap/src/utils/crypto.ts +++ b/src/bootstrap/src/utils/crypto.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type randomUUID = any; +export type randomUUID = any diff --git a/src/bootstrap/src/utils/model/model.ts b/src/bootstrap/src/utils/model/model.ts index 982102634..7be12d147 100644 --- a/src/bootstrap/src/utils/model/model.ts +++ b/src/bootstrap/src/utils/model/model.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type ModelSetting = any; +export type ModelSetting = any diff --git a/src/bootstrap/src/utils/model/modelStrings.ts b/src/bootstrap/src/utils/model/modelStrings.ts index d632b76bf..6a98f6f19 100644 --- a/src/bootstrap/src/utils/model/modelStrings.ts +++ b/src/bootstrap/src/utils/model/modelStrings.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type ModelStrings = any; +export type ModelStrings = any diff --git a/src/bootstrap/src/utils/settings/constants.ts b/src/bootstrap/src/utils/settings/constants.ts index b82138d6a..24eb36c76 100644 --- a/src/bootstrap/src/utils/settings/constants.ts +++ b/src/bootstrap/src/utils/settings/constants.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type SettingSource = any; +export type SettingSource = any diff --git a/src/bootstrap/src/utils/settings/settingsCache.ts b/src/bootstrap/src/utils/settings/settingsCache.ts index 818a7b15c..5a0a77205 100644 --- a/src/bootstrap/src/utils/settings/settingsCache.ts +++ b/src/bootstrap/src/utils/settings/settingsCache.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type resetSettingsCache = any; +export type resetSettingsCache = any diff --git a/src/bootstrap/src/utils/settings/types.ts b/src/bootstrap/src/utils/settings/types.ts index dfe971ff5..dfb762ce0 100644 --- a/src/bootstrap/src/utils/settings/types.ts +++ b/src/bootstrap/src/utils/settings/types.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type PluginHookMatcher = any; +export type PluginHookMatcher = any diff --git a/src/bootstrap/src/utils/signal.ts b/src/bootstrap/src/utils/signal.ts index 7c6732c50..f689745dc 100644 --- a/src/bootstrap/src/utils/signal.ts +++ b/src/bootstrap/src/utils/signal.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type createSignal = any; +export type createSignal = any diff --git a/src/bootstrap/state.ts b/src/bootstrap/state.ts index 66702cadf..d9d687bf9 100644 --- a/src/bootstrap/state.ts +++ b/src/bootstrap/state.ts @@ -1755,4 +1755,6 @@ export function getPromptId(): string | null { export function setPromptId(id: string | null): void { STATE.promptId = id } -export function isReplBridgeActive(): boolean { return false; } +export function isReplBridgeActive(): boolean { + return false +} diff --git a/src/bridge/bridgeApi.ts b/src/bridge/bridgeApi.ts index e6792eb43..1e0490ce9 100644 --- a/src/bridge/bridgeApi.ts +++ b/src/bridge/bridgeApi.ts @@ -225,7 +225,9 @@ export function createBridgeApiClient(deps: BridgeApiDeps): BridgeApiClient { ) handleErrorStatus(response.status, response.data, 'Poll') - rcLog(`poll response: status=${response.status} hasData=${!!response.data} url=${deps.baseUrl}`) + rcLog( + `poll response: status=${response.status} hasData=${!!response.data} url=${deps.baseUrl}`, + ) // Empty body or null = no work available if (!response.data) { diff --git a/src/bridge/bridgeMain.ts b/src/bridge/bridgeMain.ts index c0b172386..f7dbe4d03 100644 --- a/src/bridge/bridgeMain.ts +++ b/src/bridge/bridgeMain.ts @@ -448,9 +448,11 @@ export async function runBridgeLoop( ): (status: SessionDoneStatus) => void { return (rawStatus: SessionDoneStatus): void => { const workId = sessionWorkIds.get(sessionId) - rcLog(`session done: sessionId=${sessionId} workId=${workId ?? 'none'} status=${rawStatus}` + - ` wasTimedOut=${timedOutSessions.has(sessionId)} duration=${Math.round((Date.now() - startTime) / 1000)}s` + - ` stderr=${handle.lastStderr.length > 0 ? handle.lastStderr.join('\\n').slice(0, 500) : '(none)'}`) + rcLog( + `session done: sessionId=${sessionId} workId=${workId ?? 'none'} status=${rawStatus}` + + ` wasTimedOut=${timedOutSessions.has(sessionId)} duration=${Math.round((Date.now() - startTime) / 1000)}s` + + ` stderr=${handle.lastStderr.length > 0 ? handle.lastStderr.join('\\n').slice(0, 500) : '(none)'}`, + ) activeSessions.delete(sessionId) sessionStartTimes.delete(sessionId) sessionWorkIds.delete(sessionId) @@ -609,7 +611,9 @@ export async function runBridgeLoop( const pollConfig = getPollIntervalConfig() try { - rcLog(`poll: envId=${environmentId} activeSessions=${activeSessions.size}`) + rcLog( + `poll: envId=${environmentId} activeSessions=${activeSessions.size}`, + ) const work = await api.pollForWork( environmentId, environmentSecret, @@ -864,7 +868,9 @@ export async function runBridgeLoop( break case 'session': { const sessionId = work.data.id - rcLog(`work received: type=session sessionId=${sessionId} workId=${work.id}`) + rcLog( + `work received: type=session sessionId=${sessionId} workId=${work.id}`, + ) try { validateBridgeId(sessionId, 'session_id') } catch { @@ -1032,9 +1038,9 @@ export async function runBridgeLoop( rcLog( `spawning session: sessionId=${sessionId} sdkUrl=${sdkUrl}` + - ` useCcrV2=${useCcrV2} workerEpoch=${workerEpoch}` + - ` dir=${sessionDir}` + - ` accessToken=${secret.session_ingress_token ? secret.session_ingress_token.slice(0, 8) + '...' : 'NONE'}`, + ` useCcrV2=${useCcrV2} workerEpoch=${workerEpoch}` + + ` dir=${sessionDir}` + + ` accessToken=${secret.session_ingress_token ? secret.session_ingress_token.slice(0, 8) + '...' : 'NONE'}`, ) const spawnResult = safeSpawn( spawner, @@ -1281,8 +1287,8 @@ export async function runBridgeLoop( const errMsg = describeAxiosError(err) rcLog( `poll error: ${errMsg}` + - ` isConn=${isConnectionError(err)} isServer=${isServerError(err)}` + - ` activeSessions=${activeSessions.size}`, + ` isConn=${isConnectionError(err)} isServer=${isServerError(err)}` + + ` activeSessions=${activeSessions.size}`, ) if (isConnectionError(err) || isServerError(err)) { @@ -1676,7 +1682,7 @@ async function stopWorkWithRetry( } const errMsg = errorMessage(err) if (attempt < MAX_ATTEMPTS) { - const delay = addJitter(baseDelayMs * Math.pow(2, attempt - 1)) + const delay = addJitter(baseDelayMs * 2 ** (attempt - 1)) logger.logVerbose( `Failed to stop work ${workId} (attempt ${attempt}/${MAX_ATTEMPTS}), retrying in ${formatDelay(delay)}: ${errMsg}`, ) @@ -1964,7 +1970,6 @@ NOTES - You must be logged in with a Claude account that has a subscription - Run \`claude\` first in the directory to accept the workspace trust dialog ${serverNote}` - // biome-ignore lint/suspicious/noConsole: intentional help output console.log(help) } @@ -2003,7 +2008,6 @@ export async function bridgeMain(args: string[]): Promise { return } if (parsed.error) { - // biome-ignore lint/suspicious/noConsole: intentional error output console.error(`Error: ${parsed.error}`) // eslint-disable-next-line custom-rules/no-process-exit process.exit(1) @@ -2042,7 +2046,6 @@ export async function bridgeMain(args: string[]): Promise { const { PERMISSION_MODES } = await import('../types/permissions.js') const valid: readonly string[] = PERMISSION_MODES if (!valid.includes(permissionMode)) { - // biome-ignore lint/suspicious/noConsole: intentional error output console.error( `Error: Invalid permission mode '${permissionMode}'. Valid modes: ${valid.join(', ')}`, ) @@ -2085,7 +2088,6 @@ export async function bridgeMain(args: string[]): Promise { Promise.all([shutdown1PEventLogging(), shutdownDatadog()]), sleep(500, undefined, { unref: true }), ]).catch(() => {}) - // biome-ignore lint/suspicious/noConsole: intentional error output console.error( 'Error: Multi-session Remote Control is not enabled for your account yet.', ) @@ -2102,7 +2104,6 @@ export async function bridgeMain(args: string[]): Promise { // The bridge bypasses main.tsx (which renders the interactive TrustDialog via showSetupScreens), // so we must verify trust was previously established by a normal `claude` session. if (!checkHasTrustDialogAccepted()) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error( `Error: Workspace not trusted. Please run \`claude\` in ${dir} first to review and accept the workspace trust dialog.`, ) @@ -2119,7 +2120,6 @@ export async function bridgeMain(args: string[]): Promise { const bridgeToken = getBridgeAccessToken() if (!bridgeToken) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error(BRIDGE_LOGIN_ERROR) // eslint-disable-next-line custom-rules/no-process-exit process.exit(1) @@ -2138,7 +2138,6 @@ export async function bridgeMain(args: string[]): Promise { input: process.stdin, output: process.stdout, }) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log( '\nRemote Control lets you access this CLI session from the web (claude.ai/code)\nor the Claude app, so you can pick up where you left off on any device.\n\nYou can disconnect remote access anytime by running /remote-control again.\n', ) @@ -2170,7 +2169,6 @@ export async function bridgeMain(args: string[]): Promise { ) const found = await readBridgePointerAcrossWorktrees(dir) if (!found) { - // biome-ignore lint/suspicious/noConsole: intentional error output console.error( `Error: No recent session found in this directory or its worktrees. Run \`claude remote-control\` to start a new one.`, ) @@ -2181,7 +2179,6 @@ export async function bridgeMain(args: string[]): Promise { const ageMin = Math.round(pointer.ageMs / 60_000) const ageStr = ageMin < 60 ? `${ageMin}m` : `${Math.round(ageMin / 60)}h` const fromWt = pointerDir !== dir ? ` from worktree ${pointerDir}` : '' - // biome-ignore lint/suspicious/noConsole: intentional info output console.error( `Resuming session ${pointer.sessionId} (${ageStr} ago)${fromWt}\u2026`, ) @@ -2202,7 +2199,6 @@ export async function bridgeMain(args: string[]): Promise { !baseUrl.includes('localhost') && !baseUrl.includes('127.0.0.1') ) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error( 'Error: Remote Control base URL uses HTTP. Only HTTPS or localhost HTTP is allowed.', ) @@ -2238,7 +2234,6 @@ export async function bridgeMain(args: string[]): Promise { ? getCurrentProjectConfig().remoteControlSpawnMode : undefined if (savedSpawnMode === 'worktree' && !worktreeAvailable) { - // biome-ignore lint/suspicious/noConsole: intentional warning output console.error( 'Warning: Saved spawn mode is worktree but this directory is not a git repository. Falling back to same-dir.', ) @@ -2265,7 +2260,6 @@ export async function bridgeMain(args: string[]): Promise { input: process.stdin, output: process.stdout, }) - // biome-ignore lint/suspicious/noConsole: intentional dialog output console.log( `\nClaude Remote Control is launching in spawn mode which lets you create new sessions in this project from Claude Code on Web or your Mobile app. Learn more here: https://code.claude.com/docs/en/remote-control\n\n` + `Spawn mode for this project:\n` + @@ -2344,7 +2338,6 @@ export async function bridgeMain(args: string[]): Promise { // Only reachable via explicit --spawn=worktree (default is same-dir); // saved worktree pref was already guarded above. if (spawnMode === 'worktree' && !worktreeAvailable) { - // biome-ignore lint/suspicious/noConsole: intentional error output console.error( `Error: Worktree mode requires a git repository or WorktreeCreate hooks configured. Use --spawn=session for single-session mode.`, ) @@ -2379,7 +2372,6 @@ export async function bridgeMain(args: string[]): Promise { try { validateBridgeId(resumeSessionId, 'sessionId') } catch { - // biome-ignore lint/suspicious/noConsole: intentional error output console.error( `Error: Invalid session ID "${resumeSessionId}". Session IDs must not contain unsafe characters.`, ) @@ -2405,7 +2397,6 @@ export async function bridgeMain(args: string[]): Promise { const { clearBridgePointer } = await import('./bridgePointer.js') await clearBridgePointer(resumePointerDir) } - // biome-ignore lint/suspicious/noConsole: intentional error output console.error( `Error: Session ${resumeSessionId} not found. It may have been archived or expired, or your login may have lapsed (run \`claude /login\`).`, ) @@ -2417,7 +2408,6 @@ export async function bridgeMain(args: string[]): Promise { const { clearBridgePointer } = await import('./bridgePointer.js') await clearBridgePointer(resumePointerDir) } - // biome-ignore lint/suspicious/noConsole: intentional error output console.error( `Error: Session ${resumeSessionId} has no environment_id. It may never have been attached to a bridge.`, ) @@ -2471,7 +2461,6 @@ export async function bridgeMain(args: string[]): Promise { status: err instanceof BridgeFatalError ? err.status : undefined, }) // Registration failures are fatal — print a clean message instead of a stack trace. - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error( err instanceof BridgeFatalError && err.status === 404 ? 'Remote Control environments are not available for your account.' @@ -2496,7 +2485,6 @@ export async function bridgeMain(args: string[]): Promise { `Bridge resume env mismatch: requested ${reuseEnvironmentId}, backend returned ${environmentId}. Falling back to fresh session.`, ), ) - // biome-ignore lint/suspicious/noConsole: intentional warning output console.warn( `Warning: Could not resume session ${resumeSessionId} — its environment has expired. Creating a fresh session instead.`, ) @@ -2547,7 +2535,6 @@ export async function bridgeMain(args: string[]): Promise { const { clearBridgePointer } = await import('./bridgePointer.js') await clearBridgePointer(resumePointerDir) } - // biome-ignore lint/suspicious/noConsole: intentional error output console.error( isFatal ? `Error: ${errorMessage(err)}` diff --git a/src/bridge/bridgeMessaging.ts b/src/bridge/bridgeMessaging.ts index eab387fb8..b07ca8dd5 100644 --- a/src/bridge/bridgeMessaging.ts +++ b/src/bridge/bridgeMessaging.ts @@ -104,7 +104,8 @@ export function isEligibleBridgeMessage(m: Message): boolean { export function extractTitleText(m: Message): string | undefined { if (m.type !== 'user' || m.isMeta || m.toolUseResult || m.isCompactSummary) return undefined - if (m.origin && (m.origin as { kind?: string }).kind !== 'human') return undefined + if (m.origin && (m.origin as { kind?: string }).kind !== 'human') + return undefined const content = m.message!.content let raw: string | undefined if (typeof content === 'string') { @@ -266,7 +267,13 @@ export function handleServerControlRequest( // Outbound-only: reply error for mutable requests so claude.ai doesn't show // false success. initialize must still succeed (server kills the connection // if it doesn't — see comment above). - const req = request.request as { subtype: string; model?: string; max_thinking_tokens?: number | null; mode?: string; [key: string]: unknown } + const req = request.request as { + subtype: string + model?: string + max_thinking_tokens?: number | null + mode?: string + [key: string]: unknown + } if (outboundOnly && req.subtype !== 'initialize') { response = { type: 'control_response', @@ -389,8 +396,8 @@ export function handleServerControlRequest( void transport.write(event) rcLog( `control_response: subtype=${req.subtype}` + - ` request_id=${request.request_id}` + - ` result=${(response.response as { subtype?: string }).subtype}`, + ` request_id=${request.request_id}` + + ` result=${(response.response as { subtype?: string }).subtype}`, ) logForDebugging( `[bridge:repl] Sent control_response for ${req.subtype} request_id=${request.request_id} result=${(response.response as { subtype?: string }).subtype}`, diff --git a/src/bridge/inboundMessages.ts b/src/bridge/inboundMessages.ts index 0f93a3f38..83d614e94 100644 --- a/src/bridge/inboundMessages.ts +++ b/src/bridge/inboundMessages.ts @@ -24,7 +24,9 @@ export function extractInboundMessageFields( | { content: string | Array; uuid: UUID | undefined } | undefined { if (msg.type !== 'user') return undefined - const content = (msg.message as { content?: string | Array } | undefined)?.content + const content = ( + msg.message as { content?: string | Array } | undefined + )?.content if (!content) return undefined if (Array.isArray(content) && content.length === 0) return undefined diff --git a/src/bridge/initReplBridge.ts b/src/bridge/initReplBridge.ts index 6d012b93b..fb29ec191 100644 --- a/src/bridge/initReplBridge.ts +++ b/src/bridge/initReplBridge.ts @@ -290,7 +290,9 @@ export async function initReplBridge( isSyntheticMessage(msg) ) continue - const rawContent = getContentText(msg.message!.content as string | ContentBlockParam[]) + const rawContent = getContentText( + msg.message!.content as string | ContentBlockParam[], + ) if (!rawContent) continue const derived = deriveTitle(rawContent) if (!derived) continue diff --git a/src/bridge/rcDebugLog.ts b/src/bridge/rcDebugLog.ts index 90ecf6bca..1573fbdc1 100644 --- a/src/bridge/rcDebugLog.ts +++ b/src/bridge/rcDebugLog.ts @@ -20,7 +20,10 @@ export function rcLog(msg: string): void { try { if (!headerWritten) { ensureLogDir() - appendFileSync(LOG_PATH, `\n===== RC-DEBUG session ${new Date().toISOString()} =====\n`) + appendFileSync( + LOG_PATH, + `\n===== RC-DEBUG session ${new Date().toISOString()} =====\n`, + ) headerWritten = true } const ts = new Date().toISOString().slice(11, 23) // HH:mm:ss.SSS diff --git a/src/bridge/remoteBridgeCore.ts b/src/bridge/remoteBridgeCore.ts index c3b0547e4..fcefaacde 100644 --- a/src/bridge/remoteBridgeCore.ts +++ b/src/bridge/remoteBridgeCore.ts @@ -834,7 +834,10 @@ export async function initEnvLessBridgeCore( for (const msg of filtered) { if (msg.uuid) recentPostedUUIDs.add(msg.uuid as string) } - const events = filtered.map(m => ({ ...m, session_id: sessionId })) as StdoutMessage[] + const events = filtered.map(m => ({ + ...m, + session_id: sessionId, + })) as StdoutMessage[] void transport.writeBatch(events) }, sendControlRequest(request: SDKControlRequest) { @@ -844,8 +847,14 @@ export async function initEnvLessBridgeCore( ) return } - const event: TransportMessage = { ...request, session_id: sessionId } as TransportMessage - if ((request as { request?: { subtype?: string } }).request?.subtype === 'can_use_tool') { + const event: TransportMessage = { + ...request, + session_id: sessionId, + } as TransportMessage + if ( + (request as { request?: { subtype?: string } }).request?.subtype === + 'can_use_tool' + ) { transport.reportState('requires_action') } void transport.write(event as StdoutMessage) @@ -860,7 +869,10 @@ export async function initEnvLessBridgeCore( ) return } - const event: TransportMessage = { ...response, session_id: sessionId } as TransportMessage + const event: TransportMessage = { + ...response, + session_id: sessionId, + } as TransportMessage transport.reportState('running') void transport.write(event as StdoutMessage) logForDebugging('[remote-bridge] Sent control_response') diff --git a/src/bridge/replBridge.ts b/src/bridge/replBridge.ts index 1d91c9990..1c3107222 100644 --- a/src/bridge/replBridge.ts +++ b/src/bridge/replBridge.ts @@ -452,7 +452,6 @@ export async function initBridgeCore( // re-created after a connection loss. let currentSessionId: string - if (reusedPriorSession && prior) { currentSessionId = prior.sessionId logForDebugging( @@ -632,9 +631,9 @@ export async function initBridgeCore( environmentRecreations++ rcLog( `doReconnect: attempt=${environmentRecreations}/${MAX_ENVIRONMENT_RECREATIONS}` + - ` envId=${environmentId}` + - ` sessionId=${currentSessionId}` + - ` workId=${currentWorkId}`, + ` envId=${environmentId}` + + ` sessionId=${currentSessionId}` + + ` workId=${currentWorkId}`, ) // Invalidate any in-flight v2 handshake — the environment is being // recreated, so a stale transport arriving post-reconnect would be @@ -846,7 +845,6 @@ export async function initBridgeCore( // UUIDs are scoped per-session on the server, so re-flushing is safe. previouslyFlushedUUIDs?.clear() - // Reset the counter so independent reconnections hours apart don't // exhaust the limit — it guards against rapid consecutive failures, // not lifetime total. @@ -907,8 +905,8 @@ export async function initBridgeCore( function handleTransportPermanentClose(closeCode: number | undefined): void { rcLog( `handleTransportPermanentClose: code=${closeCode}` + - ` transport=${transport ? 'exists' : 'null'}` + - ` pollAborted=${pollController.signal.aborted}`, + ` transport=${transport ? 'exists' : 'null'}` + + ` pollAborted=${pollController.signal.aborted}`, ) logForDebugging( `[bridge:repl] Transport permanently closed: code=${closeCode}`, @@ -1303,7 +1301,9 @@ export async function initBridgeCore( session_id: currentSessionId, })) as TransportMessage[] const dropsBefore = newTransport.droppedBatchCount - void newTransport.writeBatch(events as StdoutMessage[]).then(() => { + void newTransport + .writeBatch(events as StdoutMessage[]) + .then(() => { // If any batch was dropped during this flush (SI down for // maxConsecutiveFailures attempts), flush() still resolved // normally but the events were NOT delivered. Don't mark @@ -1357,10 +1357,10 @@ export async function initBridgeCore( const parsed = JSON.parse(data) rcLog( `ingress: type=${parsed.type}` + - `${parsed.type === 'control_request' ? ` subtype=${(parsed.request as Record)?.subtype} request_id=${parsed.request_id}` : ''}` + - `${parsed.type === 'control_response' ? ` subtype=${(parsed.response as Record)?.subtype} request_id=${(parsed.response as Record)?.request_id}` : ''}` + - `${parsed.type === 'user' ? ` uuid=${parsed.uuid}` : ''}` + - `${parsed.type === 'keep_alive' ? '' : ` len=${data.length}`}`, + `${parsed.type === 'control_request' ? ` subtype=${(parsed.request as Record)?.subtype} request_id=${parsed.request_id}` : ''}` + + `${parsed.type === 'control_response' ? ` subtype=${(parsed.response as Record)?.subtype} request_id=${(parsed.response as Record)?.request_id}` : ''}` + + `${parsed.type === 'user' ? ` uuid=${parsed.uuid}` : ''}` + + `${parsed.type === 'keep_alive' ? '' : ` len=${data.length}`}`, ) } catch { rcLog(`ingress (non-JSON): ${String(data).slice(0, 200)}`) @@ -1387,9 +1387,9 @@ export async function initBridgeCore( if (transport !== newTransport) return rcLog( `transport onClose: code=${closeCode}` + - ` connected=${newTransport.isConnectedStatus()}` + - ` state=${newTransport.getStateLabel()}` + - ` seq=${newTransport.getLastSequenceNum()}`, + ` connected=${newTransport.isConnectedStatus()}` + + ` state=${newTransport.getStateLabel()}` + + ` seq=${newTransport.getLastSequenceNum()}`, ) handleTransportPermanentClose(closeCode) }) @@ -1818,7 +1818,10 @@ export async function initBridgeCore( for (const msg of filtered) { if (msg.uuid) recentPostedUUIDs.add(msg.uuid as string) } - const events: TransportMessage[] = filtered.map(m => ({ ...m, session_id: currentSessionId })) as TransportMessage[] + const events: TransportMessage[] = filtered.map(m => ({ + ...m, + session_id: currentSessionId, + })) as TransportMessage[] void transport.writeBatch(events as StdoutMessage[]) }, sendControlRequest(request: SDKControlRequest) { @@ -1828,7 +1831,10 @@ export async function initBridgeCore( ) return } - const event: TransportMessage = { ...request, session_id: currentSessionId } as TransportMessage + const event: TransportMessage = { + ...request, + session_id: currentSessionId, + } as TransportMessage void transport.write(event as StdoutMessage) logForDebugging( `[bridge:repl] Sent control_request request_id=${request.request_id}`, @@ -1841,7 +1847,10 @@ export async function initBridgeCore( ) return } - const event: TransportMessage = { ...response, session_id: currentSessionId } as TransportMessage + const event: TransportMessage = { + ...response, + session_id: currentSessionId, + } as TransportMessage void transport.write(event as StdoutMessage) logForDebugging('[bridge:repl] Sent control_response') }, diff --git a/src/bridge/src/entrypoints/sdk/controlTypes.ts b/src/bridge/src/entrypoints/sdk/controlTypes.ts index bd1b0590d..0558ada22 100644 --- a/src/bridge/src/entrypoints/sdk/controlTypes.ts +++ b/src/bridge/src/entrypoints/sdk/controlTypes.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type StdoutMessage = any; +export type StdoutMessage = any diff --git a/src/bridge/webhookSanitizer.ts b/src/bridge/webhookSanitizer.ts index a2999b07c..d8927ae9e 100644 --- a/src/bridge/webhookSanitizer.ts +++ b/src/bridge/webhookSanitizer.ts @@ -11,21 +11,44 @@ /** Patterns that match known secret/token formats. */ const SECRET_PATTERNS: Array<{ pattern: RegExp; replacement: string }> = [ // GitHub tokens (PAT, OAuth, App, Server-to-server) - { pattern: /\b(ghp|gho|ghs|ghu|github_pat)_[A-Za-z0-9_]{10,}\b/g, replacement: '[REDACTED_GITHUB_TOKEN]' }, + { + pattern: /\b(ghp|gho|ghs|ghu|github_pat)_[A-Za-z0-9_]{10,}\b/g, + replacement: '[REDACTED_GITHUB_TOKEN]', + }, // Anthropic API keys - { pattern: /\bsk-ant-[A-Za-z0-9_-]{10,}\b/g, replacement: '[REDACTED_ANTHROPIC_KEY]' }, + { + pattern: /\bsk-ant-[A-Za-z0-9_-]{10,}\b/g, + replacement: '[REDACTED_ANTHROPIC_KEY]', + }, // Generic Bearer tokens in headers - { pattern: /(Bearer\s+)[A-Za-z0-9._\-/+=]{20,}/gi, replacement: '$1[REDACTED_TOKEN]' }, + { + pattern: /(Bearer\s+)[A-Za-z0-9._\-/+=]{20,}/gi, + replacement: '$1[REDACTED_TOKEN]', + }, // AWS access keys - { pattern: /\b(AKIA|ASIA)[A-Z0-9]{16}\b/g, replacement: '[REDACTED_AWS_KEY]' }, + { + pattern: /\b(AKIA|ASIA)[A-Z0-9]{16}\b/g, + replacement: '[REDACTED_AWS_KEY]', + }, // AWS secret keys (40-char base64-like strings after common labels) - { pattern: /(aws_secret_access_key|secret_key|SecretAccessKey)['":\s=]+[A-Za-z0-9/+=]{30,}/gi, replacement: '$1=[REDACTED_AWS_SECRET]' }, + { + pattern: + /(aws_secret_access_key|secret_key|SecretAccessKey)['":\s=]+[A-Za-z0-9/+=]{30,}/gi, + replacement: '$1=[REDACTED_AWS_SECRET]', + }, // Generic API key patterns (key=value or "key": "value") - { pattern: /(api[_-]?key|apikey|secret|password|token|credential)['":\s=]+["']?[A-Za-z0-9._\-/+=]{16,}["']?/gi, replacement: '$1=[REDACTED]' }, + { + pattern: + /(api[_-]?key|apikey|secret|password|token|credential)['":\s=]+["']?[A-Za-z0-9._\-/+=]{16,}["']?/gi, + replacement: '$1=[REDACTED]', + }, // npm tokens { pattern: /\bnpm_[A-Za-z0-9]{36}\b/g, replacement: '[REDACTED_NPM_TOKEN]' }, // Slack tokens - { pattern: /\bxox[bporas]-[A-Za-z0-9-]{10,}\b/g, replacement: '[REDACTED_SLACK_TOKEN]' }, + { + pattern: /\bxox[bporas]-[A-Za-z0-9-]{10,}\b/g, + replacement: '[REDACTED_SLACK_TOKEN]', + }, ] /** Maximum content length before truncation (100KB). */ diff --git a/src/buddy/CompanionSprite.tsx b/src/buddy/CompanionSprite.tsx index 22dbeb643..85ab36a0c 100644 --- a/src/buddy/CompanionSprite.tsx +++ b/src/buddy/CompanionSprite.tsx @@ -1,50 +1,50 @@ -import { feature } from 'bun:bundle' -import figures from 'figures' -import React, { useEffect, useRef, useState } from 'react' -import { useTerminalSize } from '../hooks/useTerminalSize.js' -import { Box, Text, stringWidth } from '@anthropic/ink' -import { useAppState, useSetAppState } from '../state/AppState.js' -import type { AppState } from '../state/AppStateStore.js' -import { getGlobalConfig } from '../utils/config.js' -import { isFullscreenActive } from '../utils/fullscreen.js' -import type { Theme } from '../utils/theme.js' -import { getCompanion } from './companion.js' -import { renderFace, renderSprite, spriteFrameCount } from './sprites.js' -import { RARITY_COLORS } from './types.js' +import { feature } from 'bun:bundle'; +import figures from 'figures'; +import React, { useEffect, useRef, useState } from 'react'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { Box, Text, stringWidth } from '@anthropic/ink'; +import { useAppState, useSetAppState } from '../state/AppState.js'; +import type { AppState } from '../state/AppStateStore.js'; +import { getGlobalConfig } from '../utils/config.js'; +import { isFullscreenActive } from '../utils/fullscreen.js'; +import type { Theme } from '../utils/theme.js'; +import { getCompanion } from './companion.js'; +import { renderFace, renderSprite, spriteFrameCount } from './sprites.js'; +import { RARITY_COLORS } from './types.js'; -const TICK_MS = 500 -const BUBBLE_SHOW = 20 // ticks → ~10s at 500ms -const FADE_WINDOW = 6 // last ~3s the bubble dims so you know it's about to go -const PET_BURST_MS = 2500 // how long hearts float after /buddy pet +const TICK_MS = 500; +const BUBBLE_SHOW = 20; // ticks → ~10s at 500ms +const FADE_WINDOW = 6; // last ~3s the bubble dims so you know it's about to go +const PET_BURST_MS = 2500; // how long hearts float after /buddy pet // Idle sequence: mostly rest (frame 0), occasional fidget (frames 1-2), rare blink. // Sequence indices map to sprite frames; -1 means "blink on frame 0". -const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0] +const IDLE_SEQUENCE = [0, 0, 0, 0, 1, 0, 0, 0, -1, 0, 0, 2, 0, 0, 0]; // Hearts float up-and-out over 5 ticks (~2.5s). Prepended above the sprite. -const H = figures.heart +const H = figures.heart; const PET_HEARTS = [ ` ${H} ${H} `, ` ${H} ${H} ${H} `, ` ${H} ${H} ${H} `, `${H} ${H} ${H} `, '· · · ', -] +]; function wrap(text: string, width: number): string[] { - const words = text.split(' ') - const lines: string[] = [] - let cur = '' + const words = text.split(' '); + const lines: string[] = []; + let cur = ''; for (const w of words) { if (cur.length + w.length + 1 > width && cur) { - lines.push(cur) - cur = w + lines.push(cur); + cur = w; } else { - cur = cur ? `${cur} ${w}` : w + cur = cur ? `${cur} ${w}` : w; } } - if (cur) lines.push(cur) - return lines + if (cur) lines.push(cur); + return lines; } function SpeechBubble({ @@ -53,40 +53,29 @@ function SpeechBubble({ fading, tail, }: { - text: string - color: keyof Theme - fading: boolean - tail: 'down' | 'right' + text: string; + color: keyof Theme; + fading: boolean; + tail: 'down' | 'right'; }): React.ReactNode { - const lines = wrap(text, 30) - const borderColor = fading ? 'inactive' : color + const lines = wrap(text, 30); + const borderColor = fading ? 'inactive' : color; const bubble = ( - + {lines.map((l, i) => ( - + {l} ))} - ) + ); if (tail === 'right') { return ( {bubble} - ) + ); } return ( @@ -96,18 +85,18 @@ function SpeechBubble({ - ) + ); } -export const MIN_COLS_FOR_FULL_SPRITE = 100 -const SPRITE_BODY_WIDTH = 12 -const NAME_ROW_PAD = 2 // focused state wraps name in spaces: ` name ` -const SPRITE_PADDING_X = 2 -const BUBBLE_WIDTH = 36 // SpeechBubble box (34) + tail column -const NARROW_QUIP_CAP = 24 +export const MIN_COLS_FOR_FULL_SPRITE = 100; +const SPRITE_BODY_WIDTH = 12; +const NAME_ROW_PAD = 2; // focused state wraps name in spaces: ` name ` +const SPRITE_PADDING_X = 2; +const BUBBLE_WIDTH = 36; // SpeechBubble box (34) + tail column +const NARROW_QUIP_CAP = 24; function spriteColWidth(nameWidth: number): number { - return Math.max(SPRITE_BODY_WIDTH, nameWidth + NAME_ROW_PAD) + return Math.max(SPRITE_BODY_WIDTH, nameWidth + NAME_ROW_PAD); } // Width the sprite area consumes. PromptInput subtracts this so text wraps @@ -115,89 +104,73 @@ function spriteColWidth(nameWidth: number): number { // width); in non-fullscreen it sits inline and needs BUBBLE_WIDTH more. // Narrow terminals: 0 — REPL.tsx stacks the one-liner on its own row // (above input in fullscreen, below in scrollback), so no reservation. -export function companionReservedColumns( - terminalColumns: number, - speaking: boolean, -): number { - if (!feature('BUDDY')) return 0 - const companion = getCompanion() - if (!companion || getGlobalConfig().companionMuted) return 0 - if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0 - const nameWidth = stringWidth(companion.name) - const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0 - return spriteColWidth(nameWidth) + SPRITE_PADDING_X + bubble +export function companionReservedColumns(terminalColumns: number, speaking: boolean): number { + if (!feature('BUDDY')) return 0; + const companion = getCompanion(); + if (!companion || getGlobalConfig().companionMuted) return 0; + if (terminalColumns < MIN_COLS_FOR_FULL_SPRITE) return 0; + const nameWidth = stringWidth(companion.name); + const bubble = speaking && !isFullscreenActive() ? BUBBLE_WIDTH : 0; + return spriteColWidth(nameWidth) + SPRITE_PADDING_X + bubble; } export function CompanionSprite(): React.ReactNode { - const reaction = useAppState(s => s.companionReaction) - const petAt = useAppState(s => s.companionPetAt) - const focused = useAppState(s => s.footerSelection === 'companion') - const setAppState = useSetAppState() - const { columns } = useTerminalSize() - const [tick, setTick] = useState(0) - const lastSpokeTick = useRef(0) + const reaction = useAppState(s => s.companionReaction); + const petAt = useAppState(s => s.companionPetAt); + const focused = useAppState(s => s.footerSelection === 'companion'); + const setAppState = useSetAppState(); + const { columns } = useTerminalSize(); + const [tick, setTick] = useState(0); + const lastSpokeTick = useRef(0); // Sync-during-render (not useEffect) so the first post-pet render already // has petStartTick=tick and petAge=0 — otherwise frame 0 is skipped. const [{ petStartTick, forPetAt }, setPetStart] = useState({ petStartTick: 0, forPetAt: petAt, - }) + }); if (petAt !== forPetAt) { - setPetStart({ petStartTick: tick, forPetAt: petAt }) + setPetStart({ petStartTick: tick, forPetAt: petAt }); } useEffect(() => { - const timer = setInterval( - setT => setT((t: number) => t + 1), - TICK_MS, - setTick, - ) - return () => clearInterval(timer) - }, []) + const timer = setInterval(setT => setT((t: number) => t + 1), TICK_MS, setTick); + return () => clearInterval(timer); + }, []); useEffect(() => { - if (!reaction) return - lastSpokeTick.current = tick + if (!reaction) return; + lastSpokeTick.current = tick; const timer = setTimeout( setA => setA((prev: AppState) => - prev.companionReaction === undefined - ? prev - : { ...prev, companionReaction: undefined }, + prev.companionReaction === undefined ? prev : { ...prev, companionReaction: undefined }, ), BUBBLE_SHOW * TICK_MS, setAppState, - ) - return () => clearTimeout(timer) + ); + return () => clearTimeout(timer); // eslint-disable-next-line react-hooks/exhaustive-deps -- tick intentionally captured at reaction-change, not tracked - }, [reaction, setAppState]) + }, [reaction, setAppState]); - if (!feature('BUDDY')) return null - const companion = getCompanion() - if (!companion || getGlobalConfig().companionMuted) return null + if (!feature('BUDDY')) return null; + const companion = getCompanion(); + if (!companion || getGlobalConfig().companionMuted) return null; - const color = RARITY_COLORS[companion.rarity] - const colWidth = spriteColWidth(stringWidth(companion.name)) + const color = RARITY_COLORS[companion.rarity]; + const colWidth = spriteColWidth(stringWidth(companion.name)); - const bubbleAge = reaction ? tick - lastSpokeTick.current : 0 - const fading = - reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW + const bubbleAge = reaction ? tick - lastSpokeTick.current : 0; + const fading = reaction !== undefined && bubbleAge >= BUBBLE_SHOW - FADE_WINDOW; - const petAge = petAt ? tick - petStartTick : Infinity - const petting = petAge * TICK_MS < PET_BURST_MS + const petAge = petAt ? tick - petStartTick : Infinity; + const petting = petAge * TICK_MS < PET_BURST_MS; // Narrow terminals: collapse to one-line face. When speaking, the quip // replaces the name beside the face (no room for a bubble). if (columns < MIN_COLS_FOR_FULL_SPRITE) { const quip = - reaction && reaction.length > NARROW_QUIP_CAP - ? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…' - : reaction - const label = quip - ? `"${quip}"` - : focused - ? ` ${companion.name} ` - : companion.name + reaction && reaction.length > NARROW_QUIP_CAP ? reaction.slice(0, NARROW_QUIP_CAP - 1) + '…' : reaction; + const label = quip ? `"${quip}"` : focused ? ` ${companion.name} ` : companion.name; return ( @@ -210,44 +183,34 @@ export function CompanionSprite(): React.ReactNode { dimColor={!focused && !reaction} bold={focused} inverse={focused && !reaction} - color={ - reaction - ? fading - ? 'inactive' - : color - : focused - ? color - : undefined - } + color={reaction ? (fading ? 'inactive' : color) : focused ? color : undefined} > {label} - ) + ); } - const frameCount = spriteFrameCount(companion.species) - const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null + const frameCount = spriteFrameCount(companion.species); + const heartFrame = petting ? PET_HEARTS[petAge % PET_HEARTS.length] : null; - let spriteFrame: number - let blink = false + let spriteFrame: number; + let blink = false; if (reaction || petting) { // Excited: cycle all fidget frames fast - spriteFrame = tick % frameCount + spriteFrame = tick % frameCount; } else { - const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]! + const step = IDLE_SEQUENCE[tick % IDLE_SEQUENCE.length]!; if (step === -1) { - spriteFrame = 0 - blink = true + spriteFrame = 0; + blink = true; } else { - spriteFrame = step % frameCount + spriteFrame = step % frameCount; } } - const body = renderSprite(companion, spriteFrame).map(line => - blink ? line.replaceAll(companion.eye, '-') : line, - ) - const sprite = heartFrame ? [heartFrame, ...body] : body + const body = renderSprite(companion, spriteFrame).map(line => (blink ? line.replaceAll(companion.eye, '-') : line)); + const sprite = heartFrame ? [heartFrame, ...body] : body; // Name row doubles as hint row — unfocused shows dim name + ↓ discovery, // focused shows inverse name. The enter-to-open hint lives in @@ -255,31 +218,20 @@ export function CompanionSprite(): React.ReactNode { // sprite doesn't jump up when selected. flexShrink=0 stops the // inline-bubble row wrapper from squeezing the sprite to fit. const spriteColumn = ( - + {sprite.map((line, i) => ( {line} ))} - + {focused ? ` ${companion.name} ` : companion.name} - ) + ); if (!reaction) { - return {spriteColumn} + return {spriteColumn}; } // Fullscreen: bubble renders separately via CompanionFloatingBubble in @@ -288,19 +240,14 @@ export function CompanionSprite(): React.ReactNode { // Non-fullscreen: bubble sits inline beside the sprite (input shrinks) // because floating into Static scrollback can't be cleared. if (isFullscreenActive()) { - return {spriteColumn} + return {spriteColumn}; } return ( - + {spriteColumn} - ) + ); } // Floating bubble overlay for fullscreen mode. Mounted in FullscreenLayout's @@ -308,33 +255,29 @@ export function CompanionSprite(): React.ReactNode { // the ScrollBox region. CompanionSprite owns the clear-after-10s timer; this // just reads companionReaction and renders the fade. export function CompanionFloatingBubble(): React.ReactNode { - const reaction = useAppState(s => s.companionReaction) + const reaction = useAppState(s => s.companionReaction); const [{ tick, forReaction }, setTick] = useState({ tick: 0, forReaction: reaction, - }) + }); // Reset tick synchronously when reaction changes (not in useEffect, which // runs post-render and would show one stale-faded frame). Storing the // reaction the tick is counting FOR alongside the tick itself means the // fade computation never sees a tick from a previous reaction. if (reaction !== forReaction) { - setTick({ tick: 0, forReaction: reaction }) + setTick({ tick: 0, forReaction: reaction }); } useEffect(() => { - if (!reaction) return - const timer = setInterval( - set => set(s => ({ ...s, tick: s.tick + 1 })), - TICK_MS, - setTick, - ) - return () => clearInterval(timer) - }, [reaction]) + if (!reaction) return; + const timer = setInterval(set => set(s => ({ ...s, tick: s.tick + 1 })), TICK_MS, setTick); + return () => clearInterval(timer); + }, [reaction]); - if (!feature('BUDDY') || !reaction) return null - const companion = getCompanion() - if (!companion || getGlobalConfig().companionMuted) return null + if (!feature('BUDDY') || !reaction) return null; + const companion = getCompanion(); + if (!companion || getGlobalConfig().companionMuted) return null; return ( = BUBBLE_SHOW - FADE_WINDOW} tail="down" /> - ) + ); } diff --git a/src/buddy/useBuddyNotification.tsx b/src/buddy/useBuddyNotification.tsx index 2df078e79..99492a1f1 100644 --- a/src/buddy/useBuddyNotification.tsx +++ b/src/buddy/useBuddyNotification.tsx @@ -1,25 +1,23 @@ -import { feature } from 'bun:bundle' -import React, { useEffect } from 'react' -import { useNotifications } from '../context/notifications.js' -import { Text } from '@anthropic/ink' -import { getGlobalConfig } from '../utils/config.js' -import { getRainbowColor } from '../utils/thinking.js' +import { feature } from 'bun:bundle'; +import React, { useEffect } from 'react'; +import { useNotifications } from '../context/notifications.js'; +import { Text } from '@anthropic/ink'; +import { getGlobalConfig } from '../utils/config.js'; +import { getRainbowColor } from '../utils/thinking.js'; // Local date, not UTC — 24h rolling wave across timezones. Sustained Twitter // buzz instead of a single UTC-midnight spike, gentler on soul-gen load. // Teaser window: April 1-7, 2026 only. Command stays live forever after. export function isBuddyTeaserWindow(): boolean { - if (process.env.USER_TYPE === 'ant') return true - const d = new Date() - return d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7 + if (process.env.USER_TYPE === 'ant') return true; + const d = new Date(); + return d.getFullYear() === 2026 && d.getMonth() === 3 && d.getDate() <= 7; } export function isBuddyLive(): boolean { - if (process.env.USER_TYPE === 'ant') return true - const d = new Date() - return ( - d.getFullYear() > 2026 || (d.getFullYear() === 2026 && d.getMonth() >= 3) - ) + if (process.env.USER_TYPE === 'ant') return true; + const d = new Date(); + return d.getFullYear() > 2026 || (d.getFullYear() === 2026 && d.getMonth() >= 3); } function RainbowText({ text }: { text: string }): React.ReactNode { @@ -31,37 +29,35 @@ function RainbowText({ text }: { text: string }): React.ReactNode { ))} - ) + ); } // Rainbow /buddy teaser shown on startup when no companion hatched yet. // Idle presence and reactions are handled by CompanionSprite directly. export function useBuddyNotification(): void { - const { addNotification, removeNotification } = useNotifications() + const { addNotification, removeNotification } = useNotifications(); useEffect(() => { - if (!feature('BUDDY')) return - const config = getGlobalConfig() - if (config.companion || !isBuddyTeaserWindow()) return + if (!feature('BUDDY')) return; + const config = getGlobalConfig(); + if (config.companion || !isBuddyTeaserWindow()) return; addNotification({ key: 'buddy-teaser', jsx: , priority: 'immediate', timeoutMs: 15_000, - }) - return () => removeNotification('buddy-teaser') - }, [addNotification, removeNotification]) + }); + return () => removeNotification('buddy-teaser'); + }, [addNotification, removeNotification]); } -export function findBuddyTriggerPositions( - text: string, -): Array<{ start: number; end: number }> { - if (!feature('BUDDY')) return [] - const triggers: Array<{ start: number; end: number }> = [] - const re = /\/buddy\b/g - let m: RegExpExecArray | null +export function findBuddyTriggerPositions(text: string): Array<{ start: number; end: number }> { + if (!feature('BUDDY')) return []; + const triggers: Array<{ start: number; end: number }> = []; + const re = /\/buddy\b/g; + let m: RegExpExecArray | null; while ((m = re.exec(text)) !== null) { - triggers.push({ start: m.index, end: m.index + m[0].length }) + triggers.push({ start: m.index, end: m.index + m[0].length }); } - return triggers + return triggers; } diff --git a/src/cli/bg.ts b/src/cli/bg.ts index 3bc18e39f..ee2fbbdc1 100644 --- a/src/cli/bg.ts +++ b/src/cli/bg.ts @@ -200,7 +200,9 @@ export async function attachHandler(target: string | undefined): Promise { const { TmuxEngine } = await import('./bg/engines/tmux.js') const tmux = new TmuxEngine() if (!(await tmux.available())) { - console.error('tmux is no longer available. Cannot attach to tmux session.') + console.error( + 'tmux is no longer available. Cannot attach to tmux session.', + ) process.exitCode = 1 return } @@ -301,7 +303,9 @@ export async function handleBgStart(args: string[]): Promise { console.log(` Engine: ${result.engineUsed}`) console.log(` Log: ${result.logPath}`) console.log() - console.log(`Use \`claude daemon attach ${result.sessionName}\` to reconnect.`) + console.log( + `Use \`claude daemon attach ${result.sessionName}\` to reconnect.`, + ) console.log(`Use \`claude daemon status\` to check status.`) console.log(`Use \`claude daemon kill ${result.sessionName}\` to stop.`) } catch (e) { diff --git a/src/cli/bg/engines/detached.ts b/src/cli/bg/engines/detached.ts index 3e168e4fd..d3eeeb22d 100644 --- a/src/cli/bg/engines/detached.ts +++ b/src/cli/bg/engines/detached.ts @@ -1,7 +1,12 @@ import { spawn } from 'child_process' import { openSync, closeSync, mkdirSync } from 'fs' import { dirname } from 'path' -import type { BgEngine, BgStartOptions, BgStartResult, SessionEntry } from '../engine.js' +import type { + BgEngine, + BgStartOptions, + BgStartResult, + SessionEntry, +} from '../engine.js' import { tailLog } from '../tail.js' export class DetachedEngine implements BgEngine { diff --git a/src/cli/bg/engines/index.ts b/src/cli/bg/engines/index.ts index cee304c54..b05bedd72 100644 --- a/src/cli/bg/engines/index.ts +++ b/src/cli/bg/engines/index.ts @@ -1,4 +1,9 @@ -export type { BgEngine, BgStartOptions, BgStartResult, SessionEntry } from '../engine.js' +export type { + BgEngine, + BgStartOptions, + BgStartResult, + SessionEntry, +} from '../engine.js' export async function selectEngine(): Promise { if (process.platform === 'win32') { diff --git a/src/cli/bg/engines/tmux.ts b/src/cli/bg/engines/tmux.ts index bf978b621..2d5486ef3 100644 --- a/src/cli/bg/engines/tmux.ts +++ b/src/cli/bg/engines/tmux.ts @@ -1,7 +1,12 @@ import { spawnSync } from 'child_process' import { execFileNoThrow } from '../../../utils/execFileNoThrow.js' import { quote } from '../../../utils/bash/shellQuote.js' -import type { BgEngine, BgStartOptions, BgStartResult, SessionEntry } from '../engine.js' +import type { + BgEngine, + BgStartOptions, + BgStartResult, + SessionEntry, +} from '../engine.js' export class TmuxEngine implements BgEngine { readonly name = 'tmux' as const diff --git a/src/cli/exit.ts b/src/cli/exit.ts index 99e56f97b..b31fcf904 100644 --- a/src/cli/exit.ts +++ b/src/cli/exit.ts @@ -17,7 +17,6 @@ /** Write an error message to stderr (if given) and exit with code 1. */ export function cliError(msg?: string): never { - // biome-ignore lint/suspicious/noConsole: centralized CLI error output if (msg) console.error(msg) process.exit(1) return undefined as never diff --git a/src/cli/handlers/agents.ts b/src/cli/handlers/agents.ts index f02ce8e1d..5f2a40493 100644 --- a/src/cli/handlers/agents.ts +++ b/src/cli/handlers/agents.ts @@ -59,12 +59,9 @@ export async function agentsHandler(): Promise { } if (lines.length === 0) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log('No agents found.') } else { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`${totalActive} active agents\n`) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(lines.join('\n').trimEnd()) } } diff --git a/src/cli/handlers/auth.ts b/src/cli/handlers/auth.ts index 8b92c7dde..b17f9be57 100644 --- a/src/cli/handlers/auth.ts +++ b/src/cli/handlers/auth.ts @@ -159,7 +159,9 @@ export async function authLogin({ const orgResult = await validateForceLoginOrg() if (!orgResult.valid) { - process.stderr.write((orgResult as { valid: false; message: string }).message + '\n') + process.stderr.write( + (orgResult as { valid: false; message: string }).message + '\n', + ) process.exit(1) } @@ -209,7 +211,9 @@ export async function authLogin({ const orgResult = await validateForceLoginOrg() if (!orgResult.valid) { - process.stderr.write((orgResult as { valid: false; message: string }).message + '\n') + process.stderr.write( + (orgResult as { valid: false; message: string }).message + '\n', + ) process.exit(1) } diff --git a/src/cli/handlers/mcp.tsx b/src/cli/handlers/mcp.tsx index ab543af11..95fb15de9 100644 --- a/src/cli/handlers/mcp.tsx +++ b/src/cli/handlers/mcp.tsx @@ -3,203 +3,163 @@ * These are dynamically imported only when the corresponding `claude mcp *` command runs. */ -import { stat } from 'fs/promises' -import pMap from 'p-map' -import { cwd } from 'process' -import React from 'react' -import { MCPServerDesktopImportDialog } from '../../components/MCPServerDesktopImportDialog.js' -import { wrappedRender as render } from '@anthropic/ink' -import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js' +import { stat } from 'fs/promises'; +import pMap from 'p-map'; +import { cwd } from 'process'; +import React from 'react'; +import { MCPServerDesktopImportDialog } from '../../components/MCPServerDesktopImportDialog.js'; +import { wrappedRender as render } from '@anthropic/ink'; +import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from '../../services/analytics/index.js' +} from '../../services/analytics/index.js'; import { clearMcpClientConfig, clearServerTokensFromLocalStorage, getMcpClientConfig, readClientSecret, saveMcpClientSecret, -} from '../../services/mcp/auth.js' -import { - connectToServer, - getMcpServerConnectionBatchSize, -} from '../../services/mcp/client.js' +} from '../../services/mcp/auth.js'; +import { connectToServer, getMcpServerConnectionBatchSize } from '../../services/mcp/client.js'; import { addMcpConfig, getAllMcpConfigs, getMcpConfigByName, getMcpConfigsByScope, removeMcpConfig, -} from '../../services/mcp/config.js' -import type { - ConfigScope, - ScopedMcpServerConfig, -} from '../../services/mcp/types.js' -import { - describeMcpConfigFilePath, - ensureConfigScope, - getScopeLabel, -} from '../../services/mcp/utils.js' -import { AppStateProvider } from '../../state/AppState.js' -import { - getCurrentProjectConfig, - getGlobalConfig, - saveCurrentProjectConfig, -} from '../../utils/config.js' -import { isFsInaccessible } from '../../utils/errors.js' -import { gracefulShutdown } from '../../utils/gracefulShutdown.js' -import { safeParseJSON } from '../../utils/json.js' -import { getPlatform } from '../../utils/platform.js' -import { cliError, cliOk } from '../exit.js' +} from '../../services/mcp/config.js'; +import type { ConfigScope, ScopedMcpServerConfig } from '../../services/mcp/types.js'; +import { describeMcpConfigFilePath, ensureConfigScope, getScopeLabel } from '../../services/mcp/utils.js'; +import { AppStateProvider } from '../../state/AppState.js'; +import { getCurrentProjectConfig, getGlobalConfig, saveCurrentProjectConfig } from '../../utils/config.js'; +import { isFsInaccessible } from '../../utils/errors.js'; +import { gracefulShutdown } from '../../utils/gracefulShutdown.js'; +import { safeParseJSON } from '../../utils/json.js'; +import { getPlatform } from '../../utils/platform.js'; +import { cliError, cliOk } from '../exit.js'; -async function checkMcpServerHealth( - name: string, - server: ScopedMcpServerConfig, -): Promise { +async function checkMcpServerHealth(name: string, server: ScopedMcpServerConfig): Promise { try { - const result = await connectToServer(name, server) + const result = await connectToServer(name, server); if (result.type === 'connected') { - return '✓ Connected' + return '✓ Connected'; } else if (result.type === 'needs-auth') { - return '! Needs authentication' + return '! Needs authentication'; } else { - return '✗ Failed to connect' + return '✗ Failed to connect'; } } catch (_error) { - return '✗ Connection error' + return '✗ Connection error'; } } // mcp serve (lines 4512–4532) -export async function mcpServeHandler({ - debug, - verbose, -}: { - debug?: boolean - verbose?: boolean -}): Promise { - const providedCwd = cwd() - logEvent('tengu_mcp_start', {}) +export async function mcpServeHandler({ debug, verbose }: { debug?: boolean; verbose?: boolean }): Promise { + const providedCwd = cwd(); + logEvent('tengu_mcp_start', {}); try { - await stat(providedCwd) + await stat(providedCwd); } catch (error) { if (isFsInaccessible(error)) { - cliError(`Error: Directory ${providedCwd} does not exist`) + cliError(`Error: Directory ${providedCwd} does not exist`); } - throw error + throw error; } try { - const { setup } = await import('../../setup.js') - await setup(providedCwd, 'default', false, false, undefined, false) - const { startMCPServer } = await import('../../entrypoints/mcp.js') - await startMCPServer(providedCwd, debug ?? false, verbose ?? false) + const { setup } = await import('../../setup.js'); + await setup(providedCwd, 'default', false, false, undefined, false); + const { startMCPServer } = await import('../../entrypoints/mcp.js'); + await startMCPServer(providedCwd, debug ?? false, verbose ?? false); } catch (error) { - cliError(`Error: Failed to start MCP server: ${error}`) + cliError(`Error: Failed to start MCP server: ${error}`); } } // mcp remove (lines 4545–4635) -export async function mcpRemoveHandler( - name: string, - options: { scope?: string }, -): Promise { +export async function mcpRemoveHandler(name: string, options: { scope?: string }): Promise { // Look up config before removing so we can clean up secure storage - const serverBeforeRemoval = getMcpConfigByName(name) + const serverBeforeRemoval = getMcpConfigByName(name); const cleanupSecureStorage = () => { - if ( - serverBeforeRemoval && - (serverBeforeRemoval.type === 'sse' || - serverBeforeRemoval.type === 'http') - ) { - clearServerTokensFromLocalStorage(name, serverBeforeRemoval) - clearMcpClientConfig(name, serverBeforeRemoval) + if (serverBeforeRemoval && (serverBeforeRemoval.type === 'sse' || serverBeforeRemoval.type === 'http')) { + clearServerTokensFromLocalStorage(name, serverBeforeRemoval); + clearMcpClientConfig(name, serverBeforeRemoval); } - } + }; try { if (options.scope) { - const scope = ensureConfigScope(options.scope) + const scope = ensureConfigScope(options.scope); logEvent('tengu_mcp_delete', { name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - scope: - scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); - await removeMcpConfig(name, scope) - cleanupSecureStorage() - process.stdout.write(`Removed MCP server ${name} from ${scope} config\n`) - cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`) + await removeMcpConfig(name, scope); + cleanupSecureStorage(); + process.stdout.write(`Removed MCP server ${name} from ${scope} config\n`); + cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`); } // If no scope specified, check where the server exists - const projectConfig = getCurrentProjectConfig() - const globalConfig = getGlobalConfig() + const projectConfig = getCurrentProjectConfig(); + const globalConfig = getGlobalConfig(); // Check if server exists in project scope (.mcp.json) - const { servers: projectServers } = getMcpConfigsByScope('project') - const mcpJsonExists = !!projectServers[name] + const { servers: projectServers } = getMcpConfigsByScope('project'); + const mcpJsonExists = !!projectServers[name]; // Count how many scopes contain this server - const scopes: Array> = [] - if (projectConfig.mcpServers?.[name]) scopes.push('local') - if (mcpJsonExists) scopes.push('project') - if (globalConfig.mcpServers?.[name]) scopes.push('user') + const scopes: Array> = []; + if (projectConfig.mcpServers?.[name]) scopes.push('local'); + if (mcpJsonExists) scopes.push('project'); + if (globalConfig.mcpServers?.[name]) scopes.push('user'); if (scopes.length === 0) { - cliError(`No MCP server found with name: "${name}"`) + cliError(`No MCP server found with name: "${name}"`); } else if (scopes.length === 1) { // Server exists in only one scope, remove it - const scope = scopes[0]! + const scope = scopes[0]!; logEvent('tengu_mcp_delete', { name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - scope: - scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); - await removeMcpConfig(name, scope) - cleanupSecureStorage() - process.stdout.write( - `Removed MCP server "${name}" from ${scope} config\n`, - ) - cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`) + await removeMcpConfig(name, scope); + cleanupSecureStorage(); + process.stdout.write(`Removed MCP server "${name}" from ${scope} config\n`); + cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`); } else { // Server exists in multiple scopes - process.stderr.write(`MCP server "${name}" exists in multiple scopes:\n`) + process.stderr.write(`MCP server "${name}" exists in multiple scopes:\n`); scopes.forEach(scope => { - process.stderr.write( - ` - ${getScopeLabel(scope)} (${describeMcpConfigFilePath(scope)})\n`, - ) - }) - process.stderr.write('\nTo remove from a specific scope, use:\n') + process.stderr.write(` - ${getScopeLabel(scope)} (${describeMcpConfigFilePath(scope)})\n`); + }); + process.stderr.write('\nTo remove from a specific scope, use:\n'); scopes.forEach(scope => { - process.stderr.write(` claude mcp remove "${name}" -s ${scope}\n`) - }) - cliError() + process.stderr.write(` claude mcp remove "${name}" -s ${scope}\n`); + }); + cliError(); } } catch (error) { - cliError((error as Error).message) + cliError((error as Error).message); } } // mcp list (lines 4641–4688) export async function mcpListHandler(): Promise { - logEvent('tengu_mcp_list', {}) - const { servers: configs } = await getAllMcpConfigs() + logEvent('tengu_mcp_list', {}); + const { servers: configs } = await getAllMcpConfigs(); if (Object.keys(configs).length === 0) { - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log( - 'No MCP servers configured. Use `claude mcp add` to add a server.', - ) + console.log('No MCP servers configured. Use `claude mcp add` to add a server.'); } else { - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log('Checking MCP server health...\n') + console.log('Checking MCP server health...\n'); // Check servers concurrently - const entries = Object.entries(configs) + const entries = Object.entries(configs); const results = await pMap( entries, async ([name, server]) => ({ @@ -208,127 +168,100 @@ export async function mcpListHandler(): Promise { status: await checkMcpServerHealth(name, server), }), { concurrency: getMcpServerConnectionBatchSize() }, - ) + ); for (const { name, server, status } of results) { // Intentionally excluding sse-ide servers here since they're internal if (server.type === 'sse') { - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(`${name}: ${server.url} (SSE) - ${status}`) + console.log(`${name}: ${server.url} (SSE) - ${status}`); } else if (server.type === 'http') { - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(`${name}: ${server.url} (HTTP) - ${status}`) + console.log(`${name}: ${server.url} (HTTP) - ${status}`); } else if (server.type === 'claudeai-proxy') { - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(`${name}: ${server.url} - ${status}`) + console.log(`${name}: ${server.url} - ${status}`); } else if (!server.type || server.type === 'stdio') { - const stdioServer = server as { command: string; args: string[]; type?: string } - const args = Array.isArray(stdioServer.args) ? stdioServer.args : [] - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(`${name}: ${stdioServer.command} ${args.join(' ')} - ${status}`) + const stdioServer = server as { command: string; args: string[]; type?: string }; + const args = Array.isArray(stdioServer.args) ? stdioServer.args : []; + console.log(`${name}: ${stdioServer.command} ${args.join(' ')} - ${status}`); } } } // Use gracefulShutdown to properly clean up MCP server connections // (process.exit bypasses cleanup handlers, leaving child processes orphaned) - await gracefulShutdown(0) + await gracefulShutdown(0); } // mcp get (lines 4694–4786) export async function mcpGetHandler(name: string): Promise { logEvent('tengu_mcp_get', { name: name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - const server = getMcpConfigByName(name) + }); + const server = getMcpConfigByName(name); if (!server) { - cliError(`No MCP server found with name: ${name}`) + cliError(`No MCP server found with name: ${name}`); } - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(`${name}:`) - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` Scope: ${getScopeLabel(server.scope)}`) + console.log(`${name}:`); + console.log(` Scope: ${getScopeLabel(server.scope)}`); // Check server health - const status = await checkMcpServerHealth(name, server) - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` Status: ${status}`) + const status = await checkMcpServerHealth(name, server); + console.log(` Status: ${status}`); // Intentionally excluding sse-ide servers here since they're internal if (server.type === 'sse') { - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` Type: sse`) - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` URL: ${server.url}`) + console.log(` Type: sse`); + console.log(` URL: ${server.url}`); if (server.headers) { - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(' Headers:') + console.log(' Headers:'); for (const [key, value] of Object.entries(server.headers)) { - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` ${key}: ${value}`) + console.log(` ${key}: ${value}`); } } if (server.oauth?.clientId || server.oauth?.callbackPort) { - const parts: string[] = [] + const parts: string[] = []; if (server.oauth.clientId) { - parts.push('client_id configured') - const clientConfig = getMcpClientConfig(name, server) - if (clientConfig?.clientSecret) parts.push('client_secret configured') + parts.push('client_id configured'); + const clientConfig = getMcpClientConfig(name, server); + if (clientConfig?.clientSecret) parts.push('client_secret configured'); } - if (server.oauth.callbackPort) - parts.push(`callback_port ${server.oauth.callbackPort}`) - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` OAuth: ${parts.join(', ')}`) + if (server.oauth.callbackPort) parts.push(`callback_port ${server.oauth.callbackPort}`); + console.log(` OAuth: ${parts.join(', ')}`); } } else if (server.type === 'http') { - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` Type: http`) - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` URL: ${server.url}`) + console.log(` Type: http`); + console.log(` URL: ${server.url}`); if (server.headers) { - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(' Headers:') + console.log(' Headers:'); for (const [key, value] of Object.entries(server.headers)) { - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` ${key}: ${value}`) + console.log(` ${key}: ${value}`); } } if (server.oauth?.clientId || server.oauth?.callbackPort) { - const parts: string[] = [] + const parts: string[] = []; if (server.oauth.clientId) { - parts.push('client_id configured') - const clientConfig = getMcpClientConfig(name, server) - if (clientConfig?.clientSecret) parts.push('client_secret configured') + parts.push('client_id configured'); + const clientConfig = getMcpClientConfig(name, server); + if (clientConfig?.clientSecret) parts.push('client_secret configured'); } - if (server.oauth.callbackPort) - parts.push(`callback_port ${server.oauth.callbackPort}`) - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` OAuth: ${parts.join(', ')}`) + if (server.oauth.callbackPort) parts.push(`callback_port ${server.oauth.callbackPort}`); + console.log(` OAuth: ${parts.join(', ')}`); } } else if (server.type === 'stdio') { - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` Type: stdio`) - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` Command: ${server.command}`) - const args = Array.isArray(server.args) ? server.args : [] - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` Args: ${args.join(' ')}`) + console.log(` Type: stdio`); + console.log(` Command: ${server.command}`); + const args = Array.isArray(server.args) ? server.args : []; + console.log(` Args: ${args.join(' ')}`); if (server.env) { - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(' Environment:') + console.log(' Environment:'); for (const [key, value] of Object.entries(server.env)) { - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` ${key}=${value}`) + console.log(` ${key}=${value}`); } } } - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log( - `\nTo remove this server, run: claude mcp remove "${name}" -s ${server.scope}`, - ) + console.log(`\nTo remove this server, run: claude mcp remove "${name}" -s ${server.scope}`); // Use gracefulShutdown to properly clean up MCP server connections // (process.exit bypasses cleanup handlers, leaving child processes orphaned) - await gracefulShutdown(0) + await gracefulShutdown(0); } // mcp add-json (lines 4801–4870) @@ -338,8 +271,8 @@ export async function mcpAddJsonHandler( options: { scope?: string; clientSecret?: true }, ): Promise { try { - const scope = ensureConfigScope(options.scope) - const parsedJson = safeParseJSON(json) + const scope = ensureConfigScope(options.scope); + const parsedJson = safeParseJSON(json); // Read secret before writing config so cancellation doesn't leave partial state const needsSecret = @@ -353,15 +286,15 @@ export async function mcpAddJsonHandler( 'oauth' in parsedJson && parsedJson.oauth && typeof parsedJson.oauth === 'object' && - 'clientId' in parsedJson.oauth - const clientSecret = needsSecret ? await readClientSecret() : undefined + 'clientId' in parsedJson.oauth; + const clientSecret = needsSecret ? await readClientSecret() : undefined; - await addMcpConfig(name, parsedJson, scope) + await addMcpConfig(name, parsedJson, scope); const transportType = parsedJson && typeof parsedJson === 'object' && 'type' in parsedJson ? String(parsedJson.type || 'stdio') - : 'stdio' + : 'stdio'; if ( clientSecret && @@ -372,53 +305,38 @@ export async function mcpAddJsonHandler( 'url' in parsedJson && typeof parsedJson.url === 'string' ) { - saveMcpClientSecret( - name, - { type: parsedJson.type, url: parsedJson.url }, - clientSecret, - ) + saveMcpClientSecret(name, { type: parsedJson.type, url: parsedJson.url }, clientSecret); } logEvent('tengu_mcp_add', { - scope: - scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - source: - 'json' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + source: 'json' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, type: transportType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + }); - cliOk(`Added ${transportType} MCP server ${name} to ${scope} config`) + cliOk(`Added ${transportType} MCP server ${name} to ${scope} config`); } catch (error) { - cliError((error as Error).message) + cliError((error as Error).message); } } // mcp add-from-claude-desktop (lines 4881–4927) -export async function mcpAddFromDesktopHandler(options: { - scope?: string -}): Promise { +export async function mcpAddFromDesktopHandler(options: { scope?: string }): Promise { try { - const scope = ensureConfigScope(options.scope) - const platform = getPlatform() + const scope = ensureConfigScope(options.scope); + const platform = getPlatform(); logEvent('tengu_mcp_add', { - scope: - scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - platform: - platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - source: - 'desktop' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + scope: scope as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + platform: platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + source: 'desktop' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); - const { readClaudeDesktopMcpServers } = await import( - '../../utils/claudeDesktop.js' - ) - const servers = await readClaudeDesktopMcpServers() + const { readClaudeDesktopMcpServers } = await import('../../utils/claudeDesktop.js'); + const servers = await readClaudeDesktopMcpServers(); if (Object.keys(servers).length === 0) { - cliOk( - 'No MCP servers found in Claude Desktop configuration or configuration file does not exist.', - ) + cliOk('No MCP servers found in Claude Desktop configuration or configuration file does not exist.'); } const { unmount } = await render( @@ -428,29 +346,29 @@ export async function mcpAddFromDesktopHandler(options: { servers={servers} scope={scope} onDone={() => { - unmount() + unmount(); }} /> , { exitOnCtrlC: true }, - ) + ); } catch (error) { - cliError((error as Error).message) + cliError((error as Error).message); } } // mcp reset-project-choices (lines 4935–4952) export async function mcpResetChoicesHandler(): Promise { - logEvent('tengu_mcp_reset_mcpjson_choices', {}) + logEvent('tengu_mcp_reset_mcpjson_choices', {}); saveCurrentProjectConfig(current => ({ ...current, enabledMcpjsonServers: [], disabledMcpjsonServers: [], enableAllProjectMcpServers: false, - })) + })); cliOk( 'All project-scoped (.mcp.json) server approvals and rejections have been reset.\n' + 'You will be prompted for approval next time you start Claude Code.', - ) + ); } diff --git a/src/cli/handlers/plugins.ts b/src/cli/handlers/plugins.ts index 9236abe0a..8a3c48a30 100644 --- a/src/cli/handlers/plugins.ts +++ b/src/cli/handlers/plugins.ts @@ -72,27 +72,21 @@ export function handleMarketplaceError(error: unknown, action: string): never { function printValidationResult(result: ValidationResult): void { if (result.errors.length > 0) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log( `${figures.cross} Found ${result.errors.length} ${plural(result.errors.length, 'error')}:\n`, ) result.errors.forEach(error => { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` ${figures.pointer} ${error.path}: ${error.message}`) }) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log('') } if (result.warnings.length > 0) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log( `${figures.warning} Found ${result.warnings.length} ${plural(result.warnings.length, 'warning')}:\n`, ) result.warnings.forEach(warning => { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` ${figures.pointer} ${warning.path}: ${warning.message}`) }) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log('') } } @@ -106,7 +100,6 @@ export async function pluginValidateHandler( try { const result = await validateManifest(manifestPath) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`Validating ${result.fileType} manifest: ${result.filePath}\n`) printValidationResult(result) @@ -120,7 +113,6 @@ export async function pluginValidateHandler( if (basename(manifestDir) === '.claude-plugin') { contentResults = await validatePluginContents(dirname(manifestDir)) for (const r of contentResults) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`Validating ${r.fileType}: ${r.filePath}\n`) printValidationResult(r) } @@ -139,13 +131,11 @@ export async function pluginValidateHandler( : `${figures.tick} Validation passed`, ) } else { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`${figures.cross} Validation failed`) process.exit(1) } } catch (error) { logError(error) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error( `${figures.cross} Unexpected error during validation: ${errorMessage(error)}`, ) @@ -358,7 +348,6 @@ export async function pluginListHandler(options: { } if (pluginIds.length > 0) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log('Installed plugins:\n') } @@ -383,25 +372,18 @@ export async function pluginListHandler(options: { const version = installation.version || 'unknown' const scope = installation.scope - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` ${figures.pointer} ${pluginId}`) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Version: ${version}`) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Scope: ${scope}`) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Status: ${status}`) for (const error of pluginErrors) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Error: ${getPluginErrorMessage(error)}`) } - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log('') } } if (inlinePlugins.length > 0 || inlineLoadErrors.length > 0) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log('Session-only plugins (--plugin-dir):\n') for (const p of inlinePlugins) { // Same dirName≠manifestName fallback as the JSON path above — error @@ -413,19 +395,13 @@ export async function pluginListHandler(options: { pErrors.length > 0 ? `${figures.cross} loaded with errors` : `${figures.tick} loaded` - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` ${figures.pointer} ${p.source}`) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Version: ${p.manifest.version ?? 'unknown'}`) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Path: ${p.path}`) - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Status: ${status}`) for (const e of pErrors) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Error: ${getPluginErrorMessage(e)}`) } - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log('') } // Path-level failures: no LoadedPlugin object exists. Show them so @@ -433,7 +409,6 @@ export async function pluginListHandler(options: { for (const e of inlineLoadErrors.filter(e => e.source.startsWith('inline['), )) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log( ` ${figures.pointer} ${e.source}: ${figures.cross} ${getPluginErrorMessage(e)}\n`, ) @@ -489,12 +464,10 @@ export async function marketplaceAddHandler( } } - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log('Adding marketplace...') const { name, alreadyMaterialized, resolvedSource } = await addMarketplaceSource(marketplaceSource, message => { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(message) }) @@ -555,33 +528,25 @@ export async function marketplaceListHandler(options: { cliOk('No marketplaces configured') } - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log('Configured marketplaces:\n') names.forEach(name => { const marketplace = config[name] - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` ${figures.pointer} ${name}`) if (marketplace?.source) { const src = marketplace.source if (src.source === 'github') { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Source: GitHub (${src.repo})`) } else if (src.source === 'git') { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Source: Git (${src.url})`) } else if (src.source === 'url') { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Source: URL (${src.url})`) } else if (src.source === 'directory') { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Source: Directory (${src.path})`) } else if (src.source === 'file') { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(` Source: File (${src.path})`) } } - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log('') }) @@ -620,11 +585,9 @@ export async function marketplaceUpdateHandler( if (options.cowork) setUseCoworkPlugins(true) try { if (name) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`Updating marketplace: ${name}...`) await refreshMarketplace(name, message => { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(message) }) @@ -644,7 +607,6 @@ export async function marketplaceUpdateHandler( cliOk('No marketplaces configured') } - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`Updating ${marketplaceNames.length} marketplace(s)...`) await refreshAllMarketplaces() diff --git a/src/cli/handlers/util.tsx b/src/cli/handlers/util.tsx index b1a8cc0b9..a793c0a92 100644 --- a/src/cli/handlers/util.tsx +++ b/src/cli/handlers/util.tsx @@ -4,26 +4,24 @@ */ /* eslint-disable custom-rules/no-process-exit -- CLI subcommand handlers intentionally exit */ -import { cwd } from 'process' -import React from 'react' -import { WelcomeV2 } from '../../components/LogoV2/WelcomeV2.js' -import { useManagePlugins } from '../../hooks/useManagePlugins.js' -import type { Root } from '@anthropic/ink' -import { Box, Text } from '@anthropic/ink' -import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js' -import { logEvent } from '../../services/analytics/index.js' -import { MCPConnectionManager } from '../../services/mcp/MCPConnectionManager.js' -import { AppStateProvider } from '../../state/AppState.js' -import { onChangeAppState } from '../../state/onChangeAppState.js' -import { isAnthropicAuthEnabled } from '../../utils/auth.js' +import { cwd } from 'process'; +import React from 'react'; +import { WelcomeV2 } from '../../components/LogoV2/WelcomeV2.js'; +import { useManagePlugins } from '../../hooks/useManagePlugins.js'; +import type { Root } from '@anthropic/ink'; +import { Box, Text } from '@anthropic/ink'; +import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'; +import { logEvent } from '../../services/analytics/index.js'; +import { MCPConnectionManager } from '../../services/mcp/MCPConnectionManager.js'; +import { AppStateProvider } from '../../state/AppState.js'; +import { onChangeAppState } from '../../state/onChangeAppState.js'; +import { isAnthropicAuthEnabled } from '../../utils/auth.js'; export async function setupTokenHandler(root: Root): Promise { - logEvent('tengu_setup_token_command', {}) + logEvent('tengu_setup_token_command', {}); - const showAuthWarning = !isAnthropicAuthEnabled() - const { ConsoleOAuthFlow } = await import( - '../../components/ConsoleOAuthFlow.js' - ) + const showAuthWarning = !isAnthropicAuthEnabled(); + const { ConsoleOAuthFlow } = await import('../../components/ConsoleOAuthFlow.js'); await new Promise(resolve => { root.render( @@ -33,18 +31,16 @@ export async function setupTokenHandler(root: Root): Promise { {showAuthWarning && ( - Warning: You already have authentication configured via - environment variable or API key helper. + Warning: You already have authentication configured via environment variable or API key helper. - The setup-token command will create a new OAuth token which - you can use instead. + The setup-token command will create a new OAuth token which you can use instead. )} { - void resolve() + void resolve(); }} mode="setup-token" startingMessage="This will guide you through long-lived (1-year) auth token setup for your Claude account. Claude subscription required." @@ -52,75 +48,63 @@ export async function setupTokenHandler(root: Root): Promise { , - ) - }) - root.unmount() - process.exit(0) + ); + }); + root.unmount(); + process.exit(0); } // DoctorWithPlugins wrapper + doctor handler -const DoctorLazy = React.lazy(() => - import('../../screens/Doctor.js').then(m => ({ default: m.Doctor })), -) +const DoctorLazy = React.lazy(() => import('../../screens/Doctor.js').then(m => ({ default: m.Doctor }))); -function DoctorWithPlugins({ - onDone, -}: { - onDone: () => void -}): React.ReactNode { - useManagePlugins() +function DoctorWithPlugins({ onDone }: { onDone: () => void }): React.ReactNode { + useManagePlugins(); return ( - ) + ); } export async function doctorHandler(root: Root): Promise { - logEvent('tengu_doctor_command', {}) + logEvent('tengu_doctor_command', {}); await new Promise(resolve => { root.render( - + { - void resolve() + void resolve(); }} /> , - ) - }) - root.unmount() - process.exit(0) + ); + }); + root.unmount(); + process.exit(0); } // install handler -export async function installHandler( - target: string | undefined, - options: { force?: boolean }, -): Promise { - const { setup } = await import('../../setup.js') - await setup(cwd(), 'default', false, false, undefined, false) - const { install } = await import('../../commands/install.js') +export async function installHandler(target: string | undefined, options: { force?: boolean }): Promise { + const { setup } = await import('../../setup.js'); + await setup(cwd(), 'default', false, false, undefined, false); + const { install } = await import('../../commands/install.js'); await new Promise(resolve => { - const args: string[] = [] - if (target) args.push(target) - if (options.force) args.push('--force') + const args: string[] = []; + if (target) args.push(target); + if (options.force) args.push('--force'); void install.call( result => { - void resolve() - process.exit(result.includes('failed') ? 1 : 0) + void resolve(); + process.exit(result.includes('failed') ? 1 : 0); }, {}, args, - ) - }) + ); + }); } diff --git a/src/cli/src/QueryEngine.ts b/src/cli/src/QueryEngine.ts index 4771549b4..85d66db12 100644 --- a/src/cli/src/QueryEngine.ts +++ b/src/cli/src/QueryEngine.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type ask = any; +export type ask = any diff --git a/src/cli/src/cli/handlers/auth.ts b/src/cli/src/cli/handlers/auth.ts index c420d9446..241a6edee 100644 --- a/src/cli/src/cli/handlers/auth.ts +++ b/src/cli/src/cli/handlers/auth.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type installOAuthTokens = any; +export type installOAuthTokens = any diff --git a/src/cli/src/cli/remoteIO.ts b/src/cli/src/cli/remoteIO.ts index 0fc9133a5..1ae827871 100644 --- a/src/cli/src/cli/remoteIO.ts +++ b/src/cli/src/cli/remoteIO.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type RemoteIO = any; +export type RemoteIO = any diff --git a/src/cli/src/cli/structuredIO.ts b/src/cli/src/cli/structuredIO.ts index 00c29d618..abfaf2656 100644 --- a/src/cli/src/cli/structuredIO.ts +++ b/src/cli/src/cli/structuredIO.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type StructuredIO = any; +export type StructuredIO = any diff --git a/src/cli/src/commands/context/context-noninteractive.ts b/src/cli/src/commands/context/context-noninteractive.ts index 08e0c07c7..d79234af0 100644 --- a/src/cli/src/commands/context/context-noninteractive.ts +++ b/src/cli/src/commands/context/context-noninteractive.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type collectContextData = any; +export type collectContextData = any diff --git a/src/cli/src/entrypoints/agentSdkTypes.ts b/src/cli/src/entrypoints/agentSdkTypes.ts index 4ac970c6e..71a2c250d 100644 --- a/src/cli/src/entrypoints/agentSdkTypes.ts +++ b/src/cli/src/entrypoints/agentSdkTypes.ts @@ -1,14 +1,14 @@ // Auto-generated type stub — replace with real implementation -export type SDKStatus = any; -export type ModelInfo = any; -export type SDKMessage = any; -export type SDKUserMessage = any; -export type SDKUserMessageReplay = any; -export type PermissionResult = any; -export type McpServerConfigForProcessTransport = any; -export type McpServerStatus = any; -export type RewindFilesResult = any; -export type HookEvent = any; -export type HookInput = any; -export type HookJSONOutput = any; -export type PermissionUpdate = any; +export type SDKStatus = any +export type ModelInfo = any +export type SDKMessage = any +export type SDKUserMessage = any +export type SDKUserMessageReplay = any +export type PermissionResult = any +export type McpServerConfigForProcessTransport = any +export type McpServerStatus = any +export type RewindFilesResult = any +export type HookEvent = any +export type HookInput = any +export type HookJSONOutput = any +export type PermissionUpdate = any diff --git a/src/cli/src/entrypoints/sdk/controlSchemas.ts b/src/cli/src/entrypoints/sdk/controlSchemas.ts index 886758286..8df23da8d 100644 --- a/src/cli/src/entrypoints/sdk/controlSchemas.ts +++ b/src/cli/src/entrypoints/sdk/controlSchemas.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type SDKControlElicitationResponseSchema = any; +export type SDKControlElicitationResponseSchema = any diff --git a/src/cli/src/entrypoints/sdk/controlTypes.ts b/src/cli/src/entrypoints/sdk/controlTypes.ts index e78800ea8..1febbc56b 100644 --- a/src/cli/src/entrypoints/sdk/controlTypes.ts +++ b/src/cli/src/entrypoints/sdk/controlTypes.ts @@ -1,9 +1,9 @@ // Auto-generated type stub — replace with real implementation -export type StdoutMessage = any; -export type SDKControlInitializeRequest = any; -export type SDKControlInitializeResponse = any; -export type SDKControlRequest = any; -export type SDKControlResponse = any; -export type SDKControlMcpSetServersResponse = any; -export type SDKControlReloadPluginsResponse = any; -export type StdinMessage = any; +export type StdoutMessage = any +export type SDKControlInitializeRequest = any +export type SDKControlInitializeResponse = any +export type SDKControlRequest = any +export type SDKControlResponse = any +export type SDKControlMcpSetServersResponse = any +export type SDKControlReloadPluginsResponse = any +export type StdinMessage = any diff --git a/src/cli/src/hooks/useCanUseTool.ts b/src/cli/src/hooks/useCanUseTool.ts index 056468f12..9e1aa12a5 100644 --- a/src/cli/src/hooks/useCanUseTool.ts +++ b/src/cli/src/hooks/useCanUseTool.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type CanUseToolFn = any; +export type CanUseToolFn = any diff --git a/src/cli/src/services/PromptSuggestion/promptSuggestion.ts b/src/cli/src/services/PromptSuggestion/promptSuggestion.ts index 29070743b..39379f7e2 100644 --- a/src/cli/src/services/PromptSuggestion/promptSuggestion.ts +++ b/src/cli/src/services/PromptSuggestion/promptSuggestion.ts @@ -1,5 +1,5 @@ // Auto-generated type stub — replace with real implementation -export type tryGenerateSuggestion = any; -export type logSuggestionOutcome = any; -export type logSuggestionSuppressed = any; -export type PromptVariant = any; +export type tryGenerateSuggestion = any +export type logSuggestionOutcome = any +export type logSuggestionSuppressed = any +export type PromptVariant = any diff --git a/src/cli/src/services/analytics/growthbook.ts b/src/cli/src/services/analytics/growthbook.ts index e380906ea..7967fd3ee 100644 --- a/src/cli/src/services/analytics/growthbook.ts +++ b/src/cli/src/services/analytics/growthbook.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getFeatureValue_CACHED_MAY_BE_STALE = any; +export type getFeatureValue_CACHED_MAY_BE_STALE = any diff --git a/src/cli/src/services/analytics/index.ts b/src/cli/src/services/analytics/index.ts index ce0a9a827..eca4493cf 100644 --- a/src/cli/src/services/analytics/index.ts +++ b/src/cli/src/services/analytics/index.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type logEvent = any; -export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any; +export type logEvent = any +export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any diff --git a/src/cli/src/services/api/grove.ts b/src/cli/src/services/api/grove.ts index 5a12d8ce5..4d19c3c93 100644 --- a/src/cli/src/services/api/grove.ts +++ b/src/cli/src/services/api/grove.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type isQualifiedForGrove = any; -export type checkGroveForNonInteractive = any; +export type isQualifiedForGrove = any +export type checkGroveForNonInteractive = any diff --git a/src/cli/src/services/api/logging.ts b/src/cli/src/services/api/logging.ts index 2676d9ab3..d44453e57 100644 --- a/src/cli/src/services/api/logging.ts +++ b/src/cli/src/services/api/logging.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type EMPTY_USAGE = any; +export type EMPTY_USAGE = any diff --git a/src/cli/src/services/claudeAiLimits.ts b/src/cli/src/services/claudeAiLimits.ts index 5d55387cb..b354a4815 100644 --- a/src/cli/src/services/claudeAiLimits.ts +++ b/src/cli/src/services/claudeAiLimits.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type statusListeners = any; -export type ClaudeAILimits = any; +export type statusListeners = any +export type ClaudeAILimits = any diff --git a/src/cli/src/services/mcp/auth.ts b/src/cli/src/services/mcp/auth.ts index dd96658d0..dde315b26 100644 --- a/src/cli/src/services/mcp/auth.ts +++ b/src/cli/src/services/mcp/auth.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type performMCPOAuthFlow = any; -export type revokeServerTokens = any; +export type performMCPOAuthFlow = any +export type revokeServerTokens = any diff --git a/src/cli/src/services/mcp/channelAllowlist.ts b/src/cli/src/services/mcp/channelAllowlist.ts index 3bae533e2..88c7c126d 100644 --- a/src/cli/src/services/mcp/channelAllowlist.ts +++ b/src/cli/src/services/mcp/channelAllowlist.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type isChannelAllowlisted = any; -export type isChannelsEnabled = any; +export type isChannelAllowlisted = any +export type isChannelsEnabled = any diff --git a/src/cli/src/services/mcp/channelNotification.ts b/src/cli/src/services/mcp/channelNotification.ts index 2068b3ea8..38716dc9a 100644 --- a/src/cli/src/services/mcp/channelNotification.ts +++ b/src/cli/src/services/mcp/channelNotification.ts @@ -1,5 +1,5 @@ // Auto-generated type stub — replace with real implementation -export type ChannelMessageNotificationSchema = any; -export type gateChannelServer = any; -export type wrapChannelMessage = any; -export type findChannelEntry = any; +export type ChannelMessageNotificationSchema = any +export type gateChannelServer = any +export type wrapChannelMessage = any +export type findChannelEntry = any diff --git a/src/cli/src/services/mcp/client.ts b/src/cli/src/services/mcp/client.ts index 845c793d7..7c12a4f6c 100644 --- a/src/cli/src/services/mcp/client.ts +++ b/src/cli/src/services/mcp/client.ts @@ -1,7 +1,7 @@ // Auto-generated type stub — replace with real implementation -export type setupSdkMcpClients = any; -export type connectToServer = any; -export type clearServerCache = any; -export type fetchToolsForClient = any; -export type areMcpConfigsEqual = any; -export type reconnectMcpServerImpl = any; +export type setupSdkMcpClients = any +export type connectToServer = any +export type clearServerCache = any +export type fetchToolsForClient = any +export type areMcpConfigsEqual = any +export type reconnectMcpServerImpl = any diff --git a/src/cli/src/services/mcp/config.ts b/src/cli/src/services/mcp/config.ts index edc224ea5..44ebff18a 100644 --- a/src/cli/src/services/mcp/config.ts +++ b/src/cli/src/services/mcp/config.ts @@ -1,6 +1,6 @@ // Auto-generated type stub — replace with real implementation -export type filterMcpServersByPolicy = any; -export type getMcpConfigByName = any; -export type isMcpServerDisabled = any; -export type setMcpServerEnabled = any; -export type getAllMcpConfigs = any; +export type filterMcpServersByPolicy = any +export type getMcpConfigByName = any +export type isMcpServerDisabled = any +export type setMcpServerEnabled = any +export type getAllMcpConfigs = any diff --git a/src/cli/src/services/mcp/elicitationHandler.ts b/src/cli/src/services/mcp/elicitationHandler.ts index 2b791775c..584f065dd 100644 --- a/src/cli/src/services/mcp/elicitationHandler.ts +++ b/src/cli/src/services/mcp/elicitationHandler.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type runElicitationHooks = any; -export type runElicitationResultHooks = any; +export type runElicitationHooks = any +export type runElicitationResultHooks = any diff --git a/src/cli/src/services/mcp/mcpStringUtils.ts b/src/cli/src/services/mcp/mcpStringUtils.ts index 9391a1b8a..e6113ecf5 100644 --- a/src/cli/src/services/mcp/mcpStringUtils.ts +++ b/src/cli/src/services/mcp/mcpStringUtils.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getMcpPrefix = any; +export type getMcpPrefix = any diff --git a/src/cli/src/services/mcp/types.ts b/src/cli/src/services/mcp/types.ts index 9e3199967..2a867ced2 100644 --- a/src/cli/src/services/mcp/types.ts +++ b/src/cli/src/services/mcp/types.ts @@ -1,4 +1,4 @@ // Auto-generated type stub — replace with real implementation -export type MCPServerConnection = any; -export type McpSdkServerConfig = any; -export type ScopedMcpServerConfig = any; +export type MCPServerConnection = any +export type McpSdkServerConfig = any +export type ScopedMcpServerConfig = any diff --git a/src/cli/src/services/mcp/utils.ts b/src/cli/src/services/mcp/utils.ts index d77aad08e..4d299725a 100644 --- a/src/cli/src/services/mcp/utils.ts +++ b/src/cli/src/services/mcp/utils.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type commandBelongsToServer = any; -export type filterToolsByServer = any; +export type commandBelongsToServer = any +export type filterToolsByServer = any diff --git a/src/cli/src/services/mcp/vscodeSdkMcp.ts b/src/cli/src/services/mcp/vscodeSdkMcp.ts index acf5f2d9d..68573399d 100644 --- a/src/cli/src/services/mcp/vscodeSdkMcp.ts +++ b/src/cli/src/services/mcp/vscodeSdkMcp.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type setupVscodeSdkMcp = any; +export type setupVscodeSdkMcp = any diff --git a/src/cli/src/services/oauth/index.ts b/src/cli/src/services/oauth/index.ts index 81adfa1dc..e93bb2e2e 100644 --- a/src/cli/src/services/oauth/index.ts +++ b/src/cli/src/services/oauth/index.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type OAuthService = any; +export type OAuthService = any diff --git a/src/cli/src/services/policyLimits/index.ts b/src/cli/src/services/policyLimits/index.ts index 887817d1a..d46f0fab1 100644 --- a/src/cli/src/services/policyLimits/index.ts +++ b/src/cli/src/services/policyLimits/index.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type isPolicyAllowed = any; +export type isPolicyAllowed = any diff --git a/src/cli/src/services/remoteManagedSettings/index.ts b/src/cli/src/services/remoteManagedSettings/index.ts index c062fff71..cb354cb21 100644 --- a/src/cli/src/services/remoteManagedSettings/index.ts +++ b/src/cli/src/services/remoteManagedSettings/index.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type waitForRemoteManagedSettingsToLoad = any; +export type waitForRemoteManagedSettingsToLoad = any diff --git a/src/cli/src/services/settingsSync/index.ts b/src/cli/src/services/settingsSync/index.ts index 2974be7d5..596aa1b7e 100644 --- a/src/cli/src/services/settingsSync/index.ts +++ b/src/cli/src/services/settingsSync/index.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type downloadUserSettings = any; -export type redownloadUserSettings = any; +export type downloadUserSettings = any +export type redownloadUserSettings = any diff --git a/src/cli/src/state/AppStateStore.ts b/src/cli/src/state/AppStateStore.ts index caf2928ae..fec2e89be 100644 --- a/src/cli/src/state/AppStateStore.ts +++ b/src/cli/src/state/AppStateStore.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type AppState = any; +export type AppState = any diff --git a/src/cli/src/state/onChangeAppState.ts b/src/cli/src/state/onChangeAppState.ts index 676171dd6..9cd87de9d 100644 --- a/src/cli/src/state/onChangeAppState.ts +++ b/src/cli/src/state/onChangeAppState.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type externalMetadataToAppState = any; +export type externalMetadataToAppState = any diff --git a/src/cli/src/tools.ts b/src/cli/src/tools.ts index ce74ff75d..3e35e5e68 100644 --- a/src/cli/src/tools.ts +++ b/src/cli/src/tools.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type assembleToolPool = any; -export type filterToolsByDenyRules = any; +export type assembleToolPool = any +export type filterToolsByDenyRules = any diff --git a/src/cli/src/utils/abortController.ts b/src/cli/src/utils/abortController.ts index 50ffcbc73..cc188ec2b 100644 --- a/src/cli/src/utils/abortController.ts +++ b/src/cli/src/utils/abortController.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type createAbortController = any; +export type createAbortController = any diff --git a/src/cli/src/utils/array.ts b/src/cli/src/utils/array.ts index 6ca22d907..b1b296fc1 100644 --- a/src/cli/src/utils/array.ts +++ b/src/cli/src/utils/array.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type uniq = any; +export type uniq = any diff --git a/src/cli/src/utils/auth.ts b/src/cli/src/utils/auth.ts index 3322df66f..9f4b693ac 100644 --- a/src/cli/src/utils/auth.ts +++ b/src/cli/src/utils/auth.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getAccountInformation = any; +export type getAccountInformation = any diff --git a/src/cli/src/utils/autoUpdater.ts b/src/cli/src/utils/autoUpdater.ts index 5241e3992..3973f7955 100644 --- a/src/cli/src/utils/autoUpdater.ts +++ b/src/cli/src/utils/autoUpdater.ts @@ -1,4 +1,4 @@ // Auto-generated type stub — replace with real implementation -export type getLatestVersion = any; -export type InstallStatus = any; -export type installGlobalPackage = any; +export type getLatestVersion = any +export type InstallStatus = any +export type installGlobalPackage = any diff --git a/src/cli/src/utils/awsAuthStatusManager.ts b/src/cli/src/utils/awsAuthStatusManager.ts index d0ba68cc8..d7bbc67c0 100644 --- a/src/cli/src/utils/awsAuthStatusManager.ts +++ b/src/cli/src/utils/awsAuthStatusManager.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type AwsAuthStatusManager = any; +export type AwsAuthStatusManager = any diff --git a/src/cli/src/utils/betas.ts b/src/cli/src/utils/betas.ts index 3e452b4d5..1767972ea 100644 --- a/src/cli/src/utils/betas.ts +++ b/src/cli/src/utils/betas.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type modelSupportsAutoMode = any; +export type modelSupportsAutoMode = any diff --git a/src/cli/src/utils/cleanupRegistry.ts b/src/cli/src/utils/cleanupRegistry.ts index 4cbbdec8f..5f3a8d18d 100644 --- a/src/cli/src/utils/cleanupRegistry.ts +++ b/src/cli/src/utils/cleanupRegistry.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type registerCleanup = any; +export type registerCleanup = any diff --git a/src/cli/src/utils/combinedAbortSignal.ts b/src/cli/src/utils/combinedAbortSignal.ts index 603e78f81..f21839e65 100644 --- a/src/cli/src/utils/combinedAbortSignal.ts +++ b/src/cli/src/utils/combinedAbortSignal.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type createCombinedAbortSignal = any; +export type createCombinedAbortSignal = any diff --git a/src/cli/src/utils/commandLifecycle.ts b/src/cli/src/utils/commandLifecycle.ts index d2f254135..d7f53df09 100644 --- a/src/cli/src/utils/commandLifecycle.ts +++ b/src/cli/src/utils/commandLifecycle.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type notifyCommandLifecycle = any; +export type notifyCommandLifecycle = any diff --git a/src/cli/src/utils/commitAttribution.ts b/src/cli/src/utils/commitAttribution.ts index 4ee7a474d..64f8f1d08 100644 --- a/src/cli/src/utils/commitAttribution.ts +++ b/src/cli/src/utils/commitAttribution.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type incrementPromptCount = any; +export type incrementPromptCount = any diff --git a/src/cli/src/utils/completionCache.ts b/src/cli/src/utils/completionCache.ts index 1989a7093..184bdd437 100644 --- a/src/cli/src/utils/completionCache.ts +++ b/src/cli/src/utils/completionCache.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type regenerateCompletionCache = any; +export type regenerateCompletionCache = any diff --git a/src/cli/src/utils/config.ts b/src/cli/src/utils/config.ts index 12caa8ef9..e629a857a 100644 --- a/src/cli/src/utils/config.ts +++ b/src/cli/src/utils/config.ts @@ -1,4 +1,4 @@ // Auto-generated type stub — replace with real implementation -export type getGlobalConfig = any; -export type InstallMethod = any; -export type saveGlobalConfig = any; +export type getGlobalConfig = any +export type InstallMethod = any +export type saveGlobalConfig = any diff --git a/src/cli/src/utils/conversationRecovery.ts b/src/cli/src/utils/conversationRecovery.ts index 76a469ddc..6c24cb886 100644 --- a/src/cli/src/utils/conversationRecovery.ts +++ b/src/cli/src/utils/conversationRecovery.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type loadConversationForResume = any; -export type TurnInterruptionState = any; +export type loadConversationForResume = any +export type TurnInterruptionState = any diff --git a/src/cli/src/utils/cwd.ts b/src/cli/src/utils/cwd.ts index 76c192ed8..4bd56a824 100644 --- a/src/cli/src/utils/cwd.ts +++ b/src/cli/src/utils/cwd.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getCwd = any; +export type getCwd = any diff --git a/src/cli/src/utils/debug.ts b/src/cli/src/utils/debug.ts index c64d5960c..5a0f1d515 100644 --- a/src/cli/src/utils/debug.ts +++ b/src/cli/src/utils/debug.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type logForDebugging = any; +export type logForDebugging = any diff --git a/src/cli/src/utils/diagLogs.ts b/src/cli/src/utils/diagLogs.ts index 35f6099b5..9fd909fb4 100644 --- a/src/cli/src/utils/diagLogs.ts +++ b/src/cli/src/utils/diagLogs.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type logForDiagnosticsNoPII = any; -export type withDiagnosticsTiming = any; +export type logForDiagnosticsNoPII = any +export type withDiagnosticsTiming = any diff --git a/src/cli/src/utils/doctorDiagnostic.ts b/src/cli/src/utils/doctorDiagnostic.ts index 02bff9d33..e6cb3f1bd 100644 --- a/src/cli/src/utils/doctorDiagnostic.ts +++ b/src/cli/src/utils/doctorDiagnostic.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getDoctorDiagnostic = any; +export type getDoctorDiagnostic = any diff --git a/src/cli/src/utils/effort.ts b/src/cli/src/utils/effort.ts index 323def36c..2f6852fdb 100644 --- a/src/cli/src/utils/effort.ts +++ b/src/cli/src/utils/effort.ts @@ -1,5 +1,5 @@ // Auto-generated type stub — replace with real implementation -export type modelSupportsEffort = any; -export type modelSupportsMaxEffort = any; -export type EFFORT_LEVELS = any; -export type resolveAppliedEffort = any; +export type modelSupportsEffort = any +export type modelSupportsMaxEffort = any +export type EFFORT_LEVELS = any +export type resolveAppliedEffort = any diff --git a/src/cli/src/utils/errors.ts b/src/cli/src/utils/errors.ts index 6dd7f879d..aed78827c 100644 --- a/src/cli/src/utils/errors.ts +++ b/src/cli/src/utils/errors.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type AbortError = any; +export type AbortError = any diff --git a/src/cli/src/utils/fastMode.ts b/src/cli/src/utils/fastMode.ts index e67ddafc1..7d66ce16a 100644 --- a/src/cli/src/utils/fastMode.ts +++ b/src/cli/src/utils/fastMode.ts @@ -1,5 +1,5 @@ // Auto-generated type stub — replace with real implementation -export type isFastModeAvailable = any; -export type isFastModeEnabled = any; -export type isFastModeSupportedByModel = any; -export type getFastModeState = any; +export type isFastModeAvailable = any +export type isFastModeEnabled = any +export type isFastModeSupportedByModel = any +export type getFastModeState = any diff --git a/src/cli/src/utils/fileHistory.ts b/src/cli/src/utils/fileHistory.ts index d925e9f9e..795a2744d 100644 --- a/src/cli/src/utils/fileHistory.ts +++ b/src/cli/src/utils/fileHistory.ts @@ -1,5 +1,5 @@ // Auto-generated type stub — replace with real implementation -export type fileHistoryRewind = any; -export type fileHistoryCanRestore = any; -export type fileHistoryEnabled = any; -export type fileHistoryGetDiffStats = any; +export type fileHistoryRewind = any +export type fileHistoryCanRestore = any +export type fileHistoryEnabled = any +export type fileHistoryGetDiffStats = any diff --git a/src/cli/src/utils/filePersistence/filePersistence.ts b/src/cli/src/utils/filePersistence/filePersistence.ts index 57d7cf708..7b4584b26 100644 --- a/src/cli/src/utils/filePersistence/filePersistence.ts +++ b/src/cli/src/utils/filePersistence/filePersistence.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type executeFilePersistence = any; +export type executeFilePersistence = any diff --git a/src/cli/src/utils/fileStateCache.ts b/src/cli/src/utils/fileStateCache.ts index eca7afcd2..17c655394 100644 --- a/src/cli/src/utils/fileStateCache.ts +++ b/src/cli/src/utils/fileStateCache.ts @@ -1,4 +1,4 @@ // Auto-generated type stub — replace with real implementation -export type createFileStateCacheWithSizeLimit = any; -export type mergeFileStateCaches = any; -export type READ_FILE_STATE_CACHE_SIZE = any; +export type createFileStateCacheWithSizeLimit = any +export type mergeFileStateCaches = any +export type READ_FILE_STATE_CACHE_SIZE = any diff --git a/src/cli/src/utils/forkedAgent.ts b/src/cli/src/utils/forkedAgent.ts index fa626eedd..704c60729 100644 --- a/src/cli/src/utils/forkedAgent.ts +++ b/src/cli/src/utils/forkedAgent.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getLastCacheSafeParams = any; +export type getLastCacheSafeParams = any diff --git a/src/cli/src/utils/generators.ts b/src/cli/src/utils/generators.ts index c9f2bd6e0..47382765a 100644 --- a/src/cli/src/utils/generators.ts +++ b/src/cli/src/utils/generators.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type fromArray = any; +export type fromArray = any diff --git a/src/cli/src/utils/gracefulShutdown.ts b/src/cli/src/utils/gracefulShutdown.ts index c7e6f98bb..a02ac3bb8 100644 --- a/src/cli/src/utils/gracefulShutdown.ts +++ b/src/cli/src/utils/gracefulShutdown.ts @@ -1,4 +1,4 @@ // Auto-generated type stub — replace with real implementation -export type gracefulShutdown = any; -export type gracefulShutdownSync = any; -export type isShuttingDown = any; +export type gracefulShutdown = any +export type gracefulShutdownSync = any +export type isShuttingDown = any diff --git a/src/cli/src/utils/headlessProfiler.ts b/src/cli/src/utils/headlessProfiler.ts index 3028607aa..1ac76632d 100644 --- a/src/cli/src/utils/headlessProfiler.ts +++ b/src/cli/src/utils/headlessProfiler.ts @@ -1,4 +1,4 @@ // Auto-generated type stub — replace with real implementation -export type headlessProfilerStartTurn = any; -export type headlessProfilerCheckpoint = any; -export type logHeadlessProfilerTurn = any; +export type headlessProfilerStartTurn = any +export type headlessProfilerCheckpoint = any +export type logHeadlessProfilerTurn = any diff --git a/src/cli/src/utils/hooks.ts b/src/cli/src/utils/hooks.ts index 28c15cff6..658c89f07 100644 --- a/src/cli/src/utils/hooks.ts +++ b/src/cli/src/utils/hooks.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type executeNotificationHooks = any; +export type executeNotificationHooks = any diff --git a/src/cli/src/utils/hooks/AsyncHookRegistry.ts b/src/cli/src/utils/hooks/AsyncHookRegistry.ts index eca6e2fbc..3224f9da7 100644 --- a/src/cli/src/utils/hooks/AsyncHookRegistry.ts +++ b/src/cli/src/utils/hooks/AsyncHookRegistry.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type finalizePendingAsyncHooks = any; +export type finalizePendingAsyncHooks = any diff --git a/src/cli/src/utils/hooks/hookEvents.ts b/src/cli/src/utils/hooks/hookEvents.ts index 88419b696..4d27d49ce 100644 --- a/src/cli/src/utils/hooks/hookEvents.ts +++ b/src/cli/src/utils/hooks/hookEvents.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type registerHookEventHandler = any; +export type registerHookEventHandler = any diff --git a/src/cli/src/utils/idleTimeout.ts b/src/cli/src/utils/idleTimeout.ts index 0b3bf81e9..edf78e489 100644 --- a/src/cli/src/utils/idleTimeout.ts +++ b/src/cli/src/utils/idleTimeout.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type createIdleTimeoutManager = any; +export type createIdleTimeoutManager = any diff --git a/src/cli/src/utils/json.ts b/src/cli/src/utils/json.ts index d9646ab1f..19c609dba 100644 --- a/src/cli/src/utils/json.ts +++ b/src/cli/src/utils/json.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type safeParseJSON = any; +export type safeParseJSON = any diff --git a/src/cli/src/utils/localInstaller.ts b/src/cli/src/utils/localInstaller.ts index e51976252..3ff547fc1 100644 --- a/src/cli/src/utils/localInstaller.ts +++ b/src/cli/src/utils/localInstaller.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type installOrUpdateClaudePackage = any; -export type localInstallationExists = any; +export type installOrUpdateClaudePackage = any +export type localInstallationExists = any diff --git a/src/cli/src/utils/log.ts b/src/cli/src/utils/log.ts index 989e1cdb7..ef78fa508 100644 --- a/src/cli/src/utils/log.ts +++ b/src/cli/src/utils/log.ts @@ -1,4 +1,4 @@ // Auto-generated type stub — replace with real implementation -export type getInMemoryErrors = any; -export type logError = any; -export type logMCPDebug = any; +export type getInMemoryErrors = any +export type logError = any +export type logMCPDebug = any diff --git a/src/cli/src/utils/messageQueueManager.ts b/src/cli/src/utils/messageQueueManager.ts index bf258e11d..3dfb83b9b 100644 --- a/src/cli/src/utils/messageQueueManager.ts +++ b/src/cli/src/utils/messageQueueManager.ts @@ -1,8 +1,8 @@ // Auto-generated type stub — replace with real implementation -export type dequeue = any; -export type dequeueAllMatching = any; -export type enqueue = any; -export type hasCommandsInQueue = any; -export type peek = any; -export type subscribeToCommandQueue = any; -export type getCommandsByMaxPriority = any; +export type dequeue = any +export type dequeueAllMatching = any +export type enqueue = any +export type hasCommandsInQueue = any +export type peek = any +export type subscribeToCommandQueue = any +export type getCommandsByMaxPriority = any diff --git a/src/cli/src/utils/messages.ts b/src/cli/src/utils/messages.ts index 7a268a925..16bd84c82 100644 --- a/src/cli/src/utils/messages.ts +++ b/src/cli/src/utils/messages.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type createModelSwitchBreadcrumbs = any; +export type createModelSwitchBreadcrumbs = any diff --git a/src/cli/src/utils/messages/mappers.ts b/src/cli/src/utils/messages/mappers.ts index 94ac2ac78..8215b7eb9 100644 --- a/src/cli/src/utils/messages/mappers.ts +++ b/src/cli/src/utils/messages/mappers.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type toInternalMessages = any; -export type toSDKRateLimitInfo = any; +export type toInternalMessages = any +export type toSDKRateLimitInfo = any diff --git a/src/cli/src/utils/model/model.ts b/src/cli/src/utils/model/model.ts index 7986ad040..eba70c343 100644 --- a/src/cli/src/utils/model/model.ts +++ b/src/cli/src/utils/model/model.ts @@ -1,5 +1,5 @@ // Auto-generated type stub — replace with real implementation -export type getDefaultMainLoopModel = any; -export type getMainLoopModel = any; -export type modelDisplayString = any; -export type parseUserSpecifiedModel = any; +export type getDefaultMainLoopModel = any +export type getMainLoopModel = any +export type modelDisplayString = any +export type parseUserSpecifiedModel = any diff --git a/src/cli/src/utils/model/modelOptions.ts b/src/cli/src/utils/model/modelOptions.ts index b95242f35..77847cf16 100644 --- a/src/cli/src/utils/model/modelOptions.ts +++ b/src/cli/src/utils/model/modelOptions.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getModelOptions = any; +export type getModelOptions = any diff --git a/src/cli/src/utils/model/modelStrings.ts b/src/cli/src/utils/model/modelStrings.ts index ad029ac9b..094a814f0 100644 --- a/src/cli/src/utils/model/modelStrings.ts +++ b/src/cli/src/utils/model/modelStrings.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type ensureModelStringsInitialized = any; +export type ensureModelStringsInitialized = any diff --git a/src/cli/src/utils/model/providers.ts b/src/cli/src/utils/model/providers.ts index df87a41b4..1379140e8 100644 --- a/src/cli/src/utils/model/providers.ts +++ b/src/cli/src/utils/model/providers.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getAPIProvider = any; +export type getAPIProvider = any diff --git a/src/cli/src/utils/nativeInstaller/index.ts b/src/cli/src/utils/nativeInstaller/index.ts index 397e06654..7d7f27fc1 100644 --- a/src/cli/src/utils/nativeInstaller/index.ts +++ b/src/cli/src/utils/nativeInstaller/index.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type installLatest = any; -export type removeInstalledSymlink = any; +export type installLatest = any +export type removeInstalledSymlink = any diff --git a/src/cli/src/utils/nativeInstaller/packageManagers.ts b/src/cli/src/utils/nativeInstaller/packageManagers.ts index e73db3d9b..900858726 100644 --- a/src/cli/src/utils/nativeInstaller/packageManagers.ts +++ b/src/cli/src/utils/nativeInstaller/packageManagers.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getPackageManager = any; +export type getPackageManager = any diff --git a/src/cli/src/utils/path.ts b/src/cli/src/utils/path.ts index a965844dd..2d783dc63 100644 --- a/src/cli/src/utils/path.ts +++ b/src/cli/src/utils/path.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type expandPath = any; +export type expandPath = any diff --git a/src/cli/src/utils/permissions/PermissionPromptToolResultSchema.ts b/src/cli/src/utils/permissions/PermissionPromptToolResultSchema.ts index ab281b487..30294c62e 100644 --- a/src/cli/src/utils/permissions/PermissionPromptToolResultSchema.ts +++ b/src/cli/src/utils/permissions/PermissionPromptToolResultSchema.ts @@ -1,4 +1,4 @@ // Auto-generated type stub — replace with real implementation -export type outputSchema = any; -export type permissionPromptToolResultToPermissionDecision = any; -export type Output = any; +export type outputSchema = any +export type permissionPromptToolResultToPermissionDecision = any +export type Output = any diff --git a/src/cli/src/utils/permissions/PermissionResult.ts b/src/cli/src/utils/permissions/PermissionResult.ts index e09cead08..67106d1f2 100644 --- a/src/cli/src/utils/permissions/PermissionResult.ts +++ b/src/cli/src/utils/permissions/PermissionResult.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type PermissionDecision = any; -export type PermissionDecisionReason = any; +export type PermissionDecision = any +export type PermissionDecisionReason = any diff --git a/src/cli/src/utils/permissions/permissionSetup.ts b/src/cli/src/utils/permissions/permissionSetup.ts index b669de315..1f21464d3 100644 --- a/src/cli/src/utils/permissions/permissionSetup.ts +++ b/src/cli/src/utils/permissions/permissionSetup.ts @@ -1,6 +1,6 @@ // Auto-generated type stub — replace with real implementation -export type isAutoModeGateEnabled = any; -export type getAutoModeUnavailableNotification = any; -export type getAutoModeUnavailableReason = any; -export type isBypassPermissionsModeDisabled = any; -export type transitionPermissionMode = any; +export type isAutoModeGateEnabled = any +export type getAutoModeUnavailableNotification = any +export type getAutoModeUnavailableReason = any +export type isBypassPermissionsModeDisabled = any +export type transitionPermissionMode = any diff --git a/src/cli/src/utils/permissions/permissions.ts b/src/cli/src/utils/permissions/permissions.ts index e8f8477e9..4c18d0fdb 100644 --- a/src/cli/src/utils/permissions/permissions.ts +++ b/src/cli/src/utils/permissions/permissions.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type hasPermissionsToUseTool = any; +export type hasPermissionsToUseTool = any diff --git a/src/cli/src/utils/plugins/pluginIdentifier.ts b/src/cli/src/utils/plugins/pluginIdentifier.ts index a6ca96949..bf7a16dfe 100644 --- a/src/cli/src/utils/plugins/pluginIdentifier.ts +++ b/src/cli/src/utils/plugins/pluginIdentifier.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type parsePluginIdentifier = any; +export type parsePluginIdentifier = any diff --git a/src/cli/src/utils/process.ts b/src/cli/src/utils/process.ts index f3fc51827..14d228e36 100644 --- a/src/cli/src/utils/process.ts +++ b/src/cli/src/utils/process.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type writeToStdout = any; -export type registerProcessOutputErrorHandlers = any; +export type writeToStdout = any +export type registerProcessOutputErrorHandlers = any diff --git a/src/cli/src/utils/queryContext.ts b/src/cli/src/utils/queryContext.ts index 258dfa1b8..9ce88b84d 100644 --- a/src/cli/src/utils/queryContext.ts +++ b/src/cli/src/utils/queryContext.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type buildSideQuestionFallbackParams = any; +export type buildSideQuestionFallbackParams = any diff --git a/src/cli/src/utils/queryHelpers.ts b/src/cli/src/utils/queryHelpers.ts index 39a3af855..2c1527ec1 100644 --- a/src/cli/src/utils/queryHelpers.ts +++ b/src/cli/src/utils/queryHelpers.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type PermissionPromptTool = any; -export type extractReadFilesFromMessages = any; +export type PermissionPromptTool = any +export type extractReadFilesFromMessages = any diff --git a/src/cli/src/utils/queryProfiler.ts b/src/cli/src/utils/queryProfiler.ts index dd554afb0..06f798fbe 100644 --- a/src/cli/src/utils/queryProfiler.ts +++ b/src/cli/src/utils/queryProfiler.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type startQueryProfile = any; -export type logQueryProfileReport = any; +export type startQueryProfile = any +export type logQueryProfileReport = any diff --git a/src/cli/src/utils/sandbox/sandbox-adapter.ts b/src/cli/src/utils/sandbox/sandbox-adapter.ts index edebe2640..e9f663b72 100644 --- a/src/cli/src/utils/sandbox/sandbox-adapter.ts +++ b/src/cli/src/utils/sandbox/sandbox-adapter.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type SandboxManager = any; +export type SandboxManager = any diff --git a/src/cli/src/utils/semver.ts b/src/cli/src/utils/semver.ts index a786c8772..e2152fed8 100644 --- a/src/cli/src/utils/semver.ts +++ b/src/cli/src/utils/semver.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type gte = any; +export type gte = any diff --git a/src/cli/src/utils/sessionRestore.ts b/src/cli/src/utils/sessionRestore.ts index 8b6aebfd1..182d3c17a 100644 --- a/src/cli/src/utils/sessionRestore.ts +++ b/src/cli/src/utils/sessionRestore.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type restoreAgentFromSession = any; -export type restoreSessionStateFromLog = any; +export type restoreAgentFromSession = any +export type restoreSessionStateFromLog = any diff --git a/src/cli/src/utils/sessionStart.ts b/src/cli/src/utils/sessionStart.ts index 3bd206870..ba19147f2 100644 --- a/src/cli/src/utils/sessionStart.ts +++ b/src/cli/src/utils/sessionStart.ts @@ -1,4 +1,4 @@ // Auto-generated type stub — replace with real implementation -export type processSessionStartHooks = any; -export type processSetupHooks = any; -export type takeInitialUserMessage = any; +export type processSessionStartHooks = any +export type processSetupHooks = any +export type takeInitialUserMessage = any diff --git a/src/cli/src/utils/sessionState.ts b/src/cli/src/utils/sessionState.ts index 48d9aa34e..24cdbcc51 100644 --- a/src/cli/src/utils/sessionState.ts +++ b/src/cli/src/utils/sessionState.ts @@ -1,7 +1,7 @@ // Auto-generated type stub — replace with real implementation -export type getSessionState = any; -export type notifySessionStateChanged = any; -export type notifySessionMetadataChanged = any; -export type setPermissionModeChangedListener = any; -export type RequiresActionDetails = any; -export type SessionExternalMetadata = any; +export type getSessionState = any +export type notifySessionStateChanged = any +export type notifySessionMetadataChanged = any +export type setPermissionModeChangedListener = any +export type RequiresActionDetails = any +export type SessionExternalMetadata = any diff --git a/src/cli/src/utils/sessionStorage.ts b/src/cli/src/utils/sessionStorage.ts index 52b0f4d80..03514fa54 100644 --- a/src/cli/src/utils/sessionStorage.ts +++ b/src/cli/src/utils/sessionStorage.ts @@ -1,11 +1,11 @@ // Auto-generated type stub — replace with real implementation -export type hydrateRemoteSession = any; -export type hydrateFromCCRv2InternalEvents = any; -export type resetSessionFilePointer = any; -export type doesMessageExistInSession = any; -export type findUnresolvedToolUse = any; -export type recordAttributionSnapshot = any; -export type saveAgentSetting = any; -export type saveMode = any; -export type saveAiGeneratedTitle = any; -export type restoreSessionMetadata = any; +export type hydrateRemoteSession = any +export type hydrateFromCCRv2InternalEvents = any +export type resetSessionFilePointer = any +export type doesMessageExistInSession = any +export type findUnresolvedToolUse = any +export type recordAttributionSnapshot = any +export type saveAgentSetting = any +export type saveMode = any +export type saveAiGeneratedTitle = any +export type restoreSessionMetadata = any diff --git a/src/cli/src/utils/sessionTitle.ts b/src/cli/src/utils/sessionTitle.ts index 3675a2811..b87c63b9d 100644 --- a/src/cli/src/utils/sessionTitle.ts +++ b/src/cli/src/utils/sessionTitle.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type generateSessionTitle = any; +export type generateSessionTitle = any diff --git a/src/cli/src/utils/sessionUrl.ts b/src/cli/src/utils/sessionUrl.ts index a416c74b9..847e20488 100644 --- a/src/cli/src/utils/sessionUrl.ts +++ b/src/cli/src/utils/sessionUrl.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type parseSessionIdentifier = any; +export type parseSessionIdentifier = any diff --git a/src/cli/src/utils/sideQuestion.ts b/src/cli/src/utils/sideQuestion.ts index 1282d133e..c4674c72e 100644 --- a/src/cli/src/utils/sideQuestion.ts +++ b/src/cli/src/utils/sideQuestion.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type runSideQuestion = any; +export type runSideQuestion = any diff --git a/src/cli/src/utils/stream.ts b/src/cli/src/utils/stream.ts index 60b9b2220..be6d90db1 100644 --- a/src/cli/src/utils/stream.ts +++ b/src/cli/src/utils/stream.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type Stream = any; +export type Stream = any diff --git a/src/cli/src/utils/streamJsonStdoutGuard.ts b/src/cli/src/utils/streamJsonStdoutGuard.ts index 05236b55e..78d9fb414 100644 --- a/src/cli/src/utils/streamJsonStdoutGuard.ts +++ b/src/cli/src/utils/streamJsonStdoutGuard.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type installStreamJsonStdoutGuard = any; +export type installStreamJsonStdoutGuard = any diff --git a/src/cli/src/utils/streamlinedTransform.ts b/src/cli/src/utils/streamlinedTransform.ts index 439c126e0..4c0db3526 100644 --- a/src/cli/src/utils/streamlinedTransform.ts +++ b/src/cli/src/utils/streamlinedTransform.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type createStreamlinedTransformer = any; +export type createStreamlinedTransformer = any diff --git a/src/cli/src/utils/thinking.ts b/src/cli/src/utils/thinking.ts index 451decd5e..f6451cc93 100644 --- a/src/cli/src/utils/thinking.ts +++ b/src/cli/src/utils/thinking.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type ThinkingConfig = any; -export type modelSupportsAdaptiveThinking = any; +export type ThinkingConfig = any +export type modelSupportsAdaptiveThinking = any diff --git a/src/cli/src/utils/toolPool.ts b/src/cli/src/utils/toolPool.ts index 66c7603a6..e62964709 100644 --- a/src/cli/src/utils/toolPool.ts +++ b/src/cli/src/utils/toolPool.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type mergeAndFilterTools = any; +export type mergeAndFilterTools = any diff --git a/src/cli/src/utils/uuid.ts b/src/cli/src/utils/uuid.ts index a95ef5217..7070934e2 100644 --- a/src/cli/src/utils/uuid.ts +++ b/src/cli/src/utils/uuid.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type validateUuid = any; +export type validateUuid = any diff --git a/src/cli/src/utils/workloadContext.ts b/src/cli/src/utils/workloadContext.ts index c80322f82..97c9c4c1d 100644 --- a/src/cli/src/utils/workloadContext.ts +++ b/src/cli/src/utils/workloadContext.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type runWithWorkload = any; -export type WORKLOAD_CRON = any; +export type runWithWorkload = any +export type WORKLOAD_CRON = any diff --git a/src/cli/structuredIO.ts b/src/cli/structuredIO.ts index fba44e61b..dcff000e4 100644 --- a/src/cli/structuredIO.ts +++ b/src/cli/structuredIO.ts @@ -267,7 +267,9 @@ export class StructuredIO { getPendingPermissionRequests() { return Array.from(this.pendingRequests.values()) .map(entry => entry.request) - .filter(pr => (pr.request as { subtype?: string }).subtype === 'can_use_tool') + .filter( + pr => (pr.request as { subtype?: string }).subtype === 'can_use_tool', + ) } setUnexpectedResponseCallback( @@ -285,7 +287,14 @@ export class StructuredIO { * callback is aborted via the signal — otherwise the callback hangs. */ injectControlResponse(response: SDKControlResponse): void { - const responseInner = response.response as { request_id?: string; subtype?: string; error?: string; response?: unknown } | undefined + const responseInner = response.response as + | { + request_id?: string + subtype?: string + error?: string + response?: unknown + } + | undefined const requestId = responseInner?.request_id if (!requestId) return const request = this.pendingRequests.get(requestId as string) @@ -377,7 +386,12 @@ export class StructuredIO { if (uuid) { notifyCommandLifecycle(uuid, 'completed') } - const resp = message.response as { request_id: string; subtype: string; response?: Record; error?: string } + const resp = message.response as { + request_id: string + subtype: string + response?: Record + error?: string + } const request = this.pendingRequests.get(resp.request_id) if (!request) { // Check if this tool_use was already resolved through the normal @@ -386,9 +400,7 @@ export class StructuredIO { // re-processing them would push duplicate assistant messages into // the conversation, causing API 400 errors. const responsePayload = - resp.subtype === 'success' - ? resp.response - : undefined + resp.subtype === 'success' ? resp.response : undefined const toolUseID = responsePayload?.toolUseID if ( typeof toolUseID === 'string' && @@ -400,7 +412,9 @@ export class StructuredIO { return undefined } if (this.unexpectedResponseCallback) { - await this.unexpectedResponseCallback(message as SDKControlResponse & { uuid?: string }) + await this.unexpectedResponseCallback( + message as SDKControlResponse & { uuid?: string }, + ) } return undefined // Ignore responses for requests we don't know about } @@ -409,7 +423,8 @@ export class StructuredIO { // Notify the bridge when the SDK consumer resolves a can_use_tool // request, so it can cancel the stale permission prompt on claude.ai. if ( - (request.request.request as { subtype?: string }).subtype === 'can_use_tool' && + (request.request.request as { subtype?: string }).subtype === + 'can_use_tool' && this.onControlRequestResolved ) { this.onControlRequestResolved(resp.request_id) @@ -455,14 +470,15 @@ export class StructuredIO { if (message.type === 'assistant' || message.type === 'system') { return message } - if ((message as { message?: { role?: string } }).message?.role !== 'user') { + if ( + (message as { message?: { role?: string } }).message?.role !== 'user' + ) { exitWithMessage( `Error: Expected message role 'user', got '${(message as { message?: { role?: string } }).message?.role}'`, ) } return message } catch (error) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error(`Error parsing streaming input line: ${line}: ${error}`) // eslint-disable-next-line custom-rules/no-process-exit process.exit(1) @@ -491,7 +507,10 @@ export class StructuredIO { throw new Error('Request aborted') } this.outbound.enqueue(message) - if ((request as { subtype?: string }).subtype === 'can_use_tool' && this.onControlRequestSent) { + if ( + (request as { subtype?: string }).subtype === 'can_use_tool' && + this.onControlRequestSent + ) { this.onControlRequestSent(message) } const aborted = () => { @@ -687,7 +706,6 @@ export class StructuredIO { ) return result } catch (error) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error(`Error in hook callback ${callbackId}:`, error) return {} } @@ -781,7 +799,6 @@ export class StructuredIO { } function exitWithMessage(message: string): never { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error(message) // eslint-disable-next-line custom-rules/no-process-exit process.exit(1) @@ -823,7 +840,8 @@ async function executePermissionRequestHooksForSDK( const finalInput = decision.updatedInput || input // Apply permission updates if provided by hook ("always allow") - const permissionUpdates = (decision.updatedPermissions ?? []) as unknown as InternalPermissionUpdate[] + const permissionUpdates = (decision.updatedPermissions ?? + []) as unknown as InternalPermissionUpdate[] if (permissionUpdates.length > 0) { persistPermissionUpdates(permissionUpdates) const currentAppState = toolUseContext.getAppState() diff --git a/src/cli/transports/HybridTransport.ts b/src/cli/transports/HybridTransport.ts index 0f0c924b6..6ef8e511b 100644 --- a/src/cli/transports/HybridTransport.ts +++ b/src/cli/transports/HybridTransport.ts @@ -244,7 +244,7 @@ export class HybridTransport extends WebSocketTransport { ) { rcLog( `Hybrid POST ${response.status}: url=${this.postUrl.replace(/token=[^&]+/, 'token=***')}` + - ` events=${events.length} body=${JSON.stringify(response.data).slice(0, 200)}`, + ` events=${events.length} body=${JSON.stringify(response.data).slice(0, 200)}`, ) logForDebugging( `HybridTransport: POST returned ${response.status} (permanent), dropping`, diff --git a/src/cli/transports/SSETransport.ts b/src/cli/transports/SSETransport.ts index ca9c396da..42b90afbd 100644 --- a/src/cli/transports/SSETransport.ts +++ b/src/cli/transports/SSETransport.ts @@ -82,9 +82,7 @@ export function parseSSEFrames(buffer: string): { for (const rawLine of rawFrame.split('\n')) { // Normalize CRLF lines in mixed-line-ending streams. const line = - rawLine[rawLine.length - 1] === '\r' - ? rawLine.slice(0, -1) - : rawLine + rawLine[rawLine.length - 1] === '\r' ? rawLine.slice(0, -1) : rawLine if (line.startsWith(':')) { // SSE comment (e.g., `:keepalive`) @@ -482,9 +480,9 @@ export class SSETransport implements Transport { private handleConnectionError(): void { rcLog( `SSE handleConnectionError: state=${this.state}` + - ` lastSeqNum=${this.getLastSequenceNum()}` + - ` reconnectAttempts=${this.reconnectAttempts}` + - ` msSinceLastActivity=${this.lastActivityTime > 0 ? Date.now() - this.lastActivityTime : -1}`, + ` lastSeqNum=${this.getLastSequenceNum()}` + + ` reconnectAttempts=${this.reconnectAttempts}` + + ` msSinceLastActivity=${this.lastActivityTime > 0 ? Date.now() - this.lastActivityTime : -1}`, ) this.clearLivenessTimer() @@ -518,7 +516,7 @@ export class SSETransport implements Transport { this.reconnectAttempts++ const baseDelay = Math.min( - RECONNECT_BASE_DELAY_MS * Math.pow(2, this.reconnectAttempts - 1), + RECONNECT_BASE_DELAY_MS * 2 ** (this.reconnectAttempts - 1), RECONNECT_MAX_DELAY_MS, ) // Add ±25% jitter @@ -561,8 +559,8 @@ export class SSETransport implements Transport { this.livenessTimer = null rcLog( `SSE liveness timeout (${LIVENESS_TIMEOUT_MS}ms)` + - ` lastSeqNum=${this.getLastSequenceNum()}` + - ` state=${this.state}`, + ` lastSeqNum=${this.getLastSequenceNum()}` + + ` state=${this.state}`, ) logForDebugging('SSETransport: Liveness timeout, reconnecting', { level: 'error', @@ -668,7 +666,7 @@ export class SSETransport implements Transport { } const delayMs = Math.min( - POST_BASE_DELAY_MS * Math.pow(2, attempt - 1), + POST_BASE_DELAY_MS * 2 ** (attempt - 1), POST_MAX_DELAY_MS, ) await sleep(delayMs) diff --git a/src/cli/transports/Transport.ts b/src/cli/transports/Transport.ts index d63bcb00e..de0e6703f 100644 --- a/src/cli/transports/Transport.ts +++ b/src/cli/transports/Transport.ts @@ -1,2 +1,2 @@ // Auto-generated stub — replace with real implementation -export type Transport = any; +export type Transport = any diff --git a/src/cli/transports/WebSocketTransport.ts b/src/cli/transports/WebSocketTransport.ts index 5d5d8fd75..d4af1de03 100644 --- a/src/cli/transports/WebSocketTransport.ts +++ b/src/cli/transports/WebSocketTransport.ts @@ -398,10 +398,10 @@ export class WebSocketTransport implements Transport { private handleConnectionError(closeCode?: number): void { rcLog( `WS handleConnectionError: code=${closeCode}` + - ` state=${this.state}` + - ` url=${this.url.href.replace(/token=[^&]+/, 'token=***')}` + - ` msSinceLastActivity=${this.lastActivityTime > 0 ? Date.now() - this.lastActivityTime : -1}` + - ` reconnectAttempts=${this.reconnectAttempts}`, + ` state=${this.state}` + + ` url=${this.url.href.replace(/token=[^&]+/, 'token=***')}` + + ` msSinceLastActivity=${this.lastActivityTime > 0 ? Date.now() - this.lastActivityTime : -1}` + + ` reconnectAttempts=${this.reconnectAttempts}`, ) logForDebugging( `WebSocketTransport: Disconnected from ${this.url.href}` + @@ -516,7 +516,7 @@ export class WebSocketTransport implements Transport { this.reconnectAttempts++ const baseDelay = Math.min( - DEFAULT_BASE_RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts - 1), + DEFAULT_BASE_RECONNECT_DELAY * 2 ** (this.reconnectAttempts - 1), DEFAULT_MAX_RECONNECT_DELAY, ) // Add ±25% jitter to avoid thundering herd diff --git a/src/cli/transports/__tests__/SSETransport.test.ts b/src/cli/transports/__tests__/SSETransport.test.ts index 40c27ca36..10ec6f01a 100644 --- a/src/cli/transports/__tests__/SSETransport.test.ts +++ b/src/cli/transports/__tests__/SSETransport.test.ts @@ -35,7 +35,8 @@ describe('parseSSEFrames', () => { }) test('keeps incomplete trailing frame in remaining buffer for CRLF streams', () => { - const input = 'event: client_event\r\ndata: {"ok":true}\r\n\r\ndata: {"tail":1}\r\n' + const input = + 'event: client_event\r\ndata: {"ok":true}\r\n\r\ndata: {"tail":1}\r\n' const { frames, remaining } = parseSSEFrames(input) expect(frames).toEqual([ diff --git a/src/cli/transports/ccrClient.ts b/src/cli/transports/ccrClient.ts index b24256089..f0f487dfa 100644 --- a/src/cli/transports/ccrClient.ts +++ b/src/cli/transports/ccrClient.ts @@ -180,7 +180,10 @@ export function accumulateStreamEvents( chunks.push(delta.text as string) const existing = touched.get(chunks) if (existing) { - ;(existing.event as Record).delta = { type: 'text_delta', text: chunks.join('') } + ;(existing.event as Record).delta = { + type: 'text_delta', + text: chunks.join(''), + } break } const snapshot: CoalescedStreamEvent = { @@ -430,7 +433,10 @@ export class CCRClient { 'delivery batch', ) if (!result.ok) { - throw new RetryableError('delivery POST failed', (result as any).retryAfterMs) + throw new RetryableError( + 'delivery POST failed', + (result as any).retryAfterMs, + ) } }, baseDelayMs: 500, @@ -748,7 +754,14 @@ export class CCRClient { } await this.flushStreamEventBuffer() if (message.type === 'assistant') { - clearStreamAccumulatorForMessage(this.streamTextAccumulator, message as { session_id: string; parent_tool_use_id: string | null; message: { id: string } }) + clearStreamAccumulatorForMessage( + this.streamTextAccumulator, + message as { + session_id: string + parent_tool_use_id: string | null + message: { id: string } + }, + ) } await this.eventUploader.enqueue(this.toClientEvent(message)) } diff --git a/src/cli/transports/src/entrypoints/sdk/controlTypes.ts b/src/cli/transports/src/entrypoints/sdk/controlTypes.ts index 513ee706d..1cda2df03 100644 --- a/src/cli/transports/src/entrypoints/sdk/controlTypes.ts +++ b/src/cli/transports/src/entrypoints/sdk/controlTypes.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type StdoutMessage = any; -export type SDKPartialAssistantMessage = any; +export type StdoutMessage = any +export type SDKPartialAssistantMessage = any diff --git a/src/commands/add-dir/add-dir.tsx b/src/commands/add-dir/add-dir.tsx index 91180cd73..bfe51afaf 100644 --- a/src/commands/add-dir/add-dir.tsx +++ b/src/commands/add-dir/add-dir.tsx @@ -1,42 +1,33 @@ -import chalk from 'chalk' -import figures from 'figures' -import React, { useEffect } from 'react' -import { - getAdditionalDirectoriesForClaudeMd, - setAdditionalDirectoriesForClaudeMd, -} from '../../bootstrap/state.js' -import type { LocalJSXCommandContext } from '../../commands.js' -import { MessageResponse } from '../../components/MessageResponse.js' -import { AddWorkspaceDirectory } from '../../components/permissions/rules/AddWorkspaceDirectory.js' -import { Box, Text } from '@anthropic/ink' -import type { LocalJSXCommandOnDone } from '../../types/command.js' -import { - applyPermissionUpdate, - persistPermissionUpdate, -} from '../../utils/permissions/PermissionUpdate.js' -import type { PermissionUpdateDestination } from '../../utils/permissions/PermissionUpdateSchema.js' -import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' -import { - addDirHelpMessage, - validateDirectoryForWorkspace, -} from './validation.js' +import chalk from 'chalk'; +import figures from 'figures'; +import React, { useEffect } from 'react'; +import { getAdditionalDirectoriesForClaudeMd, setAdditionalDirectoriesForClaudeMd } from '../../bootstrap/state.js'; +import type { LocalJSXCommandContext } from '../../commands.js'; +import { MessageResponse } from '../../components/MessageResponse.js'; +import { AddWorkspaceDirectory } from '../../components/permissions/rules/AddWorkspaceDirectory.js'; +import { Box, Text } from '@anthropic/ink'; +import type { LocalJSXCommandOnDone } from '../../types/command.js'; +import { applyPermissionUpdate, persistPermissionUpdate } from '../../utils/permissions/PermissionUpdate.js'; +import type { PermissionUpdateDestination } from '../../utils/permissions/PermissionUpdateSchema.js'; +import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'; +import { addDirHelpMessage, validateDirectoryForWorkspace } from './validation.js'; function AddDirError({ message, args, onDone, }: { - message: string - args: string - onDone: () => void + message: string; + args: string; + onDone: () => void; }): React.ReactNode { useEffect(() => { // We need to defer calling onDone to avoid the "return null" bug where // the component unmounts before React can render the error message. // Using setTimeout ensures the error displays before the command exits. - const timer = setTimeout(onDone, 0) - return () => clearTimeout(timer) - }, [onDone]) + const timer = setTimeout(onDone, 0); + return () => clearTimeout(timer); + }, [onDone]); return ( @@ -47,7 +38,7 @@ function AddDirError({ {message} - ) + ); } export async function call( @@ -55,58 +46,53 @@ export async function call( context: LocalJSXCommandContext, args?: string, ): Promise { - const directoryPath = (args ?? '').trim() - const appState = context.getAppState() + const directoryPath = (args ?? '').trim(); + const appState = context.getAppState(); // Helper to handle adding a directory (shared by both with-path and no-path cases) const handleAddDirectory = async (path: string, remember = false) => { - const destination: PermissionUpdateDestination = remember - ? 'localSettings' - : 'session' + const destination: PermissionUpdateDestination = remember ? 'localSettings' : 'session'; const permissionUpdate = { type: 'addDirectories' as const, directories: [path], destination, - } + }; // Apply to session context - const latestAppState = context.getAppState() - const updatedContext = applyPermissionUpdate( - latestAppState.toolPermissionContext, - permissionUpdate, - ) + const latestAppState = context.getAppState(); + const updatedContext = applyPermissionUpdate(latestAppState.toolPermissionContext, permissionUpdate); context.setAppState(prev => ({ ...prev, toolPermissionContext: updatedContext, - })) + })); // Update sandbox config so Bash commands can access the new directory. // Bootstrap state is the source of truth for session-only dirs; persisted // dirs are picked up via the settings subscription, but we refresh // eagerly here to avoid a race when the user acts immediately. - const currentDirs = getAdditionalDirectoriesForClaudeMd() + const currentDirs = getAdditionalDirectoriesForClaudeMd(); if (!currentDirs.includes(path)) { - setAdditionalDirectoriesForClaudeMd([...currentDirs, path]) + setAdditionalDirectoriesForClaudeMd([...currentDirs, path]); } - SandboxManager.refreshConfig() + SandboxManager.refreshConfig(); - let message: string + let message: string; if (remember) { try { - persistPermissionUpdate(permissionUpdate) - message = `Added ${chalk.bold(path)} as a working directory and saved to local settings` + persistPermissionUpdate(permissionUpdate); + message = `Added ${chalk.bold(path)} as a working directory and saved to local settings`; } catch (error) { - message = `Added ${chalk.bold(path)} as a working directory. Failed to save to local settings: ${error instanceof Error ? error.message : 'Unknown error'}` + message = `Added ${chalk.bold(path)} as a working directory. Failed to save to local settings: ${error instanceof Error ? error.message : 'Unknown error'}`; } } else { - message = `Added ${chalk.bold(path)} as a working directory for this session` + message = `Added ${chalk.bold(path)} as a working directory for this session`; } - const messageWithHint = `${message} ${chalk.dim('· /permissions to manage')}` - onDone(messageWithHint) - } + const messageWithHint = `${message} ${chalk.dim('· /permissions to manage')}`; + onDone(messageWithHint); + }; // When no path is provided, show AddWorkspaceDirectory input form directly // and return to REPL after confirmation @@ -116,27 +102,18 @@ export async function call( permissionContext={appState.toolPermissionContext} onAddDirectory={handleAddDirectory} onCancel={() => { - onDone('Did not add a working directory.') + onDone('Did not add a working directory.'); }} /> - ) + ); } - const result = await validateDirectoryForWorkspace( - directoryPath, - appState.toolPermissionContext, - ) + const result = await validateDirectoryForWorkspace(directoryPath, appState.toolPermissionContext); if (result.resultType !== 'success') { - const message = addDirHelpMessage(result) + const message = addDirHelpMessage(result); - return ( - onDone(message)} - /> - ) + return onDone(message)} />; } return ( @@ -145,10 +122,8 @@ export async function call( permissionContext={appState.toolPermissionContext} onAddDirectory={handleAddDirectory} onCancel={() => { - onDone( - `Did not add ${chalk.bold(result.absolutePath)} as a working directory.`, - ) + onDone(`Did not add ${chalk.bold(result.absolutePath)} as a working directory.`); }} /> - ) + ); } diff --git a/src/commands/agents/agents.tsx b/src/commands/agents/agents.tsx index 6a5931756..eb242452a 100644 --- a/src/commands/agents/agents.tsx +++ b/src/commands/agents/agents.tsx @@ -1,16 +1,13 @@ -import * as React from 'react' -import { AgentsMenu } from '../../components/agents/AgentsMenu.js' -import type { ToolUseContext } from '../../Tool.js' -import { getTools } from '../../tools.js' -import type { LocalJSXCommandOnDone } from '../../types/command.js' +import * as React from 'react'; +import { AgentsMenu } from '../../components/agents/AgentsMenu.js'; +import type { ToolUseContext } from '../../Tool.js'; +import { getTools } from '../../tools.js'; +import type { LocalJSXCommandOnDone } from '../../types/command.js'; -export async function call( - onDone: LocalJSXCommandOnDone, - context: ToolUseContext, -): Promise { - const appState = context.getAppState() - const permissionContext = appState.toolPermissionContext - const tools = getTools(permissionContext) +export async function call(onDone: LocalJSXCommandOnDone, context: ToolUseContext): Promise { + const appState = context.getAppState(); + const permissionContext = appState.toolPermissionContext; + const tools = getTools(permissionContext); - return + return ; } diff --git a/src/commands/autonomy.ts b/src/commands/autonomy.ts index c387fb850..0b780070d 100644 --- a/src/commands/autonomy.ts +++ b/src/commands/autonomy.ts @@ -109,7 +109,10 @@ const call: LocalCommandCall = async (args: string) => { return { type: 'text', - value: [formatAutonomyRunsStatus(runs), formatAutonomyFlowsStatus(flows)].join('\n'), + value: [ + formatAutonomyRunsStatus(runs), + formatAutonomyFlowsStatus(flows), + ].join('\n'), } } diff --git a/src/commands/branch/branch.ts b/src/commands/branch/branch.ts index 50eeb8e78..c9b377495 100644 --- a/src/commands/branch/branch.ts +++ b/src/commands/branch/branch.ts @@ -44,8 +44,10 @@ export function deriveFirstPrompt( typeof content === 'string' ? content : content.find( - (block: { type: string; text?: string }): block is { type: 'text'; text: string } => - block.type === 'text', + (block: { + type: string + text?: string + }): block is { type: 'text'; text: string } => block.type === 'text', )?.text if (!raw) return 'Branched conversation' return ( @@ -240,7 +242,9 @@ export async function call( // Build LogOption for resume const now = new Date() const firstPrompt = deriveFirstPrompt( - serializedMessages.find(m => m.type === 'user') as Extract | undefined, + serializedMessages.find(m => m.type === 'user') as + | Extract + | undefined, ) // Save custom title - use provided title or firstPrompt as default diff --git a/src/commands/bridge/bridge.tsx b/src/commands/bridge/bridge.tsx index a9d9bc5ac..784d89b83 100644 --- a/src/commands/bridge/bridge.tsx +++ b/src/commands/bridge/bridge.tsx @@ -1,39 +1,29 @@ -import { feature } from 'bun:bundle' -import { toString as qrToString } from 'qrcode' -import * as React from 'react' -import { useEffect, useState } from 'react' -import { getBridgeAccessToken } from '../../bridge/bridgeConfig.js' -import { - checkBridgeMinVersion, - getBridgeDisabledReason, - isEnvLessBridgeEnabled, -} from '../../bridge/bridgeEnabled.js' -import { checkEnvLessBridgeMinVersion } from '../../bridge/envLessBridgeConfig.js' -import { - BRIDGE_LOGIN_INSTRUCTION, - REMOTE_CONTROL_DISCONNECTED_MSG, -} from '../../bridge/types.js' -import { Dialog, ListItem } from '@anthropic/ink' -import { shouldShowRemoteCallout } from '../../components/RemoteCallout.js' -import { useRegisterOverlay } from '../../context/overlayContext.js' -import { Box, Text } from '@anthropic/ink' -import { useKeybindings } from '../../keybindings/useKeybinding.js' +import { feature } from 'bun:bundle'; +import { toString as qrToString } from 'qrcode'; +import * as React from 'react'; +import { useEffect, useState } from 'react'; +import { getBridgeAccessToken } from '../../bridge/bridgeConfig.js'; +import { checkBridgeMinVersion, getBridgeDisabledReason, isEnvLessBridgeEnabled } from '../../bridge/bridgeEnabled.js'; +import { checkEnvLessBridgeMinVersion } from '../../bridge/envLessBridgeConfig.js'; +import { BRIDGE_LOGIN_INSTRUCTION, REMOTE_CONTROL_DISCONNECTED_MSG } from '../../bridge/types.js'; +import { Dialog, ListItem } from '@anthropic/ink'; +import { shouldShowRemoteCallout } from '../../components/RemoteCallout.js'; +import { useRegisterOverlay } from '../../context/overlayContext.js'; +import { Box, Text } from '@anthropic/ink'; +import { useKeybindings } from '../../keybindings/useKeybinding.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from '../../services/analytics/index.js' -import { useAppState, useSetAppState } from '../../state/AppState.js' -import type { ToolUseContext } from '../../Tool.js' -import type { - LocalJSXCommandContext, - LocalJSXCommandOnDone, -} from '../../types/command.js' -import { logForDebugging } from '../../utils/debug.js' +} from '../../services/analytics/index.js'; +import { useAppState, useSetAppState } from '../../state/AppState.js'; +import type { ToolUseContext } from '../../Tool.js'; +import type { LocalJSXCommandContext, LocalJSXCommandOnDone } from '../../types/command.js'; +import { logForDebugging } from '../../utils/debug.js'; type Props = { - onDone: LocalJSXCommandOnDone - name?: string -} + onDone: LocalJSXCommandOnDone; + name?: string; +}; /** * /remote-control command — manages the bidirectional bridge connection. @@ -48,35 +38,33 @@ type Props = { * URL and options to disconnect or continue. */ function BridgeToggle({ onDone, name }: Props): React.ReactNode { - const setAppState = useSetAppState() - const replBridgeConnected = useAppState(s => s.replBridgeConnected) - const replBridgeEnabled = useAppState(s => s.replBridgeEnabled) - const replBridgeOutboundOnly = useAppState(s => s.replBridgeOutboundOnly) - const [showDisconnectDialog, setShowDisconnectDialog] = useState(false) + const setAppState = useSetAppState(); + const replBridgeConnected = useAppState(s => s.replBridgeConnected); + const replBridgeEnabled = useAppState(s => s.replBridgeEnabled); + const replBridgeOutboundOnly = useAppState(s => s.replBridgeOutboundOnly); + const [showDisconnectDialog, setShowDisconnectDialog] = useState(false); - // biome-ignore lint/correctness/useExhaustiveDependencies: bridge starts once, should not restart on state changes useEffect(() => { // If already connected or enabled in full bidirectional mode, show // disconnect confirmation. Outbound-only (CCR mirror) doesn't count — // /remote-control upgrades it to full RC instead. if ((replBridgeConnected || replBridgeEnabled) && !replBridgeOutboundOnly) { - setShowDisconnectDialog(true) - return + setShowDisconnectDialog(true); + return; } - let cancelled = false + let cancelled = false; void (async () => { // Pre-flight checks before enabling (awaits GrowthBook init if disk // cache is stale — so Max users don't get a false "not enabled" error) - const error = await checkBridgePrerequisites() - if (cancelled) return + const error = await checkBridgePrerequisites(); + if (cancelled) return; if (error) { logEvent('tengu_bridge_command', { - action: - 'preflight_failed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - onDone(error, { display: 'system' }) - return + action: 'preflight_failed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + onDone(error, { display: 'system' }); + return; } // Show first-time remote dialog if not yet seen. @@ -84,48 +72,47 @@ function BridgeToggle({ onDone, name }: Props): React.ReactNode { // enables the bridge (the handler only sets replBridgeEnabled, not the name). if (shouldShowRemoteCallout()) { setAppState(prev => { - if (prev.showRemoteCallout) return prev + if (prev.showRemoteCallout) return prev; return { ...prev, showRemoteCallout: true, replBridgeInitialName: name, - } - }) - onDone('', { display: 'system' }) - return + }; + }); + onDone('', { display: 'system' }); + return; } // Enable the bridge — useReplBridge in REPL.tsx handles the rest: // registers environment, creates session with conversation, connects WebSocket logEvent('tengu_bridge_command', { - action: - 'connect' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + action: 'connect' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); setAppState(prev => { - if (prev.replBridgeEnabled && !prev.replBridgeOutboundOnly) return prev + if (prev.replBridgeEnabled && !prev.replBridgeOutboundOnly) return prev; return { ...prev, replBridgeEnabled: true, replBridgeExplicit: true, replBridgeOutboundOnly: false, replBridgeInitialName: name, - } - }) + }; + }); onDone('Remote Control connecting\u2026', { display: 'system', - }) - })() + }); + })(); return () => { - cancelled = true - } - }, []) // eslint-disable-line react-hooks/exhaustive-deps -- run once on mount + cancelled = true; + }; + }, []); // eslint-disable-line react-hooks/exhaustive-deps -- run once on mount if (showDisconnectDialog) { - return + return ; } - return null + return null; } /** @@ -133,22 +120,22 @@ function BridgeToggle({ onDone, name }: Props): React.ReactNode { * Shows the session URL and lets the user disconnect or continue. */ function BridgeDisconnectDialog({ onDone }: Props): React.ReactNode { - useRegisterOverlay('bridge-disconnect-dialog') - const setAppState = useSetAppState() - const sessionUrl = useAppState(s => s.replBridgeSessionUrl) - const connectUrl = useAppState(s => s.replBridgeConnectUrl) - const sessionActive = useAppState(s => s.replBridgeSessionActive) - const [focusIndex, setFocusIndex] = useState(2) - const [showQR, setShowQR] = useState(false) - const [qrText, setQrText] = useState('') + useRegisterOverlay('bridge-disconnect-dialog'); + const setAppState = useSetAppState(); + const sessionUrl = useAppState(s => s.replBridgeSessionUrl); + const connectUrl = useAppState(s => s.replBridgeConnectUrl); + const sessionActive = useAppState(s => s.replBridgeSessionActive); + const [focusIndex, setFocusIndex] = useState(2); + const [showQR, setShowQR] = useState(false); + const [qrText, setQrText] = useState(''); - const displayUrl = sessionActive ? sessionUrl : connectUrl + const displayUrl = sessionActive ? sessionUrl : connectUrl; // Generate QR code when URL changes or QR is toggled on useEffect(() => { if (!showQR || !displayUrl) { - setQrText('') - return + setQrText(''); + return; } qrToString(displayUrl, { type: 'utf8', @@ -156,55 +143,53 @@ function BridgeDisconnectDialog({ onDone }: Props): React.ReactNode { small: true, } as Parameters[1]) .then(setQrText) - .catch(() => setQrText('')) - }, [showQR, displayUrl]) + .catch(() => setQrText('')); + }, [showQR, displayUrl]); function handleDisconnect(): void { setAppState(prev => { - if (!prev.replBridgeEnabled) return prev + if (!prev.replBridgeEnabled) return prev; return { ...prev, replBridgeEnabled: false, replBridgeExplicit: false, replBridgeOutboundOnly: false, - } - }) + }; + }); logEvent('tengu_bridge_command', { - action: - 'disconnect' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - onDone(REMOTE_CONTROL_DISCONNECTED_MSG, { display: 'system' }) + action: 'disconnect' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + onDone(REMOTE_CONTROL_DISCONNECTED_MSG, { display: 'system' }); } function handleShowQR(): void { - setShowQR(prev => !prev) + setShowQR(prev => !prev); } function handleContinue(): void { - onDone(undefined, { display: 'skip' }) + onDone(undefined, { display: 'skip' }); } - const ITEM_COUNT = 3 + const ITEM_COUNT = 3; useKeybindings( { 'select:next': () => setFocusIndex(i => (i + 1) % ITEM_COUNT), - 'select:previous': () => - setFocusIndex(i => (i - 1 + ITEM_COUNT) % ITEM_COUNT), + 'select:previous': () => setFocusIndex(i => (i - 1 + ITEM_COUNT) % ITEM_COUNT), 'select:accept': () => { if (focusIndex === 0) { - handleDisconnect() + handleDisconnect(); } else if (focusIndex === 1) { - handleShowQR() + handleShowQR(); } else { - handleContinue() + handleContinue(); } }, }, { context: 'Select' }, - ) + ); - const qrLines = qrText ? qrText.split('\n').filter(l => l.length > 0) : [] + const qrLines = qrText ? qrText.split('\n').filter(l => l.length > 0) : []; return ( @@ -234,7 +219,7 @@ function BridgeDisconnectDialog({ onDone }: Props): React.ReactNode { Enter to select · Esc to continue - ) + ); } /** @@ -245,43 +230,39 @@ function BridgeDisconnectDialog({ onDone }: Props): React.ReactNode { */ async function checkBridgePrerequisites(): Promise { // Check organization policy — remote control may be disabled - 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')) { - return "Remote Control is disabled by your organization's policy." + return "Remote Control is disabled by your organization's policy."; } - const disabledReason = await getBridgeDisabledReason() + const disabledReason = await getBridgeDisabledReason(); if (disabledReason) { - return disabledReason + return disabledReason; } // Mirror the v1/v2 branching logic in initReplBridge: env-less (v2) is used // only when the flag is on AND the session is not perpetual. In assistant // mode (KAIROS) useReplBridge sets perpetual=true, which forces // initReplBridge onto the v1 path — so the prerequisite check must match. - let useV2 = isEnvLessBridgeEnabled() + let useV2 = isEnvLessBridgeEnabled(); if (feature('KAIROS') && useV2) { - const { isAssistantMode } = await import('../../assistant/index.js') + const { isAssistantMode } = await import('../../assistant/index.js'); if (isAssistantMode()) { - useV2 = false + useV2 = false; } } - const versionError = useV2 - ? await checkEnvLessBridgeMinVersion() - : checkBridgeMinVersion() + const versionError = useV2 ? await checkEnvLessBridgeMinVersion() : checkBridgeMinVersion(); if (versionError) { - return versionError + return versionError; } if (!getBridgeAccessToken()) { - return BRIDGE_LOGIN_INSTRUCTION + return BRIDGE_LOGIN_INSTRUCTION; } - logForDebugging('[bridge] Prerequisites passed, enabling bridge') - return null + logForDebugging('[bridge] Prerequisites passed, enabling bridge'); + return null; } export async function call( @@ -289,6 +270,6 @@ export async function call( _context: ToolUseContext & LocalJSXCommandContext, args: string, ): Promise { - const name = args.trim() || undefined - return + const name = args.trim() || undefined; + return ; } diff --git a/src/commands/btw/btw.tsx b/src/commands/btw/btw.tsx index 7da3141b9..27d1d8a45 100644 --- a/src/commands/btw/btw.tsx +++ b/src/commands/btw/btw.tsx @@ -1,118 +1,96 @@ -import * as React from 'react' -import { useEffect, useRef, useState } from 'react' -import { useInterval } from 'usehooks-ts' -import type { CommandResultDisplay } from '../../commands.js' -import { Markdown } from '../../components/Markdown.js' -import { SpinnerGlyph } from '../../components/Spinner/SpinnerGlyph.js' -import { DOWN_ARROW, UP_ARROW } from '../../constants/figures.js' -import { getSystemPrompt } from '../../constants/prompts.js' -import { useModalOrTerminalSize } from '../../context/modalContext.js' -import { getSystemContext, getUserContext } from '../../context.js' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' -import { type KeyboardEvent, type ScrollBoxHandle, ScrollBox } from '@anthropic/ink' -import { Box, Text } from '@anthropic/ink' -import type { LocalJSXCommandOnDone } from '../../types/command.js' -import type { Message } from '../../types/message.js' -import { createAbortController } from '../../utils/abortController.js' -import { saveGlobalConfig } from '../../utils/config.js' -import { errorMessage } from '../../utils/errors.js' -import { - type CacheSafeParams, - getLastCacheSafeParams, -} from '../../utils/forkedAgent.js' -import { getMessagesAfterCompactBoundary } from '../../utils/messages.js' -import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js' -import { runSideQuestion } from '../../utils/sideQuestion.js' -import { asSystemPrompt } from '../../utils/systemPromptType.js' +import * as React from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { useInterval } from 'usehooks-ts'; +import type { CommandResultDisplay } from '../../commands.js'; +import { Markdown } from '../../components/Markdown.js'; +import { SpinnerGlyph } from '../../components/Spinner/SpinnerGlyph.js'; +import { DOWN_ARROW, UP_ARROW } from '../../constants/figures.js'; +import { getSystemPrompt } from '../../constants/prompts.js'; +import { useModalOrTerminalSize } from '../../context/modalContext.js'; +import { getSystemContext, getUserContext } from '../../context.js'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import { type KeyboardEvent, type ScrollBoxHandle, ScrollBox } from '@anthropic/ink'; +import { Box, Text } from '@anthropic/ink'; +import type { LocalJSXCommandOnDone } from '../../types/command.js'; +import type { Message } from '../../types/message.js'; +import { createAbortController } from '../../utils/abortController.js'; +import { saveGlobalConfig } from '../../utils/config.js'; +import { errorMessage } from '../../utils/errors.js'; +import { type CacheSafeParams, getLastCacheSafeParams } from '../../utils/forkedAgent.js'; +import { getMessagesAfterCompactBoundary } from '../../utils/messages.js'; +import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js'; +import { runSideQuestion } from '../../utils/sideQuestion.js'; +import { asSystemPrompt } from '../../utils/systemPromptType.js'; type BtwComponentProps = { - question: string - context: ProcessUserInputContext - onDone: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void -} + question: string; + context: ProcessUserInputContext; + onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void; +}; -const CHROME_ROWS = 5 -const OUTER_CHROME_ROWS = 6 -const SCROLL_LINES = 3 +const CHROME_ROWS = 5; +const OUTER_CHROME_ROWS = 6; +const SCROLL_LINES = 3; -function BtwSideQuestion({ - question, - context, - onDone, -}: BtwComponentProps): React.ReactNode { - const [response, setResponse] = useState(null) - const [error, setError] = useState(null) - const [frame, setFrame] = useState(0) - const scrollRef = useRef(null) - const { rows } = useModalOrTerminalSize(useTerminalSize()) +function BtwSideQuestion({ question, context, onDone }: BtwComponentProps): React.ReactNode { + const [response, setResponse] = useState(null); + const [error, setError] = useState(null); + const [frame, setFrame] = useState(0); + const scrollRef = useRef(null); + const { rows } = useModalOrTerminalSize(useTerminalSize()); // Animate spinner while loading - useInterval(() => setFrame(f => f + 1), response || error ? null : 80) + useInterval(() => setFrame(f => f + 1), response || error ? null : 80); function handleKeyDown(e: KeyboardEvent): void { - if ( - e.key === 'escape' || - e.key === 'return' || - e.key === ' ' || - (e.ctrl && (e.key === 'c' || e.key === 'd')) - ) { - e.preventDefault() - onDone(undefined, { display: 'skip' }) - return + if (e.key === 'escape' || e.key === 'return' || e.key === ' ' || (e.ctrl && (e.key === 'c' || e.key === 'd'))) { + e.preventDefault(); + onDone(undefined, { display: 'skip' }); + return; } if (e.key === 'up' || (e.ctrl && e.key === 'p')) { - e.preventDefault() - scrollRef.current?.scrollBy(-SCROLL_LINES) + e.preventDefault(); + scrollRef.current?.scrollBy(-SCROLL_LINES); } if (e.key === 'down' || (e.ctrl && e.key === 'n')) { - e.preventDefault() - scrollRef.current?.scrollBy(SCROLL_LINES) + e.preventDefault(); + scrollRef.current?.scrollBy(SCROLL_LINES); } } useEffect(() => { - const abortController = createAbortController() + const abortController = createAbortController(); async function fetchResponse(): Promise { try { - const cacheSafeParams = await buildCacheSafeParams(context) - const result = await runSideQuestion({ question, cacheSafeParams }) + const cacheSafeParams = await buildCacheSafeParams(context); + const result = await runSideQuestion({ question, cacheSafeParams }); if (!abortController.signal.aborted) { if (result.response) { - setResponse(result.response) + setResponse(result.response); } else { - setError('No response received') + setError('No response received'); } } } catch (err) { if (!abortController.signal.aborted) { - setError(errorMessage(err) || 'Failed to get response') + setError(errorMessage(err) || 'Failed to get response'); } } } - void fetchResponse() + void fetchResponse(); return () => { - abortController.abort() - } - }, [question, context]) + abortController.abort(); + }; + }, [question, context]); - const maxContentHeight = Math.max(5, rows - CHROME_ROWS - OUTER_CHROME_ROWS) + const maxContentHeight = Math.max(5, rows - CHROME_ROWS - OUTER_CHROME_ROWS); return ( - + /btw{' '} @@ -136,13 +114,12 @@ function BtwSideQuestion({ {(response || error) && ( - {UP_ARROW}/{DOWN_ARROW} to scroll · Space, Enter, or Escape to - dismiss + {UP_ARROW}/{DOWN_ARROW} to scroll · Space, Enter, or Escape to dismiss )} - ) + ); } /** @@ -161,20 +138,16 @@ function BtwSideQuestion({ * --append-system-prompt, coordinator mode). */ function stripInProgressAssistantMessage(messages: Message[]): Message[] { - const last = messages.at(-1) + const last = messages.at(-1); if (last?.type === 'assistant' && last.message!.stop_reason === null) { - return messages.slice(0, -1) + return messages.slice(0, -1); } - return messages + return messages; } -async function buildCacheSafeParams( - context: ProcessUserInputContext, -): Promise { - const forkContextMessages = getMessagesAfterCompactBoundary( - stripInProgressAssistantMessage(context.messages), - ) - const saved = getLastCacheSafeParams() +async function buildCacheSafeParams(context: ProcessUserInputContext): Promise { + const forkContextMessages = getMessagesAfterCompactBoundary(stripInProgressAssistantMessage(context.messages)); + const saved = getLastCacheSafeParams(); if (saved) { return { systemPrompt: saved.systemPrompt, @@ -182,25 +155,20 @@ async function buildCacheSafeParams( systemContext: saved.systemContext, toolUseContext: context, forkContextMessages, - } + }; } const [rawSystemPrompt, userContext, systemContext] = await Promise.all([ - getSystemPrompt( - context.options.tools, - context.options.mainLoopModel, - [], - context.options.mcpClients, - ), + getSystemPrompt(context.options.tools, context.options.mainLoopModel, [], context.options.mcpClients), getUserContext(), getSystemContext(), - ]) + ]); return { systemPrompt: asSystemPrompt(rawSystemPrompt), userContext, systemContext, toolUseContext: context, forkContextMessages, - } + }; } export async function call( @@ -208,19 +176,17 @@ export async function call( context: ProcessUserInputContext, args: string, ): Promise { - const question = args?.trim() + const question = args?.trim(); if (!question) { - onDone('Usage: /btw ', { display: 'system' }) - return null + onDone('Usage: /btw ', { display: 'system' }); + return null; } saveGlobalConfig(current => ({ ...current, btwUseCount: current.btwUseCount + 1, - })) + })); - return ( - - ) + return ; } diff --git a/src/commands/buddy/buddy.ts b/src/commands/buddy/buddy.ts index d54a92d93..2553891f3 100644 --- a/src/commands/buddy/buddy.ts +++ b/src/commands/buddy/buddy.ts @@ -128,7 +128,9 @@ export async function call( return React.createElement(CompanionCard, { companion, lastReaction, - onDone: onDone as unknown as Parameters[0]['onDone'], + onDone: onDone as unknown as Parameters< + typeof CompanionCard + >[0]['onDone'], }) } diff --git a/src/commands/chrome/chrome.tsx b/src/commands/chrome/chrome.tsx index 83f34c33d..c3c370be1 100644 --- a/src/commands/chrome/chrome.tsx +++ b/src/commands/chrome/chrome.tsx @@ -1,39 +1,29 @@ -import React, { useState } from 'react' -import { - type OptionWithDescription, - Select, -} from '../../components/CustomSelect/select.js' -import { Dialog } from '@anthropic/ink' -import { Box, Text } from '@anthropic/ink' -import { useAppState } from '../../state/AppState.js' -import { isClaudeAISubscriber } from '../../utils/auth.js' -import { openBrowser } from '../../utils/browser.js' -import { - CLAUDE_IN_CHROME_MCP_SERVER_NAME, - openInChrome, -} from '../../utils/claudeInChrome/common.js' -import { isChromeExtensionInstalled } from '../../utils/claudeInChrome/setup.js' -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' -import { env } from '../../utils/env.js' -import { isRunningOnHomespace } from '../../utils/envUtils.js' +import React, { useState } from 'react'; +import { type OptionWithDescription, Select } from '../../components/CustomSelect/select.js'; +import { Dialog } from '@anthropic/ink'; +import { Box, Text } from '@anthropic/ink'; +import { useAppState } from '../../state/AppState.js'; +import { isClaudeAISubscriber } from '../../utils/auth.js'; +import { openBrowser } from '../../utils/browser.js'; +import { CLAUDE_IN_CHROME_MCP_SERVER_NAME, openInChrome } from '../../utils/claudeInChrome/common.js'; +import { isChromeExtensionInstalled } from '../../utils/claudeInChrome/setup.js'; +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; +import { env } from '../../utils/env.js'; +import { isRunningOnHomespace } from '../../utils/envUtils.js'; -const CHROME_EXTENSION_URL = 'https://claude.ai/chrome' -const CHROME_PERMISSIONS_URL = 'https://clau.de/chrome/permissions' -const CHROME_RECONNECT_URL = 'https://clau.de/chrome/reconnect' +const CHROME_EXTENSION_URL = 'https://claude.ai/chrome'; +const CHROME_PERMISSIONS_URL = 'https://clau.de/chrome/permissions'; +const CHROME_RECONNECT_URL = 'https://clau.de/chrome/reconnect'; -type MenuAction = - | 'install-extension' - | 'reconnect' - | 'manage-permissions' - | 'toggle-default' +type MenuAction = 'install-extension' | 'reconnect' | 'manage-permissions' | 'toggle-default'; type Props = { - onDone: (result?: string) => void - isExtensionInstalled: boolean - configEnabled: boolean | undefined - isClaudeAISubscriber: boolean - isWSL: boolean -} + onDone: (result?: string) => void; + isExtensionInstalled: boolean; + configEnabled: boolean | undefined; + isClaudeAISubscriber: boolean; + isWSL: boolean; +}; function ClaudeInChromeMenu({ onDone, @@ -42,72 +32,66 @@ function ClaudeInChromeMenu({ isClaudeAISubscriber, isWSL, }: Props): React.ReactNode { - const mcpClients = useAppState(s => s.mcp.clients) - const [selectKey, setSelectKey] = useState(0) - const [enabledByDefault, setEnabledByDefault] = useState( - configEnabled ?? false, - ) - const [showInstallHint, setShowInstallHint] = useState(false) - const [isExtensionInstalled, setIsExtensionInstalled] = useState(installed) + const mcpClients = useAppState(s => s.mcp.clients); + const [selectKey, setSelectKey] = useState(0); + const [enabledByDefault, setEnabledByDefault] = useState(configEnabled ?? false); + const [showInstallHint, setShowInstallHint] = useState(false); + const [isExtensionInstalled, setIsExtensionInstalled] = useState(installed); - const isHomespace = process.env.USER_TYPE === 'ant' && isRunningOnHomespace() + const isHomespace = process.env.USER_TYPE === 'ant' && isRunningOnHomespace(); - const chromeClient = mcpClients.find( - c => c.name === CLAUDE_IN_CHROME_MCP_SERVER_NAME, - ) - const isConnected = chromeClient?.type === 'connected' + const chromeClient = mcpClients.find(c => c.name === CLAUDE_IN_CHROME_MCP_SERVER_NAME); + const isConnected = chromeClient?.type === 'connected'; function openUrl(url: string): void { if (isHomespace) { - void openBrowser(url) + void openBrowser(url); } else { - void openInChrome(url) + void openInChrome(url); } } function handleAction(action: MenuAction): void { switch (action) { case 'install-extension': - setSelectKey(k => k + 1) - setShowInstallHint(true) - openUrl(CHROME_EXTENSION_URL) - break + setSelectKey(k => k + 1); + setShowInstallHint(true); + openUrl(CHROME_EXTENSION_URL); + break; case 'reconnect': - setSelectKey(k => k + 1) + setSelectKey(k => k + 1); void isChromeExtensionInstalled().then(installed => { - setIsExtensionInstalled(installed) + setIsExtensionInstalled(installed); if (installed) { - setShowInstallHint(false) + setShowInstallHint(false); } - }) - openUrl(CHROME_RECONNECT_URL) - break + }); + openUrl(CHROME_RECONNECT_URL); + break; case 'manage-permissions': - setSelectKey(k => k + 1) - openUrl(CHROME_PERMISSIONS_URL) - break + setSelectKey(k => k + 1); + openUrl(CHROME_PERMISSIONS_URL); + break; case 'toggle-default': { - const newValue = !enabledByDefault + const newValue = !enabledByDefault; saveGlobalConfig(current => ({ ...current, claudeInChromeDefaultEnabled: newValue, - })) - setEnabledByDefault(newValue) - break + })); + setEnabledByDefault(newValue); + break; } } } - const options: OptionWithDescription[] = [] - const requiresExtensionSuffix = isExtensionInstalled - ? '' - : ' (requires extension)' + const options: OptionWithDescription[] = []; + const requiresExtensionSuffix = isExtensionInstalled ? '' : ' (requires extension)'; if (!isExtensionInstalled && !isHomespace) { options.push({ label: 'Install Chrome extension', value: 'install-extension', - }) + }); } options.push( @@ -133,36 +117,23 @@ function ClaudeInChromeMenu({ label: `Enabled by default: ${enabledByDefault ? 'Yes' : 'No'}`, value: 'toggle-default', }, - ) + ); - const isDisabled = - isWSL || ((process.env.USER_TYPE as string) !== 'ant' && !isClaudeAISubscriber) + const isDisabled = isWSL || ((process.env.USER_TYPE as string) !== 'ant' && !isClaudeAISubscriber); return ( - onDone()} - color="chromeYellow" - > + onDone()} color="chromeYellow"> - Claude in Chrome works with the Chrome extension to let you control - your browser directly from Claude Code. Navigate websites, fill forms, - capture screenshots, record GIFs, and debug with console logs and - network requests. + Claude in Chrome works with the Chrome extension to let you control your browser directly from Claude Code. + Navigate websites, fill forms, capture screenshots, record GIFs, and debug with console logs and network + requests. - {isWSL && ( - - Claude in Chrome is not supported in WSL at this time. - - )} - + {isWSL && Claude in Chrome is not supported in WSL at this time.} {(process.env.USER_TYPE as string) !== 'ant' && !isClaudeAISubscriber && ( - - Claude in Chrome requires a claude.ai subscription. - + Claude in Chrome requires a claude.ai subscription. )} {!isDisabled && ( @@ -170,12 +141,7 @@ function ClaudeInChromeMenu({ {!isHomespace && ( - Status:{' '} - {isConnected ? ( - Enabled - ) : ( - Disabled - )} + Status: {isConnected ? Enabled : Disabled} Extension:{' '} @@ -187,17 +153,10 @@ function ClaudeInChromeMenu({ )} - {showInstallHint && ( - - Once installed, select {'"Reconnect extension"'} to connect. - + Once installed, select {'"Reconnect extension"'} to connect. )} @@ -208,25 +167,22 @@ function ClaudeInChromeMenu({ - Site-level permissions are inherited from the Chrome extension. - Manage permissions in the Chrome extension settings to control - which sites Claude can browse, click, and type on. + Site-level permissions are inherited from the Chrome extension. Manage permissions in the Chrome extension + settings to control which sites Claude can browse, click, and type on. )} Learn more: https://code.claude.com/docs/en/chrome - ) + ); } -export const call = async function ( - onDone: (result?: string) => void, -): Promise { - const isExtensionInstalled = await isChromeExtensionInstalled() - const config = getGlobalConfig() - const isSubscriber = isClaudeAISubscriber() - const isWSL = env.isWslEnvironment() +export const call = async function (onDone: (result?: string) => void): Promise { + const isExtensionInstalled = await isChromeExtensionInstalled(); + const config = getGlobalConfig(); + const isSubscriber = isClaudeAISubscriber(); + const isWSL = env.isWslEnvironment(); return ( - ) -} + ); +}; diff --git a/src/commands/clear/caches.ts b/src/commands/clear/caches.ts index 8eacc0d6f..578b9297f 100644 --- a/src/commands/clear/caches.ts +++ b/src/commands/clear/caches.ts @@ -93,12 +93,12 @@ export function clearSessionCaches( // Clear tungsten session usage tracking if (process.env.USER_TYPE === 'ant') { - void import('@claude-code-best/builtin-tools/tools/TungstenTool/TungstenTool.js').then( - ({ clearSessionsWithTungstenUsage, resetInitializationState }) => { - clearSessionsWithTungstenUsage() - resetInitializationState() - }, - ) + void import( + '@claude-code-best/builtin-tools/tools/TungstenTool/TungstenTool.js' + ).then(({ clearSessionsWithTungstenUsage, resetInitializationState }) => { + clearSessionsWithTungstenUsage() + resetInitializationState() + }) } // Clear attribution caches (file content cache, pending bash states) // Dynamic import to preserve dead code elimination for COMMIT_ATTRIBUTION feature flag @@ -126,19 +126,21 @@ export function clearSessionCaches( // Clear session environment variables clearSessionEnvVars() // Clear WebFetch URL cache (up to 50MB of cached page content) - void import('@claude-code-best/builtin-tools/tools/WebFetchTool/utils.js').then( - ({ clearWebFetchCache }) => clearWebFetchCache(), - ) + void import( + '@claude-code-best/builtin-tools/tools/WebFetchTool/utils.js' + ).then(({ clearWebFetchCache }) => clearWebFetchCache()) // Clear ToolSearch description cache (full tool prompts, ~500KB for 50 MCP tools) - void import('@claude-code-best/builtin-tools/tools/ToolSearchTool/ToolSearchTool.js').then( - ({ clearToolSearchDescriptionCache }) => clearToolSearchDescriptionCache(), + void import( + '@claude-code-best/builtin-tools/tools/ToolSearchTool/ToolSearchTool.js' + ).then(({ clearToolSearchDescriptionCache }) => + clearToolSearchDescriptionCache(), ) // Clear agent definitions cache (accumulates per-cwd via EnterWorktreeTool) - void import('@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js').then( - ({ clearAgentDefinitionsCache }) => clearAgentDefinitionsCache(), - ) + void import( + '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js' + ).then(({ clearAgentDefinitionsCache }) => clearAgentDefinitionsCache()) // Clear SkillTool prompt cache (accumulates per project root) - void import('@claude-code-best/builtin-tools/tools/SkillTool/prompt.js').then(({ clearPromptCache }) => - clearPromptCache(), + void import('@claude-code-best/builtin-tools/tools/SkillTool/prompt.js').then( + ({ clearPromptCache }) => clearPromptCache(), ) } diff --git a/src/commands/compact/compact.ts b/src/commands/compact/compact.ts index 16a8b586d..b12f62a2a 100644 --- a/src/commands/compact/compact.ts +++ b/src/commands/compact/compact.ts @@ -224,7 +224,7 @@ async function compactViaReactive( context.setStreamMode?.('requesting') context.setResponseLength?.(() => 0) context.onCompactProgress?.({ type: 'compact_end' }) - context.setSDKStatus?.("" as SDKStatus) + context.setSDKStatus?.('' as SDKStatus) } } diff --git a/src/commands/compact/src/bootstrap/state.ts b/src/commands/compact/src/bootstrap/state.ts index a860c549e..9d8e08961 100644 --- a/src/commands/compact/src/bootstrap/state.ts +++ b/src/commands/compact/src/bootstrap/state.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type markPostCompaction = any; +export type markPostCompaction = any diff --git a/src/commands/config/config.tsx b/src/commands/config/config.tsx index d4e216c38..95796286c 100644 --- a/src/commands/config/config.tsx +++ b/src/commands/config/config.tsx @@ -1,7 +1,7 @@ -import * as React from 'react' -import { Settings } from '../../components/Settings/Settings.js' -import type { LocalJSXCommandCall } from '../../types/command.js' +import * as React from 'react'; +import { Settings } from '../../components/Settings/Settings.js'; +import type { LocalJSXCommandCall } from '../../types/command.js'; export const call: LocalJSXCommandCall = async (onDone, context) => { - return -} + return ; +}; diff --git a/src/commands/context/context.tsx b/src/commands/context/context.tsx index 747c5a9de..ab1cb6e93 100644 --- a/src/commands/context/context.tsx +++ b/src/commands/context/context.tsx @@ -1,13 +1,13 @@ -import { feature } from 'bun:bundle' -import * as React from 'react' -import type { LocalJSXCommandContext } from '../../commands.js' -import { ContextVisualization } from '../../components/ContextVisualization.js' -import { microcompactMessages } from '../../services/compact/microCompact.js' -import type { LocalJSXCommandOnDone } from '../../types/command.js' -import type { Message } from '../../types/message.js' -import { analyzeContextUsage } from '../../utils/analyzeContext.js' -import { getMessagesAfterCompactBoundary } from '../../utils/messages.js' -import { renderToAnsiString } from '../../utils/staticRender.js' +import { feature } from 'bun:bundle'; +import * as React from 'react'; +import type { LocalJSXCommandContext } from '../../commands.js'; +import { ContextVisualization } from '../../components/ContextVisualization.js'; +import { microcompactMessages } from '../../services/compact/microCompact.js'; +import type { LocalJSXCommandOnDone } from '../../types/command.js'; +import type { Message } from '../../types/message.js'; +import { analyzeContextUsage } from '../../utils/analyzeContext.js'; +import { getMessagesAfterCompactBoundary } from '../../utils/messages.js'; +import { renderToAnsiString } from '../../utils/staticRender.js'; /** * Apply the same context transforms query.ts does before the API call, so @@ -16,36 +16,33 @@ import { renderToAnsiString } from '../../utils/staticRender.js' * was collapsed — user sees "180k, 3 spans collapsed" when the API sees 120k. */ function toApiView(messages: Message[]): Message[] { - let view = getMessagesAfterCompactBoundary(messages) + let view = getMessagesAfterCompactBoundary(messages); if (feature('CONTEXT_COLLAPSE')) { /* eslint-disable @typescript-eslint/no-require-imports */ const { projectView } = - require('../../services/contextCollapse/operations.js') as typeof import('../../services/contextCollapse/operations.js') + require('../../services/contextCollapse/operations.js') as typeof import('../../services/contextCollapse/operations.js'); /* eslint-enable @typescript-eslint/no-require-imports */ - view = projectView(view) + view = projectView(view); } - return view + return view; } -export async function call( - onDone: LocalJSXCommandOnDone, - context: LocalJSXCommandContext, -): Promise { +export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext): Promise { const { messages, getAppState, options: { mainLoopModel, tools }, - } = context + } = context; - const apiView = toApiView(messages) + const apiView = toApiView(messages); // Apply microcompact to get accurate representation of messages sent to API - const { messages: compactedMessages } = await microcompactMessages(apiView) + const { messages: compactedMessages } = await microcompactMessages(apiView); // Get terminal width for responsive sizing - const terminalWidth = process.stdout.columns || 80 + const terminalWidth = process.stdout.columns || 80; - const appState = getAppState() + const appState = getAppState(); // Analyze context with compacted messages // Pass original messages as last parameter for accurate API usage extraction @@ -59,10 +56,10 @@ export async function call( context, // Pass full context for system prompt calculation undefined, // mainThreadAgentDefinition apiView, // Original messages for API usage extraction - ) + ); // Render to ANSI string to preserve colors and pass to onDone like local commands do - const output = await renderToAnsiString() - onDone(output) - return null + const output = await renderToAnsiString(); + onDone(output); + return null; } diff --git a/src/commands/copy/copy.tsx b/src/commands/copy/copy.tsx index e54171a63..2bab38f0d 100644 --- a/src/commands/copy/copy.tsx +++ b/src/commands/copy/copy.tsx @@ -1,39 +1,39 @@ -import { mkdir, writeFile } from 'fs/promises' -import { marked, type Tokens } from 'marked' -import { tmpdir } from 'os' -import { join } from 'path' -import React, { useRef } from 'react' -import type { CommandResultDisplay } from '../../commands.js' -import type { OptionWithDescription } from '../../components/CustomSelect/select.js' -import { Select } from '../../components/CustomSelect/select.js' -import { Byline, KeyboardShortcutHint, Pane } from '@anthropic/ink' -import { Box, setClipboard, Text, stringWidth, type KeyboardEvent } from '@anthropic/ink' -import { logEvent } from '../../services/analytics/index.js' -import type { LocalJSXCommandCall } from '../../types/command.js' -import type { AssistantMessage, Message } from '../../types/message.js' -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' -import { extractTextContent, stripPromptXMLTags } from '../../utils/messages.js' -import { countCharInString } from '../../utils/stringUtils.js' +import { mkdir, writeFile } from 'fs/promises'; +import { marked, type Tokens } from 'marked'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import React, { useRef } from 'react'; +import type { CommandResultDisplay } from '../../commands.js'; +import type { OptionWithDescription } from '../../components/CustomSelect/select.js'; +import { Select } from '../../components/CustomSelect/select.js'; +import { Byline, KeyboardShortcutHint, Pane } from '@anthropic/ink'; +import { Box, setClipboard, Text, stringWidth, type KeyboardEvent } from '@anthropic/ink'; +import { logEvent } from '../../services/analytics/index.js'; +import type { LocalJSXCommandCall } from '../../types/command.js'; +import type { AssistantMessage, Message } from '../../types/message.js'; +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; +import { extractTextContent, stripPromptXMLTags } from '../../utils/messages.js'; +import { countCharInString } from '../../utils/stringUtils.js'; -const COPY_DIR = join(tmpdir(), 'claude') -const RESPONSE_FILENAME = 'response.md' -const MAX_LOOKBACK = 20 +const COPY_DIR = join(tmpdir(), 'claude'); +const RESPONSE_FILENAME = 'response.md'; +const MAX_LOOKBACK = 20; type CodeBlock = { - code: string - lang: string | undefined -} + code: string; + lang: string | undefined; +}; function extractCodeBlocks(markdown: string): CodeBlock[] { - const tokens = marked.lexer(stripPromptXMLTags(markdown)) - const blocks: CodeBlock[] = [] + const tokens = marked.lexer(stripPromptXMLTags(markdown)); + const blocks: CodeBlock[] = []; for (const token of tokens) { if (token.type === 'code') { - const codeToken = token as Tokens.Code - blocks.push({ code: codeToken.text, lang: codeToken.lang }) + const codeToken = token as Tokens.Code; + blocks.push({ code: codeToken.text, lang: codeToken.lang }); } } - return blocks + return blocks; } /** @@ -42,95 +42,80 @@ function extractCodeBlocks(markdown: string): CodeBlock[] { * Index 0 = latest, 1 = second-to-latest, etc. Caps at MAX_LOOKBACK. */ export function collectRecentAssistantTexts(messages: Message[]): string[] { - const texts: string[] = [] - for ( - let i = messages.length - 1; - i >= 0 && texts.length < MAX_LOOKBACK; - i-- - ) { - const msg = messages[i] - if (msg?.type !== 'assistant' || msg.isApiErrorMessage) continue - const content = (msg as AssistantMessage).message.content - if (!Array.isArray(content)) continue - const text = extractTextContent(content, '\n\n') - if (text) texts.push(text) + const texts: string[] = []; + for (let i = messages.length - 1; i >= 0 && texts.length < MAX_LOOKBACK; i--) { + const msg = messages[i]; + if (msg?.type !== 'assistant' || msg.isApiErrorMessage) continue; + const content = (msg as AssistantMessage).message.content; + if (!Array.isArray(content)) continue; + const text = extractTextContent(content, '\n\n'); + if (text) texts.push(text); } - return texts + return texts; } export function fileExtension(lang: string | undefined): string { if (lang) { // Sanitize to prevent path traversal (e.g. ```../../etc/passwd) // Language identifiers are alphanumeric: python, tsx, jsonc, etc. - const sanitized = lang.replace(/[^a-zA-Z0-9]/g, '') + const sanitized = lang.replace(/[^a-zA-Z0-9]/g, ''); if (sanitized && sanitized !== 'plaintext') { - return `.${sanitized}` + return `.${sanitized}`; } } - return '.txt' + return '.txt'; } async function writeToFile(text: string, filename: string): Promise { - const filePath = join(COPY_DIR, filename) - await mkdir(COPY_DIR, { recursive: true }) - await writeFile(filePath, text, 'utf-8') - return filePath + const filePath = join(COPY_DIR, filename); + await mkdir(COPY_DIR, { recursive: true }); + await writeFile(filePath, text, 'utf-8'); + return filePath; } -async function copyOrWriteToFile( - text: string, - filename: string, -): Promise { - const raw = await setClipboard(text) - if (raw) process.stdout.write(raw) - const lineCount = countCharInString(text, '\n') + 1 - const charCount = text.length +async function copyOrWriteToFile(text: string, filename: string): Promise { + const raw = await setClipboard(text); + if (raw) process.stdout.write(raw); + const lineCount = countCharInString(text, '\n') + 1; + const charCount = text.length; // Also write to a temp file — clipboard paths are best-effort (OSC 52 needs // terminal support), so the file provides a reliable fallback. try { - const filePath = await writeToFile(text, filename) - return `Copied to clipboard (${charCount} characters, ${lineCount} lines)\nAlso written to ${filePath}` + const filePath = await writeToFile(text, filename); + return `Copied to clipboard (${charCount} characters, ${lineCount} lines)\nAlso written to ${filePath}`; } catch { - return `Copied to clipboard (${charCount} characters, ${lineCount} lines)` + return `Copied to clipboard (${charCount} characters, ${lineCount} lines)`; } } function truncateLine(text: string, maxLen: number): string { - const firstLine = text.split('\n')[0] ?? '' + const firstLine = text.split('\n')[0] ?? ''; if (stringWidth(firstLine) <= maxLen) { - return firstLine + return firstLine; } - let result = '' - let width = 0 - const targetWidth = maxLen - 1 + let result = ''; + let width = 0; + const targetWidth = maxLen - 1; for (const char of firstLine) { - const charWidth = stringWidth(char) - if (width + charWidth > targetWidth) break - result += char - width += charWidth + const charWidth = stringWidth(char); + if (width + charWidth > targetWidth) break; + result += char; + width += charWidth; } - return result + '\u2026' + return result + '\u2026'; } type PickerProps = { - fullText: string - codeBlocks: CodeBlock[] - messageAge: number - onDone: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void -} + fullText: string; + codeBlocks: CodeBlock[]; + messageAge: number; + onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void; +}; -type PickerSelection = number | 'full' | 'always' +type PickerSelection = number | 'full' | 'always'; -function CopyPicker({ - fullText, - codeBlocks, - messageAge, - onDone, -}: PickerProps): React.ReactNode { - const focusedRef = useRef('full') +function CopyPicker({ fullText, codeBlocks, messageAge, onDone }: PickerProps): React.ReactNode { + const focusedRef = useRef('full'); const options: OptionWithDescription[] = [ { @@ -139,109 +124,99 @@ function CopyPicker({ description: `${fullText.length} chars, ${countCharInString(fullText, '\n') + 1} lines`, }, ...codeBlocks.map((block, index) => { - const blockLines = countCharInString(block.code, '\n') + 1 + const blockLines = countCharInString(block.code, '\n') + 1; return { label: truncateLine(block.code, 60), value: index, description: - [block.lang, blockLines > 1 ? `${blockLines} lines` : undefined] - .filter(Boolean) - .join(', ') || undefined, - } + [block.lang, blockLines > 1 ? `${blockLines} lines` : undefined].filter(Boolean).join(', ') || undefined, + }; }), { label: 'Always copy full response', value: 'always' as const, description: 'Skip this picker in the future (revert via /config)', }, - ] + ]; function getSelectionContent(selected: PickerSelection): { - text: string - filename: string - blockIndex?: number + text: string; + filename: string; + blockIndex?: number; } { if (selected === 'full' || selected === 'always') { - return { text: fullText, filename: RESPONSE_FILENAME } + return { text: fullText, filename: RESPONSE_FILENAME }; } - const block = codeBlocks[selected]! + const block = codeBlocks[selected]!; return { text: block.code, filename: `copy${fileExtension(block.lang)}`, blockIndex: selected, - } + }; } async function handleSelect(selected: PickerSelection): Promise { - const content = getSelectionContent(selected) + const content = getSelectionContent(selected); if (selected === 'always') { if (!getGlobalConfig().copyFullResponse) { - saveGlobalConfig(c => ({ ...c, copyFullResponse: true })) + saveGlobalConfig(c => ({ ...c, copyFullResponse: true })); } logEvent('tengu_copy', { block_count: codeBlocks.length, always: true, message_age: messageAge, - }) - const result = await copyOrWriteToFile(content.text, content.filename) - onDone( - `${result}\nPreference saved. Use /config to change copyFullResponse`, - ) - return + }); + const result = await copyOrWriteToFile(content.text, content.filename); + onDone(`${result}\nPreference saved. Use /config to change copyFullResponse`); + return; } logEvent('tengu_copy', { selected_block: content.blockIndex, block_count: codeBlocks.length, message_age: messageAge, - }) - const result = await copyOrWriteToFile(content.text, content.filename) - onDone(result) + }); + const result = await copyOrWriteToFile(content.text, content.filename); + onDone(result); } async function handleWrite(selected: PickerSelection): Promise { - const content = getSelectionContent(selected) + const content = getSelectionContent(selected); logEvent('tengu_copy', { selected_block: content.blockIndex, block_count: codeBlocks.length, message_age: messageAge, write_shortcut: true, - }) + }); try { - const filePath = await writeToFile(content.text, content.filename) - onDone(`Written to ${filePath}`) + const filePath = await writeToFile(content.text, content.filename); + onDone(`Written to ${filePath}`); } catch (e) { - onDone(`Failed to write file: ${e instanceof Error ? e.message : e}`) + onDone(`Failed to write file: ${e instanceof Error ? e.message : e}`); } } function handleKeyDown(e: KeyboardEvent): void { if (e.key === 'w') { - e.preventDefault() - void handleWrite(focusedRef.current) + e.preventDefault(); + void handleWrite(focusedRef.current); } } return ( - + Select content to copy: options={options} hideIndexes={false} onFocus={value => { - focusedRef.current = value + focusedRef.current = value; }} onChange={selected => { - void handleSelect(selected) + void handleSelect(selected); }} onCancel={() => { - onDone('Copy cancelled', { display: 'system' }) + onDone('Copy cancelled', { display: 'system' }); }} /> @@ -253,56 +228,47 @@ function CopyPicker({ - ) + ); } export const call: LocalJSXCommandCall = async (onDone, context, args) => { - const texts = collectRecentAssistantTexts(context.messages) + const texts = collectRecentAssistantTexts(context.messages); if (texts.length === 0) { - onDone('No assistant message to copy') - return null + onDone('No assistant message to copy'); + return null; } // /copy N reaches back N-1 messages (1 = latest, 2 = second-to-latest, ...) - let age = 0 - const arg = args?.trim() + let age = 0; + const arg = args?.trim(); if (arg) { - const n = Number(arg) + const n = Number(arg); if (!Number.isInteger(n) || n < 1) { - onDone(`Usage: /copy [N] where N is 1 (latest), 2, 3, \u2026 Got: ${arg}`) - return null + onDone(`Usage: /copy [N] where N is 1 (latest), 2, 3, \u2026 Got: ${arg}`); + return null; } if (n > texts.length) { - onDone( - `Only ${texts.length} assistant ${texts.length === 1 ? 'message' : 'messages'} available to copy`, - ) - return null + onDone(`Only ${texts.length} assistant ${texts.length === 1 ? 'message' : 'messages'} available to copy`); + return null; } - age = n - 1 + age = n - 1; } - const text = texts[age]! - const codeBlocks = extractCodeBlocks(text) - const config = getGlobalConfig() + const text = texts[age]!; + const codeBlocks = extractCodeBlocks(text); + const config = getGlobalConfig(); if (codeBlocks.length === 0 || config.copyFullResponse) { logEvent('tengu_copy', { always: config.copyFullResponse, block_count: codeBlocks.length, message_age: age, - }) - const result = await copyOrWriteToFile(text, RESPONSE_FILENAME) - onDone(result) - return null + }); + const result = await copyOrWriteToFile(text, RESPONSE_FILENAME); + onDone(result); + return null; } - return ( - - ) -} + return ; +}; diff --git a/src/commands/daemon/daemon.tsx b/src/commands/daemon/daemon.tsx index b72cc3c7a..f4bd14759 100644 --- a/src/commands/daemon/daemon.tsx +++ b/src/commands/daemon/daemon.tsx @@ -1,7 +1,4 @@ -import type { - LocalJSXCommandOnDone, - LocalJSXCommandContext, -} from '../../types/command.js' +import type { LocalJSXCommandOnDone, LocalJSXCommandContext } from '../../types/command.js'; /** * /daemon slash command — manages daemon and background sessions from the REPL. @@ -14,44 +11,41 @@ export async function call( _context: LocalJSXCommandContext, args: string, ): Promise { - const parts = args ? args.trim().split(/\s+/) : [] - const sub = parts[0] || 'status' + const parts = args ? args.trim().split(/\s+/) : []; + const sub = parts[0] || 'status'; // attach is interactive/blocking — not available inside the REPL if (sub === 'attach') { - onDone( - 'Use `claude daemon attach` from the CLI. Attach is not available inside the REPL.', - { display: 'system' }, - ) - return null + onDone('Use `claude daemon attach` from the CLI. Attach is not available inside the REPL.', { display: 'system' }); + return null; } // For all other subcommands, capture console output and return via onDone const lines = await captureConsole(async () => { if (sub === 'bg') { - const bg = await import('../../cli/bg.js') - await bg.handleBgStart(parts.slice(1)) + const bg = await import('../../cli/bg.js'); + await bg.handleBgStart(parts.slice(1)); } else { - const { daemonMain } = await import('../../daemon/main.js') - await daemonMain([sub, ...parts.slice(1)]) + const { daemonMain } = await import('../../daemon/main.js'); + await daemonMain([sub, ...parts.slice(1)]); } - }) + }); - onDone(lines.join('\n') || 'Done.', { display: 'system' }) - return null + onDone(lines.join('\n') || 'Done.', { display: 'system' }); + return null; } async function captureConsole(fn: () => Promise): Promise { - const lines: string[] = [] - const origLog = console.log - const origError = console.error - console.log = (...a: unknown[]) => lines.push(a.map(String).join(' ')) - console.error = (...a: unknown[]) => lines.push(a.map(String).join(' ')) + const lines: string[] = []; + const origLog = console.log; + const origError = console.error; + console.log = (...a: unknown[]) => lines.push(a.map(String).join(' ')); + console.error = (...a: unknown[]) => lines.push(a.map(String).join(' ')); try { - await fn() + await fn(); } finally { - console.log = origLog - console.error = origError + console.log = origLog; + console.error = origError; } - return lines + return lines; } diff --git a/src/commands/desktop/desktop.tsx b/src/commands/desktop/desktop.tsx index b601be32d..6e45ea47f 100644 --- a/src/commands/desktop/desktop.tsx +++ b/src/commands/desktop/desktop.tsx @@ -1,12 +1,9 @@ -import React from 'react' -import type { CommandResultDisplay } from '../../commands.js' -import { DesktopHandoff } from '../../components/DesktopHandoff.js' +import React from 'react'; +import type { CommandResultDisplay } from '../../commands.js'; +import { DesktopHandoff } from '../../components/DesktopHandoff.js'; export async function call( - onDone: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void, + onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void, ): Promise { - return + return ; } diff --git a/src/commands/diff/diff.tsx b/src/commands/diff/diff.tsx index cc3a41dbb..b86764648 100644 --- a/src/commands/diff/diff.tsx +++ b/src/commands/diff/diff.tsx @@ -1,7 +1,7 @@ -import * as React from 'react' -import type { LocalJSXCommandCall } from '../../types/command.js' +import * as React from 'react'; +import type { LocalJSXCommandCall } from '../../types/command.js'; export const call: LocalJSXCommandCall = async (onDone, context) => { - const { DiffDialog } = await import('../../components/diff/DiffDialog.js') - return -} + const { DiffDialog } = await import('../../components/diff/DiffDialog.js'); + return ; +}; diff --git a/src/commands/doctor/doctor.tsx b/src/commands/doctor/doctor.tsx index e696f0955..aedbc407d 100644 --- a/src/commands/doctor/doctor.tsx +++ b/src/commands/doctor/doctor.tsx @@ -1,7 +1,7 @@ -import React from 'react' -import { Doctor } from '../../screens/Doctor.js' -import type { LocalJSXCommandCall } from '../../types/command.js' +import React from 'react'; +import { Doctor } from '../../screens/Doctor.js'; +import type { LocalJSXCommandCall } from '../../types/command.js'; export const call: LocalJSXCommandCall = (onDone, _context, _args) => { - return Promise.resolve() -} + return Promise.resolve(); +}; diff --git a/src/commands/effort/effort.tsx b/src/commands/effort/effort.tsx index 2804233d0..96bb66d27 100644 --- a/src/commands/effort/effort.tsx +++ b/src/commands/effort/effort.tsx @@ -1,11 +1,11 @@ -import * as React from 'react' -import { useMainLoopModel } from '../../hooks/useMainLoopModel.js' +import * as React from 'react'; +import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from '../../services/analytics/index.js' -import { useAppState, useSetAppState } from '../../state/AppState.js' -import type { LocalJSXCommandOnDone } from '../../types/command.js' +} from '../../services/analytics/index.js'; +import { useAppState, useSetAppState } from '../../state/AppState.js'; +import type { LocalJSXCommandOnDone } from '../../types/command.js'; import { type EffortValue, getDisplayedEffortLevel, @@ -13,171 +13,157 @@ import { getEffortValueDescription, isEffortLevel, toPersistableEffort, -} from '../../utils/effort.js' -import { updateSettingsForSource } from '../../utils/settings/settings.js' +} from '../../utils/effort.js'; +import { updateSettingsForSource } from '../../utils/settings/settings.js'; -const COMMON_HELP_ARGS = ['help', '-h', '--help'] +const COMMON_HELP_ARGS = ['help', '-h', '--help']; type EffortCommandResult = { - message: string - effortUpdate?: { value: EffortValue | undefined } -} + message: string; + effortUpdate?: { value: EffortValue | undefined }; +}; function setEffortValue(effortValue: EffortValue): EffortCommandResult { - const persistable = toPersistableEffort(effortValue) + const persistable = toPersistableEffort(effortValue); if (persistable !== undefined) { const result = updateSettingsForSource('userSettings', { effortLevel: persistable, - }) + }); if (result.error) { return { message: `Failed to set effort level: ${result.error.message}`, - } + }; } } logEvent('tengu_effort_command', { - effort: - effortValue as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + effort: effortValue as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); // Env var wins at resolveAppliedEffort time. Only flag it when it actually // conflicts — if env matches what the user just asked for, the outcome is // the same, so "Set effort to X" is true and the note is noise. - const envOverride = getEffortEnvOverride() + const envOverride = getEffortEnvOverride(); if (envOverride !== undefined && envOverride !== effortValue) { - const envRaw = process.env.CLAUDE_CODE_EFFORT_LEVEL + const envRaw = process.env.CLAUDE_CODE_EFFORT_LEVEL; if (persistable === undefined) { return { message: `Not applied: CLAUDE_CODE_EFFORT_LEVEL=${envRaw} overrides effort this session, and ${effortValue} is session-only (nothing saved)`, effortUpdate: { value: effortValue }, - } + }; } return { message: `CLAUDE_CODE_EFFORT_LEVEL=${envRaw} overrides this session — clear it and ${effortValue} takes over`, effortUpdate: { value: effortValue }, - } + }; } - const description = getEffortValueDescription(effortValue) - const suffix = persistable !== undefined ? '' : ' (this session only)' + const description = getEffortValueDescription(effortValue); + const suffix = persistable !== undefined ? '' : ' (this session only)'; return { message: `Set effort level to ${effortValue}${suffix}: ${description}`, effortUpdate: { value: effortValue }, - } + }; } -export function showCurrentEffort( - appStateEffort: EffortValue | undefined, - model: string, -): EffortCommandResult { - const envOverride = getEffortEnvOverride() - const effectiveValue = - envOverride === null ? undefined : (envOverride ?? appStateEffort) +export function showCurrentEffort(appStateEffort: EffortValue | undefined, model: string): EffortCommandResult { + const envOverride = getEffortEnvOverride(); + const effectiveValue = envOverride === null ? undefined : (envOverride ?? appStateEffort); if (effectiveValue === undefined) { - const level = getDisplayedEffortLevel(model, appStateEffort) - return { message: `Effort level: auto (currently ${level})` } + const level = getDisplayedEffortLevel(model, appStateEffort); + return { message: `Effort level: auto (currently ${level})` }; } - const description = getEffortValueDescription(effectiveValue) + const description = getEffortValueDescription(effectiveValue); return { message: `Current effort level: ${effectiveValue} (${description})`, - } + }; } function unsetEffortLevel(): EffortCommandResult { const result = updateSettingsForSource('userSettings', { effortLevel: undefined, - }) + }); if (result.error) { return { message: `Failed to set effort level: ${result.error.message}`, - } + }; } logEvent('tengu_effort_command', { - effort: - 'auto' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + effort: 'auto' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); // env=auto/unset (null) matches what /effort auto asks for, so only warn // when env is pinning a specific level that will keep overriding. - const envOverride = getEffortEnvOverride() + const envOverride = getEffortEnvOverride(); if (envOverride !== undefined && envOverride !== null) { - const envRaw = process.env.CLAUDE_CODE_EFFORT_LEVEL + const envRaw = process.env.CLAUDE_CODE_EFFORT_LEVEL; return { message: `Cleared effort from settings, but CLAUDE_CODE_EFFORT_LEVEL=${envRaw} still controls this session`, effortUpdate: { value: undefined }, - } + }; } return { message: 'Effort level set to auto', effortUpdate: { value: undefined }, - } + }; } export function executeEffort(args: string): EffortCommandResult { - const normalized = args.toLowerCase() + const normalized = args.toLowerCase(); if (normalized === 'auto' || normalized === 'unset') { - return unsetEffortLevel() + return unsetEffortLevel(); } if (!isEffortLevel(normalized)) { return { message: `Invalid argument: ${args}. Valid options are: low, medium, high, max, auto`, - } + }; } - return setEffortValue(normalized) + return setEffortValue(normalized); } -function ShowCurrentEffort({ - onDone, -}: { - onDone: (result: string) => void -}): React.ReactNode { - const effortValue = useAppState(s => s.effortValue) - const model = useMainLoopModel() - const { message } = showCurrentEffort(effortValue, model) - onDone(message) - return null +function ShowCurrentEffort({ onDone }: { onDone: (result: string) => void }): React.ReactNode { + const effortValue = useAppState(s => s.effortValue); + const model = useMainLoopModel(); + const { message } = showCurrentEffort(effortValue, model); + onDone(message); + return null; } function ApplyEffortAndClose({ result, onDone, }: { - result: EffortCommandResult - onDone: (result: string) => void + result: EffortCommandResult; + onDone: (result: string) => void; }): React.ReactNode { - const setAppState = useSetAppState() - const { effortUpdate, message } = result + const setAppState = useSetAppState(); + const { effortUpdate, message } = result; React.useEffect(() => { if (effortUpdate) { setAppState(prev => ({ ...prev, effortValue: effortUpdate.value, - })) + })); } - onDone(message) - }, [setAppState, effortUpdate, message, onDone]) - return null + onDone(message); + }, [setAppState, effortUpdate, message, onDone]); + return null; } -export async function call( - onDone: LocalJSXCommandOnDone, - _context: unknown, - args?: string, -): Promise { - args = args?.trim() || '' +export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, args?: string): Promise { + args = args?.trim() || ''; if (COMMON_HELP_ARGS.includes(args)) { onDone( 'Usage: /effort [low|medium|high|max|auto]\n\nEffort levels:\n- low: Quick, straightforward implementation\n- medium: Balanced approach with standard testing\n- high: Comprehensive implementation with extensive testing\n- max: Maximum capability with deepest reasoning (Opus 4.6 only)\n- auto: Use the default effort level for your model', - ) - return + ); + return; } if (!args || args === 'current' || args === 'status') { - return + return ; } - const result = executeEffort(args) - return + const result = executeEffort(args); + return ; } diff --git a/src/commands/exit/exit.tsx b/src/commands/exit/exit.tsx index 64e9ed77c..1af3dd796 100644 --- a/src/commands/exit/exit.tsx +++ b/src/commands/exit/exit.tsx @@ -1,44 +1,36 @@ -import { feature } from 'bun:bundle' -import { spawnSync } from 'child_process' -import sample from 'lodash-es/sample.js' -import * as React from 'react' -import { ExitFlow } from '../../components/ExitFlow.js' -import type { LocalJSXCommandOnDone } from '../../types/command.js' -import { isBgSession } from '../../utils/concurrentSessions.js' -import { gracefulShutdown } from '../../utils/gracefulShutdown.js' -import { getCurrentWorktreeSession } from '../../utils/worktree.js' +import { feature } from 'bun:bundle'; +import { spawnSync } from 'child_process'; +import sample from 'lodash-es/sample.js'; +import * as React from 'react'; +import { ExitFlow } from '../../components/ExitFlow.js'; +import type { LocalJSXCommandOnDone } from '../../types/command.js'; +import { isBgSession } from '../../utils/concurrentSessions.js'; +import { gracefulShutdown } from '../../utils/gracefulShutdown.js'; +import { getCurrentWorktreeSession } from '../../utils/worktree.js'; -const GOODBYE_MESSAGES = ['Goodbye!', 'See ya!', 'Bye!', 'Catch you later!'] +const GOODBYE_MESSAGES = ['Goodbye!', 'See ya!', 'Bye!', 'Catch you later!']; function getRandomGoodbyeMessage(): string { - return sample(GOODBYE_MESSAGES) ?? 'Goodbye!' + return sample(GOODBYE_MESSAGES) ?? 'Goodbye!'; } -export async function call( - onDone: LocalJSXCommandOnDone, -): Promise { +export async function call(onDone: LocalJSXCommandOnDone): Promise { // Inside a `claude --bg` tmux session: detach instead of kill. The REPL // keeps running; `claude attach` can reconnect. Covers /exit, /quit, // ctrl+c, ctrl+d — all funnel through here via REPL's handleExit. if (feature('BG_SESSIONS') && isBgSession()) { - onDone() - spawnSync('tmux', ['detach-client'], { stdio: 'ignore' }) - return null + onDone(); + spawnSync('tmux', ['detach-client'], { stdio: 'ignore' }); + return null; } - const showWorktree = getCurrentWorktreeSession() !== null + const showWorktree = getCurrentWorktreeSession() !== null; if (showWorktree) { - return ( - onDone()} - /> - ) + return onDone()} />; } - onDone(getRandomGoodbyeMessage()) - await gracefulShutdown(0, 'prompt_input_exit') - return null + onDone(getRandomGoodbyeMessage()); + await gracefulShutdown(0, 'prompt_input_exit'); + return null; } diff --git a/src/commands/export/export.tsx b/src/commands/export/export.tsx index d13436b02..2b9e24f7f 100644 --- a/src/commands/export/export.tsx +++ b/src/commands/export/export.tsx @@ -1,49 +1,49 @@ -import { join } from 'path' -import React from 'react' -import { ExportDialog } from '../../components/ExportDialog.js' -import type { ToolUseContext } from '../../Tool.js' -import type { LocalJSXCommandOnDone } from '../../types/command.js' -import type { Message } from '../../types/message.js' -import { getCwd } from '../../utils/cwd.js' -import { renderMessagesToPlainText } from '../../utils/exportRenderer.js' -import { writeFileSync_DEPRECATED } from '../../utils/slowOperations.js' +import { join } from 'path'; +import React from 'react'; +import { ExportDialog } from '../../components/ExportDialog.js'; +import type { ToolUseContext } from '../../Tool.js'; +import type { LocalJSXCommandOnDone } from '../../types/command.js'; +import type { Message } from '../../types/message.js'; +import { getCwd } from '../../utils/cwd.js'; +import { renderMessagesToPlainText } from '../../utils/exportRenderer.js'; +import { writeFileSync_DEPRECATED } from '../../utils/slowOperations.js'; function formatTimestamp(date: Date): string { - const year = date.getFullYear() - const month = String(date.getMonth() + 1).padStart(2, '0') - const day = String(date.getDate()).padStart(2, '0') - const hours = String(date.getHours()).padStart(2, '0') - const minutes = String(date.getMinutes()).padStart(2, '0') - const seconds = String(date.getSeconds()).padStart(2, '0') - return `${year}-${month}-${day}-${hours}${minutes}${seconds}` + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + const hours = String(date.getHours()).padStart(2, '0'); + const minutes = String(date.getMinutes()).padStart(2, '0'); + const seconds = String(date.getSeconds()).padStart(2, '0'); + return `${year}-${month}-${day}-${hours}${minutes}${seconds}`; } export function extractFirstPrompt(messages: Message[]): string { - const firstUserMessage = messages.find(msg => msg.type === 'user') + const firstUserMessage = messages.find(msg => msg.type === 'user'); if (!firstUserMessage || firstUserMessage.type !== 'user') { - return '' + return ''; } - const content = firstUserMessage.message?.content - let result = '' + const content = firstUserMessage.message?.content; + let result = ''; if (typeof content === 'string') { - result = content.trim() + result = content.trim(); } else if (Array.isArray(content)) { - const textContent = content.find(item => item.type === 'text') + const textContent = content.find(item => item.type === 'text'); if (textContent && 'text' in textContent) { - result = textContent.text.trim() + result = textContent.text.trim(); } } // Take first line only and limit length - result = result.split('\n')[0] || '' + result = result.split('\n')[0] || ''; if (result.length > 50) { - result = result.substring(0, 49) + '…' + result = result.substring(0, 49) + '…'; } - return result + return result; } export function sanitizeFilename(text: string): string { @@ -53,14 +53,12 @@ export function sanitizeFilename(text: string): string { .replace(/[^a-z0-9\s-]/g, '') // Remove special chars .replace(/\s+/g, '-') // Replace spaces with hyphens .replace(/-+/g, '-') // Replace multiple hyphens with single - .replace(/^-|-$/g, '') // Remove leading/trailing hyphens + .replace(/^-|-$/g, ''); // Remove leading/trailing hyphens } -async function exportWithReactRenderer( - context: ToolUseContext, -): Promise { - const tools = context.options.tools || [] - return renderMessagesToPlainText(context.messages, tools) +async function exportWithReactRenderer(context: ToolUseContext): Promise { + const tools = context.options.tools || []; + return renderMessagesToPlainText(context.messages, tools); } export async function call( @@ -69,43 +67,37 @@ export async function call( args: string, ): Promise { // Render the conversation content - const content = await exportWithReactRenderer(context) + const content = await exportWithReactRenderer(context); // If args are provided, write directly to file and skip dialog - const filename = args.trim() + const filename = args.trim(); if (filename) { - const finalFilename = filename.endsWith('.txt') - ? filename - : filename.replace(/\.[^.]+$/, '') + '.txt' - const filepath = join(getCwd(), finalFilename) + const finalFilename = filename.endsWith('.txt') ? filename : filename.replace(/\.[^.]+$/, '') + '.txt'; + const filepath = join(getCwd(), finalFilename); try { writeFileSync_DEPRECATED(filepath, content, { encoding: 'utf-8', flush: true, - }) - onDone(`Conversation exported to: ${filepath}`) - return null + }); + onDone(`Conversation exported to: ${filepath}`); + return null; } catch (error) { - onDone( - `Failed to export conversation: ${error instanceof Error ? error.message : 'Unknown error'}`, - ) - return null + onDone(`Failed to export conversation: ${error instanceof Error ? error.message : 'Unknown error'}`); + return null; } } // Generate default filename from first prompt or timestamp - const firstPrompt = extractFirstPrompt(context.messages) - const timestamp = formatTimestamp(new Date()) + const firstPrompt = extractFirstPrompt(context.messages); + const timestamp = formatTimestamp(new Date()); - let defaultFilename: string + let defaultFilename: string; if (firstPrompt) { - const sanitized = sanitizeFilename(firstPrompt) - defaultFilename = sanitized - ? `${timestamp}-${sanitized}.txt` - : `conversation-${timestamp}.txt` + const sanitized = sanitizeFilename(firstPrompt); + defaultFilename = sanitized ? `${timestamp}-${sanitized}.txt` : `conversation-${timestamp}.txt`; } else { - defaultFilename = `conversation-${timestamp}.txt` + defaultFilename = `conversation-${timestamp}.txt`; } // Return the dialog component when no args provided @@ -114,8 +106,8 @@ export async function call( content={content} defaultFilename={defaultFilename} onDone={result => { - onDone(result.message) + onDone(result.message); }} /> - ) + ); } diff --git a/src/commands/extra-usage/extra-usage.tsx b/src/commands/extra-usage/extra-usage.tsx index 4bdb6284b..174b943a1 100644 --- a/src/commands/extra-usage/extra-usage.tsx +++ b/src/commands/extra-usage/extra-usage.tsx @@ -1,29 +1,27 @@ -import React from 'react' -import type { LocalJSXCommandContext } from '../../commands.js' -import type { LocalJSXCommandOnDone } from '../../types/command.js' -import { Login } from '../login/login.js' -import { runExtraUsage } from './extra-usage-core.js' +import React from 'react'; +import type { LocalJSXCommandContext } from '../../commands.js'; +import type { LocalJSXCommandOnDone } from '../../types/command.js'; +import { Login } from '../login/login.js'; +import { runExtraUsage } from './extra-usage-core.js'; export async function call( onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext, ): Promise { - const result = await runExtraUsage() + const result = await runExtraUsage(); if (result.type === 'message') { - onDone(result.value) - return null + onDone(result.value); + return null; } return ( { - context.onChangeAPIKey() - onDone(success ? 'Login successful' : 'Login interrupted') + context.onChangeAPIKey(); + onDone(success ? 'Login successful' : 'Login interrupted'); }} /> - ) + ); } diff --git a/src/commands/fast/fast.tsx b/src/commands/fast/fast.tsx index 2ab17db78..8d4343db9 100644 --- a/src/commands/fast/fast.tsx +++ b/src/commands/fast/fast.tsx @@ -1,23 +1,16 @@ -import * as React from 'react' -import { useState } from 'react' -import type { - CommandResultDisplay, - LocalJSXCommandContext, -} from '../../commands.js' -import { Dialog } from '@anthropic/ink' -import { FastIcon, getFastIconString } from '../../components/FastIcon.js' -import { Box, Link, Text } from '@anthropic/ink' -import { useKeybindings } from '../../keybindings/useKeybinding.js' +import * as React from 'react'; +import { useState } from 'react'; +import type { CommandResultDisplay, LocalJSXCommandContext } from '../../commands.js'; +import { Dialog } from '@anthropic/ink'; +import { FastIcon, getFastIconString } from '../../components/FastIcon.js'; +import { Box, Link, Text } from '@anthropic/ink'; +import { useKeybindings } from '../../keybindings/useKeybinding.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from '../../services/analytics/index.js' -import { - type AppState, - useAppState, - useSetAppState, -} from '../../state/AppState.js' -import type { LocalJSXCommandOnDone } from '../../types/command.js' +} from '../../services/analytics/index.js'; +import { type AppState, useAppState, useSetAppState } from '../../state/AppState.js'; +import type { LocalJSXCommandOnDone } from '../../types/command.js'; import { clearFastModeCooldown, FAST_MODE_MODEL_DISPLAY, @@ -27,33 +20,28 @@ import { isFastModeEnabled, isFastModeSupportedByModel, prefetchFastModeStatus, -} from '../../utils/fastMode.js' -import { formatDuration } from '../../utils/format.js' -import { formatModelPricing, getOpus46CostTier } from '../../utils/modelCost.js' -import { updateSettingsForSource } from '../../utils/settings/settings.js' +} from '../../utils/fastMode.js'; +import { formatDuration } from '../../utils/format.js'; +import { formatModelPricing, getOpus46CostTier } from '../../utils/modelCost.js'; +import { updateSettingsForSource } from '../../utils/settings/settings.js'; -function applyFastMode( - enable: boolean, - setAppState: (f: (prev: AppState) => AppState) => void, -): void { - clearFastModeCooldown() +function applyFastMode(enable: boolean, setAppState: (f: (prev: AppState) => AppState) => void): void { + clearFastModeCooldown(); updateSettingsForSource('userSettings', { fastMode: enable ? true : undefined, - }) + }); if (enable) { setAppState(prev => { // Only switch model if current model doesn't support fast mode - const needsModelSwitch = !isFastModeSupportedByModel(prev.mainLoopModel) + const needsModelSwitch = !isFastModeSupportedByModel(prev.mainLoopModel); return { ...prev, - ...(needsModelSwitch - ? { mainLoopModel: getFastModeModel(), mainLoopModelForSession: null } - : {}), + ...(needsModelSwitch ? { mainLoopModel: getFastModeModel(), mainLoopModelForSession: null } : {}), fastMode: true, - } - }) + }; + }); } else { - setAppState(prev => ({ ...prev, fastMode: false })) + setAppState(prev => ({ ...prev, fastMode: false })); } } @@ -61,38 +49,32 @@ export function FastModePicker({ onDone, unavailableReason, }: { - onDone: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void - unavailableReason: string | null + onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void; + unavailableReason: string | null; }): React.ReactNode { - const model = useAppState(s => s.mainLoopModel) - const initialFastMode = useAppState(s => s.fastMode) - const setAppState = useSetAppState() - const [enableFastMode, setEnableFastMode] = useState(initialFastMode ?? false) - const runtimeState = getFastModeRuntimeState() - const isCooldown = runtimeState.status === 'cooldown' - const isUnavailable = unavailableReason !== null - const pricing = formatModelPricing(getOpus46CostTier(true)) + const model = useAppState(s => s.mainLoopModel); + const initialFastMode = useAppState(s => s.fastMode); + const setAppState = useSetAppState(); + const [enableFastMode, setEnableFastMode] = useState(initialFastMode ?? false); + const runtimeState = getFastModeRuntimeState(); + const isCooldown = runtimeState.status === 'cooldown'; + const isUnavailable = unavailableReason !== null; + const pricing = formatModelPricing(getOpus46CostTier(true)); function handleConfirm(): void { - if (isUnavailable) return - applyFastMode(enableFastMode, setAppState) + if (isUnavailable) return; + applyFastMode(enableFastMode, setAppState); logEvent('tengu_fast_mode_toggled', { enabled: enableFastMode, - source: - 'picker' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + source: 'picker' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); if (enableFastMode) { - const fastIcon = getFastIconString(enableFastMode) - const modelUpdated = !isFastModeSupportedByModel(model) - ? ` · model set to ${FAST_MODE_MODEL_DISPLAY}` - : '' - onDone(`${fastIcon} Fast mode ON${modelUpdated} · ${pricing}`) + const fastIcon = getFastIconString(enableFastMode); + const modelUpdated = !isFastModeSupportedByModel(model) ? ` · model set to ${FAST_MODE_MODEL_DISPLAY}` : ''; + onDone(`${fastIcon} Fast mode ON${modelUpdated} · ${pricing}`); } else { - setAppState(prev => ({ ...prev, fastMode: false })) - onDone(`Fast mode OFF`) + setAppState(prev => ({ ...prev, fastMode: false })); + onDone(`Fast mode OFF`); } } @@ -100,20 +82,18 @@ export function FastModePicker({ if (isUnavailable) { // Ensure fast mode is off if the org has disabled it if (initialFastMode) { - applyFastMode(false, setAppState) + applyFastMode(false, setAppState); } - onDone('Fast mode OFF', { display: 'system' }) - return + onDone('Fast mode OFF', { display: 'system' }); + return; } - const message = initialFastMode - ? `${getFastIconString()} Kept Fast mode ON` - : `Kept Fast mode OFF` - onDone(message, { display: 'system' }) + const message = initialFastMode ? `${getFastIconString()} Kept Fast mode ON` : `Kept Fast mode OFF`; + onDone(message, { display: 'system' }); } function handleToggle(): void { - if (isUnavailable) return - setEnableFastMode(prev => !prev) + if (isUnavailable) return; + setEnableFastMode(prev => !prev); } useKeybindings( @@ -126,13 +106,13 @@ export function FastModePicker({ 'confirm:toggle': handleToggle, }, { context: 'Confirmation' }, - ) + ); const title = ( Fast mode (research preview) - ) + ); return ( Fast mode - + {enableFastMode ? 'ON ' : 'OFF'} {pricing} @@ -186,12 +163,10 @@ export function FastModePicker({ )} Learn more:{' '} - - https://code.claude.com/docs/en/fast-mode - + https://code.claude.com/docs/en/fast-mode - ) + ); } async function handleFastModeShortcut( @@ -199,28 +174,25 @@ async function handleFastModeShortcut( getAppState: () => AppState, setAppState: (f: (prev: AppState) => AppState) => void, ): Promise { - const unavailableReason = getFastModeUnavailableReason() + const unavailableReason = getFastModeUnavailableReason(); if (unavailableReason) { - return `Fast mode unavailable: ${unavailableReason}` + return `Fast mode unavailable: ${unavailableReason}`; } - const { mainLoopModel } = getAppState() - applyFastMode(enable, setAppState) + const { mainLoopModel } = getAppState(); + applyFastMode(enable, setAppState); logEvent('tengu_fast_mode_toggled', { enabled: enable, - source: - 'shortcut' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + source: 'shortcut' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); if (enable) { - const fastIcon = getFastIconString(true) - const modelUpdated = !isFastModeSupportedByModel(mainLoopModel) - ? ` · model set to ${FAST_MODE_MODEL_DISPLAY}` - : '' - const pricing = formatModelPricing(getOpus46CostTier(true)) - return `${fastIcon} Fast mode ON${modelUpdated} · ${pricing}` + const fastIcon = getFastIconString(true); + const modelUpdated = !isFastModeSupportedByModel(mainLoopModel) ? ` · model set to ${FAST_MODE_MODEL_DISPLAY}` : ''; + const pricing = formatModelPricing(getOpus46CostTier(true)); + return `${fastIcon} Fast mode ON${modelUpdated} · ${pricing}`; } else { - return `Fast mode OFF` + return `Fast mode OFF`; } } @@ -230,31 +202,24 @@ export async function call( args?: string, ): Promise { if (!isFastModeEnabled()) { - return null + return null; } // Fetch org fast mode status before showing the picker. We must know // whether the org has disabled fast mode before allowing any toggle. // If a startup prefetch is already in flight, this awaits it. - await prefetchFastModeStatus() + await prefetchFastModeStatus(); - const arg = args?.trim().toLowerCase() + const arg = args?.trim().toLowerCase(); if (arg === 'on' || arg === 'off') { - const result = await handleFastModeShortcut( - arg === 'on', - context.getAppState, - context.setAppState, - ) - onDone(result) - return null + const result = await handleFastModeShortcut(arg === 'on', context.getAppState, context.setAppState); + onDone(result); + return null; } - const unavailableReason = getFastModeUnavailableReason() + const unavailableReason = getFastModeUnavailableReason(); logEvent('tengu_fast_mode_picker_shown', { - unavailable_reason: (unavailableReason ?? - '') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - return ( - - ) + unavailable_reason: (unavailableReason ?? '') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + return ; } diff --git a/src/commands/feedback/feedback.tsx b/src/commands/feedback/feedback.tsx index 1c3fda4bd..aa29f333b 100644 --- a/src/commands/feedback/feedback.tsx +++ b/src/commands/feedback/feedback.tsx @@ -1,27 +1,21 @@ -import * as React from 'react' -import type { - CommandResultDisplay, - LocalJSXCommandContext, -} from '../../commands.js' -import { Feedback } from '../../components/Feedback.js' -import type { LocalJSXCommandOnDone } from '../../types/command.js' -import type { Message } from '../../types/message.js' +import * as React from 'react'; +import type { CommandResultDisplay, LocalJSXCommandContext } from '../../commands.js'; +import { Feedback } from '../../components/Feedback.js'; +import type { LocalJSXCommandOnDone } from '../../types/command.js'; +import type { Message } from '../../types/message.js'; // Shared function to render the Feedback component export function renderFeedbackComponent( - onDone: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void, + onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void, abortSignal: AbortSignal, messages: Message[], initialDescription: string = '', backgroundTasks: { [taskId: string]: { - type: string - identity?: { agentId: string } - messages?: Message[] - } + type: string; + identity?: { agentId: string }; + messages?: Message[]; + }; } = {}, ): React.ReactNode { return ( @@ -32,7 +26,7 @@ export function renderFeedbackComponent( onDone={onDone} backgroundTasks={backgroundTasks} /> - ) + ); } export async function call( @@ -40,11 +34,6 @@ export async function call( context: LocalJSXCommandContext, args?: string, ): Promise { - const initialDescription = args || '' - return renderFeedbackComponent( - onDone, - context.abortController.signal, - context.messages, - initialDescription, - ) + const initialDescription = args || ''; + return renderFeedbackComponent(onDone, context.abortController.signal, context.messages, initialDescription); } diff --git a/src/commands/force-snip.ts b/src/commands/force-snip.ts index 6d1a355af..7702d35e8 100644 --- a/src/commands/force-snip.ts +++ b/src/commands/force-snip.ts @@ -25,7 +25,7 @@ const call: LocalCommandCall = async (_args, context) => { // Collect UUIDs of every message that will be snipped (everything currently // in the conversation). The next call to `snipCompactIfNeeded` will honour // the boundary and strip these from the model-facing view. - const removedUuids = messages.map((m) => m.uuid) + const removedUuids = messages.map(m => m.uuid) const boundaryMessage: Message = { type: 'system', @@ -39,7 +39,7 @@ const call: LocalCommandCall = async (_args, context) => { }, } as Message // subtype is feature-gated; cast through Message - setMessages((prev) => [...prev, boundaryMessage]) + setMessages(prev => [...prev, boundaryMessage]) return { type: 'text', diff --git a/src/commands/fork/fork.tsx b/src/commands/fork/fork.tsx index b9e416a01..cd2f8d701 100644 --- a/src/commands/fork/fork.tsx +++ b/src/commands/fork/fork.tsx @@ -1,9 +1,9 @@ -import { feature } from 'bun:bundle' -import React from 'react' -import { AgentTool } from '@claude-code-best/builtin-tools/tools/AgentTool/AgentTool.js' -import { isInForkChild } from '@claude-code-best/builtin-tools/tools/AgentTool/forkSubagent.js' -import { logForDebugging } from '../../utils/debug.js' -import type { LocalJSXCommandOnDone, LocalJSXCommandContext } from '../../types/command.js' +import { feature } from 'bun:bundle'; +import React from 'react'; +import { AgentTool } from '@claude-code-best/builtin-tools/tools/AgentTool/AgentTool.js'; +import { isInForkChild } from '@claude-code-best/builtin-tools/tools/AgentTool/forkSubagent.js'; +import { logForDebugging } from '../../utils/debug.js'; +import type { LocalJSXCommandOnDone, LocalJSXCommandContext } from '../../types/command.js'; export async function call( onDone: LocalJSXCommandOnDone, @@ -12,30 +12,30 @@ export async function call( ): Promise { // Check feature flag if (!feature('FORK_SUBAGENT')) { - onDone('Fork subagent feature is not enabled. Set FEATURE_FORK_SUBAGENT=1 to enable.', { display: 'system' }) - return null + onDone('Fork subagent feature is not enabled. Set FEATURE_FORK_SUBAGENT=1 to enable.', { display: 'system' }); + return null; } // Recursive fork guard if (isInForkChild(context.messages)) { - onDone('Fork is not available inside a forked worker. Complete your task directly using your tools.', { display: 'system' }) - return null + onDone('Fork is not available inside a forked worker. Complete your task directly using your tools.', { + display: 'system', + }); + return null; } - const directive = args.trim() + const directive = args.trim(); if (!directive) { - onDone('Usage: /fork \nExample: /fork Fix the null check in validate.ts', { display: 'system' }) - return null + onDone('Usage: /fork \nExample: /fork Fix the null check in validate.ts', { display: 'system' }); + return null; } // Find the last assistant message to fork from - const lastAssistantMessage = [...context.messages].reverse().find( - m => m.type === 'assistant' - ) as any // Type assertion to avoid complex type import + const lastAssistantMessage = [...context.messages].reverse().find(m => m.type === 'assistant') as any; // Type assertion to avoid complex type import if (!lastAssistantMessage) { - onDone('Cannot fork: no assistant response in conversation history.', { display: 'system' }) - return null + onDone('Cannot fork: no assistant response in conversation history.', { display: 'system' }); + return null; } try { @@ -45,29 +45,24 @@ export async function call( prompt: directive, run_in_background: true, // fork always runs async description: `Fork: ${directive.slice(0, 30)}${directive.length > 30 ? '...' : ''}`, - } + }; // Call AgentTool with proper parameters: // - input: the agent parameters (no subagent_type => fork path) // - toolUseContext: the current context (ToolUseContext) // - canUseTool: permission-check function from context // - assistantMessage: the last assistant message to fork from - AgentTool.call( - input, - context, - context.canUseTool!, - lastAssistantMessage - ).catch(error => { - logForDebugging(`Fork subagent async error: ${error}`, { level: 'error' }) - }) + AgentTool.call(input, context, context.canUseTool!, lastAssistantMessage).catch(error => { + logForDebugging(`Fork subagent async error: ${error}`, { level: 'error' }); + }); // Notify user that fork has been started - onDone(`Forked subagent started with directive: "${directive}"`, { display: 'system' }) - return null + onDone(`Forked subagent started with directive: "${directive}"`, { display: 'system' }); + return null; } catch (error) { // Catches synchronous setup errors only - logForDebugging(`Fork command setup error: ${error}`, { level: 'error' }) - onDone(`Fork failed: ${error instanceof Error ? error.message : String(error)}`, { display: 'system' }) - return null + logForDebugging(`Fork command setup error: ${error}`, { level: 'error' }); + onDone(`Fork failed: ${error instanceof Error ? error.message : String(error)}`, { display: 'system' }); + return null; } } diff --git a/src/commands/help/help.tsx b/src/commands/help/help.tsx index f4e066b5d..b2310c13f 100644 --- a/src/commands/help/help.tsx +++ b/src/commands/help/help.tsx @@ -1,10 +1,7 @@ -import * as React from 'react' -import { HelpV2 } from '../../components/HelpV2/HelpV2.js' -import type { LocalJSXCommandCall } from '../../types/command.js' +import * as React from 'react'; +import { HelpV2 } from '../../components/HelpV2/HelpV2.js'; +import type { LocalJSXCommandCall } from '../../types/command.js'; -export const call: LocalJSXCommandCall = async ( - onDone, - { options: { commands } }, -) => { - return -} +export const call: LocalJSXCommandCall = async (onDone, { options: { commands } }) => { + return ; +}; diff --git a/src/commands/hooks/hooks.tsx b/src/commands/hooks/hooks.tsx index 80d27e3ac..cabdea254 100644 --- a/src/commands/hooks/hooks.tsx +++ b/src/commands/hooks/hooks.tsx @@ -1,13 +1,13 @@ -import * as React from 'react' -import { HooksConfigMenu } from '../../components/hooks/HooksConfigMenu.js' -import { logEvent } from '../../services/analytics/index.js' -import { getTools } from '../../tools.js' -import type { LocalJSXCommandCall } from '../../types/command.js' +import * as React from 'react'; +import { HooksConfigMenu } from '../../components/hooks/HooksConfigMenu.js'; +import { logEvent } from '../../services/analytics/index.js'; +import { getTools } from '../../tools.js'; +import type { LocalJSXCommandCall } from '../../types/command.js'; export const call: LocalJSXCommandCall = async (onDone, context) => { - logEvent('tengu_hooks_command', {}) - const appState = context.getAppState() - const permissionContext = appState.toolPermissionContext - const toolNames = getTools(permissionContext).map(tool => tool.name) - return -} + logEvent('tengu_hooks_command', {}); + const appState = context.getAppState(); + const permissionContext = appState.toolPermissionContext; + const toolNames = getTools(permissionContext).map(tool => tool.name); + return ; +}; diff --git a/src/commands/ide/ide.tsx b/src/commands/ide/ide.tsx index d5944636d..6fe36d995 100644 --- a/src/commands/ide/ide.tsx +++ b/src/commands/ide/ide.tsx @@ -1,25 +1,22 @@ -import chalk from 'chalk' -import * as path from 'path' -import React, { useCallback, useEffect, useRef, useState } from 'react' -import { logEvent } from 'src/services/analytics/index.js' -import type { - CommandResultDisplay, - LocalJSXCommandContext, -} from '../../commands.js' -import { Select } from '../../components/CustomSelect/index.js' -import { Dialog } from '@anthropic/ink' +import chalk from 'chalk'; +import * as path from 'path'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { logEvent } from 'src/services/analytics/index.js'; +import type { CommandResultDisplay, LocalJSXCommandContext } from '../../commands.js'; +import { Select } from '../../components/CustomSelect/index.js'; +import { Dialog } from '@anthropic/ink'; import { IdeAutoConnectDialog, IdeDisableAutoConnectDialog, shouldShowAutoConnectDialog, shouldShowDisableAutoConnectDialog, -} from '../../components/IdeAutoConnectDialog.js' -import { Box, Text } from '@anthropic/ink' -import { clearServerCache } from '../../services/mcp/client.js' -import type { ScopedMcpServerConfig } from '../../services/mcp/types.js' -import { useAppState, useSetAppState } from '../../state/AppState.js' -import { getCwd } from '../../utils/cwd.js' -import { execFileNoThrow } from '../../utils/execFileNoThrow.js' +} from '../../components/IdeAutoConnectDialog.js'; +import { Box, Text } from '@anthropic/ink'; +import { clearServerCache } from '../../services/mcp/client.js'; +import type { ScopedMcpServerConfig } from '../../services/mcp/types.js'; +import { useAppState, useSetAppState } from '../../state/AppState.js'; +import { getCwd } from '../../utils/cwd.js'; +import { execFileNoThrow } from '../../utils/execFileNoThrow.js'; import { type DetectedIDEInfo, detectIDEs, @@ -29,16 +26,16 @@ import { isSupportedJetBrainsTerminal, isSupportedTerminal, toIDEDisplayName, -} from '../../utils/ide.js' -import { getCurrentWorktreeSession } from '../../utils/worktree.js' +} from '../../utils/ide.js'; +import { getCurrentWorktreeSession } from '../../utils/worktree.js'; type IDEScreenProps = { - availableIDEs: DetectedIDEInfo[] - unavailableIDEs: DetectedIDEInfo[] - selectedIDE?: DetectedIDEInfo | null - onClose: () => void - onSelect: (ide?: DetectedIDEInfo) => void -} + availableIDEs: DetectedIDEInfo[]; + unavailableIDEs: DetectedIDEInfo[]; + selectedIDE?: DetectedIDEInfo | null; + onClose: () => void; + onSelect: (ide?: DetectedIDEInfo) => void; +}; function IDEScreen({ availableIDEs, @@ -47,51 +44,43 @@ function IDEScreen({ onClose, onSelect, }: IDEScreenProps): React.ReactNode { - const [selectedValue, setSelectedValue] = useState( - selectedIDE?.port?.toString() ?? 'None', - ) - const [showAutoConnectDialog, setShowAutoConnectDialog] = useState(false) - const [showDisableAutoConnectDialog, setShowDisableAutoConnectDialog] = - useState(false) + const [selectedValue, setSelectedValue] = useState(selectedIDE?.port?.toString() ?? 'None'); + const [showAutoConnectDialog, setShowAutoConnectDialog] = useState(false); + const [showDisableAutoConnectDialog, setShowDisableAutoConnectDialog] = useState(false); const handleSelectIDE = useCallback( (value: string) => { if (value !== 'None' && shouldShowAutoConnectDialog()) { - setShowAutoConnectDialog(true) + setShowAutoConnectDialog(true); } else if (value === 'None' && shouldShowDisableAutoConnectDialog()) { - setShowDisableAutoConnectDialog(true) + setShowDisableAutoConnectDialog(true); } else { - onSelect(availableIDEs.find(ide => ide.port === parseInt(value))) + onSelect(availableIDEs.find(ide => ide.port === parseInt(value, 10))); } }, [availableIDEs, onSelect], - ) + ); const ideCounts = availableIDEs.reduce>((acc, ide) => { - acc[ide.name] = (acc[ide.name] || 0) + 1 - return acc - }, {}) + acc[ide.name] = (acc[ide.name] || 0) + 1; + return acc; + }, {}); const options = availableIDEs .map(ide => { - const hasMultipleInstances = (ideCounts[ide.name] || 0) > 1 - const showWorkspace = - hasMultipleInstances && ide.workspaceFolders.length > 0 + const hasMultipleInstances = (ideCounts[ide.name] || 0) > 1; + const showWorkspace = hasMultipleInstances && ide.workspaceFolders.length > 0; return { label: ide.name, value: ide.port.toString(), - description: showWorkspace - ? formatWorkspaceFolders(ide.workspaceFolders) - : undefined, - } + description: showWorkspace ? formatWorkspaceFolders(ide.workspaceFolders) : undefined, + }; }) - .concat([{ label: 'None', value: 'None', description: undefined }]) + .concat([{ label: 'None', value: 'None', description: undefined }]); if (showAutoConnectDialog) { - return ( - handleSelectIDE(selectedValue)} /> - ) + return handleSelectIDE(selectedValue)} />; } if (showDisableAutoConnectDialog) { @@ -100,10 +89,10 @@ function IDEScreen({ onComplete={() => { // Always disconnect when user selects "None", regardless of their // choice about disabling auto-connect - onSelect(undefined) + onSelect(undefined); }} /> - ) + ); } return ( @@ -129,36 +118,28 @@ function IDEScreen({ defaultFocusValue={selectedValue} options={options} onChange={value => { - setSelectedValue(value) - handleSelectIDE(value) + setSelectedValue(value); + handleSelectIDE(value); }} /> )} {availableIDEs.length !== 0 && - availableIDEs.some( - ide => ide.name === 'VS Code' || ide.name === 'Visual Studio Code', - ) && ( + availableIDEs.some(ide => ide.name === 'VS Code' || ide.name === 'Visual Studio Code') && ( - - Note: Only one Claude Code instance can be connected to VS Code - at a time. - + Note: Only one Claude Code instance can be connected to VS Code at a time. )} {availableIDEs.length !== 0 && !isSupportedTerminal() && ( - - Tip: You can enable auto-connect to IDE in /config or with the - --ide flag - + Tip: You can enable auto-connect to IDE in /config or with the --ide flag )} {unavailableIDEs.length > 0 && ( - Found {unavailableIDEs.length} other running IDE(s). However, - their workspace/project directories do not match the current cwd. + Found {unavailableIDEs.length} other running IDE(s). However, their workspace/project directories do not + match the current cwd. {unavailableIDEs.map((ide, index) => ( @@ -173,82 +154,64 @@ function IDEScreen({ )} - ) + ); } async function findCurrentIDE( availableIDEs: DetectedIDEInfo[], dynamicMcpConfig?: Record, ): Promise { - const currentConfig = dynamicMcpConfig?.ide - if ( - !currentConfig || - (currentConfig.type !== 'sse-ide' && currentConfig.type !== 'ws-ide') - ) { - return null + const currentConfig = dynamicMcpConfig?.ide; + if (!currentConfig || (currentConfig.type !== 'sse-ide' && currentConfig.type !== 'ws-ide')) { + return null; } for (const ide of availableIDEs) { if (ide.url === currentConfig.url) { - return ide + return ide; } } - return null + return null; } type IDEOpenSelectionProps = { - availableIDEs: DetectedIDEInfo[] - onSelectIDE: (ide?: DetectedIDEInfo) => void - onDone: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void -} + availableIDEs: DetectedIDEInfo[]; + onSelectIDE: (ide?: DetectedIDEInfo) => void; + onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void; +}; -function IDEOpenSelection({ - availableIDEs, - onSelectIDE, - onDone, -}: IDEOpenSelectionProps): React.ReactNode { - const [selectedValue, setSelectedValue] = useState( - availableIDEs[0]?.port?.toString() ?? '', - ) +function IDEOpenSelection({ availableIDEs, onSelectIDE, onDone }: IDEOpenSelectionProps): React.ReactNode { + const [selectedValue, setSelectedValue] = useState(availableIDEs[0]?.port?.toString() ?? ''); const handleSelectIDE = useCallback( (value: string) => { - const selectedIDE = availableIDEs.find( - ide => ide.port === parseInt(value), - ) - onSelectIDE(selectedIDE) + const selectedIDE = availableIDEs.find(ide => ide.port === parseInt(value, 10)); + onSelectIDE(selectedIDE); }, [availableIDEs, onSelectIDE], - ) + ); const options = availableIDEs.map(ide => ({ label: ide.name, value: ide.port.toString(), - })) + })); function handleCancel(): void { - onDone('IDE selection cancelled', { display: 'system' }) + onDone('IDE selection cancelled', { display: 'system' }); } return ( - + { - setSelectedValue(value) - handleSelectIDE(value) + setSelectedValue(value); + handleSelectIDE(value); }} /> - ) + ); } -function InstallOnMount({ - ide, - onInstall, -}: { - ide: IdeType - onInstall: (ide: IdeType) => void -}): React.ReactNode { +function InstallOnMount({ ide, onInstall }: { ide: IdeType; onInstall: (ide: IdeType) => void }): React.ReactNode { useEffect(() => { - onInstall(ide) - }, [ide, onInstall]) - return null + onInstall(ide); + }, [ide, onInstall]); + return null; } export async function call( - onDone: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void, + onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void, context: LocalJSXCommandContext, args: string, ): Promise { - logEvent('tengu_ext_ide_command', {}) + logEvent('tengu_ext_ide_command', {}); const { options: { dynamicMcpConfig }, onChangeDynamicMcpConfig, - } = context + } = context; // Handle 'open' argument if (args?.trim() === 'open') { - const worktreeSession = getCurrentWorktreeSession() - const targetPath = worktreeSession ? worktreeSession.worktreePath : getCwd() + const worktreeSession = getCurrentWorktreeSession(); + const targetPath = worktreeSession ? worktreeSession.worktreePath : getCwd(); // Detect available IDEs - const detectedIDEs = await detectIDEs(true) - const availableIDEs = detectedIDEs.filter(ide => ide.isValid) + const detectedIDEs = await detectIDEs(true); + const availableIDEs = detectedIDEs.filter(ide => ide.isValid); if (availableIDEs.length === 0) { - onDone('No IDEs with Claude Code extension detected.') - return null + onDone('No IDEs with Claude Code extension detected.'); + return null; } // Return IDE selection component @@ -346,8 +293,8 @@ export async function call( availableIDEs={availableIDEs} onSelectIDE={async (selectedIDE?: DetectedIDEInfo) => { if (!selectedIDE) { - onDone('No IDE selected.') - return + onDone('No IDE selected.'); + return; } // Try to open the project in the selected IDE @@ -357,58 +304,50 @@ export async function call( selectedIDE.name.toLowerCase().includes('windsurf') ) { // VS Code-based IDEs - const { code } = await execFileNoThrow('code', [targetPath]) + const { code } = await execFileNoThrow('code', [targetPath]); if (code === 0) { - onDone( - `Opened ${worktreeSession ? 'worktree' : 'project'} in ${chalk.bold(selectedIDE.name)}`, - ) + onDone(`Opened ${worktreeSession ? 'worktree' : 'project'} in ${chalk.bold(selectedIDE.name)}`); } else { - onDone( - `Failed to open in ${selectedIDE.name}. Try opening manually: ${targetPath}`, - ) + onDone(`Failed to open in ${selectedIDE.name}. Try opening manually: ${targetPath}`); } } else if (isSupportedJetBrainsTerminal()) { // JetBrains IDEs - they usually open via their CLI tools onDone( `Please open the ${worktreeSession ? 'worktree' : 'project'} manually in ${chalk.bold(selectedIDE.name)}: ${targetPath}`, - ) + ); } else { onDone( `Please open the ${worktreeSession ? 'worktree' : 'project'} manually in ${chalk.bold(selectedIDE.name)}: ${targetPath}`, - ) + ); } }} onDone={() => { - onDone('Exited without opening IDE', { display: 'system' }) + onDone('Exited without opening IDE', { display: 'system' }); }} /> - ) + ); } - const detectedIDEs = await detectIDEs(true) + const detectedIDEs = await detectIDEs(true); // If no IDEs with extensions detected, check for running IDEs and offer to install - if ( - detectedIDEs.length === 0 && - context.onInstallIDEExtension && - !isSupportedTerminal() - ) { - const runningIDEs = await detectRunningIDEs() + if (detectedIDEs.length === 0 && context.onInstallIDEExtension && !isSupportedTerminal()) { + const runningIDEs = await detectRunningIDEs(); const onInstall = (ide: IdeType) => { if (context.onInstallIDEExtension) { - context.onInstallIDEExtension(ide) + context.onInstallIDEExtension(ide); // The completion message will be shown after installation if (isJetBrainsIde(ide)) { onDone( `Installed plugin to ${chalk.bold(toIDEDisplayName(ide))}\n` + `Please ${chalk.bold('restart your IDE')} completely for it to take effect`, - ) + ); } else { - onDone(`Installed extension to ${chalk.bold(toIDEDisplayName(ide))}`) + onDone(`Installed extension to ${chalk.bold(toIDEDisplayName(ide))}`); } } - } + }; if (runningIDEs.length > 1) { // Show selector when multiple IDEs are running @@ -417,19 +356,19 @@ export async function call( runningIDEs={runningIDEs} onSelectIDE={onInstall} onDone={() => { - onDone('No IDE selected.', { display: 'system' }) + onDone('No IDE selected.', { display: 'system' }); }} /> - ) + ); } else if (runningIDEs.length === 1) { - return + return ; } } - const availableIDEs = detectedIDEs.filter(ide => ide.isValid) - const unavailableIDEs = detectedIDEs.filter(ide => !ide.isValid) + const availableIDEs = detectedIDEs.filter(ide => ide.isValid); + const unavailableIDEs = detectedIDEs.filter(ide => !ide.isValid); - const currentIDE = await findCurrentIDE(availableIDEs, dynamicMcpConfig) + const currentIDE = await findCurrentIDE(availableIDEs, dynamicMcpConfig); return ( - ) + ); } // Connection timeout slightly longer than the 30s MCP connection timeout -const IDE_CONNECTION_TIMEOUT_MS = 35000 +const IDE_CONNECTION_TIMEOUT_MS = 35000; type IDECommandFlowProps = { - availableIDEs: DetectedIDEInfo[] - unavailableIDEs: DetectedIDEInfo[] - currentIDE: DetectedIDEInfo | null - dynamicMcpConfig?: Record - onChangeDynamicMcpConfig?: ( - config: Record, - ) => void - onDone: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void -} + availableIDEs: DetectedIDEInfo[]; + unavailableIDEs: DetectedIDEInfo[]; + currentIDE: DetectedIDEInfo | null; + dynamicMcpConfig?: Record; + onChangeDynamicMcpConfig?: (config: Record) => void; + onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void; +}; function IDECommandFlow({ availableIDEs, @@ -468,80 +402,66 @@ function IDECommandFlow({ onChangeDynamicMcpConfig, onDone, }: IDECommandFlowProps): React.ReactNode { - const [connectingIDE, setConnectingIDE] = useState( - null, - ) - const ideClient = useAppState(s => s.mcp.clients.find(c => c.name === 'ide')) - const setAppState = useSetAppState() - const isFirstCheckRef = useRef(true) + const [connectingIDE, setConnectingIDE] = useState(null); + const ideClient = useAppState(s => s.mcp.clients.find(c => c.name === 'ide')); + const setAppState = useSetAppState(); + const isFirstCheckRef = useRef(true); // Watch for connection result useEffect(() => { - if (!connectingIDE) return + if (!connectingIDE) return; // Skip the first check — it reflects stale state from before the // config change was dispatched if (isFirstCheckRef.current) { - isFirstCheckRef.current = false - return + isFirstCheckRef.current = false; + return; } - if (!ideClient || ideClient.type === 'pending') return + if (!ideClient || ideClient.type === 'pending') return; if (ideClient.type === 'connected') { - onDone(`Connected to ${connectingIDE.name}.`) + onDone(`Connected to ${connectingIDE.name}.`); } else if (ideClient.type === 'failed') { - onDone(`Failed to connect to ${connectingIDE.name}.`) + onDone(`Failed to connect to ${connectingIDE.name}.`); } - }, [ideClient, connectingIDE, onDone]) + }, [ideClient, connectingIDE, onDone]); // Timeout fallback useEffect(() => { - if (!connectingIDE) return - const timer = setTimeout( - onDone, - IDE_CONNECTION_TIMEOUT_MS, - `Connection to ${connectingIDE.name} timed out.`, - ) - return () => clearTimeout(timer) - }, [connectingIDE, onDone]) + if (!connectingIDE) return; + const timer = setTimeout(onDone, IDE_CONNECTION_TIMEOUT_MS, `Connection to ${connectingIDE.name} timed out.`); + return () => clearTimeout(timer); + }, [connectingIDE, onDone]); const handleSelectIDE = useCallback( (selectedIDE?: DetectedIDEInfo) => { if (!onChangeDynamicMcpConfig) { - onDone('Error connecting to IDE.') - return + onDone('Error connecting to IDE.'); + return; } - const newConfig = { ...(dynamicMcpConfig || {}) } + const newConfig = { ...(dynamicMcpConfig || {}) }; if (currentIDE) { - delete newConfig.ide + delete newConfig.ide; } if (!selectedIDE) { // Close the MCP transport and remove the client from state if (ideClient && ideClient.type === 'connected' && currentIDE) { // Null out onclose to prevent auto-reconnection - ideClient.client.onclose = () => {} - void clearServerCache('ide', ideClient.config) + ideClient.client.onclose = () => {}; + void clearServerCache('ide', ideClient.config); setAppState(prev => ({ ...prev, mcp: { ...prev.mcp, clients: prev.mcp.clients.filter(c => c.name !== 'ide'), - tools: prev.mcp.tools.filter( - t => !t.name?.startsWith('mcp__ide__'), - ), - commands: prev.mcp.commands.filter( - c => !c.name?.startsWith('mcp__ide__'), - ), + tools: prev.mcp.tools.filter(t => !t.name?.startsWith('mcp__ide__')), + commands: prev.mcp.commands.filter(c => !c.name?.startsWith('mcp__ide__')), }, - })) + })); } - onChangeDynamicMcpConfig(newConfig) - onDone( - currentIDE - ? `Disconnected from ${currentIDE.name}.` - : 'No IDE selected.', - ) - return + onChangeDynamicMcpConfig(newConfig); + onDone(currentIDE ? `Disconnected from ${currentIDE.name}.` : 'No IDE selected.'); + return; } - const url = selectedIDE.url + const url = selectedIDE.url; newConfig.ide = { type: url.startsWith('ws:') ? 'ws-ide' : 'sse-ide', url: url, @@ -549,23 +469,16 @@ function IDECommandFlow({ authToken: selectedIDE.authToken, ideRunningInWindows: selectedIDE.ideRunningInWindows, scope: 'dynamic' as const, - } as ScopedMcpServerConfig - isFirstCheckRef.current = true - setConnectingIDE(selectedIDE) - onChangeDynamicMcpConfig(newConfig) + } as ScopedMcpServerConfig; + isFirstCheckRef.current = true; + setConnectingIDE(selectedIDE); + onChangeDynamicMcpConfig(newConfig); }, - [ - dynamicMcpConfig, - currentIDE, - ideClient, - setAppState, - onChangeDynamicMcpConfig, - onDone, - ], - ) + [dynamicMcpConfig, currentIDE, ideClient, setAppState, onChangeDynamicMcpConfig, onDone], + ); if (connectingIDE) { - return Connecting to {connectingIDE.name}… + return Connecting to {connectingIDE.name}…; } return ( @@ -576,7 +489,7 @@ function IDECommandFlow({ onClose={() => onDone('IDE selection cancelled', { display: 'system' })} onSelect={handleSelectIDE} /> - ) + ); } /** @@ -585,46 +498,43 @@ function IDECommandFlow({ * @param maxLength Maximum total length of the formatted string * @returns Formatted string with folder paths */ -export function formatWorkspaceFolders( - folders: string[], - maxLength: number = 100, -): string { - if (folders.length === 0) return '' +export function formatWorkspaceFolders(folders: string[], maxLength: number = 100): string { + if (folders.length === 0) return ''; - const cwd = getCwd() + const cwd = getCwd(); // Only show first 2 workspaces - const foldersToShow = folders.slice(0, 2) - const hasMore = folders.length > 2 + const foldersToShow = folders.slice(0, 2); + const hasMore = folders.length > 2; // Account for ", …" if there are more folders - const ellipsisOverhead = hasMore ? 3 : 0 // ", …" + const ellipsisOverhead = hasMore ? 3 : 0; // ", …" // Account for commas and spaces between paths (", " = 2 chars per separator) - const separatorOverhead = (foldersToShow.length - 1) * 2 - const availableLength = maxLength - separatorOverhead - ellipsisOverhead + const separatorOverhead = (foldersToShow.length - 1) * 2; + const availableLength = maxLength - separatorOverhead - ellipsisOverhead; - const maxLengthPerPath = Math.floor(availableLength / foldersToShow.length) + const maxLengthPerPath = Math.floor(availableLength / foldersToShow.length); - const cwdNFC = cwd.normalize('NFC') + const cwdNFC = cwd.normalize('NFC'); const formattedFolders = foldersToShow.map(folder => { // Strip cwd from the beginning if present // Normalize both to NFC for consistent comparison (macOS uses NFD paths) - const folderNFC = folder.normalize('NFC') + const folderNFC = folder.normalize('NFC'); if (folderNFC.startsWith(cwdNFC + path.sep)) { - folder = folderNFC.slice(cwdNFC.length + 1) + folder = folderNFC.slice(cwdNFC.length + 1); } if (folder.length <= maxLengthPerPath) { - return folder + return folder; } - return '…' + folder.slice(-(maxLengthPerPath - 1)) - }) + return '…' + folder.slice(-(maxLengthPerPath - 1)); + }); - let result = formattedFolders.join(', ') + let result = formattedFolders.join(', '); if (hasMore) { - result += ', …' + result += ', …'; } - return result + return result; } diff --git a/src/commands/ide/src/services/analytics/index.ts b/src/commands/ide/src/services/analytics/index.ts index 60402f927..c095b5a65 100644 --- a/src/commands/ide/src/services/analytics/index.ts +++ b/src/commands/ide/src/services/analytics/index.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type logEvent = any; +export type logEvent = any diff --git a/src/commands/insights.ts b/src/commands/insights.ts index 1e5e40dd5..27c7c8c9d 100644 --- a/src/commands/insights.ts +++ b/src/commands/insights.ts @@ -895,7 +895,9 @@ async function summarizeTranscriptChunk(chunk: string): Promise { }, }) - const text = extractTextContent(result.message.content as readonly { readonly type: string }[]) + const text = extractTextContent( + result.message.content as readonly { readonly type: string }[], + ) return text || chunk.slice(0, 2000) } catch { // On error, just return truncated chunk @@ -1038,7 +1040,9 @@ RESPOND WITH ONLY A VALID JSON OBJECT matching this schema: }, }) - const text = extractTextContent(result.message.content as readonly { readonly type: string }[]) + const text = extractTextContent( + result.message.content as readonly { readonly type: string }[], + ) // Parse JSON from response const jsonMatch = text.match(/\{[\s\S]*\}/) @@ -1589,7 +1593,9 @@ async function generateSectionInsight( }, }) - const text = extractTextContent(result.message.content as readonly { readonly type: string }[]) + const text = extractTextContent( + result.message.content as readonly { readonly type: string }[], + ) if (text) { // Parse JSON from response @@ -3058,7 +3064,6 @@ const usageReport: Command = { // Show collection message if collecting if (collectRemote && hasRemoteHosts) { - // biome-ignore lint/suspicious/noConsole: intentional console.error( `Collecting sessions from ${remoteHosts.length} homespace(s): ${remoteHosts.join(', ')}...`, ) diff --git a/src/commands/install-github-app/ApiKeyStep.tsx b/src/commands/install-github-app/ApiKeyStep.tsx index 942bc662a..66055677f 100644 --- a/src/commands/install-github-app/ApiKeyStep.tsx +++ b/src/commands/install-github-app/ApiKeyStep.tsx @@ -1,19 +1,19 @@ -import React, { useCallback, useState } from 'react' -import TextInput from '../../components/TextInput.js' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' -import { Box, color, Text, useTheme } from '@anthropic/ink' -import { useKeybindings } from '../../keybindings/useKeybinding.js' +import React, { useCallback, useState } from 'react'; +import TextInput from '../../components/TextInput.js'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import { Box, color, Text, useTheme } from '@anthropic/ink'; +import { useKeybindings } from '../../keybindings/useKeybinding.js'; interface ApiKeyStepProps { - existingApiKey: string | null - useExistingKey: boolean - apiKeyOrOAuthToken: string - onApiKeyChange: (value: string) => void - onToggleUseExistingKey: (useExisting: boolean) => void - onSubmit: () => void - onCreateOAuthToken?: () => void - selectedOption?: 'existing' | 'new' | 'oauth' - onSelectOption?: (option: 'existing' | 'new' | 'oauth') => void + existingApiKey: string | null; + useExistingKey: boolean; + apiKeyOrOAuthToken: string; + onApiKeyChange: (value: string) => void; + onToggleUseExistingKey: (useExisting: boolean) => void; + onSubmit: () => void; + onCreateOAuthToken?: () => void; + selectedOption?: 'existing' | 'new' | 'oauth'; + onSelectOption?: (option: 'existing' | 'new' | 'oauth') => void; } export function ApiKeyStep({ @@ -23,62 +23,47 @@ export function ApiKeyStep({ onSubmit, onToggleUseExistingKey, onCreateOAuthToken, - selectedOption = existingApiKey - ? 'existing' - : onCreateOAuthToken - ? 'oauth' - : 'new', + selectedOption = existingApiKey ? 'existing' : onCreateOAuthToken ? 'oauth' : 'new', onSelectOption, }: ApiKeyStepProps) { - const [cursorOffset, setCursorOffset] = useState(0) - const terminalSize = useTerminalSize() - const [theme] = useTheme() + const [cursorOffset, setCursorOffset] = useState(0); + const terminalSize = useTerminalSize(); + const [theme] = useTheme(); const handlePrevious = useCallback(() => { if (selectedOption === 'new' && onCreateOAuthToken) { // From 'new' go up to 'oauth' - onSelectOption?.('oauth') + onSelectOption?.('oauth'); } else if (selectedOption === 'oauth' && existingApiKey) { // From 'oauth' go up to 'existing' (only if it exists) - onSelectOption?.('existing') - onToggleUseExistingKey(true) + onSelectOption?.('existing'); + onToggleUseExistingKey(true); } - }, [ - selectedOption, - onCreateOAuthToken, - existingApiKey, - onSelectOption, - onToggleUseExistingKey, - ]) + }, [selectedOption, onCreateOAuthToken, existingApiKey, onSelectOption, onToggleUseExistingKey]); const handleNext = useCallback(() => { if (selectedOption === 'existing') { // From 'existing' go down to 'oauth' (if available) or 'new' - onSelectOption?.(onCreateOAuthToken ? 'oauth' : 'new') - onToggleUseExistingKey(false) + onSelectOption?.(onCreateOAuthToken ? 'oauth' : 'new'); + onToggleUseExistingKey(false); } else if (selectedOption === 'oauth') { // From 'oauth' go down to 'new' - onSelectOption?.('new') + onSelectOption?.('new'); } - }, [ - selectedOption, - onCreateOAuthToken, - onSelectOption, - onToggleUseExistingKey, - ]) + }, [selectedOption, onCreateOAuthToken, onSelectOption, onToggleUseExistingKey]); const handleConfirm = useCallback(() => { if (selectedOption === 'oauth' && onCreateOAuthToken) { - onCreateOAuthToken() + onCreateOAuthToken(); } else { - onSubmit() + onSubmit(); } - }, [selectedOption, onCreateOAuthToken, onSubmit]) + }, [selectedOption, onCreateOAuthToken, onSubmit]); // When the text input is visible, omit confirm:yes so bare 'y' passes // through to the input instead of submitting. TextInput's onSubmit handles // Enter. Keep the Confirmation context (not Settings) to avoid j/k bindings. - const isTextInputVisible = selectedOption === 'new' + const isTextInputVisible = selectedOption === 'new'; useKeybindings( { 'confirm:previous': handlePrevious, @@ -86,14 +71,14 @@ export function ApiKeyStep({ 'confirm:yes': handleConfirm, }, { context: 'Confirmation', isActive: !isTextInputVisible }, - ) + ); useKeybindings( { 'confirm:previous': handlePrevious, 'confirm:next': handleNext, }, { context: 'Confirmation', isActive: isTextInputVisible }, - ) + ); return ( <> @@ -105,9 +90,7 @@ export function ApiKeyStep({ {existingApiKey && ( - {selectedOption === 'existing' - ? color('success', theme)('> ') - : ' '} + {selectedOption === 'existing' ? color('success', theme)('> ') : ' '} Use your existing Claude Code API key @@ -115,9 +98,7 @@ export function ApiKeyStep({ {onCreateOAuthToken && ( - {selectedOption === 'oauth' - ? color('success', theme)('> ') - : ' '} + {selectedOption === 'oauth' ? color('success', theme)('> ') : ' '} Create a long-lived token with your Claude subscription @@ -148,5 +129,5 @@ export function ApiKeyStep({ ↑/↓ to select · Enter to continue - ) + ); } diff --git a/src/commands/install-github-app/CheckExistingSecretStep.tsx b/src/commands/install-github-app/CheckExistingSecretStep.tsx index de7f4b9a7..9fb66320b 100644 --- a/src/commands/install-github-app/CheckExistingSecretStep.tsx +++ b/src/commands/install-github-app/CheckExistingSecretStep.tsx @@ -1,15 +1,15 @@ -import React, { useCallback, useState } from 'react' -import TextInput from '../../components/TextInput.js' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' -import { Box, color, Text, useTheme } from '@anthropic/ink' -import { useKeybindings } from '../../keybindings/useKeybinding.js' +import React, { useCallback, useState } from 'react'; +import TextInput from '../../components/TextInput.js'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import { Box, color, Text, useTheme } from '@anthropic/ink'; +import { useKeybindings } from '../../keybindings/useKeybinding.js'; interface CheckExistingSecretStepProps { - useExistingSecret: boolean - secretName: string - onToggleUseExistingSecret: (useExisting: boolean) => void - onSecretNameChange: (value: string) => void - onSubmit: () => void + useExistingSecret: boolean; + secretName: string; + onToggleUseExistingSecret: (useExisting: boolean) => void; + onSecretNameChange: (value: string) => void; + onSubmit: () => void; } export function CheckExistingSecretStep({ @@ -19,21 +19,15 @@ export function CheckExistingSecretStep({ onSecretNameChange, onSubmit, }: CheckExistingSecretStepProps) { - const [cursorOffset, setCursorOffset] = useState(0) - const terminalSize = useTerminalSize() - const [theme] = useTheme() + const [cursorOffset, setCursorOffset] = useState(0); + const terminalSize = useTerminalSize(); + const [theme] = useTheme(); // When the text input is visible, omit confirm:yes so bare 'y' passes // through to the input instead of submitting. TextInput's onSubmit handles // Enter. Keep the Confirmation context (not Settings) to avoid j/k bindings. - const handlePrevious = useCallback( - () => onToggleUseExistingSecret(true), - [onToggleUseExistingSecret], - ) - const handleNext = useCallback( - () => onToggleUseExistingSecret(false), - [onToggleUseExistingSecret], - ) + const handlePrevious = useCallback(() => onToggleUseExistingSecret(true), [onToggleUseExistingSecret]); + const handleNext = useCallback(() => onToggleUseExistingSecret(false), [onToggleUseExistingSecret]); useKeybindings( { 'confirm:previous': handlePrevious, @@ -41,14 +35,14 @@ export function CheckExistingSecretStep({ 'confirm:yes': onSubmit, }, { context: 'Confirmation', isActive: useExistingSecret }, - ) + ); useKeybindings( { 'confirm:previous': handlePrevious, 'confirm:next': handleNext, }, { context: 'Confirmation', isActive: !useExistingSecret }, - ) + ); return ( <> @@ -58,9 +52,7 @@ export function CheckExistingSecretStep({ Setup API key secret - - ANTHROPIC_API_KEY already exists in repository secrets! - + ANTHROPIC_API_KEY already exists in repository secrets! Would you like to: @@ -80,9 +72,7 @@ export function CheckExistingSecretStep({ {!useExistingSecret && ( <> - - Enter new secret name (alphanumeric with underscores): - + Enter new secret name (alphanumeric with underscores): ↑/↓ to select · Enter to continue - ) + ); } diff --git a/src/commands/install-github-app/CheckGitHubStep.tsx b/src/commands/install-github-app/CheckGitHubStep.tsx index a43be6c6c..1f4fc9521 100644 --- a/src/commands/install-github-app/CheckGitHubStep.tsx +++ b/src/commands/install-github-app/CheckGitHubStep.tsx @@ -1,6 +1,6 @@ -import React from 'react' -import { Text } from '@anthropic/ink' +import React from 'react'; +import { Text } from '@anthropic/ink'; export function CheckGitHubStep() { - return Checking GitHub CLI installation… + return Checking GitHub CLI installation…; } diff --git a/src/commands/install-github-app/ChooseRepoStep.tsx b/src/commands/install-github-app/ChooseRepoStep.tsx index 67e921834..634b539ae 100644 --- a/src/commands/install-github-app/ChooseRepoStep.tsx +++ b/src/commands/install-github-app/ChooseRepoStep.tsx @@ -1,16 +1,16 @@ -import React, { useCallback, useState } from 'react' -import TextInput from '../../components/TextInput.js' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' -import { Box, Text } from '@anthropic/ink' -import { useKeybindings } from '../../keybindings/useKeybinding.js' +import React, { useCallback, useState } from 'react'; +import TextInput from '../../components/TextInput.js'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import { Box, Text } from '@anthropic/ink'; +import { useKeybindings } from '../../keybindings/useKeybinding.js'; interface ChooseRepoStepProps { - currentRepo: string | null - useCurrentRepo: boolean - repoUrl: string - onRepoUrlChange: (value: string) => void - onToggleUseCurrentRepo: (useCurrentRepo: boolean) => void - onSubmit: () => void + currentRepo: string | null; + useCurrentRepo: boolean; + repoUrl: string; + onRepoUrlChange: (value: string) => void; + onToggleUseCurrentRepo: (useCurrentRepo: boolean) => void; + onSubmit: () => void; } export function ChooseRepoStep({ @@ -21,32 +21,32 @@ export function ChooseRepoStep({ onSubmit, onToggleUseCurrentRepo, }: ChooseRepoStepProps) { - const [cursorOffset, setCursorOffset] = useState(0) - const [showEmptyError, setShowEmptyError] = useState(false) - const terminalSize = useTerminalSize() - const textInputColumns = terminalSize.columns + const [cursorOffset, setCursorOffset] = useState(0); + const [showEmptyError, setShowEmptyError] = useState(false); + const terminalSize = useTerminalSize(); + const textInputColumns = terminalSize.columns; const handleSubmit = useCallback(() => { - const repoName = useCurrentRepo ? currentRepo : repoUrl + const repoName = useCurrentRepo ? currentRepo : repoUrl; if (!repoName?.trim()) { - setShowEmptyError(true) - return + setShowEmptyError(true); + return; } - onSubmit() - }, [useCurrentRepo, currentRepo, repoUrl, onSubmit]) + onSubmit(); + }, [useCurrentRepo, currentRepo, repoUrl, onSubmit]); // When the text input is visible, omit confirm:yes so bare 'y' passes // through to the input instead of submitting. TextInput's onSubmit handles // Enter. Keep the Confirmation context (not Settings) to avoid j/k bindings. - const isTextInputVisible = !useCurrentRepo || !currentRepo + const isTextInputVisible = !useCurrentRepo || !currentRepo; const handlePrevious = useCallback(() => { - onToggleUseCurrentRepo(true) - setShowEmptyError(false) - }, [onToggleUseCurrentRepo]) + onToggleUseCurrentRepo(true); + setShowEmptyError(false); + }, [onToggleUseCurrentRepo]); const handleNext = useCallback(() => { - onToggleUseCurrentRepo(false) - setShowEmptyError(false) - }, [onToggleUseCurrentRepo]) + onToggleUseCurrentRepo(false); + setShowEmptyError(false); + }, [onToggleUseCurrentRepo]); useKeybindings( { @@ -55,14 +55,14 @@ export function ChooseRepoStep({ 'confirm:yes': handleSubmit, }, { context: 'Confirmation', isActive: !isTextInputVisible }, - ) + ); useKeybindings( { 'confirm:previous': handlePrevious, 'confirm:next': handleNext, }, { context: 'Confirmation', isActive: isTextInputVisible }, - ) + ); return ( <> @@ -73,10 +73,7 @@ export function ChooseRepoStep({ {currentRepo && ( - + {useCurrentRepo ? '> ' : ' '} Use current repository: {currentRepo} @@ -96,8 +93,8 @@ export function ChooseRepoStep({ { - onRepoUrlChange(value) - setShowEmptyError(false) + onRepoUrlChange(value); + setShowEmptyError(false); }} onSubmit={handleSubmit} focus={true} @@ -116,10 +113,8 @@ export function ChooseRepoStep({ )} - - {currentRepo ? '↑/↓ to select · ' : ''}Enter to continue - + {currentRepo ? '↑/↓ to select · ' : ''}Enter to continue - ) + ); } diff --git a/src/commands/install-github-app/CreatingStep.tsx b/src/commands/install-github-app/CreatingStep.tsx index c021d0bcb..48f2eac08 100644 --- a/src/commands/install-github-app/CreatingStep.tsx +++ b/src/commands/install-github-app/CreatingStep.tsx @@ -1,14 +1,14 @@ -import React from 'react' -import { Box, Text } from '@anthropic/ink' -import type { Workflow } from './types.js' +import React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import type { Workflow } from './types.js'; interface CreatingStepProps { - currentWorkflowInstallStep: number - secretExists: boolean - useExistingSecret: boolean - secretName: string - skipWorkflow?: boolean - selectedWorkflows: Workflow[] + currentWorkflowInstallStep: number; + secretExists: boolean; + useExistingSecret: boolean; + secretName: string; + skipWorkflow?: boolean; + selectedWorkflows: Workflow[]; } export function CreatingStep({ @@ -22,21 +22,15 @@ export function CreatingStep({ const progressSteps = skipWorkflow ? [ 'Getting repository information', - secretExists && useExistingSecret - ? 'Using existing API key secret' - : `Setting up ${secretName} secret`, + secretExists && useExistingSecret ? 'Using existing API key secret' : `Setting up ${secretName} secret`, ] : [ 'Getting repository information', 'Creating branch', - selectedWorkflows.length > 1 - ? 'Creating workflow files' - : 'Creating workflow file', - secretExists && useExistingSecret - ? 'Using existing API key secret' - : `Setting up ${secretName} secret`, + selectedWorkflows.length > 1 ? 'Creating workflow files' : 'Creating workflow file', + secretExists && useExistingSecret ? 'Using existing API key secret' : `Setting up ${secretName} secret`, 'Opening pull request page', - ] + ]; return ( <> @@ -46,33 +40,25 @@ export function CreatingStep({ Create GitHub Actions workflow {progressSteps.map((stepText, index) => { - let status: 'completed' | 'in-progress' | 'pending' = 'pending' + let status: 'completed' | 'in-progress' | 'pending' = 'pending'; if (index < currentWorkflowInstallStep) { - status = 'completed' + status = 'completed'; } else if (index === currentWorkflowInstallStep) { - status = 'in-progress' + status = 'in-progress'; } return ( - + {status === 'completed' ? '✓ ' : ''} {stepText} {status === 'in-progress' ? '…' : ''} - ) + ); })} - ) + ); } diff --git a/src/commands/install-github-app/ErrorStep.tsx b/src/commands/install-github-app/ErrorStep.tsx index 5864a0659..19f9a30e2 100644 --- a/src/commands/install-github-app/ErrorStep.tsx +++ b/src/commands/install-github-app/ErrorStep.tsx @@ -1,18 +1,14 @@ -import React from 'react' -import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js' -import { Box, Text } from '@anthropic/ink' +import React from 'react'; +import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js'; +import { Box, Text } from '@anthropic/ink'; interface ErrorStepProps { - error: string | undefined - errorReason?: string - errorInstructions?: string[] + error: string | undefined; + errorReason?: string; + errorInstructions?: string[]; } -export function ErrorStep({ - error, - errorReason, - errorInstructions, -}: ErrorStepProps) { +export function ErrorStep({ error, errorReason, errorInstructions }: ErrorStepProps) { return ( <> @@ -38,8 +34,7 @@ export function ErrorStep({ )} - For manual setup instructions, see:{' '} - {GITHUB_ACTION_SETUP_DOCS_URL} + For manual setup instructions, see: {GITHUB_ACTION_SETUP_DOCS_URL} @@ -47,5 +42,5 @@ export function ErrorStep({ Press any key to exit - ) + ); } diff --git a/src/commands/install-github-app/ExistingWorkflowStep.tsx b/src/commands/install-github-app/ExistingWorkflowStep.tsx index 11b0a1bb2..2a11e6adf 100644 --- a/src/commands/install-github-app/ExistingWorkflowStep.tsx +++ b/src/commands/install-github-app/ExistingWorkflowStep.tsx @@ -1,16 +1,13 @@ -import React from 'react' -import { Select } from 'src/components/CustomSelect/index.js' -import { Box, Text } from '@anthropic/ink' +import React from 'react'; +import { Select } from 'src/components/CustomSelect/index.js'; +import { Box, Text } from '@anthropic/ink'; interface ExistingWorkflowStepProps { - repoName: string - onSelectAction: (action: 'update' | 'skip' | 'exit') => void + repoName: string; + onSelectAction: (action: 'update' | 'skip' | 'exit') => void; } -export function ExistingWorkflowStep({ - repoName, - onSelectAction, -}: ExistingWorkflowStepProps) { +export function ExistingWorkflowStep({ repoName, onSelectAction }: ExistingWorkflowStepProps) { const options = [ { label: 'Update workflow file with latest version', @@ -24,15 +21,15 @@ export function ExistingWorkflowStep({ label: 'Exit without making changes', value: 'exit', }, - ] + ]; const handleSelect = (value: string) => { - onSelectAction(value as 'update' | 'skip' | 'exit') - } + onSelectAction(value as 'update' | 'skip' | 'exit'); + }; const handleCancel = () => { - onSelectAction('exit') - } + onSelectAction('exit'); + }; return ( @@ -43,28 +40,21 @@ export function ExistingWorkflowStep({ - A Claude workflow file already exists at{' '} - .github/workflows/claude.yml + A Claude workflow file already exists at .github/workflows/claude.yml What would you like to do? - View the latest workflow template at:{' '} - - https://github.com/anthropics/claude-code-action/blob/main/examples/claude.yml - + https://github.com/anthropics/claude-code-action/blob/main/examples/claude.yml - ) + ); } diff --git a/src/commands/install-github-app/InstallAppStep.tsx b/src/commands/install-github-app/InstallAppStep.tsx index e966578c1..2ba78cc3c 100644 --- a/src/commands/install-github-app/InstallAppStep.tsx +++ b/src/commands/install-github-app/InstallAppStep.tsx @@ -1,17 +1,17 @@ -import figures from 'figures' -import React from 'react' -import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js' -import { Box, Text } from '@anthropic/ink' -import { useKeybinding } from '../../keybindings/useKeybinding.js' +import figures from 'figures'; +import React from 'react'; +import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js'; +import { Box, Text } from '@anthropic/ink'; +import { useKeybinding } from '../../keybindings/useKeybinding.js'; interface InstallAppStepProps { - repoUrl: string - onSubmit: () => void + repoUrl: string; + onSubmit: () => void; } export function InstallAppStep({ repoUrl, onSubmit }: InstallAppStepProps) { // Enter to submit - useKeybinding('confirm:yes', onSubmit, { context: 'Confirmation' }) + useKeybinding('confirm:yes', onSubmit, { context: 'Confirmation' }); return ( @@ -33,9 +33,7 @@ export function InstallAppStep({ repoUrl, onSubmit }: InstallAppStepProps) { - - Important: Make sure to grant access to this specific repository - + Important: Make sure to grant access to this specific repository @@ -44,10 +42,9 @@ export function InstallAppStep({ repoUrl, onSubmit }: InstallAppStepProps) { - Having trouble? See manual setup instructions at:{' '} - {GITHUB_ACTION_SETUP_DOCS_URL} + Having trouble? See manual setup instructions at: {GITHUB_ACTION_SETUP_DOCS_URL} - ) + ); } diff --git a/src/commands/install-github-app/OAuthFlowStep.tsx b/src/commands/install-github-app/OAuthFlowStep.tsx index f207b00ea..621fbc43d 100644 --- a/src/commands/install-github-app/OAuthFlowStep.tsx +++ b/src/commands/install-github-app/OAuthFlowStep.tsx @@ -1,20 +1,20 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from 'src/services/analytics/index.js' -import { KeyboardShortcutHint } from '@anthropic/ink' -import { Spinner } from '../../components/Spinner.js' -import TextInput from '../../components/TextInput.js' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' -import { type KeyboardEvent, setClipboard, Box, Link, Text } from '@anthropic/ink' -import { OAuthService } from '../../services/oauth/index.js' -import { saveOAuthTokensIfNeeded } from '../../utils/auth.js' -import { logError } from '../../utils/log.js' +} from 'src/services/analytics/index.js'; +import { KeyboardShortcutHint } from '@anthropic/ink'; +import { Spinner } from '../../components/Spinner.js'; +import TextInput from '../../components/TextInput.js'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import { type KeyboardEvent, setClipboard, Box, Link, Text } from '@anthropic/ink'; +import { OAuthService } from '../../services/oauth/index.js'; +import { saveOAuthTokensIfNeeded } from '../../utils/auth.js'; +import { logError } from '../../utils/log.js'; interface OAuthFlowStepProps { - onSuccess: (token: string) => void - onCancel: () => void + onSuccess: (token: string) => void; + onCancel: () => void; } type OAuthStatus = @@ -23,139 +23,132 @@ type OAuthStatus = | { state: 'processing' } | { state: 'success'; token: string } | { state: 'error'; message: string; toRetry?: OAuthStatus } - | { state: 'about_to_retry'; nextState: OAuthStatus } + | { state: 'about_to_retry'; nextState: OAuthStatus }; -const PASTE_HERE_MSG = 'Paste code here if prompted > ' +const PASTE_HERE_MSG = 'Paste code here if prompted > '; -export function OAuthFlowStep({ - onSuccess, - onCancel, -}: OAuthFlowStepProps): React.ReactNode { +export function OAuthFlowStep({ onSuccess, onCancel }: OAuthFlowStepProps): React.ReactNode { const [oauthStatus, setOAuthStatus] = useState({ state: 'starting', - }) - const [oauthService] = useState(() => new OAuthService()) - const [pastedCode, setPastedCode] = useState('') - const [cursorOffset, setCursorOffset] = useState(0) - const [showPastePrompt, setShowPastePrompt] = useState(false) - const [urlCopied, setUrlCopied] = useState(false) - const timersRef = useRef>(new Set()) + }); + const [oauthService] = useState(() => new OAuthService()); + const [pastedCode, setPastedCode] = useState(''); + const [cursorOffset, setCursorOffset] = useState(0); + const [showPastePrompt, setShowPastePrompt] = useState(false); + const [urlCopied, setUrlCopied] = useState(false); + const timersRef = useRef>(new Set()); // Separate ref so startOAuth's timer clear doesn't cancel the urlCopied reset - const urlCopiedTimerRef = useRef(undefined) + const urlCopiedTimerRef = useRef(undefined); - const terminalSize = useTerminalSize() - const textInputColumns = Math.max( - 50, - terminalSize.columns - PASTE_HERE_MSG.length - 4, - ) + const terminalSize = useTerminalSize(); + const textInputColumns = Math.max(50, terminalSize.columns - PASTE_HERE_MSG.length - 4); function handleKeyDown(e: KeyboardEvent): void { - if (oauthStatus.state !== 'error') return - e.preventDefault() + if (oauthStatus.state !== 'error') return; + e.preventDefault(); if (e.key === 'return' && oauthStatus.toRetry) { - setPastedCode('') - setCursorOffset(0) + setPastedCode(''); + setCursorOffset(0); setOAuthStatus({ state: 'about_to_retry', nextState: oauthStatus.toRetry, - }) + }); } else { - onCancel() + onCancel(); } } async function handleSubmitCode(value: string, url: string) { try { // Expecting format "authorizationCode#state" from the authorization callback URL - const [authorizationCode, state] = value.split('#') + const [authorizationCode, state] = value.split('#'); if (!authorizationCode || !state) { setOAuthStatus({ state: 'error', message: 'Invalid code. Please make sure the full code was copied', toRetry: { state: 'waiting_for_login', url }, - }) - return + }); + return; } // Track which path the user is taking (manual code entry) - logEvent('tengu_oauth_manual_entry', {}) + logEvent('tengu_oauth_manual_entry', {}); oauthService.handleManualAuthCodeInput({ authorizationCode, state, - }) + }); } catch (err: unknown) { - logError(err) + logError(err); setOAuthStatus({ state: 'error', message: (err as Error).message, toRetry: { state: 'waiting_for_login', url }, - }) + }); } } const startOAuth = useCallback(async () => { // Clear any existing timers when starting new OAuth flow - timersRef.current.forEach(timer => clearTimeout(timer)) - timersRef.current.clear() + timersRef.current.forEach(timer => clearTimeout(timer)); + timersRef.current.clear(); try { const result = await oauthService.startOAuthFlow( async url => { - setOAuthStatus({ state: 'waiting_for_login', url }) - const timer = setTimeout(setShowPastePrompt, 3000, true) - timersRef.current.add(timer) + setOAuthStatus({ state: 'waiting_for_login', url }); + const timer = setTimeout(setShowPastePrompt, 3000, true); + timersRef.current.add(timer); }, { loginWithClaudeAi: true, // Always use Claude AI for subscription tokens inferenceOnly: true, expiresIn: 365 * 24 * 60 * 60, // 1 year }, - ) + ); // Show processing state - setOAuthStatus({ state: 'processing' }) + setOAuthStatus({ state: 'processing' }); // OAuthFlowStep creates inference-only tokens for GitHub Actions, not a // replacement login. Use saveOAuthTokensIfNeeded directly to avoid // performLogout which would destroy the user's existing auth session. - saveOAuthTokensIfNeeded(result) + saveOAuthTokensIfNeeded(result); // For OAuth flow, the access token can be used as an API key const timer1 = setTimeout( (setOAuthStatus, accessToken, onSuccess, timersRef) => { - setOAuthStatus({ state: 'success', token: accessToken }) + setOAuthStatus({ state: 'success', token: accessToken }); // Auto-continue after brief delay to show success - const timer2 = setTimeout(onSuccess, 1000, accessToken) - timersRef.current.add(timer2 as unknown as NodeJS.Timeout) + const timer2 = setTimeout(onSuccess, 1000, accessToken); + timersRef.current.add(timer2 as unknown as NodeJS.Timeout); }, 100, setOAuthStatus, result.accessToken, onSuccess, timersRef, - ) - timersRef.current.add(timer1) + ); + timersRef.current.add(timer1); } catch (err) { - const errorMessage = (err as Error).message + const errorMessage = (err as Error).message; setOAuthStatus({ state: 'error', message: errorMessage, toRetry: { state: 'starting' }, // Allow retry by starting fresh OAuth flow - }) - logError(err) + }); + logError(err); logEvent('tengu_oauth_error', { - error: - errorMessage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + error: errorMessage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); } - }, [oauthService, onSuccess]) + }, [oauthService, onSuccess]); useEffect(() => { if (oauthStatus.state === 'starting') { - void startOAuth() + void startOAuth(); } - }, [oauthStatus.state, startOAuth]) + }, [oauthStatus.state, startOAuth]); // Retry logic useEffect(() => { @@ -163,46 +156,41 @@ export function OAuthFlowStep({ const timer = setTimeout( (nextState, setShowPastePrompt, setOAuthStatus) => { // Only show paste prompt when retrying to waiting_for_login - setShowPastePrompt(nextState.state === 'waiting_for_login') - setOAuthStatus(nextState) + setShowPastePrompt(nextState.state === 'waiting_for_login'); + setOAuthStatus(nextState); }, 500, oauthStatus.nextState, setShowPastePrompt, setOAuthStatus, - ) - timersRef.current.add(timer) + ); + timersRef.current.add(timer); } - }, [oauthStatus]) + }, [oauthStatus]); useEffect(() => { - if ( - pastedCode === 'c' && - oauthStatus.state === 'waiting_for_login' && - showPastePrompt && - !urlCopied - ) { + if (pastedCode === 'c' && oauthStatus.state === 'waiting_for_login' && showPastePrompt && !urlCopied) { void setClipboard(oauthStatus.url).then(raw => { - if (raw) process.stdout.write(raw) - setUrlCopied(true) - clearTimeout(urlCopiedTimerRef.current) - urlCopiedTimerRef.current = setTimeout(setUrlCopied, 2000, false) - }) - setPastedCode('') + if (raw) process.stdout.write(raw); + setUrlCopied(true); + clearTimeout(urlCopiedTimerRef.current); + urlCopiedTimerRef.current = setTimeout(setUrlCopied, 2000, false); + }); + setPastedCode(''); } - }, [pastedCode, oauthStatus, showPastePrompt, urlCopied]) + }, [pastedCode, oauthStatus, showPastePrompt, urlCopied]); // Cleanup OAuth service and timers when component unmounts useEffect(() => { - const timers = timersRef.current + const timers = timersRef.current; return () => { - oauthService.cleanup() + oauthService.cleanup(); // Clear all timers - timers.forEach(timer => clearTimeout(timer)) - timers.clear() - clearTimeout(urlCopiedTimerRef.current) - } - }, [oauthService]) + timers.forEach(timer => clearTimeout(timer)); + timers.clear(); + clearTimeout(urlCopiedTimerRef.current); + }; + }, [oauthService]); // Helper function to render the appropriate status message function renderStatusMessage(): React.ReactNode { @@ -213,7 +201,7 @@ export function OAuthFlowStep({ Starting authentication… - ) + ); case 'waiting_for_login': return ( @@ -221,9 +209,7 @@ export function OAuthFlowStep({ {!showPastePrompt && ( - - Opening browser to sign in with your Claude account… - + Opening browser to sign in with your Claude account… )} @@ -233,9 +219,7 @@ export function OAuthFlowStep({ - handleSubmitCode(value, oauthStatus.url) - } + onSubmit={(value: string) => handleSubmitCode(value, oauthStatus.url)} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} columns={textInputColumns} @@ -243,7 +227,7 @@ export function OAuthFlowStep({ )} - ) + ); case 'processing': return ( @@ -251,52 +235,42 @@ export function OAuthFlowStep({ Processing authentication… - ) + ); case 'success': return ( - - ✓ Authentication token created successfully! - + ✓ Authentication token created successfully! Using token for GitHub Actions setup… - ) + ); case 'error': return ( OAuth error: {oauthStatus.message} {oauthStatus.toRetry ? ( - - Press Enter to try again, or any other key to cancel - + Press Enter to try again, or any other key to cancel ) : ( Press any key to return to API key selection )} - ) + ); case 'about_to_retry': return ( Retrying… - ) + ); default: - return null + return null; } } return ( - + {/* Show header inline only for initial starting state */} {oauthStatus.state === 'starting' && ( @@ -305,21 +279,17 @@ export function OAuthFlowStep({ )} {/* Show header for non-starting states (to avoid duplicate with inline header)*/} - {oauthStatus.state !== 'success' && - oauthStatus.state !== 'starting' && - oauthStatus.state !== 'processing' && ( - - Create Authentication Token - Creating a long-lived token for GitHub Actions - - )} + {oauthStatus.state !== 'success' && oauthStatus.state !== 'starting' && oauthStatus.state !== 'processing' && ( + + Create Authentication Token + Creating a long-lived token for GitHub Actions + + )} {/* Show URL when paste prompt is visible */} {oauthStatus.state === 'waiting_for_login' && showPastePrompt && ( - - Browser didn't open? Use the url below to sign in{' '} - + Browser didn't open? Use the url below to sign in {urlCopied ? ( (Copied!) ) : ( @@ -337,5 +307,5 @@ export function OAuthFlowStep({ {renderStatusMessage()} - ) + ); } diff --git a/src/commands/install-github-app/SuccessStep.tsx b/src/commands/install-github-app/SuccessStep.tsx index 2080f3dc7..a7be52f16 100644 --- a/src/commands/install-github-app/SuccessStep.tsx +++ b/src/commands/install-github-app/SuccessStep.tsx @@ -1,12 +1,12 @@ -import React from 'react' -import { Box, Text } from '@anthropic/ink' +import React from 'react'; +import { Box, Text } from '@anthropic/ink'; type SuccessStepProps = { - secretExists: boolean - useExistingSecret: boolean - secretName: string - skipWorkflow?: boolean -} + secretExists: boolean; + useExistingSecret: boolean; + secretName: string; + skipWorkflow?: boolean; +}; export function SuccessStep({ secretExists, @@ -21,14 +21,10 @@ export function SuccessStep({ Install GitHub App Success - {!skipWorkflow && ( - ✓ GitHub Actions workflow created! - )} + {!skipWorkflow && ✓ GitHub Actions workflow created!} {secretExists && useExistingSecret && ( - - ✓ Using existing ANTHROPIC_API_KEY secret - + ✓ Using existing ANTHROPIC_API_KEY secret )} {(!secretExists || !useExistingSecret) && ( @@ -41,18 +37,14 @@ export function SuccessStep({ {skipWorkflow ? ( <> - - 1. Install the Claude GitHub App if you haven't already - + 1. Install the Claude GitHub App if you haven't already 2. Your workflow file was kept unchanged 3. API key is configured and ready to use ) : ( <> 1. A pre-filled PR page has been created - - 2. Install the Claude GitHub App if you haven't already - + 2. Install the Claude GitHub App if you haven't already 3. Merge the PR to enable Claude PR assistance )} @@ -61,5 +53,5 @@ export function SuccessStep({ Press any key to exit - ) + ); } diff --git a/src/commands/install-github-app/WarningsStep.tsx b/src/commands/install-github-app/WarningsStep.tsx index c3d347798..3d9bc6242 100644 --- a/src/commands/install-github-app/WarningsStep.tsx +++ b/src/commands/install-github-app/WarningsStep.tsx @@ -1,27 +1,25 @@ -import figures from 'figures' -import React from 'react' -import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js' -import { Box, Text } from '@anthropic/ink' -import { useKeybinding } from '../../keybindings/useKeybinding.js' -import type { Warning } from './types.js' +import figures from 'figures'; +import React from 'react'; +import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js'; +import { Box, Text } from '@anthropic/ink'; +import { useKeybinding } from '../../keybindings/useKeybinding.js'; +import type { Warning } from './types.js'; interface WarningsStepProps { - warnings: Warning[] - onContinue: () => void + warnings: Warning[]; + onContinue: () => void; } export function WarningsStep({ warnings, onContinue }: WarningsStepProps) { // Enter to continue - useKeybinding('confirm:yes', onContinue, { context: 'Confirmation' }) + useKeybinding('confirm:yes', onContinue, { context: 'Confirmation' }); return ( <> {figures.warning} Setup Warnings - - We found some potential issues, but you can continue anyway - + We found some potential issues, but you can continue anyway {warnings.map((warning, index) => ( @@ -55,5 +53,5 @@ export function WarningsStep({ warnings, onContinue }: WarningsStepProps) { - ) + ); } diff --git a/src/commands/install-github-app/install-github-app.tsx b/src/commands/install-github-app/install-github-app.tsx index c57be920c..4595b4da3 100644 --- a/src/commands/install-github-app/install-github-app.tsx +++ b/src/commands/install-github-app/install-github-app.tsx @@ -1,32 +1,32 @@ -import { execa } from 'execa' -import React, { useCallback, useState } from 'react' +import { execa } from 'execa'; +import React, { useCallback, useState } from 'react'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from 'src/services/analytics/index.js' -import { WorkflowMultiselectDialog } from '../../components/WorkflowMultiselectDialog.js' -import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js' -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' -import { type KeyboardEvent, Box } from '@anthropic/ink' -import type { LocalJSXCommandOnDone } from '../../types/command.js' -import { getAnthropicApiKey, isAnthropicAuthEnabled } from '../../utils/auth.js' -import { openBrowser } from '../../utils/browser.js' -import { execFileNoThrow } from '../../utils/execFileNoThrow.js' -import { getGithubRepo } from '../../utils/git.js' -import { plural } from '../../utils/stringUtils.js' -import { ApiKeyStep } from './ApiKeyStep.js' -import { CheckExistingSecretStep } from './CheckExistingSecretStep.js' -import { CheckGitHubStep } from './CheckGitHubStep.js' -import { ChooseRepoStep } from './ChooseRepoStep.js' -import { CreatingStep } from './CreatingStep.js' -import { ErrorStep } from './ErrorStep.js' -import { ExistingWorkflowStep } from './ExistingWorkflowStep.js' -import { InstallAppStep } from './InstallAppStep.js' -import { OAuthFlowStep } from './OAuthFlowStep.js' -import { SuccessStep } from './SuccessStep.js' -import { setupGitHubActions } from './setupGitHubActions.js' -import type { State, Warning, Workflow } from './types.js' -import { WarningsStep } from './WarningsStep.js' +} from 'src/services/analytics/index.js'; +import { WorkflowMultiselectDialog } from '../../components/WorkflowMultiselectDialog.js'; +import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js'; +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; +import { type KeyboardEvent, Box } from '@anthropic/ink'; +import type { LocalJSXCommandOnDone } from '../../types/command.js'; +import { getAnthropicApiKey, isAnthropicAuthEnabled } from '../../utils/auth.js'; +import { openBrowser } from '../../utils/browser.js'; +import { execFileNoThrow } from '../../utils/execFileNoThrow.js'; +import { getGithubRepo } from '../../utils/git.js'; +import { plural } from '../../utils/stringUtils.js'; +import { ApiKeyStep } from './ApiKeyStep.js'; +import { CheckExistingSecretStep } from './CheckExistingSecretStep.js'; +import { CheckGitHubStep } from './CheckGitHubStep.js'; +import { ChooseRepoStep } from './ChooseRepoStep.js'; +import { CreatingStep } from './CreatingStep.js'; +import { ErrorStep } from './ErrorStep.js'; +import { ExistingWorkflowStep } from './ExistingWorkflowStep.js'; +import { InstallAppStep } from './InstallAppStep.js'; +import { OAuthFlowStep } from './OAuthFlowStep.js'; +import { SuccessStep } from './SuccessStep.js'; +import { setupGitHubActions } from './setupGitHubActions.js'; +import type { State, Warning, Workflow } from './types.js'; +import { WarningsStep } from './WarningsStep.js'; const INITIAL_STATE: State = { step: 'check-gh', @@ -44,54 +44,50 @@ const INITIAL_STATE: State = { selectedWorkflows: ['claude', 'claude-review'] as Workflow[], selectedApiKeyOption: 'new' as 'existing' | 'new' | 'oauth', authType: 'api_key', -} +}; -function InstallGitHubApp(props: { - onDone: (message: string) => void -}): React.ReactNode { - const [existingApiKey] = useState(() => getAnthropicApiKey()) +function InstallGitHubApp(props: { onDone: (message: string) => void }): React.ReactNode { + const [existingApiKey] = useState(() => getAnthropicApiKey()); const [state, setState] = useState({ ...INITIAL_STATE, useExistingKey: !!existingApiKey, - selectedApiKeyOption: (existingApiKey - ? 'existing' - : isAnthropicAuthEnabled() - ? 'oauth' - : 'new') as 'existing' | 'new' | 'oauth', - }) - useExitOnCtrlCDWithKeybindings() + selectedApiKeyOption: (existingApiKey ? 'existing' : isAnthropicAuthEnabled() ? 'oauth' : 'new') as + | 'existing' + | 'new' + | 'oauth', + }); + useExitOnCtrlCDWithKeybindings(); React.useEffect(() => { - logEvent('tengu_install_github_app_started', {}) - }, []) + logEvent('tengu_install_github_app_started', {}); + }, []); const checkGitHubCLI = useCallback(async () => { - const warnings: Warning[] = [] + const warnings: Warning[] = []; // Check if gh is installed const ghVersionResult = await execa('gh --version', { shell: true, reject: false, - }) + }); if (ghVersionResult.exitCode !== 0) { warnings.push({ title: 'GitHub CLI not found', - message: - 'GitHub CLI (gh) does not appear to be installed or accessible.', + message: 'GitHub CLI (gh) does not appear to be installed or accessible.', instructions: [ 'Install GitHub CLI from https://cli.github.com/', 'macOS: brew install gh', 'Windows: winget install --id GitHub.cli', 'Linux: See installation instructions at https://github.com/cli/cli#installation', ], - }) + }); } // Check auth status const authResult = await execa('gh auth status -a', { shell: true, reject: false, - }) + }); if (authResult.exitCode !== 0) { warnings.push({ title: 'GitHub CLI not authenticated', @@ -101,19 +97,19 @@ function InstallGitHubApp(props: { 'Follow the prompts to authenticate with GitHub', 'Or set up authentication using environment variables or other methods', ], - }) + }); } else { // Check if required scopes are present in the Token scopes line - const tokenScopesMatch = authResult.stdout.match(/Token scopes:.*$/m) + const tokenScopesMatch = authResult.stdout.match(/Token scopes:.*$/m); if (tokenScopesMatch) { - const scopes = tokenScopesMatch[0] - const missingScopes: string[] = [] + const scopes = tokenScopesMatch[0]; + const missingScopes: string[] = []; if (!scopes.includes('repo')) { - missingScopes.push('repo') + missingScopes.push('repo'); } if (!scopes.includes('workflow')) { - missingScopes.push('workflow') + missingScopes.push('workflow'); } if (missingScopes.length > 0) { @@ -131,18 +127,18 @@ function InstallGitHubApp(props: { '', 'This will add the necessary permissions to manage workflows and secrets.', ], - })) - return + })); + return; } } } // Check if in a git repo and get remote URL - const currentRepo = (await getGithubRepo()) ?? '' + const currentRepo = (await getGithubRepo()) ?? ''; logEvent('tengu_install_github_app_step_completed', { step: 'check-gh' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + }); setState(prev => ({ ...prev, @@ -151,14 +147,14 @@ function InstallGitHubApp(props: { selectedRepoName: currentRepo, useCurrentRepo: !!currentRepo, // Set to false if no repo detected step: warnings.length > 0 ? 'warnings' : 'choose-repo', - })) - }, []) + })); + }, []); React.useEffect(() => { if (state.step === 'check-gh') { - void checkGitHubCLI() + void checkGitHubCLI(); } - }, [state.step, checkGitHubCLI]) + }, [state.step, checkGitHubCLI]); const runSetupGitHubActions = useCallback( async (apiKeyOrOAuthToken: string | null, secretName: string) => { @@ -166,7 +162,7 @@ function InstallGitHubApp(props: { ...prev, step: 'creating', currentWorkflowInstallStep: 0, - })) + })); try { await setupGitHubActions( @@ -177,7 +173,7 @@ function InstallGitHubApp(props: { setState(prev => ({ ...prev, currentWorkflowInstallStep: prev.currentWorkflowInstallStep + 1, - })) + })); }, state.workflowAction === 'skip', state.selectedWorkflows, @@ -187,22 +183,18 @@ function InstallGitHubApp(props: { workflowExists: state.workflowExists, secretExists: state.secretExists, }, - ) + ); logEvent('tengu_install_github_app_step_completed', { step: 'creating' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - setState(prev => ({ ...prev, step: 'success' })) + }); + setState(prev => ({ ...prev, step: 'success' })); } catch (error) { - const errorMessage = - error instanceof Error - ? error.message - : 'Failed to set up GitHub Actions' + const errorMessage = error instanceof Error ? error.message : 'Failed to set up GitHub Actions'; if (errorMessage.includes('workflow file already exists')) { logEvent('tengu_install_github_app_error', { - reason: - 'workflow_file_exists' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + reason: 'workflow_file_exists' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); setState(prev => ({ ...prev, step: 'error', @@ -215,12 +207,11 @@ function InstallGitHubApp(props: { ' 2. Update the existing file manually using the template from:', ` ${GITHUB_ACTION_SETUP_DOCS_URL}`, ], - })) + })); } else { logEvent('tengu_install_github_app_error', { - reason: - 'setup_github_actions_failed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + reason: 'setup_github_actions_failed' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); setState(prev => ({ ...prev, @@ -228,7 +219,7 @@ function InstallGitHubApp(props: { error: errorMessage, errorReason: 'GitHub Actions setup failed', errorInstructions: [], - })) + })); } } }, @@ -241,42 +232,32 @@ function InstallGitHubApp(props: { state.secretExists, state.authType, ], - ) + ); async function openGitHubAppInstallation() { - const installUrl = 'https://github.com/apps/claude' - await openBrowser(installUrl) + const installUrl = 'https://github.com/apps/claude'; + await openBrowser(installUrl); } - async function checkRepositoryPermissions( - repoName: string, - ): Promise<{ hasAccess: boolean; error?: string }> { + async function checkRepositoryPermissions(repoName: string): Promise<{ hasAccess: boolean; error?: string }> { try { - const result = await execFileNoThrow('gh', [ - 'api', - `repos/${repoName}`, - '--jq', - '.permissions.admin', - ]) + const result = await execFileNoThrow('gh', ['api', `repos/${repoName}`, '--jq', '.permissions.admin']); if (result.code === 0) { - const hasAdmin = result.stdout.trim() === 'true' - return { hasAccess: hasAdmin } + const hasAdmin = result.stdout.trim() === 'true'; + return { hasAccess: hasAdmin }; } - if ( - result.stderr.includes('404') || - result.stderr.includes('Not Found') - ) { + if (result.stderr.includes('404') || result.stderr.includes('Not Found')) { return { hasAccess: false, error: 'repository_not_found', - } + }; } - return { hasAccess: false } + return { hasAccess: false }; } catch { - return { hasAccess: false } + return { hasAccess: false }; } } @@ -286,9 +267,9 @@ function InstallGitHubApp(props: { `repos/${repoName}/contents/.github/workflows/claude.yml`, '--jq', '.sha', - ]) + ]); - return checkFileResult.code === 0 + return checkFileResult.code === 0; } async function checkExistingSecret() { @@ -299,20 +280,20 @@ function InstallGitHubApp(props: { 'actions', '--repo', state.selectedRepoName, - ]) + ]); if (checkSecretsResult.code === 0) { - const lines = checkSecretsResult.stdout.split('\n') + const lines = checkSecretsResult.stdout.split('\n'); const hasAnthropicKey = lines.some((line: string) => { - return /^ANTHROPIC_API_KEY\s+/.test(line) - }) + return /^ANTHROPIC_API_KEY\s+/.test(line); + }); if (hasAnthropicKey) { setState(prev => ({ ...prev, secretExists: true, step: 'check-existing-secret', - })) + })); } else { // No existing secret found if (existingApiKey) { @@ -321,11 +302,11 @@ function InstallGitHubApp(props: { ...prev, apiKeyOrOAuthToken: existingApiKey, useExistingKey: true, - })) - await runSetupGitHubActions(existingApiKey, state.secretName) + })); + await runSetupGitHubActions(existingApiKey, state.secretName); } else { // No local key, go to API key step - setState(prev => ({ ...prev, step: 'api-key' })) + setState(prev => ({ ...prev, step: 'api-key' })); } } } else { @@ -336,11 +317,11 @@ function InstallGitHubApp(props: { ...prev, apiKeyOrOAuthToken: existingApiKey, useExistingKey: true, - })) - await runSetupGitHubActions(existingApiKey, state.secretName) + })); + await runSetupGitHubActions(existingApiKey, state.secretName); } else { // No local key, go to API key step - setState(prev => ({ ...prev, step: 'api-key' })) + setState(prev => ({ ...prev, step: 'api-key' })); } } } @@ -349,33 +330,28 @@ function InstallGitHubApp(props: { if (state.step === 'warnings') { logEvent('tengu_install_github_app_step_completed', { step: 'warnings' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - setState(prev => ({ ...prev, step: 'install-app' })) - setTimeout(openGitHubAppInstallation, 0) + }); + setState(prev => ({ ...prev, step: 'install-app' })); + setTimeout(openGitHubAppInstallation, 0); } else if (state.step === 'choose-repo') { - let repoName = state.useCurrentRepo - ? state.currentRepo - : state.selectedRepoName + let repoName = state.useCurrentRepo ? state.currentRepo : state.selectedRepoName; if (!repoName.trim()) { - return + return; } - const repoWarnings: Warning[] = [] + const repoWarnings: Warning[] = []; if (repoName.includes('github.com')) { - const match = repoName.match(/github\.com[:/]([^/]+\/[^/]+)(\.git)?$/) + const match = repoName.match(/github\.com[:/]([^/]+\/[^/]+)(\.git)?$/); if (!match) { repoWarnings.push({ title: 'Invalid GitHub URL format', message: 'The repository URL format appears to be invalid.', - instructions: [ - 'Use format: owner/repo or https://github.com/owner/repo', - 'Example: anthropics/claude-cli', - ], - }) + instructions: ['Use format: owner/repo or https://github.com/owner/repo', 'Example: anthropics/claude-cli'], + }); } else { - repoName = match[1]?.replace(/\.git$/, '') || '' + repoName = match[1]?.replace(/\.git$/, '') || ''; } } @@ -383,14 +359,11 @@ function InstallGitHubApp(props: { repoWarnings.push({ title: 'Repository format warning', message: 'Repository should be in format "owner/repo"', - instructions: [ - 'Use format: owner/repo', - 'Example: anthropics/claude-cli', - ], - }) + instructions: ['Use format: owner/repo', 'Example: anthropics/claude-cli'], + }); } - const permissionCheck = await checkRepositoryPermissions(repoName) + const permissionCheck = await checkRepositoryPermissions(repoName); if (permissionCheck.error === 'repository_not_found') { repoWarnings.push({ @@ -402,7 +375,7 @@ function InstallGitHubApp(props: { 'For private repositories, make sure your GitHub token has the "repo" scope', 'You can add the repo scope with: gh auth refresh -h github.com -s repo,workflow', ], - }) + }); } else if (!permissionCheck.hasAccess) { repoWarnings.push({ title: 'Admin permissions required', @@ -412,81 +385,77 @@ function InstallGitHubApp(props: { 'Ask a repository admin to run this command if setup fails', 'Alternatively, you can use the manual setup instructions', ], - }) + }); } - const workflowExists = await checkExistingWorkflowFile(repoName) + const workflowExists = await checkExistingWorkflowFile(repoName); if (repoWarnings.length > 0) { - const allWarnings = [...state.warnings, ...repoWarnings] + const allWarnings = [...state.warnings, ...repoWarnings]; setState(prev => ({ ...prev, selectedRepoName: repoName, workflowExists, warnings: allWarnings, step: 'warnings', - })) + })); } else { logEvent('tengu_install_github_app_step_completed', { step: 'choose-repo' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + }); setState(prev => ({ ...prev, selectedRepoName: repoName, workflowExists, step: 'install-app', - })) - setTimeout(openGitHubAppInstallation, 0) + })); + setTimeout(openGitHubAppInstallation, 0); } } else if (state.step === 'install-app') { logEvent('tengu_install_github_app_step_completed', { step: 'install-app' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + }); if (state.workflowExists) { - setState(prev => ({ ...prev, step: 'check-existing-workflow' })) + setState(prev => ({ ...prev, step: 'check-existing-workflow' })); } else { - setState(prev => ({ ...prev, step: 'select-workflows' })) + setState(prev => ({ ...prev, step: 'select-workflows' })); } } else if (state.step === 'check-existing-workflow') { - return + return; } else if (state.step === 'select-workflows') { // Handled by the WorkflowMultiselectDialog component - return + return; } else if (state.step === 'check-existing-secret') { logEvent('tengu_install_github_app_step_completed', { step: 'check-existing-secret' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + }); if (state.useExistingSecret) { - await runSetupGitHubActions(null, state.secretName) + await runSetupGitHubActions(null, state.secretName); } else { // User wants to use a new secret name with their API key - await runSetupGitHubActions(state.apiKeyOrOAuthToken, state.secretName) + await runSetupGitHubActions(state.apiKeyOrOAuthToken, state.secretName); } } else if (state.step === 'api-key') { // In the new flow, api-key step only appears when user has no existing key // They either entered a new key or will create OAuth token if (state.selectedApiKeyOption === 'oauth') { // OAuth flow already handled by handleCreateOAuthToken - return + return; } // If user selected 'existing' option, use the existing API key - const apiKeyToUse = - state.selectedApiKeyOption === 'existing' - ? existingApiKey - : state.apiKeyOrOAuthToken + const apiKeyToUse = state.selectedApiKeyOption === 'existing' ? existingApiKey : state.apiKeyOrOAuthToken; if (!apiKeyToUse) { logEvent('tengu_install_github_app_error', { - reason: - 'api_key_missing' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + reason: 'api_key_missing' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); setState(prev => ({ ...prev, step: 'error', error: 'API key is required', - })) - return + })); + return; } // Store the API key being used (either existing or newly entered) @@ -494,7 +463,7 @@ function InstallGitHubApp(props: { ...prev, apiKeyOrOAuthToken: apiKeyToUse, useExistingKey: state.selectedApiKeyOption === 'existing', - })) + })); // Check if ANTHROPIC_API_KEY secret already exists const checkSecretsResult = await execFileNoThrow('gh', [ @@ -504,132 +473,132 @@ function InstallGitHubApp(props: { 'actions', '--repo', state.selectedRepoName, - ]) + ]); if (checkSecretsResult.code === 0) { - const lines = checkSecretsResult.stdout.split('\n') + const lines = checkSecretsResult.stdout.split('\n'); const hasAnthropicKey = lines.some((line: string) => { - return /^ANTHROPIC_API_KEY\s+/.test(line) - }) + return /^ANTHROPIC_API_KEY\s+/.test(line); + }); if (hasAnthropicKey) { logEvent('tengu_install_github_app_step_completed', { step: 'api-key' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + }); setState(prev => ({ ...prev, secretExists: true, step: 'check-existing-secret', - })) + })); } else { logEvent('tengu_install_github_app_step_completed', { step: 'api-key' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + }); // No existing secret, proceed to creating - await runSetupGitHubActions(apiKeyToUse, state.secretName) + await runSetupGitHubActions(apiKeyToUse, state.secretName); } } else { logEvent('tengu_install_github_app_step_completed', { step: 'api-key' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + }); // Error checking secrets, proceed anyway - await runSetupGitHubActions(apiKeyToUse, state.secretName) + await runSetupGitHubActions(apiKeyToUse, state.secretName); } } - } + }; const handleRepoUrlChange = (value: string) => { - setState(prev => ({ ...prev, selectedRepoName: value })) - } + setState(prev => ({ ...prev, selectedRepoName: value })); + }; const handleApiKeyChange = (value: string) => { - setState(prev => ({ ...prev, apiKeyOrOAuthToken: value })) - } + setState(prev => ({ ...prev, apiKeyOrOAuthToken: value })); + }; const handleApiKeyOptionChange = (option: 'existing' | 'new' | 'oauth') => { - setState(prev => ({ ...prev, selectedApiKeyOption: option })) - } + setState(prev => ({ ...prev, selectedApiKeyOption: option })); + }; const handleCreateOAuthToken = useCallback(() => { logEvent('tengu_install_github_app_step_completed', { step: 'api-key' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - setState(prev => ({ ...prev, step: 'oauth-flow' })) - }, []) + }); + setState(prev => ({ ...prev, step: 'oauth-flow' })); + }, []); const handleOAuthSuccess = useCallback( (token: string) => { logEvent('tengu_install_github_app_step_completed', { step: 'oauth-flow' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + }); setState(prev => ({ ...prev, apiKeyOrOAuthToken: token, useExistingKey: false, secretName: 'CLAUDE_CODE_OAUTH_TOKEN', authType: 'oauth_token', - })) - void runSetupGitHubActions(token, 'CLAUDE_CODE_OAUTH_TOKEN') + })); + void runSetupGitHubActions(token, 'CLAUDE_CODE_OAUTH_TOKEN'); }, [runSetupGitHubActions], - ) + ); const handleOAuthCancel = useCallback(() => { - setState(prev => ({ ...prev, step: 'api-key' })) - }, []) + setState(prev => ({ ...prev, step: 'api-key' })); + }, []); const handleSecretNameChange = (value: string) => { - if (value && !/^[a-zA-Z0-9_]+$/.test(value)) return - setState(prev => ({ ...prev, secretName: value })) - } + if (value && !/^[a-zA-Z0-9_]+$/.test(value)) return; + setState(prev => ({ ...prev, secretName: value })); + }; const handleToggleUseCurrentRepo = (useCurrentRepo: boolean) => { setState(prev => ({ ...prev, useCurrentRepo, selectedRepoName: useCurrentRepo ? prev.currentRepo : '', - })) - } + })); + }; const handleToggleUseExistingKey = (useExistingKey: boolean) => { - setState(prev => ({ ...prev, useExistingKey })) - } + setState(prev => ({ ...prev, useExistingKey })); + }; const handleToggleUseExistingSecret = (useExistingSecret: boolean) => { setState(prev => ({ ...prev, useExistingSecret, secretName: useExistingSecret ? 'ANTHROPIC_API_KEY' : '', - })) - } + })); + }; const handleWorkflowAction = async (action: 'update' | 'skip' | 'exit') => { if (action === 'exit') { - props.onDone('Installation cancelled by user') - return + props.onDone('Installation cancelled by user'); + return; } logEvent('tengu_install_github_app_step_completed', { step: 'check-existing-workflow' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + }); - setState(prev => ({ ...prev, workflowAction: action })) + setState(prev => ({ ...prev, workflowAction: action })); if (action === 'skip' || action === 'update') { // Check if user has existing local API key if (existingApiKey) { - await checkExistingSecret() + await checkExistingSecret(); } else { // No local key, go straight to API key step - setState(prev => ({ ...prev, step: 'api-key' })) + setState(prev => ({ ...prev, step: 'api-key' })); } } - } + }; function handleDismissKeyDown(e: KeyboardEvent): void { - e.preventDefault() + e.preventDefault(); if (state.step === 'success') { - logEvent('tengu_install_github_app_completed', {}) + logEvent('tengu_install_github_app_completed', {}); } props.onDone( state.step === 'success' @@ -637,16 +606,14 @@ function InstallGitHubApp(props: { : state.error ? `Couldn't install GitHub App: ${state.error}\nFor manual setup instructions, see: ${GITHUB_ACTION_SETUP_DOCS_URL}` : `GitHub App installation failed\nFor manual setup instructions, see: ${GITHUB_ACTION_SETUP_DOCS_URL}`, - ) + ); } switch (state.step) { case 'check-gh': - return + return ; case 'warnings': - return ( - - ) + return ; case 'choose-repo': return ( - ) + ); case 'install-app': - return ( - - ) + return ; case 'check-existing-workflow': - return ( - - ) + return ; case 'check-existing-secret': return ( - ) + ); case 'api-key': return ( - ) + ); case 'creating': return ( - ) + ); case 'success': return ( @@ -719,17 +674,13 @@ function InstallGitHubApp(props: { skipWorkflow={state.workflowAction === 'skip'} /> - ) + ); case 'error': return ( - + - ) + ); case 'select-workflows': return ( { logEvent('tengu_install_github_app_step_completed', { step: 'select-workflows' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + }); setState(prev => ({ ...prev, selectedWorkflows, - })) + })); // Check if user has existing local API key if (existingApiKey) { - void checkExistingSecret() + void checkExistingSecret(); } else { // No local key, go straight to API key step - setState(prev => ({ ...prev, step: 'api-key' })) + setState(prev => ({ ...prev, step: 'api-key' })); } }} /> - ) + ); case 'oauth-flow': - return ( - - ) + return ; } } -export async function call( - onDone: LocalJSXCommandOnDone, -): Promise { - return +export async function call(onDone: LocalJSXCommandOnDone): Promise { + return ; } diff --git a/src/commands/install-github-app/src/components/CustomSelect/index.ts b/src/commands/install-github-app/src/components/CustomSelect/index.ts index d95b49c7a..4947147f2 100644 --- a/src/commands/install-github-app/src/components/CustomSelect/index.ts +++ b/src/commands/install-github-app/src/components/CustomSelect/index.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type Select = any; +export type Select = any diff --git a/src/commands/install-github-app/src/services/analytics/index.ts b/src/commands/install-github-app/src/services/analytics/index.ts index 142e7b6f5..2e0f796da 100644 --- a/src/commands/install-github-app/src/services/analytics/index.ts +++ b/src/commands/install-github-app/src/services/analytics/index.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any; -export type logEvent = any; +export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any +export type logEvent = any diff --git a/src/commands/install-github-app/src/utils/config.ts b/src/commands/install-github-app/src/utils/config.ts index 507e64a40..6ed23f24d 100644 --- a/src/commands/install-github-app/src/utils/config.ts +++ b/src/commands/install-github-app/src/utils/config.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type saveGlobalConfig = any; +export type saveGlobalConfig = any diff --git a/src/commands/install.tsx b/src/commands/install.tsx index bb72ad4a8..5d2b0c2bd 100644 --- a/src/commands/install.tsx +++ b/src/commands/install.tsx @@ -1,28 +1,25 @@ -import { homedir } from 'node:os' -import { join } from 'node:path' -import React, { useEffect, useState } from 'react' -import type { CommandResultDisplay } from 'src/commands.js' -import { logEvent } from 'src/services/analytics/index.js' -import { StatusIcon } from '@anthropic/ink' -import { Box, wrappedRender as render, Text } from '@anthropic/ink' -import { logForDebugging } from '../utils/debug.js' -import { env } from '../utils/env.js' -import { errorMessage } from '../utils/errors.js' +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import React, { useEffect, useState } from 'react'; +import type { CommandResultDisplay } from 'src/commands.js'; +import { logEvent } from 'src/services/analytics/index.js'; +import { StatusIcon } from '@anthropic/ink'; +import { Box, wrappedRender as render, Text } from '@anthropic/ink'; +import { logForDebugging } from '../utils/debug.js'; +import { env } from '../utils/env.js'; +import { errorMessage } from '../utils/errors.js'; import { checkInstall, cleanupNpmInstallations, cleanupShellAliases, installLatest, -} from '../utils/nativeInstaller/index.js' -import { - getInitialSettings, - updateSettingsForSource, -} from '../utils/settings/settings.js' +} from '../utils/nativeInstaller/index.js'; +import { getInitialSettings, updateSettingsForSource } from '../utils/settings/settings.js'; interface InstallProps { - onDone: (result: string, options?: { display?: CommandResultDisplay }) => void - force?: boolean - target?: string // 'latest', 'stable', or version like '1.0.34' + onDone: (result: string, options?: { display?: CommandResultDisplay }) => void; + force?: boolean; + target?: string; // 'latest', 'stable', or version like '1.0.34' } type InstallState = @@ -32,24 +29,24 @@ type InstallState = | { type: 'setting-up' } | { type: 'set-up'; messages: string[] } | { type: 'success'; version: string; setupMessages?: string[] } - | { type: 'error'; message: string; warnings?: string[] } + | { type: 'error'; message: string; warnings?: string[] }; function getInstallationPath(): string { - const isWindows = env.platform === 'win32' - const homeDir = homedir() + const isWindows = env.platform === 'win32'; + const homeDir = homedir(); if (isWindows) { // Convert to Windows-style path - const windowsPath = join(homeDir, '.local', 'bin', 'claude.exe') + const windowsPath = join(homeDir, '.local', 'bin', 'claude.exe'); // Replace forward slashes with backslashes for Windows display - return windowsPath.replace(/\//g, '\\') + return windowsPath.replace(/\//g, '\\'); } - return '~/.local/bin/claude' + return '~/.local/bin/claude'; } function SetupNotes({ messages }: { messages: string[] }): React.ReactNode { - if (messages.length === 0) return null + if (messages.length === 0) return null; return ( @@ -65,183 +62,151 @@ function SetupNotes({ messages }: { messages: string[] }): React.ReactNode { ))} - ) + ); } function Install({ onDone, force, target }: InstallProps): React.ReactNode { - const [state, setState] = useState({ type: 'checking' }) + const [state, setState] = useState({ type: 'checking' }); useEffect(() => { async function run() { try { - logForDebugging( - `Install: Starting installation process (force=${force}, target=${target})`, - ) + logForDebugging(`Install: Starting installation process (force=${force}, target=${target})`); // Install native build first - const channelOrVersion = - target || getInitialSettings()?.autoUpdatesChannel || 'latest' - setState({ type: 'installing', version: channelOrVersion }) + const channelOrVersion = target || getInitialSettings()?.autoUpdatesChannel || 'latest'; + setState({ type: 'installing', version: channelOrVersion }); // Pass force flag to trigger reinstall even if up to date logForDebugging( `Install: Calling installLatest(channelOrVersion=${channelOrVersion}, forceReinstall=${force})`, - ) - const result = await installLatest(channelOrVersion, force) + ); + const result = await installLatest(channelOrVersion, force); logForDebugging( `Install: installLatest returned version=${result.latestVersion}, wasUpdated=${result.wasUpdated}, lockFailed=${result.lockFailed}`, - ) + ); // Check specifically for lock failure if (result.lockFailed) { throw new Error( 'Could not install - another process is currently installing Claude. Please try again in a moment.', - ) + ); } // If we couldn't get the version, there might be an issue if (!result.latestVersion) { - logForDebugging( - 'Install: Failed to retrieve version information during install', - { level: 'error' }, - ) + logForDebugging('Install: Failed to retrieve version information during install', { level: 'error' }); } if (!result.wasUpdated) { - logForDebugging('Install: Already up to date') + logForDebugging('Install: Already up to date'); } // Set up launcher and shell integration - setState({ type: 'setting-up' }) - const setupMessages = await checkInstall(true) + setState({ type: 'setting-up' }); + const setupMessages = await checkInstall(true); - logForDebugging( - `Install: Setup launcher completed with ${setupMessages.length} messages`, - ) + logForDebugging(`Install: Setup launcher completed with ${setupMessages.length} messages`); if (setupMessages.length > 0) { - setupMessages.forEach(msg => - logForDebugging(`Install: Setup message: ${msg.message}`), - ) + setupMessages.forEach(msg => logForDebugging(`Install: Setup message: ${msg.message}`)); } // Now that native installation succeeded, clean up old npm installations - logForDebugging( - 'Install: Cleaning up npm installations after successful install', - ) - const { removed, errors, warnings } = await cleanupNpmInstallations() + logForDebugging('Install: Cleaning up npm installations after successful install'); + const { removed, errors, warnings } = await cleanupNpmInstallations(); if (removed > 0) { - logForDebugging(`Cleaned up ${removed} npm installation(s)`) + logForDebugging(`Cleaned up ${removed} npm installation(s)`); } if (errors.length > 0) { - logForDebugging(`Cleanup errors: ${errors.join(', ')}`) + logForDebugging(`Cleanup errors: ${errors.join(', ')}`); // Continue despite cleanup errors - native install already succeeded } // Clean up old shell aliases - const aliasMessages = await cleanupShellAliases() + const aliasMessages = await cleanupShellAliases(); if (aliasMessages.length > 0) { - logForDebugging( - `Shell alias cleanup: ${aliasMessages.map(m => m.message).join('; ')}`, - ) + logForDebugging(`Shell alias cleanup: ${aliasMessages.map(m => m.message).join('; ')}`); } // Log success event logEvent('tengu_claude_install_command', { has_version: result.latestVersion ? 1 : 0, forced: force ? 1 : 0, - }) + }); // If user explicitly specified a channel, save it to settings if (target === 'latest' || target === 'stable') { updateSettingsForSource('userSettings', { autoUpdatesChannel: target, - }) - logForDebugging( - `Install: Saved autoUpdatesChannel=${target} to user settings`, - ) + }); + logForDebugging(`Install: Saved autoUpdatesChannel=${target} to user settings`); } // Combine all warning/info messages (convert SetupMessage to string) - const allWarnings = [...warnings, ...aliasMessages.map(m => m.message)] + const allWarnings = [...warnings, ...aliasMessages.map(m => m.message)]; // Check if there were any setup errors or notes if (setupMessages.length > 0) { setState({ type: 'set-up', messages: setupMessages.map(m => m.message), - }) + }); // Still mark as success but show both setup messages and cleanup warnings setTimeout(setState, 2000, { type: 'success' as const, version: result.latestVersion || 'current', - setupMessages: [ - ...setupMessages.map(m => m.message), - ...allWarnings, - ], - }) + setupMessages: [...setupMessages.map(m => m.message), ...allWarnings], + }); } else { // No setup messages, go straight to success (but still show cleanup warnings if any) - logForDebugging('Install: Shell PATH already configured') + logForDebugging('Install: Shell PATH already configured'); setState({ type: 'success', version: result.latestVersion || 'current', setupMessages: allWarnings.length > 0 ? allWarnings : undefined, - }) + }); } } catch (error) { logForDebugging(`Install command failed: ${error}`, { level: 'error', - }) + }); setState({ type: 'error', message: errorMessage(error), - }) + }); } } - void run() - }, [force, target]) + void run(); + }, [force, target]); useEffect(() => { if (state.type === 'success') { // Give success message time to render before exiting - setTimeout( - onDone, - 2000, - 'Claude Code installation completed successfully', - { - display: 'system' as const, - }, - ) + setTimeout(onDone, 2000, 'Claude Code installation completed successfully', { + display: 'system' as const, + }); } else if (state.type === 'error') { // Give error message time to render before exiting setTimeout(onDone, 3000, 'Claude Code installation failed', { display: 'system' as const, - }) + }); } - }, [state, onDone]) + }, [state, onDone]); return ( - {state.type === 'checking' && ( - Checking installation status... - )} + {state.type === 'checking' && Checking installation status...} - {state.type === 'cleaning-npm' && ( - Cleaning up old npm installations... - )} + {state.type === 'cleaning-npm' && Cleaning up old npm installations...} {state.type === 'installing' && ( - - Installing Claude Code native build {state.version}... - + Installing Claude Code native build {state.version}... )} - {state.type === 'setting-up' && ( - Setting up launcher and shell integration... - )} + {state.type === 'setting-up' && Setting up launcher and shell integration...} {state.type === 'set-up' && } @@ -291,7 +256,7 @@ function Install({ onDone, force, target }: InstallProps): React.ReactNode { )} - ) + ); } // This is only used from cli.tsx, not as a slash command @@ -301,27 +266,24 @@ export const install = { description: 'Install Claude Code native build', argumentHint: '[options]', async call( - onDone: ( - result: string, - options?: { display?: CommandResultDisplay }, - ) => void, + onDone: (result: string, options?: { display?: CommandResultDisplay }) => void, _context: unknown, args: string[], ) { // Parse arguments - const force = args.includes('--force') - const nonFlagArgs = args.filter(arg => !arg.startsWith('--')) - const target = nonFlagArgs[0] // 'latest', 'stable', or version like '1.0.34' + const force = args.includes('--force'); + const nonFlagArgs = args.filter(arg => !arg.startsWith('--')); + const target = nonFlagArgs[0]; // 'latest', 'stable', or version like '1.0.34' const { unmount } = await render( { - unmount() - onDone(result, options) + unmount(); + onDone(result, options); }} force={force} target={target} />, - ) + ); }, -} +}; diff --git a/src/commands/job/job.tsx b/src/commands/job/job.tsx index 2942db182..539b8dfd1 100644 --- a/src/commands/job/job.tsx +++ b/src/commands/job/job.tsx @@ -1,4 +1,4 @@ -import type { LocalJSXCommandOnDone, LocalJSXCommandContext } from '../../types/command.js' +import type { LocalJSXCommandOnDone, LocalJSXCommandContext } from '../../types/command.js'; /** * /job slash command — manages template jobs from inside the REPL. @@ -11,24 +11,24 @@ export async function call( _context: LocalJSXCommandContext, args: string, ): Promise { - const parts = args ? args.trim().split(/\s+/) : [] - const sub = parts[0] || 'list' + const parts = args ? args.trim().split(/\s+/) : []; + const sub = parts[0] || 'list'; // Capture console output so we can return it as onDone text - const lines: string[] = [] - const origLog = console.log - const origError = console.error - console.log = (...a: unknown[]) => lines.push(a.map(String).join(' ')) - console.error = (...a: unknown[]) => lines.push(a.map(String).join(' ')) + const lines: string[] = []; + const origLog = console.log; + const origError = console.error; + console.log = (...a: unknown[]) => lines.push(a.map(String).join(' ')); + console.error = (...a: unknown[]) => lines.push(a.map(String).join(' ')); try { - const { templatesMain } = await import('../../cli/handlers/templateJobs.js') - await templatesMain([sub, ...parts.slice(1)]) + const { templatesMain } = await import('../../cli/handlers/templateJobs.js'); + await templatesMain([sub, ...parts.slice(1)]); } finally { - console.log = origLog - console.error = origError + console.log = origLog; + console.error = origError; } - onDone(lines.join('\n') || 'Done.', { display: 'system' }) - return null + onDone(lines.join('\n') || 'Done.', { display: 'system' }); + return null; } diff --git a/src/commands/login/login.tsx b/src/commands/login/login.tsx index b4329fe62..cbe148357 100644 --- a/src/commands/login/login.tsx +++ b/src/commands/login/login.tsx @@ -1,90 +1,81 @@ -import { feature } from 'bun:bundle' -import * as React from 'react' -import { resetCostState } from '../../bootstrap/state.js' -import { - clearTrustedDeviceToken, - enrollTrustedDevice, -} from '../../bridge/trustedDevice.js' -import type { LocalJSXCommandContext } from '../../commands.js' -import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js' -import { ConsoleOAuthFlow } from '../../components/ConsoleOAuthFlow.js' -import { Dialog } from '@anthropic/ink' -import { useMainLoopModel } from '../../hooks/useMainLoopModel.js' -import { Text } from '@anthropic/ink' -import { refreshGrowthBookAfterAuthChange } from '../../services/analytics/growthbook.js' -import { refreshPolicyLimits } from '../../services/policyLimits/index.js' -import { refreshRemoteManagedSettings } from '../../services/remoteManagedSettings/index.js' -import type { LocalJSXCommandOnDone } from '../../types/command.js' -import { stripSignatureBlocks } from '../../utils/messages.js' +import { feature } from 'bun:bundle'; +import * as React from 'react'; +import { resetCostState } from '../../bootstrap/state.js'; +import { clearTrustedDeviceToken, enrollTrustedDevice } from '../../bridge/trustedDevice.js'; +import type { LocalJSXCommandContext } from '../../commands.js'; +import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js'; +import { ConsoleOAuthFlow } from '../../components/ConsoleOAuthFlow.js'; +import { Dialog } from '@anthropic/ink'; +import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'; +import { Text } from '@anthropic/ink'; +import { refreshGrowthBookAfterAuthChange } from '../../services/analytics/growthbook.js'; +import { refreshPolicyLimits } from '../../services/policyLimits/index.js'; +import { refreshRemoteManagedSettings } from '../../services/remoteManagedSettings/index.js'; +import type { LocalJSXCommandOnDone } from '../../types/command.js'; +import { stripSignatureBlocks } from '../../utils/messages.js'; import { checkAndDisableAutoModeIfNeeded, checkAndDisableBypassPermissionsIfNeeded, resetAutoModeGateCheck, resetBypassPermissionsCheck, -} from '../../utils/permissions/bypassPermissionsKillswitch.js' -import { resetUserCache } from '../../utils/user.js' +} from '../../utils/permissions/bypassPermissionsKillswitch.js'; +import { resetUserCache } from '../../utils/user.js'; -export async function call( - onDone: LocalJSXCommandOnDone, - context: LocalJSXCommandContext, -): Promise { +export async function call(onDone: LocalJSXCommandOnDone, context: LocalJSXCommandContext): Promise { return ( { - context.onChangeAPIKey() + context.onChangeAPIKey(); // Signature-bearing blocks (thinking, connector_text) are bound to the API key — // strip them so the new key doesn't reject stale signatures. - context.setMessages(stripSignatureBlocks) + context.setMessages(stripSignatureBlocks); if (success) { // Post-login refresh logic. Keep in sync with onboarding in src/interactiveHelpers.tsx // Reset cost state when switching accounts - resetCostState() + resetCostState(); // Refresh remotely managed settings after login (non-blocking) - void refreshRemoteManagedSettings() + void refreshRemoteManagedSettings(); // Refresh policy limits after login (non-blocking) - void refreshPolicyLimits() + void refreshPolicyLimits(); // Clear user data cache BEFORE GrowthBook refresh so it picks up fresh credentials - resetUserCache() + resetUserCache(); // Refresh GrowthBook after login to get updated feature flags (e.g., for claude.ai MCPs) - refreshGrowthBookAfterAuthChange() + refreshGrowthBookAfterAuthChange(); // Clear any stale trusted device token from a previous account before // re-enrolling — prevents sending the old token on bridge calls while // the async enrollTrustedDevice() is in-flight. - clearTrustedDeviceToken() + clearTrustedDeviceToken(); // Enroll as a trusted device for Remote Control (10-min fresh-session window) - void enrollTrustedDevice() + void enrollTrustedDevice(); // Reset killswitch gate checks and re-run with new org - resetBypassPermissionsCheck() - const appState = context.getAppState() - void checkAndDisableBypassPermissionsIfNeeded( - appState.toolPermissionContext, - context.setAppState, - ) + resetBypassPermissionsCheck(); + const appState = context.getAppState(); + void checkAndDisableBypassPermissionsIfNeeded(appState.toolPermissionContext, context.setAppState); if (feature('TRANSCRIPT_CLASSIFIER')) { - resetAutoModeGateCheck() + resetAutoModeGateCheck(); void checkAndDisableAutoModeIfNeeded( appState.toolPermissionContext, context.setAppState, appState.fastMode, - ) + ); } // Increment authVersion to trigger re-fetching of auth-dependent data in hooks (e.g., MCP servers) context.setAppState(prev => ({ ...prev, authVersion: prev.authVersion + 1, - })) + })); } - onDone(success ? 'Login successful' : 'Login interrupted') + onDone(success ? 'Login successful' : 'Login interrupted'); }} /> - ) + ); } export function Login(props: { - onDone: (success: boolean, mainLoopModel: string) => void - startingMessage?: string + onDone: (success: boolean, mainLoopModel: string) => void; + startingMessage?: string; }): React.ReactNode { - const mainLoopModel = useMainLoopModel() + const mainLoopModel = useMainLoopModel(); return ( Press {exitState.keyName} again to exit ) : ( - + ) } > - props.onDone(true, mainLoopModel)} - startingMessage={props.startingMessage} - /> + props.onDone(true, mainLoopModel)} startingMessage={props.startingMessage} /> - ) + ); } diff --git a/src/commands/logout/logout.tsx b/src/commands/logout/logout.tsx index 4223feff4..ea9a23f02 100644 --- a/src/commands/logout/logout.tsx +++ b/src/commands/logout/logout.tsx @@ -1,89 +1,80 @@ -import * as React from 'react' -import { clearTrustedDeviceTokenCache } from '../../bridge/trustedDevice.js' -import { Text } from '@anthropic/ink' -import { refreshGrowthBookAfterAuthChange } from '../../services/analytics/growthbook.js' -import { - getGroveNoticeConfig, - getGroveSettings, -} from '../../services/api/grove.js' -import { clearPolicyLimitsCache } from '../../services/policyLimits/index.js' +import * as React from 'react'; +import { clearTrustedDeviceTokenCache } from '../../bridge/trustedDevice.js'; +import { Text } from '@anthropic/ink'; +import { refreshGrowthBookAfterAuthChange } from '../../services/analytics/growthbook.js'; +import { getGroveNoticeConfig, getGroveSettings } from '../../services/api/grove.js'; +import { clearPolicyLimitsCache } from '../../services/policyLimits/index.js'; // flushTelemetry is loaded lazily to avoid pulling in ~1.1MB of OpenTelemetry at startup -import { clearRemoteManagedSettingsCache } from '../../services/remoteManagedSettings/index.js' -import { getClaudeAIOAuthTokens, removeApiKey } from '../../utils/auth.js' -import { clearBetasCaches } from '../../utils/betas.js' -import { saveGlobalConfig } from '../../utils/config.js' -import { gracefulShutdownSync } from '../../utils/gracefulShutdown.js' -import { getSecureStorage } from '../../utils/secureStorage/index.js' -import { clearToolSchemaCache } from '../../utils/toolSchemaCache.js' -import { resetUserCache } from '../../utils/user.js' +import { clearRemoteManagedSettingsCache } from '../../services/remoteManagedSettings/index.js'; +import { getClaudeAIOAuthTokens, removeApiKey } from '../../utils/auth.js'; +import { clearBetasCaches } from '../../utils/betas.js'; +import { saveGlobalConfig } from '../../utils/config.js'; +import { gracefulShutdownSync } from '../../utils/gracefulShutdown.js'; +import { getSecureStorage } from '../../utils/secureStorage/index.js'; +import { clearToolSchemaCache } from '../../utils/toolSchemaCache.js'; +import { resetUserCache } from '../../utils/user.js'; -export async function performLogout({ - clearOnboarding = false, -}): Promise { +export async function performLogout({ clearOnboarding = false }): Promise { // Flush telemetry BEFORE clearing credentials to prevent org data leakage - const { flushTelemetry } = await import( - '../../utils/telemetry/instrumentation.js' - ) - await flushTelemetry() + const { flushTelemetry } = await import('../../utils/telemetry/instrumentation.js'); + await flushTelemetry(); - await removeApiKey() + await removeApiKey(); // Wipe all secure storage data on logout - const secureStorage = getSecureStorage() - secureStorage.delete() + const secureStorage = getSecureStorage(); + secureStorage.delete(); - await clearAuthRelatedCaches() + await clearAuthRelatedCaches(); saveGlobalConfig(current => { - const updated = { ...current } + const updated = { ...current }; if (clearOnboarding) { - updated.hasCompletedOnboarding = false - updated.subscriptionNoticeCount = 0 - updated.hasAvailableSubscription = false + updated.hasCompletedOnboarding = false; + updated.subscriptionNoticeCount = 0; + updated.hasAvailableSubscription = false; if (updated.customApiKeyResponses?.approved) { updated.customApiKeyResponses = { ...updated.customApiKeyResponses, approved: [], - } + }; } } - updated.oauthAccount = undefined - return updated - }) + updated.oauthAccount = undefined; + return updated; + }); } // clearing anything memoized that must be invalidated when user/session/auth changes export async function clearAuthRelatedCaches(): Promise { // Clear the OAuth token cache - getClaudeAIOAuthTokens.cache?.clear?.() - clearTrustedDeviceTokenCache() - clearBetasCaches() - clearToolSchemaCache() + getClaudeAIOAuthTokens.cache?.clear?.(); + clearTrustedDeviceTokenCache(); + clearBetasCaches(); + clearToolSchemaCache(); // Clear user data cache BEFORE GrowthBook refresh so it picks up fresh credentials - resetUserCache() - refreshGrowthBookAfterAuthChange() + resetUserCache(); + refreshGrowthBookAfterAuthChange(); // Clear Grove config cache - getGroveNoticeConfig.cache?.clear?.() - getGroveSettings.cache?.clear?.() + getGroveNoticeConfig.cache?.clear?.(); + getGroveSettings.cache?.clear?.(); // Clear remotely managed settings cache - await clearRemoteManagedSettingsCache() + await clearRemoteManagedSettingsCache(); // Clear policy limits cache - await clearPolicyLimitsCache() + await clearPolicyLimitsCache(); } export async function call(): Promise { - await performLogout({ clearOnboarding: true }) + await performLogout({ clearOnboarding: true }); - const message = ( - Successfully logged out from your Anthropic account. - ) + const message = Successfully logged out from your Anthropic account.; setTimeout(() => { - gracefulShutdownSync(0, 'logout') - }, 200) + gracefulShutdownSync(0, 'logout'); + }, 200); - return message + return message; } diff --git a/src/commands/mcp/mcp.tsx b/src/commands/mcp/mcp.tsx index 4f32927e9..40838a822 100644 --- a/src/commands/mcp/mcp.tsx +++ b/src/commands/mcp/mcp.tsx @@ -1,10 +1,10 @@ -import React, { useEffect, useRef } from 'react' -import { MCPSettings } from '../../components/mcp/index.js' -import { MCPReconnect } from '../../components/mcp/MCPReconnect.js' -import { useMcpToggleEnabled } from '../../services/mcp/MCPConnectionManager.js' -import { useAppState } from '../../state/AppState.js' -import type { LocalJSXCommandOnDone } from '../../types/command.js' -import { PluginSettings } from '../plugin/PluginSettings.js' +import React, { useEffect, useRef } from 'react'; +import { MCPSettings } from '../../components/mcp/index.js'; +import { MCPReconnect } from '../../components/mcp/MCPReconnect.js'; +import { useMcpToggleEnabled } from '../../services/mcp/MCPConnectionManager.js'; +import { useAppState } from '../../state/AppState.js'; +import type { LocalJSXCommandOnDone } from '../../types/command.js'; +import { PluginSettings } from '../plugin/PluginSettings.js'; // TODO: This is a hack to get the context value from toggleMcpServer (useContext only works in a component) // Ideally, all MCP state and functions would be in global state. @@ -13,93 +13,72 @@ function MCPToggle({ target, onComplete, }: { - action: 'enable' | 'disable' - target: string - onComplete: (result: string) => void + action: 'enable' | 'disable'; + target: string; + onComplete: (result: string) => void; }): null { - const mcpClients = useAppState(s => s.mcp.clients) - const toggleMcpServer = useMcpToggleEnabled() - const didRun = useRef(false) + const mcpClients = useAppState(s => s.mcp.clients); + const toggleMcpServer = useMcpToggleEnabled(); + const didRun = useRef(false); useEffect(() => { - if (didRun.current) return - didRun.current = true + if (didRun.current) return; + didRun.current = true; - const isEnabling = action === 'enable' - const clients = mcpClients.filter(c => c.name !== 'ide') + const isEnabling = action === 'enable'; + const clients = mcpClients.filter(c => c.name !== 'ide'); const toToggle = target === 'all' - ? clients.filter(c => - isEnabling ? c.type === 'disabled' : c.type !== 'disabled', - ) - : clients.filter(c => c.name === target) + ? clients.filter(c => (isEnabling ? c.type === 'disabled' : c.type !== 'disabled')) + : clients.filter(c => c.name === target); if (toToggle.length === 0) { onComplete( target === 'all' ? `All MCP servers are already ${isEnabling ? 'enabled' : 'disabled'}` : `MCP server "${target}" not found`, - ) - return + ); + return; } for (const s of toToggle) { - void toggleMcpServer(s.name) + void toggleMcpServer(s.name); } onComplete( target === 'all' ? `${isEnabling ? 'Enabled' : 'Disabled'} ${toToggle.length} MCP server(s)` : `MCP server "${target}" ${isEnabling ? 'enabled' : 'disabled'}`, - ) - }, [action, target, mcpClients, toggleMcpServer, onComplete]) + ); + }, [action, target, mcpClients, toggleMcpServer, onComplete]); - return null + return null; } -export async function call( - onDone: LocalJSXCommandOnDone, - _context: unknown, - args?: string, -): Promise { +export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, args?: string): Promise { if (args) { - const parts = args.trim().split(/\s+/) + const parts = args.trim().split(/\s+/); // Allow /mcp no-redirect to bypass the redirect for testing if (parts[0] === 'no-redirect') { - return + return ; } if (parts[0] === 'reconnect' && parts[1]) { - return ( - - ) + return ; } if (parts[0] === 'enable' || parts[0] === 'disable') { return ( - 1 ? parts.slice(1).join(' ') : 'all'} - onComplete={onDone} - /> - ) + 1 ? parts.slice(1).join(' ') : 'all'} onComplete={onDone} /> + ); } } // Redirect base /mcp command to /plugins installed tab for ant users if (process.env.USER_TYPE === 'ant') { - return ( - - ) + return ; } - return + return ; } diff --git a/src/commands/memory/memory.tsx b/src/commands/memory/memory.tsx index 885ab57dd..3bc4c07b4 100644 --- a/src/commands/memory/memory.tsx +++ b/src/commands/memory/memory.tsx @@ -1,86 +1,74 @@ -import { mkdir, writeFile } from 'fs/promises' -import * as React from 'react' -import type { CommandResultDisplay } from '../../commands.js' -import { Dialog } from '@anthropic/ink' -import { MemoryFileSelector } from '../../components/memory/MemoryFileSelector.js' -import { getRelativeMemoryPath } from '../../components/memory/MemoryUpdateNotification.js' -import { Box, Link, Text } from '@anthropic/ink' -import type { LocalJSXCommandCall } from '../../types/command.js' -import { clearMemoryFileCaches, getMemoryFiles } from '../../utils/claudemd.js' -import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' -import { getErrnoCode } from '../../utils/errors.js' -import { logError } from '../../utils/log.js' -import { editFileInEditor } from '../../utils/promptEditor.js' +import { mkdir, writeFile } from 'fs/promises'; +import * as React from 'react'; +import type { CommandResultDisplay } from '../../commands.js'; +import { Dialog } from '@anthropic/ink'; +import { MemoryFileSelector } from '../../components/memory/MemoryFileSelector.js'; +import { getRelativeMemoryPath } from '../../components/memory/MemoryUpdateNotification.js'; +import { Box, Link, Text } from '@anthropic/ink'; +import type { LocalJSXCommandCall } from '../../types/command.js'; +import { clearMemoryFileCaches, getMemoryFiles } from '../../utils/claudemd.js'; +import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'; +import { getErrnoCode } from '../../utils/errors.js'; +import { logError } from '../../utils/log.js'; +import { editFileInEditor } from '../../utils/promptEditor.js'; function MemoryCommand({ onDone, }: { - onDone: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void + onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void; }): React.ReactNode { const handleSelectMemoryFile = async (memoryPath: string) => { try { // Create claude directory if it doesn't exist (idempotent with recursive) if (memoryPath.includes(getClaudeConfigHomeDir())) { - await mkdir(getClaudeConfigHomeDir(), { recursive: true }) + await mkdir(getClaudeConfigHomeDir(), { recursive: true }); } // Create file if it doesn't exist (wx flag fails if file exists, // which we catch to preserve existing content) try { - await writeFile(memoryPath, '', { encoding: 'utf8', flag: 'wx' }) + await writeFile(memoryPath, '', { encoding: 'utf8', flag: 'wx' }); } catch (e: unknown) { if (getErrnoCode(e) !== 'EEXIST') { - throw e + throw e; } } - await editFileInEditor(memoryPath) + await editFileInEditor(memoryPath); // Determine which environment variable controls the editor - let editorSource = 'default' - let editorValue = '' + let editorSource = 'default'; + let editorValue = ''; if (process.env.VISUAL) { - editorSource = '$VISUAL' - editorValue = process.env.VISUAL + editorSource = '$VISUAL'; + editorValue = process.env.VISUAL; } else if (process.env.EDITOR) { - editorSource = '$EDITOR' - editorValue = process.env.EDITOR + editorSource = '$EDITOR'; + editorValue = process.env.EDITOR; } - const editorInfo = - editorSource !== 'default' - ? `Using ${editorSource}="${editorValue}".` - : '' + const editorInfo = editorSource !== 'default' ? `Using ${editorSource}="${editorValue}".` : ''; const editorHint = editorInfo ? `> ${editorInfo} To change editor, set $EDITOR or $VISUAL environment variable.` - : `> To use a different editor, set the $EDITOR or $VISUAL environment variable.` + : `> To use a different editor, set the $EDITOR or $VISUAL environment variable.`; - onDone( - `Opened memory file at ${getRelativeMemoryPath(memoryPath)}\n\n${editorHint}`, - { display: 'system' }, - ) + onDone(`Opened memory file at ${getRelativeMemoryPath(memoryPath)}\n\n${editorHint}`, { display: 'system' }); } catch (error) { - logError(error) - onDone(`Error opening memory file: ${error}`) + logError(error); + onDone(`Error opening memory file: ${error}`); } - } + }; const handleCancel = () => { - onDone('Cancelled memory editing', { display: 'system' }) - } + onDone('Cancelled memory editing', { display: 'system' }); + }; return ( - + @@ -90,13 +78,13 @@ function MemoryCommand({ - ) + ); } export const call: LocalJSXCommandCall = async onDone => { // Clear + prime before rendering — Suspense handles the unprimed case, // but awaiting here avoids a fallback flash on initial open. - clearMemoryFileCaches() - await getMemoryFiles() - return -} + clearMemoryFileCaches(); + await getMemoryFiles(); + return ; +}; diff --git a/src/commands/mobile/mobile.tsx b/src/commands/mobile/mobile.tsx index 6c1d8f828..bbc588c7a 100644 --- a/src/commands/mobile/mobile.tsx +++ b/src/commands/mobile/mobile.tsx @@ -1,16 +1,16 @@ -import { toString as qrToString } from 'qrcode' -import * as React from 'react' -import { useCallback, useEffect, useState } from 'react' -import { Pane } from '@anthropic/ink' -import { type KeyboardEvent, Box, Text } from '@anthropic/ink' -import { useKeybinding } from '../../keybindings/useKeybinding.js' -import type { LocalJSXCommandOnDone } from '../../types/command.js' +import { toString as qrToString } from 'qrcode'; +import * as React from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { Pane } from '@anthropic/ink'; +import { type KeyboardEvent, Box, Text } from '@anthropic/ink'; +import { useKeybinding } from '../../keybindings/useKeybinding.js'; +import type { LocalJSXCommandOnDone } from '../../types/command.js'; -type Platform = 'ios' | 'android' +type Platform = 'ios' | 'android'; type Props = { - onDone: () => void -} + onDone: () => void; +}; const PLATFORMS: Record = { ios: { @@ -19,17 +19,17 @@ const PLATFORMS: Record = { android: { url: 'https://play.google.com/store/apps/details?id=com.anthropic.claude', }, -} +}; function MobileQRCode({ onDone }: Props): React.ReactNode { - const [platform, setPlatform] = useState('ios') + const [platform, setPlatform] = useState('ios'); const [qrCodes, setQrCodes] = useState>({ ios: '', android: '', - }) + }); - const { url } = PLATFORMS[platform] - const qrCode = qrCodes[platform] + const { url } = PLATFORMS[platform]; + const qrCode = qrCodes[platform]; // Generate both QR codes upfront to avoid flicker when switching useEffect(() => { @@ -43,42 +43,37 @@ function MobileQRCode({ onDone }: Props): React.ReactNode { type: 'utf8', errorCorrectionLevel: 'L', }), - ]) - setQrCodes({ ios, android }) + ]); + setQrCodes({ ios, android }); } generateQRCodes().catch(() => { // QR generation failed, leave empty - }) - }, []) + }); + }, []); const handleClose = useCallback(() => { - onDone() - }, [onDone]) + onDone(); + }, [onDone]); - useKeybinding('confirm:no', handleClose, { context: 'Confirmation' }) + useKeybinding('confirm:no', handleClose, { context: 'Confirmation' }); function handleKeyDown(e: KeyboardEvent): void { if (e.key === 'q' || (e.ctrl && e.key === 'c')) { - e.preventDefault() - onDone() - return + e.preventDefault(); + onDone(); + return; } if (e.key === 'tab' || e.key === 'left' || e.key === 'right') { - e.preventDefault() - setPlatform(prev => (prev === 'ios' ? 'android' : 'ios')) + e.preventDefault(); + setPlatform(prev => (prev === 'ios' ? 'android' : 'ios')); } } - const lines = qrCode.split('\n').filter(line => line.length > 0) + const lines = qrCode.split('\n').filter(line => line.length > 0); return ( - + {lines.map((line, i) => ( @@ -94,10 +89,7 @@ function MobileQRCode({ onDone }: Props): React.ReactNode { iOS {' / '} - + Android @@ -106,11 +98,9 @@ function MobileQRCode({ onDone }: Props): React.ReactNode { {url} - ) + ); } -export async function call( - onDone: LocalJSXCommandOnDone, -): Promise { - return +export async function call(onDone: LocalJSXCommandOnDone): Promise { + return ; } diff --git a/src/commands/model/model.tsx b/src/commands/model/model.tsx index f3523305c..5aa2be4c6 100644 --- a/src/commands/model/model.tsx +++ b/src/commands/model/model.tsx @@ -1,119 +1,96 @@ -import chalk from 'chalk' -import * as React from 'react' -import type { CommandResultDisplay } from '../../commands.js' -import { ModelPicker } from '../../components/ModelPicker.js' -import { COMMON_HELP_ARGS, COMMON_INFO_ARGS } from '../../constants/xml.js' +import chalk from 'chalk'; +import * as React from 'react'; +import type { CommandResultDisplay } from '../../commands.js'; +import { ModelPicker } from '../../components/ModelPicker.js'; +import { COMMON_HELP_ARGS, COMMON_INFO_ARGS } from '../../constants/xml.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from '../../services/analytics/index.js' -import { useAppState, useSetAppState } from '../../state/AppState.js' -import type { LocalJSXCommandCall } from '../../types/command.js' -import type { EffortLevel } from '../../utils/effort.js' -import { isBilledAsExtraUsage } from '../../utils/extraUsage.js' +} from '../../services/analytics/index.js'; +import { useAppState, useSetAppState } from '../../state/AppState.js'; +import type { LocalJSXCommandCall } from '../../types/command.js'; +import type { EffortLevel } from '../../utils/effort.js'; +import { isBilledAsExtraUsage } from '../../utils/extraUsage.js'; import { clearFastModeCooldown, isFastModeAvailable, isFastModeEnabled, isFastModeSupportedByModel, -} from '../../utils/fastMode.js' -import { MODEL_ALIASES } from '../../utils/model/aliases.js' -import { - checkOpus1mAccess, - checkSonnet1mAccess, -} from '../../utils/model/check1mAccess.js' +} from '../../utils/fastMode.js'; +import { MODEL_ALIASES } from '../../utils/model/aliases.js'; +import { checkOpus1mAccess, checkSonnet1mAccess } from '../../utils/model/check1mAccess.js'; import { getDefaultMainLoopModelSetting, isOpus1mMergeEnabled, renderDefaultModelSetting, -} from '../../utils/model/model.js' -import { isModelAllowed } from '../../utils/model/modelAllowlist.js' -import { validateModel } from '../../utils/model/validateModel.js' +} from '../../utils/model/model.js'; +import { isModelAllowed } from '../../utils/model/modelAllowlist.js'; +import { validateModel } from '../../utils/model/validateModel.js'; function ModelPickerWrapper({ onDone, }: { - onDone: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void + onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void; }): React.ReactNode { - const mainLoopModel = useAppState(s => s.mainLoopModel) - const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession) - const isFastMode = useAppState(s => s.fastMode) - const setAppState = useSetAppState() + const mainLoopModel = useAppState(s => s.mainLoopModel); + const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession); + const isFastMode = useAppState(s => s.fastMode); + const setAppState = useSetAppState(); function handleCancel(): void { logEvent('tengu_model_command_menu', { - action: - 'cancel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - const displayModel = renderModelLabel(mainLoopModel) + action: 'cancel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + const displayModel = renderModelLabel(mainLoopModel); onDone(`Kept model as ${chalk.bold(displayModel)}`, { display: 'system', - }) + }); } - function handleSelect( - model: string | null, - effort: EffortLevel | undefined, - ): void { + function handleSelect(model: string | null, effort: EffortLevel | undefined): void { logEvent('tengu_model_command_menu', { - action: - model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - from_model: - mainLoopModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - to_model: - model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + action: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + from_model: mainLoopModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + to_model: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); setAppState(prev => ({ ...prev, mainLoopModel: model, mainLoopModelForSession: null, - })) + })); - let message = `Set model to ${chalk.bold(renderModelLabel(model))}` + let message = `Set model to ${chalk.bold(renderModelLabel(model))}`; if (effort !== undefined) { - message += ` with ${chalk.bold(effort)} effort` + message += ` with ${chalk.bold(effort)} effort`; } // Turn off fast mode if switching to unsupported model - let wasFastModeToggledOn = undefined + let wasFastModeToggledOn; if (isFastModeEnabled()) { - clearFastModeCooldown() + clearFastModeCooldown(); if (!isFastModeSupportedByModel(model) && isFastMode) { setAppState(prev => ({ ...prev, fastMode: false, - })) - wasFastModeToggledOn = false + })); + wasFastModeToggledOn = false; // Do not update fast mode in settings since this is an automatic downgrade - } else if ( - isFastModeSupportedByModel(model) && - isFastModeAvailable() && - isFastMode - ) { - message += ` · Fast mode ON` - wasFastModeToggledOn = true + } else if (isFastModeSupportedByModel(model) && isFastModeAvailable() && isFastMode) { + message += ` · Fast mode ON`; + wasFastModeToggledOn = true; } } - if ( - isBilledAsExtraUsage( - model, - wasFastModeToggledOn === true, - isOpus1mMergeEnabled(), - ) - ) { - message += ` · Billed as extra usage` + if (isBilledAsExtraUsage(model, wasFastModeToggledOn === true, isOpus1mMergeEnabled())) { + message += ` · Billed as extra usage`; } if (wasFastModeToggledOn === false) { // Fast mode was toggled off, show suffix after extra usage billing - message += ` · Fast mode OFF` + message += ` · Fast mode OFF`; } - onDone(message) + onDone(message); } return ( @@ -124,37 +101,30 @@ function ModelPickerWrapper({ onCancel={handleCancel} isStandaloneCommand showFastModeNotice={ - isFastModeEnabled() && - isFastMode && - isFastModeSupportedByModel(mainLoopModel) && - isFastModeAvailable() + isFastModeEnabled() && isFastMode && isFastModeSupportedByModel(mainLoopModel) && isFastModeAvailable() } /> - ) + ); } function SetModelAndClose({ args, onDone, }: { - args: string - onDone: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void + args: string; + onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void; }): React.ReactNode { - const isFastMode = useAppState(s => s.fastMode) - const setAppState = useSetAppState() - const model = args === 'default' ? null : args + const isFastMode = useAppState(s => s.fastMode); + const setAppState = useSetAppState(); + const model = args === 'default' ? null : args; React.useEffect(() => { async function handleModelChange(): Promise { if (model && !isModelAllowed(model)) { - onDone( - `Model '${model}' is not available. Your organization restricts model selection.`, - { display: 'system' }, - ) - return + onDone(`Model '${model}' is not available. Your organization restricts model selection.`, { + display: 'system', + }); + return; } // @[MODEL LAUNCH]: Update check for 1M access. @@ -162,47 +132,47 @@ function SetModelAndClose({ onDone( `Opus 4.6 with 1M context is not available for your account. Learn more: https://code.claude.com/docs/en/model-config#extended-context-with-1m`, { display: 'system' }, - ) - return + ); + return; } if (model && isSonnet1mUnavailable(model)) { onDone( `Sonnet 4.6 with 1M context is not available for your account. Learn more: https://code.claude.com/docs/en/model-config#extended-context-with-1m`, { display: 'system' }, - ) - return + ); + return; } // Skip validation for default model if (!model) { - setModel(null) - return + setModel(null); + return; } // Skip validation for known aliases - they're predefined and should work if (isKnownAlias(model)) { - setModel(model) - return + setModel(model); + return; } // Validate and set custom model try { // Don't use parseUserSpecifiedModel for non-aliases since it lowercases the input // and model names are case-sensitive - const { valid, error } = await validateModel(model) + const { valid, error } = await validateModel(model); if (valid) { - setModel(model) + setModel(model); } else { onDone(error || `Model '${model}' not found`, { display: 'system', - }) + }); } } catch (error) { onDone(`Failed to validate model: ${(error as Error).message}`, { display: 'system', - }) + }); } } @@ -211,127 +181,103 @@ function SetModelAndClose({ ...prev, mainLoopModel: modelValue, mainLoopModelForSession: null, - })) - let message = `Set model to ${chalk.bold(renderModelLabel(modelValue))}` + })); + let message = `Set model to ${chalk.bold(renderModelLabel(modelValue))}`; - let wasFastModeToggledOn = undefined + let wasFastModeToggledOn; if (isFastModeEnabled()) { - clearFastModeCooldown() + clearFastModeCooldown(); if (!isFastModeSupportedByModel(modelValue) && isFastMode) { setAppState(prev => ({ ...prev, fastMode: false, - })) - wasFastModeToggledOn = false + })); + wasFastModeToggledOn = false; // Do not update fast mode in settings since this is an automatic downgrade } else if (isFastModeSupportedByModel(modelValue) && isFastMode) { - message += ` · Fast mode ON` - wasFastModeToggledOn = true + message += ` · Fast mode ON`; + wasFastModeToggledOn = true; } } - if ( - isBilledAsExtraUsage( - modelValue, - wasFastModeToggledOn === true, - isOpus1mMergeEnabled(), - ) - ) { - message += ` · Billed as extra usage` + if (isBilledAsExtraUsage(modelValue, wasFastModeToggledOn === true, isOpus1mMergeEnabled())) { + message += ` · Billed as extra usage`; } if (wasFastModeToggledOn === false) { // Fast mode was toggled off, show suffix after extra usage billing - message += ` · Fast mode OFF` + message += ` · Fast mode OFF`; } - onDone(message) + onDone(message); } - void handleModelChange() - }, [model, onDone, setAppState]) + void handleModelChange(); + }, [model, onDone, setAppState]); - return null + return null; } function isKnownAlias(model: string): boolean { - return (MODEL_ALIASES as readonly string[]).includes( - model.toLowerCase().trim(), - ) + return (MODEL_ALIASES as readonly string[]).includes(model.toLowerCase().trim()); } function isOpus1mUnavailable(model: string): boolean { - const m = model.toLowerCase() - return ( - !checkOpus1mAccess() && - !isOpus1mMergeEnabled() && - m.includes('opus') && - m.includes('[1m]') - ) + const m = model.toLowerCase(); + return !checkOpus1mAccess() && !isOpus1mMergeEnabled() && m.includes('opus') && m.includes('[1m]'); } function isSonnet1mUnavailable(model: string): boolean { - const m = model.toLowerCase() + const m = model.toLowerCase(); // Warn about Sonnet and Sonnet 4.6, but not Sonnet 4.5 since that had // a different access criteria. - return ( - !checkSonnet1mAccess() && - (m.includes('sonnet[1m]') || m.includes('sonnet-4-6[1m]')) - ) + return !checkSonnet1mAccess() && (m.includes('sonnet[1m]') || m.includes('sonnet-4-6[1m]')); } -function ShowModelAndClose({ - onDone, -}: { - onDone: (result?: string) => void -}): React.ReactNode { - const mainLoopModel = useAppState(s => s.mainLoopModel) - const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession) - const effortValue = useAppState(s => s.effortValue) - const displayModel = renderModelLabel(mainLoopModel) - const effortInfo = - effortValue !== undefined ? ` (effort: ${effortValue})` : '' +function ShowModelAndClose({ onDone }: { onDone: (result?: string) => void }): React.ReactNode { + const mainLoopModel = useAppState(s => s.mainLoopModel); + const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession); + const effortValue = useAppState(s => s.effortValue); + const displayModel = renderModelLabel(mainLoopModel); + const effortInfo = effortValue !== undefined ? ` (effort: ${effortValue})` : ''; if (mainLoopModelForSession) { onDone( `Current model: ${chalk.bold(renderModelLabel(mainLoopModelForSession))} (session override from plan mode)\nBase model: ${displayModel}${effortInfo}`, - ) + ); } else { - onDone(`Current model: ${displayModel}${effortInfo}`) + onDone(`Current model: ${displayModel}${effortInfo}`); } - return null + return null; } export const call: LocalJSXCommandCall = async (onDone, _context, args) => { - args = args?.trim() || '' + args = args?.trim() || ''; if (COMMON_INFO_ARGS.includes(args)) { logEvent('tengu_model_command_inline_help', { args: args as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - return + }); + return ; } if (COMMON_HELP_ARGS.includes(args)) { - onDone( - 'Run /model to open the model selection menu, or /model [modelName] to set the model.', - { display: 'system' }, - ) - return + onDone('Run /model to open the model selection menu, or /model [modelName] to set the model.', { + display: 'system', + }); + return; } if (args) { logEvent('tengu_model_command_inline', { args: args as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - return + }); + return ; } - return -} + return ; +}; function renderModelLabel(model: string | null): string { - const rendered = renderDefaultModelSetting( - model ?? getDefaultMainLoopModelSetting(), - ) - return model === null ? `${rendered} (default)` : rendered + const rendered = renderDefaultModelSetting(model ?? getDefaultMainLoopModelSetting()); + return model === null ? `${rendered} (default)` : rendered; } diff --git a/src/commands/output-style/output-style.tsx b/src/commands/output-style/output-style.tsx index c445658a4..13062ea95 100644 --- a/src/commands/output-style/output-style.tsx +++ b/src/commands/output-style/output-style.tsx @@ -1,8 +1,8 @@ -import type { LocalJSXCommandOnDone } from '../../types/command.js' +import type { LocalJSXCommandOnDone } from '../../types/command.js'; export async function call(onDone: LocalJSXCommandOnDone): Promise { onDone( '/output-style has been deprecated. Use /config to change your output style, or set it in your settings file. Changes take effect on the next session.', { display: 'system' }, - ) + ); } diff --git a/src/commands/passes/passes.tsx b/src/commands/passes/passes.tsx index bf0363560..360ebe68d 100644 --- a/src/commands/passes/passes.tsx +++ b/src/commands/passes/passes.tsx @@ -1,24 +1,22 @@ -import * as React from 'react' -import { Passes } from '../../components/Passes/Passes.js' -import { logEvent } from '../../services/analytics/index.js' -import { getCachedRemainingPasses } from '../../services/api/referral.js' -import type { LocalJSXCommandOnDone } from '../../types/command.js' -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' +import * as React from 'react'; +import { Passes } from '../../components/Passes/Passes.js'; +import { logEvent } from '../../services/analytics/index.js'; +import { getCachedRemainingPasses } from '../../services/api/referral.js'; +import type { LocalJSXCommandOnDone } from '../../types/command.js'; +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; -export async function call( - onDone: LocalJSXCommandOnDone, -): Promise { +export async function call(onDone: LocalJSXCommandOnDone): Promise { // Mark that user has visited /passes so we stop showing the upsell - const config = getGlobalConfig() - const isFirstVisit = !config.hasVisitedPasses + const config = getGlobalConfig(); + const isFirstVisit = !config.hasVisitedPasses; if (isFirstVisit) { - const remaining = getCachedRemainingPasses() + const remaining = getCachedRemainingPasses(); saveGlobalConfig(current => ({ ...current, hasVisitedPasses: true, passesLastSeenRemaining: remaining ?? current.passesLastSeenRemaining, - })) + })); } - logEvent('tengu_guest_passes_visited', { is_first_visit: isFirstVisit }) - return + logEvent('tengu_guest_passes_visited', { is_first_visit: isFirstVisit }); + return ; } diff --git a/src/commands/peers/peers.ts b/src/commands/peers/peers.ts index aed37d327..40ac86c5c 100644 --- a/src/commands/peers/peers.ts +++ b/src/commands/peers/peers.ts @@ -29,9 +29,7 @@ export const call: LocalCommandCall = async (_args, _context) => { ? ` started: ${formatAge(peer.startedAt)}` : '' - lines.push( - ` [${status}] PID ${peer.pid} (${label})${cwd}${age}`, - ) + lines.push(` [${status}] PID ${peer.pid} (${label})${cwd}${age}`) if (peer.messagingSocketPath) { lines.push(` socket: ${peer.messagingSocketPath}`) } @@ -42,9 +40,7 @@ export const call: LocalCommandCall = async (_args, _context) => { } lines.push('') - lines.push( - 'To message a peer: use SendMessage with to="uds:"', - ) + lines.push('To message a peer: use SendMessage with to="uds:"') return { type: 'text', value: lines.join('\n') } } diff --git a/src/commands/permissions/permissions.tsx b/src/commands/permissions/permissions.tsx index f88dcd93c..83676488a 100644 --- a/src/commands/permissions/permissions.tsx +++ b/src/commands/permissions/permissions.tsx @@ -1,18 +1,15 @@ -import * as React from 'react' -import { PermissionRuleList } from '../../components/permissions/rules/PermissionRuleList.js' -import type { LocalJSXCommandCall } from '../../types/command.js' -import { createPermissionRetryMessage } from '../../utils/messages.js' +import * as React from 'react'; +import { PermissionRuleList } from '../../components/permissions/rules/PermissionRuleList.js'; +import type { LocalJSXCommandCall } from '../../types/command.js'; +import { createPermissionRetryMessage } from '../../utils/messages.js'; export const call: LocalJSXCommandCall = async (onDone, context) => { return ( { - context.setMessages(prev => [ - ...prev, - createPermissionRetryMessage(commands), - ]) + context.setMessages(prev => [...prev, createPermissionRetryMessage(commands)]); }} /> - ) -} + ); +}; diff --git a/src/commands/plan/plan.tsx b/src/commands/plan/plan.tsx index d7694182c..475cb4ef6 100644 --- a/src/commands/plan/plan.tsx +++ b/src/commands/plan/plan.tsx @@ -1,24 +1,24 @@ -import * as React from 'react' -import { handlePlanModeTransition } from '../../bootstrap/state.js' -import type { LocalJSXCommandContext } from '../../commands.js' -import { Box, Text } from '@anthropic/ink' -import type { LocalJSXCommandOnDone } from '../../types/command.js' -import { getExternalEditor } from '../../utils/editor.js' -import { toIDEDisplayName } from '../../utils/ide.js' -import { applyPermissionUpdate } from '../../utils/permissions/PermissionUpdate.js' -import { prepareContextForPlanMode } from '../../utils/permissions/permissionSetup.js' -import { getPlan, getPlanFilePath } from '../../utils/plans.js' -import { editFileInEditor } from '../../utils/promptEditor.js' -import { renderToString } from '../../utils/staticRender.js' +import * as React from 'react'; +import { handlePlanModeTransition } from '../../bootstrap/state.js'; +import type { LocalJSXCommandContext } from '../../commands.js'; +import { Box, Text } from '@anthropic/ink'; +import type { LocalJSXCommandOnDone } from '../../types/command.js'; +import { getExternalEditor } from '../../utils/editor.js'; +import { toIDEDisplayName } from '../../utils/ide.js'; +import { applyPermissionUpdate } from '../../utils/permissions/PermissionUpdate.js'; +import { prepareContextForPlanMode } from '../../utils/permissions/permissionSetup.js'; +import { getPlan, getPlanFilePath } from '../../utils/plans.js'; +import { editFileInEditor } from '../../utils/promptEditor.js'; +import { renderToString } from '../../utils/staticRender.js'; function PlanDisplay({ planContent, planPath, editorName, }: { - planContent: string - planPath: string - editorName: string | undefined + planContent: string; + planPath: string; + editorName: string | undefined; }): React.ReactNode { return ( @@ -37,7 +37,7 @@ function PlanDisplay({ )} - ) + ); } export async function call( @@ -45,63 +45,58 @@ export async function call( context: LocalJSXCommandContext, args: string, ): Promise { - const { getAppState, setAppState } = context - const appState = getAppState() - const currentMode = appState.toolPermissionContext.mode + const { getAppState, setAppState } = context; + const appState = getAppState(); + const currentMode = appState.toolPermissionContext.mode; // If not in plan mode, enable it if (currentMode !== 'plan') { - handlePlanModeTransition(currentMode, 'plan') + handlePlanModeTransition(currentMode, 'plan'); setAppState(prev => ({ ...prev, - toolPermissionContext: applyPermissionUpdate( - prepareContextForPlanMode(prev.toolPermissionContext), - { type: 'setMode', mode: 'plan', destination: 'session' }, - ), - })) - const description = args.trim() + toolPermissionContext: applyPermissionUpdate(prepareContextForPlanMode(prev.toolPermissionContext), { + type: 'setMode', + mode: 'plan', + destination: 'session', + }), + })); + const description = args.trim(); if (description && description !== 'open') { - onDone('Enabled plan mode', { shouldQuery: true }) + onDone('Enabled plan mode', { shouldQuery: true }); } else { - onDone('Enabled plan mode') + onDone('Enabled plan mode'); } - return null + return null; } // Already in plan mode - show the current plan - const planContent = getPlan() - const planPath = getPlanFilePath() + const planContent = getPlan(); + const planPath = getPlanFilePath(); if (!planContent) { - onDone('Already in plan mode. No plan written yet.') - return null + onDone('Already in plan mode. No plan written yet.'); + return null; } // If user typed "/plan open", open in editor - const argList = args.trim().split(/\s+/) + const argList = args.trim().split(/\s+/); if (argList[0] === 'open') { - const result = await editFileInEditor(planPath) + const result = await editFileInEditor(planPath); if (result.error) { - onDone(`Failed to open plan in editor: ${result.error}`) + onDone(`Failed to open plan in editor: ${result.error}`); } else { - onDone(`Opened plan in editor: ${planPath}`) + onDone(`Opened plan in editor: ${planPath}`); } - return null + return null; } - const editor = getExternalEditor() - const editorName = editor ? toIDEDisplayName(editor) : undefined + const editor = getExternalEditor(); + const editorName = editor ? toIDEDisplayName(editor) : undefined; - const display = ( - - ) + const display = ; // Render to string and pass to onDone like local commands do - const output = await renderToString(display) - onDone(output) - return null + const output = await renderToString(display); + onDone(output); + return null; } diff --git a/src/commands/plugin/AddMarketplace.tsx b/src/commands/plugin/AddMarketplace.tsx index 7a9138333..35964f5a4 100644 --- a/src/commands/plugin/AddMarketplace.tsx +++ b/src/commands/plugin/AddMarketplace.tsx @@ -1,37 +1,34 @@ -import * as React from 'react' -import { useEffect, useRef, useState } from 'react' +import * as React from 'react'; +import { useEffect, useRef, useState } from 'react'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from 'src/services/analytics/index.js' -import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js' -import { Byline, KeyboardShortcutHint } from '@anthropic/ink' -import { Spinner } from '../../components/Spinner.js' -import TextInput from '../../components/TextInput.js' -import { Box, Text } from '@anthropic/ink' -import { toError } from '../../utils/errors.js' -import { logError } from '../../utils/log.js' -import { clearAllCaches } from '../../utils/plugins/cacheUtils.js' -import { - addMarketplaceSource, - saveMarketplaceToSettings, -} from '../../utils/plugins/marketplaceManager.js' -import { parseMarketplaceInput } from '../../utils/plugins/parseMarketplaceInput.js' -import type { ViewState } from './types.js' +} from 'src/services/analytics/index.js'; +import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js'; +import { Byline, KeyboardShortcutHint } from '@anthropic/ink'; +import { Spinner } from '../../components/Spinner.js'; +import TextInput from '../../components/TextInput.js'; +import { Box, Text } from '@anthropic/ink'; +import { toError } from '../../utils/errors.js'; +import { logError } from '../../utils/log.js'; +import { clearAllCaches } from '../../utils/plugins/cacheUtils.js'; +import { addMarketplaceSource, saveMarketplaceToSettings } from '../../utils/plugins/marketplaceManager.js'; +import { parseMarketplaceInput } from '../../utils/plugins/parseMarketplaceInput.js'; +import type { ViewState } from './types.js'; type Props = { - inputValue: string - setInputValue: (value: string) => void - cursorOffset: number - setCursorOffset: (offset: number) => void - error: string | null - setError: (error: string | null) => void - result: string | null - setResult: (result: string | null) => void - setViewState: (state: ViewState) => void - onAddComplete?: () => void | Promise - cliMode?: boolean -} + inputValue: string; + setInputValue: (value: string) => void; + cursorOffset: number; + setCursorOffset: (offset: number) => void; + error: string | null; + setError: (error: string | null) => void; + result: string | null; + setResult: (result: string | null) => void; + setViewState: (state: ViewState) => void; + onAddComplete?: () => void | Promise; + cliMode?: boolean; +}; export function AddMarketplace({ inputValue, @@ -46,95 +43,87 @@ export function AddMarketplace({ onAddComplete, cliMode = false, }: Props): React.ReactNode { - const hasAttemptedAutoAdd = useRef(false) - const [isLoading, setLoading] = useState(false) - const [progressMessage, setProgressMessage] = useState('') + const hasAttemptedAutoAdd = useRef(false); + const [isLoading, setLoading] = useState(false); + const [progressMessage, setProgressMessage] = useState(''); const handleAdd = async () => { - const input = inputValue.trim() + const input = inputValue.trim(); if (!input) { - setError('Please enter a marketplace source') - return + setError('Please enter a marketplace source'); + return; } - const parsed = await parseMarketplaceInput(input) + const parsed = await parseMarketplaceInput(input); if (!parsed) { - setError( - 'Invalid marketplace source format. Try: owner/repo, https://..., or ./path', - ) - return + setError('Invalid marketplace source format. Try: owner/repo, https://..., or ./path'); + return; } // Check if parseMarketplaceInput returned an error if ('error' in parsed) { - setError(parsed.error) - return + setError(parsed.error); + return; } - setError(null) + setError(null); try { - setLoading(true) - setProgressMessage('') - const { name, resolvedSource } = await addMarketplaceSource( - parsed, - message => { - setProgressMessage(message) - }, - ) - saveMarketplaceToSettings(name, { source: resolvedSource }) - clearAllCaches() + setLoading(true); + setProgressMessage(''); + const { name, resolvedSource } = await addMarketplaceSource(parsed, message => { + setProgressMessage(message); + }); + saveMarketplaceToSettings(name, { source: resolvedSource }); + clearAllCaches(); - let sourceType = parsed.source + let sourceType = parsed.source; if (parsed.source === 'github') { - sourceType = - parsed.repo as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS + sourceType = parsed.repo as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS; } logEvent('tengu_marketplace_added', { - source_type: - sourceType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + source_type: sourceType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); if (onAddComplete) { - await onAddComplete() + await onAddComplete(); } - setProgressMessage('') - setLoading(false) + setProgressMessage(''); + setLoading(false); if (cliMode) { // In CLI mode, set result to trigger completion - setResult(`Successfully added marketplace: ${name}`) + setResult(`Successfully added marketplace: ${name}`); } else { // In interactive mode, switch to browse view - setViewState({ type: 'browse-marketplace', targetMarketplace: name }) + setViewState({ type: 'browse-marketplace', targetMarketplace: name }); } } catch (err) { - const error = toError(err) - logError(error) - setError(error.message) - setProgressMessage('') - setLoading(false) + const error = toError(err); + logError(error); + setError(error.message); + setProgressMessage(''); + setLoading(false); if (cliMode) { // In CLI mode, set result with error to trigger completion - setResult(`Error: ${error.message}`) + setResult(`Error: ${error.message}`); } else { - setResult(null) + setResult(null); } } - } + }; // Auto-add if inputValue is provided useEffect(() => { if (inputValue && !hasAttemptedAutoAdd.current && !error && !result) { - hasAttemptedAutoAdd.current = true - void handleAdd() + hasAttemptedAutoAdd.current = true; + void handleAdd(); } // eslint-disable-next-line react-hooks/exhaustive-deps - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional - }, []) // Only run once on mount + }, []); // Only run once on mount return ( @@ -165,9 +154,7 @@ export function AddMarketplace({ {isLoading && ( - - {progressMessage || 'Adding marketplace to configuration…'} - + {progressMessage || 'Adding marketplace to configuration…'} )} {error && ( @@ -185,15 +172,10 @@ export function AddMarketplace({ - + - ) + ); } diff --git a/src/commands/plugin/BrowseMarketplace.tsx b/src/commands/plugin/BrowseMarketplace.tsx index 0f1653538..1aaf84e39 100644 --- a/src/commands/plugin/BrowseMarketplace.tsx +++ b/src/commands/plugin/BrowseMarketplace.tsx @@ -1,79 +1,64 @@ -import figures from 'figures' -import * as React from 'react' -import { useEffect, useState } from 'react' -import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js' -import { Box, Byline, Text } from '@anthropic/ink' -import { - useKeybinding, - useKeybindings, -} from '../../keybindings/useKeybinding.js' -import type { LoadedPlugin } from '../../types/plugin.js' -import { count } from '../../utils/array.js' -import { openBrowser } from '../../utils/browser.js' -import { logForDebugging } from '../../utils/debug.js' -import { errorMessage } from '../../utils/errors.js' -import { clearAllCaches } from '../../utils/plugins/cacheUtils.js' -import { - formatInstallCount, - getInstallCounts, -} from '../../utils/plugins/installCounts.js' -import { - isPluginGloballyInstalled, - isPluginInstalled, -} from '../../utils/plugins/installedPluginsManager.js' +import figures from 'figures'; +import * as React from 'react'; +import { useEffect, useState } from 'react'; +import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js'; +import { Box, Byline, Text } from '@anthropic/ink'; +import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js'; +import type { LoadedPlugin } from '../../types/plugin.js'; +import { count } from '../../utils/array.js'; +import { openBrowser } from '../../utils/browser.js'; +import { logForDebugging } from '../../utils/debug.js'; +import { errorMessage } from '../../utils/errors.js'; +import { clearAllCaches } from '../../utils/plugins/cacheUtils.js'; +import { formatInstallCount, getInstallCounts } from '../../utils/plugins/installCounts.js'; +import { isPluginGloballyInstalled, isPluginInstalled } from '../../utils/plugins/installedPluginsManager.js'; import { createPluginId, formatFailureDetails, formatMarketplaceLoadingErrors, getMarketplaceSourceDisplay, loadMarketplacesWithGracefulDegradation, -} from '../../utils/plugins/marketplaceHelpers.js' -import { - getMarketplace, - loadKnownMarketplacesConfig, -} from '../../utils/plugins/marketplaceManager.js' -import { OFFICIAL_MARKETPLACE_NAME } from '../../utils/plugins/officialMarketplace.js' -import { installPluginFromMarketplace } from '../../utils/plugins/pluginInstallationHelpers.js' -import { isPluginBlockedByPolicy } from '../../utils/plugins/pluginPolicy.js' -import { plural } from '../../utils/stringUtils.js' -import { truncateToWidth } from '../../utils/truncate.js' -import { - findPluginOptionsTarget, - PluginOptionsFlow, -} from './PluginOptionsFlow.js' -import { PluginTrustWarning } from './PluginTrustWarning.js' +} from '../../utils/plugins/marketplaceHelpers.js'; +import { getMarketplace, loadKnownMarketplacesConfig } from '../../utils/plugins/marketplaceManager.js'; +import { OFFICIAL_MARKETPLACE_NAME } from '../../utils/plugins/officialMarketplace.js'; +import { installPluginFromMarketplace } from '../../utils/plugins/pluginInstallationHelpers.js'; +import { isPluginBlockedByPolicy } from '../../utils/plugins/pluginPolicy.js'; +import { plural } from '../../utils/stringUtils.js'; +import { truncateToWidth } from '../../utils/truncate.js'; +import { findPluginOptionsTarget, PluginOptionsFlow } from './PluginOptionsFlow.js'; +import { PluginTrustWarning } from './PluginTrustWarning.js'; import { buildPluginDetailsMenuOptions, extractGitHubRepo, type InstallablePlugin, PluginSelectionKeyHint, -} from './pluginDetailsHelpers.js' -import type { ViewState as ParentViewState } from './types.js' -import { usePagination } from './usePagination.js' +} from './pluginDetailsHelpers.js'; +import type { ViewState as ParentViewState } from './types.js'; +import { usePagination } from './usePagination.js'; type Props = { - error: string | null - setError: (error: string | null) => void - result: string | null - setResult: (result: string | null) => void - setViewState: (state: ParentViewState) => void - onInstallComplete?: () => void | Promise - targetMarketplace?: string - targetPlugin?: string -} + error: string | null; + setError: (error: string | null) => void; + result: string | null; + setResult: (result: string | null) => void; + setViewState: (state: ParentViewState) => void; + onInstallComplete?: () => void | Promise; + targetMarketplace?: string; + targetPlugin?: string; +}; type ViewState = | 'marketplace-list' | 'plugin-list' | 'plugin-details' - | { type: 'plugin-options'; plugin: LoadedPlugin; pluginId: string } + | { type: 'plugin-options'; plugin: LoadedPlugin; pluginId: string }; type MarketplaceInfo = { - name: string - totalPlugins: number - installedCount: number - source?: string -} + name: string; + totalPlugins: number; + installedCount: number; + source?: string; +}; export function BrowseMarketplace({ error, @@ -86,46 +71,34 @@ export function BrowseMarketplace({ targetPlugin, }: Props): React.ReactNode { // View state - const [viewState, setViewState] = useState('marketplace-list') - const [selectedMarketplace, setSelectedMarketplace] = useState( - null, - ) - const [selectedPlugin, setSelectedPlugin] = - useState(null) + const [viewState, setViewState] = useState('marketplace-list'); + const [selectedMarketplace, setSelectedMarketplace] = useState(null); + const [selectedPlugin, setSelectedPlugin] = useState(null); // Data state - const [marketplaces, setMarketplaces] = useState([]) - const [availablePlugins, setAvailablePlugins] = useState( - [], - ) - const [loading, setLoading] = useState(true) - const [installCounts, setInstallCounts] = useState | null>(null) + const [marketplaces, setMarketplaces] = useState([]); + const [availablePlugins, setAvailablePlugins] = useState([]); + const [loading, setLoading] = useState(true); + const [installCounts, setInstallCounts] = useState | null>(null); // Selection state - const [selectedIndex, setSelectedIndex] = useState(0) - const [selectedForInstall, setSelectedForInstall] = useState>( - new Set(), - ) - const [installingPlugins, setInstallingPlugins] = useState>( - new Set(), - ) + const [selectedIndex, setSelectedIndex] = useState(0); + const [selectedForInstall, setSelectedForInstall] = useState>(new Set()); + const [installingPlugins, setInstallingPlugins] = useState>(new Set()); // Pagination for plugin list (continuous scrolling) const pagination = usePagination({ totalItems: availablePlugins.length, selectedIndex, - }) + }); // Details view state - const [detailsMenuIndex, setDetailsMenuIndex] = useState(0) - const [isInstalling, setIsInstalling] = useState(false) - const [installError, setInstallError] = useState(null) + const [detailsMenuIndex, setDetailsMenuIndex] = useState(0); + const [isInstalling, setIsInstalling] = useState(false); + const [installError, setInstallError] = useState(null); // Warning state for non-critical errors (e.g., some marketplaces failed to load) - const [warning, setWarning] = useState(null) + const [warning, setWarning] = useState(null); // Handle escape to go back - viewState-dependent navigation const handleBack = React.useCallback(() => { @@ -136,111 +109,94 @@ export function BrowseMarketplace({ setParentViewState({ type: 'manage-marketplaces', targetMarketplace, - }) + }); } else if (marketplaces.length === 1) { // If there's only one marketplace, skip the marketplace-list view // since we auto-navigated past it on load - setParentViewState({ type: 'menu' }) + setParentViewState({ type: 'menu' }); } else { - setViewState('marketplace-list') - setSelectedMarketplace(null) - setSelectedForInstall(new Set()) + setViewState('marketplace-list'); + setSelectedMarketplace(null); + setSelectedForInstall(new Set()); } } else if (viewState === 'plugin-details') { - setViewState('plugin-list') - setSelectedPlugin(null) + setViewState('plugin-list'); + setSelectedPlugin(null); } else { // At root level (marketplace-list), exit the plugin menu - setParentViewState({ type: 'menu' }) + setParentViewState({ type: 'menu' }); } - }, [viewState, targetMarketplace, setParentViewState, marketplaces.length]) + }, [viewState, targetMarketplace, setParentViewState, marketplaces.length]); - useKeybinding('confirm:no', handleBack, { context: 'Confirmation' }) + useKeybinding('confirm:no', handleBack, { context: 'Confirmation' }); // Load marketplaces and count installed plugins useEffect(() => { async function loadMarketplaceData() { try { - const config = await loadKnownMarketplacesConfig() + const config = await loadKnownMarketplacesConfig(); // Load marketplaces with graceful degradation - const { marketplaces, failures } = - await loadMarketplacesWithGracefulDegradation(config) + const { marketplaces, failures } = await loadMarketplacesWithGracefulDegradation(config); - const marketplaceInfos: MarketplaceInfo[] = [] - for (const { - name, - config: marketplaceConfig, - data: marketplace, - } of marketplaces) { + const marketplaceInfos: MarketplaceInfo[] = []; + for (const { name, config: marketplaceConfig, data: marketplace } of marketplaces) { if (marketplace) { // Count how many plugins from this marketplace are installed - const installedFromThisMarketplace = count( - marketplace.plugins, - plugin => isPluginInstalled(createPluginId((plugin as { name: string }).name, name)), - ) + const installedFromThisMarketplace = count(marketplace.plugins, plugin => + isPluginInstalled(createPluginId((plugin as { name: string }).name, name)), + ); marketplaceInfos.push({ name, totalPlugins: marketplace.plugins.length, installedCount: installedFromThisMarketplace, source: getMarketplaceSourceDisplay(marketplaceConfig.source), - }) + }); } } // Sort so claude-plugin-directory is always first marketplaceInfos.sort((a, b) => { - if (a.name === 'claude-plugin-directory') return -1 - if (b.name === 'claude-plugin-directory') return 1 - return 0 - }) + if (a.name === 'claude-plugin-directory') return -1; + if (b.name === 'claude-plugin-directory') return 1; + return 0; + }); - setMarketplaces(marketplaceInfos) + setMarketplaces(marketplaceInfos); // Handle marketplace loading errors/warnings - const successCount = count(marketplaces, m => m.data !== null) - const errorResult = formatMarketplaceLoadingErrors( - failures, - successCount, - ) + const successCount = count(marketplaces, m => m.data !== null); + const errorResult = formatMarketplaceLoadingErrors(failures, successCount); if (errorResult) { if (errorResult.type === 'warning') { - setWarning( - errorResult.message + '. Showing available marketplaces.', - ) + setWarning(errorResult.message + '. Showing available marketplaces.'); } else { - throw new Error(errorResult.message) + throw new Error(errorResult.message); } } // Skip marketplace selection if there's only one marketplace - if ( - marketplaceInfos.length === 1 && - !targetMarketplace && - !targetPlugin - ) { - const singleMarketplace = marketplaceInfos[0] + if (marketplaceInfos.length === 1 && !targetMarketplace && !targetPlugin) { + const singleMarketplace = marketplaceInfos[0]; if (singleMarketplace) { - setSelectedMarketplace(singleMarketplace.name) - setViewState('plugin-list') + setSelectedMarketplace(singleMarketplace.name); + setViewState('plugin-list'); } } // Handle targetMarketplace and targetPlugin after marketplaces are loaded if (targetPlugin) { // Search for the plugin across all marketplaces - let foundPlugin: InstallablePlugin | null = null - let foundMarketplace: string | null = null + let foundPlugin: InstallablePlugin | null = null; + let foundMarketplace: string | null = null; for (const [name] of Object.entries(config)) { - const marketplace = await getMarketplace(name) + const marketplace = await getMarketplace(name); if (marketplace) { - const plugin = marketplace.plugins.find( - p => p.name === targetPlugin, - ) + const plugin = marketplace.plugins.find(p => p.name === targetPlugin); if (plugin) { - const pluginId = createPluginId(plugin.name, name) + const pluginId = createPluginId(plugin.name, name); foundPlugin = { entry: plugin, marketplaceName: name, @@ -249,9 +205,9 @@ export function BrowseMarketplace({ // exists (nothing to add). Project/local-scope installs don't // block — user may want to promote to user scope (gh-29997). isInstalled: isPluginGloballyInstalled(pluginId), - } - foundMarketplace = name - break + }; + foundMarketplace = name; + break; } } } @@ -263,65 +219,59 @@ export function BrowseMarketplace({ // The plugin-details view offers all three scope options; the backend // (installPluginOp → addInstalledPlugin) already supports multiple // scope entries per plugin. - const pluginId = foundPlugin.pluginId - const globallyInstalled = isPluginGloballyInstalled(pluginId) + const pluginId = foundPlugin.pluginId; + const globallyInstalled = isPluginGloballyInstalled(pluginId); if (globallyInstalled) { - setError( - `Plugin '${pluginId}' is already installed globally. Use '/plugin' to manage existing plugins.`, - ) + setError(`Plugin '${pluginId}' is already installed globally. Use '/plugin' to manage existing plugins.`); } else { // Navigate to the plugin details view - setSelectedMarketplace(foundMarketplace) - setSelectedPlugin(foundPlugin) - setViewState('plugin-details') + setSelectedMarketplace(foundMarketplace); + setSelectedPlugin(foundPlugin); + setViewState('plugin-details'); } } else { - setError(`Plugin "${targetPlugin}" not found in any marketplace`) + setError(`Plugin "${targetPlugin}" not found in any marketplace`); } } else if (targetMarketplace) { // Navigate directly to the specified marketplace - const marketplaceExists = marketplaceInfos.some( - m => m.name === targetMarketplace, - ) + const marketplaceExists = marketplaceInfos.some(m => m.name === targetMarketplace); if (marketplaceExists) { - setSelectedMarketplace(targetMarketplace) - setViewState('plugin-list') + setSelectedMarketplace(targetMarketplace); + setViewState('plugin-list'); } else { - setError(`Marketplace "${targetMarketplace}" not found`) + setError(`Marketplace "${targetMarketplace}" not found`); } } } catch (err) { - setError( - err instanceof Error ? err.message : 'Failed to load marketplaces', - ) + setError(err instanceof Error ? err.message : 'Failed to load marketplaces'); } finally { - setLoading(false) + setLoading(false); } } - void loadMarketplaceData() - }, [setError, targetMarketplace, targetPlugin]) + void loadMarketplaceData(); + }, [setError, targetMarketplace, targetPlugin]); // Load plugins when a marketplace is selected useEffect(() => { - if (!selectedMarketplace) return + if (!selectedMarketplace) return; - let cancelled = false + let cancelled = false; async function loadPluginsForMarketplace(marketplaceName: string) { - setLoading(true) + setLoading(true); try { - const marketplace = await getMarketplace(marketplaceName) - if (cancelled) return + const marketplace = await getMarketplace(marketplaceName); + if (cancelled) return; if (!marketplace) { - throw new Error(`Failed to load marketplace: ${marketplaceName}`) + throw new Error(`Failed to load marketplace: ${marketplaceName}`); } // Filter out already installed plugins - const installablePlugins: InstallablePlugin[] = [] + const installablePlugins: InstallablePlugin[] = []; for (const entry of marketplace.plugins) { - const pluginId = createPluginId(entry.name, marketplaceName) - if (isPluginBlockedByPolicy(pluginId)) continue + const pluginId = createPluginId(entry.name, marketplaceName); + if (isPluginBlockedByPolicy(pluginId)) continue; installablePlugins.push({ entry, marketplaceName: marketplaceName, @@ -330,70 +280,62 @@ export function BrowseMarketplace({ // Project/local installs don't block — user can add user scope // via the plugin-details view (gh-29997). isInstalled: isPluginGloballyInstalled(pluginId), - }) + }); } // Fetch install counts and sort by popularity try { - const counts = await getInstallCounts() - if (cancelled) return - setInstallCounts(counts) + const counts = await getInstallCounts(); + if (cancelled) return; + setInstallCounts(counts); if (counts) { // Sort by install count (descending), then alphabetically installablePlugins.sort((a, b) => { - const countA = counts.get(a.pluginId) ?? 0 - const countB = counts.get(b.pluginId) ?? 0 - if (countA !== countB) return countB - countA - return a.entry.name.localeCompare(b.entry.name) - }) + const countA = counts.get(a.pluginId) ?? 0; + const countB = counts.get(b.pluginId) ?? 0; + if (countA !== countB) return countB - countA; + return a.entry.name.localeCompare(b.entry.name); + }); } else { // No counts available - sort alphabetically - installablePlugins.sort((a, b) => - a.entry.name.localeCompare(b.entry.name), - ) + installablePlugins.sort((a, b) => a.entry.name.localeCompare(b.entry.name)); } } catch (error) { - if (cancelled) return + if (cancelled) return; // Log the error, then gracefully degrade to alphabetical sort - logForDebugging( - `Failed to fetch install counts: ${errorMessage(error)}`, - ) - installablePlugins.sort((a, b) => - a.entry.name.localeCompare(b.entry.name), - ) + logForDebugging(`Failed to fetch install counts: ${errorMessage(error)}`); + installablePlugins.sort((a, b) => a.entry.name.localeCompare(b.entry.name)); } - setAvailablePlugins(installablePlugins) - setSelectedIndex(0) - setSelectedForInstall(new Set()) + setAvailablePlugins(installablePlugins); + setSelectedIndex(0); + setSelectedForInstall(new Set()); } catch (err) { - if (cancelled) return - setError(err instanceof Error ? err.message : 'Failed to load plugins') + if (cancelled) return; + setError(err instanceof Error ? err.message : 'Failed to load plugins'); } finally { - setLoading(false) + setLoading(false); } } - void loadPluginsForMarketplace(selectedMarketplace) + void loadPluginsForMarketplace(selectedMarketplace); return () => { - cancelled = true - } - }, [selectedMarketplace, setError]) + cancelled = true; + }; + }, [selectedMarketplace, setError]); // Install selected plugins const installSelectedPlugins = async () => { - if (selectedForInstall.size === 0) return + if (selectedForInstall.size === 0) return; - const pluginsToInstall = availablePlugins.filter(p => - selectedForInstall.has(p.pluginId), - ) + const pluginsToInstall = availablePlugins.filter(p => selectedForInstall.has(p.pluginId)); - setInstallingPlugins(new Set(pluginsToInstall.map(p => p.pluginId))) + setInstallingPlugins(new Set(pluginsToInstall.map(p => p.pluginId))); - let successCount = 0 - let failureCount = 0 - const newFailedPlugins: Array<{ name: string; reason: string }> = [] + let successCount = 0; + let failureCount = 0; + const newFailedPlugins: Array<{ name: string; reason: string }> = []; for (const plugin of pluginsToInstall) { const result = await installPluginFromMarketplace({ @@ -401,228 +343,219 @@ export function BrowseMarketplace({ entry: plugin.entry, marketplaceName: plugin.marketplaceName, scope: 'user', - }) + }); if (result.success) { - successCount++ + successCount++; } else { - failureCount++ + failureCount++; newFailedPlugins.push({ name: plugin.entry.name, reason: (result as { success: false; error: string }).error, - }) + }); } } - setInstallingPlugins(new Set()) - setSelectedForInstall(new Set()) - clearAllCaches() + setInstallingPlugins(new Set()); + setSelectedForInstall(new Set()); + clearAllCaches(); // Handle installation results if (failureCount === 0) { // All succeeded const message = - `✓ Installed ${successCount} ${plural(successCount, 'plugin')}. ` + - `Run /reload-plugins to activate.` + `✓ Installed ${successCount} ${plural(successCount, 'plugin')}. ` + `Run /reload-plugins to activate.`; - setResult(message) + setResult(message); } else if (successCount === 0) { // All failed - show error with reasons - setError( - `Failed to install: ${formatFailureDetails(newFailedPlugins, true)}`, - ) + setError(`Failed to install: ${formatFailureDetails(newFailedPlugins, true)}`); } else { // Mixed results - show partial success const message = `✓ Installed ${successCount} of ${successCount + failureCount} plugins. ` + `Failed: ${formatFailureDetails(newFailedPlugins, false)}. ` + - `Run /reload-plugins to activate successfully installed plugins.` + `Run /reload-plugins to activate successfully installed plugins.`; - setResult(message) + setResult(message); } // Handle completion callback and navigation if (successCount > 0) { if (onInstallComplete) { - await onInstallComplete() + await onInstallComplete(); } } - setParentViewState({ type: 'menu' }) - } + setParentViewState({ type: 'menu' }); + }; // Install single plugin from details view - const handleSinglePluginInstall = async ( - plugin: InstallablePlugin, - scope: 'user' | 'project' | 'local' = 'user', - ) => { - setIsInstalling(true) - setInstallError(null) + const handleSinglePluginInstall = async (plugin: InstallablePlugin, scope: 'user' | 'project' | 'local' = 'user') => { + setIsInstalling(true); + setInstallError(null); const result = await installPluginFromMarketplace({ pluginId: plugin.pluginId, entry: plugin.entry, marketplaceName: plugin.marketplaceName, scope, - }) + }); if (result.success) { - const loaded = await findPluginOptionsTarget(plugin.pluginId) + const loaded = await findPluginOptionsTarget(plugin.pluginId); if (loaded) { - setIsInstalling(false) + setIsInstalling(false); setViewState({ type: 'plugin-options', plugin: loaded, pluginId: plugin.pluginId, - }) - return + }); + return; } - setResult(result.message) + setResult(result.message); if (onInstallComplete) { - await onInstallComplete() + await onInstallComplete(); } - setParentViewState({ type: 'menu' }) + setParentViewState({ type: 'menu' }); } else { - setIsInstalling(false) - setInstallError((result as { success: false; error: string }).error) + setIsInstalling(false); + setInstallError((result as { success: false; error: string }).error); } - } + }; // Handle error state useEffect(() => { if (error) { - setResult(error) + setResult(error); } - }, [error, setResult]) + }, [error, setResult]); // Marketplace-list navigation useKeybindings( { 'select:previous': () => { if (selectedIndex > 0) { - setSelectedIndex(selectedIndex - 1) + setSelectedIndex(selectedIndex - 1); } }, 'select:next': () => { if (selectedIndex < marketplaces.length - 1) { - setSelectedIndex(selectedIndex + 1) + setSelectedIndex(selectedIndex + 1); } }, 'select:accept': () => { - const marketplace = marketplaces[selectedIndex] + const marketplace = marketplaces[selectedIndex]; if (marketplace) { - setSelectedMarketplace(marketplace.name) - setViewState('plugin-list') + setSelectedMarketplace(marketplace.name); + setViewState('plugin-list'); } }, }, { context: 'Select', isActive: viewState === 'marketplace-list' }, - ) + ); // Plugin-list navigation useKeybindings( { 'select:previous': () => { if (selectedIndex > 0) { - pagination.handleSelectionChange(selectedIndex - 1, setSelectedIndex) + pagination.handleSelectionChange(selectedIndex - 1, setSelectedIndex); } }, 'select:next': () => { if (selectedIndex < availablePlugins.length - 1) { - pagination.handleSelectionChange(selectedIndex + 1, setSelectedIndex) + pagination.handleSelectionChange(selectedIndex + 1, setSelectedIndex); } }, 'select:accept': () => { - if ( - selectedIndex === availablePlugins.length && - selectedForInstall.size > 0 - ) { - void installSelectedPlugins() + if (selectedIndex === availablePlugins.length && selectedForInstall.size > 0) { + void installSelectedPlugins(); } else if (selectedIndex < availablePlugins.length) { - const plugin = availablePlugins[selectedIndex] + const plugin = availablePlugins[selectedIndex]; if (plugin) { if (plugin.isInstalled) { setParentViewState({ type: 'manage-plugins', targetPlugin: plugin.entry.name, targetMarketplace: plugin.marketplaceName, - }) + }); } else { - setSelectedPlugin(plugin) - setViewState('plugin-details') - setDetailsMenuIndex(0) - setInstallError(null) + setSelectedPlugin(plugin); + setViewState('plugin-details'); + setDetailsMenuIndex(0); + setInstallError(null); } } } }, }, { context: 'Select', isActive: viewState === 'plugin-list' }, - ) + ); useKeybindings( { 'plugin:toggle': () => { if (selectedIndex < availablePlugins.length) { - const plugin = availablePlugins[selectedIndex] + const plugin = availablePlugins[selectedIndex]; if (plugin && !plugin.isInstalled) { - const newSelection = new Set(selectedForInstall) + const newSelection = new Set(selectedForInstall); if (newSelection.has(plugin.pluginId)) { - newSelection.delete(plugin.pluginId) + newSelection.delete(plugin.pluginId); } else { - newSelection.add(plugin.pluginId) + newSelection.add(plugin.pluginId); } - setSelectedForInstall(newSelection) + setSelectedForInstall(newSelection); } } }, 'plugin:install': () => { if (selectedForInstall.size > 0) { - void installSelectedPlugins() + void installSelectedPlugins(); } }, }, { context: 'Plugin', isActive: viewState === 'plugin-list' }, - ) + ); // Plugin-details navigation const detailsMenuOptions = React.useMemo(() => { - if (!selectedPlugin) return [] - const hasHomepage = selectedPlugin.entry.homepage - const githubRepo = extractGitHubRepo(selectedPlugin) - return buildPluginDetailsMenuOptions(hasHomepage, githubRepo) - }, [selectedPlugin]) + if (!selectedPlugin) return []; + const hasHomepage = selectedPlugin.entry.homepage; + const githubRepo = extractGitHubRepo(selectedPlugin); + return buildPluginDetailsMenuOptions(hasHomepage, githubRepo); + }, [selectedPlugin]); useKeybindings( { 'select:previous': () => { if (detailsMenuIndex > 0) { - setDetailsMenuIndex(detailsMenuIndex - 1) + setDetailsMenuIndex(detailsMenuIndex - 1); } }, 'select:next': () => { if (detailsMenuIndex < detailsMenuOptions.length - 1) { - setDetailsMenuIndex(detailsMenuIndex + 1) + setDetailsMenuIndex(detailsMenuIndex + 1); } }, 'select:accept': () => { - if (!selectedPlugin) return - const action = detailsMenuOptions[detailsMenuIndex]?.action - const hasHomepage = selectedPlugin.entry.homepage - const githubRepo = extractGitHubRepo(selectedPlugin) + if (!selectedPlugin) return; + const action = detailsMenuOptions[detailsMenuIndex]?.action; + const hasHomepage = selectedPlugin.entry.homepage; + const githubRepo = extractGitHubRepo(selectedPlugin); if (action === 'install-user') { - void handleSinglePluginInstall(selectedPlugin, 'user') + void handleSinglePluginInstall(selectedPlugin, 'user'); } else if (action === 'install-project') { - void handleSinglePluginInstall(selectedPlugin, 'project') + void handleSinglePluginInstall(selectedPlugin, 'project'); } else if (action === 'install-local') { - void handleSinglePluginInstall(selectedPlugin, 'local') + void handleSinglePluginInstall(selectedPlugin, 'local'); } else if (action === 'homepage' && hasHomepage) { - void openBrowser(hasHomepage) + void openBrowser(hasHomepage); } else if (action === 'github' && githubRepo) { - void openBrowser(`https://github.com/${githubRepo}`) + void openBrowser(`https://github.com/${githubRepo}`); } else if (action === 'back') { - setViewState('plugin-list') - setSelectedPlugin(null) + setViewState('plugin-list'); + setSelectedPlugin(null); } }, }, @@ -630,16 +563,16 @@ export function BrowseMarketplace({ context: 'Select', isActive: viewState === 'plugin-details' && !!selectedPlugin, }, - ) + ); if (typeof viewState === 'object' && viewState.type === 'plugin-options') { - const { plugin, pluginId } = viewState + const { plugin, pluginId } = viewState; function finish(msg: string): void { - setResult(msg) + setResult(msg); if (onInstallComplete) { - void onInstallComplete() + void onInstallComplete(); } - setParentViewState({ type: 'menu' }) + setParentViewState({ type: 'menu' }); } return ( { switch (outcome) { case 'configured': - finish( - `✓ Installed and configured ${plugin.name}. Run /reload-plugins to apply.`, - ) - break + finish(`✓ Installed and configured ${plugin.name}. Run /reload-plugins to apply.`); + break; case 'skipped': - finish( - `✓ Installed ${plugin.name}. Run /reload-plugins to apply.`, - ) - break + finish(`✓ Installed ${plugin.name}. Run /reload-plugins to apply.`); + break; case 'error': - finish(`Installed but failed to save config: ${detail}`) - break + finish(`Installed but failed to save config: ${detail}`); + break; } }} /> - ) + ); } // Loading state if (loading) { - return Loading… + return Loading…; } // Error state if (error) { - return {error} + return {error}; } // Marketplace selection view @@ -685,9 +614,7 @@ export function BrowseMarketplace({ Select marketplace No marketplaces configured. - - Add a marketplace first using {"'Add marketplace'"}. - + Add a marketplace first using {"'Add marketplace'"}. - ) + ); } return ( @@ -717,23 +644,16 @@ export function BrowseMarketplace({ )} {marketplaces.map((marketplace, index) => ( - + - {selectedIndex === index ? figures.pointer : ' '}{' '} - {marketplace.name} + {selectedIndex === index ? figures.pointer : ' '} {marketplace.name} - {marketplace.totalPlugins}{' '} - {plural(marketplace.totalPlugins, 'plugin')} available - {marketplace.installedCount > 0 && - ` · ${marketplace.installedCount} already installed`} + {marketplace.totalPlugins} {plural(marketplace.totalPlugins, 'plugin')} available + {marketplace.installedCount > 0 && ` · ${marketplace.installedCount} already installed`} {marketplace.source && ` · ${marketplace.source}`} @@ -743,12 +663,7 @@ export function BrowseMarketplace({ - + - ) + ); } // Plugin details view if (viewState === 'plugin-details' && selectedPlugin) { - const hasHomepage = selectedPlugin.entry.homepage - const githubRepo = extractGitHubRepo(selectedPlugin) + const hasHomepage = selectedPlugin.entry.homepage; + const githubRepo = extractGitHubRepo(selectedPlugin); - const menuOptions = buildPluginDetailsMenuOptions(hasHomepage, githubRepo) + const menuOptions = buildPluginDetailsMenuOptions(hasHomepage, githubRepo); return ( @@ -778,9 +693,7 @@ export function BrowseMarketplace({ {/* Plugin metadata */} {selectedPlugin.entry.name} - {selectedPlugin.entry.version && ( - Version: {selectedPlugin.entry.version} - )} + {selectedPlugin.entry.version && Version: {selectedPlugin.entry.version}} {selectedPlugin.entry.description && ( {selectedPlugin.entry.description} @@ -818,9 +731,7 @@ export function BrowseMarketplace({ )} {selectedPlugin.entry.hooks && ( - - · Hooks: {Object.keys(selectedPlugin.entry.hooks).join(', ')} - + · Hooks: {Object.keys(selectedPlugin.entry.hooks).join(', ')} )} {selectedPlugin.entry.mcpServers && ( @@ -843,9 +754,7 @@ export function BrowseMarketplace({ selectedPlugin.entry.source.source === 'url' || selectedPlugin.entry.source.source === 'npm' || selectedPlugin.entry.source.source === 'pip') ? ( - - · Component summary not available for remote plugin - + · Component summary not available for remote plugin ) : ( // TODO: Actually scan local plugin directories to show real components // This would require accessing the filesystem to check for: @@ -853,9 +762,7 @@ export function BrowseMarketplace({ // - agents/ directory and list files // - hooks/ directory and list files // - .mcp.json or mcp-servers.json files - - · Components will be discovered at installation - + · Components will be discovered at installation )} )} @@ -877,9 +784,7 @@ export function BrowseMarketplace({ {detailsMenuIndex === index && {'> '}} {detailsMenuIndex !== index && {' '}} - {isInstalling && option.action === 'install' - ? 'Installing…' - : option.label} + {isInstalling && option.action === 'install' ? 'Installing…' : option.label} ))} @@ -888,23 +793,13 @@ export function BrowseMarketplace({ - - + + - ) + ); } // Plugin installation view @@ -915,25 +810,18 @@ export function BrowseMarketplace({ Install plugins No new plugins available to install. - - All plugins from this marketplace are already installed. - + All plugins from this marketplace are already installed. - + - ) + ); } // Get visible plugins from pagination - const visiblePlugins = pagination.getVisibleItems(availablePlugins) + const visiblePlugins = pagination.getVisibleItems(availablePlugins); return ( @@ -950,22 +838,16 @@ export function BrowseMarketplace({ {/* Plugin list */} {visiblePlugins.map((plugin, visibleIndex) => { - const actualIndex = pagination.toActualIndex(visibleIndex) - const isSelected = selectedIndex === actualIndex - const isSelectedForInstall = selectedForInstall.has(plugin.pluginId) - const isInstalling = installingPlugins.has(plugin.pluginId) - const isLast = visibleIndex === visiblePlugins.length - 1 + const actualIndex = pagination.toActualIndex(visibleIndex); + const isSelected = selectedIndex === actualIndex; + const isSelectedForInstall = selectedForInstall.has(plugin.pluginId); + const isInstalling = installingPlugins.has(plugin.pluginId); + const isLast = visibleIndex === visiblePlugins.length - 1; return ( - + - - {isSelected ? figures.pointer : ' '}{' '} - + {isSelected ? figures.pointer : ' '} {plugin.isInstalled ? figures.tick @@ -975,37 +857,25 @@ export function BrowseMarketplace({ ? figures.radioOn : figures.radioOff}{' '} {plugin.entry.name} - {plugin.entry.category && ( - [{plugin.entry.category}] - )} - {plugin.entry.tags?.includes('community-managed') && ( - [Community Managed] - )} + {plugin.entry.category && [{plugin.entry.category}]} + {plugin.entry.tags?.includes('community-managed') && [Community Managed]} {plugin.isInstalled && (installed)} - {installCounts && - selectedMarketplace === OFFICIAL_MARKETPLACE_NAME && ( - - {' · '} - {formatInstallCount( - installCounts.get(plugin.pluginId) ?? 0, - )}{' '} - installs - - )} + {installCounts && selectedMarketplace === OFFICIAL_MARKETPLACE_NAME && ( + + {' · '} + {formatInstallCount(installCounts.get(plugin.pluginId) ?? 0)} installs + + )} {plugin.entry.description && ( - - {truncateToWidth(plugin.entry.description, 60)} - - {plugin.entry.version && ( - · v{plugin.entry.version} - )} + {truncateToWidth(plugin.entry.description, 60)} + {plugin.entry.version && · v{plugin.entry.version}} )} - ) + ); })} {/* Scroll down indicator */} @@ -1026,5 +896,5 @@ export function BrowseMarketplace({ 0} /> - ) + ); } diff --git a/src/commands/plugin/DiscoverPlugins.tsx b/src/commands/plugin/DiscoverPlugins.tsx index 53cb0466f..c18b637a3 100644 --- a/src/commands/plugin/DiscoverPlugins.tsx +++ b/src/commands/plugin/DiscoverPlugins.tsx @@ -1,28 +1,22 @@ -import figures from 'figures' -import * as React from 'react' -import { useCallback, useEffect, useMemo, useState } from 'react' -import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js' -import { SearchBox } from '../../components/SearchBox.js' -import { Byline } from '@anthropic/ink' -import { useSearchInput } from '../../hooks/useSearchInput.js' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import figures from 'figures'; +import * as React from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js'; +import { SearchBox } from '../../components/SearchBox.js'; +import { Byline } from '@anthropic/ink'; +import { useSearchInput } from '../../hooks/useSearchInput.js'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; // eslint-disable-next-line custom-rules/prefer-use-keybindings -- useInput needed for raw search mode text input -import { Box, Text, useInput, useTerminalFocus } from '@anthropic/ink' -import { - useKeybinding, - useKeybindings, -} from '../../keybindings/useKeybinding.js' -import type { LoadedPlugin } from '../../types/plugin.js' -import { count } from '../../utils/array.js' -import { openBrowser } from '../../utils/browser.js' -import { logForDebugging } from '../../utils/debug.js' -import { errorMessage } from '../../utils/errors.js' -import { clearAllCaches } from '../../utils/plugins/cacheUtils.js' -import { - formatInstallCount, - getInstallCounts, -} from '../../utils/plugins/installCounts.js' -import { isPluginGloballyInstalled } from '../../utils/plugins/installedPluginsManager.js' +import { Box, Text, useInput, useTerminalFocus } from '@anthropic/ink'; +import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js'; +import type { LoadedPlugin } from '../../types/plugin.js'; +import { count } from '../../utils/array.js'; +import { openBrowser } from '../../utils/browser.js'; +import { logForDebugging } from '../../utils/debug.js'; +import { errorMessage } from '../../utils/errors.js'; +import { clearAllCaches } from '../../utils/plugins/cacheUtils.js'; +import { formatInstallCount, getInstallCounts } from '../../utils/plugins/installCounts.js'; +import { isPluginGloballyInstalled } from '../../utils/plugins/installedPluginsManager.js'; import { createPluginId, detectEmptyMarketplaceReason, @@ -30,41 +24,31 @@ import { formatFailureDetails, formatMarketplaceLoadingErrors, loadMarketplacesWithGracefulDegradation, -} from '../../utils/plugins/marketplaceHelpers.js' -import { loadKnownMarketplacesConfig } from '../../utils/plugins/marketplaceManager.js' -import { OFFICIAL_MARKETPLACE_NAME } from '../../utils/plugins/officialMarketplace.js' -import { installPluginFromMarketplace } from '../../utils/plugins/pluginInstallationHelpers.js' -import { isPluginBlockedByPolicy } from '../../utils/plugins/pluginPolicy.js' -import { plural } from '../../utils/stringUtils.js' -import { truncateToWidth } from '../../utils/truncate.js' -import { - findPluginOptionsTarget, - PluginOptionsFlow, -} from './PluginOptionsFlow.js' -import { PluginTrustWarning } from './PluginTrustWarning.js' -import { - buildPluginDetailsMenuOptions, - extractGitHubRepo, - type InstallablePlugin, -} from './pluginDetailsHelpers.js' -import type { ViewState as ParentViewState } from './types.js' -import { usePagination } from './usePagination.js' +} from '../../utils/plugins/marketplaceHelpers.js'; +import { loadKnownMarketplacesConfig } from '../../utils/plugins/marketplaceManager.js'; +import { OFFICIAL_MARKETPLACE_NAME } from '../../utils/plugins/officialMarketplace.js'; +import { installPluginFromMarketplace } from '../../utils/plugins/pluginInstallationHelpers.js'; +import { isPluginBlockedByPolicy } from '../../utils/plugins/pluginPolicy.js'; +import { plural } from '../../utils/stringUtils.js'; +import { truncateToWidth } from '../../utils/truncate.js'; +import { findPluginOptionsTarget, PluginOptionsFlow } from './PluginOptionsFlow.js'; +import { PluginTrustWarning } from './PluginTrustWarning.js'; +import { buildPluginDetailsMenuOptions, extractGitHubRepo, type InstallablePlugin } from './pluginDetailsHelpers.js'; +import type { ViewState as ParentViewState } from './types.js'; +import { usePagination } from './usePagination.js'; type Props = { - error: string | null - setError: (error: string | null) => void - result: string | null - setResult: (result: string | null) => void - setViewState: (state: ParentViewState) => void - onInstallComplete?: () => void | Promise - onSearchModeChange?: (isActive: boolean) => void - targetPlugin?: string -} + error: string | null; + setError: (error: string | null) => void; + result: string | null; + setResult: (result: string | null) => void; + setViewState: (state: ParentViewState) => void; + onInstallComplete?: () => void | Promise; + onSearchModeChange?: (isActive: boolean) => void; + targetPlugin?: string; +}; -type ViewState = - | 'plugin-list' - | 'plugin-details' - | { type: 'plugin-options'; plugin: LoadedPlugin; pluginId: string } +type ViewState = 'plugin-list' | 'plugin-details' | { type: 'plugin-options'; plugin: LoadedPlugin; pluginId: string }; export function DiscoverPlugins({ error, @@ -77,29 +61,23 @@ export function DiscoverPlugins({ targetPlugin, }: Props): React.ReactNode { // View state - const [viewState, setViewState] = useState('plugin-list') - const [selectedPlugin, setSelectedPlugin] = - useState(null) + const [viewState, setViewState] = useState('plugin-list'); + const [selectedPlugin, setSelectedPlugin] = useState(null); // Data state - const [availablePlugins, setAvailablePlugins] = useState( - [], - ) - const [loading, setLoading] = useState(true) - const [installCounts, setInstallCounts] = useState | null>(null) + const [availablePlugins, setAvailablePlugins] = useState([]); + const [loading, setLoading] = useState(true); + const [installCounts, setInstallCounts] = useState | null>(null); // Search state - const [isSearchMode, setIsSearchModeRaw] = useState(false) + const [isSearchMode, setIsSearchModeRaw] = useState(false); const setIsSearchMode = useCallback( (active: boolean) => { - setIsSearchModeRaw(active) - onSearchModeChange?.(active) + setIsSearchModeRaw(active); + onSearchModeChange?.(active); }, [onSearchModeChange], - ) + ); const { query: searchQuery, setQuery: setSearchQuery, @@ -107,74 +85,67 @@ export function DiscoverPlugins({ } = useSearchInput({ isActive: viewState === 'plugin-list' && isSearchMode && !loading, onExit: () => { - setIsSearchMode(false) + setIsSearchMode(false); }, - }) - const isTerminalFocused = useTerminalFocus() - const { columns: terminalWidth } = useTerminalSize() + }); + const isTerminalFocused = useTerminalFocus(); + const { columns: terminalWidth } = useTerminalSize(); // Filter plugins based on search query const filteredPlugins = useMemo(() => { - if (!searchQuery) return availablePlugins - const lowerQuery = searchQuery.toLowerCase() + if (!searchQuery) return availablePlugins; + const lowerQuery = searchQuery.toLowerCase(); return availablePlugins.filter( plugin => plugin.entry.name.toLowerCase().includes(lowerQuery) || plugin.entry.description?.toLowerCase().includes(lowerQuery) || plugin.marketplaceName.toLowerCase().includes(lowerQuery), - ) - }, [availablePlugins, searchQuery]) + ); + }, [availablePlugins, searchQuery]); // Selection state - const [selectedIndex, setSelectedIndex] = useState(0) - const [selectedForInstall, setSelectedForInstall] = useState>( - new Set(), - ) - const [installingPlugins, setInstallingPlugins] = useState>( - new Set(), - ) + const [selectedIndex, setSelectedIndex] = useState(0); + const [selectedForInstall, setSelectedForInstall] = useState>(new Set()); + const [installingPlugins, setInstallingPlugins] = useState>(new Set()); // Pagination for plugin list (continuous scrolling) const pagination = usePagination({ totalItems: filteredPlugins.length, selectedIndex, - }) + }); // Reset selection when search query changes useEffect(() => { - setSelectedIndex(0) - }, [searchQuery]) + setSelectedIndex(0); + }, [searchQuery]); // Details view state - const [detailsMenuIndex, setDetailsMenuIndex] = useState(0) - const [isInstalling, setIsInstalling] = useState(false) - const [installError, setInstallError] = useState(null) + const [detailsMenuIndex, setDetailsMenuIndex] = useState(0); + const [isInstalling, setIsInstalling] = useState(false); + const [installError, setInstallError] = useState(null); // Warning state for non-critical errors - const [warning, setWarning] = useState(null) + const [warning, setWarning] = useState(null); // Empty state reason - const [emptyReason, setEmptyReason] = useState( - null, - ) + const [emptyReason, setEmptyReason] = useState(null); // Load all plugins from all marketplaces useEffect(() => { async function loadAllPlugins() { try { - const config = await loadKnownMarketplacesConfig() + const config = await loadKnownMarketplacesConfig(); // Load marketplaces with graceful degradation - const { marketplaces, failures } = - await loadMarketplacesWithGracefulDegradation(config) + const { marketplaces, failures } = await loadMarketplacesWithGracefulDegradation(config); // Collect all plugins from all marketplaces - const allPlugins: InstallablePlugin[] = [] + const allPlugins: InstallablePlugin[] = []; for (const { name, data: marketplace } of marketplaces) { if (marketplace) { for (const entry of marketplace.plugins) { - const pluginId = createPluginId(entry.name, name) + const pluginId = createPluginId(entry.name, name); allPlugins.push({ entry, marketplaceName: name, @@ -183,113 +154,98 @@ export function DiscoverPlugins({ // Project/local-scope installs don't block — user may want to // promote to user scope so it's available everywhere (gh-29997). isInstalled: isPluginGloballyInstalled(pluginId), - }) + }); } } } // Filter out installed and policy-blocked plugins - const uninstalledPlugins = allPlugins.filter( - p => !p.isInstalled && !isPluginBlockedByPolicy(p.pluginId), - ) + const uninstalledPlugins = allPlugins.filter(p => !p.isInstalled && !isPluginBlockedByPolicy(p.pluginId)); // Fetch install counts and sort by popularity try { - const counts = await getInstallCounts() - setInstallCounts(counts) + const counts = await getInstallCounts(); + setInstallCounts(counts); if (counts) { // Sort by install count (descending), then alphabetically uninstalledPlugins.sort((a, b) => { - const countA = counts.get(a.pluginId) ?? 0 - const countB = counts.get(b.pluginId) ?? 0 - if (countA !== countB) return countB - countA - return a.entry.name.localeCompare(b.entry.name) - }) + const countA = counts.get(a.pluginId) ?? 0; + const countB = counts.get(b.pluginId) ?? 0; + if (countA !== countB) return countB - countA; + return a.entry.name.localeCompare(b.entry.name); + }); } else { // No counts available - sort alphabetically - uninstalledPlugins.sort((a, b) => - a.entry.name.localeCompare(b.entry.name), - ) + uninstalledPlugins.sort((a, b) => a.entry.name.localeCompare(b.entry.name)); } } catch (error) { // Log the error, then gracefully degrade to alphabetical sort - logForDebugging( - `Failed to fetch install counts: ${errorMessage(error)}`, - ) - uninstalledPlugins.sort((a, b) => - a.entry.name.localeCompare(b.entry.name), - ) + logForDebugging(`Failed to fetch install counts: ${errorMessage(error)}`); + uninstalledPlugins.sort((a, b) => a.entry.name.localeCompare(b.entry.name)); } - setAvailablePlugins(uninstalledPlugins) + setAvailablePlugins(uninstalledPlugins); // Detect empty reason if no plugins available - const configuredCount = Object.keys(config).length + const configuredCount = Object.keys(config).length; if (uninstalledPlugins.length === 0) { const reason = await detectEmptyMarketplaceReason({ configuredMarketplaceCount: configuredCount, failedMarketplaceCount: failures.length, - }) - setEmptyReason(reason) + }); + setEmptyReason(reason); } // Handle marketplace loading errors/warnings - const successCount = count(marketplaces, m => m.data !== null) - const errorResult = formatMarketplaceLoadingErrors( - failures, - successCount, - ) + const successCount = count(marketplaces, m => m.data !== null); + const errorResult = formatMarketplaceLoadingErrors(failures, successCount); if (errorResult) { if (errorResult.type === 'warning') { - setWarning(errorResult.message + '. Showing available plugins.') + setWarning(errorResult.message + '. Showing available plugins.'); } else { - throw new Error(errorResult.message) + throw new Error(errorResult.message); } } // Handle targetPlugin - navigate directly to plugin details // Search in allPlugins (before filtering) to handle installed plugins gracefully if (targetPlugin) { - const foundPlugin = allPlugins.find( - p => p.entry.name === targetPlugin, - ) + const foundPlugin = allPlugins.find(p => p.entry.name === targetPlugin); if (foundPlugin) { if (foundPlugin.isInstalled) { setError( `Plugin '${foundPlugin.pluginId}' is already installed. Use '/plugin' to manage existing plugins.`, - ) + ); } else { - setSelectedPlugin(foundPlugin) - setViewState('plugin-details') + setSelectedPlugin(foundPlugin); + setViewState('plugin-details'); } } else { - setError(`Plugin "${targetPlugin}" not found in any marketplace`) + setError(`Plugin "${targetPlugin}" not found in any marketplace`); } } } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load plugins') + setError(err instanceof Error ? err.message : 'Failed to load plugins'); } finally { - setLoading(false) + setLoading(false); } } - void loadAllPlugins() - }, [setError, targetPlugin]) + void loadAllPlugins(); + }, [setError, targetPlugin]); // Install selected plugins const installSelectedPlugins = async () => { - if (selectedForInstall.size === 0) return + if (selectedForInstall.size === 0) return; - const pluginsToInstall = availablePlugins.filter(p => - selectedForInstall.has(p.pluginId), - ) + const pluginsToInstall = availablePlugins.filter(p => selectedForInstall.has(p.pluginId)); - setInstallingPlugins(new Set(pluginsToInstall.map(p => p.pluginId))) + setInstallingPlugins(new Set(pluginsToInstall.map(p => p.pluginId))); - let successCount = 0 - let failureCount = 0 - const newFailedPlugins: Array<{ name: string; reason: string }> = [] + let successCount = 0; + let failureCount = 0; + const newFailedPlugins: Array<{ name: string; reason: string }> = []; for (const plugin of pluginsToInstall) { const result = await installPluginFromMarketplace({ @@ -297,128 +253,122 @@ export function DiscoverPlugins({ entry: plugin.entry, marketplaceName: plugin.marketplaceName, scope: 'user', - }) + }); if (result.success) { - successCount++ + successCount++; } else { - failureCount++ + failureCount++; newFailedPlugins.push({ name: plugin.entry.name, reason: (result as { success: false; error: string }).error, - }) + }); } } - setInstallingPlugins(new Set()) - setSelectedForInstall(new Set()) - clearAllCaches() + setInstallingPlugins(new Set()); + setSelectedForInstall(new Set()); + clearAllCaches(); // Handle installation results if (failureCount === 0) { const message = - `✓ Installed ${successCount} ${plural(successCount, 'plugin')}. ` + - `Run /reload-plugins to activate.` - setResult(message) + `✓ Installed ${successCount} ${plural(successCount, 'plugin')}. ` + `Run /reload-plugins to activate.`; + setResult(message); } else if (successCount === 0) { - setError( - `Failed to install: ${formatFailureDetails(newFailedPlugins, true)}`, - ) + setError(`Failed to install: ${formatFailureDetails(newFailedPlugins, true)}`); } else { const message = `✓ Installed ${successCount} of ${successCount + failureCount} plugins. ` + `Failed: ${formatFailureDetails(newFailedPlugins, false)}. ` + - `Run /reload-plugins to activate successfully installed plugins.` - setResult(message) + `Run /reload-plugins to activate successfully installed plugins.`; + setResult(message); } if (successCount > 0) { if (onInstallComplete) { - await onInstallComplete() + await onInstallComplete(); } } - setParentViewState({ type: 'menu' }) - } + setParentViewState({ type: 'menu' }); + }; // Install single plugin from details view - const handleSinglePluginInstall = async ( - plugin: InstallablePlugin, - scope: 'user' | 'project' | 'local' = 'user', - ) => { - setIsInstalling(true) - setInstallError(null) + const handleSinglePluginInstall = async (plugin: InstallablePlugin, scope: 'user' | 'project' | 'local' = 'user') => { + setIsInstalling(true); + setInstallError(null); const result = await installPluginFromMarketplace({ pluginId: plugin.pluginId, entry: plugin.entry, marketplaceName: plugin.marketplaceName, scope, - }) + }); if (result.success) { - const loaded = await findPluginOptionsTarget(plugin.pluginId) + const loaded = await findPluginOptionsTarget(plugin.pluginId); if (loaded) { - setIsInstalling(false) + setIsInstalling(false); setViewState({ type: 'plugin-options', plugin: loaded, pluginId: plugin.pluginId, - }) - return + }); + return; } - setResult(result.message) + setResult(result.message); if (onInstallComplete) { - await onInstallComplete() + await onInstallComplete(); } - setParentViewState({ type: 'menu' }) + setParentViewState({ type: 'menu' }); } else { - setIsInstalling(false) - setInstallError((result as { success: false; error: string }).error) + setIsInstalling(false); + setInstallError((result as { success: false; error: string }).error); } - } + }; // Handle error state useEffect(() => { if (error) { - setResult(error) + setResult(error); } - }, [error, setResult]) + }, [error, setResult]); // Escape in plugin-details view - go back to plugin-list useKeybinding( 'confirm:no', () => { - setViewState('plugin-list') - setSelectedPlugin(null) + setViewState('plugin-list'); + setSelectedPlugin(null); }, { context: 'Confirmation', isActive: viewState === 'plugin-details', }, - ) + ); // Escape in plugin-list view (not search mode) - exit to parent menu useKeybinding( 'confirm:no', () => { - setParentViewState({ type: 'menu' }) + setParentViewState({ type: 'menu' }); }, { context: 'Confirmation', isActive: viewState === 'plugin-list' && !isSearchMode, }, - ) + ); // Handle entering search mode (non-escape keys) useInput( (input, _key) => { - const keyIsNotCtrlOrMeta = !_key.ctrl && !_key.meta + const keyIsNotCtrlOrMeta = !_key.ctrl && !_key.meta; if (!isSearchMode) { // Enter search mode with '/' or any printable character if (input === '/' && keyIsNotCtrlOrMeta) { - setIsSearchMode(true) - setSearchQuery('') + setIsSearchMode(true); + setSearchQuery(''); } else if ( keyIsNotCtrlOrMeta && input.length > 0 && @@ -428,49 +378,46 @@ export function DiscoverPlugins({ input !== 'k' && input !== 'i' ) { - setIsSearchMode(true) - setSearchQuery(input) + setIsSearchMode(true); + setSearchQuery(input); } } }, { isActive: viewState === 'plugin-list' && !loading }, - ) + ); // Plugin-list navigation (non-search mode) useKeybindings( { 'select:previous': () => { if (selectedIndex === 0) { - setIsSearchMode(true) + setIsSearchMode(true); } else { - pagination.handleSelectionChange(selectedIndex - 1, setSelectedIndex) + pagination.handleSelectionChange(selectedIndex - 1, setSelectedIndex); } }, 'select:next': () => { if (selectedIndex < filteredPlugins.length - 1) { - pagination.handleSelectionChange(selectedIndex + 1, setSelectedIndex) + pagination.handleSelectionChange(selectedIndex + 1, setSelectedIndex); } }, 'select:accept': () => { - if ( - selectedIndex === filteredPlugins.length && - selectedForInstall.size > 0 - ) { - void installSelectedPlugins() + if (selectedIndex === filteredPlugins.length && selectedForInstall.size > 0) { + void installSelectedPlugins(); } else if (selectedIndex < filteredPlugins.length) { - const plugin = filteredPlugins[selectedIndex] + const plugin = filteredPlugins[selectedIndex]; if (plugin) { if (plugin.isInstalled) { setParentViewState({ type: 'manage-plugins', targetPlugin: plugin.entry.name, targetMarketplace: plugin.marketplaceName, - }) + }); } else { - setSelectedPlugin(plugin) - setViewState('plugin-details') - setDetailsMenuIndex(0) - setInstallError(null) + setSelectedPlugin(plugin); + setViewState('plugin-details'); + setDetailsMenuIndex(0); + setInstallError(null); } } } @@ -480,27 +427,27 @@ export function DiscoverPlugins({ context: 'Select', isActive: viewState === 'plugin-list' && !isSearchMode, }, - ) + ); useKeybindings( { 'plugin:toggle': () => { if (selectedIndex < filteredPlugins.length) { - const plugin = filteredPlugins[selectedIndex] + const plugin = filteredPlugins[selectedIndex]; if (plugin && !plugin.isInstalled) { - const newSelection = new Set(selectedForInstall) + const newSelection = new Set(selectedForInstall); if (newSelection.has(plugin.pluginId)) { - newSelection.delete(plugin.pluginId) + newSelection.delete(plugin.pluginId); } else { - newSelection.add(plugin.pluginId) + newSelection.add(plugin.pluginId); } - setSelectedForInstall(newSelection) + setSelectedForInstall(newSelection); } } }, 'plugin:install': () => { if (selectedForInstall.size > 0) { - void installSelectedPlugins() + void installSelectedPlugins(); } }, }, @@ -508,46 +455,46 @@ export function DiscoverPlugins({ context: 'Plugin', isActive: viewState === 'plugin-list' && !isSearchMode, }, - ) + ); // Plugin-details navigation const detailsMenuOptions = React.useMemo(() => { - if (!selectedPlugin) return [] - const hasHomepage = selectedPlugin.entry.homepage - const githubRepo = extractGitHubRepo(selectedPlugin) - return buildPluginDetailsMenuOptions(hasHomepage, githubRepo) - }, [selectedPlugin]) + if (!selectedPlugin) return []; + const hasHomepage = selectedPlugin.entry.homepage; + const githubRepo = extractGitHubRepo(selectedPlugin); + return buildPluginDetailsMenuOptions(hasHomepage, githubRepo); + }, [selectedPlugin]); useKeybindings( { 'select:previous': () => { if (detailsMenuIndex > 0) { - setDetailsMenuIndex(detailsMenuIndex - 1) + setDetailsMenuIndex(detailsMenuIndex - 1); } }, 'select:next': () => { if (detailsMenuIndex < detailsMenuOptions.length - 1) { - setDetailsMenuIndex(detailsMenuIndex + 1) + setDetailsMenuIndex(detailsMenuIndex + 1); } }, 'select:accept': () => { - if (!selectedPlugin) return - const action = detailsMenuOptions[detailsMenuIndex]?.action - const hasHomepage = selectedPlugin.entry.homepage - const githubRepo = extractGitHubRepo(selectedPlugin) + if (!selectedPlugin) return; + const action = detailsMenuOptions[detailsMenuIndex]?.action; + const hasHomepage = selectedPlugin.entry.homepage; + const githubRepo = extractGitHubRepo(selectedPlugin); if (action === 'install-user') { - void handleSinglePluginInstall(selectedPlugin, 'user') + void handleSinglePluginInstall(selectedPlugin, 'user'); } else if (action === 'install-project') { - void handleSinglePluginInstall(selectedPlugin, 'project') + void handleSinglePluginInstall(selectedPlugin, 'project'); } else if (action === 'install-local') { - void handleSinglePluginInstall(selectedPlugin, 'local') + void handleSinglePluginInstall(selectedPlugin, 'local'); } else if (action === 'homepage' && hasHomepage) { - void openBrowser(hasHomepage) + void openBrowser(hasHomepage); } else if (action === 'github' && githubRepo) { - void openBrowser(`https://github.com/${githubRepo}`) + void openBrowser(`https://github.com/${githubRepo}`); } else if (action === 'back') { - setViewState('plugin-list') - setSelectedPlugin(null) + setViewState('plugin-list'); + setSelectedPlugin(null); } }, }, @@ -555,16 +502,16 @@ export function DiscoverPlugins({ context: 'Select', isActive: viewState === 'plugin-details' && !!selectedPlugin, }, - ) + ); if (typeof viewState === 'object' && viewState.type === 'plugin-options') { - const { plugin, pluginId } = viewState + const { plugin, pluginId } = viewState; function finish(msg: string): void { - setResult(msg) + setResult(msg); if (onInstallComplete) { - void onInstallComplete() + void onInstallComplete(); } - setParentViewState({ type: 'menu' }) + setParentViewState({ type: 'menu' }); } return ( { switch (outcome) { case 'configured': - finish( - `✓ Installed and configured ${plugin.name}. Run /reload-plugins to apply.`, - ) - break + finish(`✓ Installed and configured ${plugin.name}. Run /reload-plugins to apply.`); + break; case 'skipped': - finish( - `✓ Installed ${plugin.name}. Run /reload-plugins to apply.`, - ) - break + finish(`✓ Installed ${plugin.name}. Run /reload-plugins to apply.`); + break; case 'error': - finish(`Installed but failed to save config: ${detail}`) - break + finish(`Installed but failed to save config: ${detail}`); + break; } }} /> - ) + ); } // Loading state if (loading) { - return Loading… + return Loading…; } // Error state if (error) { - return {error} + return {error}; } // Plugin details view if (viewState === 'plugin-details' && selectedPlugin) { - const hasHomepage = selectedPlugin.entry.homepage - const githubRepo = extractGitHubRepo(selectedPlugin) + const hasHomepage = selectedPlugin.entry.homepage; + const githubRepo = extractGitHubRepo(selectedPlugin); - const menuOptions = buildPluginDetailsMenuOptions(hasHomepage, githubRepo) + const menuOptions = buildPluginDetailsMenuOptions(hasHomepage, githubRepo); return ( @@ -617,9 +560,7 @@ export function DiscoverPlugins({ {selectedPlugin.entry.name} from {selectedPlugin.marketplaceName} - {selectedPlugin.entry.version && ( - Version: {selectedPlugin.entry.version} - )} + {selectedPlugin.entry.version && Version: {selectedPlugin.entry.version}} {selectedPlugin.entry.description && ( {selectedPlugin.entry.description} @@ -651,9 +592,7 @@ export function DiscoverPlugins({ {detailsMenuIndex === index && {'> '}} {detailsMenuIndex !== index && {' '}} - {isInstalling && option.action.startsWith('install-') - ? 'Installing…' - : option.label} + {isInstalling && option.action.startsWith('install-') ? 'Installing…' : option.label} ))} @@ -662,23 +601,13 @@ export function DiscoverPlugins({ - - + + - ) + ); } // Empty state @@ -695,11 +624,11 @@ export function DiscoverPlugins({ - ) + ); } // Get visible plugins from pagination - const visiblePlugins = pagination.getVisibleItems(filteredPlugins) + const visiblePlugins = pagination.getVisibleItems(filteredPlugins); return ( @@ -708,8 +637,7 @@ export function DiscoverPlugins({ {pagination.needsPagination && ( {' '} - ({pagination.scrollPosition.current}/ - {pagination.scrollPosition.total}) + ({pagination.scrollPosition.current}/{pagination.scrollPosition.total}) )} @@ -750,11 +678,11 @@ export function DiscoverPlugins({ {/* Plugin list - use startIndex in key to force re-render on scroll */} {visiblePlugins.map((plugin, visibleIndex) => { - const actualIndex = pagination.toActualIndex(visibleIndex) - const isSelected = selectedIndex === actualIndex - const isSelectedForInstall = selectedForInstall.has(plugin.pluginId) - const isInstallingThis = installingPlugins.has(plugin.pluginId) - const isLast = visibleIndex === visiblePlugins.length - 1 + const actualIndex = pagination.toActualIndex(visibleIndex); + const isSelected = selectedIndex === actualIndex; + const isSelectedForInstall = selectedForInstall.has(plugin.pluginId); + const isInstallingThis = installingPlugins.has(plugin.pluginId); + const isLast = visibleIndex === visiblePlugins.length - 1; return ( - + {isSelected && !isSearchMode ? figures.pointer : ' '}{' '} - {isInstallingThis - ? figures.ellipsis - : isSelectedForInstall - ? figures.radioOn - : figures.radioOff}{' '} + {isInstallingThis ? figures.ellipsis : isSelectedForInstall ? figures.radioOn : figures.radioOff}{' '} {plugin.entry.name} · {plugin.marketplaceName} - {plugin.entry.tags?.includes('community-managed') && ( - [Community Managed] + {plugin.entry.tags?.includes('community-managed') && [Community Managed]} + {installCounts && plugin.marketplaceName === OFFICIAL_MARKETPLACE_NAME && ( + + {' · '} + {formatInstallCount(installCounts.get(plugin.pluginId) ?? 0)} installs + )} - {installCounts && - plugin.marketplaceName === OFFICIAL_MARKETPLACE_NAME && ( - - {' · '} - {formatInstallCount( - installCounts.get(plugin.pluginId) ?? 0, - )}{' '} - installs - - )} {plugin.entry.description && ( - - {truncateToWidth(plugin.entry.description, 60)} - + {truncateToWidth(plugin.entry.description, 60)} )} - ) + ); })} {/* Scroll down indicator */} @@ -820,21 +734,18 @@ export function DiscoverPlugins({ 0} - canToggle={ - selectedIndex < filteredPlugins.length && - !filteredPlugins[selectedIndex]?.isInstalled - } + canToggle={selectedIndex < filteredPlugins.length && !filteredPlugins[selectedIndex]?.isInstalled} /> - ) + ); } function DiscoverPluginsKeyHint({ hasSelection, canToggle, }: { - hasSelection: boolean - canToggle: boolean + hasSelection: boolean; + canToggle: boolean; }): React.ReactNode { return ( @@ -851,39 +762,20 @@ function DiscoverPluginsKeyHint({ )} type to search {canToggle && ( - + )} - - + + - ) + ); } /** * Context-aware empty state message for the Discover screen */ -function EmptyStateMessage({ - reason, -}: { - reason: EmptyMarketplaceReason | null -}): React.ReactNode { +function EmptyStateMessage({ reason }: { reason: EmptyMarketplaceReason | null }): React.ReactNode { switch (reason) { case 'git-not-installed': return ( @@ -891,52 +783,42 @@ function EmptyStateMessage({ Git is required to install marketplaces. Please install git and restart Claude Code. - ) + ); case 'all-blocked-by-policy': return ( <> - - Your organization policy does not allow any external marketplaces. - + Your organization policy does not allow any external marketplaces. Contact your administrator. - ) + ); case 'policy-restricts-sources': return ( <> - - Your organization restricts which marketplaces can be added. - - - Switch to the Marketplaces tab to view allowed sources. - + Your organization restricts which marketplaces can be added. + Switch to the Marketplaces tab to view allowed sources. - ) + ); case 'all-marketplaces-failed': return ( <> Failed to load marketplace data. Check your network connection. - ) + ); case 'all-plugins-installed': return ( <> All available plugins are already installed. - - Check for new plugins later or add more marketplaces. - + Check for new plugins later or add more marketplaces. - ) + ); case 'no-marketplaces-configured': default: return ( <> No plugins available. - - Add a marketplace first using the Marketplaces tab. - + Add a marketplace first using the Marketplaces tab. - ) + ); } } diff --git a/src/commands/plugin/ManageMarketplaces.tsx b/src/commands/plugin/ManageMarketplaces.tsx index 5ec3dbe80..2e2493d95 100644 --- a/src/commands/plugin/ManageMarketplaces.tsx +++ b/src/commands/plugin/ManageMarketplaces.tsx @@ -1,71 +1,65 @@ -import figures from 'figures' -import * as React from 'react' -import { useEffect, useRef, useState } from 'react' +import figures from 'figures'; +import * as React from 'react'; +import { useEffect, useRef, useState } from 'react'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from 'src/services/analytics/index.js' -import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js' -import { Byline, KeyboardShortcutHint } from '@anthropic/ink' +} from 'src/services/analytics/index.js'; +import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js'; +import { Byline, KeyboardShortcutHint } from '@anthropic/ink'; // eslint-disable-next-line custom-rules/prefer-use-keybindings -- useInput needed for marketplace-specific u/r shortcuts and y/n confirmation not in keybinding schema -import { Box, Text, useInput } from '@anthropic/ink' -import { - useKeybinding, - useKeybindings, -} from '../../keybindings/useKeybinding.js' -import type { LoadedPlugin } from '../../types/plugin.js' -import { count } from '../../utils/array.js' -import { shouldSkipPluginAutoupdate } from '../../utils/config.js' -import { errorMessage } from '../../utils/errors.js' -import { clearAllCaches } from '../../utils/plugins/cacheUtils.js' +import { Box, Text, useInput } from '@anthropic/ink'; +import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js'; +import type { LoadedPlugin } from '../../types/plugin.js'; +import { count } from '../../utils/array.js'; +import { shouldSkipPluginAutoupdate } from '../../utils/config.js'; +import { errorMessage } from '../../utils/errors.js'; +import { clearAllCaches } from '../../utils/plugins/cacheUtils.js'; import { createPluginId, formatMarketplaceLoadingErrors, getMarketplaceSourceDisplay, loadMarketplacesWithGracefulDegradation, -} from '../../utils/plugins/marketplaceHelpers.js' +} from '../../utils/plugins/marketplaceHelpers.js'; import { loadKnownMarketplacesConfig, refreshMarketplace, removeMarketplaceSource, setMarketplaceAutoUpdate, -} from '../../utils/plugins/marketplaceManager.js' -import { updatePluginsForMarketplaces } from '../../utils/plugins/pluginAutoupdate.js' -import { loadAllPlugins } from '../../utils/plugins/pluginLoader.js' -import { isMarketplaceAutoUpdate } from '../../utils/plugins/schemas.js' -import { - getSettingsForSource, - updateSettingsForSource, -} from '../../utils/settings/settings.js' -import { plural } from '../../utils/stringUtils.js' -import type { ViewState } from './types.js' +} from '../../utils/plugins/marketplaceManager.js'; +import { updatePluginsForMarketplaces } from '../../utils/plugins/pluginAutoupdate.js'; +import { loadAllPlugins } from '../../utils/plugins/pluginLoader.js'; +import { isMarketplaceAutoUpdate } from '../../utils/plugins/schemas.js'; +import { getSettingsForSource, updateSettingsForSource } from '../../utils/settings/settings.js'; +import { plural } from '../../utils/stringUtils.js'; +import type { ViewState } from './types.js'; type Props = { - setViewState: (state: ViewState) => void - error?: string | null - setError?: (error: string | null) => void - setResult: (result: string | null) => void + setViewState: (state: ViewState) => void; + error?: string | null; + setError?: (error: string | null) => void; + setResult: (result: string | null) => void; exitState: { - pending: boolean - keyName: 'Ctrl-C' | 'Ctrl-D' | null - } - onManageComplete?: () => void | Promise - targetMarketplace?: string - action?: 'update' | 'remove' -} + pending: boolean; + keyName: 'Ctrl-C' | 'Ctrl-D' | null; + }; + onManageComplete?: () => void | Promise; + targetMarketplace?: string; + action?: 'update' | 'remove'; +}; type MarketplaceState = { - name: string - source: string - lastUpdated?: string - pluginCount?: number - installedPlugins?: LoadedPlugin[] - pendingUpdate?: boolean - pendingRemove?: boolean - autoUpdate?: boolean -} + name: string; + source: string; + lastUpdated?: string; + pluginCount?: number; + installedPlugins?: LoadedPlugin[]; + pendingUpdate?: boolean; + pendingRemove?: boolean; + autoUpdate?: boolean; +}; -type InternalViewState = 'list' | 'details' | 'confirm-remove' +type InternalViewState = 'list' | 'details' | 'confirm-remove'; export function ManageMarketplaces({ setViewState, @@ -77,39 +71,33 @@ export function ManageMarketplaces({ targetMarketplace, action, }: Props): React.ReactNode { - const [marketplaceStates, setMarketplaceStates] = useState< - MarketplaceState[] - >([]) - const [loading, setLoading] = useState(true) - const [selectedIndex, setSelectedIndex] = useState(0) - const [isProcessing, setIsProcessing] = useState(false) - const [processError, setProcessError] = useState(null) - const [successMessage, setSuccessMessage] = useState(null) - const [progressMessage, setProgressMessage] = useState(null) - const [internalView, setInternalView] = useState('list') - const [selectedMarketplace, setSelectedMarketplace] = - useState(null) - const [detailsMenuIndex, setDetailsMenuIndex] = useState(0) - const hasAttemptedAutoAction = useRef(false) + const [marketplaceStates, setMarketplaceStates] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedIndex, setSelectedIndex] = useState(0); + const [isProcessing, setIsProcessing] = useState(false); + const [processError, setProcessError] = useState(null); + const [successMessage, setSuccessMessage] = useState(null); + const [progressMessage, setProgressMessage] = useState(null); + const [internalView, setInternalView] = useState('list'); + const [selectedMarketplace, setSelectedMarketplace] = useState(null); + const [detailsMenuIndex, setDetailsMenuIndex] = useState(0); + const hasAttemptedAutoAction = useRef(false); // Load marketplaces and their installed plugins useEffect(() => { async function loadMarketplaces() { try { - const config = await loadKnownMarketplacesConfig() - const { enabled, disabled } = await loadAllPlugins() - const allPlugins = [...enabled, ...disabled] + const config = await loadKnownMarketplacesConfig(); + const { enabled, disabled } = await loadAllPlugins(); + const allPlugins = [...enabled, ...disabled]; // Load marketplaces with graceful degradation - const { marketplaces, failures } = - await loadMarketplacesWithGracefulDegradation(config) + const { marketplaces, failures } = await loadMarketplacesWithGracefulDegradation(config); - const states: MarketplaceState[] = [] + const states: MarketplaceState[] = []; for (const { name, config: entry, data: marketplace } of marketplaces) { // Get all plugins installed from this marketplace - const installedFromMarketplace = allPlugins.filter(plugin => - plugin.source.endsWith(`@${name}`), - ) + const installedFromMarketplace = allPlugins.filter(plugin => plugin.source.endsWith(`@${name}`)); states.push({ name, @@ -120,149 +108,135 @@ export function ManageMarketplaces({ pendingUpdate: false, pendingRemove: false, autoUpdate: isMarketplaceAutoUpdate(name, entry), - }) + }); } // Sort: claude-plugin-directory first, then alphabetically states.sort((a, b) => { - if (a.name === 'claude-plugin-directory') return -1 - if (b.name === 'claude-plugin-directory') return 1 - return a.name.localeCompare(b.name) - }) - setMarketplaceStates(states) + if (a.name === 'claude-plugin-directory') return -1; + if (b.name === 'claude-plugin-directory') return 1; + return a.name.localeCompare(b.name); + }); + setMarketplaceStates(states); // Handle marketplace loading errors/warnings - const successCount = count(marketplaces, m => m.data !== null) - const errorResult = formatMarketplaceLoadingErrors( - failures, - successCount, - ) + const successCount = count(marketplaces, m => m.data !== null); + const errorResult = formatMarketplaceLoadingErrors(failures, successCount); if (errorResult) { if (errorResult.type === 'warning') { - setProcessError(errorResult.message) + setProcessError(errorResult.message); } else { - throw new Error(errorResult.message) + throw new Error(errorResult.message); } } // Auto-execute if target and action provided if (targetMarketplace && !hasAttemptedAutoAction.current && !error) { - hasAttemptedAutoAction.current = true - const targetIndex = states.findIndex( - s => s.name === targetMarketplace, - ) + hasAttemptedAutoAction.current = true; + const targetIndex = states.findIndex(s => s.name === targetMarketplace); if (targetIndex >= 0) { - const targetState = states[targetIndex] + const targetState = states[targetIndex]; if (action) { // Mark the action as pending and execute - setSelectedIndex(targetIndex + 1) // +1 because "Add Marketplace" is at index 0 - const newStates = [...states] + setSelectedIndex(targetIndex + 1); // +1 because "Add Marketplace" is at index 0 + const newStates = [...states]; if (action === 'update') { - newStates[targetIndex]!.pendingUpdate = true + newStates[targetIndex]!.pendingUpdate = true; } else if (action === 'remove') { - newStates[targetIndex]!.pendingRemove = true + newStates[targetIndex]!.pendingRemove = true; } - setMarketplaceStates(newStates) + setMarketplaceStates(newStates); // Apply the change immediately - setTimeout(applyChanges, 100, newStates) + setTimeout(applyChanges, 100, newStates); } else if (targetState) { // No action - just show the details view for this marketplace - setSelectedIndex(targetIndex + 1) // +1 because "Add Marketplace" is at index 0 - setSelectedMarketplace(targetState) - setInternalView('details') + setSelectedIndex(targetIndex + 1); // +1 because "Add Marketplace" is at index 0 + setSelectedMarketplace(targetState); + setInternalView('details'); } } else if (setError) { - setError(`Marketplace not found: ${targetMarketplace}`) + setError(`Marketplace not found: ${targetMarketplace}`); } } } catch (err) { if (setError) { - setError( - err instanceof Error ? err.message : 'Failed to load marketplaces', - ) + setError(err instanceof Error ? err.message : 'Failed to load marketplaces'); } - setProcessError( - err instanceof Error ? err.message : 'Failed to load marketplaces', - ) + setProcessError(err instanceof Error ? err.message : 'Failed to load marketplaces'); } finally { - setLoading(false) + setLoading(false); } } - void loadMarketplaces() + void loadMarketplaces(); // eslint-disable-next-line react-hooks/exhaustive-deps - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional - }, [targetMarketplace, action, error]) + }, [targetMarketplace, action, error]); // Check if there are any pending changes const hasPendingChanges = () => { - return marketplaceStates.some( - state => state.pendingUpdate || state.pendingRemove, - ) - } + return marketplaceStates.some(state => state.pendingUpdate || state.pendingRemove); + }; // Get count of pending operations const getPendingCounts = () => { - const updateCount = count(marketplaceStates, s => s.pendingUpdate) - const removeCount = count(marketplaceStates, s => s.pendingRemove) - return { updateCount, removeCount } - } + const updateCount = count(marketplaceStates, s => s.pendingUpdate); + const removeCount = count(marketplaceStates, s => s.pendingRemove); + return { updateCount, removeCount }; + }; // Apply all pending changes const applyChanges = async (states?: MarketplaceState[]) => { - const statesToProcess = states || marketplaceStates - const wasInDetailsView = internalView === 'details' - setIsProcessing(true) - setProcessError(null) - setSuccessMessage(null) - setProgressMessage(null) + const statesToProcess = states || marketplaceStates; + const wasInDetailsView = internalView === 'details'; + setIsProcessing(true); + setProcessError(null); + setSuccessMessage(null); + setProgressMessage(null); try { - const settings = getSettingsForSource('userSettings') - let updatedCount = 0 - let removedCount = 0 - const refreshedMarketplaces = new Set() + const settings = getSettingsForSource('userSettings'); + let updatedCount = 0; + let removedCount = 0; + const refreshedMarketplaces = new Set(); for (const state of statesToProcess) { // Handle remove if (state.pendingRemove) { // First uninstall all plugins from this marketplace if (state.installedPlugins && state.installedPlugins.length > 0) { - const newEnabledPlugins = { ...settings?.enabledPlugins } + const newEnabledPlugins = { ...settings?.enabledPlugins }; for (const plugin of state.installedPlugins) { - const pluginId = createPluginId(plugin.name, state.name) + const pluginId = createPluginId(plugin.name, state.name); // Mark as disabled/uninstalled - newEnabledPlugins[pluginId] = false + newEnabledPlugins[pluginId] = false; } updateSettingsForSource('userSettings', { enabledPlugins: newEnabledPlugins, - }) + }); } // Then remove the marketplace - await removeMarketplaceSource(state.name) - removedCount++ + await removeMarketplaceSource(state.name); + removedCount++; logEvent('tengu_marketplace_removed', { - marketplace_name: - state.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + marketplace_name: state.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, plugins_uninstalled: state.installedPlugins?.length || 0, - }) - continue + }); + continue; } // Handle update if (state.pendingUpdate) { // Refresh individual marketplace for efficiency with progress reporting await refreshMarketplace(state.name, (message: string) => { - setProgressMessage(message) - }) - updatedCount++ - refreshedMarketplaces.add(state.name.toLowerCase()) + setProgressMessage(message); + }); + updatedCount++; + refreshedMarketplaces.add(state.name.toLowerCase()); logEvent('tengu_marketplace_updated', { - marketplace_name: - state.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + marketplace_name: state.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); } } @@ -274,35 +248,30 @@ export function ManageMarketplaces({ // stamps the NEW dir with .orphaned_at on the next startup. See #29512. // updatePluginOp (called inside the helper) is what actually writes // installed_plugins.json via updateInstallationPathOnDisk. - let updatedPluginCount = 0 + let updatedPluginCount = 0; if (refreshedMarketplaces.size > 0) { - const updatedPluginIds = await updatePluginsForMarketplaces( - refreshedMarketplaces, - ) - updatedPluginCount = updatedPluginIds.length + const updatedPluginIds = await updatePluginsForMarketplaces(refreshedMarketplaces); + updatedPluginCount = updatedPluginIds.length; } // Clear caches after changes - clearAllCaches() + clearAllCaches(); // Call completion callback if (onManageComplete) { - await onManageComplete() + await onManageComplete(); } // Reload marketplace data to show updated timestamps - const config = await loadKnownMarketplacesConfig() - const { enabled, disabled } = await loadAllPlugins() - const allPlugins = [...enabled, ...disabled] + const config = await loadKnownMarketplacesConfig(); + const { enabled, disabled } = await loadAllPlugins(); + const allPlugins = [...enabled, ...disabled]; - const { marketplaces } = - await loadMarketplacesWithGracefulDegradation(config) + const { marketplaces } = await loadMarketplacesWithGracefulDegradation(config); - const newStates: MarketplaceState[] = [] + const newStates: MarketplaceState[] = []; for (const { name, config: entry, data: marketplace } of marketplaces) { - const installedFromMarketplace = allPlugins.filter(plugin => - plugin.source.endsWith(`@${name}`), - ) + const installedFromMarketplace = allPlugins.filter(plugin => plugin.source.endsWith(`@${name}`)); newStates.push({ name, @@ -313,93 +282,83 @@ export function ManageMarketplaces({ pendingUpdate: false, pendingRemove: false, autoUpdate: isMarketplaceAutoUpdate(name, entry), - }) + }); } // Sort: claude-plugin-directory first, then alphabetically newStates.sort((a, b) => { - if (a.name === 'claude-plugin-directory') return -1 - if (b.name === 'claude-plugin-directory') return 1 - return a.name.localeCompare(b.name) - }) - setMarketplaceStates(newStates) + if (a.name === 'claude-plugin-directory') return -1; + if (b.name === 'claude-plugin-directory') return 1; + return a.name.localeCompare(b.name); + }); + setMarketplaceStates(newStates); // Update selected marketplace reference with fresh data if (wasInDetailsView && selectedMarketplace) { - const updatedMarketplace = newStates.find( - s => s.name === selectedMarketplace.name, - ) + const updatedMarketplace = newStates.find(s => s.name === selectedMarketplace.name); if (updatedMarketplace) { - setSelectedMarketplace(updatedMarketplace) + setSelectedMarketplace(updatedMarketplace); } } // Build success message - const actions: string[] = [] + const actions: string[] = []; if (updatedCount > 0) { const pluginPart = - updatedPluginCount > 0 - ? ` (${updatedPluginCount} ${plural(updatedPluginCount, 'plugin')} bumped)` - : '' - actions.push( - `Updated ${updatedCount} ${plural(updatedCount, 'marketplace')}${pluginPart}`, - ) + updatedPluginCount > 0 ? ` (${updatedPluginCount} ${plural(updatedPluginCount, 'plugin')} bumped)` : ''; + actions.push(`Updated ${updatedCount} ${plural(updatedCount, 'marketplace')}${pluginPart}`); } if (removedCount > 0) { - actions.push( - `Removed ${removedCount} ${plural(removedCount, 'marketplace')}`, - ) + actions.push(`Removed ${removedCount} ${plural(removedCount, 'marketplace')}`); } if (actions.length > 0) { - const successMsg = `${figures.tick} ${actions.join(', ')}` + const successMsg = `${figures.tick} ${actions.join(', ')}`; // If we were in details view, stay there and show success if (wasInDetailsView) { - setSuccessMessage(successMsg) + setSuccessMessage(successMsg); } else { // Otherwise show result and exit to menu - setResult(successMsg) - setTimeout(setViewState, 2000, { type: 'menu' as const }) + setResult(successMsg); + setTimeout(setViewState, 2000, { type: 'menu' as const }); } } else if (!wasInDetailsView) { - setViewState({ type: 'menu' }) + setViewState({ type: 'menu' }); } } catch (err) { - const errorMsg = errorMessage(err) - setProcessError(errorMsg) + const errorMsg = errorMessage(err); + setProcessError(errorMsg); if (setError) { - setError(errorMsg) + setError(errorMsg); } } finally { - setIsProcessing(false) - setProgressMessage(null) + setIsProcessing(false); + setProgressMessage(null); } - } + }; // Handle confirming marketplace removal const confirmRemove = async () => { - if (!selectedMarketplace) return + if (!selectedMarketplace) return; // Mark for removal and apply const newStates = marketplaceStates.map(state => - state.name === selectedMarketplace.name - ? { ...state, pendingRemove: true } - : state, - ) - setMarketplaceStates(newStates) - await applyChanges(newStates) - } + state.name === selectedMarketplace.name ? { ...state, pendingRemove: true } : state, + ); + setMarketplaceStates(newStates); + await applyChanges(newStates); + }; // Build menu options for details view const buildDetailsMenuOptions = ( marketplace: MarketplaceState | null, ): Array<{ label: string; secondaryLabel?: string; value: string }> => { - if (!marketplace) return [] + if (!marketplace) return []; const options: Array<{ - label: string - secondaryLabel?: string - value: string + label: string; + secondaryLabel?: string; + value: string; }> = [ { label: `Browse plugins (${marketplace.pluginCount ?? 0})`, @@ -412,63 +371,51 @@ export function ManageMarketplaces({ : undefined, value: 'update', }, - ] + ]; // Only show auto-update toggle if auto-updater is not globally disabled if (!shouldSkipPluginAutoupdate()) { options.push({ - label: marketplace.autoUpdate - ? 'Disable auto-update' - : 'Enable auto-update', + label: marketplace.autoUpdate ? 'Disable auto-update' : 'Enable auto-update', value: 'toggle-auto-update', - }) + }); } - options.push({ label: 'Remove marketplace', value: 'remove' }) + options.push({ label: 'Remove marketplace', value: 'remove' }); - return options - } + return options; + }; // Handle toggling auto-update for a marketplace const handleToggleAutoUpdate = async (marketplace: MarketplaceState) => { - const newAutoUpdate = !marketplace.autoUpdate + const newAutoUpdate = !marketplace.autoUpdate; try { - await setMarketplaceAutoUpdate(marketplace.name, newAutoUpdate) + await setMarketplaceAutoUpdate(marketplace.name, newAutoUpdate); // Update local state setMarketplaceStates(prev => - prev.map(state => - state.name === marketplace.name - ? { ...state, autoUpdate: newAutoUpdate } - : state, - ), - ) + prev.map(state => (state.name === marketplace.name ? { ...state, autoUpdate: newAutoUpdate } : state)), + ); // Update selected marketplace reference - setSelectedMarketplace(prev => - prev ? { ...prev, autoUpdate: newAutoUpdate } : prev, - ) + setSelectedMarketplace(prev => (prev ? { ...prev, autoUpdate: newAutoUpdate } : prev)); } catch (err) { - setProcessError( - err instanceof Error ? err.message : 'Failed to update setting', - ) + setProcessError(err instanceof Error ? err.message : 'Failed to update setting'); } - } + }; // Escape in details or confirm-remove view - go back to list useKeybinding( 'confirm:no', () => { - setInternalView('list') - setDetailsMenuIndex(0) + setInternalView('list'); + setDetailsMenuIndex(0); }, { context: 'Confirmation', - isActive: - !isProcessing && - (internalView === 'details' || internalView === 'confirm-remove'), + isActive: !isProcessing && (internalView === 'details' || internalView === 'confirm-remove'), }, - ) + ); // Escape in list view with pending changes - clear pending changes useKeybinding( @@ -480,59 +427,58 @@ export function ManageMarketplaces({ pendingUpdate: false, pendingRemove: false, })), - ) - setSelectedIndex(0) + ); + setSelectedIndex(0); }, { context: 'Confirmation', isActive: !isProcessing && internalView === 'list' && hasPendingChanges(), }, - ) + ); // Escape in list view without pending changes - exit to parent menu useKeybinding( 'confirm:no', () => { - setViewState({ type: 'menu' }) + setViewState({ type: 'menu' }); }, { context: 'Confirmation', - isActive: - !isProcessing && internalView === 'list' && !hasPendingChanges(), + isActive: !isProcessing && internalView === 'list' && !hasPendingChanges(), }, - ) + ); // List view — navigation (up/down/enter via configurable keybindings) useKeybindings( { 'select:previous': () => setSelectedIndex(prev => Math.max(0, prev - 1)), 'select:next': () => { - const totalItems = marketplaceStates.length + 1 - setSelectedIndex(prev => Math.min(totalItems - 1, prev + 1)) + const totalItems = marketplaceStates.length + 1; + setSelectedIndex(prev => Math.min(totalItems - 1, prev + 1)); }, 'select:accept': () => { - const marketplaceIndex = selectedIndex - 1 + const marketplaceIndex = selectedIndex - 1; if (selectedIndex === 0) { - setViewState({ type: 'add-marketplace' }) + setViewState({ type: 'add-marketplace' }); } else if (hasPendingChanges()) { - void applyChanges() + void applyChanges(); } else { - const marketplace = marketplaceStates[marketplaceIndex] + const marketplace = marketplaceStates[marketplaceIndex]; if (marketplace) { - setSelectedMarketplace(marketplace) - setInternalView('details') - setDetailsMenuIndex(0) + setSelectedMarketplace(marketplace); + setInternalView('details'); + setDetailsMenuIndex(0); } } }, }, { context: 'Select', isActive: !isProcessing && internalView === 'list' }, - ) + ); // List view — marketplace-specific actions (u/r shortcuts) useInput( input => { - const marketplaceIndex = selectedIndex - 1 + const marketplaceIndex = selectedIndex - 1; if ((input === 'u' || input === 'U') && marketplaceIndex >= 0) { setMarketplaceStates(prev => prev.map((state, idx) => @@ -540,54 +486,49 @@ export function ManageMarketplaces({ ? { ...state, pendingUpdate: !state.pendingUpdate, - pendingRemove: state.pendingUpdate - ? state.pendingRemove - : false, + pendingRemove: state.pendingUpdate ? state.pendingRemove : false, } : state, ), - ) + ); } else if ((input === 'r' || input === 'R') && marketplaceIndex >= 0) { - const marketplace = marketplaceStates[marketplaceIndex] + const marketplace = marketplaceStates[marketplaceIndex]; if (marketplace) { - setSelectedMarketplace(marketplace) - setInternalView('confirm-remove') + setSelectedMarketplace(marketplace); + setInternalView('confirm-remove'); } } }, { isActive: !isProcessing && internalView === 'list' }, - ) + ); // Details view — navigation useKeybindings( { - 'select:previous': () => - setDetailsMenuIndex(prev => Math.max(0, prev - 1)), + 'select:previous': () => setDetailsMenuIndex(prev => Math.max(0, prev - 1)), 'select:next': () => { - const menuOptions = buildDetailsMenuOptions(selectedMarketplace) - setDetailsMenuIndex(prev => Math.min(menuOptions.length - 1, prev + 1)) + const menuOptions = buildDetailsMenuOptions(selectedMarketplace); + setDetailsMenuIndex(prev => Math.min(menuOptions.length - 1, prev + 1)); }, 'select:accept': () => { - if (!selectedMarketplace) return - const menuOptions = buildDetailsMenuOptions(selectedMarketplace) - const selectedOption = menuOptions[detailsMenuIndex] + if (!selectedMarketplace) return; + const menuOptions = buildDetailsMenuOptions(selectedMarketplace); + const selectedOption = menuOptions[detailsMenuIndex]; if (selectedOption?.value === 'browse') { setViewState({ type: 'browse-marketplace', targetMarketplace: selectedMarketplace.name, - }) + }); } else if (selectedOption?.value === 'update') { const newStates = marketplaceStates.map(state => - state.name === selectedMarketplace.name - ? { ...state, pendingUpdate: true } - : state, - ) - setMarketplaceStates(newStates) - void applyChanges(newStates) + state.name === selectedMarketplace.name ? { ...state, pendingUpdate: true } : state, + ); + setMarketplaceStates(newStates); + void applyChanges(newStates); } else if (selectedOption?.value === 'toggle-auto-update') { - void handleToggleAutoUpdate(selectedMarketplace) + void handleToggleAutoUpdate(selectedMarketplace); } else if (selectedOption?.value === 'remove') { - setInternalView('confirm-remove') + setInternalView('confirm-remove'); } }, }, @@ -595,23 +536,23 @@ export function ManageMarketplaces({ context: 'Select', isActive: !isProcessing && internalView === 'details', }, - ) + ); // Confirm-remove view — y/n input useInput( input => { if (input === 'y' || input === 'Y') { - void confirmRemove() + void confirmRemove(); } else if (input === 'n' || input === 'N') { - setInternalView('list') - setSelectedMarketplace(null) + setInternalView('list'); + setSelectedMarketplace(null); } }, { isActive: !isProcessing && internalView === 'confirm-remove' }, - ) + ); if (loading) { - return Loading marketplaces… + return Loading marketplaces…; } if (marketplaceStates.length === 0) { @@ -652,12 +593,12 @@ export function ManageMarketplaces({ - ) + ); } // Show confirmation dialog if (internalView === 'confirm-remove' && selectedMarketplace) { - const pluginCount = selectedMarketplace.installedPlugins?.length || 0 + const pluginCount = selectedMarketplace.installedPlugins?.length || 0; return ( @@ -667,39 +608,36 @@ export function ManageMarketplaces({ {pluginCount > 0 && ( - This will also uninstall {pluginCount}{' '} - {plural(pluginCount, 'plugin')} from this marketplace: + This will also uninstall {pluginCount} {plural(pluginCount, 'plugin')} from this marketplace: )} - {selectedMarketplace.installedPlugins && - selectedMarketplace.installedPlugins.length > 0 && ( - - {selectedMarketplace.installedPlugins.map(plugin => ( - - • {plugin.name} - - ))} - - )} + {selectedMarketplace.installedPlugins && selectedMarketplace.installedPlugins.length > 0 && ( + + {selectedMarketplace.installedPlugins.map(plugin => ( + + • {plugin.name} + + ))} + + )} - Press y to confirm or n to - cancel + Press y to confirm or n to cancel - ) + ); } // Show marketplace details if (internalView === 'details' && selectedMarketplace) { // Check if this marketplace is currently being processed // Check pendingUpdate first so we show updating state immediately when user presses Enter - const isUpdating = selectedMarketplace.pendingUpdate || isProcessing + const isUpdating = selectedMarketplace.pendingUpdate || isProcessing; - const menuOptions = buildDetailsMenuOptions(selectedMarketplace) + const menuOptions = buildDetailsMenuOptions(selectedMarketplace); return ( @@ -707,32 +645,30 @@ export function ManageMarketplaces({ {selectedMarketplace.source} - {selectedMarketplace.pluginCount || 0} available{' '} - {plural(selectedMarketplace.pluginCount || 0, 'plugin')} + {selectedMarketplace.pluginCount || 0} available {plural(selectedMarketplace.pluginCount || 0, 'plugin')} {/* Installed plugins section */} - {selectedMarketplace.installedPlugins && - selectedMarketplace.installedPlugins.length > 0 && ( - - - Installed plugins ({selectedMarketplace.installedPlugins.length} - ): - - - {selectedMarketplace.installedPlugins.map(plugin => ( - - {figures.bullet} - - {plugin.name} - {plugin.manifest.description} - + {selectedMarketplace.installedPlugins && selectedMarketplace.installedPlugins.length > 0 && ( + + + Installed plugins ({selectedMarketplace.installedPlugins.length} + ): + + + {selectedMarketplace.installedPlugins.map(plugin => ( + + {figures.bullet} + + {plugin.name} + {plugin.manifest.description} - ))} - + + ))} - )} + + )} {/* Processing indicator */} {isUpdating && ( @@ -760,33 +696,28 @@ export function ManageMarketplaces({ {!isUpdating && ( {menuOptions.map((option, idx) => { - if (!option) return null - const isSelected = idx === detailsMenuIndex + if (!option) return null; + const isSelected = idx === detailsMenuIndex; return ( {isSelected ? figures.pointer : ' '} {option.label} - {option.secondaryLabel && ( - {option.secondaryLabel} - )} + {option.secondaryLabel && {option.secondaryLabel}} - ) + ); })} )} {/* Show explanatory text at the bottom when auto-update is enabled */} - {!isUpdating && - !shouldSkipPluginAutoupdate() && - selectedMarketplace.autoUpdate && ( - - - Auto-update enabled. Claude Code will automatically update this - marketplace and its installed plugins. - - - )} + {!isUpdating && !shouldSkipPluginAutoupdate() && selectedMarketplace.autoUpdate && ( + + + Auto-update enabled. Claude Code will automatically update this marketplace and its installed plugins. + + + )} @@ -811,11 +742,11 @@ export function ManageMarketplaces({ - ) + ); } // Show marketplace list - const { updateCount, removeCount } = getPendingCounts() + const { updateCount, removeCount } = getPendingCounts(); return ( @@ -836,58 +767,38 @@ export function ManageMarketplaces({ {/* Marketplace list */} {marketplaceStates.map((state, idx) => { - const isSelected = idx + 1 === selectedIndex // +1 because Add Marketplace is at index 0 + const isSelected = idx + 1 === selectedIndex; // +1 because Add Marketplace is at index 0 // Build status indicators - const indicators: string[] = [] - if (state.pendingUpdate) indicators.push('UPDATE') - if (state.pendingRemove) indicators.push('REMOVE') + const indicators: string[] = []; + if (state.pendingUpdate) indicators.push('UPDATE'); + if (state.pendingRemove) indicators.push('REMOVE'); return ( - {isSelected ? figures.pointer : ' '}{' '} - {state.pendingRemove ? figures.cross : figures.bullet} + {isSelected ? figures.pointer : ' '} {state.pendingRemove ? figures.cross : figures.bullet} - - {state.name === 'claude-plugins-official' && ( - - )} + + {state.name === 'claude-plugins-official' && } {state.name} - {state.name === 'claude-plugins-official' && ( - - )} + {state.name === 'claude-plugins-official' && } - {indicators.length > 0 && ( - [{indicators.join(', ')}] - )} + {indicators.length > 0 && [{indicators.join(', ')}]} {state.source} - {state.pluginCount !== undefined && ( - <>{state.pluginCount} available - )} - {state.installedPlugins && - state.installedPlugins.length > 0 && ( - <> • {state.installedPlugins.length} installed - )} - {state.lastUpdated && ( - <> - {' '} - • Updated{' '} - {new Date(state.lastUpdated).toLocaleDateString()} - + {state.pluginCount !== undefined && <>{state.pluginCount} available} + {state.installedPlugins && state.installedPlugins.length > 0 && ( + <> • {state.installedPlugins.length} installed )} + {state.lastUpdated && <> • Updated {new Date(state.lastUpdated).toLocaleDateString()}} - ) + ); })} @@ -895,8 +806,7 @@ export function ManageMarketplaces({ {hasPendingChanges() && ( - Pending changes:{' '} - Enter to apply + Pending changes: Enter to apply {updateCount > 0 && ( @@ -925,18 +835,15 @@ export function ManageMarketplaces({ )} - + - ) + ); } type ManageMarketplacesKeyHintsProps = { - exitState: Props['exitState'] - hasPendingActions: boolean -} + exitState: Props['exitState']; + hasPendingActions: boolean; +}; function ManageMarketplacesKeyHints({ exitState, @@ -949,7 +856,7 @@ function ManageMarketplacesKeyHints({ Press {exitState.keyName} again to go back - ) + ); } return ( @@ -965,19 +872,10 @@ function ManageMarketplacesKeyHints({ /> )} {!hasPendingActions && ( - - )} - {!hasPendingActions && ( - - )} - {!hasPendingActions && ( - + )} + {!hasPendingActions && } + {!hasPendingActions && } - ) + ); } diff --git a/src/commands/plugin/ManagePlugins.tsx b/src/commands/plugin/ManagePlugins.tsx index a3524724c..b47bd44b2 100644 --- a/src/commands/plugin/ManagePlugins.tsx +++ b/src/commands/plugin/ManagePlugins.tsx @@ -1,40 +1,32 @@ -import figures from 'figures' -import type { Dirent } from 'fs' -import * as fs from 'fs/promises' -import * as path from 'path' -import * as React from 'react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js' -import { Byline } from '@anthropic/ink' -import { MCPRemoteServerMenu } from '../../components/mcp/MCPRemoteServerMenu.js' -import { MCPStdioServerMenu } from '../../components/mcp/MCPStdioServerMenu.js' -import { MCPToolDetailView } from '../../components/mcp/MCPToolDetailView.js' -import { MCPToolListView } from '../../components/mcp/MCPToolListView.js' -import type { - ClaudeAIServerInfo, - HTTPServerInfo, - SSEServerInfo, - StdioServerInfo, -} from '../../components/mcp/types.js' -import { SearchBox } from '../../components/SearchBox.js' -import { useSearchInput } from '../../hooks/useSearchInput.js' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import figures from 'figures'; +import type { Dirent } from 'fs'; +import * as fs from 'fs/promises'; +import * as path from 'path'; +import * as React from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js'; +import { Byline } from '@anthropic/ink'; +import { MCPRemoteServerMenu } from '../../components/mcp/MCPRemoteServerMenu.js'; +import { MCPStdioServerMenu } from '../../components/mcp/MCPStdioServerMenu.js'; +import { MCPToolDetailView } from '../../components/mcp/MCPToolDetailView.js'; +import { MCPToolListView } from '../../components/mcp/MCPToolListView.js'; +import type { ClaudeAIServerInfo, HTTPServerInfo, SSEServerInfo, StdioServerInfo } from '../../components/mcp/types.js'; +import { SearchBox } from '../../components/SearchBox.js'; +import { useSearchInput } from '../../hooks/useSearchInput.js'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; // eslint-disable-next-line custom-rules/prefer-use-keybindings -- useInput needed for raw search mode text input -import { Box, Text, useInput, useTerminalFocus } from '@anthropic/ink' -import { - useKeybinding, - useKeybindings, -} from '../../keybindings/useKeybinding.js' -import { getBuiltinPluginDefinition } from '../../plugins/builtinPlugins.js' -import { useMcpToggleEnabled } from '../../services/mcp/MCPConnectionManager.js' +import { Box, Text, useInput, useTerminalFocus } from '@anthropic/ink'; +import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js'; +import { getBuiltinPluginDefinition } from '../../plugins/builtinPlugins.js'; +import { useMcpToggleEnabled } from '../../services/mcp/MCPConnectionManager.js'; import type { MCPServerConnection, McpClaudeAIProxyServerConfig, McpHTTPServerConfig, McpSSEServerConfig, McpStdioServerConfig, -} from '../../services/mcp/types.js' -import { filterToolsByServer } from '../../services/mcp/utils.js' +} from '../../services/mcp/types.js'; +import { filterToolsByServer } from '../../services/mcp/utils.js'; import { disablePluginOp, enablePluginOp, @@ -43,86 +35,76 @@ import { isPluginEnabledAtProjectScope, uninstallPluginOp, updatePluginOp, -} from '../../services/plugins/pluginOperations.js' -import { useAppState } from '../../state/AppState.js' -import type { Tool } from '../../Tool.js' -import type { LoadedPlugin, PluginError } from '../../types/plugin.js' -import { count } from '../../utils/array.js' -import { openBrowser } from '../../utils/browser.js' -import { logForDebugging } from '../../utils/debug.js' -import { errorMessage, toError } from '../../utils/errors.js' -import { logError } from '../../utils/log.js' -import { clearAllCaches } from '../../utils/plugins/cacheUtils.js' -import { loadInstalledPluginsV2 } from '../../utils/plugins/installedPluginsManager.js' -import { getMarketplace } from '../../utils/plugins/marketplaceManager.js' +} from '../../services/plugins/pluginOperations.js'; +import { useAppState } from '../../state/AppState.js'; +import type { Tool } from '../../Tool.js'; +import type { LoadedPlugin, PluginError } from '../../types/plugin.js'; +import { count } from '../../utils/array.js'; +import { openBrowser } from '../../utils/browser.js'; +import { logForDebugging } from '../../utils/debug.js'; +import { errorMessage, toError } from '../../utils/errors.js'; +import { logError } from '../../utils/log.js'; +import { clearAllCaches } from '../../utils/plugins/cacheUtils.js'; +import { loadInstalledPluginsV2 } from '../../utils/plugins/installedPluginsManager.js'; +import { getMarketplace } from '../../utils/plugins/marketplaceManager.js'; import { isMcpbSource, loadMcpbFile, type McpbNeedsConfigResult, type UserConfigValues, -} from '../../utils/plugins/mcpbHandler.js' -import { - getPluginDataDirSize, - pluginDataDirPath, -} from '../../utils/plugins/pluginDirectories.js' -import { - getFlaggedPlugins, - markFlaggedPluginsSeen, - removeFlaggedPlugin, -} from '../../utils/plugins/pluginFlagging.js' -import { - type PersistablePluginScope, - parsePluginIdentifier, -} from '../../utils/plugins/pluginIdentifier.js' -import { loadAllPlugins } from '../../utils/plugins/pluginLoader.js' +} from '../../utils/plugins/mcpbHandler.js'; +import { getPluginDataDirSize, pluginDataDirPath } from '../../utils/plugins/pluginDirectories.js'; +import { getFlaggedPlugins, markFlaggedPluginsSeen, removeFlaggedPlugin } from '../../utils/plugins/pluginFlagging.js'; +import { type PersistablePluginScope, parsePluginIdentifier } from '../../utils/plugins/pluginIdentifier.js'; +import { loadAllPlugins } from '../../utils/plugins/pluginLoader.js'; import { loadPluginOptions, type PluginOptionSchema, savePluginOptions, -} from '../../utils/plugins/pluginOptionsStorage.js' -import { isPluginBlockedByPolicy } from '../../utils/plugins/pluginPolicy.js' -import { getPluginEditableScopes } from '../../utils/plugins/pluginStartupCheck.js' +} from '../../utils/plugins/pluginOptionsStorage.js'; +import { isPluginBlockedByPolicy } from '../../utils/plugins/pluginPolicy.js'; +import { getPluginEditableScopes } from '../../utils/plugins/pluginStartupCheck.js'; import { getSettings_DEPRECATED, getSettingsForSource, updateSettingsForSource, -} from '../../utils/settings/settings.js' -import { jsonParse } from '../../utils/slowOperations.js' -import { plural } from '../../utils/stringUtils.js' -import { formatErrorMessage, getErrorGuidance } from './PluginErrors.js' -import { PluginOptionsDialog } from './PluginOptionsDialog.js' -import { PluginOptionsFlow } from './PluginOptionsFlow.js' -import type { ViewState as ParentViewState } from './types.js' -import { UnifiedInstalledCell } from './UnifiedInstalledCell.js' -import type { UnifiedInstalledItem } from './unifiedTypes.js' -import { usePagination } from './usePagination.js' +} from '../../utils/settings/settings.js'; +import { jsonParse } from '../../utils/slowOperations.js'; +import { plural } from '../../utils/stringUtils.js'; +import { formatErrorMessage, getErrorGuidance } from './PluginErrors.js'; +import { PluginOptionsDialog } from './PluginOptionsDialog.js'; +import { PluginOptionsFlow } from './PluginOptionsFlow.js'; +import type { ViewState as ParentViewState } from './types.js'; +import { UnifiedInstalledCell } from './UnifiedInstalledCell.js'; +import type { UnifiedInstalledItem } from './unifiedTypes.js'; +import { usePagination } from './usePagination.js'; type Props = { - setViewState: (state: ParentViewState) => void - setResult: (result: string | null) => void - onManageComplete?: () => void | Promise - onSearchModeChange?: (isActive: boolean) => void - targetPlugin?: string - targetMarketplace?: string - action?: 'enable' | 'disable' | 'uninstall' -} + setViewState: (state: ParentViewState) => void; + setResult: (result: string | null) => void; + onManageComplete?: () => void | Promise; + onSearchModeChange?: (isActive: boolean) => void; + targetPlugin?: string; + targetMarketplace?: string; + action?: 'enable' | 'disable' | 'uninstall'; +}; type FlaggedPluginInfo = { - id: string - name: string - marketplace: string - reason: string - text: string - flaggedAt: string -} + id: string; + name: string; + marketplace: string; + reason: string; + text: string; + flaggedAt: string; +}; type FailedPluginInfo = { - id: string - name: string - marketplace: string - errors: PluginError[] - scope: PersistablePluginScope -} + id: string; + name: string; + marketplace: string; + errors: PluginError[]; + scope: PersistablePluginScope; +}; type ViewState = | 'plugin-list' @@ -136,22 +118,22 @@ type ViewState = | { type: 'failed-plugin-details'; plugin: FailedPluginInfo } | { type: 'mcp-detail'; client: MCPServerConnection } | { type: 'mcp-tools'; client: MCPServerConnection } - | { type: 'mcp-tool-detail'; client: MCPServerConnection; tool: Tool } + | { type: 'mcp-tool-detail'; client: MCPServerConnection; tool: Tool }; type MarketplaceInfo = { - name: string - installedPlugins: LoadedPlugin[] - enabledCount?: number - disabledCount?: number -} + name: string; + installedPlugins: LoadedPlugin[]; + enabledCount?: number; + disabledCount?: number; +}; type PluginState = { - plugin: LoadedPlugin - marketplace: string - scope?: 'user' | 'project' | 'local' | 'managed' | 'builtin' - pendingEnable?: boolean // Toggle enable/disable - pendingUpdate?: boolean // Marked for update -} + plugin: LoadedPlugin; + marketplace: string; + scope?: 'user' | 'project' | 'local' | 'managed' | 'builtin'; + pendingEnable?: boolean; // Toggle enable/disable + pendingUpdate?: boolean; // Marked for update +}; /** * Get list of base file names (without .md extension) from a directory @@ -164,23 +146,20 @@ type PluginState = { */ async function getBaseFileNames(dirPath: string): Promise { try { - const entries = await fs.readdir(dirPath, { withFileTypes: true }) + const entries = await fs.readdir(dirPath, { withFileTypes: true }); return entries .filter((entry: Dirent) => entry.isFile() && entry.name.endsWith('.md')) .map((entry: Dirent) => { // Remove .md extension specifically - const baseName = path.basename(entry.name, '.md') - return baseName - }) + const baseName = path.basename(entry.name, '.md'); + return baseName; + }); } catch (error) { - const errorMsg = errorMessage(error) - logForDebugging( - `Failed to read plugin components from ${dirPath}: ${errorMsg}`, - { level: 'error' }, - ) - logError(toError(error)) + const errorMsg = errorMessage(error); + logForDebugging(`Failed to read plugin components from ${dirPath}: ${errorMsg}`, { level: 'error' }); + logError(toError(error)); // Return empty array to allow graceful degradation - plugin details can still be shown - return [] + return []; } } @@ -196,18 +175,18 @@ async function getBaseFileNames(dirPath: string): Promise { */ async function getSkillDirNames(dirPath: string): Promise { try { - const entries = await fs.readdir(dirPath, { withFileTypes: true }) - const skillNames: string[] = [] + const entries = await fs.readdir(dirPath, { withFileTypes: true }); + const skillNames: string[] = []; for (const entry of entries) { // Check if it's a directory or symlink (symlinks may point to skill directories) if (entry.isDirectory() || entry.isSymbolicLink()) { // Check if this directory contains a SKILL.md file - const skillFilePath = path.join(dirPath, entry.name, 'SKILL.md') + const skillFilePath = path.join(dirPath, entry.name, 'SKILL.md'); try { - const st = await fs.stat(skillFilePath) + const st = await fs.stat(skillFilePath); if (st.isFile()) { - skillNames.push(entry.name) + skillNames.push(entry.name); } } catch { // No SKILL.md file in this directory, skip it @@ -215,16 +194,13 @@ async function getSkillDirNames(dirPath: string): Promise { } } - return skillNames + return skillNames; } catch (error) { - const errorMsg = errorMessage(error) - logForDebugging( - `Failed to read skill directories from ${dirPath}: ${errorMsg}`, - { level: 'error' }, - ) - logError(toError(error)) + const errorMsg = errorMessage(error); + logForDebugging(`Failed to read skill directories from ${dirPath}: ${errorMsg}`, { level: 'error' }); + logError(toError(error)); // Return empty array to allow graceful degradation - plugin details can still be shown - return [] + return []; } } @@ -233,18 +209,18 @@ function PluginComponentsDisplay({ plugin, marketplace, }: { - plugin: LoadedPlugin - marketplace: string + plugin: LoadedPlugin; + marketplace: string; }): React.ReactNode { const [components, setComponents] = useState<{ - commands?: string | string[] | Record | null - agents?: string | string[] | Record | null - skills?: string | string[] | Record | null - hooks?: unknown - mcpServers?: unknown - } | null>(null) - const [loading, setLoading] = useState(true) - const [error, setError] = useState(null) + commands?: string | string[] | Record | null; + agents?: string | string[] | Record | null; + skills?: string | string[] | Record | null; + hooks?: unknown; + mcpServers?: unknown; + } | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); useEffect(() => { async function loadComponents() { @@ -252,109 +228,103 @@ function PluginComponentsDisplay({ // Built-in plugins don't have a marketplace entry — read from the // registered definition directly. if (marketplace === 'builtin') { - const builtinDef = getBuiltinPluginDefinition(plugin.name) + const builtinDef = getBuiltinPluginDefinition(plugin.name); if (builtinDef) { - const skillNames = builtinDef.skills?.map(s => s.name) ?? [] - const hookEvents = builtinDef.hooks - ? Object.keys(builtinDef.hooks) - : [] - const mcpServerNames = builtinDef.mcpServers - ? Object.keys(builtinDef.mcpServers) - : [] + const skillNames = builtinDef.skills?.map(s => s.name) ?? []; + const hookEvents = builtinDef.hooks ? Object.keys(builtinDef.hooks) : []; + const mcpServerNames = builtinDef.mcpServers ? Object.keys(builtinDef.mcpServers) : []; setComponents({ commands: null, agents: null, skills: skillNames.length > 0 ? skillNames : null, hooks: hookEvents.length > 0 ? hookEvents : null, mcpServers: mcpServerNames.length > 0 ? mcpServerNames : null, - }) + }); } else { - setError(`Built-in plugin ${plugin.name} not found`) + setError(`Built-in plugin ${plugin.name} not found`); } - setLoading(false) - return + setLoading(false); + return; } - const marketplaceData = await getMarketplace(marketplace) + const marketplaceData = await getMarketplace(marketplace); // Find the plugin entry in the array - const pluginEntry = marketplaceData.plugins.find( - p => p.name === plugin.name, - ) + const pluginEntry = marketplaceData.plugins.find(p => p.name === plugin.name); if (pluginEntry) { // Combine commands from both sources - const commandPathList = [] + const commandPathList = []; if (plugin.commandsPath) { - commandPathList.push(plugin.commandsPath) + commandPathList.push(plugin.commandsPath); } if (plugin.commandsPaths) { - commandPathList.push(...plugin.commandsPaths) + commandPathList.push(...plugin.commandsPaths); } // Get base file names from all command paths - const commandList: string[] = [] + const commandList: string[] = []; for (const commandPath of commandPathList) { if (typeof commandPath === 'string') { // commandPath is already a full path - const baseNames = await getBaseFileNames(commandPath) - commandList.push(...baseNames) + const baseNames = await getBaseFileNames(commandPath); + commandList.push(...baseNames); } } // Combine agents from both sources - const agentPathList = [] + const agentPathList = []; if (plugin.agentsPath) { - agentPathList.push(plugin.agentsPath) + agentPathList.push(plugin.agentsPath); } if (plugin.agentsPaths) { - agentPathList.push(...plugin.agentsPaths) + agentPathList.push(...plugin.agentsPaths); } // Get base file names from all agent paths - const agentList: string[] = [] + const agentList: string[] = []; for (const agentPath of agentPathList) { if (typeof agentPath === 'string') { // agentPath is already a full path - const baseNames = await getBaseFileNames(agentPath) - agentList.push(...baseNames) + const baseNames = await getBaseFileNames(agentPath); + agentList.push(...baseNames); } } // Combine skills from both sources - const skillPathList = [] + const skillPathList = []; if (plugin.skillsPath) { - skillPathList.push(plugin.skillsPath) + skillPathList.push(plugin.skillsPath); } if (plugin.skillsPaths) { - skillPathList.push(...plugin.skillsPaths) + skillPathList.push(...plugin.skillsPaths); } // Get skill directory names from all skill paths // Skills are directories containing SKILL.md files - const skillList: string[] = [] + const skillList: string[] = []; for (const skillPath of skillPathList) { if (typeof skillPath === 'string') { // skillPath is already a full path to a skills directory - const skillDirNames = await getSkillDirNames(skillPath) - skillList.push(...skillDirNames) + const skillDirNames = await getSkillDirNames(skillPath); + skillList.push(...skillDirNames); } } // Combine hooks from both sources - const hooksList = [] + const hooksList = []; if (plugin.hooksConfig) { - hooksList.push(Object.keys(plugin.hooksConfig)) + hooksList.push(Object.keys(plugin.hooksConfig)); } if (pluginEntry.hooks) { - hooksList.push(pluginEntry.hooks) + hooksList.push(pluginEntry.hooks); } // Combine MCP servers from both sources - const mcpServersList = [] + const mcpServersList = []; if (plugin.mcpServers) { - mcpServersList.push(Object.keys(plugin.mcpServers)) + mcpServersList.push(Object.keys(plugin.mcpServers)); } if (pluginEntry.mcpServers) { - mcpServersList.push(pluginEntry.mcpServers) + mcpServersList.push(pluginEntry.mcpServers); } setComponents({ @@ -363,19 +333,17 @@ function PluginComponentsDisplay({ skills: skillList.length > 0 ? skillList : null, hooks: hooksList.length > 0 ? hooksList : null, mcpServers: mcpServersList.length > 0 ? mcpServersList : null, - }) + }); } else { - setError(`Plugin ${plugin.name} not found in marketplace`) + setError(`Plugin ${plugin.name} not found in marketplace`); } } catch (err) { - setError( - err instanceof Error ? err.message : 'Failed to load components', - ) + setError(err instanceof Error ? err.message : 'Failed to load components'); } finally { - setLoading(false) + setLoading(false); } } - void loadComponents() + void loadComponents(); }, [ plugin.name, plugin.commandsPath, @@ -387,10 +355,10 @@ function PluginComponentsDisplay({ plugin.hooksConfig, plugin.mcpServers, marketplace, - ]) + ]); if (loading) { - return null // Don't show loading state for cleaner UI + return null; // Don't show loading state for cleaner UI } if (error) { @@ -399,22 +367,18 @@ function PluginComponentsDisplay({ Components: Error: {error} - ) + ); } if (!components) { - return null // No components info available + return null; // No components info available } const hasComponents = - components.commands || - components.agents || - components.skills || - components.hooks || - components.mcpServers + components.commands || components.agents || components.skills || components.hooks || components.mcpServers; if (!hasComponents) { - return null // No components defined + return null; // No components defined } return ( @@ -457,8 +421,7 @@ function PluginComponentsDisplay({ ? components.hooks : Array.isArray(components.hooks) ? components.hooks.map(String).join(', ') - : typeof components.hooks === 'object' && - components.hooks !== null + : typeof components.hooks === 'object' && components.hooks !== null ? Object.keys(components.hooks).join(', ') : String(components.hooks)} @@ -470,32 +433,28 @@ function PluginComponentsDisplay({ ? components.mcpServers : Array.isArray(components.mcpServers) ? components.mcpServers.map(String).join(', ') - : typeof components.mcpServers === 'object' && - components.mcpServers !== null + : typeof components.mcpServers === 'object' && components.mcpServers !== null ? Object.keys(components.mcpServers).join(', ') : String(components.mcpServers)} ) : null} - ) + ); } /** * Check if a plugin is from a local source and cannot be remotely updated * @returns Error message if local, null if remote/updatable */ -async function checkIfLocalPlugin( - pluginName: string, - marketplaceName: string, -): Promise { - const marketplace = await getMarketplace(marketplaceName) - const entry = marketplace?.plugins.find(p => p.name === pluginName) +async function checkIfLocalPlugin(pluginName: string, marketplaceName: string): Promise { + const marketplace = await getMarketplace(marketplaceName); + const entry = marketplace?.plugins.find(p => p.name === pluginName); if (entry && typeof entry.source === 'string') { - return `Local plugins cannot be updated remotely. To update, modify the source at: ${entry.source}` + return `Local plugins cannot be updated remotely. To update, modify the source at: ${entry.source}`; } - return null + return null; } /** @@ -504,13 +463,11 @@ async function checkIfLocalPlugin( * Checks policySettings directly rather than installation scope, since managed * settings don't create installation records with scope 'managed'. */ -export function filterManagedDisabledPlugins( - plugins: LoadedPlugin[], -): LoadedPlugin[] { +export function filterManagedDisabledPlugins(plugins: LoadedPlugin[]): LoadedPlugin[] { return plugins.filter(plugin => { - const marketplace = plugin.source.split('@')[1] || 'local' - return !isPluginBlockedByPolicy(`${plugin.name}@${marketplace}`) - }) + const marketplace = plugin.source.split('@')[1] || 'local'; + return !isPluginBlockedByPolicy(`${plugin.name}@${marketplace}`); + }); } export function ManagePlugins({ @@ -523,25 +480,25 @@ export function ManagePlugins({ action, }: Props): React.ReactNode { // App state for MCP access - const mcpClients = useAppState(s => s.mcp.clients) - const mcpTools = useAppState(s => s.mcp.tools) - const pluginErrors = useAppState(s => s.plugins.errors) - const flaggedPlugins = getFlaggedPlugins() + const mcpClients = useAppState(s => s.mcp.clients); + const mcpTools = useAppState(s => s.mcp.tools); + const pluginErrors = useAppState(s => s.plugins.errors); + const flaggedPlugins = getFlaggedPlugins(); // Search state - const [isSearchMode, setIsSearchModeRaw] = useState(false) + const [isSearchMode, setIsSearchModeRaw] = useState(false); const setIsSearchMode = useCallback( (active: boolean) => { - setIsSearchModeRaw(active) - onSearchModeChange?.(active) + setIsSearchModeRaw(active); + onSearchModeChange?.(active); }, [onSearchModeChange], - ) - const isTerminalFocused = useTerminalFocus() - const { columns: terminalWidth } = useTerminalSize() + ); + const isTerminalFocused = useTerminalFocus(); + const { columns: terminalWidth } = useTerminalSize(); // View state - const [viewState, setViewState] = useState('plugin-list') + const [viewState, setViewState] = useState('plugin-list'); const { query: searchQuery, @@ -550,92 +507,70 @@ export function ManagePlugins({ } = useSearchInput({ isActive: viewState === 'plugin-list' && isSearchMode, onExit: () => { - setIsSearchMode(false) + setIsSearchMode(false); }, - }) - const [selectedPlugin, setSelectedPlugin] = useState(null) + }); + const [selectedPlugin, setSelectedPlugin] = useState(null); // Data state - const [marketplaces, setMarketplaces] = useState([]) - const [pluginStates, setPluginStates] = useState([]) - const [loading, setLoading] = useState(true) - const [pendingToggles, setPendingToggles] = useState< - Map - >(new Map()) + const [marketplaces, setMarketplaces] = useState([]); + const [pluginStates, setPluginStates] = useState([]); + const [loading, setLoading] = useState(true); + const [pendingToggles, setPendingToggles] = useState>(new Map()); // Guard to prevent auto-navigation from re-triggering after the user // navigates away (targetPlugin is never cleared by the parent). - const hasAutoNavigated = useRef(false) + const hasAutoNavigated = useRef(false); // Auto-action (enable/disable/uninstall) to fire after auto-navigation lands. // Ref, not state: it's consumed by a one-shot effect that already re-runs on // viewState/selectedPlugin, so a render-triggering state var would be redundant. - const pendingAutoActionRef = useRef< - 'enable' | 'disable' | 'uninstall' | undefined - >(undefined) + const pendingAutoActionRef = useRef<'enable' | 'disable' | 'uninstall' | undefined>(undefined); // MCP toggle hook - const toggleMcpServer = useMcpToggleEnabled() + const toggleMcpServer = useMcpToggleEnabled(); // Handle escape to go back - viewState-dependent navigation const handleBack = React.useCallback(() => { if (viewState === 'plugin-details') { - setViewState('plugin-list') - setSelectedPlugin(null) - setProcessError(null) - } else if ( - typeof viewState === 'object' && - viewState.type === 'failed-plugin-details' - ) { - setViewState('plugin-list') - setProcessError(null) + setViewState('plugin-list'); + setSelectedPlugin(null); + setProcessError(null); + } else if (typeof viewState === 'object' && viewState.type === 'failed-plugin-details') { + setViewState('plugin-list'); + setProcessError(null); } else if (viewState === 'configuring') { - setViewState('plugin-details') - setConfigNeeded(null) + setViewState('plugin-details'); + setConfigNeeded(null); } else if ( typeof viewState === 'object' && - (viewState.type === 'plugin-options' || - viewState.type === 'configuring-options') + (viewState.type === 'plugin-options' || viewState.type === 'configuring-options') ) { // Cancel mid-sequence — plugin is already enabled, just bail to list. // User can configure later via the Configure options menu if they want. - setViewState('plugin-list') - setSelectedPlugin(null) - setResult( - 'Plugin enabled. Configuration skipped — run /reload-plugins to apply.', - ) + setViewState('plugin-list'); + setSelectedPlugin(null); + setResult('Plugin enabled. Configuration skipped — run /reload-plugins to apply.'); if (onManageComplete) { - void onManageComplete() + void onManageComplete(); } - } else if ( - typeof viewState === 'object' && - viewState.type === 'flagged-detail' - ) { - setViewState('plugin-list') - setProcessError(null) - } else if ( - typeof viewState === 'object' && - viewState.type === 'mcp-detail' - ) { - setViewState('plugin-list') - setProcessError(null) - } else if ( - typeof viewState === 'object' && - viewState.type === 'mcp-tools' - ) { - setViewState({ type: 'mcp-detail', client: viewState.client }) - } else if ( - typeof viewState === 'object' && - viewState.type === 'mcp-tool-detail' - ) { - setViewState({ type: 'mcp-tools', client: viewState.client }) + } else if (typeof viewState === 'object' && viewState.type === 'flagged-detail') { + setViewState('plugin-list'); + setProcessError(null); + } else if (typeof viewState === 'object' && viewState.type === 'mcp-detail') { + setViewState('plugin-list'); + setProcessError(null); + } else if (typeof viewState === 'object' && viewState.type === 'mcp-tools') { + setViewState({ type: 'mcp-detail', client: viewState.client }); + } else if (typeof viewState === 'object' && viewState.type === 'mcp-tool-detail') { + setViewState({ type: 'mcp-tools', client: viewState.client }); } else { if (pendingToggles.size > 0) { - setResult('Run /reload-plugins to apply plugin changes.') - return + setResult('Run /reload-plugins to apply plugin changes.'); + return; } - setParentViewState({ type: 'menu' }) + setParentViewState({ type: 'menu' }); } - }, [viewState, setParentViewState, pendingToggles, setResult]) + }, [viewState, setParentViewState, pendingToggles, setResult]); // Escape when not in search mode - go back. // Excludes confirm-project-uninstall (has its own confirm:no handler in @@ -647,68 +582,60 @@ export function ManagePlugins({ isActive: (viewState !== 'plugin-list' || !isSearchMode) && viewState !== 'confirm-project-uninstall' && - !( - typeof viewState === 'object' && - viewState.type === 'confirm-data-cleanup' - ), - }) + !(typeof viewState === 'object' && viewState.type === 'confirm-data-cleanup'), + }); // Helper to get MCP status const getMcpStatus = ( client: MCPServerConnection, ): 'connected' | 'disabled' | 'pending' | 'needs-auth' | 'failed' => { - if (client.type === 'connected') return 'connected' - if (client.type === 'disabled') return 'disabled' - if (client.type === 'pending') return 'pending' - if (client.type === 'needs-auth') return 'needs-auth' - return 'failed' - } + if (client.type === 'connected') return 'connected'; + if (client.type === 'disabled') return 'disabled'; + if (client.type === 'pending') return 'pending'; + if (client.type === 'needs-auth') return 'needs-auth'; + return 'failed'; + }; // Derive unified items from plugins and MCP servers const unifiedItems = useMemo(() => { - const mergedSettings = getSettings_DEPRECATED() + const mergedSettings = getSettings_DEPRECATED(); // Build map of plugin name -> child MCPs // Plugin MCPs have names like "plugin:pluginName:serverName" - const pluginMcpMap = new Map< - string, - Array<{ displayName: string; client: MCPServerConnection }> - >() + const pluginMcpMap = new Map>(); for (const client of mcpClients) { if (client.name.startsWith('plugin:')) { - const parts = client.name.split(':') + const parts = client.name.split(':'); if (parts.length >= 3) { - const pluginName = parts[1]! - const serverName = parts.slice(2).join(':') - const existing = pluginMcpMap.get(pluginName) || [] - existing.push({ displayName: serverName, client }) - pluginMcpMap.set(pluginName, existing) + const pluginName = parts[1]!; + const serverName = parts.slice(2).join(':'); + const existing = pluginMcpMap.get(pluginName) || []; + existing.push({ displayName: serverName, client }); + pluginMcpMap.set(pluginName, existing); } } } // Build plugin items (unsorted for now) type PluginWithChildren = { - item: UnifiedInstalledItem & { type: 'plugin' } - originalScope: 'user' | 'project' | 'local' | 'managed' | 'builtin' - childMcps: Array<{ displayName: string; client: MCPServerConnection }> - } - const pluginsWithChildren: PluginWithChildren[] = [] + item: UnifiedInstalledItem & { type: 'plugin' }; + originalScope: 'user' | 'project' | 'local' | 'managed' | 'builtin'; + childMcps: Array<{ displayName: string; client: MCPServerConnection }>; + }; + const pluginsWithChildren: PluginWithChildren[] = []; for (const state of pluginStates) { - const pluginId = `${state.plugin.name}@${state.marketplace}` - const isEnabled = mergedSettings?.enabledPlugins?.[pluginId] !== false + const pluginId = `${state.plugin.name}@${state.marketplace}`; + const isEnabled = mergedSettings?.enabledPlugins?.[pluginId] !== false; const errors = pluginErrors.filter( e => ('plugin' in e && e.plugin === state.plugin.name) || e.source === pluginId || e.source.startsWith(`${state.plugin.name}@`), - ) + ); // Built-in plugins use 'builtin' scope; others look up from V2 data. - const originalScope = state.plugin.isBuiltin - ? 'builtin' - : state.scope || 'user' + const originalScope = state.plugin.isBuiltin ? 'builtin' : state.scope || 'user'; pluginsWithChildren.push({ item: { @@ -728,44 +655,37 @@ export function ManagePlugins({ }, originalScope, childMcps: pluginMcpMap.get(state.plugin.name) || [], - }) + }); } // Find orphan errors (errors for plugins that failed to load entirely) - const matchedPluginIds = new Set( - pluginsWithChildren.map(({ item }) => item.id), - ) - const matchedPluginNames = new Set( - pluginsWithChildren.map(({ item }) => item.name), - ) - const orphanErrorsBySource = new Map() + const matchedPluginIds = new Set(pluginsWithChildren.map(({ item }) => item.id)); + const matchedPluginNames = new Set(pluginsWithChildren.map(({ item }) => item.name)); + const orphanErrorsBySource = new Map(); for (const error of pluginErrors) { if ( matchedPluginIds.has(error.source) || - ('plugin' in error && - typeof error.plugin === 'string' && - matchedPluginNames.has(error.plugin)) + ('plugin' in error && typeof error.plugin === 'string' && matchedPluginNames.has(error.plugin)) ) { - continue + continue; } - const existing = orphanErrorsBySource.get(error.source) || [] - existing.push(error) - orphanErrorsBySource.set(error.source, existing) + const existing = orphanErrorsBySource.get(error.source) || []; + existing.push(error); + orphanErrorsBySource.set(error.source, existing); } - const pluginScopes = getPluginEditableScopes() - const failedPluginItems: UnifiedInstalledItem[] = [] + const pluginScopes = getPluginEditableScopes(); + const failedPluginItems: UnifiedInstalledItem[] = []; for (const [pluginId, errors] of orphanErrorsBySource) { // Skip plugins that are already shown in the flagged section - if (pluginId in flaggedPlugins) continue - const parsed = parsePluginIdentifier(pluginId) - const pluginName = parsed.name || pluginId - const marketplace = parsed.marketplace || 'unknown' - const rawScope = pluginScopes.get(pluginId) + if (pluginId in flaggedPlugins) continue; + const parsed = parsePluginIdentifier(pluginId); + const pluginName = parsed.name || pluginId; + const marketplace = parsed.marketplace || 'unknown'; + const rawScope = pluginScopes.get(pluginId); // 'flag' is session-only (from --plugin-dir / flagSettings) and undefined // means the plugin isn't in any settings source. Default both to 'user' // since UnifiedInstalledItem doesn't have a 'flag' scope variant. - const scope = - rawScope === 'flag' || rawScope === undefined ? 'user' : rawScope + const scope = rawScope === 'flag' || rawScope === undefined ? 'user' : rawScope; failedPluginItems.push({ type: 'failed-plugin', id: pluginId, @@ -774,14 +694,14 @@ export function ManagePlugins({ scope, errorCount: errors.length, errors, - }) + }); } // Build standalone MCP items - const standaloneMcps: UnifiedInstalledItem[] = [] + const standaloneMcps: UnifiedInstalledItem[] = []; for (const client of mcpClients) { - if (client.name === 'ide') continue - if (client.name.startsWith('plugin:')) continue + if (client.name === 'ide') continue; + if (client.name.startsWith('plugin:')) continue; standaloneMcps.push({ type: 'mcp', @@ -791,7 +711,7 @@ export function ManagePlugins({ scope: client.config.scope, status: getMcpStatus(client), client, - }) + }); } // Define scope order for display @@ -804,29 +724,28 @@ export function ManagePlugins({ managed: 4, dynamic: 5, builtin: 6, - } + }; // Build final list by merging plugins (with their child MCPs) and standalone MCPs // Group by scope to avoid duplicate scope headers - const unified: UnifiedInstalledItem[] = [] + const unified: UnifiedInstalledItem[] = []; // Create a map of scope -> items for proper merging - const itemsByScope = new Map() + const itemsByScope = new Map(); // Add plugins with their child MCPs for (const { item, originalScope, childMcps } of pluginsWithChildren) { - const scope = item.scope + const scope = item.scope; if (!itemsByScope.has(scope)) { - itemsByScope.set(scope, []) + itemsByScope.set(scope, []); } - itemsByScope.get(scope)!.push(item) + itemsByScope.get(scope)!.push(item); // Add child MCPs right after the plugin, indented (use original scope, not 'flagged'). // Built-in plugins map to 'user' for display since MCP ConfigScope doesn't include 'builtin'. for (const { displayName, client } of childMcps) { - const displayScope = - originalScope === 'builtin' ? 'user' : originalScope + const displayScope = originalScope === 'builtin' ? 'user' : originalScope; if (!itemsByScope.has(displayScope)) { - itemsByScope.set(displayScope, []) + itemsByScope.set(displayScope, []); } itemsByScope.get(displayScope)!.push({ type: 'mcp', @@ -837,36 +756,36 @@ export function ManagePlugins({ status: getMcpStatus(client), client, indented: true, - }) + }); } } // Add standalone MCPs to their respective scope groups for (const mcp of standaloneMcps) { - const scope = mcp.scope + const scope = mcp.scope; if (!itemsByScope.has(scope)) { - itemsByScope.set(scope, []) + itemsByScope.set(scope, []); } - itemsByScope.get(scope)!.push(mcp) + itemsByScope.get(scope)!.push(mcp); } // Add failed plugins to their respective scope groups for (const failedPlugin of failedPluginItems) { - const scope = failedPlugin.scope + const scope = failedPlugin.scope; if (!itemsByScope.has(scope)) { - itemsByScope.set(scope, []) + itemsByScope.set(scope, []); } - itemsByScope.get(scope)!.push(failedPlugin) + itemsByScope.get(scope)!.push(failedPlugin); } // Add flagged (delisted) plugins from user settings. // Reason/text are looked up from the cached security messages file. for (const [pluginId, entry] of Object.entries(flaggedPlugins)) { - const parsed = parsePluginIdentifier(pluginId) - const pluginName = parsed.name || pluginId - const marketplace = parsed.marketplace || 'unknown' + const parsed = parsePluginIdentifier(pluginId); + const pluginName = parsed.name || pluginId; + const marketplace = parsed.marketplace || 'unknown'; if (!itemsByScope.has('flagged')) { - itemsByScope.set('flagged', []) + itemsByScope.set('flagged', []); } itemsByScope.get('flagged')!.push({ type: 'flagged-plugin', @@ -877,232 +796,205 @@ export function ManagePlugins({ reason: 'delisted', text: 'Removed from marketplace', flaggedAt: entry.flaggedAt, - }) + }); } // Sort scopes and build final list - const sortedScopes = [...itemsByScope.keys()].sort( - (a, b) => (scopeOrder[a] ?? 99) - (scopeOrder[b] ?? 99), - ) + const sortedScopes = [...itemsByScope.keys()].sort((a, b) => (scopeOrder[a] ?? 99) - (scopeOrder[b] ?? 99)); for (const scope of sortedScopes) { - const items = itemsByScope.get(scope)! + const items = itemsByScope.get(scope)!; // Separate items into plugin groups (with their child MCPs) and standalone MCPs // This preserves parent-child relationships that would be broken by naive sorting - const pluginGroups: UnifiedInstalledItem[][] = [] - const standaloneMcpsInScope: UnifiedInstalledItem[] = [] + const pluginGroups: UnifiedInstalledItem[][] = []; + const standaloneMcpsInScope: UnifiedInstalledItem[] = []; - let i = 0 + let i = 0; while (i < items.length) { - const item = items[i]! - if ( - item.type === 'plugin' || - item.type === 'failed-plugin' || - item.type === 'flagged-plugin' - ) { + const item = items[i]!; + if (item.type === 'plugin' || item.type === 'failed-plugin' || item.type === 'flagged-plugin') { // Collect the plugin and its child MCPs as a group - const group: UnifiedInstalledItem[] = [item] - i++ + const group: UnifiedInstalledItem[] = [item]; + i++; // Look ahead for indented child MCPs - let nextItem = items[i] + let nextItem = items[i]; while (nextItem?.type === 'mcp' && nextItem.indented) { - group.push(nextItem) - i++ - nextItem = items[i] + group.push(nextItem); + i++; + nextItem = items[i]; } - pluginGroups.push(group) + pluginGroups.push(group); } else if (item.type === 'mcp' && !item.indented) { // Standalone MCP (not a child of a plugin) - standaloneMcpsInScope.push(item) - i++ + standaloneMcpsInScope.push(item); + i++; } else { // Skip orphaned indented MCPs (shouldn't happen) - i++ + i++; } } // Sort plugin groups by the plugin name (first item in each group) - pluginGroups.sort((a, b) => a[0]!.name.localeCompare(b[0]!.name)) + pluginGroups.sort((a, b) => a[0]!.name.localeCompare(b[0]!.name)); // Sort standalone MCPs by name - standaloneMcpsInScope.sort((a, b) => a.name.localeCompare(b.name)) + standaloneMcpsInScope.sort((a, b) => a.name.localeCompare(b.name)); // Build final list: plugins (with their children) first, then standalone MCPs for (const group of pluginGroups) { - unified.push(...group) + unified.push(...group); } - unified.push(...standaloneMcpsInScope) + unified.push(...standaloneMcpsInScope); } - return unified - }, [pluginStates, mcpClients, pluginErrors, pendingToggles, flaggedPlugins]) + return unified; + }, [pluginStates, mcpClients, pluginErrors, pendingToggles, flaggedPlugins]); // Mark flagged plugins as seen when the Installed view renders them. // After 48 hours from seenAt, they auto-clear on next load. const flaggedIds = useMemo( - () => - unifiedItems - .filter(item => item.type === 'flagged-plugin') - .map(item => item.id), + () => unifiedItems.filter(item => item.type === 'flagged-plugin').map(item => item.id), [unifiedItems], - ) + ); useEffect(() => { if (flaggedIds.length > 0) { - void markFlaggedPluginsSeen(flaggedIds) + void markFlaggedPluginsSeen(flaggedIds); } - }, [flaggedIds]) + }, [flaggedIds]); // Filter items based on search query (matches name or description) const filteredItems = useMemo(() => { - if (!searchQuery) return unifiedItems - const lowerQuery = searchQuery.toLowerCase() + if (!searchQuery) return unifiedItems; + const lowerQuery = searchQuery.toLowerCase(); return unifiedItems.filter( item => item.name.toLowerCase().includes(lowerQuery) || - ('description' in item && - item.description?.toLowerCase().includes(lowerQuery)), - ) - }, [unifiedItems, searchQuery]) + ('description' in item && item.description?.toLowerCase().includes(lowerQuery)), + ); + }, [unifiedItems, searchQuery]); // Selection state - const [selectedIndex, setSelectedIndex] = useState(0) + const [selectedIndex, setSelectedIndex] = useState(0); // Pagination for unified list (continuous scrolling) const pagination = usePagination({ totalItems: filteredItems.length, selectedIndex, maxVisible: 8, - }) + }); // Details view state - const [detailsMenuIndex, setDetailsMenuIndex] = useState(0) - const [isProcessing, setIsProcessing] = useState(false) - const [processError, setProcessError] = useState(null) + const [detailsMenuIndex, setDetailsMenuIndex] = useState(0); + const [isProcessing, setIsProcessing] = useState(false); + const [processError, setProcessError] = useState(null); // Configuration state - const [configNeeded, setConfigNeeded] = - useState(null) - const [_isLoadingConfig, setIsLoadingConfig] = useState(false) - const [selectedPluginHasMcpb, setSelectedPluginHasMcpb] = useState(false) + const [configNeeded, setConfigNeeded] = useState(null); + const [_isLoadingConfig, setIsLoadingConfig] = useState(false); + const [selectedPluginHasMcpb, setSelectedPluginHasMcpb] = useState(false); // Detect if selected plugin has MCPB // Reads raw marketplace.json to work with old cached marketplaces useEffect(() => { if (!selectedPlugin) { - setSelectedPluginHasMcpb(false) - return + setSelectedPluginHasMcpb(false); + return; } async function detectMcpb() { // Check plugin manifest first - const mcpServersSpec = selectedPlugin!.plugin.manifest.mcpServers - let hasMcpb = false + const mcpServersSpec = selectedPlugin!.plugin.manifest.mcpServers; + let hasMcpb = false; if (mcpServersSpec) { hasMcpb = - (typeof mcpServersSpec === 'string' && - isMcpbSource(mcpServersSpec)) || - (Array.isArray(mcpServersSpec) && - mcpServersSpec.some(s => typeof s === 'string' && isMcpbSource(s))) + (typeof mcpServersSpec === 'string' && isMcpbSource(mcpServersSpec)) || + (Array.isArray(mcpServersSpec) && mcpServersSpec.some(s => typeof s === 'string' && isMcpbSource(s))); } // If not in manifest, read raw marketplace.json directly (bypassing schema validation) // This works even with old cached marketplaces from before MCPB support if (!hasMcpb) { try { - const marketplaceDir = path.join(selectedPlugin!.plugin.path, '..') - const marketplaceJsonPath = path.join( - marketplaceDir, - '.claude-plugin', - 'marketplace.json', - ) + const marketplaceDir = path.join(selectedPlugin!.plugin.path, '..'); + const marketplaceJsonPath = path.join(marketplaceDir, '.claude-plugin', 'marketplace.json'); - const content = await fs.readFile(marketplaceJsonPath, 'utf-8') - const marketplace = jsonParse(content) + const content = await fs.readFile(marketplaceJsonPath, 'utf-8'); + const marketplace = jsonParse(content); - const entry = marketplace.plugins?.find( - (p: { name: string }) => p.name === selectedPlugin!.plugin.name, - ) + const entry = marketplace.plugins?.find((p: { name: string }) => p.name === selectedPlugin!.plugin.name); if (entry?.mcpServers) { - const spec = entry.mcpServers + const spec = entry.mcpServers; hasMcpb = (typeof spec === 'string' && isMcpbSource(spec)) || - (Array.isArray(spec) && - spec.some( - (s: unknown) => typeof s === 'string' && isMcpbSource(s), - )) + (Array.isArray(spec) && spec.some((s: unknown) => typeof s === 'string' && isMcpbSource(s))); } } catch (err) { - logForDebugging(`Failed to read raw marketplace.json: ${err}`) + logForDebugging(`Failed to read raw marketplace.json: ${err}`); } } - setSelectedPluginHasMcpb(hasMcpb) + setSelectedPluginHasMcpb(hasMcpb); } - void detectMcpb() - }, [selectedPlugin]) + void detectMcpb(); + }, [selectedPlugin]); // Load installed plugins grouped by marketplace useEffect(() => { async function loadInstalledPlugins() { - setLoading(true) + setLoading(true); try { - const { enabled, disabled } = await loadAllPlugins() - const mergedSettings = getSettings_DEPRECATED() // Use merged settings to respect all layers + const { enabled, disabled } = await loadAllPlugins(); + const mergedSettings = getSettings_DEPRECATED(); // Use merged settings to respect all layers - const allPlugins = filterManagedDisabledPlugins([ - ...enabled, - ...disabled, - ]) + const allPlugins = filterManagedDisabledPlugins([...enabled, ...disabled]); // Group plugins by marketplace - const pluginsByMarketplace: Record = {} + const pluginsByMarketplace: Record = {}; for (const plugin of allPlugins) { - const marketplace = plugin.source.split('@')[1] || 'local' + const marketplace = plugin.source.split('@')[1] || 'local'; if (!pluginsByMarketplace[marketplace]) { - pluginsByMarketplace[marketplace] = [] + pluginsByMarketplace[marketplace] = []; } - pluginsByMarketplace[marketplace]!.push(plugin) + pluginsByMarketplace[marketplace]!.push(plugin); } // Create marketplace info array with enabled/disabled counts - const marketplaceInfos: MarketplaceInfo[] = [] + const marketplaceInfos: MarketplaceInfo[] = []; for (const [name, plugins] of Object.entries(pluginsByMarketplace)) { const enabledCount = count(plugins, p => { - const pluginId = `${p.name}@${name}` - return mergedSettings?.enabledPlugins?.[pluginId] !== false - }) - const disabledCount = plugins.length - enabledCount + const pluginId = `${p.name}@${name}`; + return mergedSettings?.enabledPlugins?.[pluginId] !== false; + }); + const disabledCount = plugins.length - enabledCount; marketplaceInfos.push({ name, installedPlugins: plugins, enabledCount, disabledCount, - }) + }); } // Sort marketplaces: claude-plugin-directory first, then alphabetically marketplaceInfos.sort((a, b) => { - if (a.name === 'claude-plugin-directory') return -1 - if (b.name === 'claude-plugin-directory') return 1 - return a.name.localeCompare(b.name) - }) + if (a.name === 'claude-plugin-directory') return -1; + if (b.name === 'claude-plugin-directory') return 1; + return a.name.localeCompare(b.name); + }); - setMarketplaces(marketplaceInfos) + setMarketplaces(marketplaceInfos); // Build flat list of all plugin states - const allStates: PluginState[] = [] + const allStates: PluginState[] = []; for (const marketplace of marketplaceInfos) { for (const plugin of marketplace.installedPlugins) { - const pluginId = `${plugin.name}@${marketplace.name}` + const pluginId = `${plugin.name}@${marketplace.name}`; // Built-in plugins don't have V2 install entries — skip the lookup. - const scope = plugin.isBuiltin - ? 'builtin' - : getPluginInstallationFromV2(pluginId).scope + const scope = plugin.isBuiltin ? 'builtin' : getPluginInstallationFromV2(pluginId).scope; allStates.push({ plugin, @@ -1110,43 +1002,40 @@ export function ManagePlugins({ scope, pendingEnable: undefined, pendingUpdate: false, - }) + }); } } - setPluginStates(allStates) - setSelectedIndex(0) + setPluginStates(allStates); + setSelectedIndex(0); } finally { - setLoading(false) + setLoading(false); } } - void loadInstalledPlugins() - }, []) + void loadInstalledPlugins(); + }, []); // Auto-navigate to target plugin if specified (once only) useEffect(() => { - if (hasAutoNavigated.current) return + if (hasAutoNavigated.current) return; if (targetPlugin && marketplaces.length > 0 && !loading) { // targetPlugin may be `name` or `name@marketplace` (parseArgs passes the // raw arg through). Parse it so p.name matching works either way. - const { name: targetName, marketplace: targetMktFromId } = - parsePluginIdentifier(targetPlugin) - const effectiveTargetMarketplace = targetMarketplace ?? targetMktFromId + const { name: targetName, marketplace: targetMktFromId } = parsePluginIdentifier(targetPlugin); + const effectiveTargetMarketplace = targetMarketplace ?? targetMktFromId; // Use targetMarketplace if provided, otherwise search all const marketplacesToSearch = effectiveTargetMarketplace ? marketplaces.filter(m => m.name === effectiveTargetMarketplace) - : marketplaces + : marketplaces; // First check successfully loaded plugins for (const marketplace of marketplacesToSearch) { - const plugin = marketplace.installedPlugins.find( - p => p.name === targetName, - ) + const plugin = marketplace.installedPlugins.find(p => p.name === targetName); if (plugin) { // Get scope from V2 data for proper operation handling - const pluginId = `${plugin.name}@${marketplace.name}` - const { scope } = getPluginInstallationFromV2(pluginId) + const pluginId = `${plugin.name}@${marketplace.name}`; + const { scope } = getPluginInstallationFromV2(pluginId); const pluginState: PluginState = { plugin, @@ -1154,19 +1043,17 @@ export function ManagePlugins({ scope, pendingEnable: undefined, pendingUpdate: false, - } - setSelectedPlugin(pluginState) - setViewState('plugin-details') - pendingAutoActionRef.current = action - hasAutoNavigated.current = true - return + }; + setSelectedPlugin(pluginState); + setViewState('plugin-details'); + pendingAutoActionRef.current = action; + hasAutoNavigated.current = true; + return; } } // Fall back to failed plugins (those with errors but not loaded) - const failedItem = unifiedItems.find( - item => item.type === 'failed-plugin' && item.name === targetName, - ) + const failedItem = unifiedItems.find(item => item.type === 'failed-plugin' && item.name === targetName); if (failedItem && failedItem.type === 'failed-plugin') { setViewState({ type: 'failed-plugin-details', @@ -1177,8 +1064,8 @@ export function ManagePlugins({ errors: failedItem.errors, scope: failedItem.scope, }, - }) - hasAutoNavigated.current = true + }); + hasAutoNavigated.current = true; } // No match in loaded OR failed plugins — close the dialog with a @@ -1186,53 +1073,37 @@ export function ManagePlugins({ // this when an action was requested (e.g. /plugin uninstall X); // plain navigation (/plugin manage) should still just show the list. if (!hasAutoNavigated.current && action) { - hasAutoNavigated.current = true - setResult(`Plugin "${targetPlugin}" is not installed in this project`) + hasAutoNavigated.current = true; + setResult(`Plugin "${targetPlugin}" is not installed in this project`); } } - }, [ - targetPlugin, - targetMarketplace, - marketplaces, - loading, - unifiedItems, - action, - setResult, - ]) + }, [targetPlugin, targetMarketplace, marketplaces, loading, unifiedItems, action, setResult]); // Handle single plugin operations from details view - const handleSingleOperation = async ( - operation: 'enable' | 'disable' | 'update' | 'uninstall', - ) => { - if (!selectedPlugin) return + const handleSingleOperation = async (operation: 'enable' | 'disable' | 'update' | 'uninstall') => { + if (!selectedPlugin) return; - const pluginScope = selectedPlugin.scope || 'user' - const isBuiltin = pluginScope === 'builtin' + const pluginScope = selectedPlugin.scope || 'user'; + const isBuiltin = pluginScope === 'builtin'; // Built-in plugins can only be enabled/disabled, not updated/uninstalled. if (isBuiltin && (operation === 'update' || operation === 'uninstall')) { - setProcessError('Built-in plugins cannot be updated or uninstalled.') - return + setProcessError('Built-in plugins cannot be updated or uninstalled.'); + return; } // Managed scope plugins can only be updated, not enabled/disabled/uninstalled - if ( - !isBuiltin && - !isInstallableScope(pluginScope) && - operation !== 'update' - ) { - setProcessError( - 'This plugin is managed by your organization. Contact your admin to disable it.', - ) - return + if (!isBuiltin && !isInstallableScope(pluginScope) && operation !== 'update') { + setProcessError('This plugin is managed by your organization. Contact your admin to disable it.'); + return; } - setIsProcessing(true) - setProcessError(null) + setIsProcessing(true); + setProcessError(null); try { - const pluginId = `${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}` - let reverseDependents: string[] | undefined + const pluginId = `${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}`; + let reverseDependents: string[] | undefined; // enable/disable omit scope — pluginScope is the install scope from // installed_plugins.json (where files are cached), which can diverge @@ -1240,23 +1111,23 @@ export function ManagePlugins({ // the cross-scope guard. Auto-detect finds the right scope. #38084 switch (operation) { case 'enable': { - const enableResult = await enablePluginOp(pluginId) + const enableResult = await enablePluginOp(pluginId); if (!enableResult.success) { - throw new Error(enableResult.message) + throw new Error(enableResult.message); } - break + break; } case 'disable': { - const disableResult = await disablePluginOp(pluginId) + const disableResult = await disablePluginOp(pluginId); if (!disableResult.success) { - throw new Error(disableResult.message) + throw new Error(disableResult.message); } - reverseDependents = disableResult.reverseDependents - break + reverseDependents = disableResult.reverseDependents; + break; } case 'uninstall': { - if (isBuiltin) break // guarded above; narrows pluginScope - if (!isInstallableScope(pluginScope)) break + if (isBuiltin) break; // guarded above; narrows pluginScope + if (!isInstallableScope(pluginScope)) break; // If the plugin is enabled in .claude/settings.json (shared with the // team), divert to a confirmation dialog that offers to disable in // settings.local.json instead. Check the settings file directly — @@ -1264,71 +1135,66 @@ export function ManagePlugins({ // the plugin is ALSO project-enabled, and uninstalling the user-scope // install would leave the project enablement active. if (isPluginEnabledAtProjectScope(pluginId)) { - setIsProcessing(false) - setViewState('confirm-project-uninstall') - return + setIsProcessing(false); + setViewState('confirm-project-uninstall'); + return; } // If the plugin has persistent data (${CLAUDE_PLUGIN_DATA}) AND this // is the last scope, prompt before deleting it. For multi-scope // installs, the op's isLastScope check won't delete regardless of // the user's y/n — showing the dialog would mislead ("y" → nothing // happens). Length check mirrors pluginOperations.ts:513. - const installs = loadInstalledPluginsV2().plugins[pluginId] - const isLastScope = !installs || installs.length <= 1 - const dataSize = isLastScope - ? await getPluginDataDirSize(pluginId) - : null + const installs = loadInstalledPluginsV2().plugins[pluginId]; + const isLastScope = !installs || installs.length <= 1; + const dataSize = isLastScope ? await getPluginDataDirSize(pluginId) : null; if (dataSize) { - setIsProcessing(false) - setViewState({ type: 'confirm-data-cleanup', size: dataSize }) - return + setIsProcessing(false); + setViewState({ type: 'confirm-data-cleanup', size: dataSize }); + return; } - const result = await uninstallPluginOp(pluginId, pluginScope) + const result = await uninstallPluginOp(pluginId, pluginScope); if (!result.success) { - throw new Error(result.message) + throw new Error(result.message); } - reverseDependents = result.reverseDependents - break + reverseDependents = result.reverseDependents; + break; } case 'update': { - if (isBuiltin) break // guarded above; narrows pluginScope - const result = await updatePluginOp(pluginId, pluginScope) + if (isBuiltin) break; // guarded above; narrows pluginScope + const result = await updatePluginOp(pluginId, pluginScope); if (!result.success) { - throw new Error(result.message) + throw new Error(result.message); } // If already up to date, show message and exit if (result.alreadyUpToDate) { - setResult( - `${selectedPlugin.plugin.name} is already at the latest version (${result.newVersion}).`, - ) + setResult(`${selectedPlugin.plugin.name} is already at the latest version (${result.newVersion}).`); if (onManageComplete) { - await onManageComplete() + await onManageComplete(); } - setParentViewState({ type: 'menu' }) - return + setParentViewState({ type: 'menu' }); + return; } // Success - will show standard message below - break + break; } } // Operations (enable, disable, uninstall, update) now use centralized functions // that handle their own settings updates, so we only need to clear caches here - clearAllCaches() + clearAllCaches(); // Prompt for manifest.userConfig + channel userConfig if the plugin ends // up enabled. Re-read settings rather than keying on `operation === // 'enable'`: install enables on install, so the menu shows "Disable" // first. PluginOptionsFlow itself checks getUnconfiguredOptions — if // nothing needs filling, it calls onDone('skipped') immediately. - const pluginIdNow = `${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}` - const settingsAfter = getSettings_DEPRECATED() - const enabledAfter = - settingsAfter?.enabledPlugins?.[pluginIdNow] !== false + const pluginIdNow = `${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}`; + const settingsAfter = getSettings_DEPRECATED(); + const enabledAfter = settingsAfter?.enabledPlugins?.[pluginIdNow] !== false; if (enabledAfter) { - setIsProcessing(false) - setViewState({ type: 'plugin-options' }) - return + setIsProcessing(false); + setViewState({ type: 'plugin-options' }); + return; } const operationName = @@ -1338,123 +1204,106 @@ export function ManagePlugins({ ? 'Disabled' : operation === 'update' ? 'Updated' - : 'Uninstalled' + : 'Uninstalled'; // Single-line warning — notification timeout is ~8s, multi-line would scroll off. // The persistent record is in the Errors tab (dependency-unsatisfied after reload). const depWarn = - reverseDependents && reverseDependents.length > 0 - ? ` · required by ${reverseDependents.join(', ')}` - : '' - const message = `✓ ${operationName} ${selectedPlugin.plugin.name}${depWarn}. Run /reload-plugins to apply.` - setResult(message) + reverseDependents && reverseDependents.length > 0 ? ` · required by ${reverseDependents.join(', ')}` : ''; + const message = `✓ ${operationName} ${selectedPlugin.plugin.name}${depWarn}. Run /reload-plugins to apply.`; + setResult(message); if (onManageComplete) { - await onManageComplete() + await onManageComplete(); } - setParentViewState({ type: 'menu' }) + setParentViewState({ type: 'menu' }); } catch (error) { - setIsProcessing(false) - const errorMessage = - error instanceof Error ? error.message : String(error) - setProcessError(`Failed to ${operation}: ${errorMessage}`) - logError(toError(error)) + setIsProcessing(false); + const errorMessage = error instanceof Error ? error.message : String(error); + setProcessError(`Failed to ${operation}: ${errorMessage}`); + logError(toError(error)); } - } + }; // Latest-ref: lets the auto-action effect call the current closure without // adding handleSingleOperation (recreated every render) to its deps. - const handleSingleOperationRef = useRef(handleSingleOperation) - handleSingleOperationRef.current = handleSingleOperation + const handleSingleOperationRef = useRef(handleSingleOperation); + handleSingleOperationRef.current = handleSingleOperation; // Auto-execute the action prop (/plugin uninstall X, /plugin enable X, etc.) // once auto-navigation has landed on plugin-details. useEffect(() => { - if ( - viewState === 'plugin-details' && - selectedPlugin && - pendingAutoActionRef.current - ) { - const pending = pendingAutoActionRef.current - pendingAutoActionRef.current = undefined - void handleSingleOperationRef.current(pending) + if (viewState === 'plugin-details' && selectedPlugin && pendingAutoActionRef.current) { + const pending = pendingAutoActionRef.current; + pendingAutoActionRef.current = undefined; + void handleSingleOperationRef.current(pending); } - }, [viewState, selectedPlugin]) + }, [viewState, selectedPlugin]); // Handle toggle enable/disable const handleToggle = React.useCallback(() => { - if (selectedIndex >= filteredItems.length) return - const item = filteredItems[selectedIndex] - if (item?.type === 'flagged-plugin') return + if (selectedIndex >= filteredItems.length) return; + const item = filteredItems[selectedIndex]; + if (item?.type === 'flagged-plugin') return; if (item?.type === 'plugin') { - const pluginId = `${item.plugin.name}@${item.marketplace}` - const mergedSettings = getSettings_DEPRECATED() - const currentPending = pendingToggles.get(pluginId) - const isEnabled = mergedSettings?.enabledPlugins?.[pluginId] !== false - const pluginScope = item.scope - const isBuiltin = pluginScope === 'builtin' + const pluginId = `${item.plugin.name}@${item.marketplace}`; + const mergedSettings = getSettings_DEPRECATED(); + const currentPending = pendingToggles.get(pluginId); + const isEnabled = mergedSettings?.enabledPlugins?.[pluginId] !== false; + const pluginScope = item.scope; + const isBuiltin = pluginScope === 'builtin'; if (isBuiltin || isInstallableScope(pluginScope)) { - const newPending = new Map(pendingToggles) + const newPending = new Map(pendingToggles); // Omit scope — see handleSingleOperation's enable/disable comment. if (currentPending) { // Cancel: reverse the operation back to the original state - newPending.delete(pluginId) + newPending.delete(pluginId); void (async () => { try { if (currentPending === 'will-disable') { - await enablePluginOp(pluginId) + await enablePluginOp(pluginId); } else { - await disablePluginOp(pluginId) + await disablePluginOp(pluginId); } - clearAllCaches() + clearAllCaches(); } catch (err) { - logError(err) + logError(err); } - })() + })(); } else { - newPending.set(pluginId, isEnabled ? 'will-disable' : 'will-enable') + newPending.set(pluginId, isEnabled ? 'will-disable' : 'will-enable'); void (async () => { try { if (isEnabled) { - await disablePluginOp(pluginId) + await disablePluginOp(pluginId); } else { - await enablePluginOp(pluginId) + await enablePluginOp(pluginId); } - clearAllCaches() + clearAllCaches(); } catch (err) { - logError(err) + logError(err); } - })() + })(); } - setPendingToggles(newPending) + setPendingToggles(newPending); } } else if (item?.type === 'mcp') { - void toggleMcpServer(item.client.name) + void toggleMcpServer(item.client.name); } - }, [ - selectedIndex, - filteredItems, - pendingToggles, - pluginStates, - toggleMcpServer, - ]) + }, [selectedIndex, filteredItems, pendingToggles, pluginStates, toggleMcpServer]); // Handle accept (Enter) in plugin-list const handleAccept = React.useCallback(() => { - if (selectedIndex >= filteredItems.length) return - const item = filteredItems[selectedIndex] + if (selectedIndex >= filteredItems.length) return; + const item = filteredItems[selectedIndex]; if (item?.type === 'plugin') { - const state = pluginStates.find( - s => - s.plugin.name === item.plugin.name && - s.marketplace === item.marketplace, - ) + const state = pluginStates.find(s => s.plugin.name === item.plugin.name && s.marketplace === item.marketplace); if (state) { - setSelectedPlugin(state) - setViewState('plugin-details') - setDetailsMenuIndex(0) - setProcessError(null) + setSelectedPlugin(state); + setViewState('plugin-details'); + setDetailsMenuIndex(0); + setProcessError(null); } } else if (item?.type === 'flagged-plugin') { setViewState({ @@ -1467,8 +1316,8 @@ export function ManagePlugins({ text: item.text, flaggedAt: item.flaggedAt, }, - }) - setProcessError(null) + }); + setProcessError(null); } else if (item?.type === 'failed-plugin') { setViewState({ type: 'failed-plugin-details', @@ -1479,28 +1328,28 @@ export function ManagePlugins({ errors: item.errors, scope: item.scope, }, - }) - setDetailsMenuIndex(0) - setProcessError(null) + }); + setDetailsMenuIndex(0); + setProcessError(null); } else if (item?.type === 'mcp') { - setViewState({ type: 'mcp-detail', client: item.client }) - setProcessError(null) + setViewState({ type: 'mcp-detail', client: item.client }); + setProcessError(null); } - }, [selectedIndex, filteredItems, pluginStates]) + }, [selectedIndex, filteredItems, pluginStates]); // Plugin-list navigation (non-search mode) useKeybindings( { 'select:previous': () => { if (selectedIndex === 0) { - setIsSearchMode(true) + setIsSearchMode(true); } else { - pagination.handleSelectionChange(selectedIndex - 1, setSelectedIndex) + pagination.handleSelectionChange(selectedIndex - 1, setSelectedIndex); } }, 'select:next': () => { if (selectedIndex < filteredItems.length - 1) { - pagination.handleSelectionChange(selectedIndex + 1, setSelectedIndex) + pagination.handleSelectionChange(selectedIndex + 1, setSelectedIndex); } }, 'select:accept': handleAccept, @@ -1509,7 +1358,7 @@ export function ManagePlugins({ context: 'Select', isActive: viewState === 'plugin-list' && !isSearchMode, }, - ) + ); useKeybindings( { 'plugin:toggle': handleToggle }, @@ -1517,114 +1366,97 @@ export function ManagePlugins({ context: 'Plugin', isActive: viewState === 'plugin-list' && !isSearchMode, }, - ) + ); // Handle dismiss action in flagged-detail view const handleFlaggedDismiss = React.useCallback(() => { - if (typeof viewState !== 'object' || viewState.type !== 'flagged-detail') - return - void removeFlaggedPlugin(viewState.plugin.id) - setViewState('plugin-list') - }, [viewState]) + if (typeof viewState !== 'object' || viewState.type !== 'flagged-detail') return; + void removeFlaggedPlugin(viewState.plugin.id); + setViewState('plugin-list'); + }, [viewState]); useKeybindings( { 'select:accept': handleFlaggedDismiss }, { context: 'Select', - isActive: - typeof viewState === 'object' && viewState.type === 'flagged-detail', + isActive: typeof viewState === 'object' && viewState.type === 'flagged-detail', }, - ) + ); // Build details menu items (needed for navigation) const detailsMenuItems = React.useMemo(() => { - if (viewState !== 'plugin-details' || !selectedPlugin) return [] + if (viewState !== 'plugin-details' || !selectedPlugin) return []; - const mergedSettings = getSettings_DEPRECATED() - const pluginId = `${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}` - const isEnabled = mergedSettings?.enabledPlugins?.[pluginId] !== false - const isBuiltin = selectedPlugin.marketplace === 'builtin' + const mergedSettings = getSettings_DEPRECATED(); + const pluginId = `${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}`; + const isEnabled = mergedSettings?.enabledPlugins?.[pluginId] !== false; + const isBuiltin = selectedPlugin.marketplace === 'builtin'; - const menuItems: Array<{ label: string; action: () => void }> = [] + const menuItems: Array<{ label: string; action: () => void }> = []; menuItems.push({ label: isEnabled ? 'Disable plugin' : 'Enable plugin', - action: () => - void handleSingleOperation(isEnabled ? 'disable' : 'enable'), - }) + action: () => void handleSingleOperation(isEnabled ? 'disable' : 'enable'), + }); // Update/Uninstall options — not available for built-in plugins if (!isBuiltin) { menuItems.push({ - label: selectedPlugin.pendingUpdate - ? 'Unmark for update' - : 'Mark for update', + label: selectedPlugin.pendingUpdate ? 'Unmark for update' : 'Mark for update', action: async () => { try { - const localError = await checkIfLocalPlugin( - selectedPlugin.plugin.name, - selectedPlugin.marketplace, - ) + const localError = await checkIfLocalPlugin(selectedPlugin.plugin.name, selectedPlugin.marketplace); if (localError) { - setProcessError(localError) - return + setProcessError(localError); + return; } - const newStates = [...pluginStates] + const newStates = [...pluginStates]; const index = newStates.findIndex( - s => - s.plugin.name === selectedPlugin.plugin.name && - s.marketplace === selectedPlugin.marketplace, - ) + s => s.plugin.name === selectedPlugin.plugin.name && s.marketplace === selectedPlugin.marketplace, + ); if (index !== -1) { - newStates[index]!.pendingUpdate = !selectedPlugin.pendingUpdate - setPluginStates(newStates) + newStates[index]!.pendingUpdate = !selectedPlugin.pendingUpdate; + setPluginStates(newStates); setSelectedPlugin({ ...selectedPlugin, pendingUpdate: !selectedPlugin.pendingUpdate, - }) + }); } } catch (error) { - setProcessError( - error instanceof Error - ? error.message - : 'Failed to check plugin update availability', - ) + setProcessError(error instanceof Error ? error.message : 'Failed to check plugin update availability'); } }, - }) + }); if (selectedPluginHasMcpb) { menuItems.push({ label: 'Configure', action: async () => { - setIsLoadingConfig(true) + setIsLoadingConfig(true); try { - const mcpServersSpec = selectedPlugin.plugin.manifest.mcpServers + const mcpServersSpec = selectedPlugin.plugin.manifest.mcpServers; - let mcpbPath: string | null = null - if ( - typeof mcpServersSpec === 'string' && - isMcpbSource(mcpServersSpec) - ) { - mcpbPath = mcpServersSpec + let mcpbPath: string | null = null; + if (typeof mcpServersSpec === 'string' && isMcpbSource(mcpServersSpec)) { + mcpbPath = mcpServersSpec; } else if (Array.isArray(mcpServersSpec)) { for (const spec of mcpServersSpec) { if (typeof spec === 'string' && isMcpbSource(spec)) { - mcpbPath = spec - break + mcpbPath = spec; + break; } } } if (!mcpbPath) { - setProcessError('No MCPB file found in plugin') - setIsLoadingConfig(false) - return + setProcessError('No MCPB file found in plugin'); + setIsLoadingConfig(false); + return; } - const pluginId = `${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}` + const pluginId = `${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}`; const result = await loadMcpbFile( mcpbPath, selectedPlugin.plugin.path, @@ -1632,22 +1464,22 @@ export function ManagePlugins({ undefined, undefined, true, - ) + ); if ('status' in result && result.status === 'needs-config') { - setConfigNeeded(result) - setViewState('configuring') + setConfigNeeded(result); + setViewState('configuring'); } else { - setProcessError('Failed to load MCPB for configuration') + setProcessError('Failed to load MCPB for configuration'); } } catch (err) { - const errorMsg = errorMessage(err) - setProcessError(`Failed to load configuration: ${errorMsg}`) + const errorMsg = errorMessage(err); + setProcessError(`Failed to load configuration: ${errorMsg}`); } finally { - setIsLoadingConfig(false) + setIsLoadingConfig(false); } }, - }) + }); } if ( @@ -1660,28 +1492,27 @@ export function ManagePlugins({ setViewState({ type: 'configuring-options', schema: selectedPlugin.plugin.manifest.userConfig!, - }) + }); }, - }) + }); } menuItems.push({ label: 'Update now', action: () => void handleSingleOperation('update'), - }) + }); menuItems.push({ label: 'Uninstall', action: () => void handleSingleOperation('uninstall'), - }) + }); } if (selectedPlugin.plugin.manifest.homepage) { menuItems.push({ label: 'Open homepage', - action: () => - void openBrowser(selectedPlugin.plugin.manifest.homepage!), - }) + action: () => void openBrowser(selectedPlugin.plugin.manifest.homepage!), + }); } if (selectedPlugin.plugin.manifest.repository) { @@ -1690,39 +1521,38 @@ export function ManagePlugins({ // Azure DevOps, etc. (gh-31598). pluginDetailsHelpers.tsx:74 keeps // 'View on GitHub' because that path has an explicit isGitHub check. label: 'View repository', - action: () => - void openBrowser(selectedPlugin.plugin.manifest.repository!), - }) + action: () => void openBrowser(selectedPlugin.plugin.manifest.repository!), + }); } menuItems.push({ label: 'Back to plugin list', action: () => { - setViewState('plugin-list') - setSelectedPlugin(null) - setProcessError(null) + setViewState('plugin-list'); + setSelectedPlugin(null); + setProcessError(null); }, - }) + }); - return menuItems - }, [viewState, selectedPlugin, selectedPluginHasMcpb, pluginStates]) + return menuItems; + }, [viewState, selectedPlugin, selectedPluginHasMcpb, pluginStates]); // Plugin-details navigation useKeybindings( { 'select:previous': () => { if (detailsMenuIndex > 0) { - setDetailsMenuIndex(detailsMenuIndex - 1) + setDetailsMenuIndex(detailsMenuIndex - 1); } }, 'select:next': () => { if (detailsMenuIndex < detailsMenuItems.length - 1) { - setDetailsMenuIndex(detailsMenuIndex + 1) + setDetailsMenuIndex(detailsMenuIndex + 1); } }, 'select:accept': () => { if (detailsMenuItems[detailsMenuIndex]) { - detailsMenuItems[detailsMenuIndex]!.action() + detailsMenuItems[detailsMenuIndex]!.action(); } }, }, @@ -1730,21 +1560,18 @@ export function ManagePlugins({ context: 'Select', isActive: viewState === 'plugin-details' && !!selectedPlugin, }, - ) + ); // Failed-plugin-details: only "Uninstall" option, handle Enter useKeybindings( { 'select:accept': () => { - if ( - typeof viewState === 'object' && - viewState.type === 'failed-plugin-details' - ) { + if (typeof viewState === 'object' && viewState.type === 'failed-plugin-details') { void (async () => { - setIsProcessing(true) - setProcessError(null) - const pluginId = viewState.plugin.id - const pluginScope = viewState.plugin.scope + setIsProcessing(true); + setProcessError(null); + const pluginId = viewState.plugin.id; + const pluginScope = viewState.plugin.scope; // Pass scope to uninstallPluginOp so it can find the correct V2 // installation record and clean up on-disk files. Fall back to // default scope if not installable (e.g. 'managed', though that @@ -1754,43 +1581,39 @@ export function ManagePlugins({ // The normal uninstall path prompts; this one preserves. const result = isInstallableScope(pluginScope) ? await uninstallPluginOp(pluginId, pluginScope, false) - : await uninstallPluginOp(pluginId, 'user', false) - let success = result.success + : await uninstallPluginOp(pluginId, 'user', false); + let success = result.success; if (!success) { // Plugin was never installed (only in enabledPlugins settings). // Remove directly from all editable settings sources. - const editableSources = [ - 'userSettings' as const, - 'projectSettings' as const, - 'localSettings' as const, - ] + const editableSources = ['userSettings' as const, 'projectSettings' as const, 'localSettings' as const]; for (const source of editableSources) { - const settings = getSettingsForSource(source) + const settings = getSettingsForSource(source); if (settings?.enabledPlugins?.[pluginId] !== undefined) { updateSettingsForSource(source, { enabledPlugins: { ...settings.enabledPlugins, [pluginId]: undefined, }, - }) - success = true + }); + success = true; } } // Clear memoized caches so next loadAllPlugins() picks up settings changes - clearAllCaches() + clearAllCaches(); } if (success) { if (onManageComplete) { - await onManageComplete() + await onManageComplete(); } - setIsProcessing(false) + setIsProcessing(false); // Return to list (don't setResult — that closes the whole dialog) - setViewState('plugin-list') + setViewState('plugin-list'); } else { - setIsProcessing(false) - setProcessError(result.message) + setIsProcessing(false); + setProcessError(result.message); } - })() + })(); } }, }, @@ -1801,16 +1624,16 @@ export function ManagePlugins({ viewState.type === 'failed-plugin-details' && viewState.plugin.scope !== 'managed', }, - ) + ); // Confirm-project-uninstall: y/enter disables in settings.local.json, n/escape cancels useKeybindings( { 'confirm:yes': () => { - if (!selectedPlugin) return - setIsProcessing(true) - setProcessError(null) - const pluginId = `${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}` + if (!selectedPlugin) return; + setIsProcessing(true); + setProcessError(null); + const pluginId = `${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}`; // Write `false` directly — disablePluginOp's cross-scope guard would // reject this (plugin isn't in localSettings yet; the override IS the // point). @@ -1819,32 +1642,29 @@ export function ManagePlugins({ ...getSettingsForSource('localSettings')?.enabledPlugins, [pluginId]: false, }, - }) + }); if (error) { - setIsProcessing(false) - setProcessError(`Failed to write settings: ${error.message}`) - return + setIsProcessing(false); + setProcessError(`Failed to write settings: ${error.message}`); + return; } - clearAllCaches() + clearAllCaches(); setResult( `✓ Disabled ${selectedPlugin.plugin.name} in .claude/settings.local.json. Run /reload-plugins to apply.`, - ) - if (onManageComplete) void onManageComplete() - setParentViewState({ type: 'menu' }) + ); + if (onManageComplete) void onManageComplete(); + setParentViewState({ type: 'menu' }); }, 'confirm:no': () => { - setViewState('plugin-details') - setProcessError(null) + setViewState('plugin-details'); + setProcessError(null); }, }, { context: 'Confirmation', - isActive: - viewState === 'confirm-project-uninstall' && - !!selectedPlugin && - !isProcessing, + isActive: viewState === 'confirm-project-uninstall' && !!selectedPlugin && !isProcessing, }, - ) + ); // Confirm-data-cleanup: y uninstalls + deletes data dir, n uninstalls + keeps, // esc cancels. Raw useInput because: (1) the Confirmation context maps @@ -1856,75 +1676,63 @@ export function ManagePlugins({ // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw y/n/esc; Enter must not trigger destructive delete useInput( (input, key) => { - if (!selectedPlugin) return - const pluginId = `${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}` - const pluginScope = selectedPlugin.scope + if (!selectedPlugin) return; + const pluginId = `${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}`; + const pluginScope = selectedPlugin.scope; // Dialog is only reachable from the uninstall case (which guards on // isBuiltin), but TS can't track that across viewState transitions. - if ( - !pluginScope || - pluginScope === 'builtin' || - !isInstallableScope(pluginScope) - ) - return + if (!pluginScope || pluginScope === 'builtin' || !isInstallableScope(pluginScope)) return; const doUninstall = async (deleteDataDir: boolean) => { - setIsProcessing(true) - setProcessError(null) + setIsProcessing(true); + setProcessError(null); try { - const result = await uninstallPluginOp( - pluginId, - pluginScope, - deleteDataDir, - ) - if (!result.success) throw new Error(result.message) - clearAllCaches() - const suffix = deleteDataDir ? '' : ' · data preserved' - setResult(`${figures.tick} ${result.message}${suffix}`) - if (onManageComplete) void onManageComplete() - setParentViewState({ type: 'menu' }) + const result = await uninstallPluginOp(pluginId, pluginScope, deleteDataDir); + if (!result.success) throw new Error(result.message); + clearAllCaches(); + const suffix = deleteDataDir ? '' : ' · data preserved'; + setResult(`${figures.tick} ${result.message}${suffix}`); + if (onManageComplete) void onManageComplete(); + setParentViewState({ type: 'menu' }); } catch (e) { - setIsProcessing(false) - setProcessError(e instanceof Error ? e.message : String(e)) + setIsProcessing(false); + setProcessError(e instanceof Error ? e.message : String(e)); } - } + }; if (input === 'y' || input === 'Y') { - void doUninstall(true) + void doUninstall(true); } else if (input === 'n' || input === 'N') { - void doUninstall(false) + void doUninstall(false); } else if (key.escape) { - setViewState('plugin-details') - setProcessError(null) + setViewState('plugin-details'); + setProcessError(null); } }, { isActive: - typeof viewState === 'object' && - viewState.type === 'confirm-data-cleanup' && - !!selectedPlugin && - !isProcessing, + typeof viewState === 'object' && viewState.type === 'confirm-data-cleanup' && !!selectedPlugin && !isProcessing, }, - ) + ); // Reset selection when search query changes React.useEffect(() => { - setSelectedIndex(0) - }, [searchQuery]) + setSelectedIndex(0); + }, [searchQuery]); // Handle input for entering search mode (text input handled by useSearchInput hook) // eslint-disable-next-line custom-rules/prefer-use-keybindings -- useInput needed for raw search mode text input useInput( (input, key) => { - const keyIsNotCtrlOrMeta = !key.ctrl && !key.meta + const keyIsNotCtrlOrMeta = !key.ctrl && !key.meta; if (isSearchMode) { // Text input is handled by useSearchInput hook - return + return; } // Enter search mode with '/' or any printable character (except navigation keys) if (input === '/' && keyIsNotCtrlOrMeta) { - setIsSearchMode(true) - setSearchQuery('') - setSelectedIndex(0) + setIsSearchMode(true); + setSearchQuery(''); + setSelectedIndex(0); } else if ( keyIsNotCtrlOrMeta && input.length > 0 && @@ -1933,17 +1741,17 @@ export function ManagePlugins({ input !== 'k' && input !== ' ' ) { - setIsSearchMode(true) - setSearchQuery(input) - setSelectedIndex(0) + setIsSearchMode(true); + setSearchQuery(input); + setSelectedIndex(0); } }, { isActive: viewState === 'plugin-list' }, - ) + ); // Loading state if (loading) { - return Loading installed plugins… + return Loading installed plugins…; } // No plugins or MCPs installed @@ -1958,24 +1766,20 @@ export function ManagePlugins({ Esc to go back - ) + ); } - if ( - typeof viewState === 'object' && - viewState.type === 'plugin-options' && - selectedPlugin - ) { - const pluginId = `${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}` + if (typeof viewState === 'object' && viewState.type === 'plugin-options' && selectedPlugin) { + const pluginId = `${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}`; function finish(msg: string): void { - setResult(msg) + setResult(msg); // Plugin is enabled regardless of whether config was saved or // skipped — onManageComplete → markPluginsChanged → the // persistent "run /reload-plugins" notice. if (onManageComplete) { - void onManageComplete() + void onManageComplete(); } - setParentViewState({ type: 'menu' }) + setParentViewState({ type: 'menu' }); } return ( { switch (outcome) { case 'configured': - finish( - `✓ Enabled and configured ${selectedPlugin.plugin.name}. Run /reload-plugins to apply.`, - ) - break + finish(`✓ Enabled and configured ${selectedPlugin.plugin.name}. Run /reload-plugins to apply.`); + break; case 'skipped': - finish( - `✓ Enabled ${selectedPlugin.plugin.name}. Run /reload-plugins to apply.`, - ) - break + finish(`✓ Enabled ${selectedPlugin.plugin.name}. Run /reload-plugins to apply.`); + break; case 'error': - finish(`Failed to save configuration: ${detail}`) - break + finish(`Failed to save configuration: ${detail}`); + break; } }} /> - ) + ); } // Configure options (from the Manage menu) - if ( - typeof viewState === 'object' && - viewState.type === 'configuring-options' && - selectedPlugin - ) { - const pluginId = `${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}` + if (typeof viewState === 'object' && viewState.type === 'configuring-options' && selectedPlugin) { + const pluginId = `${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}`; return ( { try { - savePluginOptions(pluginId, values, viewState.schema) - clearAllCaches() - setResult( - 'Configuration saved. Run /reload-plugins for changes to take effect.', - ) + savePluginOptions(pluginId, values, viewState.schema); + clearAllCaches(); + setResult('Configuration saved. Run /reload-plugins for changes to take effect.'); } catch (err) { - setProcessError( - `Failed to save configuration: ${errorMessage(err)}`, - ) + setProcessError(`Failed to save configuration: ${errorMessage(err)}`); } - setViewState('plugin-details') + setViewState('plugin-details'); }} onCancel={() => setViewState('plugin-details')} /> - ) + ); } // Configuration view if (viewState === 'configuring' && configNeeded && selectedPlugin) { - const pluginId = `${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}` + const pluginId = `${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}`; async function handleSave(config: UserConfigValues) { - if (!configNeeded || !selectedPlugin) return + if (!configNeeded || !selectedPlugin) return; try { // Find MCPB path again - const mcpServersSpec = selectedPlugin.plugin.manifest.mcpServers - let mcpbPath: string | null = null + const mcpServersSpec = selectedPlugin.plugin.manifest.mcpServers; + let mcpbPath: string | null = null; - if ( - typeof mcpServersSpec === 'string' && - isMcpbSource(mcpServersSpec) - ) { - mcpbPath = mcpServersSpec + if (typeof mcpServersSpec === 'string' && isMcpbSource(mcpServersSpec)) { + mcpbPath = mcpServersSpec; } else if (Array.isArray(mcpServersSpec)) { for (const spec of mcpServersSpec) { if (typeof spec === 'string' && isMcpbSource(spec)) { - mcpbPath = spec - break + mcpbPath = spec; + break; } } } if (!mcpbPath) { - setProcessError('No MCPB file found') - setViewState('plugin-details') - return + setProcessError('No MCPB file found'); + setViewState('plugin-details'); + return; } // Reload with provided config - await loadMcpbFile( - mcpbPath, - selectedPlugin.plugin.path, - pluginId, - undefined, - config, - ) + await loadMcpbFile(mcpbPath, selectedPlugin.plugin.path, pluginId, undefined, config); // Success - go back to details - setProcessError(null) - setConfigNeeded(null) - setViewState('plugin-details') - setResult( - 'Configuration saved. Run /reload-plugins for changes to take effect.', - ) + setProcessError(null); + setConfigNeeded(null); + setViewState('plugin-details'); + setResult('Configuration saved. Run /reload-plugins for changes to take effect.'); } catch (err) { - const errorMsg = errorMessage(err) - setProcessError(`Failed to save configuration: ${errorMsg}`) - setViewState('plugin-details') + const errorMsg = errorMessage(err); + setProcessError(`Failed to save configuration: ${errorMsg}`); + setViewState('plugin-details'); } } function handleCancel() { - setConfigNeeded(null) - setViewState('plugin-details') + setConfigNeeded(null); + setViewState('plugin-details'); } return ( @@ -2103,12 +1884,12 @@ export function ManagePlugins({ onSave={handleSave} onCancel={handleCancel} /> - ) + ); } // Flagged plugin detail view if (typeof viewState === 'object' && viewState.type === 'flagged-detail') { - const fp = viewState.plugin + const fp = viewState.plugin; return ( @@ -2123,13 +1904,9 @@ export function ManagePlugins({ - - Removed from marketplace · reason: {fp.reason} - + Removed from marketplace · reason: {fp.reason} {fp.text} - - Flagged on {new Date(fp.flaggedAt).toLocaleDateString()} - + Flagged on {new Date(fp.flaggedAt).toLocaleDateString()} @@ -2140,21 +1917,11 @@ export function ManagePlugins({ - - + + - ) + ); } // Confirm-project-uninstall: warn about shared .claude/settings.json, @@ -2163,15 +1930,11 @@ export function ManagePlugins({ return ( - {selectedPlugin.plugin.name} is enabled in .claude/settings.json - (shared with your team) + {selectedPlugin.plugin.name} is enabled in .claude/settings.json (shared with your team) Disable it just for you in .claude/settings.local.json? - - This has the same effect as uninstalling, without affecting other - contributors. - + This has the same effect as uninstalling, without affecting other contributors. {processError && ( @@ -2199,28 +1962,19 @@ export function ManagePlugins({ )} - ) + ); } // Confirm-data-cleanup: prompt before deleting ${CLAUDE_PLUGIN_DATA} dir - if ( - typeof viewState === 'object' && - viewState.type === 'confirm-data-cleanup' && - selectedPlugin - ) { + if (typeof viewState === 'object' && viewState.type === 'confirm-data-cleanup' && selectedPlugin) { return ( - {selectedPlugin.plugin.name} has {viewState.size.human} of persistent - data + {selectedPlugin.plugin.name} has {viewState.size.human} of persistent data Delete it along with the plugin? - - {pluginDataDirPath( - `${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}`, - )} - + {pluginDataDirPath(`${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}`)} {processError && ( @@ -2232,20 +1986,19 @@ export function ManagePlugins({ Uninstalling… ) : ( - y to delete · n to keep ·{' '} - esc to cancel + y to delete · n to keep · esc to cancel )} - ) + ); } // Plugin details view if (viewState === 'plugin-details' && selectedPlugin) { - const mergedSettings = getSettings_DEPRECATED() // Use merged settings to respect all layers - const pluginId = `${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}` - const isEnabled = mergedSettings?.enabledPlugins?.[pluginId] !== false + const mergedSettings = getSettings_DEPRECATED(); // Use merged settings to respect all layers + const pluginId = `${selectedPlugin.plugin.name}@${selectedPlugin.marketplace}`; + const isEnabled = mergedSettings?.enabledPlugins?.[pluginId] !== false; // Compute plugin errors section const filteredPluginErrors = pluginErrors.filter( @@ -2253,16 +2006,15 @@ export function ManagePlugins({ ('plugin' in e && e.plugin === selectedPlugin.plugin.name) || e.source === pluginId || e.source.startsWith(`${selectedPlugin.plugin.name}@`), - ) + ); const pluginErrorsSection = filteredPluginErrors.length === 0 ? null : ( - {filteredPluginErrors.length}{' '} - {plural(filteredPluginErrors.length, 'error')}: + {filteredPluginErrors.length} {plural(filteredPluginErrors.length, 'error')}: {filteredPluginErrors.map((error, i) => { - const guidance = getErrorGuidance(error) + const guidance = getErrorGuidance(error); return ( {formatErrorMessage(error)} @@ -2272,10 +2024,10 @@ export function ManagePlugins({ )} - ) + ); })} - ) + ); return ( @@ -2315,19 +2067,12 @@ export function ManagePlugins({ {/* Current status */} Status: - - {isEnabled ? 'Enabled' : 'Disabled'} - - {selectedPlugin.pendingUpdate && ( - · Marked for update - )} + {isEnabled ? 'Enabled' : 'Disabled'} + {selectedPlugin.pendingUpdate && · Marked for update} {/* Installed components */} - + {/* Plugin errors */} {pluginErrorsSection} @@ -2335,7 +2080,7 @@ export function ManagePlugins({ {/* Menu */} {detailsMenuItems.map((item, index) => { - const isSelected = index === detailsMenuIndex + const isSelected = index === detailsMenuIndex; return ( @@ -2354,7 +2099,7 @@ export function ManagePlugins({ {item.label} - ) + ); })} @@ -2375,42 +2120,22 @@ export function ManagePlugins({ - - - + + + - ) + ); } // Failed plugin detail view - if ( - typeof viewState === 'object' && - viewState.type === 'failed-plugin-details' - ) { - const failedPlugin = viewState.plugin + if (typeof viewState === 'object' && viewState.type === 'failed-plugin-details') { + const failedPlugin = viewState.plugin; - const firstError = failedPlugin.errors[0] - const errorMessage = firstError - ? formatErrorMessage(firstError) - : 'Failed to load' + const firstError = failedPlugin.errors[0]; + const errorMessage = firstError ? formatErrorMessage(firstError) : 'Failed to load'; return ( @@ -2423,9 +2148,7 @@ export function ManagePlugins({ {failedPlugin.scope === 'managed' ? ( - - Managed by your organization — contact your admin - + Managed by your organization — contact your admin ) : ( @@ -2448,43 +2171,38 @@ export function ManagePlugins({ description="remove" /> )} - + - ) + ); } // MCP detail view if (typeof viewState === 'object' && viewState.type === 'mcp-detail') { - const client = viewState.client - const serverToolsCount = filterToolsByServer(mcpTools, client.name).length + const client = viewState.client; + const serverToolsCount = filterToolsByServer(mcpTools, client.name).length; // Common handlers for MCP menus const handleMcpViewTools = () => { - setViewState({ type: 'mcp-tools', client }) - } + setViewState({ type: 'mcp-tools', client }); + }; const handleMcpCancel = () => { - setViewState('plugin-list') - } + setViewState('plugin-list'); + }; const handleMcpComplete = (result?: string) => { if (result) { - setResult(result) + setResult(result); } - setViewState('plugin-list') - } + setViewState('plugin-list'); + }; // Transform MCPServerConnection to appropriate ServerInfo type - const scope = client.config.scope - const configType = client.config.type + const scope = client.config.scope; + const configType = client.config.type; if (configType === 'stdio') { const server: StdioServerInfo = { @@ -2493,7 +2211,7 @@ export function ManagePlugins({ scope, transport: 'stdio', config: client.config as McpStdioServerConfig, - } + }; return ( - ) + ); } else if (configType === 'sse') { const server: SSEServerInfo = { name: client.name, @@ -2512,7 +2230,7 @@ export function ManagePlugins({ transport: 'sse', isAuthenticated: undefined, config: client.config as McpSSEServerConfig, - } + }; return ( - ) + ); } else if (configType === 'http') { const server: HTTPServerInfo = { name: client.name, @@ -2531,7 +2249,7 @@ export function ManagePlugins({ transport: 'http', isAuthenticated: undefined, config: client.config as McpHTTPServerConfig, - } + }; return ( - ) + ); } else if (configType === 'claudeai-proxy') { const server: ClaudeAIServerInfo = { name: client.name, @@ -2550,7 +2268,7 @@ export function ManagePlugins({ transport: 'claudeai-proxy', isAuthenticated: undefined, config: client.config as McpClaudeAIProxyServerConfig, - } + }; return ( - ) + ); } // Fallback - shouldn't happen but handle gracefully - setViewState('plugin-list') - return null + setViewState('plugin-list'); + return null; } // MCP tools view if (typeof viewState === 'object' && viewState.type === 'mcp-tools') { - const client = viewState.client - const scope = client.config.scope - const configType = client.config.type + const client = viewState.client; + const scope = client.config.scope; + const configType = client.config.type; // Build ServerInfo for MCPToolListView - let server: - | StdioServerInfo - | SSEServerInfo - | HTTPServerInfo - | ClaudeAIServerInfo + let server: StdioServerInfo | SSEServerInfo | HTTPServerInfo | ClaudeAIServerInfo; if (configType === 'stdio') { server = { name: client.name, @@ -2587,7 +2301,7 @@ export function ManagePlugins({ scope, transport: 'stdio', config: client.config as McpStdioServerConfig, - } + }; } else if (configType === 'sse') { server = { name: client.name, @@ -2596,7 +2310,7 @@ export function ManagePlugins({ transport: 'sse', isAuthenticated: undefined, config: client.config as McpSSEServerConfig, - } + }; } else if (configType === 'http') { server = { name: client.name, @@ -2605,7 +2319,7 @@ export function ManagePlugins({ transport: 'http', isAuthenticated: undefined, config: client.config as McpHTTPServerConfig, - } + }; } else { server = { name: client.name, @@ -2614,32 +2328,28 @@ export function ManagePlugins({ transport: 'claudeai-proxy', isAuthenticated: undefined, config: client.config as McpClaudeAIProxyServerConfig, - } + }; } return ( { - setViewState({ type: 'mcp-tool-detail', client, tool }) + setViewState({ type: 'mcp-tool-detail', client, tool }); }} onBack={() => setViewState({ type: 'mcp-detail', client })} /> - ) + ); } // MCP tool detail view if (typeof viewState === 'object' && viewState.type === 'mcp-tool-detail') { - const { client, tool } = viewState - const scope = client.config.scope - const configType = client.config.type + const { client, tool } = viewState; + const scope = client.config.scope; + const configType = client.config.type; // Build ServerInfo for MCPToolDetailView - let server: - | StdioServerInfo - | SSEServerInfo - | HTTPServerInfo - | ClaudeAIServerInfo + let server: StdioServerInfo | SSEServerInfo | HTTPServerInfo | ClaudeAIServerInfo; if (configType === 'stdio') { server = { name: client.name, @@ -2647,7 +2357,7 @@ export function ManagePlugins({ scope, transport: 'stdio', config: client.config as McpStdioServerConfig, - } + }; } else if (configType === 'sse') { server = { name: client.name, @@ -2656,7 +2366,7 @@ export function ManagePlugins({ transport: 'sse', isAuthenticated: undefined, config: client.config as McpSSEServerConfig, - } + }; } else if (configType === 'http') { server = { name: client.name, @@ -2665,7 +2375,7 @@ export function ManagePlugins({ transport: 'http', isAuthenticated: undefined, config: client.config as McpHTTPServerConfig, - } + }; } else { server = { name: client.name, @@ -2674,20 +2384,14 @@ export function ManagePlugins({ transport: 'claudeai-proxy', isAuthenticated: undefined, config: client.config as McpClaudeAIProxyServerConfig, - } + }; } - return ( - setViewState({ type: 'mcp-tools', client })} - /> - ) + return setViewState({ type: 'mcp-tools', client })} />; } // Plugin list view (main management interface) - const visibleItems = pagination.getVisibleItems(filteredItems) + const visibleItems = pagination.getVisibleItems(filteredItems); return ( @@ -2718,37 +2422,36 @@ export function ManagePlugins({ {/* Unified list of plugins and MCPs grouped by scope */} {visibleItems.map((item, visibleIndex) => { - const actualIndex = pagination.toActualIndex(visibleIndex) - const isSelected = actualIndex === selectedIndex && !isSearchMode + const actualIndex = pagination.toActualIndex(visibleIndex); + const isSelected = actualIndex === selectedIndex && !isSearchMode; // Check if we need to show a scope header - const prevItem = - visibleIndex > 0 ? visibleItems[visibleIndex - 1] : null - const showScopeHeader = !prevItem || prevItem.scope !== item.scope + const prevItem = visibleIndex > 0 ? visibleItems[visibleIndex - 1] : null; + const showScopeHeader = !prevItem || prevItem.scope !== item.scope; // Get scope label const getScopeLabel = (scope: string): string => { switch (scope) { case 'flagged': - return 'Flagged' + return 'Flagged'; case 'project': - return 'Project' + return 'Project'; case 'local': - return 'Local' + return 'Local'; case 'user': - return 'User' + return 'User'; case 'enterprise': - return 'Enterprise' + return 'Enterprise'; case 'managed': - return 'Managed' + return 'Managed'; case 'builtin': - return 'Built-in' + return 'Built-in'; case 'dynamic': - return 'Built-in' + return 'Built-in'; default: - return scope + return scope; } - } + }; return ( @@ -2765,7 +2468,7 @@ export function ManagePlugins({ )} - ) + ); })} {/* Scroll down indicator */} @@ -2780,24 +2483,9 @@ export function ManagePlugins({ type to search - - - + + + @@ -2811,5 +2499,5 @@ export function ManagePlugins({ )} - ) + ); } diff --git a/src/commands/plugin/PluginErrors.tsx b/src/commands/plugin/PluginErrors.tsx index 1a81fa2a1..532771147 100644 --- a/src/commands/plugin/PluginErrors.tsx +++ b/src/commands/plugin/PluginErrors.tsx @@ -1,140 +1,139 @@ -import { getPluginErrorMessage, type PluginError } from '../../types/plugin.js' +import { getPluginErrorMessage, type PluginError } from '../../types/plugin.js'; export function formatErrorMessage(error: PluginError): string { switch (error.type) { case 'path-not-found': - return `${error.component} path not found: ${error.path}` + return `${error.component} path not found: ${error.path}`; case 'git-auth-failed': - return `Git ${error.authType.toUpperCase()} authentication failed for ${error.gitUrl}` + return `Git ${error.authType.toUpperCase()} authentication failed for ${error.gitUrl}`; case 'git-timeout': - return `Git ${error.operation} timed out for ${error.gitUrl}` + return `Git ${error.operation} timed out for ${error.gitUrl}`; case 'network-error': - return `Network error accessing ${error.url}${error.details ? `: ${error.details}` : ''}` + return `Network error accessing ${error.url}${error.details ? `: ${error.details}` : ''}`; case 'manifest-parse-error': - return `Failed to parse manifest at ${error.manifestPath}: ${error.parseError}` + return `Failed to parse manifest at ${error.manifestPath}: ${error.parseError}`; case 'manifest-validation-error': - return `Invalid manifest at ${error.manifestPath}: ${error.validationErrors.join(', ')}` + return `Invalid manifest at ${error.manifestPath}: ${error.validationErrors.join(', ')}`; case 'plugin-not-found': - return `Plugin "${error.pluginId}" not found in marketplace "${error.marketplace}"` + return `Plugin "${error.pluginId}" not found in marketplace "${error.marketplace}"`; case 'marketplace-not-found': - return `Marketplace "${error.marketplace}" not found` + return `Marketplace "${error.marketplace}" not found`; case 'marketplace-load-failed': - return `Failed to load marketplace "${error.marketplace}": ${error.reason}` + return `Failed to load marketplace "${error.marketplace}": ${error.reason}`; case 'mcp-config-invalid': - return `Invalid MCP server config for "${error.serverName}": ${error.validationError}` + return `Invalid MCP server config for "${error.serverName}": ${error.validationError}`; case 'mcp-server-suppressed-duplicate': { const dup = error.duplicateOf.startsWith('plugin:') ? `server provided by plugin "${error.duplicateOf.split(':')[1] ?? '?'}"` - : `already-configured "${error.duplicateOf}"` - return `MCP server "${error.serverName}" skipped — same command/URL as ${dup}` + : `already-configured "${error.duplicateOf}"`; + return `MCP server "${error.serverName}" skipped — same command/URL as ${dup}`; } case 'hook-load-failed': - return `Failed to load hooks from ${error.hookPath}: ${error.reason}` + return `Failed to load hooks from ${error.hookPath}: ${error.reason}`; case 'component-load-failed': - return `Failed to load ${error.component} from ${error.path}: ${error.reason}` + return `Failed to load ${error.component} from ${error.path}: ${error.reason}`; case 'mcpb-download-failed': - return `Failed to download MCPB from ${error.url}: ${error.reason}` + return `Failed to download MCPB from ${error.url}: ${error.reason}`; case 'mcpb-extract-failed': - return `Failed to extract MCPB ${error.mcpbPath}: ${error.reason}` + return `Failed to extract MCPB ${error.mcpbPath}: ${error.reason}`; case 'mcpb-invalid-manifest': - return `MCPB manifest invalid at ${error.mcpbPath}: ${error.validationError}` + return `MCPB manifest invalid at ${error.mcpbPath}: ${error.validationError}`; case 'marketplace-blocked-by-policy': return error.blockedByBlocklist ? `Marketplace "${error.marketplace}" is blocked by enterprise policy` - : `Marketplace "${error.marketplace}" is not in the allowed marketplace list` + : `Marketplace "${error.marketplace}" is not in the allowed marketplace list`; case 'dependency-unsatisfied': return error.reason === 'not-enabled' ? `Dependency "${error.dependency}" is disabled` - : `Dependency "${error.dependency}" is not installed` + : `Dependency "${error.dependency}" is not installed`; case 'lsp-config-invalid': - return `Invalid LSP server config for "${error.serverName}": ${error.validationError}` + return `Invalid LSP server config for "${error.serverName}": ${error.validationError}`; case 'lsp-server-start-failed': - return `LSP server "${error.serverName}" failed to start: ${error.reason}` + return `LSP server "${error.serverName}" failed to start: ${error.reason}`; case 'lsp-server-crashed': return error.signal ? `LSP server "${error.serverName}" crashed with signal ${error.signal}` - : `LSP server "${error.serverName}" crashed with exit code ${error.exitCode ?? 'unknown'}` + : `LSP server "${error.serverName}" crashed with exit code ${error.exitCode ?? 'unknown'}`; case 'lsp-request-timeout': - return `LSP server "${error.serverName}" timed out on ${error.method} after ${error.timeoutMs}ms` + return `LSP server "${error.serverName}" timed out on ${error.method} after ${error.timeoutMs}ms`; case 'lsp-request-failed': - return `LSP server "${error.serverName}" ${error.method} failed: ${error.error}` + return `LSP server "${error.serverName}" ${error.method} failed: ${error.error}`; case 'plugin-cache-miss': - return `Plugin "${error.plugin}" not cached at ${error.installPath}` + return `Plugin "${error.plugin}" not cached at ${error.installPath}`; case 'generic-error': - return error.error + return error.error; } - const _exhaustive: never = error - return getPluginErrorMessage(_exhaustive) + const _exhaustive: never = error; + return getPluginErrorMessage(_exhaustive); } export function getErrorGuidance(error: PluginError): string | null { switch (error.type) { case 'path-not-found': - return 'Check that the path in your manifest or marketplace config is correct' + return 'Check that the path in your manifest or marketplace config is correct'; case 'git-auth-failed': return error.authType === 'ssh' ? 'Configure SSH keys or use HTTPS URL instead' - : 'Configure credentials or use SSH URL instead' + : 'Configure credentials or use SSH URL instead'; case 'git-timeout': case 'network-error': - return 'Check your internet connection and try again' + return 'Check your internet connection and try again'; case 'manifest-parse-error': - return 'Check manifest file syntax in the plugin directory' + return 'Check manifest file syntax in the plugin directory'; case 'manifest-validation-error': - return 'Check manifest file follows the required schema' + return 'Check manifest file follows the required schema'; case 'plugin-not-found': - return `Plugin may not exist in marketplace "${error.marketplace}"` + return `Plugin may not exist in marketplace "${error.marketplace}"`; case 'marketplace-not-found': return error.availableMarketplaces.length > 0 ? `Available marketplaces: ${error.availableMarketplaces.join(', ')}` - : 'Add the marketplace first using /plugin marketplace add' + : 'Add the marketplace first using /plugin marketplace add'; case 'mcp-config-invalid': - return 'Check MCP server configuration in .mcp.json or manifest' + return 'Check MCP server configuration in .mcp.json or manifest'; case 'mcp-server-suppressed-duplicate': { // duplicateOf is "plugin:name:srv" when another plugin won dedup — // users can't remove plugin-provided servers from their MCP config, // so point them at the winning plugin instead. if (error.duplicateOf.startsWith('plugin:')) { - const winningPlugin = - error.duplicateOf.split(':')[1] ?? 'the other plugin' - return `Disable plugin "${winningPlugin}" if you want this plugin's version instead` + const winningPlugin = error.duplicateOf.split(':')[1] ?? 'the other plugin'; + return `Disable plugin "${winningPlugin}" if you want this plugin's version instead`; } - return `Remove "${error.duplicateOf}" from your MCP config if you want the plugin's version instead` + return `Remove "${error.duplicateOf}" from your MCP config if you want the plugin's version instead`; } case 'hook-load-failed': - return 'Check hooks.json file syntax and structure' + return 'Check hooks.json file syntax and structure'; case 'component-load-failed': - return `Check ${error.component} directory structure and file permissions` + return `Check ${error.component} directory structure and file permissions`; case 'mcpb-download-failed': - return 'Check your internet connection and URL accessibility' + return 'Check your internet connection and URL accessibility'; case 'mcpb-extract-failed': - return 'Verify the MCPB file is valid and not corrupted' + return 'Verify the MCPB file is valid and not corrupted'; case 'mcpb-invalid-manifest': - return 'Contact the plugin author about the invalid manifest' + return 'Contact the plugin author about the invalid manifest'; case 'marketplace-blocked-by-policy': if (error.blockedByBlocklist) { - return 'This marketplace source is explicitly blocked by your administrator' + return 'This marketplace source is explicitly blocked by your administrator'; } return error.allowedSources.length > 0 ? `Allowed sources: ${error.allowedSources.join(', ')}` - : 'Contact your administrator to configure allowed marketplace sources' + : 'Contact your administrator to configure allowed marketplace sources'; case 'dependency-unsatisfied': return error.reason === 'not-enabled' ? `Enable "${error.dependency}" or uninstall "${error.plugin}"` - : `Install "${error.dependency}" or uninstall "${error.plugin}"` + : `Install "${error.dependency}" or uninstall "${error.plugin}"`; case 'lsp-config-invalid': - return 'Check LSP server configuration in the plugin manifest' + return 'Check LSP server configuration in the plugin manifest'; case 'lsp-server-start-failed': case 'lsp-server-crashed': case 'lsp-request-timeout': case 'lsp-request-failed': - return 'Check LSP server logs with --debug for details' + return 'Check LSP server logs with --debug for details'; case 'plugin-cache-miss': - return 'Run /plugins to refresh the plugin cache' + return 'Run /plugins to refresh the plugin cache'; case 'marketplace-load-failed': case 'generic-error': - return null + return null; } - const _exhaustive: never = error - return null + const _exhaustive: never = error; + return null; } diff --git a/src/commands/plugin/PluginOptionsDialog.tsx b/src/commands/plugin/PluginOptionsDialog.tsx index cb09352d3..2a9565c04 100644 --- a/src/commands/plugin/PluginOptionsDialog.tsx +++ b/src/commands/plugin/PluginOptionsDialog.tsx @@ -1,17 +1,11 @@ -import figures from 'figures' -import React, { useCallback, useState } from 'react' -import { Dialog } from '@anthropic/ink' +import figures from 'figures'; +import React, { useCallback, useState } from 'react'; +import { Dialog } from '@anthropic/ink'; // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw text input for config dialog -import { Box, Text, useInput, stringWidth } from '@anthropic/ink' -import { - useKeybinding, - useKeybindings, -} from '../../keybindings/useKeybinding.js' -import { isEnvTruthy } from '../../utils/envUtils.js' -import type { - PluginOptionSchema, - PluginOptionValues, -} from '../../utils/plugins/pluginOptionsStorage.js' +import { Box, Text, useInput, stringWidth } from '@anthropic/ink'; +import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js'; +import { isEnvTruthy } from '../../utils/envUtils.js'; +import type { PluginOptionSchema, PluginOptionValues } from '../../utils/plugins/pluginOptionsStorage.js'; /** * Build the onSave payload from collected string inputs. @@ -31,43 +25,39 @@ export function buildFinalValues( configSchema: PluginOptionSchema, initialValues: PluginOptionValues | undefined, ): PluginOptionValues { - const finalValues: PluginOptionValues = {} + const finalValues: PluginOptionValues = {}; for (const fieldKey of fields) { - const schema = configSchema[fieldKey] - const value = collected[fieldKey] ?? '' + const schema = configSchema[fieldKey]; + const value = collected[fieldKey] ?? ''; - if ( - schema?.sensitive === true && - value === '' && - initialValues?.[fieldKey] !== undefined - ) { - continue + if (schema?.sensitive === true && value === '' && initialValues?.[fieldKey] !== undefined) { + continue; } if (schema?.type === 'number') { // Number('') returns 0, not NaN — omit blank number inputs so // validateUserConfig's required check actually catches them. - if (value.trim() === '') continue - const num = Number(value) - finalValues[fieldKey] = Number.isNaN(num) ? value : num + if (value.trim() === '') continue; + const num = Number(value); + finalValues[fieldKey] = Number.isNaN(num) ? value : num; } else if (schema?.type === 'boolean') { - finalValues[fieldKey] = isEnvTruthy(value) + finalValues[fieldKey] = isEnvTruthy(value); } else { - finalValues[fieldKey] = value + finalValues[fieldKey] = value; } } - return finalValues + return finalValues; } type Props = { - title: string - subtitle: string - configSchema: PluginOptionSchema + title: string; + subtitle: string; + configSchema: PluginOptionSchema; /** Pre-fill fields when reconfiguring. Sensitive fields are not prepopulated. */ - initialValues?: PluginOptionValues - onSave: (config: PluginOptionValues) => void - onCancel: () => void -} + initialValues?: PluginOptionValues; + onSave: (config: PluginOptionValues) => void; + onCancel: () => void; +}; export function PluginOptionsDialog({ title, @@ -77,68 +67,56 @@ export function PluginOptionsDialog({ onSave, onCancel, }: Props): React.ReactNode { - const fields = Object.keys(configSchema) + const fields = Object.keys(configSchema); // Prepopulate from initialValues but skip sensitive fields — we don't // want to echo secrets back into the text buffer. const initialFor = useCallback( (key: string): string => { - if (configSchema[key]?.sensitive === true) return '' - const v = initialValues?.[key] - return v === undefined ? '' : String(v) + if (configSchema[key]?.sensitive === true) return ''; + const v = initialValues?.[key]; + return v === undefined ? '' : String(v); }, [configSchema, initialValues], - ) + ); - const [currentFieldIndex, setCurrentFieldIndex] = useState(0) - const [values, setValues] = useState>({}) - const [currentInput, setCurrentInput] = useState(() => - fields[0] ? initialFor(fields[0]) : '', - ) + const [currentFieldIndex, setCurrentFieldIndex] = useState(0); + const [values, setValues] = useState>({}); + const [currentInput, setCurrentInput] = useState(() => (fields[0] ? initialFor(fields[0]) : '')); - const currentField = fields[currentFieldIndex] - const fieldSchema = currentField ? configSchema[currentField] : null + const currentField = fields[currentFieldIndex]; + const fieldSchema = currentField ? configSchema[currentField] : null; // Use Settings context so 'n' key doesn't cancel (allows typing 'n' in input). // isCancelActive={false} on Dialog keeps its own confirm:no out of the way. - useKeybinding('confirm:no', onCancel, { context: 'Settings' }) + useKeybinding('confirm:no', onCancel, { context: 'Settings' }); // Tab to next field const handleNextField = useCallback(() => { if (currentFieldIndex < fields.length - 1 && currentField) { - setValues(prev => ({ ...prev, [currentField]: currentInput })) - setCurrentFieldIndex(prev => prev + 1) - const nextKey = fields[currentFieldIndex + 1] - setCurrentInput(nextKey ? initialFor(nextKey) : '') + setValues(prev => ({ ...prev, [currentField]: currentInput })); + setCurrentFieldIndex(prev => prev + 1); + const nextKey = fields[currentFieldIndex + 1]; + setCurrentInput(nextKey ? initialFor(nextKey) : ''); } - }, [currentFieldIndex, fields, currentField, currentInput, initialFor]) + }, [currentFieldIndex, fields, currentField, currentInput, initialFor]); // Enter to save current field and move to next, or save all if last const handleConfirm = useCallback(() => { - if (!currentField) return + if (!currentField) return; - const newValues = { ...values, [currentField]: currentInput } + const newValues = { ...values, [currentField]: currentInput }; if (currentFieldIndex === fields.length - 1) { - onSave(buildFinalValues(fields, newValues, configSchema, initialValues)) + onSave(buildFinalValues(fields, newValues, configSchema, initialValues)); } else { // Move to next field - setValues(newValues) - setCurrentFieldIndex(prev => prev + 1) - const nextKey = fields[currentFieldIndex + 1] - setCurrentInput(nextKey ? initialFor(nextKey) : '') + setValues(newValues); + setCurrentFieldIndex(prev => prev + 1); + const nextKey = fields[currentFieldIndex + 1]; + setCurrentInput(nextKey ? initialFor(nextKey) : ''); } - }, [ - currentField, - values, - currentInput, - currentFieldIndex, - fields, - configSchema, - onSave, - initialFor, - initialValues, - ]) + }, [currentField, values, currentInput, currentFieldIndex, fields, configSchema, onSave, initialFor, initialValues]); useKeybindings( { @@ -146,47 +124,38 @@ export function PluginOptionsDialog({ 'confirm:yes': handleConfirm, }, { context: 'Confirmation' }, - ) + ); // Character input handling (backspace, typing) useInput((char, key) => { // Backspace if (key.backspace || key.delete) { - setCurrentInput(prev => prev.slice(0, -1)) - return + setCurrentInput(prev => prev.slice(0, -1)); + return; } // Regular character input if (char && !key.ctrl && !key.meta && !key.tab && !key.return) { - setCurrentInput(prev => prev + char) + setCurrentInput(prev => prev + char); } - }) + }); if (!fieldSchema || !currentField) { - return null + return null; } - const isSensitive = fieldSchema.sensitive === true - const isRequired = fieldSchema.required === true - const displayValue = isSensitive - ? '*'.repeat(stringWidth(currentInput)) - : currentInput + const isSensitive = fieldSchema.sensitive === true; + const isRequired = fieldSchema.required === true; + const displayValue = isSensitive ? '*'.repeat(stringWidth(currentInput)) : currentInput; return ( - + {fieldSchema.title || currentField} {isRequired && *} - {fieldSchema.description && ( - {fieldSchema.description} - )} + {fieldSchema.description && {fieldSchema.description}} {figures.pointerSmall} @@ -200,14 +169,10 @@ export function PluginOptionsDialog({ Field {currentFieldIndex + 1} of {fields.length} {currentFieldIndex < fields.length - 1 && ( - - Tab: Next field · Enter: Save and continue - - )} - {currentFieldIndex === fields.length - 1 && ( - Enter: Save configuration + Tab: Next field · Enter: Save and continue )} + {currentFieldIndex === fields.length - 1 && Enter: Save configuration} - ) + ); } diff --git a/src/commands/plugin/PluginOptionsFlow.tsx b/src/commands/plugin/PluginOptionsFlow.tsx index 916639a22..6246b21e4 100644 --- a/src/commands/plugin/PluginOptionsFlow.tsx +++ b/src/commands/plugin/PluginOptionsFlow.tsx @@ -7,26 +7,20 @@ * onDone('skipped') immediately if nothing needs filling. */ -import * as React from 'react' -import type { LoadedPlugin } from '../../types/plugin.js' -import { errorMessage } from '../../utils/errors.js' -import { - loadMcpServerUserConfig, - saveMcpServerUserConfig, -} from '../../utils/plugins/mcpbHandler.js' -import { - getUnconfiguredChannels, - type UnconfiguredChannel, -} from '../../utils/plugins/mcpPluginIntegration.js' -import { loadAllPlugins } from '../../utils/plugins/pluginLoader.js' +import * as React from 'react'; +import type { LoadedPlugin } from '../../types/plugin.js'; +import { errorMessage } from '../../utils/errors.js'; +import { loadMcpServerUserConfig, saveMcpServerUserConfig } from '../../utils/plugins/mcpbHandler.js'; +import { getUnconfiguredChannels, type UnconfiguredChannel } from '../../utils/plugins/mcpPluginIntegration.js'; +import { loadAllPlugins } from '../../utils/plugins/pluginLoader.js'; import { getUnconfiguredOptions, loadPluginOptions, type PluginOptionSchema, type PluginOptionValues, savePluginOptions, -} from '../../utils/plugins/pluginOptionsStorage.js' -import { PluginOptionsDialog } from './PluginOptionsDialog.js' +} from '../../utils/plugins/pluginOptionsStorage.js'; +import { PluginOptionsDialog } from './PluginOptionsDialog.js'; /** * Post-install lookup: return the LoadedPlugin for the just-installed @@ -36,13 +30,9 @@ import { PluginOptionsDialog } from './PluginOptionsDialog.js' * * Install should have cleared caches already; loadAllPlugins reads fresh. */ -export async function findPluginOptionsTarget( - pluginId: string, -): Promise { - const { enabled, disabled } = await loadAllPlugins() - return [...enabled, ...disabled].find( - p => p.repository === pluginId || p.source === pluginId, - ) +export async function findPluginOptionsTarget(pluginId: string): Promise { + const { enabled, disabled } = await loadAllPlugins(); + return [...enabled, ...disabled].find(p => p.repository === pluginId || p.source === pluginId); } /** @@ -50,39 +40,35 @@ export async function findPluginOptionsTarget( * collapse to this shape — the only difference is which save function runs. */ type ConfigStep = { - key: string - title: string - subtitle: string - schema: PluginOptionSchema + key: string; + title: string; + subtitle: string; + schema: PluginOptionSchema; /** Returns any already-saved values so PluginOptionsDialog can pre-fill and * skip unchanged sensitive fields on reconfigure. */ - load: () => PluginOptionValues | undefined - save: (values: PluginOptionValues) => void -} + load: () => PluginOptionValues | undefined; + save: (values: PluginOptionValues) => void; +}; type Props = { - plugin: LoadedPlugin + plugin: LoadedPlugin; /** `name@marketplace` — the savePluginOptions / saveMcpServerUserConfig key. */ - pluginId: string + pluginId: string; /** * `configured` = user filled all fields. `skipped` = nothing needed * configuring, or user hit cancel. `error` = save threw. */ - onDone: (outcome: 'configured' | 'skipped' | 'error', detail?: string) => void -} + onDone: (outcome: 'configured' | 'skipped' | 'error', detail?: string) => void; +}; -export function PluginOptionsFlow({ - plugin, - pluginId, - onDone, -}: Props): React.ReactNode { +export function PluginOptionsFlow({ plugin, pluginId, onDone }: Props): React.ReactNode { // Build the step list once at mount. Re-calling after a save would drop the // item we just configured. const [steps] = React.useState(() => { - const result: ConfigStep[] = [] + const result: ConfigStep[] = []; // Top-level manifest.userConfig - const unconfigured = getUnconfiguredOptions(plugin) + const unconfigured = getUnconfiguredOptions(plugin); if (Object.keys(unconfigured).length > 0) { result.push({ key: 'top-level', @@ -90,68 +76,60 @@ export function PluginOptionsFlow({ subtitle: 'Plugin options', schema: unconfigured, load: () => loadPluginOptions(pluginId), - save: values => - savePluginOptions(pluginId, values, plugin.manifest.userConfig!), - }) + save: values => savePluginOptions(pluginId, values, plugin.manifest.userConfig!), + }); } // Per-channel userConfig (assistant-mode channels) - const channels: UnconfiguredChannel[] = getUnconfiguredChannels(plugin) + const channels: UnconfiguredChannel[] = getUnconfiguredChannels(plugin); for (const channel of channels) { result.push({ key: `channel:${channel.server}`, title: `Configure ${channel.displayName}`, subtitle: `Plugin: ${plugin.name}`, schema: channel.configSchema, - load: () => - loadMcpServerUserConfig(pluginId, channel.server) ?? undefined, - save: values => - saveMcpServerUserConfig( - pluginId, - channel.server, - values, - channel.configSchema, - ), - }) + load: () => loadMcpServerUserConfig(pluginId, channel.server) ?? undefined, + save: values => saveMcpServerUserConfig(pluginId, channel.server, values, channel.configSchema), + }); } - return result - }) + return result; + }); - const [index, setIndex] = React.useState(0) + const [index, setIndex] = React.useState(0); // Latest-ref: lets the effect close over the current onDone without // re-running when the parent re-renders. - const onDoneRef = React.useRef(onDone) - onDoneRef.current = onDone + const onDoneRef = React.useRef(onDone); + onDoneRef.current = onDone; // Nothing to configure → tell the caller and render nothing. Effect, // not inline call: calling setState in the parent during our render // is a React rules-of-hooks violation. React.useEffect(() => { if (steps.length === 0) { - onDoneRef.current('skipped') + onDoneRef.current('skipped'); } - }, [steps.length]) + }, [steps.length]); if (steps.length === 0) { - return null + return null; } - const current = steps[index]! + const current = steps[index]!; function handleSave(values: PluginOptionValues): void { try { - current.save(values) + current.save(values); } catch (err) { - onDone('error', errorMessage(err)) - return + onDone('error', errorMessage(err)); + return; } - const next = index + 1 + const next = index + 1; if (next < steps.length) { - setIndex(next) + setIndex(next); } else { - onDone('configured') + onDone('configured'); } } @@ -168,5 +146,5 @@ export function PluginOptionsFlow({ onSave={handleSave} onCancel={() => onDone('skipped')} /> - ) + ); } diff --git a/src/commands/plugin/PluginSettings.tsx b/src/commands/plugin/PluginSettings.tsx index e3a798e8a..d6bcfe088 100644 --- a/src/commands/plugin/PluginSettings.tsx +++ b/src/commands/plugin/PluginSettings.tsx @@ -1,73 +1,58 @@ -import figures from 'figures' -import * as React from 'react' -import { useCallback, useEffect, useState } from 'react' -import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js' -import { Byline, Pane, Tab, Tabs } from '@anthropic/ink' -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' -import { Box, Text } from '@anthropic/ink' -import { - useKeybinding, - useKeybindings, -} from '../../keybindings/useKeybinding.js' -import { useAppState, useSetAppState } from '../../state/AppState.js' -import type { PluginError } from '../../types/plugin.js' -import { errorMessage } from '../../utils/errors.js' -import { clearAllCaches } from '../../utils/plugins/cacheUtils.js' -import { loadMarketplacesWithGracefulDegradation } from '../../utils/plugins/marketplaceHelpers.js' -import { - loadKnownMarketplacesConfig, - removeMarketplaceSource, -} from '../../utils/plugins/marketplaceManager.js' -import { getPluginEditableScopes } from '../../utils/plugins/pluginStartupCheck.js' -import type { EditableSettingSource } from '../../utils/settings/constants.js' -import { - getSettingsForSource, - updateSettingsForSource, -} from '../../utils/settings/settings.js' -import { AddMarketplace } from './AddMarketplace.js' -import { BrowseMarketplace } from './BrowseMarketplace.js' -import { DiscoverPlugins } from './DiscoverPlugins.js' -import { ManageMarketplaces } from './ManageMarketplaces.js' -import { ManagePlugins } from './ManagePlugins.js' -import { formatErrorMessage, getErrorGuidance } from './PluginErrors.js' -import { type ParsedCommand, parsePluginArgs } from './parseArgs.js' -import type { PluginSettingsProps, ViewState } from './types.js' -import { ValidatePlugin } from './ValidatePlugin.js' +import figures from 'figures'; +import * as React from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js'; +import { Byline, Pane, Tab, Tabs } from '@anthropic/ink'; +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; +import { Box, Text } from '@anthropic/ink'; +import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js'; +import { useAppState, useSetAppState } from '../../state/AppState.js'; +import type { PluginError } from '../../types/plugin.js'; +import { errorMessage } from '../../utils/errors.js'; +import { clearAllCaches } from '../../utils/plugins/cacheUtils.js'; +import { loadMarketplacesWithGracefulDegradation } from '../../utils/plugins/marketplaceHelpers.js'; +import { loadKnownMarketplacesConfig, removeMarketplaceSource } from '../../utils/plugins/marketplaceManager.js'; +import { getPluginEditableScopes } from '../../utils/plugins/pluginStartupCheck.js'; +import type { EditableSettingSource } from '../../utils/settings/constants.js'; +import { getSettingsForSource, updateSettingsForSource } from '../../utils/settings/settings.js'; +import { AddMarketplace } from './AddMarketplace.js'; +import { BrowseMarketplace } from './BrowseMarketplace.js'; +import { DiscoverPlugins } from './DiscoverPlugins.js'; +import { ManageMarketplaces } from './ManageMarketplaces.js'; +import { ManagePlugins } from './ManagePlugins.js'; +import { formatErrorMessage, getErrorGuidance } from './PluginErrors.js'; +import { type ParsedCommand, parsePluginArgs } from './parseArgs.js'; +import type { PluginSettingsProps, ViewState } from './types.js'; +import { ValidatePlugin } from './ValidatePlugin.js'; -type TabId = 'discover' | 'installed' | 'marketplaces' | 'errors' +type TabId = 'discover' | 'installed' | 'marketplaces' | 'errors'; -function MarketplaceList({ - onComplete, -}: { - onComplete: (result?: string) => void -}): React.ReactNode { +function MarketplaceList({ onComplete }: { onComplete: (result?: string) => void }): React.ReactNode { useEffect(() => { async function loadList() { try { - const config = await loadKnownMarketplacesConfig() - const names = Object.keys(config) + const config = await loadKnownMarketplacesConfig(); + const names = Object.keys(config); if (names.length === 0) { - onComplete('No marketplaces configured') + onComplete('No marketplaces configured'); } else { - onComplete( - `Configured marketplaces:\n${names.map(n => ` • ${n}`).join('\n')}`, - ) + onComplete(`Configured marketplaces:\n${names.map(n => ` • ${n}`).join('\n')}`); } } catch (err) { - onComplete(`Error loading marketplaces: ${errorMessage(err)}`) + onComplete(`Error loading marketplaces: ${errorMessage(err)}`); } } - void loadList() - }, [onComplete]) + void loadList(); + }, [onComplete]); - return Loading marketplaces... + return Loading marketplaces...; } function McpRedirectBanner(): React.ReactNode { if ((process.env.USER_TYPE as string) !== 'ant') { - return null + return null; } return ( @@ -88,78 +73,75 @@ function McpRedirectBanner(): React.ReactNode { i{' '} - - [ANT-ONLY] MCP servers are now managed in /plugins. Use /mcp no-redirect - to test old UI - + [ANT-ONLY] MCP servers are now managed in /plugins. Use /mcp no-redirect to test old UI - ) + ); } type ErrorRowAction = | { kind: 'navigate'; tab: TabId; viewState: ViewState } | { - kind: 'remove-extra-marketplace' - name: string - sources: Array<{ source: EditableSettingSource; scope: string }> + kind: 'remove-extra-marketplace'; + name: string; + sources: Array<{ source: EditableSettingSource; scope: string }>; } | { kind: 'remove-installed-marketplace'; name: string } | { kind: 'managed-only'; name: string } - | { kind: 'none' } + | { kind: 'none' }; type ErrorRow = { - label: string - message: string - guidance?: string | null - action: ErrorRowAction - scope?: string -} + label: string; + message: string; + guidance?: string | null; + action: ErrorRowAction; + scope?: string; +}; /** * Determine which settings sources define an extraKnownMarketplace entry. * Returns the editable sources (user/project/local) and whether policy also has it. */ function getExtraMarketplaceSourceInfo(name: string): { - editableSources: Array<{ source: EditableSettingSource; scope: string }> - isInPolicy: boolean + editableSources: Array<{ source: EditableSettingSource; scope: string }>; + isInPolicy: boolean; } { const editableSources: Array<{ - source: EditableSettingSource - scope: string - }> = [] + source: EditableSettingSource; + scope: string; + }> = []; const sourcesToCheck = [ { source: 'userSettings' as const, scope: 'user' }, { source: 'projectSettings' as const, scope: 'project' }, { source: 'localSettings' as const, scope: 'local' }, - ] + ]; for (const { source, scope } of sourcesToCheck) { - const settings = getSettingsForSource(source) + const settings = getSettingsForSource(source); if (settings?.extraKnownMarketplaces?.[name]) { - editableSources.push({ source, scope }) + editableSources.push({ source, scope }); } } - const policySettings = getSettingsForSource('policySettings') - const isInPolicy = Boolean(policySettings?.extraKnownMarketplaces?.[name]) + const policySettings = getSettingsForSource('policySettings'); + const isInPolicy = Boolean(policySettings?.extraKnownMarketplaces?.[name]); - return { editableSources, isInPolicy } + return { editableSources, isInPolicy }; } function buildMarketplaceAction(name: string): ErrorRowAction { - const { editableSources, isInPolicy } = getExtraMarketplaceSourceInfo(name) + const { editableSources, isInPolicy } = getExtraMarketplaceSourceInfo(name); if (editableSources.length > 0) { return { kind: 'remove-extra-marketplace', name, sources: editableSources, - } + }; } if (isInPolicy) { - return { kind: 'managed-only', name } + return { kind: 'managed-only', name }; } // Marketplace is in known_marketplaces.json but not in extraKnownMarketplaces @@ -172,7 +154,7 @@ function buildMarketplaceAction(name: string): ErrorRowAction { targetMarketplace: name, action: 'remove', }, - } + }; } function buildPluginAction(pluginName: string): ErrorRowAction { @@ -184,17 +166,13 @@ function buildPluginAction(pluginName: string): ErrorRowAction { targetPlugin: pluginName, action: 'uninstall', }, - } + }; } -const TRANSIENT_ERROR_TYPES = new Set([ - 'git-auth-failed', - 'git-timeout', - 'network-error', -]) +const TRANSIENT_ERROR_TYPES = new Set(['git-auth-failed', 'git-timeout', 'network-error']); function isTransientError(error: PluginError): boolean { - return TRANSIENT_ERROR_TYPES.has(error.type) + return TRANSIENT_ERROR_TYPES.has(error.type); } /** @@ -202,11 +180,11 @@ function isTransientError(error: PluginError): boolean { * then falling back to the source field (format: "pluginName@marketplace"). */ function getPluginNameFromError(error: PluginError): string | undefined { - if ('pluginId' in error && error.pluginId) return error.pluginId - if ('plugin' in error && error.plugin) return error.plugin + if ('pluginId' in error && error.pluginId) return error.pluginId; + if ('plugin' in error && error.plugin) return error.plugin; // Fallback: source often contains "pluginName@marketplace" - if (error.source.includes('@')) return error.source.split('@')[0] - return undefined + if (error.source.includes('@')) return error.source.split('@')[0]; + return undefined; } function buildErrorRows( @@ -218,102 +196,82 @@ function buildErrorRows( transientErrors: PluginError[], pluginScopes: Map, ): ErrorRow[] { - const rows: ErrorRow[] = [] + const rows: ErrorRow[] = []; // --- Transient errors at the top (restart to retry) --- for (const error of transientErrors) { - const pluginName = - 'pluginId' in error - ? error.pluginId - : 'plugin' in error - ? error.plugin - : undefined + const pluginName = 'pluginId' in error ? error.pluginId : 'plugin' in error ? error.plugin : undefined; rows.push({ label: pluginName ?? error.source, message: formatErrorMessage(error), guidance: 'Restart to retry loading plugins', action: { kind: 'none' }, - }) + }); } // --- Marketplace errors --- // Track shown marketplace names to avoid duplicates across sources - const shownMarketplaceNames = new Set() + const shownMarketplaceNames = new Set(); for (const m of failedMarketplaces) { - shownMarketplaceNames.add(m.name) - const action = buildMarketplaceAction(m.name) - const sourceInfo = getExtraMarketplaceSourceInfo(m.name) - const scope = sourceInfo.isInPolicy - ? 'managed' - : sourceInfo.editableSources[0]?.scope + shownMarketplaceNames.add(m.name); + const action = buildMarketplaceAction(m.name); + const sourceInfo = getExtraMarketplaceSourceInfo(m.name); + const scope = sourceInfo.isInPolicy ? 'managed' : sourceInfo.editableSources[0]?.scope; rows.push({ label: m.name, message: m.error ?? 'Installation failed', - guidance: - action.kind === 'managed-only' - ? 'Managed by your organization — contact your admin' - : undefined, + guidance: action.kind === 'managed-only' ? 'Managed by your organization — contact your admin' : undefined, action, scope, - }) + }); } for (const e of extraMarketplaceErrors) { - const marketplace = 'marketplace' in e ? e.marketplace : e.source - if (shownMarketplaceNames.has(marketplace)) continue - shownMarketplaceNames.add(marketplace) - const action = buildMarketplaceAction(marketplace) - const sourceInfo = getExtraMarketplaceSourceInfo(marketplace) - const scope = sourceInfo.isInPolicy - ? 'managed' - : sourceInfo.editableSources[0]?.scope + const marketplace = 'marketplace' in e ? e.marketplace : e.source; + if (shownMarketplaceNames.has(marketplace)) continue; + shownMarketplaceNames.add(marketplace); + const action = buildMarketplaceAction(marketplace); + const sourceInfo = getExtraMarketplaceSourceInfo(marketplace); + const scope = sourceInfo.isInPolicy ? 'managed' : sourceInfo.editableSources[0]?.scope; rows.push({ label: marketplace, message: formatErrorMessage(e), guidance: - action.kind === 'managed-only' - ? 'Managed by your organization — contact your admin' - : getErrorGuidance(e), + action.kind === 'managed-only' ? 'Managed by your organization — contact your admin' : getErrorGuidance(e), action, scope, - }) + }); } // Installed marketplaces that fail to load data (from known_marketplaces.json) for (const m of brokenInstalledMarketplaces) { - if (shownMarketplaceNames.has(m.name)) continue - shownMarketplaceNames.add(m.name) + if (shownMarketplaceNames.has(m.name)) continue; + shownMarketplaceNames.add(m.name); rows.push({ label: m.name, message: m.error, action: { kind: 'remove-installed-marketplace', name: m.name }, - }) + }); } // --- Plugin errors --- - const shownPluginNames = new Set() + const shownPluginNames = new Set(); for (const error of pluginLoadingErrors) { - const pluginName = getPluginNameFromError(error) - if (pluginName && shownPluginNames.has(pluginName)) continue - if (pluginName) shownPluginNames.add(pluginName) + const pluginName = getPluginNameFromError(error); + if (pluginName && shownPluginNames.has(pluginName)) continue; + if (pluginName) shownPluginNames.add(pluginName); - const marketplace = 'marketplace' in error ? error.marketplace : undefined + const marketplace = 'marketplace' in error ? error.marketplace : undefined; // Try pluginId@marketplace format first, then just pluginName - const scope = pluginName - ? (pluginScopes.get(error.source) ?? pluginScopes.get(pluginName)) - : undefined + const scope = pluginName ? (pluginScopes.get(error.source) ?? pluginScopes.get(pluginName)) : undefined; rows.push({ - label: pluginName - ? marketplace - ? `${pluginName} @ ${marketplace}` - : pluginName - : error.source, + label: pluginName ? (marketplace ? `${pluginName} @ ${marketplace}` : pluginName) : error.source, message: formatErrorMessage(error), guidance: getErrorGuidance(error), action: pluginName ? buildPluginAction(pluginName) : { kind: 'none' }, scope, - }) + }); } // --- Other errors (non-marketplace, non-plugin-specific) --- @@ -323,52 +281,49 @@ function buildErrorRows( message: formatErrorMessage(error), guidance: getErrorGuidance(error), action: { kind: 'none' }, - }) + }); } - return rows + return rows; } /** * Remove a marketplace from extraKnownMarketplaces in the given settings sources, * and also remove any associated enabled plugins. */ -function removeExtraMarketplace( - name: string, - sources: Array<{ source: EditableSettingSource }>, -): void { +function removeExtraMarketplace(name: string, sources: Array<{ source: EditableSettingSource }>): void { for (const { source } of sources) { - const settings = getSettingsForSource(source) - if (!settings) continue + const settings = getSettingsForSource(source); + if (!settings) continue; - const updates: Record = {} + const updates: Record = {}; // Remove from extraKnownMarketplaces if (settings.extraKnownMarketplaces?.[name]) { updates.extraKnownMarketplaces = { ...settings.extraKnownMarketplaces, [name]: undefined, - } + }; } // Remove associated enabled plugins (format: "plugin@marketplace") if (settings.enabledPlugins) { - const suffix = `@${name}` - let removedPlugins = false - const updatedPlugins = { ...settings.enabledPlugins } + const suffix = `@${name}`; + let removedPlugins = false; + const updatedPlugins = { ...settings.enabledPlugins }; for (const pluginId in updatedPlugins) { if (pluginId.endsWith(suffix)) { - updatedPlugins[pluginId] = undefined - removedPlugins = true + updatedPlugins[pluginId] = undefined; + removedPlugins = true; } } if (removedPlugins) { - updates.enabledPlugins = updatedPlugins + updates.enabledPlugins = updatedPlugins; } } if (Object.keys(updates).length > 0) { - updateSettingsForSource(source, updates) + updateSettingsForSource(source, updates); } } } @@ -378,40 +333,35 @@ function ErrorsTabContent({ setActiveTab, markPluginsChanged, }: { - setViewState: (state: ViewState) => void - setActiveTab: (tab: TabId) => void - markPluginsChanged: () => void + setViewState: (state: ViewState) => void; + setActiveTab: (tab: TabId) => void; + markPluginsChanged: () => void; }): React.ReactNode { - const errors = useAppState(s => s.plugins.errors) - const installationStatus = useAppState(s => s.plugins.installationStatus) - const setAppState = useSetAppState() - const [selectedIndex, setSelectedIndex] = useState(0) - const [actionMessage, setActionMessage] = useState(null) - const [marketplaceLoadFailures, setMarketplaceLoadFailures] = useState< - Array<{ name: string; error: string }> - >([]) + const errors = useAppState(s => s.plugins.errors); + const installationStatus = useAppState(s => s.plugins.installationStatus); + const setAppState = useSetAppState(); + const [selectedIndex, setSelectedIndex] = useState(0); + const [actionMessage, setActionMessage] = useState(null); + const [marketplaceLoadFailures, setMarketplaceLoadFailures] = useState>([]); // Detect marketplaces that are installed but fail to load their data useEffect(() => { void (async () => { try { - const config = await loadKnownMarketplacesConfig() - const { failures } = - await loadMarketplacesWithGracefulDegradation(config) - setMarketplaceLoadFailures(failures) + const config = await loadKnownMarketplacesConfig(); + const { failures } = await loadMarketplacesWithGracefulDegradation(config); + setMarketplaceLoadFailures(failures); } catch { // Ignore — if we can't load config, other tabs handle it } - })() - }, []) + })(); + }, []); - const failedMarketplaces = installationStatus.marketplaces.filter( - m => m.status === 'failed', - ) - const failedMarketplaceNames = new Set(failedMarketplaces.map(m => m.name)) + const failedMarketplaces = installationStatus.marketplaces.filter(m => m.status === 'failed'); + const failedMarketplaceNames = new Set(failedMarketplaces.map(m => m.name)); // Transient errors (git/network) — show at top with "restart to retry" - const transientErrors = errors.filter(isTransientError) + const transientErrors = errors.filter(isTransientError); // Marketplace-related loading errors not already covered by install failures const extraMarketplaceErrors = errors.filter( @@ -420,35 +370,35 @@ function ErrorsTabContent({ e.type === 'marketplace-load-failed' || e.type === 'marketplace-blocked-by-policy') && !failedMarketplaceNames.has(e.marketplace), - ) + ); // Plugin-specific loading errors const pluginLoadingErrors = errors.filter(e => { - if (isTransientError(e)) return false + if (isTransientError(e)) return false; if ( e.type === 'marketplace-not-found' || e.type === 'marketplace-load-failed' || e.type === 'marketplace-blocked-by-policy' ) { - return false + return false; } - return getPluginNameFromError(e) !== undefined - }) + return getPluginNameFromError(e) !== undefined; + }); // Remaining errors with no plugin association const otherErrors = errors.filter(e => { - if (isTransientError(e)) return false + if (isTransientError(e)) return false; if ( e.type === 'marketplace-not-found' || e.type === 'marketplace-load-failed' || e.type === 'marketplace-blocked-by-policy' ) { - return false + return false; } - return getPluginNameFromError(e) === undefined - }) + return getPluginNameFromError(e) === undefined; + }); - const pluginScopes = getPluginEditableScopes() + const pluginScopes = getPluginEditableScopes(); const rows = buildErrorRows( failedMarketplaces, extraMarketplaceErrors, @@ -457,30 +407,30 @@ function ErrorsTabContent({ marketplaceLoadFailures, transientErrors, pluginScopes, - ) + ); // Handle escape to exit the plugin menu useKeybinding( 'confirm:no', () => { - setViewState({ type: 'menu' }) + setViewState({ type: 'menu' }); }, { context: 'Confirmation' }, - ) + ); const handleSelect = () => { - const row = rows[selectedIndex] - if (!row) return - const { action } = row + const row = rows[selectedIndex]; + if (!row) return; + const { action } = row; switch (action.kind) { case 'navigate': - setActiveTab(action.tab) - setViewState(action.viewState) - break + setActiveTab(action.tab); + setViewState(action.viewState); + break; case 'remove-extra-marketplace': { - const scopes = action.sources.map(s => s.scope).join(', ') - removeExtraMarketplace(action.name, action.sources) - clearAllCaches() + const scopes = action.sources.map(s => s.scope).join(', '); + removeExtraMarketplace(action.name, action.sources); + clearAllCaches(); // Synchronously clear all stale state for this marketplace so the UI // updates glitch-free. markPluginsChanged only sets needsRefresh — // it does not refresh plugins.errors, so this is the authoritative @@ -489,72 +439,56 @@ function ErrorsTabContent({ ...prev, plugins: { ...prev.plugins, - errors: prev.plugins.errors.filter( - e => !('marketplace' in e && e.marketplace === action.name), - ), + errors: prev.plugins.errors.filter(e => !('marketplace' in e && e.marketplace === action.name)), installationStatus: { ...prev.plugins.installationStatus, - marketplaces: prev.plugins.installationStatus.marketplaces.filter( - m => m.name !== action.name, - ), + marketplaces: prev.plugins.installationStatus.marketplaces.filter(m => m.name !== action.name), }, }, - })) - setActionMessage( - `${figures.tick} Removed "${action.name}" from ${scopes} settings`, - ) - markPluginsChanged() - break + })); + setActionMessage(`${figures.tick} Removed "${action.name}" from ${scopes} settings`); + markPluginsChanged(); + break; } case 'remove-installed-marketplace': { void (async () => { try { - await removeMarketplaceSource(action.name) - clearAllCaches() - setMarketplaceLoadFailures(prev => - prev.filter(f => f.name !== action.name), - ) - setActionMessage( - `${figures.tick} Removed marketplace "${action.name}"`, - ) - markPluginsChanged() + await removeMarketplaceSource(action.name); + clearAllCaches(); + setMarketplaceLoadFailures(prev => prev.filter(f => f.name !== action.name)); + setActionMessage(`${figures.tick} Removed marketplace "${action.name}"`); + markPluginsChanged(); } catch (err) { - setActionMessage( - `Failed to remove "${action.name}": ${err instanceof Error ? err.message : String(err)}`, - ) + setActionMessage(`Failed to remove "${action.name}": ${err instanceof Error ? err.message : String(err)}`); } - })() - break + })(); + break; } case 'managed-only': // No action available — guidance text already shown - break + break; case 'none': - break + break; } - } + }; useKeybindings( { 'select:previous': () => setSelectedIndex(prev => Math.max(0, prev - 1)), - 'select:next': () => - setSelectedIndex(prev => Math.min(rows.length - 1, prev + 1)), + 'select:next': () => setSelectedIndex(prev => Math.min(rows.length - 1, prev + 1)), 'select:accept': handleSelect, }, { context: 'Select', isActive: rows.length > 0 }, - ) + ); // Clamp selectedIndex when rows shrink (e.g. after removal) - const clampedIndex = Math.min(selectedIndex, Math.max(0, rows.length - 1)) + const clampedIndex = Math.min(selectedIndex, Math.max(0, rows.length - 1)); if (clampedIndex !== selectedIndex) { - setSelectedIndex(clampedIndex) + setSelectedIndex(clampedIndex); } - const selectedAction = rows[clampedIndex]?.action - const hasAction = - selectedAction && - selectedAction.kind !== 'none' && - selectedAction.kind !== 'managed-only' + const selectedAction = rows[clampedIndex]?.action; + const hasAction = selectedAction && selectedAction.kind !== 'none' && selectedAction.kind !== 'managed-only'; if (rows.length === 0) { return ( @@ -564,28 +498,21 @@ function ErrorsTabContent({ - + - ) + ); } return ( {rows.map((row, idx) => { - const isSelected = idx === clampedIndex + const isSelected = idx === clampedIndex; return ( - - {isSelected ? figures.pointer : figures.cross}{' '} - + {isSelected ? figures.pointer : figures.cross} {row.label} {row.scope && ({row.scope})} @@ -600,7 +527,7 @@ function ErrorsTabContent({ )} - ) + ); })} {actionMessage && ( @@ -612,12 +539,7 @@ function ErrorsTabContent({ - + {hasAction && ( )} - + - ) + ); } function getInitialViewState(parsedCommand: ParsedCommand): ViewState { switch (parsedCommand.type) { case 'help': - return { type: 'help' } + return { type: 'help' }; case 'validate': - return { type: 'validate', path: parsedCommand.path } + return { type: 'validate', path: parsedCommand.path }; case 'install': if (parsedCommand.marketplace) { return { type: 'browse-marketplace', targetMarketplace: parsedCommand.marketplace, targetPlugin: parsedCommand.plugin, - } + }; } if (parsedCommand.plugin) { return { type: 'discover-plugins', targetPlugin: parsedCommand.plugin, - } + }; } - return { type: 'discover-plugins' } + return { type: 'discover-plugins' }; case 'manage': - return { type: 'manage-plugins' } + return { type: 'manage-plugins' }; case 'uninstall': return { type: 'manage-plugins', targetPlugin: parsedCommand.plugin, action: 'uninstall', - } + }; case 'enable': return { type: 'manage-plugins', targetPlugin: parsedCommand.plugin, action: 'enable', - } + }; case 'disable': return { type: 'manage-plugins', targetPlugin: parsedCommand.plugin, action: 'disable', - } + }; case 'marketplace': if (parsedCommand.action === 'list') { - return { type: 'marketplace-list' } + return { type: 'marketplace-list' }; } if (parsedCommand.action === 'add') { return { type: 'add-marketplace', initialValue: parsedCommand.target, - } + }; } if (parsedCommand.action === 'remove') { return { type: 'manage-marketplaces', targetMarketplace: parsedCommand.target, action: 'remove', - } + }; } if (parsedCommand.action === 'update') { return { type: 'manage-marketplaces', targetMarketplace: parsedCommand.target, action: 'update', - } + }; } - return { type: 'marketplace-menu' } + return { type: 'marketplace-menu' }; case 'menu': default: // Default to discover view showing all plugins - return { type: 'discover-plugins' } + return { type: 'discover-plugins' }; } } function getInitialTab(viewState: ViewState): TabId { - if (viewState.type === 'manage-plugins') return 'installed' - if (viewState.type === 'manage-marketplaces') return 'marketplaces' - return 'discover' + if (viewState.type === 'manage-plugins') return 'installed'; + if (viewState.type === 'manage-marketplaces') return 'marketplaces'; + return 'discover'; } -export function PluginSettings({ - onComplete, - args, - showMcpRedirectMessage, -}: PluginSettingsProps): React.ReactNode { - const parsedCommand = parsePluginArgs(args) - const initialViewState = getInitialViewState(parsedCommand) - const [viewState, setViewState] = useState(initialViewState) - const [activeTab, setActiveTab] = useState( - getInitialTab(initialViewState), - ) +export function PluginSettings({ onComplete, args, showMcpRedirectMessage }: PluginSettingsProps): React.ReactNode { + const parsedCommand = parsePluginArgs(args); + const initialViewState = getInitialViewState(parsedCommand); + const [viewState, setViewState] = useState(initialViewState); + const [activeTab, setActiveTab] = useState(getInitialTab(initialViewState)); const [inputValue, setInputValue] = useState( viewState.type === 'add-marketplace' ? viewState.initialValue || '' : '', - ) - const [cursorOffset, setCursorOffset] = useState(0) - const [error, setError] = useState(null) - const [result, setResult] = useState(null) - const [childSearchActive, setChildSearchActive] = useState(false) - const setAppState = useSetAppState() + ); + const [cursorOffset, setCursorOffset] = useState(0); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + const [childSearchActive, setChildSearchActive] = useState(false); + const setAppState = useSetAppState(); // Error count for the Errors tab badge — counts loader errors + background // marketplace install failures. Does NOT count marketplace-on-disk load @@ -744,16 +655,15 @@ export function PluginSettings({ // May slightly overcount vs. displayed rows when a marketplace has both a // loader error and a failed install status (buildErrorRows deduplicates). const pluginErrorCount = useAppState(s => { - let count = s.plugins.errors.length + let count = s.plugins.errors.length; for (const m of s.plugins.installationStatus.marketplaces) { - if (m.status === 'failed') count++ + if (m.status === 'failed') count++; } - return count - }) - const errorsTabTitle = - pluginErrorCount > 0 ? `Errors (${pluginErrorCount})` : 'Errors' + return count; + }); + const errorsTabTitle = pluginErrorCount > 0 ? `Errors (${pluginErrorCount})` : 'Errors'; - const exitState = useExitOnCtrlCDWithKeybindings() + const exitState = useExitOnCtrlCDWithKeybindings(); /** * CLI mode is active when the user provides a complete command with all required arguments. @@ -761,9 +671,7 @@ export function PluginSettings({ * Interactive mode is used when arguments are missing, allowing the user to input them. */ const cliMode = - parsedCommand.type === 'marketplace' && - parsedCommand.action === 'add' && - parsedCommand.target !== undefined + parsedCommand.type === 'marketplace' && parsedCommand.action === 'add' && parsedCommand.target !== undefined; // Signal that plugin state has changed on disk (Layer 2) and active // components (Layer 3) are stale. User runs /reload-plugins to apply. @@ -774,32 +682,30 @@ export function PluginSettings({ // plugin changes require /reload-plugins. const markPluginsChanged = useCallback(() => { setAppState(prev => - prev.plugins.needsRefresh - ? prev - : { ...prev, plugins: { ...prev.plugins, needsRefresh: true } }, - ) - }, [setAppState]) + prev.plugins.needsRefresh ? prev : { ...prev, plugins: { ...prev.plugins, needsRefresh: true } }, + ); + }, [setAppState]); // Handle tab switching (called by Tabs component) const handleTabChange = useCallback((tabId: string) => { - const tab = tabId as TabId - setActiveTab(tab) - setError(null) + const tab = tabId as TabId; + setActiveTab(tab); + setError(null); switch (tab) { case 'discover': - setViewState({ type: 'discover-plugins' }) - break + setViewState({ type: 'discover-plugins' }); + break; case 'installed': - setViewState({ type: 'manage-plugins' }) - break + setViewState({ type: 'manage-plugins' }); + break; case 'marketplaces': - setViewState({ type: 'manage-marketplaces' }) - break + setViewState({ type: 'manage-marketplaces' }); + break; case 'errors': // No viewState change needed — ErrorsTabContent renders inside - break + break; } - }, []) + }, []); // Handle exiting when child components set viewState to 'menu'. // Child components typically set BOTH setResult(msg) and setParentViewState @@ -808,44 +714,44 @@ export function PluginSettings({ // the close AND delivers the message to the transcript. useEffect(() => { if (viewState.type === 'menu' && !result) { - onComplete() + onComplete(); } - }, [viewState.type, result, onComplete]) + }, [viewState.type, result, onComplete]); // Sync activeTab when viewState changes to a different tab's content // This handles cases like AddMarketplace navigating to browse-marketplace useEffect(() => { if (viewState.type === 'browse-marketplace' && activeTab !== 'discover') { - setActiveTab('discover') + setActiveTab('discover'); } - }, [viewState.type, activeTab]) + }, [viewState.type, activeTab]); // Handle escape key for add-marketplace mode only // Other tabbed views handle escape in their own components const handleAddMarketplaceEscape = useCallback(() => { - setActiveTab('marketplaces') - setViewState({ type: 'manage-marketplaces' }) - setInputValue('') - setError(null) - }, []) + setActiveTab('marketplaces'); + setViewState({ type: 'manage-marketplaces' }); + setInputValue(''); + setError(null); + }, []); useKeybinding('confirm:no', handleAddMarketplaceEscape, { context: 'Settings', isActive: viewState.type === 'add-marketplace', - }) + }); useEffect(() => { if (result) { - onComplete(result) + onComplete(result); } - }, [result, onComplete]) + }, [result, onComplete]); // Handle help view completion useEffect(() => { if (viewState.type === 'help') { - onComplete() + onComplete(); } - }, [viewState.type, onComplete]) + }, [viewState.type, onComplete]); // Render different views based on state if (viewState.type === 'help') { @@ -855,17 +761,9 @@ export function PluginSettings({ Installation: /plugin install - Browse and install plugins - - {' '} - /plugin install <marketplace> - Install from specific - marketplace - + /plugin install <marketplace> - Install from specific marketplace /plugin install <plugin> - Install specific plugin - - {' '} - /plugin install <plugin>@<market> - Install plugin from - marketplace - + /plugin install <plugin>@<market> - Install plugin from marketplace Management: /plugin manage - Manage installed plugins @@ -876,48 +774,36 @@ export function PluginSettings({ Marketplaces: /plugin marketplace - Marketplace management menu /plugin marketplace add - Add a marketplace - - {' '} - /plugin marketplace add <path/url> - Add marketplace directly - + /plugin marketplace add <path/url> - Add marketplace directly /plugin marketplace update - Update marketplaces - - {' '} - /plugin marketplace update <name> - Update specific marketplace - + /plugin marketplace update <name> - Update specific marketplace /plugin marketplace remove - Remove a marketplace - - {' '} - /plugin marketplace remove <name> - Remove specific marketplace - + /plugin marketplace remove <name> - Remove specific marketplace /plugin marketplace list - List all marketplaces Validation: - - {' '} - /plugin validate <path> - Validate a manifest file or directory - + /plugin validate <path> - Validate a manifest file or directory Other: /plugin - Main plugin menu /plugin help - Show this help /plugins - Alias for /plugin - ) + ); } if (viewState.type === 'validate') { - return + return ; } if (viewState.type === 'marketplace-menu') { // Show a simple menu for marketplace operations - setViewState({ type: 'menu' }) - return null + setViewState({ type: 'menu' }); + return null; } if (viewState.type === 'marketplace-list') { - return + return ; } if (viewState.type === 'add-marketplace') { @@ -935,7 +821,7 @@ export function PluginSettings({ onAddComplete={markPluginsChanged} cliMode={cliMode} /> - ) + ); } // Render tabbed interface using the design system Tabs component return ( @@ -946,11 +832,7 @@ export function PluginSettings({ onTabChange={handleTabChange} color="suggestion" disableNavigation={childSearchActive} - banner={ - showMcpRedirectMessage && activeTab === 'installed' ? ( - - ) : undefined - } + banner={showMcpRedirectMessage && activeTab === 'installed' ? : undefined} > {viewState.type === 'browse-marketplace' ? ( @@ -973,11 +855,7 @@ export function PluginSettings({ setViewState={setViewState} onInstallComplete={markPluginsChanged} onSearchModeChange={setChildSearchActive} - targetPlugin={ - viewState.type === 'discover-plugins' - ? viewState.targetPlugin - : undefined - } + targetPlugin={viewState.type === 'discover-plugins' ? viewState.targetPlugin : undefined} /> )} @@ -987,19 +865,9 @@ export function PluginSettings({ setResult={setResult} onManageComplete={markPluginsChanged} onSearchModeChange={setChildSearchActive} - targetPlugin={ - viewState.type === 'manage-plugins' - ? viewState.targetPlugin - : undefined - } - targetMarketplace={ - viewState.type === 'manage-plugins' - ? viewState.targetMarketplace - : undefined - } - action={ - viewState.type === 'manage-plugins' ? viewState.action : undefined - } + targetPlugin={viewState.type === 'manage-plugins' ? viewState.targetPlugin : undefined} + targetMarketplace={viewState.type === 'manage-plugins' ? viewState.targetMarketplace : undefined} + action={viewState.type === 'manage-plugins' ? viewState.action : undefined} /> @@ -1010,16 +878,8 @@ export function PluginSettings({ setResult={setResult} exitState={exitState} onManageComplete={markPluginsChanged} - targetMarketplace={ - viewState.type === 'manage-marketplaces' - ? viewState.targetMarketplace - : undefined - } - action={ - viewState.type === 'manage-marketplaces' - ? viewState.action - : undefined - } + targetMarketplace={viewState.type === 'manage-marketplaces' ? viewState.targetMarketplace : undefined} + action={viewState.type === 'manage-marketplaces' ? viewState.action : undefined} /> @@ -1031,5 +891,5 @@ export function PluginSettings({ - ) + ); } diff --git a/src/commands/plugin/PluginTrustWarning.tsx b/src/commands/plugin/PluginTrustWarning.tsx index 3be3dd79f..a22b6a893 100644 --- a/src/commands/plugin/PluginTrustWarning.tsx +++ b/src/commands/plugin/PluginTrustWarning.tsx @@ -1,20 +1,19 @@ -import figures from 'figures' -import * as React from 'react' -import { Box, Text } from '@anthropic/ink' -import { getPluginTrustMessage } from '../../utils/plugins/marketplaceHelpers.js' +import figures from 'figures'; +import * as React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { getPluginTrustMessage } from '../../utils/plugins/marketplaceHelpers.js'; export function PluginTrustWarning(): React.ReactNode { - const customMessage = getPluginTrustMessage() + const customMessage = getPluginTrustMessage(); return ( {figures.warning} - Make sure you trust a plugin before installing, updating, or using it. - Anthropic does not control what MCP servers, files, or other software - are included in plugins and cannot verify that they will work as - intended or that they won't change. See each plugin's homepage - for more information.{customMessage ? ` ${customMessage}` : ''} + Make sure you trust a plugin before installing, updating, or using it. Anthropic does not control what MCP + servers, files, or other software are included in plugins and cannot verify that they will work as intended or + that they won't change. See each plugin's homepage for more information. + {customMessage ? ` ${customMessage}` : ''} - ) + ); } diff --git a/src/commands/plugin/UnifiedInstalledCell.tsx b/src/commands/plugin/UnifiedInstalledCell.tsx index 05e44821f..1032b3eb7 100644 --- a/src/commands/plugin/UnifiedInstalledCell.tsx +++ b/src/commands/plugin/UnifiedInstalledCell.tsx @@ -1,46 +1,40 @@ -import figures from 'figures' -import * as React from 'react' -import { Box, color, Text, useTheme } from '@anthropic/ink' -import { plural } from '../../utils/stringUtils.js' -import type { UnifiedInstalledItem } from './unifiedTypes.js' +import figures from 'figures'; +import * as React from 'react'; +import { Box, color, Text, useTheme } from '@anthropic/ink'; +import { plural } from '../../utils/stringUtils.js'; +import type { UnifiedInstalledItem } from './unifiedTypes.js'; type Props = { - item: UnifiedInstalledItem - isSelected: boolean -} + item: UnifiedInstalledItem; + isSelected: boolean; +}; -export function UnifiedInstalledCell({ - item, - isSelected, -}: Props): React.ReactNode { - const [theme] = useTheme() +export function UnifiedInstalledCell({ item, isSelected }: Props): React.ReactNode { + const [theme] = useTheme(); if (item.type === 'plugin') { // Status icon and text - let statusIcon: string - let statusText: string + let statusIcon: string; + let statusText: string; // Show pending toggle status if set, otherwise show current status if (item.pendingToggle) { - statusIcon = color('suggestion', theme)(figures.arrowRight) - statusText = - item.pendingToggle === 'will-enable' ? 'will enable' : 'will disable' + statusIcon = color('suggestion', theme)(figures.arrowRight); + statusText = item.pendingToggle === 'will-enable' ? 'will enable' : 'will disable'; } else if (item.errorCount > 0) { - statusIcon = color('error', theme)(figures.cross) - statusText = `${item.errorCount} ${plural(item.errorCount, 'error')}` + statusIcon = color('error', theme)(figures.cross); + statusText = `${item.errorCount} ${plural(item.errorCount, 'error')}`; } else if (!item.isEnabled) { - statusIcon = color('inactive', theme)(figures.radioOff) - statusText = 'disabled' + statusIcon = color('inactive', theme)(figures.radioOff); + statusText = 'disabled'; } else { - statusIcon = color('success', theme)(figures.tick) - statusText = 'enabled' + statusIcon = color('success', theme)(figures.tick); + statusText = 'enabled'; } return ( - - {isSelected ? `${figures.pointer} ` : ' '} - + {isSelected ? `${figures.pointer} ` : ' '} {item.name} {' '} @@ -50,17 +44,15 @@ export function UnifiedInstalledCell({ · {statusIcon} {statusText} - ) + ); } if (item.type === 'flagged-plugin') { - const statusIcon = color('warning', theme)(figures.warning) + const statusIcon = color('warning', theme)(figures.warning); return ( - - {isSelected ? `${figures.pointer} ` : ' '} - + {isSelected ? `${figures.pointer} ` : ' '} {item.name} {' '} @@ -70,18 +62,16 @@ export function UnifiedInstalledCell({ · {statusIcon} removed - ) + ); } if (item.type === 'failed-plugin') { - const statusIcon = color('error', theme)(figures.cross) - const statusText = `failed to load · ${item.errorCount} ${plural(item.errorCount, 'error')}` + const statusIcon = color('error', theme)(figures.cross); + const statusText = `failed to load · ${item.errorCount} ${plural(item.errorCount, 'error')}`; return ( - - {isSelected ? `${figures.pointer} ` : ' '} - + {isSelected ? `${figures.pointer} ` : ' '} {item.name} {' '} @@ -91,37 +81,35 @@ export function UnifiedInstalledCell({ · {statusIcon} {statusText} - ) + ); } // MCP server - let statusIcon: string - let statusText: string + let statusIcon: string; + let statusText: string; if (item.status === 'connected') { - statusIcon = color('success', theme)(figures.tick) - statusText = 'connected' + statusIcon = color('success', theme)(figures.tick); + statusText = 'connected'; } else if (item.status === 'disabled') { - statusIcon = color('inactive', theme)(figures.radioOff) - statusText = 'disabled' + statusIcon = color('inactive', theme)(figures.radioOff); + statusText = 'disabled'; } else if (item.status === 'pending') { - statusIcon = color('inactive', theme)(figures.radioOff) - statusText = 'connecting…' + statusIcon = color('inactive', theme)(figures.radioOff); + statusText = 'connecting…'; } else if (item.status === 'needs-auth') { - statusIcon = color('warning', theme)(figures.triangleUpOutline) - statusText = 'Enter to auth' + statusIcon = color('warning', theme)(figures.triangleUpOutline); + statusText = 'Enter to auth'; } else { - statusIcon = color('error', theme)(figures.cross) - statusText = 'failed' + statusIcon = color('error', theme)(figures.cross); + statusText = 'failed'; } // Indented MCPs (child of a plugin) if (item.indented) { return ( - - {isSelected ? `${figures.pointer} ` : ' '} - + {isSelected ? `${figures.pointer} ` : ' '} {item.name} @@ -131,14 +119,12 @@ export function UnifiedInstalledCell({ · {statusIcon} {statusText} - ) + ); } return ( - - {isSelected ? `${figures.pointer} ` : ' '} - + {isSelected ? `${figures.pointer} ` : ' '} {item.name} {' '} @@ -147,5 +133,5 @@ export function UnifiedInstalledCell({ · {statusIcon} {statusText} - ) + ); } diff --git a/src/commands/plugin/ValidatePlugin.tsx b/src/commands/plugin/ValidatePlugin.tsx index 9eb52d4b6..f2a7932f5 100644 --- a/src/commands/plugin/ValidatePlugin.tsx +++ b/src/commands/plugin/ValidatePlugin.tsx @@ -1,16 +1,16 @@ -import figures from 'figures' -import * as React from 'react' -import { useEffect } from 'react' -import { Box, Text } from '@anthropic/ink' -import { errorMessage } from '../../utils/errors.js' -import { logError } from '../../utils/log.js' -import { validateManifest } from '../../utils/plugins/validatePlugin.js' -import { plural } from '../../utils/stringUtils.js' +import figures from 'figures'; +import * as React from 'react'; +import { useEffect } from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { errorMessage } from '../../utils/errors.js'; +import { logError } from '../../utils/log.js'; +import { validateManifest } from '../../utils/plugins/validatePlugin.js'; +import { plural } from '../../utils/stringUtils.js'; type Props = { - onComplete: (result?: string) => void - path?: string -} + onComplete: (result?: string) => void; + path?: string; +}; export function ValidatePlugin({ onComplete, path }: Props): React.ReactNode { useEffect(() => { @@ -28,76 +28,74 @@ export function ValidatePlugin({ onComplete, path }: Props): React.ReactNode { 'or .claude-plugin/plugin.json (prefers marketplace if both exist).\n\n' + 'Or from the command line:\n' + ' claude plugin validate ', - ) - return + ); + return; } try { - const result = await validateManifest(path) + const result = await validateManifest(path); - let output = '' + let output = ''; // Add header - output += `Validating ${result.fileType} manifest: ${result.filePath}\n\n` + output += `Validating ${result.fileType} manifest: ${result.filePath}\n\n`; // Show errors if (result.errors.length > 0) { - output += `${figures.cross} Found ${result.errors.length} ${plural(result.errors.length, 'error')}:\n\n` + output += `${figures.cross} Found ${result.errors.length} ${plural(result.errors.length, 'error')}:\n\n`; result.errors.forEach(error => { - output += ` ${figures.pointer} ${error.path}: ${error.message}\n` - }) + output += ` ${figures.pointer} ${error.path}: ${error.message}\n`; + }); - output += '\n' + output += '\n'; } // Show warnings if (result.warnings.length > 0) { - output += `${figures.warning} Found ${result.warnings.length} ${plural(result.warnings.length, 'warning')}:\n\n` + output += `${figures.warning} Found ${result.warnings.length} ${plural(result.warnings.length, 'warning')}:\n\n`; result.warnings.forEach(warning => { - output += ` ${figures.pointer} ${warning.path}: ${warning.message}\n` - }) + output += ` ${figures.pointer} ${warning.path}: ${warning.message}\n`; + }); - output += '\n' + output += '\n'; } // Show success or failure if (result.success) { if (result.warnings.length > 0) { - output += `${figures.tick} Validation passed with warnings\n` + output += `${figures.tick} Validation passed with warnings\n`; } else { - output += `${figures.tick} Validation passed\n` + output += `${figures.tick} Validation passed\n`; } // Exit with code 0 (success) - process.exitCode = 0 + process.exitCode = 0; } else { - output += `${figures.cross} Validation failed\n` + output += `${figures.cross} Validation failed\n`; // Exit with code 1 (validation failure) - process.exitCode = 1 + process.exitCode = 1; } - onComplete(output) + onComplete(output); } catch (error) { // Exit with code 2 (unexpected error) - process.exitCode = 2 + process.exitCode = 2; - logError(error) + logError(error); - onComplete( - `${figures.cross} Unexpected error during validation: ${errorMessage(error)}`, - ) + onComplete(`${figures.cross} Unexpected error during validation: ${errorMessage(error)}`); } } - void runValidation() - }, [onComplete, path]) + void runValidation(); + }, [onComplete, path]); return ( Running validation... - ) + ); } diff --git a/src/commands/plugin/__tests__/parseArgs.test.ts b/src/commands/plugin/__tests__/parseArgs.test.ts index 7a08fd758..31eba8c41 100644 --- a/src/commands/plugin/__tests__/parseArgs.test.ts +++ b/src/commands/plugin/__tests__/parseArgs.test.ts @@ -1,147 +1,149 @@ -import { describe, expect, test } from "bun:test"; -import { parsePluginArgs } from "../parseArgs"; +import { describe, expect, test } from 'bun:test' +import { parsePluginArgs } from '../parseArgs' -describe("parsePluginArgs", () => { +describe('parsePluginArgs', () => { // No args test("returns { type: 'menu' } for undefined", () => { - expect(parsePluginArgs(undefined)).toEqual({ type: "menu" }); - }); + expect(parsePluginArgs(undefined)).toEqual({ type: 'menu' }) + }) test("returns { type: 'menu' } for empty string", () => { - expect(parsePluginArgs("")).toEqual({ type: "menu" }); - }); + expect(parsePluginArgs('')).toEqual({ type: 'menu' }) + }) test("returns { type: 'menu' } for whitespace only", () => { - expect(parsePluginArgs(" ")).toEqual({ type: "menu" }); - }); + expect(parsePluginArgs(' ')).toEqual({ type: 'menu' }) + }) // Help test("returns { type: 'help' } for 'help'", () => { - expect(parsePluginArgs("help")).toEqual({ type: "help" }); - }); + expect(parsePluginArgs('help')).toEqual({ type: 'help' }) + }) test("returns { type: 'help' } for '--help'", () => { - expect(parsePluginArgs("--help")).toEqual({ type: "help" }); - }); + expect(parsePluginArgs('--help')).toEqual({ type: 'help' }) + }) test("returns { type: 'help' } for '-h'", () => { - expect(parsePluginArgs("-h")).toEqual({ type: "help" }); - }); + expect(parsePluginArgs('-h')).toEqual({ type: 'help' }) + }) // Install test("parses 'install my-plugin' -> { type: 'install', plugin: 'my-plugin' }", () => { - expect(parsePluginArgs("install my-plugin")).toEqual({ - type: "install", - plugin: "my-plugin", - }); - }); + expect(parsePluginArgs('install my-plugin')).toEqual({ + type: 'install', + plugin: 'my-plugin', + }) + }) test("parses 'install my-plugin@github' with marketplace", () => { - expect(parsePluginArgs("install my-plugin@github")).toEqual({ - type: "install", - plugin: "my-plugin", - marketplace: "github", - }); - }); + expect(parsePluginArgs('install my-plugin@github')).toEqual({ + type: 'install', + plugin: 'my-plugin', + marketplace: 'github', + }) + }) test("parses 'install https://github.com/...' as URL marketplace", () => { - expect(parsePluginArgs("install https://github.com/plugins/my-plugin")).toEqual({ - type: "install", - marketplace: "https://github.com/plugins/my-plugin", - }); - }); + expect( + parsePluginArgs('install https://github.com/plugins/my-plugin'), + ).toEqual({ + type: 'install', + marketplace: 'https://github.com/plugins/my-plugin', + }) + }) test("parses 'i plugin' as install shorthand", () => { - expect(parsePluginArgs("i plugin")).toEqual({ - type: "install", - plugin: "plugin", - }); - }); + expect(parsePluginArgs('i plugin')).toEqual({ + type: 'install', + plugin: 'plugin', + }) + }) - test("install without target returns type only", () => { - expect(parsePluginArgs("install")).toEqual({ type: "install" }); - }); + test('install without target returns type only', () => { + expect(parsePluginArgs('install')).toEqual({ type: 'install' }) + }) // Uninstall test("returns { type: 'uninstall', plugin: '...' }", () => { - expect(parsePluginArgs("uninstall my-plugin")).toEqual({ - type: "uninstall", - plugin: "my-plugin", - }); - }); + expect(parsePluginArgs('uninstall my-plugin')).toEqual({ + type: 'uninstall', + plugin: 'my-plugin', + }) + }) // Enable/disable test("returns { type: 'enable', plugin: '...' }", () => { - expect(parsePluginArgs("enable my-plugin")).toEqual({ - type: "enable", - plugin: "my-plugin", - }); - }); + expect(parsePluginArgs('enable my-plugin')).toEqual({ + type: 'enable', + plugin: 'my-plugin', + }) + }) test("returns { type: 'disable', plugin: '...' }", () => { - expect(parsePluginArgs("disable my-plugin")).toEqual({ - type: "disable", - plugin: "my-plugin", - }); - }); + expect(parsePluginArgs('disable my-plugin')).toEqual({ + type: 'disable', + plugin: 'my-plugin', + }) + }) // Validate test("returns { type: 'validate', path: '...' }", () => { - expect(parsePluginArgs("validate /path/to/plugin")).toEqual({ - type: "validate", - path: "/path/to/plugin", - }); - }); + expect(parsePluginArgs('validate /path/to/plugin')).toEqual({ + type: 'validate', + path: '/path/to/plugin', + }) + }) // Manage test("returns { type: 'manage' }", () => { - expect(parsePluginArgs("manage")).toEqual({ type: "manage" }); - }); + expect(parsePluginArgs('manage')).toEqual({ type: 'manage' }) + }) // Marketplace test("parses 'marketplace add ...'", () => { - expect(parsePluginArgs("marketplace add https://example.com")).toEqual({ - type: "marketplace", - action: "add", - target: "https://example.com", - }); - }); + expect(parsePluginArgs('marketplace add https://example.com')).toEqual({ + type: 'marketplace', + action: 'add', + target: 'https://example.com', + }) + }) test("parses 'marketplace remove ...'", () => { - expect(parsePluginArgs("marketplace remove my-source")).toEqual({ - type: "marketplace", - action: "remove", - target: "my-source", - }); - }); + expect(parsePluginArgs('marketplace remove my-source')).toEqual({ + type: 'marketplace', + action: 'remove', + target: 'my-source', + }) + }) test("parses 'marketplace list'", () => { - expect(parsePluginArgs("marketplace list")).toEqual({ - type: "marketplace", - action: "list", - }); - }); + expect(parsePluginArgs('marketplace list')).toEqual({ + type: 'marketplace', + action: 'list', + }) + }) test("parses 'market' as alias for 'marketplace'", () => { - expect(parsePluginArgs("market list")).toEqual({ - type: "marketplace", - action: "list", - }); - }); + expect(parsePluginArgs('market list')).toEqual({ + type: 'marketplace', + action: 'list', + }) + }) // Boundary - test("handles extra whitespace", () => { - expect(parsePluginArgs(" install my-plugin ")).toEqual({ - type: "install", - plugin: "my-plugin", - }); - }); + test('handles extra whitespace', () => { + expect(parsePluginArgs(' install my-plugin ')).toEqual({ + type: 'install', + plugin: 'my-plugin', + }) + }) - test("handles unknown subcommand gracefully", () => { - expect(parsePluginArgs("foobar")).toEqual({ type: "menu" }); - }); + test('handles unknown subcommand gracefully', () => { + expect(parsePluginArgs('foobar')).toEqual({ type: 'menu' }) + }) - test("marketplace without action returns type only", () => { - expect(parsePluginArgs("marketplace")).toEqual({ type: "marketplace" }); - }); -}); + test('marketplace without action returns type only', () => { + expect(parsePluginArgs('marketplace')).toEqual({ type: 'marketplace' }) + }) +}) diff --git a/src/commands/plugin/index.tsx b/src/commands/plugin/index.tsx index 34d505bb5..781217f38 100644 --- a/src/commands/plugin/index.tsx +++ b/src/commands/plugin/index.tsx @@ -1,4 +1,4 @@ -import type { Command } from '../../commands.js' +import type { Command } from '../../commands.js'; const plugin = { type: 'local-jsx', @@ -7,6 +7,6 @@ const plugin = { description: 'Manage Claude Code plugins', immediate: true, load: () => import('./plugin.js'), -} satisfies Command +} satisfies Command; -export default plugin +export default plugin; diff --git a/src/commands/plugin/plugin.tsx b/src/commands/plugin/plugin.tsx index c19dadf58..33ed781fc 100644 --- a/src/commands/plugin/plugin.tsx +++ b/src/commands/plugin/plugin.tsx @@ -1,11 +1,7 @@ -import * as React from 'react' -import type { LocalJSXCommandOnDone } from '../../types/command.js' -import { PluginSettings } from './PluginSettings.js' +import * as React from 'react'; +import type { LocalJSXCommandOnDone } from '../../types/command.js'; +import { PluginSettings } from './PluginSettings.js'; -export async function call( - onDone: LocalJSXCommandOnDone, - _context: unknown, - args?: string, -): Promise { - return +export async function call(onDone: LocalJSXCommandOnDone, _context: unknown, args?: string): Promise { + return ; } diff --git a/src/commands/plugin/pluginDetailsHelpers.tsx b/src/commands/plugin/pluginDetailsHelpers.tsx index 2a9909e1a..7985239c7 100644 --- a/src/commands/plugin/pluginDetailsHelpers.tsx +++ b/src/commands/plugin/pluginDetailsHelpers.tsx @@ -4,28 +4,28 @@ * Used by both DiscoverPlugins and BrowseMarketplace components. */ -import * as React from 'react' -import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js' -import { Box, Byline, Text } from '@anthropic/ink' -import type { PluginMarketplaceEntry } from '../../utils/plugins/schemas.js' +import * as React from 'react'; +import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js'; +import { Box, Byline, Text } from '@anthropic/ink'; +import type { PluginMarketplaceEntry } from '../../utils/plugins/schemas.js'; /** * Represents a plugin available for installation from a marketplace */ export type InstallablePlugin = { - entry: PluginMarketplaceEntry - marketplaceName: string - pluginId: string - isInstalled: boolean -} + entry: PluginMarketplaceEntry; + marketplaceName: string; + pluginId: string; + isInstalled: boolean; +}; /** * Menu option for plugin details view */ export type PluginDetailsMenuOption = { - label: string - action: string -} + label: string; + action: string; +}; /** * Extract GitHub repo info from a plugin's source @@ -35,17 +35,13 @@ export function extractGitHubRepo(plugin: InstallablePlugin): string | null { plugin.entry.source && typeof plugin.entry.source === 'object' && 'source' in plugin.entry.source && - plugin.entry.source.source === 'github' + plugin.entry.source.source === 'github'; - if ( - isGitHub && - typeof plugin.entry.source === 'object' && - 'repo' in plugin.entry.source - ) { - return plugin.entry.source.repo + if (isGitHub && typeof plugin.entry.source === 'object' && 'repo' in plugin.entry.source) { + return plugin.entry.source.repo; } - return null + return null; } /** @@ -65,25 +61,21 @@ export function buildPluginDetailsMenuOptions( label: 'Install for you, in this repo only (local scope)', action: 'install-local', }, - ] + ]; if (hasHomepage) { - options.push({ label: 'Open homepage', action: 'homepage' }) + options.push({ label: 'Open homepage', action: 'homepage' }); } if (githubRepo) { - options.push({ label: 'View on GitHub', action: 'github' }) + options.push({ label: 'View on GitHub', action: 'github' }); } - options.push({ label: 'Back to plugin list', action: 'back' }) - return options + options.push({ label: 'Back to plugin list', action: 'back' }); + return options; } /** * Key hint component for plugin selection screens */ -export function PluginSelectionKeyHint({ - hasSelection, -}: { - hasSelection: boolean -}): React.ReactNode { +export function PluginSelectionKeyHint({ hasSelection }: { hasSelection: boolean }): React.ReactNode { return ( @@ -97,26 +89,11 @@ export function PluginSelectionKeyHint({ bold /> )} - - - + + + - ) + ); } diff --git a/src/commands/plugin/src/services/analytics/index.ts b/src/commands/plugin/src/services/analytics/index.ts index 142e7b6f5..2e0f796da 100644 --- a/src/commands/plugin/src/services/analytics/index.ts +++ b/src/commands/plugin/src/services/analytics/index.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any; -export type logEvent = any; +export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any +export type logEvent = any diff --git a/src/commands/plugin/types.ts b/src/commands/plugin/types.ts index 436f786ee..86e77fa74 100644 --- a/src/commands/plugin/types.ts +++ b/src/commands/plugin/types.ts @@ -1,3 +1,3 @@ // Auto-generated stub — replace with real implementation -export type ViewState = any; -export type PluginSettingsProps = any; +export type ViewState = any +export type PluginSettingsProps = any diff --git a/src/commands/plugin/unifiedTypes.ts b/src/commands/plugin/unifiedTypes.ts index 7100863b2..68b595f8b 100644 --- a/src/commands/plugin/unifiedTypes.ts +++ b/src/commands/plugin/unifiedTypes.ts @@ -1,2 +1,2 @@ // Auto-generated stub — replace with real implementation -export type UnifiedInstalledItem = any; +export type UnifiedInstalledItem = any diff --git a/src/commands/poor/index.ts b/src/commands/poor/index.ts index 0ae331a68..199c0447e 100644 --- a/src/commands/poor/index.ts +++ b/src/commands/poor/index.ts @@ -3,7 +3,8 @@ import type { Command } from '../../commands.js' const poor = { type: 'local', name: 'poor', - description: 'Toggle poor mode — disable extract_memories and prompt_suggestion to save tokens', + description: + 'Toggle poor mode — disable extract_memories and prompt_suggestion to save tokens', supportsNonInteractive: false, load: () => import('./poor.js'), } satisfies Command diff --git a/src/commands/poor/poorMode.ts b/src/commands/poor/poorMode.ts index 84f4ab857..d6ab2e99f 100644 --- a/src/commands/poor/poorMode.ts +++ b/src/commands/poor/poorMode.ts @@ -5,7 +5,10 @@ * Persisted to settings.json so it survives session restarts. */ -import { getInitialSettings, updateSettingsForSource } from '../../utils/settings/settings.js' +import { + getInitialSettings, + updateSettingsForSource, +} from '../../utils/settings/settings.js' let poorModeActive: boolean | null = null diff --git a/src/commands/privacy-settings/privacy-settings.tsx b/src/commands/privacy-settings/privacy-settings.tsx index e9ac86619..0ede11716 100644 --- a/src/commands/privacy-settings/privacy-settings.tsx +++ b/src/commands/privacy-settings/privacy-settings.tsx @@ -1,75 +1,56 @@ -import * as React from 'react' -import { - type GroveDecision, - GroveDialog, - PrivacySettingsDialog, -} from '../../components/grove/Grove.js' +import * as React from 'react'; +import { type GroveDecision, GroveDialog, PrivacySettingsDialog } from '../../components/grove/Grove.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from '../../services/analytics/index.js' -import { - getGroveNoticeConfig, - getGroveSettings, - isQualifiedForGrove, -} from '../../services/api/grove.js' -import type { LocalJSXCommandOnDone } from '../../types/command.js' +} from '../../services/analytics/index.js'; +import { getGroveNoticeConfig, getGroveSettings, isQualifiedForGrove } from '../../services/api/grove.js'; +import type { LocalJSXCommandOnDone } from '../../types/command.js'; -const FALLBACK_MESSAGE = - 'Review and manage your privacy settings at https://claude.ai/settings/data-privacy-controls' +const FALLBACK_MESSAGE = 'Review and manage your privacy settings at https://claude.ai/settings/data-privacy-controls'; -export async function call( - onDone: LocalJSXCommandOnDone, -): Promise { - const qualified = await isQualifiedForGrove() +export async function call(onDone: LocalJSXCommandOnDone): Promise { + const qualified = await isQualifiedForGrove(); if (!qualified) { - onDone(FALLBACK_MESSAGE) - return null + onDone(FALLBACK_MESSAGE); + return null; } - const [settingsResult, configResult] = await Promise.all([ - getGroveSettings(), - getGroveNoticeConfig(), - ]) + const [settingsResult, configResult] = await Promise.all([getGroveSettings(), getGroveNoticeConfig()]); // Hide dialog on API failure (after retry) if (!settingsResult.success) { - onDone(FALLBACK_MESSAGE) - return null + onDone(FALLBACK_MESSAGE); + return null; } - const settings = settingsResult.data - const config = configResult.success ? configResult.data : null + const settings = settingsResult.data; + const config = configResult.success ? configResult.data : null; async function onDoneWithDecision(decision: GroveDecision) { if (decision === 'escape' || decision === 'defer') { onDone('Privacy settings dialog dismissed', { display: 'system', - }) - return + }); + return; } - await onDoneWithSettingsCheck() + await onDoneWithSettingsCheck(); } async function onDoneWithSettingsCheck() { - const updatedSettingsResult = await getGroveSettings() + const updatedSettingsResult = await getGroveSettings(); if (!updatedSettingsResult.success) { onDone('Unable to retrieve updated privacy settings', { display: 'system', - }) - return + }); + return; } - const updatedSettings = updatedSettingsResult.data - const groveStatus = updatedSettings.grove_enabled ? 'true' : 'false' - onDone(`"Help improve Claude" set to ${groveStatus}.`) - if ( - settings.grove_enabled !== null && - settings.grove_enabled !== updatedSettings.grove_enabled - ) { + const updatedSettings = updatedSettingsResult.data; + const groveStatus = updatedSettings.grove_enabled ? 'true' : 'false'; + onDone(`"Help improve Claude" set to ${groveStatus}.`); + if (settings.grove_enabled !== null && settings.grove_enabled !== updatedSettings.grove_enabled) { logEvent('tengu_grove_policy_toggled', { - state: - updatedSettings.grove_enabled as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - location: - 'settings' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + state: updatedSettings.grove_enabled as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + location: 'settings' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); } } @@ -82,15 +63,9 @@ export async function call( domainExcluded={config?.domain_excluded} onDone={onDoneWithSettingsCheck} > - ) + ); } // Show the GroveDialog for users who haven't accepted terms yet - return ( - - ) + return ; } diff --git a/src/commands/provider.ts b/src/commands/provider.ts index 19b19c021..c2d43163f 100644 --- a/src/commands/provider.ts +++ b/src/commands/provider.ts @@ -26,7 +26,9 @@ function getEnvVarForProvider(provider: string): string { function getMergedEnv(): Record { const settings = getSettings_DEPRECATED() const merged: Record = Object.fromEntries( - Object.entries(process.env).filter((e): e is [string, string] => e[1] !== undefined) + Object.entries(process.env).filter( + (e): e is [string, string] => e[1] !== undefined, + ), ) if (settings?.env) { Object.assign(merged, settings.env) @@ -123,7 +125,12 @@ const call: LocalCommandCall = async (args, context) => { // Handle different provider types // - 'anthropic', 'openai', 'gemini' are stored in settings.json (persistent) // - 'bedrock', 'vertex', 'foundry' are env-only (do NOT touch settings.json) - if (arg === 'anthropic' || arg === 'openai' || arg === 'gemini' || arg === 'grok') { + if ( + arg === 'anthropic' || + arg === 'openai' || + arg === 'gemini' || + arg === 'grok' + ) { // Clear any cloud provider env vars to avoid conflicts delete process.env.CLAUDE_CODE_USE_BEDROCK delete process.env.CLAUDE_CODE_USE_VERTEX diff --git a/src/commands/rate-limit-options/rate-limit-options.tsx b/src/commands/rate-limit-options/rate-limit-options.tsx index 50198f561..736ef87b6 100644 --- a/src/commands/rate-limit-options/rate-limit-options.tsx +++ b/src/commands/rate-limit-options/rate-limit-options.tsx @@ -1,71 +1,50 @@ -import React, { useMemo, useState } from 'react' -import type { - CommandResultDisplay, - LocalJSXCommandContext, -} from '../../commands.js' -import { - type OptionWithDescription, - Select, -} from '../../components/CustomSelect/select.js' -import { Dialog } from '@anthropic/ink' -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' -import { logEvent } from '../../services/analytics/index.js' -import { useClaudeAiLimits } from '../../services/claudeAiLimitsHook.js' -import type { ToolUseContext } from '../../Tool.js' -import type { LocalJSXCommandOnDone } from '../../types/command.js' -import { - getOauthAccountInfo, - getRateLimitTier, - getSubscriptionType, -} from '../../utils/auth.js' -import { hasClaudeAiBillingAccess } from '../../utils/billing.js' -import { call as extraUsageCall } from '../extra-usage/extra-usage.js' -import { extraUsage } from '../extra-usage/index.js' -import upgrade from '../upgrade/index.js' -import { call as upgradeCall } from '../upgrade/upgrade.js' +import React, { useMemo, useState } from 'react'; +import type { CommandResultDisplay, LocalJSXCommandContext } from '../../commands.js'; +import { type OptionWithDescription, Select } from '../../components/CustomSelect/select.js'; +import { Dialog } from '@anthropic/ink'; +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'; +import { logEvent } from '../../services/analytics/index.js'; +import { useClaudeAiLimits } from '../../services/claudeAiLimitsHook.js'; +import type { ToolUseContext } from '../../Tool.js'; +import type { LocalJSXCommandOnDone } from '../../types/command.js'; +import { getOauthAccountInfo, getRateLimitTier, getSubscriptionType } from '../../utils/auth.js'; +import { hasClaudeAiBillingAccess } from '../../utils/billing.js'; +import { call as extraUsageCall } from '../extra-usage/extra-usage.js'; +import { extraUsage } from '../extra-usage/index.js'; +import upgrade from '../upgrade/index.js'; +import { call as upgradeCall } from '../upgrade/upgrade.js'; -type RateLimitOptionsMenuOptionType = 'upgrade' | 'extra-usage' | 'cancel' +type RateLimitOptionsMenuOptionType = 'upgrade' | 'extra-usage' | 'cancel'; type RateLimitOptionsMenuProps = { onDone: ( result?: string, options?: | { - display?: CommandResultDisplay | undefined + display?: CommandResultDisplay | undefined; } | undefined, - ) => void - context: ToolUseContext & LocalJSXCommandContext -} + ) => void; + context: ToolUseContext & LocalJSXCommandContext; +}; -function RateLimitOptionsMenu({ - onDone, - context, -}: RateLimitOptionsMenuProps): React.ReactNode { - const [subCommandJSX, setSubCommandJSX] = useState(null) - const claudeAiLimits = useClaudeAiLimits() - const subscriptionType = getSubscriptionType() - const rateLimitTier = getRateLimitTier() - const hasExtraUsageEnabled = - getOauthAccountInfo()?.hasExtraUsageEnabled === true - const isMax = subscriptionType === 'max' - const isMax20x = isMax && rateLimitTier === 'default_claude_max_20x' - const isTeamOrEnterprise = - subscriptionType === 'team' || subscriptionType === 'enterprise' - const buyFirst = getFeatureValue_CACHED_MAY_BE_STALE( - 'tengu_jade_anvil_4', - false, - ) +function RateLimitOptionsMenu({ onDone, context }: RateLimitOptionsMenuProps): React.ReactNode { + const [subCommandJSX, setSubCommandJSX] = useState(null); + const claudeAiLimits = useClaudeAiLimits(); + const subscriptionType = getSubscriptionType(); + const rateLimitTier = getRateLimitTier(); + const hasExtraUsageEnabled = getOauthAccountInfo()?.hasExtraUsageEnabled === true; + const isMax = subscriptionType === 'max'; + const isMax20x = isMax && rateLimitTier === 'default_claude_max_20x'; + const isTeamOrEnterprise = subscriptionType === 'team' || subscriptionType === 'enterprise'; + const buyFirst = getFeatureValue_CACHED_MAY_BE_STALE('tengu_jade_anvil_4', false); - const options = useMemo< - OptionWithDescription[] - >(() => { - const actionOptions: OptionWithDescription[] = - [] + const options = useMemo[]>(() => { + const actionOptions: OptionWithDescription[] = []; if (extraUsage.isEnabled()) { - const hasBillingAccess = hasClaudeAiBillingAccess() - const needsToRequestFromAdmin = isTeamOrEnterprise && !hasBillingAccess + const hasBillingAccess = hasClaudeAiBillingAccess(); + const needsToRequestFromAdmin = isTeamOrEnterprise && !hasBillingAccess; // Org spend cap depleted - non-admins can't request more since there's nothing to allocate // - out_of_credits: wallet empty // - org_level_disabled_until: org spend cap hit for the month @@ -73,29 +52,26 @@ function RateLimitOptionsMenu({ const isOrgSpendCapDepleted = claudeAiLimits.overageDisabledReason === 'out_of_credits' || claudeAiLimits.overageDisabledReason === 'org_level_disabled_until' || - claudeAiLimits.overageDisabledReason === 'org_service_zero_credit_limit' + claudeAiLimits.overageDisabledReason === 'org_service_zero_credit_limit'; // Hide for non-admin Team/Enterprise users when org spend cap is depleted if (needsToRequestFromAdmin && isOrgSpendCapDepleted) { // Don't show extra-usage option } else { const isOverageState = - claudeAiLimits.overageStatus === 'rejected' || - claudeAiLimits.overageStatus === 'allowed_warning' + claudeAiLimits.overageStatus === 'rejected' || claudeAiLimits.overageStatus === 'allowed_warning'; - let label: string + let label: string; if (needsToRequestFromAdmin) { - label = isOverageState ? 'Request more' : 'Request extra usage' + label = isOverageState ? 'Request more' : 'Request extra usage'; } else { - label = hasExtraUsageEnabled - ? 'Add funds to continue with extra usage' - : 'Switch to extra usage' + label = hasExtraUsageEnabled ? 'Add funds to continue with extra usage' : 'Switch to extra usage'; } actionOptions.push({ label, value: 'extra-usage', - }) + }); } } @@ -103,19 +79,18 @@ function RateLimitOptionsMenu({ actionOptions.push({ label: 'Upgrade your plan', value: 'upgrade', - }) + }); } - const cancelOption: OptionWithDescription = - { - label: 'Stop and wait for limit to reset', - value: 'cancel', - } + const cancelOption: OptionWithDescription = { + label: 'Stop and wait for limit to reset', + value: 'cancel', + }; if (buyFirst) { - return [...actionOptions, cancelOption] + return [...actionOptions, cancelOption]; } - return [cancelOption, ...actionOptions] + return [cancelOption, ...actionOptions]; }, [ buyFirst, isMax20x, @@ -123,55 +98,51 @@ function RateLimitOptionsMenu({ hasExtraUsageEnabled, claudeAiLimits.overageStatus, claudeAiLimits.overageDisabledReason, - ]) + ]); function handleCancel(): void { - logEvent('tengu_rate_limit_options_menu_cancel', {}) - onDone(undefined, { display: 'skip' }) + logEvent('tengu_rate_limit_options_menu_cancel', {}); + onDone(undefined, { display: 'skip' }); } function handleSelect(value: RateLimitOptionsMenuOptionType): void { if (value === 'upgrade') { - logEvent('tengu_rate_limit_options_menu_select_upgrade', {}) + logEvent('tengu_rate_limit_options_menu_select_upgrade', {}); void upgradeCall(onDone, context).then(jsx => { if (jsx) { - setSubCommandJSX(jsx) + setSubCommandJSX(jsx); } - }) + }); } else if (value === 'extra-usage') { - logEvent('tengu_rate_limit_options_menu_select_extra_usage', {}) + logEvent('tengu_rate_limit_options_menu_select_extra_usage', {}); void extraUsageCall(onDone, context).then(jsx => { if (jsx) { - setSubCommandJSX(jsx) + setSubCommandJSX(jsx); } - }) + }); } else if (value === 'cancel') { - handleCancel() + handleCancel(); } } if (subCommandJSX) { - return subCommandJSX + return subCommandJSX; } return ( - + options={options} onChange={handleSelect} visibleOptionCount={options.length} /> - ) + ); } export async function call( onDone: LocalJSXCommandOnDone, context: ToolUseContext & LocalJSXCommandContext, ): Promise { - return + return ; } diff --git a/src/commands/remote-env/remote-env.tsx b/src/commands/remote-env/remote-env.tsx index 1c5f3feb6..f08a37a09 100644 --- a/src/commands/remote-env/remote-env.tsx +++ b/src/commands/remote-env/remote-env.tsx @@ -1,9 +1,7 @@ -import * as React from 'react' -import { RemoteEnvironmentDialog } from '../../components/RemoteEnvironmentDialog.js' -import type { LocalJSXCommandOnDone } from '../../types/command.js' +import * as React from 'react'; +import { RemoteEnvironmentDialog } from '../../components/RemoteEnvironmentDialog.js'; +import type { LocalJSXCommandOnDone } from '../../types/command.js'; -export async function call( - onDone: LocalJSXCommandOnDone, -): Promise { - return +export async function call(onDone: LocalJSXCommandOnDone): Promise { + return ; } diff --git a/src/commands/remote-setup/remote-setup.tsx b/src/commands/remote-setup/remote-setup.tsx index 3a68f2908..e1d42740c 100644 --- a/src/commands/remote-setup/remote-setup.tsx +++ b/src/commands/remote-setup/remote-setup.tsx @@ -1,15 +1,15 @@ -import { execa } from 'execa' -import * as React from 'react' -import { useEffect, useState } from 'react' -import { Select } from '../../components/CustomSelect/index.js' -import { Box, Dialog, LoadingState, Text } from '@anthropic/ink' +import { execa } from 'execa'; +import * as React from 'react'; +import { useEffect, useState } from 'react'; +import { Select } from '../../components/CustomSelect/index.js'; +import { Box, Dialog, LoadingState, Text } from '@anthropic/ink'; import { logEvent, type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS as SafeString, -} from '../../services/analytics/index.js' -import type { LocalJSXCommandOnDone } from '../../types/command.js' -import { openBrowser } from '../../utils/browser.js' -import { getGhAuthStatus } from '../../utils/github/ghAuthStatus.js' +} from '../../services/analytics/index.js'; +import type { LocalJSXCommandOnDone } from '../../types/command.js'; +import { openBrowser } from '../../utils/browser.js'; +import { getGhAuthStatus } from '../../utils/github/ghAuthStatus.js'; import { createDefaultEnvironment, getCodeWebUrl, @@ -17,25 +17,25 @@ import { importGithubToken, isSignedIn, RedactedGithubToken, -} from './api.js' +} from './api.js'; type CheckResult = | { status: 'not_signed_in' } | { status: 'has_gh_token'; token: RedactedGithubToken } | { status: 'gh_not_installed' } - | { status: 'gh_not_authenticated' } + | { status: 'gh_not_authenticated' }; async function checkLoginState(): Promise { if (!(await isSignedIn())) { - return { status: 'not_signed_in' } + return { status: 'not_signed_in' }; } - const ghStatus = await getGhAuthStatus() + const ghStatus = await getGhAuthStatus(); if (ghStatus === 'not_installed') { - return { status: 'gh_not_installed' } + return { status: 'gh_not_installed' }; } if (ghStatus === 'not_authenticated') { - return { status: 'gh_not_authenticated' } + return { status: 'gh_not_authenticated' }; } // ghStatus === 'authenticated'. getGhAuthStatus spawns with stdout:'ignore' @@ -45,125 +45,113 @@ async function checkLoginState(): Promise { stderr: 'ignore', timeout: 5000, reject: false, - }) - const trimmed = stdout.trim() + }); + const trimmed = stdout.trim(); if (!trimmed) { - return { status: 'gh_not_authenticated' } + return { status: 'gh_not_authenticated' }; } - return { status: 'has_gh_token', token: new RedactedGithubToken(trimmed) } + return { status: 'has_gh_token', token: new RedactedGithubToken(trimmed) }; } function errorMessage(err: ImportTokenError, codeUrl: string): string { switch (err.kind) { case 'not_signed_in': - return `Login failed. Please visit ${codeUrl} and login using the GitHub App` + return `Login failed. Please visit ${codeUrl} and login using the GitHub App`; case 'invalid_token': - return 'GitHub rejected that token. Run `gh auth login` and try again.' + return 'GitHub rejected that token. Run `gh auth login` and try again.'; case 'server': - return `Server error (${err.status}). Try again in a moment.` + return `Server error (${err.status}). Try again in a moment.`; case 'network': - return "Couldn't reach the server. Check your connection." + return "Couldn't reach the server. Check your connection."; } } -type Step = - | { name: 'checking' } - | { name: 'confirm'; token: RedactedGithubToken } - | { name: 'uploading' } +type Step = { name: 'checking' } | { name: 'confirm'; token: RedactedGithubToken } | { name: 'uploading' }; function Web({ onDone }: { onDone: LocalJSXCommandOnDone }) { - const [step, setStep] = useState({ name: 'checking' }) + const [step, setStep] = useState({ name: 'checking' }); useEffect(() => { - logEvent('tengu_remote_setup_started', {}) + logEvent('tengu_remote_setup_started', {}); void checkLoginState().then(async result => { switch (result.status) { case 'not_signed_in': logEvent('tengu_remote_setup_result', { result: 'not_signed_in' as SafeString, - }) - onDone('Not signed in to Claude. Run /login first.') - return + }); + onDone('Not signed in to Claude. Run /login first.'); + return; case 'gh_not_installed': case 'gh_not_authenticated': { - const url = `${getCodeWebUrl()}/onboarding?step=alt-auth` - await openBrowser(url) + const url = `${getCodeWebUrl()}/onboarding?step=alt-auth`; + await openBrowser(url); logEvent('tengu_remote_setup_result', { result: result.status as SafeString, - }) + }); onDone( result.status === 'gh_not_installed' ? `GitHub CLI not found. Install it via https://cli.github.com/, then run \`gh auth login\`, or connect GitHub on the web: ${url}` : `GitHub CLI not authenticated. Run \`gh auth login\` and try again, or connect GitHub on the web: ${url}`, - ) - return + ); + return; } case 'has_gh_token': - setStep({ name: 'confirm', token: result.token }) + setStep({ name: 'confirm', token: result.token }); } - }) + }); // onDone is stable across renders; intentionally not in deps. // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, []); const handleCancel = () => { logEvent('tengu_remote_setup_result', { result: 'cancelled' as SafeString, - }) - onDone() - } + }); + onDone(); + }; const handleConfirm = async (token: RedactedGithubToken) => { - setStep({ name: 'uploading' }) + setStep({ name: 'uploading' }); - const result = await importGithubToken(token) + const result = await importGithubToken(token); if (!result.ok) { - const err = (result as { ok: false; error: ImportTokenError }).error + const err = (result as { ok: false; error: ImportTokenError }).error; logEvent('tengu_remote_setup_result', { result: 'import_failed' as SafeString, error_kind: err.kind as SafeString, - }) - onDone(errorMessage(err, getCodeWebUrl())) - return + }); + onDone(errorMessage(err, getCodeWebUrl())); + return; } // Token import succeeded. Environment creation is best-effort — if it // fails, the web state machine routes to env-setup on landing, which is // one extra click but still better than the OAuth dance. - await createDefaultEnvironment() + await createDefaultEnvironment(); - const url = getCodeWebUrl() - await openBrowser(url) + const url = getCodeWebUrl(); + await openBrowser(url); logEvent('tengu_remote_setup_result', { result: 'success' as SafeString, - }) - onDone(`Connected as ${result.result.github_username}. Opened ${url}`) - } + }); + onDone(`Connected as ${result.result.github_username}. Opened ${url}`); + }; if (step.name === 'checking') { - return + return ; } if (step.name === 'uploading') { - return + return ; } - const token = step.token + const token = step.token; return ( - + - - Claude on the web requires connecting to your GitHub account to clone - and push code on your behalf. - - - Your local credentials are used to authenticate with GitHub - + Claude on the web requires connecting to your GitHub account to clone and push code on your behalf. + Your local credentials are used to authenticate with GitHub + + - ) + ); } diff --git a/src/components/ClaudeCodeHint/PluginHintMenu.tsx b/src/components/ClaudeCodeHint/PluginHintMenu.tsx index e461afb77..71fc91203 100644 --- a/src/components/ClaudeCodeHint/PluginHintMenu.tsx +++ b/src/components/ClaudeCodeHint/PluginHintMenu.tsx @@ -1,17 +1,17 @@ -import * as React from 'react' -import { Box, Text } from '@anthropic/ink' -import { Select } from '../CustomSelect/select.js' -import { PermissionDialog } from '../permissions/PermissionDialog.js' +import * as React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { Select } from '../CustomSelect/select.js'; +import { PermissionDialog } from '../permissions/PermissionDialog.js'; type Props = { - pluginName: string - pluginDescription?: string - marketplaceName: string - sourceCommand: string - onResponse: (response: 'yes' | 'no' | 'disable') => void -} + pluginName: string; + pluginDescription?: string; + marketplaceName: string; + sourceCommand: string; + onResponse: (response: 'yes' | 'no' | 'disable') => void; +}; -const AUTO_DISMISS_MS = 30_000 +const AUTO_DISMISS_MS = 30_000; export function PluginHintMenu({ pluginName, @@ -20,28 +20,24 @@ export function PluginHintMenu({ sourceCommand, onResponse, }: Props): React.ReactNode { - const onResponseRef = React.useRef(onResponse) - onResponseRef.current = onResponse + const onResponseRef = React.useRef(onResponse); + onResponseRef.current = onResponse; React.useEffect(() => { - const timeoutId = setTimeout( - ref => ref.current('no'), - AUTO_DISMISS_MS, - onResponseRef, - ) - return () => clearTimeout(timeoutId) - }, []) + const timeoutId = setTimeout(ref => ref.current('no'), AUTO_DISMISS_MS, onResponseRef); + return () => clearTimeout(timeoutId); + }, []); function onSelect(value: string): void { switch (value) { case 'yes': - onResponse('yes') - break + onResponse('yes'); + break; case 'disable': - onResponse('disable') - break + onResponse('disable'); + break; default: - onResponse('no') + onResponse('no'); } } @@ -62,15 +58,14 @@ export function PluginHintMenu({ label: "No, and don't show plugin installation hints again", value: 'disable', }, - ] + ]; return ( - The {sourceCommand} command suggests installing a - plugin. + The {sourceCommand} command suggests installing a plugin. @@ -90,13 +85,9 @@ export function PluginHintMenu({ Would you like to install it? - onResponse('no')} /> - ) + ); } diff --git a/src/components/ClaudeInChromeOnboarding.tsx b/src/components/ClaudeInChromeOnboarding.tsx index ad2ca096c..a722010cb 100644 --- a/src/components/ClaudeInChromeOnboarding.tsx +++ b/src/components/ClaudeInChromeOnboarding.tsx @@ -1,61 +1,54 @@ -import React from 'react' -import { logEvent } from 'src/services/analytics/index.js' +import React from 'react'; +import { logEvent } from 'src/services/analytics/index.js'; // eslint-disable-next-line custom-rules/prefer-use-keybindings -- enter to continue -import { Box, Dialog, Link, Newline, Text, useInput } from '@anthropic/ink' -import { isChromeExtensionInstalled } from '../utils/claudeInChrome/setup.js' -import { saveGlobalConfig } from '../utils/config.js' +import { Box, Dialog, Link, Newline, Text, useInput } from '@anthropic/ink'; +import { isChromeExtensionInstalled } from '../utils/claudeInChrome/setup.js'; +import { saveGlobalConfig } from '../utils/config.js'; -const CHROME_EXTENSION_URL = 'https://claude.ai/chrome' -const CHROME_PERMISSIONS_URL = 'https://clau.de/chrome/permissions' +const CHROME_EXTENSION_URL = 'https://claude.ai/chrome'; +const CHROME_PERMISSIONS_URL = 'https://clau.de/chrome/permissions'; type Props = { - onDone(): void -} + onDone(): void; +}; export function ClaudeInChromeOnboarding({ onDone }: Props): React.ReactNode { - const [isExtensionInstalled, setIsExtensionInstalled] = React.useState(false) + const [isExtensionInstalled, setIsExtensionInstalled] = React.useState(false); React.useEffect(() => { - logEvent('tengu_claude_in_chrome_onboarding_shown', {}) - void isChromeExtensionInstalled().then(setIsExtensionInstalled) + logEvent('tengu_claude_in_chrome_onboarding_shown', {}); + void isChromeExtensionInstalled().then(setIsExtensionInstalled); saveGlobalConfig(current => { - return { ...current, hasCompletedClaudeInChromeOnboarding: true } - }) - }, []) + return { ...current, hasCompletedClaudeInChromeOnboarding: true }; + }); + }, []); // Handle Enter to continue useInput((_input, key) => { if (key.return) { - onDone() + onDone(); } - }) + }); return ( - + - Claude in Chrome works with the Chrome extension to let you control - your browser directly from Claude Code. You can navigate websites, - fill forms, capture screenshots, record GIFs, and debug with console - logs and network requests. + Claude in Chrome works with the Chrome extension to let you control your browser directly from Claude Code. + You can navigate websites, fill forms, capture screenshots, record GIFs, and debug with console logs and + network requests. {!isExtensionInstalled && ( <> - Requires the Chrome extension. Get started at{' '} - + Requires the Chrome extension. Get started at )} - Site-level permissions are inherited from the Chrome extension. Manage - permissions in the Chrome extension settings to control which sites - Claude can browse, click, and type on + Site-level permissions are inherited from the Chrome extension. Manage permissions in the Chrome extension + settings to control which sites Claude can browse, click, and type on {isExtensionInstalled && ( <> {' '} @@ -73,5 +66,5 @@ export function ClaudeInChromeOnboarding({ onDone }: Props): React.ReactNode { - ) + ); } diff --git a/src/components/ClaudeMdExternalIncludesDialog.tsx b/src/components/ClaudeMdExternalIncludesDialog.tsx index 2614010ca..2a7756d3b 100644 --- a/src/components/ClaudeMdExternalIncludesDialog.tsx +++ b/src/components/ClaudeMdExternalIncludesDialog.tsx @@ -1,15 +1,15 @@ -import React, { useCallback } from 'react' -import { logEvent } from 'src/services/analytics/index.js' -import { Box, Dialog, Link, Text } from '@anthropic/ink' -import type { ExternalClaudeMdInclude } from '../utils/claudemd.js' -import { saveCurrentProjectConfig } from '../utils/config.js' -import { Select } from './CustomSelect/index.js' +import React, { useCallback } from 'react'; +import { logEvent } from 'src/services/analytics/index.js'; +import { Box, Dialog, Link, Text } from '@anthropic/ink'; +import type { ExternalClaudeMdInclude } from '../utils/claudemd.js'; +import { saveCurrentProjectConfig } from '../utils/config.js'; +import { Select } from './CustomSelect/index.js'; type Props = { - onDone(): void - isStandaloneDialog?: boolean - externalIncludes?: ExternalClaudeMdInclude[] -} + onDone(): void; + isStandaloneDialog?: boolean; + externalIncludes?: ExternalClaudeMdInclude[]; +}; export function ClaudeMdExternalIncludesDialog({ onDone, @@ -18,36 +18,36 @@ export function ClaudeMdExternalIncludesDialog({ }: Props): React.ReactNode { React.useEffect(() => { // Log when dialog is shown - logEvent('tengu_claude_md_includes_dialog_shown', {}) - }, []) + logEvent('tengu_claude_md_includes_dialog_shown', {}); + }, []); const handleSelection = useCallback( (value: 'yes' | 'no') => { if (value === 'no') { - logEvent('tengu_claude_md_external_includes_dialog_declined', {}) + logEvent('tengu_claude_md_external_includes_dialog_declined', {}); // Mark that we've shown the dialog but it was declined saveCurrentProjectConfig(current => ({ ...current, hasClaudeMdExternalIncludesApproved: false, hasClaudeMdExternalIncludesWarningShown: true, - })) + })); } else { - logEvent('tengu_claude_md_external_includes_dialog_accepted', {}) + logEvent('tengu_claude_md_external_includes_dialog_accepted', {}); saveCurrentProjectConfig(current => ({ ...current, hasClaudeMdExternalIncludesApproved: true, hasClaudeMdExternalIncludesWarningShown: true, - })) + })); } - onDone() + onDone(); }, [onDone], - ) + ); const handleEscape = useCallback(() => { - handleSelection('no') - }, [handleSelection]) + handleSelection('no'); + }, [handleSelection]); return ( - This project's CLAUDE.md imports files outside the current working - directory. Never allow this for third-party repositories. + This project's CLAUDE.md imports files outside the current working directory. Never allow this for + third-party repositories. {externalIncludes && externalIncludes.length > 0 && ( @@ -75,8 +75,7 @@ export function ClaudeMdExternalIncludesDialog({ )} - Important: Only use Claude Code with files you trust. Accessing - untrusted files may pose security risks{' '} + Important: Only use Claude Code with files you trust. Accessing untrusted files may pose security risks{' '} {' '} @@ -88,5 +87,5 @@ export function ClaudeMdExternalIncludesDialog({ onChange={value => handleSelection(value as 'yes' | 'no')} /> - ) + ); } diff --git a/src/components/ClickableImageRef.tsx b/src/components/ClickableImageRef.tsx index 2aa6485b6..803bf291b 100644 --- a/src/components/ClickableImageRef.tsx +++ b/src/components/ClickableImageRef.tsx @@ -1,14 +1,14 @@ -import * as React from 'react' -import { pathToFileURL } from 'url' -import { Link, supportsHyperlinks, Text } from '@anthropic/ink' -import { getStoredImagePath } from '../utils/imageStore.js' -import type { Theme } from '../utils/theme.js' +import * as React from 'react'; +import { pathToFileURL } from 'url'; +import { Link, supportsHyperlinks, Text } from '@anthropic/ink'; +import { getStoredImagePath } from '../utils/imageStore.js'; +import type { Theme } from '../utils/theme.js'; type Props = { - imageId: number - backgroundColor?: keyof Theme - isSelected?: boolean -} + imageId: number; + backgroundColor?: keyof Theme; + isSelected?: boolean; +}; /** * Renders an image reference like [Image #1] as a clickable link. @@ -18,17 +18,13 @@ type Props = { * - Terminal doesn't support hyperlinks * - Image file is not found in the store */ -export function ClickableImageRef({ - imageId, - backgroundColor, - isSelected = false, -}: Props): React.ReactNode { - const imagePath = getStoredImagePath(imageId) - const displayText = `[Image #${imageId}]` +export function ClickableImageRef({ imageId, backgroundColor, isSelected = false }: Props): React.ReactNode { + const imagePath = getStoredImagePath(imageId); + const displayText = `[Image #${imageId}]`; // If we have a stored image and terminal supports hyperlinks, make it clickable if (imagePath && supportsHyperlinks()) { - const fileUrl = pathToFileURL(imagePath).href + const fileUrl = pathToFileURL(imagePath).href; return ( } > - + {displayText} - ) + ); } // Fallback: styled but not clickable @@ -55,5 +47,5 @@ export function ClickableImageRef({ {displayText} - ) + ); } diff --git a/src/components/CompactSummary.tsx b/src/components/CompactSummary.tsx index 5d582a299..895c69eea 100644 --- a/src/components/CompactSummary.tsx +++ b/src/components/CompactSummary.tsx @@ -1,25 +1,27 @@ -import * as React from 'react' -import { BLACK_CIRCLE } from '../constants/figures.js' -import { Box, Text } from '@anthropic/ink' -import type { Screen } from '../screens/REPL.js' -import type { NormalizedUserMessage } from '../types/message.js' -import { getUserMessageText } from '../utils/messages.js' -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' -import { MessageResponse } from './MessageResponse.js' +import * as React from 'react'; +import { BLACK_CIRCLE } from '../constants/figures.js'; +import { Box, Text } from '@anthropic/ink'; +import type { Screen } from '../screens/REPL.js'; +import type { NormalizedUserMessage } from '../types/message.js'; +import { getUserMessageText } from '../utils/messages.js'; +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; +import { MessageResponse } from './MessageResponse.js'; type Props = { - message: NormalizedUserMessage - screen: Screen -} + message: NormalizedUserMessage; + screen: Screen; +}; export function CompactSummary({ message, screen }: Props): React.ReactNode { - const isTranscriptMode = screen === 'transcript' - const textContent = getUserMessageText(message) || '' - const metadata = message.summarizeMetadata as { - messagesSummarized?: number - direction?: string - userContext?: string - } | undefined + const isTranscriptMode = screen === 'transcript'; + const textContent = getUserMessageText(message) || ''; + const metadata = message.summarizeMetadata as + | { + messagesSummarized?: number; + direction?: string; + userContext?: string; + } + | undefined; // "Summarize from here" with metadata if (metadata) { @@ -36,9 +38,7 @@ export function CompactSummary({ message, screen }: Props): React.ReactNode { Summarized {metadata.messagesSummarized} messages{' '} - {metadata.direction === 'up_to' - ? 'up to this point' - : 'from this point'} + {metadata.direction === 'up_to' ? 'up to this point' : 'from this point'} {metadata.userContext && ( @@ -67,7 +67,7 @@ export function CompactSummary({ message, screen }: Props): React.ReactNode { - ) + ); } // Default compact summary (auto-compact) @@ -101,5 +101,5 @@ export function CompactSummary({ message, screen }: Props): React.ReactNode { )} - ) + ); } diff --git a/src/components/ConfigurableShortcutHint.tsx b/src/components/ConfigurableShortcutHint.tsx index 36ec0acd9..6fff0db94 100644 --- a/src/components/ConfigurableShortcutHint.tsx +++ b/src/components/ConfigurableShortcutHint.tsx @@ -1,25 +1,22 @@ -import * as React from 'react' -import type { - KeybindingAction, - KeybindingContextName, -} from '../keybindings/types.js' -import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js' -import { KeyboardShortcutHint } from '@anthropic/ink' +import * as React from 'react'; +import type { KeybindingAction, KeybindingContextName } from '../keybindings/types.js'; +import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'; +import { KeyboardShortcutHint } from '@anthropic/ink'; type Props = { /** The keybinding action (e.g., 'app:toggleTranscript') */ - action: KeybindingAction + action: KeybindingAction; /** The keybinding context (e.g., 'Global') */ - context: KeybindingContextName + context: KeybindingContextName; /** Default shortcut if keybinding not configured */ - fallback: string + fallback: string; /** The action description text (e.g., 'expand') */ - description: string + description: string; /** Whether to wrap in parentheses */ - parens?: boolean + parens?: boolean; /** Whether to show in bold */ - bold?: boolean -} + bold?: boolean; +}; /** * KeyboardShortcutHint that displays the user-configured shortcut. @@ -41,13 +38,6 @@ export function ConfigurableShortcutHint({ parens, bold, }: Props): React.ReactNode { - const shortcut = useShortcutDisplay(action, context, fallback) - return ( - - ) + const shortcut = useShortcutDisplay(action, context, fallback); + return ; } diff --git a/src/components/ConsoleOAuthFlow.tsx b/src/components/ConsoleOAuthFlow.tsx index bd1dd5d1e..c3bae5708 100644 --- a/src/components/ConsoleOAuthFlow.tsx +++ b/src/components/ConsoleOAuthFlow.tsx @@ -1,59 +1,59 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react' +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from 'src/services/analytics/index.js' -import { installOAuthTokens } from '../cli/handlers/auth.js' -import { useTerminalSize } from '../hooks/useTerminalSize.js' -import { setClipboard, useTerminalNotification, Box, Link, Text, KeyboardShortcutHint } from '@anthropic/ink' -import { useKeybinding } from '../keybindings/useKeybinding.js' -import { getSSLErrorHint } from '../services/api/errorUtils.js' -import { sendNotification } from '../services/notifier.js' -import { OAuthService } from '../services/oauth/index.js' -import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js' -import { logError } from '../utils/log.js' -import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js' -import { Select } from './CustomSelect/select.js' -import { Spinner } from './Spinner.js' -import TextInput from './TextInput.js' -import { fi } from 'zod/v4/locales' +} from 'src/services/analytics/index.js'; +import { installOAuthTokens } from '../cli/handlers/auth.js'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { setClipboard, useTerminalNotification, Box, Link, Text, KeyboardShortcutHint } from '@anthropic/ink'; +import { useKeybinding } from '../keybindings/useKeybinding.js'; +import { getSSLErrorHint } from '../services/api/errorUtils.js'; +import { sendNotification } from '../services/notifier.js'; +import { OAuthService } from '../services/oauth/index.js'; +import { getOauthAccountInfo, validateForceLoginOrg } from '../utils/auth.js'; +import { logError } from '../utils/log.js'; +import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js'; +import { Select } from './CustomSelect/select.js'; +import { Spinner } from './Spinner.js'; +import TextInput from './TextInput.js'; +import { fi } from 'zod/v4/locales'; type Props = { - onDone(): void - startingMessage?: string - mode?: 'login' | 'setup-token' - forceLoginMethod?: 'claudeai' | 'console' -} + onDone(): void; + startingMessage?: string; + mode?: 'login' | 'setup-token'; + forceLoginMethod?: 'claudeai' | 'console'; +}; type OAuthStatus = | { state: 'idle' } // Initial state, waiting to select login method | { state: 'platform_setup' } // Show platform setup info (Bedrock/Vertex/Foundry) | { - state: 'custom_platform' - baseUrl: string - apiKey: string - haikuModel: string - sonnetModel: string - opusModel: string - activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model' + state: 'custom_platform'; + baseUrl: string; + apiKey: string; + haikuModel: string; + sonnetModel: string; + opusModel: string; + activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; } // Custom platform: configure API endpoint and model names | { - state: 'openai_chat_api' - baseUrl: string - apiKey: string - haikuModel: string - sonnetModel: string - opusModel: string - activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model' + state: 'openai_chat_api'; + baseUrl: string; + apiKey: string; + haikuModel: string; + sonnetModel: string; + opusModel: string; + activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; } // OpenAI Chat Completions API platform | { - state: 'gemini_api' - baseUrl: string - apiKey: string - haikuModel: string - sonnetModel: string - opusModel: string - activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model' + state: 'gemini_api'; + baseUrl: string; + apiKey: string; + haikuModel: string; + sonnetModel: string; + opusModel: string; + activeField: 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; } // Gemini Generate Content API platform | { state: 'ready_to_start' } // Flow started, waiting for browser to open | { state: 'waiting_for_login'; url: string } // Browser opened, waiting for user to login @@ -61,170 +61,165 @@ type OAuthStatus = | { state: 'about_to_retry'; nextState: OAuthStatus } | { state: 'success'; token?: string } | { - state: 'error' - message: string - toRetry?: OAuthStatus - } + state: 'error'; + message: string; + toRetry?: OAuthStatus; + }; -const PASTE_HERE_MSG = 'Paste code here if prompted > ' +const PASTE_HERE_MSG = 'Paste code here if prompted > '; export function ConsoleOAuthFlow({ onDone, startingMessage, mode = 'login', forceLoginMethod: forceLoginMethodProp, }: Props): React.ReactNode { - const settings = getSettings_DEPRECATED() || {} - const forceLoginMethod = forceLoginMethodProp ?? settings.forceLoginMethod - const orgUUID = settings.forceLoginOrgUUID + const settings = getSettings_DEPRECATED() || {}; + const forceLoginMethod = forceLoginMethodProp ?? settings.forceLoginMethod; + const orgUUID = settings.forceLoginOrgUUID; const forcedMethodMessage = forceLoginMethod === 'claudeai' ? 'Login method pre-selected: Subscription Plan (Claude Pro/Max)' : forceLoginMethod === 'console' ? 'Login method pre-selected: API Usage Billing (Anthropic Console)' - : null + : null; - const terminal = useTerminalNotification() + const terminal = useTerminalNotification(); const [oauthStatus, setOAuthStatus] = useState(() => { if (mode === 'setup-token') { - return { state: 'ready_to_start' } + return { state: 'ready_to_start' }; } if (forceLoginMethod === 'claudeai' || forceLoginMethod === 'console') { - return { state: 'ready_to_start' } + return { state: 'ready_to_start' }; } - return { state: 'idle' } - }) + return { state: 'idle' }; + }); - const [pastedCode, setPastedCode] = useState('') - const [cursorOffset, setCursorOffset] = useState(0) - const [oauthService] = useState(() => new OAuthService()) + const [pastedCode, setPastedCode] = useState(''); + const [cursorOffset, setCursorOffset] = useState(0); + const [oauthService] = useState(() => new OAuthService()); const [loginWithClaudeAi, setLoginWithClaudeAi] = useState(() => { // Use Claude AI auth for setup-token mode to support user:inference scope - return mode === 'setup-token' || forceLoginMethod === 'claudeai' - }) + return mode === 'setup-token' || forceLoginMethod === 'claudeai'; + }); // After a few seconds we suggest the user to copy/paste url if the // browser did not open automatically. In this flow we expect the user to // copy the code from the browser and paste it in the terminal - const [showPastePrompt, setShowPastePrompt] = useState(false) - const [urlCopied, setUrlCopied] = useState(false) + const [showPastePrompt, setShowPastePrompt] = useState(false); + const [urlCopied, setUrlCopied] = useState(false); - const textInputColumns = useTerminalSize().columns - PASTE_HERE_MSG.length - 1 + const textInputColumns = useTerminalSize().columns - PASTE_HERE_MSG.length - 1; // Log forced login method on mount useEffect(() => { if (forceLoginMethod === 'claudeai') { - logEvent('tengu_oauth_claudeai_forced', {}) + logEvent('tengu_oauth_claudeai_forced', {}); } else if (forceLoginMethod === 'console') { - logEvent('tengu_oauth_console_forced', {}) + logEvent('tengu_oauth_console_forced', {}); } - }, [forceLoginMethod]) + }, [forceLoginMethod]); // Retry logic useEffect(() => { if (oauthStatus.state === 'about_to_retry') { - const timer = setTimeout(setOAuthStatus, 1000, oauthStatus.nextState) - return () => clearTimeout(timer) + const timer = setTimeout(setOAuthStatus, 1000, oauthStatus.nextState); + return () => clearTimeout(timer); } - }, [oauthStatus]) + }, [oauthStatus]); // Handle Enter to continue on success state useKeybinding( 'confirm:yes', () => { - logEvent('tengu_oauth_success', { loginWithClaudeAi }) - onDone() + logEvent('tengu_oauth_success', { loginWithClaudeAi }); + onDone(); }, { context: 'Confirmation', isActive: oauthStatus.state === 'success' && mode !== 'setup-token', }, - ) + ); // Handle Enter to continue from platform setup useKeybinding( 'confirm:yes', () => { - setOAuthStatus({ state: 'idle' }) + setOAuthStatus({ state: 'idle' }); }, { context: 'Confirmation', isActive: oauthStatus.state === 'platform_setup', }, - ) + ); // Handle Enter to retry on error state useKeybinding( 'confirm:yes', () => { if (oauthStatus.state === 'error' && oauthStatus.toRetry) { - setPastedCode('') + setPastedCode(''); setOAuthStatus({ state: 'about_to_retry', nextState: oauthStatus.toRetry, - }) + }); } }, { context: 'Confirmation', isActive: oauthStatus.state === 'error' && !!oauthStatus.toRetry, }, - ) + ); useEffect(() => { - if ( - pastedCode === 'c' && - oauthStatus.state === 'waiting_for_login' && - showPastePrompt && - !urlCopied - ) { + if (pastedCode === 'c' && oauthStatus.state === 'waiting_for_login' && showPastePrompt && !urlCopied) { void setClipboard(oauthStatus.url).then(raw => { - if (raw) process.stdout.write(raw) - setUrlCopied(true) - setTimeout(setUrlCopied, 2000, false) - }) - setPastedCode('') + if (raw) process.stdout.write(raw); + setUrlCopied(true); + setTimeout(setUrlCopied, 2000, false); + }); + setPastedCode(''); } - }, [pastedCode, oauthStatus, showPastePrompt, urlCopied]) + }, [pastedCode, oauthStatus, showPastePrompt, urlCopied]); async function handleSubmitCode(value: string, url: string) { try { // Expecting format "authorizationCode#state" from the authorization callback URL - const [authorizationCode, state] = value.split('#') + const [authorizationCode, state] = value.split('#'); if (!authorizationCode || !state) { setOAuthStatus({ state: 'error', message: 'Invalid code. Please make sure the full code was copied', toRetry: { state: 'waiting_for_login', url }, - }) - return + }); + return; } // Track which path the user is taking (manual code entry) - logEvent('tengu_oauth_manual_entry', {}) + logEvent('tengu_oauth_manual_entry', {}); oauthService.handleManualAuthCodeInput({ authorizationCode, state, - }) + }); } catch (err: unknown) { - logError(err) + logError(err); setOAuthStatus({ state: 'error', message: (err as Error).message, toRetry: { state: 'waiting_for_login', url }, - }) + }); } } const startOAuth = useCallback(async () => { try { - logEvent('tengu_oauth_flow_start', { loginWithClaudeAi }) + logEvent('tengu_oauth_flow_start', { loginWithClaudeAi }); const result = await oauthService .startOAuthFlow( async url => { - setOAuthStatus({ state: 'waiting_for_login', url }) - setTimeout(setShowPastePrompt, 3000, true) + setOAuthStatus({ state: 'waiting_for_login', url }); + setTimeout(setShowPastePrompt, 3000, true); }, { loginWithClaudeAi, @@ -234,13 +229,11 @@ export function ConsoleOAuthFlow({ }, ) .catch(err => { - const isTokenExchangeError = err.message.includes( - 'Token exchange failed', - ) + const isTokenExchangeError = err.message.includes('Token exchange failed'); // Enterprise TLS proxies (Zscaler et al.) intercept the token // exchange POST and cause cryptic SSL errors. Surface an // actionable hint so the user isn't stuck in a login loop. - const sslHint = getSSLErrorHint(err) + const sslHint = getSSLErrorHint(err); setOAuthStatus({ state: 'error', message: @@ -248,73 +241,66 @@ export function ConsoleOAuthFlow({ (isTokenExchangeError ? 'Failed to exchange authorization code for access token. Please try again.' : err.message), - toRetry: - mode === 'setup-token' - ? { state: 'ready_to_start' } - : { state: 'idle' }, - }) + toRetry: mode === 'setup-token' ? { state: 'ready_to_start' } : { state: 'idle' }, + }); logEvent('tengu_oauth_token_exchange_error', { error: err.message, ssl_error: sslHint !== null, - }) - throw err - }) + }); + throw err; + }); if (mode === 'setup-token') { // For setup-token mode, return the OAuth access token directly (it can be used as an API key) // Don't save to keychain - the token is displayed for manual use with CLAUDE_CODE_OAUTH_TOKEN - setOAuthStatus({ state: 'success', token: result.accessToken }) + setOAuthStatus({ state: 'success', token: result.accessToken }); } else { - await installOAuthTokens(result) + await installOAuthTokens(result); - const orgResult = await validateForceLoginOrg() + const orgResult = await validateForceLoginOrg(); if (!orgResult.valid) { - throw new Error((orgResult as { valid: false; message: string }).message) + throw new Error((orgResult as { valid: false; message: string }).message); } // Reset modelType to anthropic when using OAuth login - updateSettingsForSource('userSettings', { modelType: 'anthropic' } as any) + updateSettingsForSource('userSettings', { modelType: 'anthropic' } as any); - setOAuthStatus({ state: 'success' }) + setOAuthStatus({ state: 'success' }); void sendNotification( { message: 'Claude Code login successful', notificationType: 'auth_success', }, terminal, - ) + ); } } catch (err) { - const errorMessage = (err as Error).message - const sslHint = getSSLErrorHint(err) + const errorMessage = (err as Error).message; + const sslHint = getSSLErrorHint(err); setOAuthStatus({ state: 'error', message: sslHint ?? errorMessage, toRetry: { state: mode === 'setup-token' ? 'ready_to_start' : 'idle', }, - }) + }); logEvent('tengu_oauth_error', { - error: - errorMessage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + error: errorMessage as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, ssl_error: sslHint !== null, - }) + }); } - }, [oauthService, setShowPastePrompt, loginWithClaudeAi, mode, orgUUID]) + }, [oauthService, setShowPastePrompt, loginWithClaudeAi, mode, orgUUID]); - const pendingOAuthStartRef = useRef(false) + const pendingOAuthStartRef = useRef(false); useEffect(() => { - if ( - oauthStatus.state === 'ready_to_start' && - !pendingOAuthStartRef.current - ) { - pendingOAuthStartRef.current = true + if (oauthStatus.state === 'ready_to_start' && !pendingOAuthStartRef.current) { + pendingOAuthStartRef.current = true; // Start OAuth flow and reset the pending flag when complete void startOAuth().finally(() => { - pendingOAuthStartRef.current = false - }) + pendingOAuthStartRef.current = false; + }); } - }, [oauthStatus.state, startOAuth]) + }, [oauthStatus.state, startOAuth]); // Auto-exit for setup-token mode useEffect(() => { @@ -322,33 +308,31 @@ export function ConsoleOAuthFlow({ // Delay to ensure static content is fully rendered before exiting const timer = setTimeout( (loginWithClaudeAi, onDone) => { - logEvent('tengu_oauth_success', { loginWithClaudeAi }) + logEvent('tengu_oauth_success', { loginWithClaudeAi }); // Don't clear terminal so the token remains visible - onDone() + onDone(); }, 500, loginWithClaudeAi, onDone, - ) - return () => clearTimeout(timer) + ); + return () => clearTimeout(timer); } - }, [mode, oauthStatus, loginWithClaudeAi, onDone]) + }, [mode, oauthStatus, loginWithClaudeAi, onDone]); // Cleanup OAuth service when component unmounts useEffect(() => { return () => { - oauthService.cleanup() - } - }, [oauthService]) + oauthService.cleanup(); + }; + }, [oauthService]); return ( {oauthStatus.state === 'waiting_for_login' && showPastePrompt && ( - - Browser didn't open? Use the url below to sign in{' '} - + Browser didn't open? Use the url below to sign in {urlCopied ? ( (Copied!) ) : ( @@ -362,27 +346,17 @@ export function ConsoleOAuthFlow({ )} - {mode === 'setup-token' && - oauthStatus.state === 'success' && - oauthStatus.token && ( - - - ✓ Long-lived authentication token created successfully! - - - Your OAuth token (valid for 1 year): - {oauthStatus.token} - - Store this token securely. You won't be able to see it - again. - - - Use this token by setting: export - CLAUDE_CODE_OAUTH_TOKEN=<token> - - + {mode === 'setup-token' && oauthStatus.state === 'success' && oauthStatus.token && ( + + ✓ Long-lived authentication token created successfully! + + Your OAuth token (valid for 1 year): + {oauthStatus.token} + Store this token securely. You won't be able to see it again. + Use this token by setting: export CLAUDE_CODE_OAUTH_TOKEN=<token> - )} + + )} - ) + ); } type OAuthStatusMessageProps = { - oauthStatus: OAuthStatus - mode: 'login' | 'setup-token' - startingMessage: string | undefined - forcedMethodMessage: string | null - showPastePrompt: boolean - pastedCode: string - setPastedCode: (value: string) => void - cursorOffset: number - onDone: () => void - setCursorOffset: (offset: number) => void - textInputColumns: number - handleSubmitCode: (value: string, url: string) => void - setOAuthStatus: (status: OAuthStatus) => void - setLoginWithClaudeAi: (value: boolean) => void -} + oauthStatus: OAuthStatus; + mode: 'login' | 'setup-token'; + startingMessage: string | undefined; + forcedMethodMessage: string | null; + showPastePrompt: boolean; + pastedCode: string; + setPastedCode: (value: string) => void; + cursorOffset: number; + onDone: () => void; + setCursorOffset: (offset: number) => void; + textInputColumns: number; + handleSubmitCode: (value: string, url: string) => void; + setOAuthStatus: (status: OAuthStatus) => void; + setLoginWithClaudeAi: (value: boolean) => void; +}; function OAuthStatusMessage({ oauthStatus, @@ -456,8 +430,7 @@ function OAuthStatusMessage({ { label: ( - Anthropic Compatible ·{' '} - Configure your own API endpoint + Anthropic Compatible · Configure your own API endpoint {'\n'} ), @@ -466,10 +439,7 @@ function OAuthStatusMessage({ { label: ( - OpenAI Compatible ·{' '} - - Ollama, DeepSeek, vLLM, One API, etc. - + OpenAI Compatible · Ollama, DeepSeek, vLLM, One API, etc. {'\n'} ), @@ -478,8 +448,7 @@ function OAuthStatusMessage({ { label: ( - Gemini API ·{' '} - Google Gemini native REST/SSE + Gemini API · Google Gemini native REST/SSE {'\n'} ), @@ -488,16 +457,14 @@ function OAuthStatusMessage({ { label: ( - Claude account with subscription ·{' '} - Pro, Max, Team, or Enterprise + Claude account with subscription · Pro, Max, Team, or Enterprise {process.env.USER_TYPE === 'ant' && ( {'\n'} [ANT-ONLY]{' '} - Please use this option unless you need to login to a - special org for accessing sensitive data (e.g. - customer data, HIPI data) with the Console option + Please use this option unless you need to login to a special org for accessing sensitive + data (e.g. customer data, HIPI data) with the Console option )} @@ -509,8 +476,7 @@ function OAuthStatusMessage({ { label: ( - Anthropic Console account ·{' '} - API usage billing + Anthropic Console account · API usage billing {'\n'} ), @@ -519,10 +485,7 @@ function OAuthStatusMessage({ { label: ( - 3rd-party platform ·{' '} - - Amazon Bedrock, Microsoft Foundry, or Vertex AI - + 3rd-party platform · Amazon Bedrock, Microsoft Foundry, or Vertex AI {'\n'} ), @@ -531,7 +494,7 @@ function OAuthStatusMessage({ ]} onChange={value => { if (value === 'custom_platform') { - logEvent('tengu_custom_platform_selected', {}) + logEvent('tengu_custom_platform_selected', {}); setOAuthStatus({ state: 'custom_platform', baseUrl: process.env.ANTHROPIC_BASE_URL ?? '', @@ -540,9 +503,9 @@ function OAuthStatusMessage({ sonnetModel: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL ?? '', opusModel: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL ?? '', activeField: 'base_url', - }) + }); } else if (value === 'openai_chat_api') { - logEvent('tengu_openai_chat_api_selected', {}) + logEvent('tengu_openai_chat_api_selected', {}); setOAuthStatus({ state: 'openai_chat_api', baseUrl: process.env.OPENAI_BASE_URL ?? '', @@ -551,9 +514,9 @@ function OAuthStatusMessage({ sonnetModel: process.env.OPENAI_DEFAULT_SONNET_MODEL ?? '', opusModel: process.env.OPENAI_DEFAULT_OPUS_MODEL ?? '', activeField: 'base_url', - }) + }); } else if (value === 'gemini_api') { - logEvent('tengu_gemini_api_selected', {}) + logEvent('tengu_gemini_api_selected', {}); setOAuthStatus({ state: 'gemini_api', baseUrl: process.env.GEMINI_BASE_URL ?? '', @@ -562,583 +525,100 @@ function OAuthStatusMessage({ sonnetModel: process.env.GEMINI_DEFAULT_SONNET_MODEL ?? '', opusModel: process.env.GEMINI_DEFAULT_OPUS_MODEL ?? '', activeField: 'base_url', - }) + }); } else if (value === 'platform') { - logEvent('tengu_oauth_platform_selected', {}) - setOAuthStatus({ state: 'platform_setup' }) + logEvent('tengu_oauth_platform_selected', {}); + setOAuthStatus({ state: 'platform_setup' }); } else { - setOAuthStatus({ state: 'ready_to_start' }) + setOAuthStatus({ state: 'ready_to_start' }); if (value === 'claudeai') { - logEvent('tengu_oauth_claudeai_selected', {}) - setLoginWithClaudeAi(true) + logEvent('tengu_oauth_claudeai_selected', {}); + setLoginWithClaudeAi(true); } else { - logEvent('tengu_oauth_console_selected', {}) - setLoginWithClaudeAi(false) + logEvent('tengu_oauth_console_selected', {}); + setLoginWithClaudeAi(false); } } }} /> - ) + ); - case 'custom_platform': - { - type Field = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model' - const FIELDS: Field[] = ['base_url', 'api_key', 'haiku_model', 'sonnet_model', 'opus_model'] - const cp = oauthStatus as { - state: 'custom_platform' - activeField: Field - baseUrl: string - apiKey: string - haikuModel: string - sonnetModel: string - opusModel: string - } - const { activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel } = cp - const displayValues: Record = { - base_url: baseUrl, - api_key: apiKey, - haiku_model: haikuModel, - sonnet_model: sonnetModel, - opus_model: opusModel, - } + case 'custom_platform': { + type Field = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; + const FIELDS: Field[] = ['base_url', 'api_key', 'haiku_model', 'sonnet_model', 'opus_model']; + const cp = oauthStatus as { + state: 'custom_platform'; + activeField: Field; + baseUrl: string; + apiKey: string; + haikuModel: string; + sonnetModel: string; + opusModel: string; + }; + const { activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel } = cp; + const displayValues: Record = { + base_url: baseUrl, + api_key: apiKey, + haiku_model: haikuModel, + sonnet_model: sonnetModel, + opus_model: opusModel, + }; - const [inputValue, setInputValue] = useState(() => displayValues[activeField]) - const [inputCursorOffset, setInputCursorOffset] = useState( - () => displayValues[activeField].length, - ) + const [inputValue, setInputValue] = useState(() => displayValues[activeField]); + const [inputCursorOffset, setInputCursorOffset] = useState(() => displayValues[activeField].length); - const buildState = useCallback( - (field: Field, value: string, newActive?: Field) => { - const s = { - state: 'custom_platform' as const, - activeField: newActive ?? activeField, - baseUrl, - apiKey, - haikuModel, - sonnetModel, - opusModel, - } - switch (field) { - case 'base_url': - return { ...s, baseUrl: value } - case 'api_key': - return { ...s, apiKey: value } - case 'haiku_model': - return { ...s, haikuModel: value } - case 'sonnet_model': - return { ...s, sonnetModel: value } - case 'opus_model': - return { ...s, opusModel: value } - } - }, - [activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel], - ) - - const switchTo = useCallback( - (target: Field) => { - setOAuthStatus(buildState(activeField, inputValue, target)) - setInputValue(displayValues[target] ?? '') - setInputCursorOffset((displayValues[target] ?? '').length) - }, - [activeField, inputValue, displayValues, buildState, setOAuthStatus], - ) - - const doSave = useCallback(() => { - const finalVals = { ...displayValues, [activeField]: inputValue } - const env: Record = {} - - // Validate base_url if provided - if (finalVals.base_url) { - try { - new URL(finalVals.base_url) - } catch { - setOAuthStatus({ - state: 'error', - message: 'Invalid base URL: please enter a full URL including protocol (e.g., https://api.example.com)', - toRetry: { - state: 'custom_platform', - baseUrl: '', - apiKey: '', - haikuModel: '', - sonnetModel: '', - opusModel: '', - activeField: 'base_url', - }, - }) - return - } - env.ANTHROPIC_BASE_URL = finalVals.base_url + const buildState = useCallback( + (field: Field, value: string, newActive?: Field) => { + const s = { + state: 'custom_platform' as const, + activeField: newActive ?? activeField, + baseUrl, + apiKey, + haikuModel, + sonnetModel, + opusModel, + }; + switch (field) { + case 'base_url': + return { ...s, baseUrl: value }; + case 'api_key': + return { ...s, apiKey: value }; + case 'haiku_model': + return { ...s, haikuModel: value }; + case 'sonnet_model': + return { ...s, sonnetModel: value }; + case 'opus_model': + return { ...s, opusModel: value }; } + }, + [activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel], + ); - if (finalVals.api_key) env.ANTHROPIC_AUTH_TOKEN = finalVals.api_key - if (finalVals.haiku_model) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = finalVals.haiku_model - if (finalVals.sonnet_model) env.ANTHROPIC_DEFAULT_SONNET_MODEL = finalVals.sonnet_model - if (finalVals.opus_model) env.ANTHROPIC_DEFAULT_OPUS_MODEL = finalVals.opus_model - const { error } = updateSettingsForSource('userSettings', { - modelType: 'anthropic' as any, - env, - } as any) - if (error) { + const switchTo = useCallback( + (target: Field) => { + setOAuthStatus(buildState(activeField, inputValue, target)); + setInputValue(displayValues[target] ?? ''); + setInputCursorOffset((displayValues[target] ?? '').length); + }, + [activeField, inputValue, displayValues, buildState, setOAuthStatus], + ); + + const doSave = useCallback(() => { + const finalVals = { ...displayValues, [activeField]: inputValue }; + const env: Record = {}; + + // Validate base_url if provided + if (finalVals.base_url) { + try { + new URL(finalVals.base_url); + } catch { setOAuthStatus({ state: 'error', - message: 'Failed to save settings. Please try again.', + message: 'Invalid base URL: please enter a full URL including protocol (e.g., https://api.example.com)', toRetry: { state: 'custom_platform', - baseUrl: finalVals.base_url ?? '', - apiKey: finalVals.api_key ?? '', - haikuModel: finalVals.haiku_model ?? '', - sonnetModel: finalVals.sonnet_model ?? '', - opusModel: finalVals.opus_model ?? '', - activeField: 'base_url', - }, - }) - } else { - for (const [k, v] of Object.entries(env)) process.env[k] = v - setOAuthStatus({ state: 'success' }) - void onDone() - } - }, [activeField, inputValue, displayValues, setOAuthStatus, onDone]) - - const handleEnter = useCallback(() => { - const idx = FIELDS.indexOf(activeField) - if (idx === FIELDS.length - 1) { - setOAuthStatus(buildState(activeField, inputValue)) - doSave() - } else { - const next = FIELDS[idx + 1]! - setOAuthStatus(buildState(activeField, inputValue, next)) - setInputValue(displayValues[next] ?? '') - setInputCursorOffset((displayValues[next] ?? '').length) - } - }, [activeField, inputValue, buildState, doSave, displayValues, setOAuthStatus]) - - useKeybinding( - 'tabs:next', - () => { - const idx = FIELDS.indexOf(activeField) - if (idx < FIELDS.length - 1) { - setOAuthStatus(buildState(activeField, inputValue, FIELDS[idx + 1])) - setInputValue(displayValues[FIELDS[idx + 1]!] ?? '') - setInputCursorOffset((displayValues[FIELDS[idx + 1]!] ?? '').length) - } - }, - { context: 'FormField' }, - ) - useKeybinding( - 'tabs:previous', - () => { - const idx = FIELDS.indexOf(activeField) - if (idx > 0) { - setOAuthStatus(buildState(activeField, inputValue, FIELDS[idx - 1])) - setInputValue(displayValues[FIELDS[idx - 1]!] ?? '') - setInputCursorOffset((displayValues[FIELDS[idx - 1]!] ?? '').length) - } - }, - { context: 'FormField' }, - ) - useKeybinding( - 'confirm:no', - () => { - setOAuthStatus({ state: 'idle' }) - }, - { context: 'Confirmation' }, - ) - - const columns = useTerminalSize().columns - 20 - - const renderRow = ( - field: Field, - label: string, - opts?: { mask?: boolean; placeholder?: string }, - ) => { - const active = activeField === field - const val = displayValues[field] - return ( - - - {` ${label} `} - - - {active ? ( - - ) : val ? ( - - {opts?.mask - ? val.slice(0, 8) + '\u00b7'.repeat(Math.max(0, val.length - 8)) - : val} - - ) : null} - - ) - } - - return ( - - Anthropic Compatible Setup - - {renderRow('base_url', 'Base URL ')} - {renderRow('api_key', 'API Key ', { mask: true })} - {renderRow('haiku_model', 'Haiku ')} - {renderRow('sonnet_model', 'Sonnet ')} - {renderRow('opus_model', 'Opus ')} - - - ↑↓/Tab to switch · Enter on last field to save · Esc to go back - - - ) - } - - case 'openai_chat_api': - { - type OpenAIField = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model' - const OPENAI_FIELDS: OpenAIField[] = [ - 'base_url', - 'api_key', - 'haiku_model', - 'sonnet_model', - 'opus_model', - ] - const op = oauthStatus as { - state: 'openai_chat_api' - activeField: OpenAIField - baseUrl: string - apiKey: string - haikuModel: string - sonnetModel: string - opusModel: string - } - const { activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel } = op - const openaiDisplayValues: Record = { - base_url: baseUrl, - api_key: apiKey, - haiku_model: haikuModel, - sonnet_model: sonnetModel, - opus_model: opusModel, - } - - const [openaiInputValue, setOpenaiInputValue] = useState( - () => openaiDisplayValues[activeField], - ) - const [openaiInputCursorOffset, setOpenaiInputCursorOffset] = useState( - () => openaiDisplayValues[activeField].length, - ) - - const buildOpenAIState = useCallback( - (field: OpenAIField, value: string, newActive?: OpenAIField) => { - const s = { - state: 'openai_chat_api' as const, - activeField: newActive ?? activeField, - baseUrl, - apiKey, - haikuModel, - sonnetModel, - opusModel, - } - switch (field) { - case 'base_url': - return { ...s, baseUrl: value } - case 'api_key': - return { ...s, apiKey: value } - case 'haiku_model': - return { ...s, haikuModel: value } - case 'sonnet_model': - return { ...s, sonnetModel: value } - case 'opus_model': - return { ...s, opusModel: value } - } - }, - [activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel], - ) - - const doOpenAISave = useCallback(() => { - const finalVals = { ...openaiDisplayValues, [activeField]: openaiInputValue } - const env: Record = {} - - // Validate base_url if provided - if (finalVals.base_url) { - try { - new URL(finalVals.base_url) - } catch { - setOAuthStatus({ - state: 'error', - message: 'Invalid base URL: please enter a full URL including protocol (e.g., https://api.example.com)', - toRetry: { - state: 'openai_chat_api', - baseUrl: '', - apiKey: '', - haikuModel: '', - sonnetModel: '', - opusModel: '', - activeField: 'base_url', - }, - }) - return - } - env.OPENAI_BASE_URL = finalVals.base_url - } - - if (finalVals.api_key) env.OPENAI_API_KEY = finalVals.api_key - if (finalVals.haiku_model) env.OPENAI_DEFAULT_HAIKU_MODEL = finalVals.haiku_model - if (finalVals.sonnet_model) env.OPENAI_DEFAULT_SONNET_MODEL = finalVals.sonnet_model - if (finalVals.opus_model) env.OPENAI_DEFAULT_OPUS_MODEL = finalVals.opus_model - const { error } = updateSettingsForSource('userSettings', { - modelType: 'openai' as any, - env, - } as any) - if (error) { - setOAuthStatus({ - state: 'error', - message: 'Failed to save settings. Please try again.', - toRetry: { - state: 'openai_chat_api', - baseUrl: finalVals.base_url ?? '', - apiKey: finalVals.api_key ?? '', - haikuModel: finalVals.haiku_model ?? '', - sonnetModel: finalVals.sonnet_model ?? '', - opusModel: finalVals.opus_model ?? '', - activeField: 'base_url', - }, - }) - } else { - for (const [k, v] of Object.entries(env)) process.env[k] = v - setOAuthStatus({ state: 'success' }) - void onDone() - } - }, [activeField, openaiInputValue, openaiDisplayValues, setOAuthStatus, onDone]) - - const handleOpenAIEnter = useCallback(() => { - const idx = OPENAI_FIELDS.indexOf(activeField) - if (idx === OPENAI_FIELDS.length - 1) { - setOAuthStatus(buildOpenAIState(activeField, openaiInputValue)) - doOpenAISave() - } else { - const next = OPENAI_FIELDS[idx + 1]! - setOAuthStatus(buildOpenAIState(activeField, openaiInputValue, next)) - setOpenaiInputValue(openaiDisplayValues[next] ?? '') - setOpenaiInputCursorOffset((openaiDisplayValues[next] ?? '').length) - } - }, [ - activeField, - openaiInputValue, - buildOpenAIState, - doOpenAISave, - openaiDisplayValues, - setOAuthStatus, - ]) - - useKeybinding( - 'tabs:next', - () => { - const idx = OPENAI_FIELDS.indexOf(activeField) - if (idx < OPENAI_FIELDS.length - 1) { - setOAuthStatus( - buildOpenAIState(activeField, openaiInputValue, OPENAI_FIELDS[idx + 1]), - ) - setOpenaiInputValue(openaiDisplayValues[OPENAI_FIELDS[idx + 1]!] ?? '') - setOpenaiInputCursorOffset( - (openaiDisplayValues[OPENAI_FIELDS[idx + 1]!] ?? '').length, - ) - } - }, - { context: 'FormField' }, - ) - useKeybinding( - 'tabs:previous', - () => { - const idx = OPENAI_FIELDS.indexOf(activeField) - if (idx > 0) { - setOAuthStatus( - buildOpenAIState(activeField, openaiInputValue, OPENAI_FIELDS[idx - 1]), - ) - setOpenaiInputValue(openaiDisplayValues[OPENAI_FIELDS[idx - 1]!] ?? '') - setOpenaiInputCursorOffset( - (openaiDisplayValues[OPENAI_FIELDS[idx - 1]!] ?? '').length, - ) - } - }, - { context: 'FormField' }, - ) - useKeybinding( - 'confirm:no', - () => { - setOAuthStatus({ state: 'idle' }) - }, - { context: 'Confirmation' }, - ) - - const openaiColumns = useTerminalSize().columns - 20 - - const renderOpenAIRow = ( - field: OpenAIField, - label: string, - opts?: { mask?: boolean }, - ) => { - const active = activeField === field - const val = openaiDisplayValues[field] - return ( - - - {` ${label} `} - - - {active ? ( - - ) : val ? ( - - {opts?.mask - ? val.slice(0, 8) + '\u00b7'.repeat(Math.max(0, val.length - 8)) - : val} - - ) : null} - - ) - } - - return ( - - OpenAI Compatible API Setup - - Configure an OpenAI Chat Completions compatible endpoint (e.g. - Ollama, DeepSeek, vLLM). - - - {renderOpenAIRow('base_url', 'Base URL ')} - {renderOpenAIRow('api_key', 'API Key ', { mask: true })} - {renderOpenAIRow('haiku_model', 'Haiku ')} - {renderOpenAIRow('sonnet_model', 'Sonnet ')} - {renderOpenAIRow('opus_model', 'Opus ')} - - - ↑↓/Tab to switch · Enter on last field to save · Esc to go back - - - ) - } - - case 'gemini_api': - { - type GeminiField = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model' - const GEMINI_FIELDS: GeminiField[] = [ - 'base_url', - 'api_key', - 'haiku_model', - 'sonnet_model', - 'opus_model', - ] - const gp = oauthStatus as { - state: 'gemini_api' - activeField: GeminiField - baseUrl: string - apiKey: string - haikuModel: string - sonnetModel: string - opusModel: string - } - const { activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel } = gp - const geminiDisplayValues: Record = { - base_url: baseUrl, - api_key: apiKey, - haiku_model: haikuModel, - sonnet_model: sonnetModel, - opus_model: opusModel, - } - - const [geminiInputValue, setGeminiInputValue] = useState( - () => geminiDisplayValues[activeField], - ) - const [geminiInputCursorOffset, setGeminiInputCursorOffset] = useState( - () => geminiDisplayValues[activeField].length, - ) - - const buildGeminiState = useCallback( - (field: GeminiField, value: string, newActive?: GeminiField) => { - const s = { - state: 'gemini_api' as const, - activeField: newActive ?? activeField, - baseUrl, - apiKey, - haikuModel, - sonnetModel, - opusModel, - } - switch (field) { - case 'base_url': - return { ...s, baseUrl: value } - case 'api_key': - return { ...s, apiKey: value } - case 'haiku_model': - return { ...s, haikuModel: value } - case 'sonnet_model': - return { ...s, sonnetModel: value } - case 'opus_model': - return { ...s, opusModel: value } - } - }, - [activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel], - ) - - const doGeminiSave = useCallback(() => { - const finalVals = { ...geminiDisplayValues, [activeField]: geminiInputValue } - if (!finalVals.haiku_model || !finalVals.sonnet_model || !finalVals.opus_model) { - setOAuthStatus({ - state: 'error', - message: 'Gemini setup requires Haiku, Sonnet, and Opus model names.', - toRetry: { - state: 'gemini_api', - baseUrl: finalVals.base_url, - apiKey: finalVals.api_key, - haikuModel: finalVals.haiku_model, - sonnetModel: finalVals.sonnet_model, - opusModel: finalVals.opus_model, - activeField, - }, - }) - return - } - - const env: Record = {} - if (finalVals.base_url) env.GEMINI_BASE_URL = finalVals.base_url - if (finalVals.api_key) env.GEMINI_API_KEY = finalVals.api_key - if (finalVals.haiku_model) env.GEMINI_DEFAULT_HAIKU_MODEL = finalVals.haiku_model - if (finalVals.sonnet_model) env.GEMINI_DEFAULT_SONNET_MODEL = finalVals.sonnet_model - if (finalVals.opus_model) env.GEMINI_DEFAULT_OPUS_MODEL = finalVals.opus_model - const { error } = updateSettingsForSource('userSettings', { - modelType: 'gemini' as any, - env, - } as any) - if (error) { - setOAuthStatus({ - state: 'error', - message: `Failed to save: ${error.message}`, - toRetry: { - state: 'gemini_api', baseUrl: '', apiKey: '', haikuModel: '', @@ -1146,134 +626,530 @@ function OAuthStatusMessage({ opusModel: '', activeField: 'base_url', }, - }) - } else { - for (const [k, v] of Object.entries(env)) process.env[k] = v - setOAuthStatus({ state: 'success' }) - void onDone() + }); + return; } - }, [activeField, geminiInputValue, geminiDisplayValues, onDone, setOAuthStatus]) - - const handleGeminiEnter = useCallback(() => { - const idx = GEMINI_FIELDS.indexOf(activeField) - if (idx === GEMINI_FIELDS.length - 1) { - setOAuthStatus(buildGeminiState(activeField, geminiInputValue)) - doGeminiSave() - } else { - const next = GEMINI_FIELDS[idx + 1]! - setOAuthStatus(buildGeminiState(activeField, geminiInputValue, next)) - setGeminiInputValue(geminiDisplayValues[next] ?? '') - setGeminiInputCursorOffset((geminiDisplayValues[next] ?? '').length) - } - }, [ - activeField, - buildGeminiState, - doGeminiSave, - geminiDisplayValues, - geminiInputValue, - setOAuthStatus, - ]) - - useKeybinding( - 'tabs:next', - () => { - const idx = GEMINI_FIELDS.indexOf(activeField) - if (idx < GEMINI_FIELDS.length - 1) { - setOAuthStatus( - buildGeminiState(activeField, geminiInputValue, GEMINI_FIELDS[idx + 1]), - ) - setGeminiInputValue(geminiDisplayValues[GEMINI_FIELDS[idx + 1]!] ?? '') - setGeminiInputCursorOffset( - (geminiDisplayValues[GEMINI_FIELDS[idx + 1]!] ?? '').length, - ) - } - }, - { context: 'FormField' }, - ) - useKeybinding( - 'tabs:previous', - () => { - const idx = GEMINI_FIELDS.indexOf(activeField) - if (idx > 0) { - setOAuthStatus( - buildGeminiState(activeField, geminiInputValue, GEMINI_FIELDS[idx - 1]), - ) - setGeminiInputValue(geminiDisplayValues[GEMINI_FIELDS[idx - 1]!] ?? '') - setGeminiInputCursorOffset( - (geminiDisplayValues[GEMINI_FIELDS[idx - 1]!] ?? '').length, - ) - } - }, - { context: 'FormField' }, - ) - useKeybinding( - 'confirm:no', - () => { - setOAuthStatus({ state: 'idle' }) - }, - { context: 'Confirmation' }, - ) - - const geminiColumns = useTerminalSize().columns - 20 - - const renderGeminiRow = ( - field: GeminiField, - label: string, - opts?: { mask?: boolean }, - ) => { - const active = activeField === field - const val = geminiDisplayValues[field] - return ( - - - {` ${label} `} - - - {active ? ( - - ) : val ? ( - - {opts?.mask - ? val.slice(0, 8) + '\u00b7'.repeat(Math.max(0, val.length - 8)) - : val} - - ) : null} - - ) + env.ANTHROPIC_BASE_URL = finalVals.base_url; } + if (finalVals.api_key) env.ANTHROPIC_AUTH_TOKEN = finalVals.api_key; + if (finalVals.haiku_model) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = finalVals.haiku_model; + if (finalVals.sonnet_model) env.ANTHROPIC_DEFAULT_SONNET_MODEL = finalVals.sonnet_model; + if (finalVals.opus_model) env.ANTHROPIC_DEFAULT_OPUS_MODEL = finalVals.opus_model; + const { error } = updateSettingsForSource('userSettings', { + modelType: 'anthropic' as any, + env, + } as any); + if (error) { + setOAuthStatus({ + state: 'error', + message: 'Failed to save settings. Please try again.', + toRetry: { + state: 'custom_platform', + baseUrl: finalVals.base_url ?? '', + apiKey: finalVals.api_key ?? '', + haikuModel: finalVals.haiku_model ?? '', + sonnetModel: finalVals.sonnet_model ?? '', + opusModel: finalVals.opus_model ?? '', + activeField: 'base_url', + }, + }); + } else { + for (const [k, v] of Object.entries(env)) process.env[k] = v; + setOAuthStatus({ state: 'success' }); + void onDone(); + } + }, [activeField, inputValue, displayValues, setOAuthStatus, onDone]); + + const handleEnter = useCallback(() => { + const idx = FIELDS.indexOf(activeField); + if (idx === FIELDS.length - 1) { + setOAuthStatus(buildState(activeField, inputValue)); + doSave(); + } else { + const next = FIELDS[idx + 1]!; + setOAuthStatus(buildState(activeField, inputValue, next)); + setInputValue(displayValues[next] ?? ''); + setInputCursorOffset((displayValues[next] ?? '').length); + } + }, [activeField, inputValue, buildState, doSave, displayValues, setOAuthStatus]); + + useKeybinding( + 'tabs:next', + () => { + const idx = FIELDS.indexOf(activeField); + if (idx < FIELDS.length - 1) { + setOAuthStatus(buildState(activeField, inputValue, FIELDS[idx + 1])); + setInputValue(displayValues[FIELDS[idx + 1]!] ?? ''); + setInputCursorOffset((displayValues[FIELDS[idx + 1]!] ?? '').length); + } + }, + { context: 'FormField' }, + ); + useKeybinding( + 'tabs:previous', + () => { + const idx = FIELDS.indexOf(activeField); + if (idx > 0) { + setOAuthStatus(buildState(activeField, inputValue, FIELDS[idx - 1])); + setInputValue(displayValues[FIELDS[idx - 1]!] ?? ''); + setInputCursorOffset((displayValues[FIELDS[idx - 1]!] ?? '').length); + } + }, + { context: 'FormField' }, + ); + useKeybinding( + 'confirm:no', + () => { + setOAuthStatus({ state: 'idle' }); + }, + { context: 'Confirmation' }, + ); + + const columns = useTerminalSize().columns - 20; + + const renderRow = (field: Field, label: string, opts?: { mask?: boolean; placeholder?: string }) => { + const active = activeField === field; + const val = displayValues[field]; return ( - - Gemini API Setup - - Configure a Gemini Generate Content compatible endpoint. Base URL is - optional and defaults to Google's v1beta API. - - - {renderGeminiRow('base_url', 'Base URL ')} - {renderGeminiRow('api_key', 'API Key ', { mask: true })} - {renderGeminiRow('haiku_model', 'Haiku ')} - {renderGeminiRow('sonnet_model', 'Sonnet ')} - {renderGeminiRow('opus_model', 'Opus ')} - - - ↑↓/Tab to switch · Enter on last field to save · Esc to go back + + + {` ${label} `} + + {active ? ( + + ) : val ? ( + + {opts?.mask ? val.slice(0, 8) + '\u00b7'.repeat(Math.max(0, val.length - 8)) : val} + + ) : null} - ) - } + ); + }; + + return ( + + Anthropic Compatible Setup + + {renderRow('base_url', 'Base URL ')} + {renderRow('api_key', 'API Key ', { mask: true })} + {renderRow('haiku_model', 'Haiku ')} + {renderRow('sonnet_model', 'Sonnet ')} + {renderRow('opus_model', 'Opus ')} + + ↑↓/Tab to switch · Enter on last field to save · Esc to go back + + ); + } + + case 'openai_chat_api': { + type OpenAIField = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; + const OPENAI_FIELDS: OpenAIField[] = ['base_url', 'api_key', 'haiku_model', 'sonnet_model', 'opus_model']; + const op = oauthStatus as { + state: 'openai_chat_api'; + activeField: OpenAIField; + baseUrl: string; + apiKey: string; + haikuModel: string; + sonnetModel: string; + opusModel: string; + }; + const { activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel } = op; + const openaiDisplayValues: Record = { + base_url: baseUrl, + api_key: apiKey, + haiku_model: haikuModel, + sonnet_model: sonnetModel, + opus_model: opusModel, + }; + + const [openaiInputValue, setOpenaiInputValue] = useState(() => openaiDisplayValues[activeField]); + const [openaiInputCursorOffset, setOpenaiInputCursorOffset] = useState( + () => openaiDisplayValues[activeField].length, + ); + + const buildOpenAIState = useCallback( + (field: OpenAIField, value: string, newActive?: OpenAIField) => { + const s = { + state: 'openai_chat_api' as const, + activeField: newActive ?? activeField, + baseUrl, + apiKey, + haikuModel, + sonnetModel, + opusModel, + }; + switch (field) { + case 'base_url': + return { ...s, baseUrl: value }; + case 'api_key': + return { ...s, apiKey: value }; + case 'haiku_model': + return { ...s, haikuModel: value }; + case 'sonnet_model': + return { ...s, sonnetModel: value }; + case 'opus_model': + return { ...s, opusModel: value }; + } + }, + [activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel], + ); + + const doOpenAISave = useCallback(() => { + const finalVals = { ...openaiDisplayValues, [activeField]: openaiInputValue }; + const env: Record = {}; + + // Validate base_url if provided + if (finalVals.base_url) { + try { + new URL(finalVals.base_url); + } catch { + setOAuthStatus({ + state: 'error', + message: 'Invalid base URL: please enter a full URL including protocol (e.g., https://api.example.com)', + toRetry: { + state: 'openai_chat_api', + baseUrl: '', + apiKey: '', + haikuModel: '', + sonnetModel: '', + opusModel: '', + activeField: 'base_url', + }, + }); + return; + } + env.OPENAI_BASE_URL = finalVals.base_url; + } + + if (finalVals.api_key) env.OPENAI_API_KEY = finalVals.api_key; + if (finalVals.haiku_model) env.OPENAI_DEFAULT_HAIKU_MODEL = finalVals.haiku_model; + if (finalVals.sonnet_model) env.OPENAI_DEFAULT_SONNET_MODEL = finalVals.sonnet_model; + if (finalVals.opus_model) env.OPENAI_DEFAULT_OPUS_MODEL = finalVals.opus_model; + const { error } = updateSettingsForSource('userSettings', { + modelType: 'openai' as any, + env, + } as any); + if (error) { + setOAuthStatus({ + state: 'error', + message: 'Failed to save settings. Please try again.', + toRetry: { + state: 'openai_chat_api', + baseUrl: finalVals.base_url ?? '', + apiKey: finalVals.api_key ?? '', + haikuModel: finalVals.haiku_model ?? '', + sonnetModel: finalVals.sonnet_model ?? '', + opusModel: finalVals.opus_model ?? '', + activeField: 'base_url', + }, + }); + } else { + for (const [k, v] of Object.entries(env)) process.env[k] = v; + setOAuthStatus({ state: 'success' }); + void onDone(); + } + }, [activeField, openaiInputValue, openaiDisplayValues, setOAuthStatus, onDone]); + + const handleOpenAIEnter = useCallback(() => { + const idx = OPENAI_FIELDS.indexOf(activeField); + if (idx === OPENAI_FIELDS.length - 1) { + setOAuthStatus(buildOpenAIState(activeField, openaiInputValue)); + doOpenAISave(); + } else { + const next = OPENAI_FIELDS[idx + 1]!; + setOAuthStatus(buildOpenAIState(activeField, openaiInputValue, next)); + setOpenaiInputValue(openaiDisplayValues[next] ?? ''); + setOpenaiInputCursorOffset((openaiDisplayValues[next] ?? '').length); + } + }, [activeField, openaiInputValue, buildOpenAIState, doOpenAISave, openaiDisplayValues, setOAuthStatus]); + + useKeybinding( + 'tabs:next', + () => { + const idx = OPENAI_FIELDS.indexOf(activeField); + if (idx < OPENAI_FIELDS.length - 1) { + setOAuthStatus(buildOpenAIState(activeField, openaiInputValue, OPENAI_FIELDS[idx + 1])); + setOpenaiInputValue(openaiDisplayValues[OPENAI_FIELDS[idx + 1]!] ?? ''); + setOpenaiInputCursorOffset((openaiDisplayValues[OPENAI_FIELDS[idx + 1]!] ?? '').length); + } + }, + { context: 'FormField' }, + ); + useKeybinding( + 'tabs:previous', + () => { + const idx = OPENAI_FIELDS.indexOf(activeField); + if (idx > 0) { + setOAuthStatus(buildOpenAIState(activeField, openaiInputValue, OPENAI_FIELDS[idx - 1])); + setOpenaiInputValue(openaiDisplayValues[OPENAI_FIELDS[idx - 1]!] ?? ''); + setOpenaiInputCursorOffset((openaiDisplayValues[OPENAI_FIELDS[idx - 1]!] ?? '').length); + } + }, + { context: 'FormField' }, + ); + useKeybinding( + 'confirm:no', + () => { + setOAuthStatus({ state: 'idle' }); + }, + { context: 'Confirmation' }, + ); + + const openaiColumns = useTerminalSize().columns - 20; + + const renderOpenAIRow = (field: OpenAIField, label: string, opts?: { mask?: boolean }) => { + const active = activeField === field; + const val = openaiDisplayValues[field]; + return ( + + + {` ${label} `} + + + {active ? ( + + ) : val ? ( + + {opts?.mask ? val.slice(0, 8) + '\u00b7'.repeat(Math.max(0, val.length - 8)) : val} + + ) : null} + + ); + }; + + return ( + + OpenAI Compatible API Setup + Configure an OpenAI Chat Completions compatible endpoint (e.g. Ollama, DeepSeek, vLLM). + + {renderOpenAIRow('base_url', 'Base URL ')} + {renderOpenAIRow('api_key', 'API Key ', { mask: true })} + {renderOpenAIRow('haiku_model', 'Haiku ')} + {renderOpenAIRow('sonnet_model', 'Sonnet ')} + {renderOpenAIRow('opus_model', 'Opus ')} + + ↑↓/Tab to switch · Enter on last field to save · Esc to go back + + ); + } + + case 'gemini_api': { + type GeminiField = 'base_url' | 'api_key' | 'haiku_model' | 'sonnet_model' | 'opus_model'; + const GEMINI_FIELDS: GeminiField[] = ['base_url', 'api_key', 'haiku_model', 'sonnet_model', 'opus_model']; + const gp = oauthStatus as { + state: 'gemini_api'; + activeField: GeminiField; + baseUrl: string; + apiKey: string; + haikuModel: string; + sonnetModel: string; + opusModel: string; + }; + const { activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel } = gp; + const geminiDisplayValues: Record = { + base_url: baseUrl, + api_key: apiKey, + haiku_model: haikuModel, + sonnet_model: sonnetModel, + opus_model: opusModel, + }; + + const [geminiInputValue, setGeminiInputValue] = useState(() => geminiDisplayValues[activeField]); + const [geminiInputCursorOffset, setGeminiInputCursorOffset] = useState( + () => geminiDisplayValues[activeField].length, + ); + + const buildGeminiState = useCallback( + (field: GeminiField, value: string, newActive?: GeminiField) => { + const s = { + state: 'gemini_api' as const, + activeField: newActive ?? activeField, + baseUrl, + apiKey, + haikuModel, + sonnetModel, + opusModel, + }; + switch (field) { + case 'base_url': + return { ...s, baseUrl: value }; + case 'api_key': + return { ...s, apiKey: value }; + case 'haiku_model': + return { ...s, haikuModel: value }; + case 'sonnet_model': + return { ...s, sonnetModel: value }; + case 'opus_model': + return { ...s, opusModel: value }; + } + }, + [activeField, baseUrl, apiKey, haikuModel, sonnetModel, opusModel], + ); + + const doGeminiSave = useCallback(() => { + const finalVals = { ...geminiDisplayValues, [activeField]: geminiInputValue }; + if (!finalVals.haiku_model || !finalVals.sonnet_model || !finalVals.opus_model) { + setOAuthStatus({ + state: 'error', + message: 'Gemini setup requires Haiku, Sonnet, and Opus model names.', + toRetry: { + state: 'gemini_api', + baseUrl: finalVals.base_url, + apiKey: finalVals.api_key, + haikuModel: finalVals.haiku_model, + sonnetModel: finalVals.sonnet_model, + opusModel: finalVals.opus_model, + activeField, + }, + }); + return; + } + + const env: Record = {}; + if (finalVals.base_url) env.GEMINI_BASE_URL = finalVals.base_url; + if (finalVals.api_key) env.GEMINI_API_KEY = finalVals.api_key; + if (finalVals.haiku_model) env.GEMINI_DEFAULT_HAIKU_MODEL = finalVals.haiku_model; + if (finalVals.sonnet_model) env.GEMINI_DEFAULT_SONNET_MODEL = finalVals.sonnet_model; + if (finalVals.opus_model) env.GEMINI_DEFAULT_OPUS_MODEL = finalVals.opus_model; + const { error } = updateSettingsForSource('userSettings', { + modelType: 'gemini' as any, + env, + } as any); + if (error) { + setOAuthStatus({ + state: 'error', + message: `Failed to save: ${error.message}`, + toRetry: { + state: 'gemini_api', + baseUrl: '', + apiKey: '', + haikuModel: '', + sonnetModel: '', + opusModel: '', + activeField: 'base_url', + }, + }); + } else { + for (const [k, v] of Object.entries(env)) process.env[k] = v; + setOAuthStatus({ state: 'success' }); + void onDone(); + } + }, [activeField, geminiInputValue, geminiDisplayValues, onDone, setOAuthStatus]); + + const handleGeminiEnter = useCallback(() => { + const idx = GEMINI_FIELDS.indexOf(activeField); + if (idx === GEMINI_FIELDS.length - 1) { + setOAuthStatus(buildGeminiState(activeField, geminiInputValue)); + doGeminiSave(); + } else { + const next = GEMINI_FIELDS[idx + 1]!; + setOAuthStatus(buildGeminiState(activeField, geminiInputValue, next)); + setGeminiInputValue(geminiDisplayValues[next] ?? ''); + setGeminiInputCursorOffset((geminiDisplayValues[next] ?? '').length); + } + }, [activeField, buildGeminiState, doGeminiSave, geminiDisplayValues, geminiInputValue, setOAuthStatus]); + + useKeybinding( + 'tabs:next', + () => { + const idx = GEMINI_FIELDS.indexOf(activeField); + if (idx < GEMINI_FIELDS.length - 1) { + setOAuthStatus(buildGeminiState(activeField, geminiInputValue, GEMINI_FIELDS[idx + 1])); + setGeminiInputValue(geminiDisplayValues[GEMINI_FIELDS[idx + 1]!] ?? ''); + setGeminiInputCursorOffset((geminiDisplayValues[GEMINI_FIELDS[idx + 1]!] ?? '').length); + } + }, + { context: 'FormField' }, + ); + useKeybinding( + 'tabs:previous', + () => { + const idx = GEMINI_FIELDS.indexOf(activeField); + if (idx > 0) { + setOAuthStatus(buildGeminiState(activeField, geminiInputValue, GEMINI_FIELDS[idx - 1])); + setGeminiInputValue(geminiDisplayValues[GEMINI_FIELDS[idx - 1]!] ?? ''); + setGeminiInputCursorOffset((geminiDisplayValues[GEMINI_FIELDS[idx - 1]!] ?? '').length); + } + }, + { context: 'FormField' }, + ); + useKeybinding( + 'confirm:no', + () => { + setOAuthStatus({ state: 'idle' }); + }, + { context: 'Confirmation' }, + ); + + const geminiColumns = useTerminalSize().columns - 20; + + const renderGeminiRow = (field: GeminiField, label: string, opts?: { mask?: boolean }) => { + const active = activeField === field; + const val = geminiDisplayValues[field]; + return ( + + + {` ${label} `} + + + {active ? ( + + ) : val ? ( + + {opts?.mask ? val.slice(0, 8) + '\u00b7'.repeat(Math.max(0, val.length - 8)) : val} + + ) : null} + + ); + }; + + return ( + + Gemini API Setup + + Configure a Gemini Generate Content compatible endpoint. Base URL is optional and defaults to Google's + v1beta API. + + + {renderGeminiRow('base_url', 'Base URL ')} + {renderGeminiRow('api_key', 'API Key ', { mask: true })} + {renderGeminiRow('haiku_model', 'Haiku ')} + {renderGeminiRow('sonnet_model', 'Sonnet ')} + {renderGeminiRow('opus_model', 'Opus ')} + + ↑↓/Tab to switch · Enter on last field to save · Esc to go back + + ); + } case 'platform_setup': return ( @@ -1282,14 +1158,12 @@ function OAuthStatusMessage({ - Claude Code supports Amazon Bedrock, Microsoft Foundry, and Vertex - AI. Set the required environment variables, then restart Claude - Code. + Claude Code supports Amazon Bedrock, Microsoft Foundry, and Vertex AI. Set the required environment + variables, then restart Claude Code. - If you are part of an enterprise organization, contact your - administrator for setup instructions. + If you are part of an enterprise organization, contact your administrator for setup instructions. @@ -1321,7 +1195,7 @@ function OAuthStatusMessage({ - ) + ); case 'waiting_for_login': return ( @@ -1345,9 +1219,7 @@ function OAuthStatusMessage({ - handleSubmitCode(value, oauthStatus.url) - } + onSubmit={(value: string) => handleSubmitCode(value, oauthStatus.url)} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} columns={textInputColumns} @@ -1356,7 +1228,7 @@ function OAuthStatusMessage({ )} - ) + ); case 'creating_api_key': return ( @@ -1366,14 +1238,14 @@ function OAuthStatusMessage({ Creating API key for Claude Code… - ) + ); case 'about_to_retry': return ( Retrying… - ) + ); case 'success': return ( @@ -1382,8 +1254,7 @@ function OAuthStatusMessage({ <> {getOauthAccountInfo()?.emailAddress ? ( - Logged in as{' '} - {getOauthAccountInfo()?.emailAddress} + Logged in as {getOauthAccountInfo()?.emailAddress} ) : null} @@ -1392,7 +1263,7 @@ function OAuthStatusMessage({ )} - ) + ); case 'error': return ( @@ -1407,9 +1278,9 @@ function OAuthStatusMessage({ )} - ) + ); default: - return null + return null; } } diff --git a/src/components/ContextSuggestions.tsx b/src/components/ContextSuggestions.tsx index 2017d7ab9..60ab9a5c6 100644 --- a/src/components/ContextSuggestions.tsx +++ b/src/components/ContextSuggestions.tsx @@ -1,15 +1,15 @@ -import figures from 'figures' -import * as React from 'react' -import { Box, Text, StatusIcon } from '@anthropic/ink' -import type { ContextSuggestion } from '../utils/contextSuggestions.js' -import { formatTokens } from '../utils/format.js' +import figures from 'figures'; +import * as React from 'react'; +import { Box, Text, StatusIcon } from '@anthropic/ink'; +import type { ContextSuggestion } from '../utils/contextSuggestions.js'; +import { formatTokens } from '../utils/format.js'; type Props = { - suggestions: ContextSuggestion[] -} + suggestions: ContextSuggestion[]; +}; export function ContextSuggestions({ suggestions }: Props): React.ReactNode { - if (suggestions.length === 0) return null + if (suggestions.length === 0) return null; return ( @@ -22,8 +22,7 @@ export function ContextSuggestions({ suggestions }: Props): React.ReactNode { {suggestion.savingsTokens ? ( {' '} - {figures.arrowRight} save ~ - {formatTokens(suggestion.savingsTokens)} + {figures.arrowRight} save ~{formatTokens(suggestion.savingsTokens)} ) : null} @@ -33,5 +32,5 @@ export function ContextSuggestions({ suggestions }: Props): React.ReactNode { ))} - ) + ); } diff --git a/src/components/ContextVisualization.tsx b/src/components/ContextVisualization.tsx index 3502db5f8..6fffd7609 100644 --- a/src/components/ContextVisualization.tsx +++ b/src/components/ContextVisualization.tsx @@ -1,18 +1,15 @@ -import { feature } from 'bun:bundle' -import * as React from 'react' -import { Box, Text } from '@anthropic/ink' -import type { ContextData } from '../utils/analyzeContext.js' -import { generateContextSuggestions } from '../utils/contextSuggestions.js' -import { getDisplayPath } from '../utils/file.js' -import { formatTokens } from '../utils/format.js' -import { - getSourceDisplayName, - type SettingSource, -} from '../utils/settings/constants.js' -import { plural } from '../utils/stringUtils.js' -import { ContextSuggestions } from './ContextSuggestions.js' +import { feature } from 'bun:bundle'; +import * as React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import type { ContextData } from '../utils/analyzeContext.js'; +import { generateContextSuggestions } from '../utils/contextSuggestions.js'; +import { getDisplayPath } from '../utils/file.js'; +import { formatTokens } from '../utils/format.js'; +import { getSourceDisplayName, type SettingSource } from '../utils/settings/constants.js'; +import { plural } from '../utils/stringUtils.js'; +import { ContextSuggestions } from './ContextSuggestions.js'; -const RESERVED_CATEGORY_NAME = 'Autocompact buffer' +const RESERVED_CATEGORY_NAME = 'Autocompact buffer'; /** * One-liner for the legend header showing what context-collapse has done. @@ -25,41 +22,35 @@ function CollapseStatus(): React.ReactNode { if (feature('CONTEXT_COLLAPSE')) { /* eslint-disable @typescript-eslint/no-require-imports */ const { getStats, isContextCollapseEnabled } = - require('../services/contextCollapse/index.js') as typeof import('../services/contextCollapse/index.js') + require('../services/contextCollapse/index.js') as typeof import('../services/contextCollapse/index.js'); /* eslint-enable @typescript-eslint/no-require-imports */ - if (!isContextCollapseEnabled()) return null + if (!isContextCollapseEnabled()) return null; - const s = getStats() - const { health: h } = s + const s = getStats(); + const { health: h } = s; - const parts: string[] = [] + const parts: string[] = []; if (s.collapsedSpans > 0) { - parts.push( - `${s.collapsedSpans} ${plural(s.collapsedSpans, 'span')} summarized (${s.collapsedMessages} msgs)`, - ) + parts.push(`${s.collapsedSpans} ${plural(s.collapsedSpans, 'span')} summarized (${s.collapsedMessages} msgs)`); } - if (s.stagedSpans > 0) parts.push(`${s.stagedSpans} staged`) + if (s.stagedSpans > 0) parts.push(`${s.stagedSpans} staged`); const summary = parts.length > 0 ? parts.join(', ') : h.totalSpawns > 0 ? `${h.totalSpawns} ${plural(h.totalSpawns, 'spawn')}, nothing staged yet` - : 'waiting for first trigger' + : 'waiting for first trigger'; - let line2: React.ReactNode = null + let line2: React.ReactNode = null; if (h.totalErrors > 0) { line2 = ( Collapse errors: {h.totalErrors}/{h.totalSpawns} spawns failed {h.lastError ? ` (last: ${h.lastError.slice(0, 60)})` : ''} - ) + ); } else if (h.emptySpawnWarningEmitted) { - line2 = ( - - Collapse idle: {h.totalEmptySpawns} consecutive empty runs - - ) + line2 = Collapse idle: {h.totalEmptySpawns} consecutive empty runs; } return ( @@ -67,51 +58,45 @@ function CollapseStatus(): React.ReactNode { Context strategy: collapse ({summary}) {line2} - ) + ); } - return null + return null; } // Order for displaying source groups: Project > User > Managed > Plugin > Built-in -const SOURCE_DISPLAY_ORDER = [ - 'Project', - 'User', - 'Managed', - 'Plugin', - 'Built-in', -] +const SOURCE_DISPLAY_ORDER = ['Project', 'User', 'Managed', 'Plugin', 'Built-in']; /** Group items by source type for display, sorted by tokens descending within each group */ -function groupBySource< - T extends { source: SettingSource | 'plugin' | 'built-in'; tokens: number }, ->(items: T[]): Map { - const groups = new Map() +function groupBySource( + items: T[], +): Map { + const groups = new Map(); for (const item of items) { - const key = getSourceDisplayName(item.source) - const existing = groups.get(key) || [] - existing.push(item) - groups.set(key, existing) + const key = getSourceDisplayName(item.source); + const existing = groups.get(key) || []; + existing.push(item); + groups.set(key, existing); } // Sort each group by tokens descending for (const [key, group] of groups.entries()) { groups.set( key, group.sort((a, b) => b.tokens - a.tokens), - ) + ); } // Return groups in consistent order - const orderedGroups = new Map() + const orderedGroups = new Map(); for (const source of SOURCE_DISPLAY_ORDER) { - const group = groups.get(source) + const group = groups.get(source); if (group) { - orderedGroups.set(source, group) + orderedGroups.set(source, group); } } - return orderedGroups + return orderedGroups; } interface Props { - data: ContextData + data: ContextData; } export function ContextVisualization({ data }: Props): React.ReactNode { @@ -130,25 +115,17 @@ export function ContextVisualization({ data }: Props): React.ReactNode { agents, skills, messageBreakdown, - } = data + } = data; // Filter out categories with 0 tokens for the legend, and exclude Free space, Autocompact buffer, and deferred const visibleCategories = categories.filter( - cat => - cat.tokens > 0 && - cat.name !== 'Free space' && - cat.name !== RESERVED_CATEGORY_NAME && - !cat.isDeferred, - ) + cat => cat.tokens > 0 && cat.name !== 'Free space' && cat.name !== RESERVED_CATEGORY_NAME && !cat.isDeferred, + ); // Check if MCP tools are deferred (loaded on-demand via tool search) - const hasDeferredMcpTools = categories.some( - cat => cat.isDeferred && cat.name.includes('MCP'), - ) + const hasDeferredMcpTools = categories.some(cat => cat.isDeferred && cat.name.includes('MCP')); // Check if builtin tools are deferred - const hasDeferredBuiltinTools = deferredBuiltinTools.length > 0 - const autocompactCategory = categories.find( - cat => cat.name === RESERVED_CATEGORY_NAME, - ) + const hasDeferredBuiltinTools = deferredBuiltinTools.length > 0; + const autocompactCategory = categories.find(cat => cat.name === RESERVED_CATEGORY_NAME); return ( @@ -164,20 +141,20 @@ export function ContextVisualization({ data }: Props): React.ReactNode { {'⛶ '} - ) + ); } if (square.categoryName === RESERVED_CATEGORY_NAME) { return ( {'⛝ '} - ) + ); } return ( {square.squareFullness >= 0.7 ? '⛁ ' : '⛀ '} - ) + ); })} ))} @@ -186,8 +163,7 @@ export function ContextVisualization({ data }: Props): React.ReactNode { {/* Legend to the right */} - {model} · {formatTokens(totalTokens)}/{formatTokens(rawMaxTokens)}{' '} - tokens ({percentage}%) + {model} · {formatTokens(totalTokens)}/{formatTokens(rawMaxTokens)} tokens ({percentage}%) @@ -195,15 +171,13 @@ export function ContextVisualization({ data }: Props): React.ReactNode { Estimated usage by category {visibleCategories.map((cat, index) => { - const tokenDisplay = formatTokens(cat.tokens) + const tokenDisplay = formatTokens(cat.tokens); // Show "N/A" for deferred categories since they don't count toward context - const percentDisplay = cat.isDeferred - ? 'N/A' - : `${((cat.tokens / rawMaxTokens) * 100).toFixed(1)}%` - const isReserved = cat.name === RESERVED_CATEGORY_NAME - const displayName = cat.name + const percentDisplay = cat.isDeferred ? 'N/A' : `${((cat.tokens / rawMaxTokens) * 100).toFixed(1)}%`; + const isReserved = cat.name === RESERVED_CATEGORY_NAME; + const displayName = cat.name; // Deferred categories don't appear in grid, so show blank instead of symbol - const symbol = cat.isDeferred ? ' ' : isReserved ? '⛝' : '⛁' + const symbol = cat.isDeferred ? ' ' : isReserved ? '⛝' : '⛁'; return ( @@ -213,23 +187,15 @@ export function ContextVisualization({ data }: Props): React.ReactNode { {tokenDisplay} tokens ({percentDisplay}) - ) + ); })} {(categories.find(c => c.name === 'Free space')?.tokens ?? 0) > 0 && ( Free space: - {formatTokens( - categories.find(c => c.name === 'Free space')?.tokens || 0, - )}{' '} - ( - {( - ((categories.find(c => c.name === 'Free space')?.tokens || - 0) / - rawMaxTokens) * - 100 - ).toFixed(1)} + {formatTokens(categories.find(c => c.name === 'Free space')?.tokens || 0)} ( + {(((categories.find(c => c.name === 'Free space')?.tokens || 0) / rawMaxTokens) * 100).toFixed(1)} %) @@ -253,10 +219,7 @@ export function ContextVisualization({ data }: Props): React.ReactNode { MCP tools - - {' '} - · /mcp{hasDeferredMcpTools ? ' (loaded on-demand)' : ''} - + · /mcp{hasDeferredMcpTools ? ' (loaded on-demand)' : ''} {/* Show loaded tools first */} {mcpTools.some(t => t.isLoaded) && ( @@ -297,63 +260,57 @@ export function ContextVisualization({ data }: Props): React.ReactNode { )} {/* Show builtin tools: always-loaded + deferred (ant-only) */} - {((systemTools && systemTools.length > 0) || hasDeferredBuiltinTools) && - process.env.USER_TYPE === 'ant' && ( + {((systemTools && systemTools.length > 0) || hasDeferredBuiltinTools) && process.env.USER_TYPE === 'ant' && ( + + + [ANT-ONLY] System tools + {hasDeferredBuiltinTools && (some loaded on-demand)} + + {/* Always-loaded + deferred-but-loaded tools */} - - [ANT-ONLY] System tools - {hasDeferredBuiltinTools && ( - (some loaded on-demand) - )} - - {/* Always-loaded + deferred-but-loaded tools */} - - Loaded - {systemTools?.map((tool, i) => ( - + Loaded + {systemTools?.map((tool, i) => ( + + └ {tool.name}: + {formatTokens(tool.tokens)} tokens + + ))} + {deferredBuiltinTools + .filter(t => t.isLoaded) + .map((tool, i) => ( + └ {tool.name}: {formatTokens(tool.tokens)} tokens ))} + + {/* Deferred (not yet loaded) tools */} + {hasDeferredBuiltinTools && deferredBuiltinTools.some(t => !t.isLoaded) && ( + + Available {deferredBuiltinTools - .filter(t => t.isLoaded) + .filter(t => !t.isLoaded) .map((tool, i) => ( - - └ {tool.name}: - {formatTokens(tool.tokens)} tokens + + └ {tool.name} ))} - {/* Deferred (not yet loaded) tools */} - {hasDeferredBuiltinTools && - deferredBuiltinTools.some(t => !t.isLoaded) && ( - - Available - {deferredBuiltinTools - .filter(t => !t.isLoaded) - .map((tool, i) => ( - - └ {tool.name} - - ))} - - )} - - )} + )} + + )} - {systemPromptSections && - systemPromptSections.length > 0 && - process.env.USER_TYPE === 'ant' && ( - - [ANT-ONLY] System prompt sections - {systemPromptSections.map((section, i) => ( - - └ {section.name}: - {formatTokens(section.tokens)} tokens - - ))} - - )} + {systemPromptSections && systemPromptSections.length > 0 && process.env.USER_TYPE === 'ant' && ( + + [ANT-ONLY] System prompt sections + {systemPromptSections.map((section, i) => ( + + └ {section.name}: + {formatTokens(section.tokens)} tokens + + ))} + + )} {agents.length > 0 && ( @@ -361,19 +318,17 @@ export function ContextVisualization({ data }: Props): React.ReactNode { Custom agents · /agents - {Array.from(groupBySource(agents).entries()).map( - ([sourceDisplay, sourceAgents]) => ( - - {sourceDisplay} - {sourceAgents.map((agent, i) => ( - - └ {agent.agentType}: - {formatTokens(agent.tokens)} tokens - - ))} - - ), - )} + {Array.from(groupBySource(agents).entries()).map(([sourceDisplay, sourceAgents]) => ( + + {sourceDisplay} + {sourceAgents.map((agent, i) => ( + + └ {agent.agentType}: + {formatTokens(agent.tokens)} tokens + + ))} + + ))} )} @@ -398,19 +353,17 @@ export function ContextVisualization({ data }: Props): React.ReactNode { Skills · /skills - {Array.from(groupBySource(skills.skillFrontmatter).entries()).map( - ([sourceDisplay, sourceSkills]) => ( - - {sourceDisplay} - {sourceSkills.map((skill, i) => ( - - └ {skill.name}: - {formatTokens(skill.tokens)} tokens - - ))} - - ), - )} + {Array.from(groupBySource(skills.skillFrontmatter).entries()).map(([sourceDisplay, sourceSkills]) => ( + + {sourceDisplay} + {sourceSkills.map((skill, i) => ( + + └ {skill.name}: + {formatTokens(skill.tokens)} tokens + + ))} + + ))} )} @@ -421,37 +374,27 @@ export function ContextVisualization({ data }: Props): React.ReactNode { Tool calls: - - {formatTokens(messageBreakdown.toolCallTokens)} tokens - + {formatTokens(messageBreakdown.toolCallTokens)} tokens Tool results: - - {formatTokens(messageBreakdown.toolResultTokens)} tokens - + {formatTokens(messageBreakdown.toolResultTokens)} tokens Attachments: - - {formatTokens(messageBreakdown.attachmentTokens)} tokens - + {formatTokens(messageBreakdown.attachmentTokens)} tokens Assistant messages (non-tool): - - {formatTokens(messageBreakdown.assistantMessageTokens)} tokens - + {formatTokens(messageBreakdown.assistantMessageTokens)} tokens User messages (non-tool-result): - - {formatTokens(messageBreakdown.userMessageTokens)} tokens - + {formatTokens(messageBreakdown.userMessageTokens)} tokens @@ -462,8 +405,7 @@ export function ContextVisualization({ data }: Props): React.ReactNode { └ {tool.name}: - calls {formatTokens(tool.callTokens)}, results{' '} - {formatTokens(tool.resultTokens)} + calls {formatTokens(tool.callTokens)}, results {formatTokens(tool.resultTokens)} ))} @@ -473,16 +415,12 @@ export function ContextVisualization({ data }: Props): React.ReactNode { {messageBreakdown.attachmentsByType.length > 0 && ( [ANT-ONLY] Top attachments - {messageBreakdown.attachmentsByType - .slice(0, 5) - .map((attachment, i) => ( - - └ {attachment.name}: - - {formatTokens(attachment.tokens)} tokens - - - ))} + {messageBreakdown.attachmentsByType.slice(0, 5).map((attachment, i) => ( + + └ {attachment.name}: + {formatTokens(attachment.tokens)} tokens + + ))} )} @@ -490,5 +428,5 @@ export function ContextVisualization({ data }: Props): React.ReactNode { - ) + ); } diff --git a/src/components/CoordinatorAgentStatus.tsx b/src/components/CoordinatorAgentStatus.tsx index 0df45ea46..2caa408bc 100644 --- a/src/components/CoordinatorAgentStatus.tsx +++ b/src/components/CoordinatorAgentStatus.tsx @@ -6,27 +6,17 @@ * always; a timestamp shows until passed. Enter to view/steer, x to dismiss. */ -import figures from 'figures' -import * as React from 'react' -import { BLACK_CIRCLE, PAUSE_ICON, PLAY_ICON } from '../constants/figures.js' -import { useTerminalSize } from '../hooks/useTerminalSize.js' -import { Box, Text, stringWidth, wrapText } from '@anthropic/ink' -import { - type AppState, - useAppState, - useSetAppState, -} from '../state/AppState.js' -import { - enterTeammateView, - exitTeammateView, -} from '../state/teammateViewHelpers.js' -import { - isPanelAgentTask, - type LocalAgentTaskState, -} from '../tasks/LocalAgentTask/LocalAgentTask.js' -import { formatDuration, formatNumber } from '../utils/format.js' -import { evictTerminalTask } from '../utils/task/framework.js' -import { isTerminalStatus } from './tasks/taskStatusUtils.js' +import figures from 'figures'; +import * as React from 'react'; +import { BLACK_CIRCLE, PAUSE_ICON, PLAY_ICON } from '../constants/figures.js'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { Box, Text, stringWidth, wrapText } from '@anthropic/ink'; +import { type AppState, useAppState, useSetAppState } from '../state/AppState.js'; +import { enterTeammateView, exitTeammateView } from '../state/teammateViewHelpers.js'; +import { isPanelAgentTask, type LocalAgentTaskState } from '../tasks/LocalAgentTask/LocalAgentTask.js'; +import { formatDuration, formatNumber } from '../utils/format.js'; +import { evictTerminalTask } from '../utils/task/framework.js'; +import { isTerminalStatus } from './tasks/taskStatusUtils.js'; /** * Which panel-managed tasks currently have a visible row. @@ -36,62 +26,57 @@ import { isTerminalStatus } from './tasks/taskStatusUtils.js' * the filter time-dependent. Shared by panel render, useCoordinatorTaskCount, * and index resolvers so the math can't drift. */ -export function getVisibleAgentTasks( - tasks: AppState['tasks'], -): LocalAgentTaskState[] { +export function getVisibleAgentTasks(tasks: AppState['tasks']): LocalAgentTaskState[] { return Object.values(tasks) - .filter( - (t): t is LocalAgentTaskState => - isPanelAgentTask(t) && t.evictAfter !== 0, - ) - .sort((a, b) => a.startTime - b.startTime) + .filter((t): t is LocalAgentTaskState => isPanelAgentTask(t) && t.evictAfter !== 0) + .sort((a, b) => a.startTime - b.startTime); } export function CoordinatorTaskPanel(): React.ReactNode { - const tasks = useAppState(s => s.tasks) - const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId) - const agentNameRegistry = useAppState(s => s.agentNameRegistry) - const coordinatorTaskIndex = useAppState(s => s.coordinatorTaskIndex) - const tasksSelected = useAppState(s => s.footerSelection === 'tasks') - const selectedIndex = tasksSelected ? coordinatorTaskIndex : undefined - const setAppState = useSetAppState() + const tasks = useAppState(s => s.tasks); + const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId); + const agentNameRegistry = useAppState(s => s.agentNameRegistry); + const coordinatorTaskIndex = useAppState(s => s.coordinatorTaskIndex); + const tasksSelected = useAppState(s => s.footerSelection === 'tasks'); + const selectedIndex = tasksSelected ? coordinatorTaskIndex : undefined; + const setAppState = useSetAppState(); - const visibleTasks = getVisibleAgentTasks(tasks) - const hasTasks = Object.values(tasks).some(isPanelAgentTask) + const visibleTasks = getVisibleAgentTasks(tasks); + const hasTasks = Object.values(tasks).some(isPanelAgentTask); // 1s tick: re-render for elapsed time + evict tasks past their deadline. // The eviction deletes from prev.tasks, which makes useCoordinatorTaskCount // (and other consumers) see the updated count without their own tick. - const tasksRef = React.useRef(tasks) - tasksRef.current = tasks - const [, setTick] = React.useState(0) + const tasksRef = React.useRef(tasks); + tasksRef.current = tasks; + const [, setTick] = React.useState(0); React.useEffect(() => { - if (!hasTasks) return + if (!hasTasks) return; const interval = setInterval( (tasksRef, setAppState, setTick) => { - const now = Date.now() + const now = Date.now(); for (const t of Object.values(tasksRef.current)) { if (isPanelAgentTask(t) && (t.evictAfter ?? Infinity) <= now) { - evictTerminalTask(t.id, setAppState) + evictTerminalTask(t.id, setAppState); } } - setTick((prev: number) => prev + 1) + setTick((prev: number) => prev + 1); }, 1000, tasksRef, setAppState, setTick, - ) - return () => clearInterval(interval) - }, [hasTasks, setAppState]) + ); + return () => clearInterval(interval); + }, [hasTasks, setAppState]); const nameByAgentId = React.useMemo(() => { - const inv = new Map() - for (const [n, id] of agentNameRegistry) inv.set(id, n) - return inv - }, [agentNameRegistry]) + const inv = new Map(); + for (const [n, id] of agentNameRegistry) inv.set(id, n); + return inv; + }, [agentNameRegistry]); if (visibleTasks.length === 0) { - return null + return null; } return ( @@ -112,7 +97,7 @@ export function CoordinatorTaskPanel(): React.ReactNode { /> ))} - ) + ); } /** @@ -121,12 +106,12 @@ export function CoordinatorTaskPanel(): React.ReactNode { * stays accurate without needing its own tick. */ export function useCoordinatorTaskCount(): number { - const tasks = useAppState(s => s.tasks) + const tasks = useAppState(s => s.tasks); return React.useMemo(() => { - if ((process.env.USER_TYPE as string) !== 'ant') return 0 - const count = getVisibleAgentTasks(tasks).length - return count > 0 ? count + 1 : 0 - }, [tasks]) + if ((process.env.USER_TYPE as string) !== 'ant') return 0; + const count = getVisibleAgentTasks(tasks).length; + return count > 0 ? count + 1 : 0; + }, [tasks]); } function MainLine({ @@ -134,95 +119,71 @@ function MainLine({ isViewed, onClick, }: { - isSelected?: boolean - isViewed?: boolean - onClick: () => void + isSelected?: boolean; + isViewed?: boolean; + onClick: () => void; }): React.ReactNode { - const [hover, setHover] = React.useState(false) - const prefix = isSelected || hover ? figures.pointer + ' ' : ' ' - const bullet = isViewed ? BLACK_CIRCLE : figures.circle + const [hover, setHover] = React.useState(false); + const prefix = isSelected || hover ? figures.pointer + ' ' : ' '; + const bullet = isViewed ? BLACK_CIRCLE : figures.circle; return ( - setHover(true)} - onMouseLeave={() => setHover(false)} - > + setHover(true)} onMouseLeave={() => setHover(false)}> {prefix} {bullet} main - ) + ); } type AgentLineProps = { - task: LocalAgentTaskState - name?: string - isSelected?: boolean - isViewed?: boolean - onClick?: () => void -} + task: LocalAgentTaskState; + name?: string; + isSelected?: boolean; + isViewed?: boolean; + onClick?: () => void; +}; -function AgentLine({ - task, - name, - isSelected, - isViewed, - onClick, -}: AgentLineProps): React.ReactNode { - const { columns } = useTerminalSize() - const [hover, setHover] = React.useState(false) - const isRunning = !isTerminalStatus(task.status) - const pausedMs = task.totalPausedMs ?? 0 +function AgentLine({ task, name, isSelected, isViewed, onClick }: AgentLineProps): React.ReactNode { + const { columns } = useTerminalSize(); + const [hover, setHover] = React.useState(false); + const isRunning = !isTerminalStatus(task.status); + const pausedMs = task.totalPausedMs ?? 0; const elapsedMs = Math.max( 0, - isRunning - ? Date.now() - task.startTime - pausedMs - : (task.endTime ?? task.startTime) - task.startTime - pausedMs, - ) + isRunning ? Date.now() - task.startTime - pausedMs : (task.endTime ?? task.startTime) - task.startTime - pausedMs, + ); - const elapsed = formatDuration(elapsedMs) - const tokenCount = task.progress?.tokenCount + const elapsed = formatDuration(elapsedMs); + const tokenCount = task.progress?.tokenCount; // Derive direction arrow from activity state, same logic as Spinner - const lastActivity = task.progress?.lastActivity - const arrow = lastActivity ? figures.arrowDown : figures.arrowUp + const lastActivity = task.progress?.lastActivity; + const arrow = lastActivity ? figures.arrowDown : figures.arrowUp; - const tokenText = - tokenCount !== undefined && tokenCount > 0 - ? ` · ${arrow} ${formatNumber(tokenCount)} tokens` - : '' + const tokenText = tokenCount !== undefined && tokenCount > 0 ? ` · ${arrow} ${formatNumber(tokenCount)} tokens` : ''; - const queuedCount = task.pendingMessages.length - const queuedText = queuedCount > 0 ? ` · ${queuedCount} queued` : '' + const queuedCount = task.pendingMessages.length; + const queuedText = queuedCount > 0 ? ` · ${queuedCount} queued` : ''; // Precedence: AI summary > static description (no tool-call activity noise) - const displayDescription = task.progress?.summary || task.description + const displayDescription = task.progress?.summary || task.description; - const highlighted = isSelected || hover - const prefix = highlighted ? figures.pointer + ' ' : ' ' - const bullet = isViewed ? BLACK_CIRCLE : figures.circle - const dim = !highlighted && !isViewed + const highlighted = isSelected || hover; + const prefix = highlighted ? figures.pointer + ' ' : ' '; + const bullet = isViewed ? BLACK_CIRCLE : figures.circle; + const dim = !highlighted && !isViewed; - const sep = isRunning ? PLAY_ICON : PAUSE_ICON + const sep = isRunning ? PLAY_ICON : PAUSE_ICON; // Name is the steering handle — kept out of truncation and undimmed so it // stays readable even when the row is inactive. Short by convention (the // Agent tool prompt asks for "one or two words, lowercase"). - const namePart = name ? `${name}: ` : '' - const hintPart = - isSelected && !isViewed ? ` · x to ${isRunning ? 'stop' : 'clear'}` : '' - const suffixPart = ` ${sep} ${elapsed}${tokenText}${queuedText}${hintPart}` + const namePart = name ? `${name}: ` : ''; + const hintPart = isSelected && !isViewed ? ` · x to ${isRunning ? 'stop' : 'clear'}` : ''; + const suffixPart = ` ${sep} ${elapsed}${tokenText}${queuedText}${hintPart}`; const availableForDesc = - columns - - stringWidth(prefix) - - stringWidth(`${bullet} `) - - stringWidth(namePart) - - stringWidth(suffixPart) - const truncated = wrapText( - displayDescription, - Math.max(0, availableForDesc), - 'truncate-end', - ) + columns - stringWidth(prefix) - stringWidth(`${bullet} `) - stringWidth(namePart) - stringWidth(suffixPart); + const truncated = wrapText(displayDescription, Math.max(0, availableForDesc), 'truncate-end'); const line = ( @@ -241,16 +202,12 @@ function AgentLine({ {queuedCount > 0 && {queuedText}} {hintPart && {hintPart}} - ) + ); - if (!onClick) return line + if (!onClick) return line; return ( - setHover(true)} - onMouseLeave={() => setHover(false)} - > + setHover(true)} onMouseLeave={() => setHover(false)}> {line} - ) + ); } diff --git a/src/components/CostThresholdDialog.tsx b/src/components/CostThresholdDialog.tsx index 283e76c44..a04efbc85 100644 --- a/src/components/CostThresholdDialog.tsx +++ b/src/components/CostThresholdDialog.tsx @@ -1,17 +1,14 @@ -import React from 'react' -import { Box, Dialog, Link, Text } from '@anthropic/ink' -import { Select } from './CustomSelect/index.js' +import React from 'react'; +import { Box, Dialog, Link, Text } from '@anthropic/ink'; +import { Select } from './CustomSelect/index.js'; type Props = { - onDone: () => void -} + onDone: () => void; +}; export function CostThresholdDialog({ onDone }: Props): React.ReactNode { return ( - + Learn more about how to monitor your spending: @@ -26,5 +23,5 @@ export function CostThresholdDialog({ onDone }: Props): React.ReactNode { onChange={onDone} /> - ) + ); } diff --git a/src/components/CtrlOToExpand.tsx b/src/components/CtrlOToExpand.tsx index 01ac3b2fb..426f0e179 100644 --- a/src/components/CtrlOToExpand.tsx +++ b/src/components/CtrlOToExpand.tsx @@ -1,49 +1,35 @@ -import chalk from 'chalk' -import React, { useContext } from 'react' -import { Text } from '@anthropic/ink' -import { getShortcutDisplay } from '../keybindings/shortcutFormat.js' -import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js' -import { KeyboardShortcutHint } from '@anthropic/ink' -import { InVirtualListContext } from './messageActions.js' +import chalk from 'chalk'; +import React, { useContext } from 'react'; +import { Text } from '@anthropic/ink'; +import { getShortcutDisplay } from '../keybindings/shortcutFormat.js'; +import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'; +import { KeyboardShortcutHint } from '@anthropic/ink'; +import { InVirtualListContext } from './messageActions.js'; // Context to track if we're inside a sub agent // Similar to MessageResponseContext, this helps us avoid showing // too many "(ctrl+o to expand)" hints in sub agent output -const SubAgentContext = React.createContext(false) +const SubAgentContext = React.createContext(false); -export function SubAgentProvider({ - children, -}: { - children: React.ReactNode -}): React.ReactNode { - return ( - {children} - ) +export function SubAgentProvider({ children }: { children: React.ReactNode }): React.ReactNode { + return {children}; } export function CtrlOToExpand(): React.ReactNode { - const isInSubAgent = useContext(SubAgentContext) - const inVirtualList = useContext(InVirtualListContext) - const expandShortcut = useShortcutDisplay( - 'app:toggleTranscript', - 'Global', - 'ctrl+o', - ) + const isInSubAgent = useContext(SubAgentContext); + const inVirtualList = useContext(InVirtualListContext); + const expandShortcut = useShortcutDisplay('app:toggleTranscript', 'Global', 'ctrl+o'); if (isInSubAgent || inVirtualList) { - return null + return null; } return ( - ) + ); } export function ctrlOToExpand(): string { - const shortcut = getShortcutDisplay( - 'app:toggleTranscript', - 'Global', - 'ctrl+o', - ) - return chalk.dim(`(${shortcut} to expand)`) + const shortcut = getShortcutDisplay('app:toggleTranscript', 'Global', 'ctrl+o'); + return chalk.dim(`(${shortcut} to expand)`); } diff --git a/src/components/CustomSelect/SelectMulti.tsx b/src/components/CustomSelect/SelectMulti.tsx index 198917487..5f1f15ac2 100644 --- a/src/components/CustomSelect/SelectMulti.tsx +++ b/src/components/CustomSelect/SelectMulti.tsx @@ -1,69 +1,66 @@ -import figures from 'figures' -import React from 'react' -import { Box, Text } from '@anthropic/ink' -import type { PastedContent } from '../../utils/config.js' -import type { ImageDimensions } from '../../utils/imageResizer.js' -import type { OptionWithDescription } from './select.js' -import { SelectInputOption } from './select-input-option.js' -import { SelectOption } from './select-option.js' -import { useMultiSelectState } from './use-multi-select-state.js' +import figures from 'figures'; +import React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import type { PastedContent } from '../../utils/config.js'; +import type { ImageDimensions } from '../../utils/imageResizer.js'; +import type { OptionWithDescription } from './select.js'; +import { SelectInputOption } from './select-input-option.js'; +import { SelectOption } from './select-option.js'; +import { useMultiSelectState } from './use-multi-select-state.js'; export type SelectMultiProps = { - readonly isDisabled?: boolean - readonly visibleOptionCount?: number - readonly options: OptionWithDescription[] - readonly defaultValue?: T[] - readonly onCancel: () => void - readonly onChange?: (values: T[]) => void - readonly onFocus?: (value: T) => void - readonly focusValue?: T + readonly isDisabled?: boolean; + readonly visibleOptionCount?: number; + readonly options: OptionWithDescription[]; + readonly defaultValue?: T[]; + readonly onCancel: () => void; + readonly onChange?: (values: T[]) => void; + readonly onFocus?: (value: T) => void; + readonly focusValue?: T; /** * Text for the submit button. When provided, a submit button is shown and * Enter toggles selection (submit only fires when the button is focused). * When omitted, Enter submits directly and Space toggles selection. */ - readonly submitButtonText?: string + readonly submitButtonText?: string; /** * Callback when user submits. Receives the currently selected values. */ - readonly onSubmit?: (values: T[]) => void + readonly onSubmit?: (values: T[]) => void; /** * When true, hides the numeric indexes next to each option. */ - readonly hideIndexes?: boolean + readonly hideIndexes?: boolean; /** * Callback when user presses down from the last item (submit button). * If provided, navigation will not wrap to the first item. */ - readonly onDownFromLastItem?: () => void + readonly onDownFromLastItem?: () => void; /** * Callback when user presses up from the first item. * If provided, navigation will not wrap to the last item. */ - readonly onUpFromFirstItem?: () => void + readonly onUpFromFirstItem?: () => void; /** * Focus the last option initially instead of the first. */ - readonly initialFocusLast?: boolean + readonly initialFocusLast?: boolean; /** * Callback to open external editor for editing input option values. * When provided, ctrl+g will trigger this callback in input options * with the current value and a setter function to update the internal state. */ - readonly onOpenEditor?: ( - currentValue: string, - setValue: (value: string) => void, - ) => void + readonly onOpenEditor?: (currentValue: string, setValue: (value: string) => void) => void; readonly onImagePaste?: ( base64Image: string, mediaType?: string, filename?: string, dimensions?: ImageDimensions, sourcePath?: string, - ) => void - readonly pastedContents?: Record - readonly onRemoveImage?: (id: number) => void -} + ) => void; + readonly pastedContents?: Record; + readonly onRemoveImage?: (id: number) => void; +}; export function SelectMulti({ isDisabled = false, @@ -100,53 +97,44 @@ export function SelectMulti({ onUpFromFirstItem, initialFocusLast, hideIndexes, - }) + }); - const maxIndexWidth = options.length.toString().length + const maxIndexWidth = options.length.toString().length; return ( {state.visibleOptions.map((option, index) => { - const isOptionFocused = - !isDisabled && - state.focusedValue === option.value && - !state.isSubmitFocused - const isSelected = state.selectedValues.includes(option.value) + const isOptionFocused = !isDisabled && state.focusedValue === option.value && !state.isSubmitFocused; + const isSelected = state.selectedValues.includes(option.value); - const isFirstVisibleOption = option.index === state.visibleFromIndex - const isLastVisibleOption = option.index === state.visibleToIndex - 1 - const areMoreOptionsBelow = state.visibleToIndex < options.length - const areMoreOptionsAbove = state.visibleFromIndex > 0 + const isFirstVisibleOption = option.index === state.visibleFromIndex; + const isLastVisibleOption = option.index === state.visibleToIndex - 1; + const areMoreOptionsBelow = state.visibleToIndex < options.length; + const areMoreOptionsAbove = state.visibleFromIndex > 0; - const i = state.visibleFromIndex + index + 1 + const i = state.visibleFromIndex + index + 1; if (option.type === 'input') { - const inputValue = state.inputValues.get(option.value) || '' + const inputValue = state.inputValues.get(option.value) || ''; return ( { - state.updateInputValue(option.value, value) + state.updateInputValue(option.value, value); }} onSubmit={() => {}} /* We handle submit higher up */ onExit={() => { - onCancel() + onCancel(); }} layout="compact" onOpenEditor={onOpenEditor} @@ -154,56 +142,39 @@ export function SelectMulti({ pastedContents={pastedContents} onRemoveImage={onRemoveImage} > - - [{isSelected ? figures.tick : ' '}]{' '} - + [{isSelected ? figures.tick : ' '}] - ) + ); } return ( - {!hideIndexes && ( - {`${i}.`.padEnd(maxIndexWidth)} - )} - - [{isSelected ? figures.tick : ' '}] - - - {option.label} - + {!hideIndexes && {`${i}.`.padEnd(maxIndexWidth)}} + [{isSelected ? figures.tick : ' '}] + {option.label} - ) + ); })} {submitButtonText && onSubmit && ( - {state.isSubmitFocused ? ( - {figures.pointer} - ) : ( - - )} + {state.isSubmitFocused ? {figures.pointer} : } - + {submitButtonText} )} - ) + ); } diff --git a/src/components/CustomSelect/select-input-option.tsx b/src/components/CustomSelect/select-input-option.tsx index ccb8a3989..7ff24f43e 100644 --- a/src/components/CustomSelect/select-input-option.tsx +++ b/src/components/CustomSelect/select-input-option.tsx @@ -1,55 +1,49 @@ -import React, { type ReactNode, useEffect, useRef, useState } from 'react' +import React, { type ReactNode, useEffect, useRef, useState } from 'react'; // eslint-disable-next-line custom-rules/prefer-use-keybindings -- UP arrow exit not in Attachments bindings -import { Box, Text, useInput } from '@anthropic/ink' -import { - useKeybinding, - useKeybindings, -} from '../../keybindings/useKeybinding.js' -import type { PastedContent } from '../../utils/config.js' -import { getImageFromClipboard } from '../../utils/imagePaste.js' -import type { ImageDimensions } from '../../utils/imageResizer.js' -import { ClickableImageRef } from '../ClickableImageRef.js' -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' -import { Byline } from '@anthropic/ink' -import TextInput from '../TextInput.js' -import type { OptionWithDescription } from './select.js' -import { SelectOption } from './select-option.js' +import { Box, Text, useInput } from '@anthropic/ink'; +import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js'; +import type { PastedContent } from '../../utils/config.js'; +import { getImageFromClipboard } from '../../utils/imagePaste.js'; +import type { ImageDimensions } from '../../utils/imageResizer.js'; +import { ClickableImageRef } from '../ClickableImageRef.js'; +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; +import { Byline } from '@anthropic/ink'; +import TextInput from '../TextInput.js'; +import type { OptionWithDescription } from './select.js'; +import { SelectOption } from './select-option.js'; type Props = { - option: Extract, { type: 'input' }> - isFocused: boolean - isSelected: boolean - shouldShowDownArrow: boolean - shouldShowUpArrow: boolean - maxIndexWidth: number - index: number - inputValue: string - onInputChange: (value: string) => void - onSubmit: (value: string) => void - onExit?: () => void - layout: 'compact' | 'expanded' - children?: ReactNode + option: Extract, { type: 'input' }>; + isFocused: boolean; + isSelected: boolean; + shouldShowDownArrow: boolean; + shouldShowUpArrow: boolean; + maxIndexWidth: number; + index: number; + inputValue: string; + onInputChange: (value: string) => void; + onSubmit: (value: string) => void; + onExit?: () => void; + layout: 'compact' | 'expanded'; + children?: ReactNode; /** * When true, shows the label before the input field. * When false (default), uses the label as the placeholder. */ - showLabel?: boolean + showLabel?: boolean; /** * Callback to open external editor for editing the input value. * When provided, ctrl+g will trigger this callback with the current value * and a setter function to update the internal state. */ - onOpenEditor?: ( - currentValue: string, - setValue: (value: string) => void, - ) => void + onOpenEditor?: (currentValue: string, setValue: (value: string) => void) => void; /** * When true, automatically reset cursor to end of line when: * - Option becomes focused * - Input value changes * This prevents cursor position bugs when the input value updates asynchronously. */ - resetCursorOnUpdate?: boolean + resetCursorOnUpdate?: boolean; /** * Optional callback when an image is pasted into the input. */ @@ -59,32 +53,32 @@ type Props = { filename?: string, dimensions?: ImageDimensions, sourcePath?: string, - ) => void + ) => void; /** * Pasted content to display inline above the input when focused. */ - pastedContents?: Record + pastedContents?: Record; /** * Callback to remove a pasted image by its ID. */ - onRemoveImage?: (id: number) => void + onRemoveImage?: (id: number) => void; /** * Whether image selection mode is active. */ - imagesSelected?: boolean + imagesSelected?: boolean; /** * Currently selected image index within the image attachments array. */ - selectedImageIndex?: number + selectedImageIndex?: number; /** * Callback to set image selection mode on/off. */ - onImagesSelectedChange?: (selected: boolean) => void + onImagesSelectedChange?: (selected: boolean) => void; /** * Callback to change the selected image index. */ - onSelectedImageIndexChange?: (index: number) => void -} + onSelectedImageIndexChange?: (index: number) => void; +}; export function SelectInputOption({ option, @@ -111,17 +105,15 @@ export function SelectInputOption({ onImagesSelectedChange, onSelectedImageIndexChange, }: Props): React.ReactNode { - const imageAttachments = pastedContents - ? Object.values(pastedContents).filter(c => c.type === 'image') - : [] + const imageAttachments = pastedContents ? Object.values(pastedContents).filter(c => c.type === 'image') : []; // Allow individual options to force showing the label via showLabelWithValue - const showLabel = showLabelProp || option.showLabelWithValue === true - const [cursorOffset, setCursorOffset] = useState(inputValue.length) + const showLabel = showLabelProp || option.showLabelWithValue === true; + const [cursorOffset, setCursorOffset] = useState(inputValue.length); // Track whether the latest inputValue change was from user typing/pasting, // so we can skip resetting cursor to end on user-initiated changes. - const isUserEditing = useRef(false) + const isUserEditing = useRef(false); // Reset cursor to end of line when: // 1. Option becomes focused (user navigates to it) @@ -131,119 +123,101 @@ export function SelectInputOption({ useEffect(() => { if (resetCursorOnUpdate && isFocused) { if (isUserEditing.current) { - isUserEditing.current = false + isUserEditing.current = false; } else { - setCursorOffset(inputValue.length) + setCursorOffset(inputValue.length); } } - }, [resetCursorOnUpdate, isFocused, inputValue]) + }, [resetCursorOnUpdate, isFocused, inputValue]); // ctrl+g to open external editor (reuses chat:externalEditor keybinding) useKeybinding( 'chat:externalEditor', () => { - onOpenEditor?.(inputValue, onInputChange) + onOpenEditor?.(inputValue, onInputChange); }, { context: 'Chat', isActive: isFocused && !!onOpenEditor }, - ) + ); // ctrl+v to paste image from clipboard (same as PromptInput) useKeybinding( 'chat:imagePaste', () => { - if (!onImagePaste) return + if (!onImagePaste) return; void getImageFromClipboard().then(imageData => { if (imageData) { - onImagePaste( - imageData.base64, - imageData.mediaType, - undefined, - imageData.dimensions, - ) + onImagePaste(imageData.base64, imageData.mediaType, undefined, imageData.dimensions); } - }) + }); }, { context: 'Chat', isActive: isFocused && !!onImagePaste }, - ) + ); // Backspace with empty input removes the last pasted image (non-image-selection mode) useKeybinding( 'attachments:remove', () => { if (imageAttachments.length > 0 && onRemoveImage) { - onRemoveImage(imageAttachments.at(-1)!.id) + onRemoveImage(imageAttachments.at(-1)!.id); } }, { context: 'Attachments', - isActive: - isFocused && - !imagesSelected && - inputValue === '' && - imageAttachments.length > 0 && - !!onRemoveImage, + isActive: isFocused && !imagesSelected && inputValue === '' && imageAttachments.length > 0 && !!onRemoveImage, }, - ) + ); // Image selection mode keybindings — reuses existing Attachments actions useKeybindings( { 'attachments:next': () => { if (imageAttachments.length > 1) { - onSelectedImageIndexChange?.( - (selectedImageIndex + 1) % imageAttachments.length, - ) + onSelectedImageIndexChange?.((selectedImageIndex + 1) % imageAttachments.length); } }, 'attachments:previous': () => { if (imageAttachments.length > 1) { - onSelectedImageIndexChange?.( - (selectedImageIndex - 1 + imageAttachments.length) % - imageAttachments.length, - ) + onSelectedImageIndexChange?.((selectedImageIndex - 1 + imageAttachments.length) % imageAttachments.length); } }, 'attachments:remove': () => { - const img = imageAttachments[selectedImageIndex] + const img = imageAttachments[selectedImageIndex]; if (img && onRemoveImage) { - onRemoveImage(img.id) + onRemoveImage(img.id); // If no images left after removal, exit image selection if (imageAttachments.length <= 1) { - onImagesSelectedChange?.(false) + onImagesSelectedChange?.(false); } else { // Adjust index if we deleted the last image - onSelectedImageIndexChange?.( - Math.min(selectedImageIndex, imageAttachments.length - 2), - ) + onSelectedImageIndexChange?.(Math.min(selectedImageIndex, imageAttachments.length - 2)); } } }, 'attachments:exit': () => { - onImagesSelectedChange?.(false) + onImagesSelectedChange?.(false); }, }, { context: 'Attachments', isActive: isFocused && !!imagesSelected }, - ) + ); // UP arrow exits image selection mode (UP isn't bound to attachments:exit) useInput( (_input, key) => { if (key.upArrow) { - onImagesSelectedChange?.(false) + onImagesSelectedChange?.(false); } }, { isActive: isFocused && !!imagesSelected }, - ) + ); // Exit image mode when option loses focus useEffect(() => { if (!isFocused && imagesSelected) { - onImagesSelectedChange?.(false) + onImagesSelectedChange?.(false); } - }, [isFocused, imagesSelected, onImagesSelectedChange]) + }, [isFocused, imagesSelected, onImagesSelectedChange]); - const descriptionPaddingLeft = - layout === 'expanded' ? maxIndexWidth + 3 : maxIndexWidth + 4 + const descriptionPaddingLeft = layout === 'expanded' ? maxIndexWidth + 3 : maxIndexWidth + 4; return ( @@ -254,28 +228,21 @@ export function SelectInputOption({ shouldShowUpArrow={shouldShowUpArrow} declareCursor={false} > - + {`${index}.`.padEnd(maxIndexWidth + 2)} {children} {showLabel ? ( <> - - {option.label} - + {option.label} {isFocused ? ( <> - - {option.labelValueSeparator ?? ', '} - + {option.labelValueSeparator ?? ', '} { - isUserEditing.current = true - onInputChange(value) - option.onChange(value) + isUserEditing.current = true; + onInputChange(value); + option.onChange(value); }} onSubmit={onSubmit} onExit={onExit} @@ -288,13 +255,13 @@ export function SelectInputOption({ columns={80} onImagePaste={onImagePaste} onPaste={(pastedText: string) => { - isUserEditing.current = true - const before = inputValue.slice(0, cursorOffset) - const after = inputValue.slice(cursorOffset) - const newValue = before + pastedText + after - onInputChange(newValue) - option.onChange(newValue) - setCursorOffset(before.length + pastedText.length) + isUserEditing.current = true; + const before = inputValue.slice(0, cursorOffset); + const after = inputValue.slice(cursorOffset); + const newValue = before + pastedText + after; + onInputChange(newValue); + option.onChange(newValue); + setCursorOffset(before.length + pastedText.length); }} /> @@ -311,16 +278,13 @@ export function SelectInputOption({ { - isUserEditing.current = true - onInputChange(value) - option.onChange(value) + isUserEditing.current = true; + onInputChange(value); + option.onChange(value); }} onSubmit={onSubmit} onExit={onExit} - placeholder={ - option.placeholder || - (typeof option.label === 'string' ? option.label : undefined) - } + placeholder={option.placeholder || (typeof option.label === 'string' ? option.label : undefined)} focus={!imagesSelected} showCursor={true} multiline={true} @@ -329,19 +293,17 @@ export function SelectInputOption({ columns={80} onImagePaste={onImagePaste} onPaste={(pastedText: string) => { - isUserEditing.current = true - const before = inputValue.slice(0, cursorOffset) - const after = inputValue.slice(cursorOffset) - const newValue = before + pastedText + after - onInputChange(newValue) - option.onChange(newValue) - setCursorOffset(before.length + pastedText.length) + isUserEditing.current = true; + const before = inputValue.slice(0, cursorOffset); + const after = inputValue.slice(cursorOffset); + const newValue = before + pastedText + after; + onInputChange(newValue); + option.onChange(newValue); + setCursorOffset(before.length + pastedText.length); }} /> ) : ( - - {inputValue || option.placeholder || option.label} - + {inputValue || option.placeholder || option.label} )} @@ -349,9 +311,7 @@ export function SelectInputOption({ {option.description} @@ -408,5 +368,5 @@ export function SelectInputOption({ )} {layout === 'expanded' && } - ) + ); } diff --git a/src/components/CustomSelect/select-option.tsx b/src/components/CustomSelect/select-option.tsx index a4c5a386d..c779f4039 100644 --- a/src/components/CustomSelect/select-option.tsx +++ b/src/components/CustomSelect/select-option.tsx @@ -1,43 +1,43 @@ -import React, { type ReactNode } from 'react' -import { ListItem } from '@anthropic/ink' +import React, { type ReactNode } from 'react'; +import { ListItem } from '@anthropic/ink'; export type SelectOptionProps = { /** * Determines if option is focused. */ - readonly isFocused: boolean + readonly isFocused: boolean; /** * Determines if option is selected. */ - readonly isSelected: boolean + readonly isSelected: boolean; /** * Option label. */ - readonly children: ReactNode + readonly children: ReactNode; /** * Optional description to display below the label. */ - readonly description?: string + readonly description?: string; /** * Determines if the down arrow should be shown. */ - readonly shouldShowDownArrow?: boolean + readonly shouldShowDownArrow?: boolean; /** * Determines if the up arrow should be shown. */ - readonly shouldShowUpArrow?: boolean + readonly shouldShowUpArrow?: boolean; /** * Whether ListItem should declare the terminal cursor position. * Set false when a child declares its own cursor (e.g. BaseTextInput). */ - readonly declareCursor?: boolean -} + readonly declareCursor?: boolean; +}; export function SelectOption({ isFocused, @@ -60,5 +60,5 @@ export function SelectOption({ > {children} - ) + ); } diff --git a/src/components/CustomSelect/select.tsx b/src/components/CustomSelect/select.tsx index 7d368c2c3..65251bd4e 100644 --- a/src/components/CustomSelect/select.tsx +++ b/src/components/CustomSelect/select.tsx @@ -1,43 +1,43 @@ -import figures from 'figures' -import React, { type ReactNode, useEffect, useRef, useState } from 'react' -import { Ansi, Box, Text, stringWidth, useDeclaredCursor } from '@anthropic/ink' -import { count } from '../../utils/array.js' -import type { PastedContent } from '../../utils/config.js' -import type { ImageDimensions } from '../../utils/imageResizer.js' -import { SelectInputOption } from './select-input-option.js' -import { SelectOption } from './select-option.js' -import { useSelectInput } from './use-select-input.js' -import { useSelectState } from './use-select-state.js' +import figures from 'figures'; +import React, { type ReactNode, useEffect, useRef, useState } from 'react'; +import { Ansi, Box, Text, stringWidth, useDeclaredCursor } from '@anthropic/ink'; +import { count } from '../../utils/array.js'; +import type { PastedContent } from '../../utils/config.js'; +import type { ImageDimensions } from '../../utils/imageResizer.js'; +import { SelectInputOption } from './select-input-option.js'; +import { SelectOption } from './select-option.js'; +import { useSelectInput } from './use-select-input.js'; +import { useSelectState } from './use-select-state.js'; // Extract text content from ReactNode for width calculation function getTextContent(node: ReactNode): string { - if (typeof node === 'string') return node - if (typeof node === 'number') return String(node) - if (!node) return '' - if (Array.isArray(node)) return node.map(getTextContent).join('') + if (typeof node === 'string') return node; + if (typeof node === 'number') return String(node); + if (!node) return ''; + if (Array.isArray(node)) return node.map(getTextContent).join(''); if (React.isValidElement<{ children?: ReactNode }>(node)) { - return getTextContent(node.props.children) + return getTextContent(node.props.children); } - return '' + return ''; } type BaseOption = { - description?: string - dimDescription?: boolean - label: ReactNode - value: T - disabled?: boolean -} + description?: string; + dimDescription?: boolean; + label: ReactNode; + value: T; + disabled?: boolean; +}; export type OptionWithDescription = | (BaseOption & { - type?: 'text' + type?: 'text'; }) | (BaseOption & { - type: 'input' - onChange: (value: string) => void - placeholder?: string - initialValue?: string + type: 'input'; + onChange: (value: string) => void; + placeholder?: string; + initialValue?: string; /** * Controls behavior when submitting with empty input: * - true: calls onChange (treats empty as valid submission) @@ -46,26 +46,26 @@ export type OptionWithDescription = * Also affects initial Enter press: when true, submits immediately; * when false, enters input mode first so user can type. */ - allowEmptySubmitToCancel?: boolean + allowEmptySubmitToCancel?: boolean; /** * When true, always shows the label alongside the input value, regardless of * the global inlineDescriptions/showLabel setting. Use this when the label * provides important context that should always be visible (e.g., "Yes, and allow..."). */ - showLabelWithValue?: boolean + showLabelWithValue?: boolean; /** * Custom separator between label and value when showLabel is true. * Defaults to ", ". Use ": " for labels that read better with a colon. */ - labelValueSeparator?: string + labelValueSeparator?: string; /** * When true, automatically reset cursor to end of line when: * - Option becomes focused * - Input value changes * This prevents cursor position bugs when the input value updates asynchronously. */ - resetCursorOnUpdate?: boolean - }) + resetCursorOnUpdate?: boolean; + }); export type SelectProps = { /** @@ -73,65 +73,65 @@ export type SelectProps = { * * @default false */ - readonly isDisabled?: boolean + readonly isDisabled?: boolean; /** * When true, prevents selection on Enter but allows scrolling. * * @default false */ - readonly disableSelection?: boolean + readonly disableSelection?: boolean; /** * When true, hides the numeric indexes next to each option. * * @default false */ - readonly hideIndexes?: boolean + readonly hideIndexes?: boolean; /** * Number of visible options. * * @default 5 */ - readonly visibleOptionCount?: number + readonly visibleOptionCount?: number; /** * Highlight text in option labels. */ - readonly highlightText?: string + readonly highlightText?: string; /** * Options. */ - readonly options: OptionWithDescription[] + readonly options: OptionWithDescription[]; /** * Default value. */ - readonly defaultValue?: T + readonly defaultValue?: T; /** * Callback when cancel is pressed. */ - readonly onCancel?: () => void + readonly onCancel?: () => void; /** * Callback when selected option changes. */ - readonly onChange?: (value: T) => void + readonly onChange?: (value: T) => void; /** * Callback when focused option changes. * Note: This is for one-way notification only. Avoid combining with focusValue * for bidirectional sync, as this can cause feedback loops. */ - readonly onFocus?: (value: T) => void + readonly onFocus?: (value: T) => void; /** * Initial value to focus. This is used to set focus when the component mounts. */ - readonly defaultFocusValue?: T + readonly defaultFocusValue?: T; /** * Layout of the options. @@ -139,7 +139,7 @@ export type SelectProps = { * - `expanded` uses multiple lines and an empty line between options * - `compact-vertical` uses compact index formatting with descriptions below labels */ - readonly layout?: 'compact' | 'expanded' | 'compact-vertical' + readonly layout?: 'compact' | 'expanded' | 'compact-vertical'; /** * When true, descriptions are rendered inline after the label instead of @@ -147,35 +147,32 @@ export type SelectProps = { * * @default false */ - readonly inlineDescriptions?: boolean + readonly inlineDescriptions?: boolean; /** * Callback when user presses up from the first item. * If provided, navigation will not wrap to the last item. */ - readonly onUpFromFirstItem?: () => void + readonly onUpFromFirstItem?: () => void; /** * Callback when user presses down from the last item. * If provided, navigation will not wrap to the first item. */ - readonly onDownFromLastItem?: () => void + readonly onDownFromLastItem?: () => void; /** * Callback when input mode should be toggled for an option. * Called when Tab is pressed (to enter or exit input mode). */ - readonly onInputModeToggle?: (value: T) => void + readonly onInputModeToggle?: (value: T) => void; /** * Callback to open external editor for editing input option values. * When provided, ctrl+g will trigger this callback in input options * with the current value and a setter function to update the internal state. */ - readonly onOpenEditor?: ( - currentValue: string, - setValue: (value: string) => void, - ) => void + readonly onOpenEditor?: (currentValue: string, setValue: (value: string) => void) => void; /** * Optional callback when an image is pasted into an input option. @@ -186,18 +183,18 @@ export type SelectProps = { filename?: string, dimensions?: ImageDimensions, sourcePath?: string, - ) => void + ) => void; /** * Pasted content to display inline in input options. */ - readonly pastedContents?: Record + readonly pastedContents?: Record; /** * Callback to remove a pasted image by its ID. */ - readonly onRemoveImage?: (id: number) => void -} + readonly onRemoveImage?: (id: number) => void; +}; export function Select({ isDisabled = false, @@ -222,47 +219,47 @@ export function Select({ onRemoveImage, }: SelectProps): React.ReactNode { // Image selection mode state - const [imagesSelected, setImagesSelected] = useState(false) - const [selectedImageIndex, setSelectedImageIndex] = useState(0) + const [imagesSelected, setImagesSelected] = useState(false); + const [selectedImageIndex, setSelectedImageIndex] = useState(0); // State for input type options const [inputValues, setInputValues] = useState>(() => { - const initialMap = new Map() + const initialMap = new Map(); options.forEach(option => { if (option.type === 'input' && option.initialValue) { - initialMap.set(option.value, option.initialValue) + initialMap.set(option.value, option.initialValue); } - }) - return initialMap - }) + }); + return initialMap; + }); // Track the last initialValue we synced, so we can detect user edits - const lastInitialValues = useRef>(new Map()) + const lastInitialValues = useRef>(new Map()); // Sync initialValue changes to inputValues state, but only if user hasn't edited useEffect(() => { for (const option of options) { if (option.type === 'input' && option.initialValue !== undefined) { - const lastInitial = lastInitialValues.current.get(option.value) ?? '' - const currentValue = inputValues.get(option.value) ?? '' - const newInitial = option.initialValue + const lastInitial = lastInitialValues.current.get(option.value) ?? ''; + const currentValue = inputValues.get(option.value) ?? ''; + const newInitial = option.initialValue; // Only update if: // 1. The initialValue has changed // 2. The user hasn't edited (current value still matches the last initialValue we set) if (newInitial !== lastInitial && currentValue === lastInitial) { setInputValues(prev => { - const next = new Map(prev) - next.set(option.value, newInitial) - return next - }) + const next = new Map(prev); + next.set(option.value, newInitial); + return next; + }); } // Always track the latest initialValue - lastInitialValues.current.set(option.value, newInitial) + lastInitialValues.current.set(option.value, newInitial); } } - }, [options, inputValues]) + }, [options, inputValues]); const state = useSelectState({ visibleOptionCount, @@ -272,7 +269,7 @@ export function Select({ onCancel, onFocus, focusValue: defaultFocusValue, - }) + }); useSelectInput({ isDisabled, @@ -286,48 +283,42 @@ export function Select({ inputValues, imagesSelected, onEnterImageSelection: () => { - if ( - pastedContents && - Object.values(pastedContents).some(c => c.type === 'image') - ) { - const imageCount = count( - Object.values(pastedContents), - c => c.type === 'image', - ) - setImagesSelected(true) - setSelectedImageIndex(imageCount - 1) - return true + if (pastedContents && Object.values(pastedContents).some(c => c.type === 'image')) { + const imageCount = count(Object.values(pastedContents), c => c.type === 'image'); + setImagesSelected(true); + setSelectedImageIndex(imageCount - 1); + return true; } - return false + return false; }, - }) + }); const styles = { container: () => ({ flexDirection: 'column' as const }), highlightedText: () => ({ bold: true }), - } + }; if (layout === 'expanded') { - const maxIndexWidth = state.options.length.toString().length + const maxIndexWidth = state.options.length.toString().length; return ( {state.visibleOptions.map((option, index) => { - const isFirstVisibleOption = option.index === state.visibleFromIndex - const isLastVisibleOption = option.index === state.visibleToIndex - 1 - const areMoreOptionsBelow = state.visibleToIndex < options.length - const areMoreOptionsAbove = state.visibleFromIndex > 0 + const isFirstVisibleOption = option.index === state.visibleFromIndex; + const isLastVisibleOption = option.index === state.visibleToIndex - 1; + const areMoreOptionsBelow = state.visibleToIndex < options.length; + const areMoreOptionsAbove = state.visibleFromIndex > 0; - const i = state.visibleFromIndex + index + 1 + const i = state.visibleFromIndex + index + 1; - const isFocused = !isDisabled && state.focusedValue === option.value - const isSelected = state.value === option.value + const isFocused = !isDisabled && state.focusedValue === option.value; + const isSelected = state.value === option.value; // Handle input type options if (option.type === 'input') { const inputValue = inputValues.has(option.value) ? inputValues.get(option.value)! - : option.initialValue || '' + : option.initialValue || ''; return ( ({ inputValue={inputValue} onInputChange={value => { setInputValues(prev => { - const next = new Map(prev) - next.set(option.value, value) - return next - }) + const next = new Map(prev); + next.set(option.value, value); + return next; + }); }} onSubmit={(value: string) => { const hasImageAttachments = - pastedContents && - Object.values(pastedContents).some(c => c.type === 'image') - if ( - value.trim() || - hasImageAttachments || - option.allowEmptySubmitToCancel - ) { - onChange?.(option.value) + pastedContents && Object.values(pastedContents).some(c => c.type === 'image'); + if (value.trim() || hasImageAttachments || option.allowEmptySubmitToCancel) { + onChange?.(option.value); } else { - onCancel?.() + onCancel?.(); } }} onExit={onCancel} @@ -374,20 +360,16 @@ export function Select({ onImagesSelectedChange={setImagesSelected} onSelectedImageIndexChange={setSelectedImageIndex} /> - ) + ); } // Handle text type options - let label: ReactNode = option.label + let label: ReactNode = option.label; // Only apply highlight when label is a string - if ( - typeof option.label === 'string' && - highlightText && - option.label.includes(highlightText) - ) { - const labelText = option.label - const index = labelText.indexOf(highlightText) + if (typeof option.label === 'string' && highlightText && option.label.includes(highlightText)) { + const labelText = option.label; + const index = labelText.indexOf(highlightText); label = ( <> @@ -395,24 +377,20 @@ export function Select({ {highlightText} {labelText.slice(index + highlightText.length)} - ) + ); } - const isOptionDisabled = option.disabled === true + const isOptionDisabled = option.disabled === true; const optionColor = isOptionDisabled ? undefined : isSelected ? 'success' : isFocused ? 'suggestion' - : undefined + : undefined; return ( - + ({ {option.description && ( - + {option.description} )} - ) + ); })} - ) + ); } if (layout === 'compact-vertical') { - const maxIndexWidth = hideIndexes - ? 0 - : state.options.length.toString().length + const maxIndexWidth = hideIndexes ? 0 : state.options.length.toString().length; return ( {state.visibleOptions.map((option, index) => { - const isFirstVisibleOption = option.index === state.visibleFromIndex - const isLastVisibleOption = option.index === state.visibleToIndex - 1 - const areMoreOptionsBelow = state.visibleToIndex < options.length - const areMoreOptionsAbove = state.visibleFromIndex > 0 + const isFirstVisibleOption = option.index === state.visibleFromIndex; + const isLastVisibleOption = option.index === state.visibleToIndex - 1; + const areMoreOptionsBelow = state.visibleToIndex < options.length; + const areMoreOptionsAbove = state.visibleFromIndex > 0; - const i = state.visibleFromIndex + index + 1 + const i = state.visibleFromIndex + index + 1; - const isFocused = !isDisabled && state.focusedValue === option.value - const isSelected = state.value === option.value + const isFocused = !isDisabled && state.focusedValue === option.value; + const isSelected = state.value === option.value; // Handle input type options if (option.type === 'input') { const inputValue = inputValues.has(option.value) ? inputValues.get(option.value)! - : option.initialValue || '' + : option.initialValue || ''; return ( ({ inputValue={inputValue} onInputChange={value => { setInputValues(prev => { - const next = new Map(prev) - next.set(option.value, value) - return next - }) + const next = new Map(prev); + next.set(option.value, value); + return next; + }); }} onSubmit={(value: string) => { const hasImageAttachments = - pastedContents && - Object.values(pastedContents).some(c => c.type === 'image') - if ( - value.trim() || - hasImageAttachments || - option.allowEmptySubmitToCancel - ) { - onChange?.(option.value) + pastedContents && Object.values(pastedContents).some(c => c.type === 'image'); + if (value.trim() || hasImageAttachments || option.allowEmptySubmitToCancel) { + onChange?.(option.value); } else { - onCancel?.() + onCancel?.(); } }} onExit={onCancel} @@ -512,20 +478,16 @@ export function Select({ onImagesSelectedChange={setImagesSelected} onSelectedImageIndexChange={setSelectedImageIndex} /> - ) + ); } // Handle text type options - let label: ReactNode = option.label + let label: ReactNode = option.label; // Only apply highlight when label is a string - if ( - typeof option.label === 'string' && - highlightText && - option.label.includes(highlightText) - ) { - const labelText = option.label - const index = labelText.indexOf(highlightText) + if (typeof option.label === 'string' && highlightText && option.label.includes(highlightText)) { + const labelText = option.label; + const index = labelText.indexOf(highlightText); label = ( <> @@ -533,17 +495,13 @@ export function Select({ {highlightText} {labelText.slice(index + highlightText.length)} - ) + ); } - const isOptionDisabled = option.disabled === true + const isOptionDisabled = option.disabled === true; return ( - + ({ shouldShowUpArrow={areMoreOptionsAbove && isFirstVisibleOption} > <> - {!hideIndexes && ( - {`${i}.`.padEnd(maxIndexWidth + 1)} - )} + {!hideIndexes && {`${i}.`.padEnd(maxIndexWidth + 1)}} {label} @@ -573,67 +521,50 @@ export function Select({ {option.description && ( {option.description} )} - ) + ); })} - ) + ); } - const maxIndexWidth = hideIndexes ? 0 : state.options.length.toString().length + const maxIndexWidth = hideIndexes ? 0 : state.options.length.toString().length; // Check if any visible options have descriptions (for two-column layout) // Also check that there are NO input options, since they're not supported in two-column layout // Skip two-column layout when inlineDescriptions is enabled - const hasInputOptions = state.visibleOptions.some(opt => opt.type === 'input') - const hasDescriptions = - !inlineDescriptions && - !hasInputOptions && - state.visibleOptions.some(opt => opt.description) + const hasInputOptions = state.visibleOptions.some(opt => opt.type === 'input'); + const hasDescriptions = !inlineDescriptions && !hasInputOptions && state.visibleOptions.some(opt => opt.description); // Pre-compute option data for two-column layout const optionData = state.visibleOptions.map((option, index) => { - const isFirstVisibleOption = option.index === state.visibleFromIndex - const isLastVisibleOption = option.index === state.visibleToIndex - 1 - const areMoreOptionsBelow = state.visibleToIndex < options.length - const areMoreOptionsAbove = state.visibleFromIndex > 0 - const i = state.visibleFromIndex + index + 1 - const isFocused = !isDisabled && state.focusedValue === option.value - const isSelected = state.value === option.value - const isOptionDisabled = option.disabled === true + const isFirstVisibleOption = option.index === state.visibleFromIndex; + const isLastVisibleOption = option.index === state.visibleToIndex - 1; + const areMoreOptionsBelow = state.visibleToIndex < options.length; + const areMoreOptionsAbove = state.visibleFromIndex > 0; + const i = state.visibleFromIndex + index + 1; + const isFocused = !isDisabled && state.focusedValue === option.value; + const isSelected = state.value === option.value; + const isOptionDisabled = option.disabled === true; - let label: ReactNode = option.label - if ( - typeof option.label === 'string' && - highlightText && - option.label.includes(highlightText) - ) { - const labelText = option.label - const idx = labelText.indexOf(highlightText) + let label: ReactNode = option.label; + if (typeof option.label === 'string' && highlightText && option.label.includes(highlightText)) { + const labelText = option.label; + const idx = labelText.indexOf(highlightText); label = ( <> {labelText.slice(0, idx)} {highlightText} {labelText.slice(idx + highlightText.length)} - ) + ); } return { @@ -645,41 +576,37 @@ export function Select({ isOptionDisabled, shouldShowDownArrow: areMoreOptionsBelow && isLastVisibleOption, shouldShowUpArrow: areMoreOptionsAbove && isFirstVisibleOption, - } - }) + }; + }); // Calculate max label width for alignment when descriptions exist if (hasDescriptions) { const maxLabelWidth = Math.max( ...optionData.map(data => { - if (data.option.type === 'input') return 0 - const labelText = getTextContent(data.option.label) + if (data.option.type === 'input') return 0; + const labelText = getTextContent(data.option.label); // Width: indicator (1) + space (1) + index + label + space + checkmark (1) - const indexWidth = hideIndexes ? 0 : maxIndexWidth + 2 - const checkmarkWidth = data.isSelected ? 2 : 0 - return 2 + indexWidth + stringWidth(labelText) + checkmarkWidth + const indexWidth = hideIndexes ? 0 : maxIndexWidth + 2; + const checkmarkWidth = data.isSelected ? 2 : 0; + return 2 + indexWidth + stringWidth(labelText) + checkmarkWidth; }), - ) + ); return ( {optionData.map(data => { if (data.option.type === 'input') { // Input options not supported in two-column layout - return null + return null; } - const labelText = getTextContent(data.option.label) - const indexWidth = hideIndexes ? 0 : maxIndexWidth + 2 - const checkmarkWidth = data.isSelected ? 2 : 0 - const currentLabelWidth = - 2 + indexWidth + stringWidth(labelText) + checkmarkWidth - const padding = maxLabelWidth - currentLabelWidth + const labelText = getTextContent(data.option.label); + const indexWidth = hideIndexes ? 0 : maxIndexWidth + 2; + const checkmarkWidth = data.isSelected ? 2 : 0; + const currentLabelWidth = 2 + indexWidth + stringWidth(labelText) + checkmarkWidth; + const padding = maxLabelWidth - currentLabelWidth; return ( - + {/* Label part - no gap, handle spacing explicitly */} {data.isFocused ? ( @@ -704,16 +631,10 @@ export function Select({ : undefined } > - {!hideIndexes && ( - - {`${data.index}.`.padEnd(maxIndexWidth + 2)} - - )} + {!hideIndexes && {`${data.index}.`.padEnd(maxIndexWidth + 2)}} {data.label} - {data.isSelected && ( - {figures.tick} - )} + {data.isSelected && {figures.tick}} {/* Padding to align descriptions */} {padding > 0 && {' '.repeat(padding)}} @@ -721,10 +642,7 @@ export function Select({ ({ - ) + ); })} - ) + ); } return ( @@ -750,19 +668,17 @@ export function Select({ {state.visibleOptions.map((option, index) => { // Handle input type options if (option.type === 'input') { - const inputValue = inputValues.has(option.value) - ? inputValues.get(option.value)! - : option.initialValue || '' + const inputValue = inputValues.has(option.value) ? inputValues.get(option.value)! : option.initialValue || ''; - const isFirstVisibleOption = option.index === state.visibleFromIndex - const isLastVisibleOption = option.index === state.visibleToIndex - 1 - const areMoreOptionsBelow = state.visibleToIndex < options.length - const areMoreOptionsAbove = state.visibleFromIndex > 0 + const isFirstVisibleOption = option.index === state.visibleFromIndex; + const isLastVisibleOption = option.index === state.visibleToIndex - 1; + const areMoreOptionsBelow = state.visibleToIndex < options.length; + const areMoreOptionsAbove = state.visibleFromIndex > 0; - const i = state.visibleFromIndex + index + 1 + const i = state.visibleFromIndex + index + 1; - const isFocused = !isDisabled && state.focusedValue === option.value - const isSelected = state.value === option.value + const isFocused = !isDisabled && state.focusedValue === option.value; + const isSelected = state.value === option.value; return ( ({ inputValue={inputValue} onInputChange={value => { setInputValues(prev => { - const next = new Map(prev) - next.set(option.value, value) - return next - }) + const next = new Map(prev); + next.set(option.value, value); + return next; + }); }} onSubmit={(value: string) => { const hasImageAttachments = - pastedContents && - Object.values(pastedContents).some(c => c.type === 'image') - if ( - value.trim() || - hasImageAttachments || - option.allowEmptySubmitToCancel - ) { - onChange?.(option.value) + pastedContents && Object.values(pastedContents).some(c => c.type === 'image'); + if (value.trim() || hasImageAttachments || option.allowEmptySubmitToCancel) { + onChange?.(option.value); } else { - onCancel?.() + onCancel?.(); } }} onExit={onCancel} @@ -809,20 +720,16 @@ export function Select({ onImagesSelectedChange={setImagesSelected} onSelectedImageIndexChange={setSelectedImageIndex} /> - ) + ); } // Handle text type options - let label: ReactNode = option.label + let label: ReactNode = option.label; // Only apply highlight when label is a string - if ( - typeof option.label === 'string' && - highlightText && - option.label.includes(highlightText) - ) { - const labelText = option.label - const index = labelText.indexOf(highlightText) + if (typeof option.label === 'string' && highlightText && option.label.includes(highlightText)) { + const labelText = option.label; + const index = labelText.indexOf(highlightText); label = ( <> @@ -830,19 +737,19 @@ export function Select({ {highlightText} {labelText.slice(index + highlightText.length)} - ) + ); } - const isFirstVisibleOption = option.index === state.visibleFromIndex - const isLastVisibleOption = option.index === state.visibleToIndex - 1 - const areMoreOptionsBelow = state.visibleToIndex < options.length - const areMoreOptionsAbove = state.visibleFromIndex > 0 + const isFirstVisibleOption = option.index === state.visibleFromIndex; + const isLastVisibleOption = option.index === state.visibleToIndex - 1; + const areMoreOptionsBelow = state.visibleToIndex < options.length; + const areMoreOptionsAbove = state.visibleFromIndex > 0; - const i = state.visibleFromIndex + index + 1 + const i = state.visibleFromIndex + index + 1; - const isFocused = !isDisabled && state.focusedValue === option.value - const isSelected = state.value === option.value - const isOptionDisabled = option.disabled === true + const isFocused = !isDisabled && state.focusedValue === option.value; + const isSelected = state.value === option.value; + const isOptionDisabled = option.disabled === true; return ( ({ shouldShowUpArrow={areMoreOptionsAbove && isFirstVisibleOption} > - {!hideIndexes && ( - {`${i}.`.padEnd(maxIndexWidth + 2)} - )} + {!hideIndexes && {`${i}.`.padEnd(maxIndexWidth + 2)}} {label} {inlineDescriptions && option.description && ( - - {' '} - {option.description} - + {option.description} )} @@ -886,46 +776,32 @@ export function Select({ {option.description} )} - ) + ); })} - ) + ); } // Row container for the two-column (label + description) layout. Unlike // the other Select layouts, this one doesn't render through SelectOption → // ListItem, so it declares the native cursor directly. Parks the cursor // on the pointer indicator so screen readers / magnifiers track focus. -function TwoColumnRow({ - isFocused, - children, -}: { - isFocused: boolean - children: ReactNode -}): React.ReactNode { +function TwoColumnRow({ isFocused, children }: { isFocused: boolean; children: ReactNode }): React.ReactNode { const cursorRef = useDeclaredCursor({ line: 0, column: 0, active: isFocused, - }) + }); return ( {children} - ) + ); } diff --git a/src/components/CustomSelect/use-multi-select-state.ts b/src/components/CustomSelect/use-multi-select-state.ts index a089a20d4..66ca78d70 100644 --- a/src/components/CustomSelect/use-multi-select-state.ts +++ b/src/components/CustomSelect/use-multi-select-state.ts @@ -381,7 +381,7 @@ export function useMultiSelectState({ // Handle numeric keys (1-9) for direct selection if (!hideIndexes && /^[0-9]+$/.test(normalizedInput)) { - const index = parseInt(normalizedInput) - 1 + const index = parseInt(normalizedInput, 10) - 1 if (index >= 0 && index < options.length) { const value = options[index]!.value const newValues = selectedValues.includes(value) diff --git a/src/components/CustomSelect/use-select-input.ts b/src/components/CustomSelect/use-select-input.ts index b289056ee..0dcccc3c1 100644 --- a/src/components/CustomSelect/use-select-input.ts +++ b/src/components/CustomSelect/use-select-input.ts @@ -255,7 +255,7 @@ export const useSelectInput = ({ disableSelection !== 'numeric' && /^[0-9]+$/.test(normalizedInput) ) { - const index = parseInt(normalizedInput) - 1 + const index = parseInt(normalizedInput, 10) - 1 if (index >= 0 && index < state.options.length) { const selectedOption = state.options[index]! if (selectedOption.disabled === true) { diff --git a/src/components/DesktopHandoff.tsx b/src/components/DesktopHandoff.tsx index 8dfe65156..60e51e3ae 100644 --- a/src/components/DesktopHandoff.tsx +++ b/src/components/DesktopHandoff.tsx @@ -1,120 +1,106 @@ -import React, { useEffect, useState } from 'react' -import type { CommandResultDisplay } from '../commands.js' +import React, { useEffect, useState } from 'react'; +import type { CommandResultDisplay } from '../commands.js'; // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw input for "any key" dismiss and y/n prompt -import { Box, Text, useInput, LoadingState } from '@anthropic/ink' -import { getDesktopInstallStatus, openCurrentSessionInDesktop } from '../utils/desktopDeepLink.js' -import { openBrowser } from '../utils/browser.js' +import { Box, Text, useInput, LoadingState } from '@anthropic/ink'; +import { getDesktopInstallStatus, openCurrentSessionInDesktop } from '../utils/desktopDeepLink.js'; +import { openBrowser } from '../utils/browser.js'; -import { errorMessage } from '../utils/errors.js' -import { gracefulShutdown } from '../utils/gracefulShutdown.js' -import { flushSessionStorage } from '../utils/sessionStorage.js' +import { errorMessage } from '../utils/errors.js'; +import { gracefulShutdown } from '../utils/gracefulShutdown.js'; +import { flushSessionStorage } from '../utils/sessionStorage.js'; -const DESKTOP_DOCS_URL = 'https://clau.de/desktop' +const DESKTOP_DOCS_URL = 'https://clau.de/desktop'; export function getDownloadUrl(): string { switch (process.platform) { case 'win32': - return 'https://claude.ai/api/desktop/win32/x64/exe/latest/redirect' + return 'https://claude.ai/api/desktop/win32/x64/exe/latest/redirect'; default: - return 'https://claude.ai/api/desktop/darwin/universal/dmg/latest/redirect' + return 'https://claude.ai/api/desktop/darwin/universal/dmg/latest/redirect'; } } -type DesktopHandoffState = - | 'checking' - | 'prompt-download' - | 'flushing' - | 'opening' - | 'success' - | 'error' +type DesktopHandoffState = 'checking' | 'prompt-download' | 'flushing' | 'opening' | 'success' | 'error'; type Props = { - onDone: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void -} + onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void; +}; export function DesktopHandoff({ onDone }: Props): React.ReactNode { - const [state, setState] = useState('checking') - const [error, setError] = useState(null) - const [downloadMessage, setDownloadMessage] = useState('') + const [state, setState] = useState('checking'); + const [error, setError] = useState(null); + const [downloadMessage, setDownloadMessage] = useState(''); // Handle keyboard input for error and prompt-download states useInput(input => { if (state === 'error') { - onDone(error ?? 'Unknown error', { display: 'system' }) - return + onDone(error ?? 'Unknown error', { display: 'system' }); + return; } if (state === 'prompt-download') { if (input === 'y' || input === 'Y') { - openBrowser(getDownloadUrl()).catch(() => {}) + openBrowser(getDownloadUrl()).catch(() => {}); onDone( `Starting download. Re-run /desktop once you\u2019ve installed the app.\nLearn more at ${DESKTOP_DOCS_URL}`, { display: 'system' }, - ) + ); } else if (input === 'n' || input === 'N') { - onDone( - `The desktop app is required for /desktop. Learn more at ${DESKTOP_DOCS_URL}`, - { display: 'system' }, - ) + onDone(`The desktop app is required for /desktop. Learn more at ${DESKTOP_DOCS_URL}`, { display: 'system' }); } } - }) + }); useEffect(() => { async function performHandoff(): Promise { // Check Desktop install status - setState('checking') - const installStatus = await getDesktopInstallStatus() + setState('checking'); + const installStatus = await getDesktopInstallStatus(); if (installStatus.status === 'not-installed') { - setDownloadMessage('Claude Desktop is not installed.') - setState('prompt-download') - return + setDownloadMessage('Claude Desktop is not installed.'); + setState('prompt-download'); + return; } if (installStatus.status === 'version-too-old') { - setDownloadMessage( - `Claude Desktop needs to be updated (found v${installStatus.version}, need v1.1.2396+).`, - ) - setState('prompt-download') - return + setDownloadMessage(`Claude Desktop needs to be updated (found v${installStatus.version}, need v1.1.2396+).`); + setState('prompt-download'); + return; } // Flush session storage to ensure transcript is fully written - setState('flushing') - await flushSessionStorage() + setState('flushing'); + await flushSessionStorage(); // Open the deep link (uses claude-dev:// in dev mode) - setState('opening') - const result = await openCurrentSessionInDesktop() + setState('opening'); + const result = await openCurrentSessionInDesktop(); if (!result.success) { - setError(result.error ?? 'Failed to open Claude Desktop') - setState('error') - return + setError(result.error ?? 'Failed to open Claude Desktop'); + setState('error'); + return; } // Success - exit the CLI - setState('success') + setState('success'); // Give the user a moment to see the success message setTimeout( async (onDone: Props['onDone']) => { - onDone('Session transferred to Claude Desktop', { display: 'system' }) - await gracefulShutdown(0, 'other') + onDone('Session transferred to Claude Desktop', { display: 'system' }); + await gracefulShutdown(0, 'other'); }, 500, onDone, - ) + ); } performHandoff().catch(err => { - setError(errorMessage(err)) - setState('error') - }) - }, [onDone]) + setError(errorMessage(err)); + setState('error'); + }); + }, [onDone]); if (state === 'error') { return ( @@ -122,7 +108,7 @@ export function DesktopHandoff({ onDone }: Props): React.ReactNode { Error: {error} Press any key to continue… - ) + ); } if (state === 'prompt-download') { @@ -131,18 +117,15 @@ export function DesktopHandoff({ onDone }: Props): React.ReactNode { {downloadMessage} Download now? (y/n) - ) + ); } - const messages: Record< - Exclude, - string - > = { + const messages: Record, string> = { checking: 'Checking for Claude Desktop…', flushing: 'Saving session…', opening: 'Opening Claude Desktop…', success: 'Opening in Claude Desktop…', - } + }; - return + return ; } diff --git a/src/components/DesktopUpsell/DesktopUpsellStartup.tsx b/src/components/DesktopUpsell/DesktopUpsellStartup.tsx index f2ca4d46e..a73b9f226 100644 --- a/src/components/DesktopUpsell/DesktopUpsellStartup.tsx +++ b/src/components/DesktopUpsell/DesktopUpsellStartup.tsx @@ -1,84 +1,78 @@ -import * as React from 'react' -import { useEffect, useState } from 'react' -import { Box, Text } from '@anthropic/ink' -import { getDynamicConfig_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' -import { logEvent } from '../../services/analytics/index.js' -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' -import { Select } from '../CustomSelect/select.js' -import { DesktopHandoff } from '../DesktopHandoff.js' -import { PermissionDialog } from '../permissions/PermissionDialog.js' +import * as React from 'react'; +import { useEffect, useState } from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { getDynamicConfig_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'; +import { logEvent } from '../../services/analytics/index.js'; +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; +import { Select } from '../CustomSelect/select.js'; +import { DesktopHandoff } from '../DesktopHandoff.js'; +import { PermissionDialog } from '../permissions/PermissionDialog.js'; type DesktopUpsellConfig = { - enable_shortcut_tip: boolean - enable_startup_dialog: boolean -} + enable_shortcut_tip: boolean; + enable_startup_dialog: boolean; +}; const DESKTOP_UPSELL_DEFAULT: DesktopUpsellConfig = { enable_shortcut_tip: false, enable_startup_dialog: false, -} +}; export function getDesktopUpsellConfig(): DesktopUpsellConfig { - return getDynamicConfig_CACHED_MAY_BE_STALE( - 'tengu_desktop_upsell', - DESKTOP_UPSELL_DEFAULT, - ) + return getDynamicConfig_CACHED_MAY_BE_STALE('tengu_desktop_upsell', DESKTOP_UPSELL_DEFAULT); } function isSupportedPlatform(): boolean { - return ( - process.platform === 'darwin' || - (process.platform === 'win32' && process.arch === 'x64') - ) + return process.platform === 'darwin' || (process.platform === 'win32' && process.arch === 'x64'); } export function shouldShowDesktopUpsellStartup(): boolean { - if (!isSupportedPlatform()) return false - if (!getDesktopUpsellConfig().enable_startup_dialog) return false - const config = getGlobalConfig() - if (config.desktopUpsellDismissed) return false - if ((config.desktopUpsellSeenCount ?? 0) >= 3) return false - return true + if (!isSupportedPlatform()) return false; + if (!getDesktopUpsellConfig().enable_startup_dialog) return false; + const config = getGlobalConfig(); + if (config.desktopUpsellDismissed) return false; + if ((config.desktopUpsellSeenCount ?? 0) >= 3) return false; + return true; } -type DesktopUpsellSelection = 'try' | 'not-now' | 'never' +type DesktopUpsellSelection = 'try' | 'not-now' | 'never'; type Props = { - onDone: () => void -} + onDone: () => void; +}; export function DesktopUpsellStartup({ onDone }: Props): React.ReactNode { - const [showHandoff, setShowHandoff] = useState(false) + const [showHandoff, setShowHandoff] = useState(false); // Increment seen count on mount (guard in updater for StrictMode safety) useEffect(() => { - const newCount = (getGlobalConfig().desktopUpsellSeenCount ?? 0) + 1 + const newCount = (getGlobalConfig().desktopUpsellSeenCount ?? 0) + 1; saveGlobalConfig(prev => { - if ((prev.desktopUpsellSeenCount ?? 0) >= newCount) return prev - return { ...prev, desktopUpsellSeenCount: newCount } - }) - logEvent('tengu_desktop_upsell_shown', { seen_count: newCount }) - }, []) + if ((prev.desktopUpsellSeenCount ?? 0) >= newCount) return prev; + return { ...prev, desktopUpsellSeenCount: newCount }; + }); + logEvent('tengu_desktop_upsell_shown', { seen_count: newCount }); + }, []); if (showHandoff) { - return onDone()} /> + return onDone()} />; } function handleSelect(value: DesktopUpsellSelection): void { switch (value) { case 'try': - setShowHandoff(true) - return + setShowHandoff(true); + return; case 'never': saveGlobalConfig(prev => { - if (prev.desktopUpsellDismissed) return prev - return { ...prev, desktopUpsellDismissed: true } - }) - onDone() - return + if (prev.desktopUpsellDismissed) return prev; + return { ...prev, desktopUpsellDismissed: true }; + }); + onDone(); + return; case 'not-now': - onDone() - return + onDone(); + return; } } @@ -86,23 +80,16 @@ export function DesktopUpsellStartup({ onDone }: Props): React.ReactNode { { label: 'Open in Claude Code Desktop', value: 'try' as const }, { label: 'Not now', value: 'not-now' as const }, { label: "Don't ask again", value: 'never' as const }, - ] + ]; return ( - - Same Claude Code with visual diffs, live app preview, parallel - sessions, and more. - + Same Claude Code with visual diffs, live app preview, parallel sessions, and more. - handleSelect('not-now')} /> - ) + ); } diff --git a/src/components/DevBar.tsx b/src/components/DevBar.tsx index c1f8aa9ee..181557239 100644 --- a/src/components/DevBar.tsx +++ b/src/components/DevBar.tsx @@ -1,46 +1,44 @@ -import * as React from 'react' -import { useState } from 'react' -import { getSlowOperations } from '../bootstrap/state.js' -import { Text, useInterval } from '@anthropic/ink' +import * as React from 'react'; +import { useState } from 'react'; +import { getSlowOperations } from '../bootstrap/state.js'; +import { Text, useInterval } from '@anthropic/ink'; // Show DevBar for dev builds or all ants function shouldShowDevBar(): boolean { - return ( - process.env.NODE_ENV === 'development' || process.env.USER_TYPE === 'ant' - ) + return process.env.NODE_ENV === 'development' || process.env.USER_TYPE === 'ant'; } export function DevBar(): React.ReactNode { const [slowOps, setSlowOps] = useState< ReadonlyArray<{ - operation: string - durationMs: number - timestamp: number + operation: string; + durationMs: number; + timestamp: number; }> - >(getSlowOperations) + >(getSlowOperations); useInterval( () => { - setSlowOps(getSlowOperations()) + setSlowOps(getSlowOperations()); }, shouldShowDevBar() ? 500 : null, - ) + ); // Only show when there's something to display if (!shouldShowDevBar() || slowOps.length === 0) { - return null + return null; } // Single-line format so short terminals don't lose rows to dev noise. const recentOps = slowOps .slice(-3) .map(op => `${op.operation} (${Math.round(op.durationMs)}ms)`) - .join(' · ') + .join(' · '); return ( [ANT-ONLY] slow sync: {recentOps} - ) + ); } diff --git a/src/components/DevChannelsDialog.tsx b/src/components/DevChannelsDialog.tsx index f055e4ae6..e2fe4bd81 100644 --- a/src/components/DevChannelsDialog.tsx +++ b/src/components/DevChannelsDialog.tsx @@ -1,54 +1,42 @@ -import React, { useCallback } from 'react' -import type { ChannelEntry } from '../bootstrap/state.js' -import { Box, Text, Dialog } from '@anthropic/ink' -import { gracefulShutdownSync } from '../utils/gracefulShutdown.js' -import { Select } from './CustomSelect/index.js' +import React, { useCallback } from 'react'; +import type { ChannelEntry } from '../bootstrap/state.js'; +import { Box, Text, Dialog } from '@anthropic/ink'; +import { gracefulShutdownSync } from '../utils/gracefulShutdown.js'; +import { Select } from './CustomSelect/index.js'; type Props = { - channels: ChannelEntry[] - onAccept(): void -} + channels: ChannelEntry[]; + onAccept(): void; +}; -export function DevChannelsDialog({ - channels, - onAccept, -}: Props): React.ReactNode { +export function DevChannelsDialog({ channels, onAccept }: Props): React.ReactNode { function onChange(value: 'accept' | 'exit') { switch (value) { case 'accept': - onAccept() - break + onAccept(); + break; case 'exit': - gracefulShutdownSync(1) - break + gracefulShutdownSync(1); + break; } } const handleEscape = useCallback(() => { - gracefulShutdownSync(0) - }, []) + gracefulShutdownSync(0); + }, []); return ( - + - --dangerously-load-development-channels is for local channel - development only. Do not use this option to run channels you have - downloaded off the internet. + --dangerously-load-development-channels is for local channel development only. Do not use this option to run + channels you have downloaded off the internet. Please use --channels to run a list of approved channels. Channels:{' '} {channels - .map(c => - c.kind === 'plugin' - ? `plugin:${c.name}@${c.marketplace}` - : `server:${c.name}`, - ) + .map(c => (c.kind === 'plugin' ? `plugin:${c.name}@${c.marketplace}` : `server:${c.name}`)) .join(', ')} @@ -61,5 +49,5 @@ export function DevChannelsDialog({ onChange={value => onChange(value as 'accept' | 'exit')} /> - ) + ); } diff --git a/src/components/DiagnosticsDisplay.tsx b/src/components/DiagnosticsDisplay.tsx index 5a92190b5..d588eb9b7 100644 --- a/src/components/DiagnosticsDisplay.tsx +++ b/src/components/DiagnosticsDisplay.tsx @@ -1,33 +1,27 @@ -import { relative } from 'path' -import React from 'react' -import { Box, Text } from '@anthropic/ink' -import { DiagnosticTrackingService } from '../services/diagnosticTracking.js' -import type { Attachment } from '../utils/attachments.js' -import { getCwd } from '../utils/cwd.js' -import { CtrlOToExpand } from './CtrlOToExpand.js' -import { MessageResponse } from './MessageResponse.js' +import { relative } from 'path'; +import React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { DiagnosticTrackingService } from '../services/diagnosticTracking.js'; +import type { Attachment } from '../utils/attachments.js'; +import { getCwd } from '../utils/cwd.js'; +import { CtrlOToExpand } from './CtrlOToExpand.js'; +import { MessageResponse } from './MessageResponse.js'; -type DiagnosticsAttachment = Extract +type DiagnosticsAttachment = Extract; type DiagnosticsDisplayProps = { - attachment: DiagnosticsAttachment - verbose: boolean -} + attachment: DiagnosticsAttachment; + verbose: boolean; +}; -export function DiagnosticsDisplay({ - attachment, - verbose, -}: DiagnosticsDisplayProps): React.ReactNode { +export function DiagnosticsDisplay({ attachment, verbose }: DiagnosticsDisplayProps): React.ReactNode { // Only show if there are diagnostics to report - if (attachment.files.length === 0) return null + if (attachment.files.length === 0) return null; // Count total issues - const totalIssues = attachment.files.reduce( - (sum, file) => sum + file.diagnostics.length, - 0, - ) + const totalIssues = attachment.files.reduce((sum, file) => sum + file.diagnostics.length, 0); - const fileCount = attachment.files.length + const fileCount = attachment.files.length; if (verbose) { // Show all diagnostics in verbose mode (ctrl+o) @@ -37,14 +31,7 @@ export function DiagnosticsDisplay({ - - {relative( - getCwd(), - file.uri - .replace('file://', '') - .replace('_claude_fs_right:', ''), - )} - {' '} + {relative(getCwd(), file.uri.replace('file://', '').replace('_claude_fs_right:', ''))}{' '} {file.uri.startsWith('file://') ? '(file://)' @@ -59,12 +46,9 @@ export function DiagnosticsDisplay({ {' '} - {DiagnosticTrackingService.getSeveritySymbol( - diagnostic.severity, - )} + {DiagnosticTrackingService.getSeveritySymbol(diagnostic.severity)} {' [Line '} - {diagnostic.range.start.line + 1}: - {diagnostic.range.start.character + 1} + {diagnostic.range.start.line + 1}:{diagnostic.range.start.character + 1} {'] '} {diagnostic.message} {diagnostic.code ? ` [${diagnostic.code}]` : ''} @@ -75,17 +59,16 @@ export function DiagnosticsDisplay({ ))} - ) + ); } else { // Show summary in normal mode return ( - Found {totalIssues} new diagnostic{' '} - {totalIssues === 1 ? 'issue' : 'issues'} in {fileCount}{' '} + Found {totalIssues} new diagnostic {totalIssues === 1 ? 'issue' : 'issues'} in {fileCount}{' '} {fileCount === 1 ? 'file' : 'files'} - ) + ); } } diff --git a/src/components/EffortCallout.tsx b/src/components/EffortCallout.tsx index a9acc10a8..95a45f27e 100644 --- a/src/components/EffortCallout.tsx +++ b/src/components/EffortCallout.tsx @@ -1,72 +1,66 @@ -import React, { useCallback, useEffect, useRef } from 'react' -import { Box, Text } from '@anthropic/ink' -import { - isMaxSubscriber, - isProSubscriber, - isTeamSubscriber, -} from '../utils/auth.js' -import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' -import type { EffortLevel } from '../utils/effort.js' +import React, { useCallback, useEffect, useRef } from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { isMaxSubscriber, isProSubscriber, isTeamSubscriber } from '../utils/auth.js'; +import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'; +import type { EffortLevel } from '../utils/effort.js'; import { convertEffortValueToLevel, getDefaultEffortForModel, getOpusDefaultEffortConfig, toPersistableEffort, -} from '../utils/effort.js' -import { parseUserSpecifiedModel } from '../utils/model/model.js' -import { updateSettingsForSource } from '../utils/settings/settings.js' -import type { OptionWithDescription } from './CustomSelect/select.js' -import { Select } from './CustomSelect/select.js' -import { effortLevelToSymbol } from './EffortIndicator.js' -import { PermissionDialog } from './permissions/PermissionDialog.js' +} from '../utils/effort.js'; +import { parseUserSpecifiedModel } from '../utils/model/model.js'; +import { updateSettingsForSource } from '../utils/settings/settings.js'; +import type { OptionWithDescription } from './CustomSelect/select.js'; +import { Select } from './CustomSelect/select.js'; +import { effortLevelToSymbol } from './EffortIndicator.js'; +import { PermissionDialog } from './permissions/PermissionDialog.js'; -type EffortCalloutSelection = EffortLevel | undefined | 'dismiss' +type EffortCalloutSelection = EffortLevel | undefined | 'dismiss'; type Props = { - model: string - onDone: (selection: EffortCalloutSelection) => void -} + model: string; + onDone: (selection: EffortCalloutSelection) => void; +}; -const AUTO_DISMISS_MS = 30_000 +const AUTO_DISMISS_MS = 30_000; export function EffortCallout({ model, onDone }: Props): React.ReactNode { - const defaultEffortConfig = getOpusDefaultEffortConfig() + const defaultEffortConfig = getOpusDefaultEffortConfig(); // Latest-ref pattern — write via effect so React Compiler can memoize. - const onDoneRef = useRef(onDone) + const onDoneRef = useRef(onDone); useEffect(() => { - onDoneRef.current = onDone - }) + onDoneRef.current = onDone; + }); const handleCancel = useCallback((): void => { - onDoneRef.current('dismiss') - }, []) + onDoneRef.current('dismiss'); + }, []); // Permanently dismiss on mount so it only shows once useEffect(() => { - markV2Dismissed() - }, []) + markV2Dismissed(); + }, []); // 30-second auto-dismiss timer useEffect(() => { - const timeoutId = setTimeout(handleCancel, AUTO_DISMISS_MS) - return () => clearTimeout(timeoutId) - }, [handleCancel]) + const timeoutId = setTimeout(handleCancel, AUTO_DISMISS_MS); + return () => clearTimeout(timeoutId); + }, [handleCancel]); - const defaultEffort = getDefaultEffortForModel(model) - const defaultLevel = defaultEffort - ? convertEffortValueToLevel(defaultEffort) - : 'high' + const defaultEffort = getDefaultEffortForModel(model); + const defaultLevel = defaultEffort ? convertEffortValueToLevel(defaultEffort) : 'high'; const handleSelect = useCallback( (value: EffortLevel): void => { - const effortLevel = value === defaultLevel ? undefined : value + const effortLevel = value === defaultLevel ? undefined : value; updateSettingsForSource('userSettings', { effortLevel: toPersistableEffort(effortLevel), - }) - onDoneRef.current(value) + }); + onDoneRef.current(value); }, [defaultLevel], - ) + ); const options: OptionWithDescription[] = [ { @@ -75,7 +69,7 @@ export function EffortCallout({ model, onDone }: Props): React.ReactNode { }, { label: , value: 'high' }, { label: , value: 'low' }, - ] + ]; return ( @@ -85,41 +79,26 @@ export function EffortCallout({ model, onDone }: Props): React.ReactNode { - low {'·'}{' '} - medium {'·'}{' '} + low {'·'} medium {'·'}{' '} high - - ) + ); } -function EffortIndicatorSymbol({ - level, -}: { - level: EffortLevel -}): React.ReactNode { - return {effortLevelToSymbol(level)} +function EffortIndicatorSymbol({ level }: { level: EffortLevel }): React.ReactNode { + return {effortLevelToSymbol(level)}; } -function EffortOptionLabel({ - level, - text, -}: { - level: EffortLevel - text: string -}): React.ReactNode { +function EffortOptionLabel({ level, text }: { level: EffortLevel; text: string }): React.ReactNode { return ( <> {text} - ) + ); } /** @@ -132,46 +111,46 @@ function EffortOptionLabel({ */ export function shouldShowEffortCallout(model: string): boolean { // Only show for Opus 4.6 for now - const parsed = parseUserSpecifiedModel(model) + const parsed = parseUserSpecifiedModel(model); if (!parsed.toLowerCase().includes('opus-4-6')) { - return false + return false; } - const config = getGlobalConfig() - if (config.effortCalloutV2Dismissed) return false + const config = getGlobalConfig(); + if (config.effortCalloutV2Dismissed) return false; // Don't show to brand-new users — they never knew the old default, so this // isn't a change for them. Mark as dismissed so it stays suppressed. if (config.numStartups <= 1) { - markV2Dismissed() - return false + markV2Dismissed(); + return false; } // Pro users already had medium default before this PR. Show the new copy, // but skip if they already saw the v1 dialog — no point nagging twice. if (isProSubscriber()) { if (config.effortCalloutDismissed) { - markV2Dismissed() - return false + markV2Dismissed(); + return false; } - return getOpusDefaultEffortConfig().enabled + return getOpusDefaultEffortConfig().enabled; } // Max/Team are the target of the tengu_grey_step2 config. // Don't mark dismissed when config is disabled — they should see the dialog // once it's enabled for them. if (isMaxSubscriber() || isTeamSubscriber()) { - return getOpusDefaultEffortConfig().enabled + return getOpusDefaultEffortConfig().enabled; } // Everyone else (free tier, API key, non-subscribers): not in scope. - markV2Dismissed() - return false + markV2Dismissed(); + return false; } function markV2Dismissed(): void { saveGlobalConfig(current => { - if (current.effortCalloutV2Dismissed) return current - return { ...current, effortCalloutV2Dismissed: true } - }) + if (current.effortCalloutV2Dismissed) return current; + return { ...current, effortCalloutV2Dismissed: true }; + }); } diff --git a/src/components/ExitFlow.tsx b/src/components/ExitFlow.tsx index c2e527054..f442c23e5 100644 --- a/src/components/ExitFlow.tsx +++ b/src/components/ExitFlow.tsx @@ -1,33 +1,29 @@ -import sample from 'lodash-es/sample.js' -import React from 'react' -import { gracefulShutdown } from '../utils/gracefulShutdown.js' -import { WorktreeExitDialog } from './WorktreeExitDialog.js' +import sample from 'lodash-es/sample.js'; +import React from 'react'; +import { gracefulShutdown } from '../utils/gracefulShutdown.js'; +import { WorktreeExitDialog } from './WorktreeExitDialog.js'; -const GOODBYE_MESSAGES = ['Goodbye!', 'See ya!', 'Bye!', 'Catch you later!'] +const GOODBYE_MESSAGES = ['Goodbye!', 'See ya!', 'Bye!', 'Catch you later!']; function getRandomGoodbyeMessage(): string { - return sample(GOODBYE_MESSAGES) ?? 'Goodbye!' + return sample(GOODBYE_MESSAGES) ?? 'Goodbye!'; } type Props = { - onDone: (message?: string) => void - onCancel?: () => void - showWorktree: boolean -} + onDone: (message?: string) => void; + onCancel?: () => void; + showWorktree: boolean; +}; -export function ExitFlow({ - showWorktree, - onDone, - onCancel, -}: Props): React.ReactNode { +export function ExitFlow({ showWorktree, onDone, onCancel }: Props): React.ReactNode { async function onExit(resultMessage?: string) { - onDone(resultMessage ?? getRandomGoodbyeMessage()) - await gracefulShutdown(0, 'prompt_input_exit') + onDone(resultMessage ?? getRandomGoodbyeMessage()); + await gracefulShutdown(0, 'prompt_input_exit'); } if (showWorktree) { - return + return ; } - return null + return null; } diff --git a/src/components/ExportDialog.tsx b/src/components/ExportDialog.tsx index b217d4c15..652426e65 100644 --- a/src/components/ExportDialog.tsx +++ b/src/components/ExportDialog.tsx @@ -1,86 +1,78 @@ -import { join } from 'path' -import React, { useCallback, useState } from 'react' -import type { ExitState } from '../hooks/useExitOnCtrlCDWithKeybindings.js' -import { useTerminalSize } from '../hooks/useTerminalSize.js' -import { setClipboard, Box, Text, Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink' -import { useKeybinding } from '../keybindings/useKeybinding.js' -import { getCwd } from '../utils/cwd.js' -import { writeFileSync_DEPRECATED } from '../utils/slowOperations.js' -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' -import { Select } from './CustomSelect/select.js' -import TextInput from './TextInput.js' +import { join } from 'path'; +import React, { useCallback, useState } from 'react'; +import type { ExitState } from '../hooks/useExitOnCtrlCDWithKeybindings.js'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { setClipboard, Box, Text, Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink'; +import { useKeybinding } from '../keybindings/useKeybinding.js'; +import { getCwd } from '../utils/cwd.js'; +import { writeFileSync_DEPRECATED } from '../utils/slowOperations.js'; +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; +import { Select } from './CustomSelect/select.js'; +import TextInput from './TextInput.js'; type ExportDialogProps = { - content: string - defaultFilename: string - onDone: (result: { success: boolean; message: string }) => void -} + content: string; + defaultFilename: string; + onDone: (result: { success: boolean; message: string }) => void; +}; -type ExportOption = 'clipboard' | 'file' +type ExportOption = 'clipboard' | 'file'; -export function ExportDialog({ - content, - defaultFilename, - onDone, -}: ExportDialogProps): React.ReactNode { - const [, setSelectedOption] = useState(null) - const [filename, setFilename] = useState(defaultFilename) - const [cursorOffset, setCursorOffset] = useState( - defaultFilename.length, - ) - const [showFilenameInput, setShowFilenameInput] = useState(false) - const { columns } = useTerminalSize() +export function ExportDialog({ content, defaultFilename, onDone }: ExportDialogProps): React.ReactNode { + const [, setSelectedOption] = useState(null); + const [filename, setFilename] = useState(defaultFilename); + const [cursorOffset, setCursorOffset] = useState(defaultFilename.length); + const [showFilenameInput, setShowFilenameInput] = useState(false); + const { columns } = useTerminalSize(); // Handle going back from filename input to option selection const handleGoBack = useCallback(() => { - setShowFilenameInput(false) - setSelectedOption(null) - }, []) + setShowFilenameInput(false); + setSelectedOption(null); + }, []); const handleSelectOption = async (value: string): Promise => { if (value === 'clipboard') { // Copy to clipboard immediately - const raw = await setClipboard(content) - if (raw) process.stdout.write(raw) - onDone({ success: true, message: 'Conversation copied to clipboard' }) + const raw = await setClipboard(content); + if (raw) process.stdout.write(raw); + onDone({ success: true, message: 'Conversation copied to clipboard' }); } else if (value === 'file') { - setSelectedOption('file') - setShowFilenameInput(true) + setSelectedOption('file'); + setShowFilenameInput(true); } - } + }; const handleFilenameSubmit = () => { - const finalFilename = filename.endsWith('.txt') - ? filename - : filename.replace(/\.[^.]+$/, '') + '.txt' - const filepath = join(getCwd(), finalFilename) + const finalFilename = filename.endsWith('.txt') ? filename : filename.replace(/\.[^.]+$/, '') + '.txt'; + const filepath = join(getCwd(), finalFilename); try { writeFileSync_DEPRECATED(filepath, content, { encoding: 'utf-8', flush: true, - }) + }); onDone({ success: true, message: `Conversation exported to: ${filepath}`, - }) + }); } catch (error) { onDone({ success: false, message: `Failed to export conversation: ${error instanceof Error ? error.message : 'Unknown error'}`, - }) + }); } - } + }; // Dialog calls onCancel when Escape is pressed. If we are in the filename // input sub-screen, go back to the option list instead of closing entirely. const handleCancel = useCallback(() => { if (showFilenameInput) { - handleGoBack() + handleGoBack(); } else { - onDone({ success: false, message: 'Export cancelled' }) + onDone({ success: false, message: 'Export cancelled' }); } - }, [showFilenameInput, handleGoBack, onDone]) + }, [showFilenameInput, handleGoBack, onDone]); const options = [ { @@ -93,7 +85,7 @@ export function ExportDialog({ value: 'file', description: 'Save the conversation to a file in the current directory', }, - ] + ]; // Custom input guide that changes based on dialog state function renderInputGuide(exitState: ExitState): React.ReactNode { @@ -101,35 +93,23 @@ export function ExportDialog({ return ( - + - ) + ); } if (exitState.pending) { - return Press {exitState.keyName} again to exit + return Press {exitState.keyName} again to exit; } - return ( - - ) + return ; } // Use Settings context so 'n' key doesn't cancel (allows typing 'n' in filename input) useKeybinding('confirm:no', handleCancel, { context: 'Settings', isActive: showFilenameInput, - }) + }); return ( {!showFilenameInput ? ( - ) : ( Enter filename: @@ -165,5 +141,5 @@ export function ExportDialog({ )} - ) + ); } diff --git a/src/components/FallbackToolUseErrorMessage.tsx b/src/components/FallbackToolUseErrorMessage.tsx index a60ad65b9..f4bc1d1f1 100644 --- a/src/components/FallbackToolUseErrorMessage.tsx +++ b/src/components/FallbackToolUseErrorMessage.tsx @@ -1,63 +1,49 @@ -import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/messages/messages.mjs' -import * as React from 'react' -import { stripUnderlineAnsi } from 'src/components/shell/OutputLine.js' -import { extractTag } from 'src/utils/messages.js' -import { removeSandboxViolationTags } from 'src/utils/sandbox/sandbox-ui-utils.js' -import { Box, Text } from '@anthropic/ink' -import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js' -import { countCharInString } from '../utils/stringUtils.js' -import { MessageResponse } from './MessageResponse.js' +import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/messages/messages.mjs'; +import * as React from 'react'; +import { stripUnderlineAnsi } from 'src/components/shell/OutputLine.js'; +import { extractTag } from 'src/utils/messages.js'; +import { removeSandboxViolationTags } from 'src/utils/sandbox/sandbox-ui-utils.js'; +import { Box, Text } from '@anthropic/ink'; +import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'; +import { countCharInString } from '../utils/stringUtils.js'; +import { MessageResponse } from './MessageResponse.js'; -const MAX_RENDERED_LINES = 10 +const MAX_RENDERED_LINES = 10; type Props = { - result: ToolResultBlockParam['content'] - verbose: boolean -} + result: ToolResultBlockParam['content']; + verbose: boolean; +}; -export function FallbackToolUseErrorMessage({ - result, - verbose, -}: Props): React.ReactNode { - const transcriptShortcut = useShortcutDisplay( - 'app:toggleTranscript', - 'Global', - 'ctrl+o', - ) - let error: string +export function FallbackToolUseErrorMessage({ result, verbose }: Props): React.ReactNode { + const transcriptShortcut = useShortcutDisplay('app:toggleTranscript', 'Global', 'ctrl+o'); + let error: string; if (typeof result !== 'string') { - error = 'Tool execution failed' + error = 'Tool execution failed'; } else { - const extractedError = extractTag(result, 'tool_use_error') ?? result + const extractedError = extractTag(result, 'tool_use_error') ?? result; // Remove sandbox_violations tags from error display (Claude still sees them in the tool result) - const withoutSandboxViolations = removeSandboxViolationTags(extractedError) + const withoutSandboxViolations = removeSandboxViolationTags(extractedError); // Strip tags but keep their content (tags are for the model, not the UI) - const withoutErrorTags = withoutSandboxViolations.replace(/<\/?error>/g, '') - const trimmed = withoutErrorTags.trim() + const withoutErrorTags = withoutSandboxViolations.replace(/<\/?error>/g, ''); + const trimmed = withoutErrorTags.trim(); if (!verbose && trimmed.includes('InputValidationError: ')) { - error = 'Invalid tool parameters' - } else if ( - trimmed.startsWith('Error: ') || - trimmed.startsWith('Cancelled: ') - ) { - error = trimmed + error = 'Invalid tool parameters'; + } else if (trimmed.startsWith('Error: ') || trimmed.startsWith('Cancelled: ')) { + error = trimmed; } else { - error = `Error: ${trimmed}` + error = `Error: ${trimmed}`; } } - const plusLines = countCharInString(error, '\n') + 1 - MAX_RENDERED_LINES + const plusLines = countCharInString(error, '\n') + 1 - MAX_RENDERED_LINES; return ( - {stripUnderlineAnsi( - verbose - ? error - : error.split('\n').slice(0, MAX_RENDERED_LINES).join('\n'), - )} + {stripUnderlineAnsi(verbose ? error : error.split('\n').slice(0, MAX_RENDERED_LINES).join('\n'))} {!verbose && plusLines > 0 && ( // The careful layout is a workaround for the dim-bold @@ -75,5 +61,5 @@ export function FallbackToolUseErrorMessage({ )} - ) + ); } diff --git a/src/components/FallbackToolUseRejectedMessage.tsx b/src/components/FallbackToolUseRejectedMessage.tsx index 95ec389cc..b128b5146 100644 --- a/src/components/FallbackToolUseRejectedMessage.tsx +++ b/src/components/FallbackToolUseRejectedMessage.tsx @@ -1,11 +1,11 @@ -import * as React from 'react' -import { InterruptedByUser } from './InterruptedByUser.js' -import { MessageResponse } from './MessageResponse.js' +import * as React from 'react'; +import { InterruptedByUser } from './InterruptedByUser.js'; +import { MessageResponse } from './MessageResponse.js'; export function FallbackToolUseRejectedMessage(): React.ReactNode { return ( - ) + ); } diff --git a/src/components/FastIcon.tsx b/src/components/FastIcon.tsx index b89283145..b3ae42ad5 100644 --- a/src/components/FastIcon.tsx +++ b/src/components/FastIcon.tsx @@ -1,13 +1,13 @@ -import chalk from 'chalk' -import * as React from 'react' -import { LIGHTNING_BOLT } from '../constants/figures.js' -import { Text, color } from '@anthropic/ink' -import { getGlobalConfig } from '../utils/config.js' -import { resolveThemeSetting } from '../utils/systemTheme.js' +import chalk from 'chalk'; +import * as React from 'react'; +import { LIGHTNING_BOLT } from '../constants/figures.js'; +import { Text, color } from '@anthropic/ink'; +import { getGlobalConfig } from '../utils/config.js'; +import { resolveThemeSetting } from '../utils/systemTheme.js'; type Props = { - cooldown?: boolean -} + cooldown?: boolean; +}; export function FastIcon({ cooldown }: Props): React.ReactNode { if (cooldown) { @@ -15,18 +15,18 @@ export function FastIcon({ cooldown }: Props): React.ReactNode { {LIGHTNING_BOLT} - ) + ); } - return {LIGHTNING_BOLT} + return {LIGHTNING_BOLT}; } export function getFastIconString(applyColor = true, cooldown = false): string { if (!applyColor) { - return LIGHTNING_BOLT + return LIGHTNING_BOLT; } - const themeName = resolveThemeSetting(getGlobalConfig().theme) + const themeName = resolveThemeSetting(getGlobalConfig().theme); if (cooldown) { - return chalk.dim(color('promptBorder', themeName)(LIGHTNING_BOLT)) + return chalk.dim(color('promptBorder', themeName)(LIGHTNING_BOLT)); } - return color('fastMode', themeName)(LIGHTNING_BOLT) + return color('fastMode', themeName)(LIGHTNING_BOLT); } diff --git a/src/components/Feedback.tsx b/src/components/Feedback.tsx index 56a7ba99c..0e13c103a 100644 --- a/src/components/Feedback.tsx +++ b/src/components/Feedback.tsx @@ -1,184 +1,166 @@ -import axios from 'axios' -import { readFile, stat } from 'fs/promises' -import * as React from 'react' -import { useCallback, useEffect, useState } from 'react' -import { getLastAPIRequest } from 'src/bootstrap/state.js' -import { logEventTo1P } from 'src/services/analytics/firstPartyEventLogger.js' +import axios from 'axios'; +import { readFile, stat } from 'fs/promises'; +import * as React from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { getLastAPIRequest } from 'src/bootstrap/state.js'; +import { logEventTo1P } from 'src/services/analytics/firstPartyEventLogger.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from 'src/services/analytics/index.js' -import { - getLastAssistantMessage, - normalizeMessagesForAPI, -} from 'src/utils/messages.js' -import type { CommandResultDisplay } from '../commands.js' -import { useTerminalSize } from '../hooks/useTerminalSize.js' -import { Box, Text, useInput } from '@anthropic/ink' -import { useKeybinding } from '../keybindings/useKeybinding.js' -import { queryHaiku } from '../services/api/claude.js' -import { startsWithApiErrorPrefix } from '../services/api/errors.js' -import type { Message } from '../types/message.js' -import { checkAndRefreshOAuthTokenIfNeeded } from '../utils/auth.js' -import { openBrowser } from '../utils/browser.js' -import { logForDebugging } from '../utils/debug.js' -import { env } from '../utils/env.js' -import { type GitRepoState, getGitState, getIsGit } from '../utils/git.js' -import { getAuthHeaders, getUserAgent } from '../utils/http.js' -import { getInMemoryErrors, logError } from '../utils/log.js' -import { isEssentialTrafficOnly } from '../utils/privacyLevel.js' +} from 'src/services/analytics/index.js'; +import { getLastAssistantMessage, normalizeMessagesForAPI } from 'src/utils/messages.js'; +import type { CommandResultDisplay } from '../commands.js'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { Box, Text, useInput } from '@anthropic/ink'; +import { useKeybinding } from '../keybindings/useKeybinding.js'; +import { queryHaiku } from '../services/api/claude.js'; +import { startsWithApiErrorPrefix } from '../services/api/errors.js'; +import type { Message } from '../types/message.js'; +import { checkAndRefreshOAuthTokenIfNeeded } from '../utils/auth.js'; +import { openBrowser } from '../utils/browser.js'; +import { logForDebugging } from '../utils/debug.js'; +import { env } from '../utils/env.js'; +import { type GitRepoState, getGitState, getIsGit } from '../utils/git.js'; +import { getAuthHeaders, getUserAgent } from '../utils/http.js'; +import { getInMemoryErrors, logError } from '../utils/log.js'; +import { isEssentialTrafficOnly } from '../utils/privacyLevel.js'; import { extractTeammateTranscriptsFromTasks, getTranscriptPath, loadAllSubagentTranscriptsFromDisk, MAX_TRANSCRIPT_READ_BYTES, -} from '../utils/sessionStorage.js' -import { jsonStringify } from '../utils/slowOperations.js' -import { asSystemPrompt } from '../utils/systemPromptType.js' -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' -import { Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink' -import TextInput from './TextInput.js' +} from '../utils/sessionStorage.js'; +import { jsonStringify } from '../utils/slowOperations.js'; +import { asSystemPrompt } from '../utils/systemPromptType.js'; +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; +import { Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink'; +import TextInput from './TextInput.js'; // This value was determined experimentally by testing the URL length limit -const GITHUB_URL_LIMIT = 7250 +const GITHUB_URL_LIMIT = 7250; const GITHUB_ISSUES_REPO_URL = process.env.USER_TYPE === 'ant' ? 'https://github.com/anthropics/claude-cli-internal/issues' - : 'https://github.com/anthropics/claude-code/issues' + : 'https://github.com/anthropics/claude-code/issues'; type Props = { - abortSignal: AbortSignal - messages: Message[] - initialDescription?: string - onDone(result: string, options?: { display?: CommandResultDisplay }): void + abortSignal: AbortSignal; + messages: Message[]; + initialDescription?: string; + onDone(result: string, options?: { display?: CommandResultDisplay }): void; backgroundTasks?: { [taskId: string]: { - type: string - identity?: { agentId: string } - messages?: Message[] - } - } -} + type: string; + identity?: { agentId: string }; + messages?: Message[]; + }; + }; +}; -type Step = 'userInput' | 'consent' | 'submitting' | 'done' +type Step = 'userInput' | 'consent' | 'submitting' | 'done'; type FeedbackData = { // latestAssistantMessageId is the message ID from the latest main model call - latestAssistantMessageId: string | null - message_count: number - datetime: string - description: string - platform: string - gitRepo: boolean - version: string | null - transcript: Message[] - subagentTranscripts?: { [agentId: string]: Message[] } - rawTranscriptJsonl?: string -} + latestAssistantMessageId: string | null; + message_count: number; + datetime: string; + description: string; + platform: string; + gitRepo: boolean; + version: string | null; + transcript: Message[]; + subagentTranscripts?: { [agentId: string]: Message[] }; + rawTranscriptJsonl?: string; +}; // Utility function to redact sensitive information from strings export function redactSensitiveInfo(text: string): string { - let redacted = text + let redacted = text; // Anthropic API keys (sk-ant...) with or without quotes // First handle the case with quotes - redacted = redacted.replace(/"(sk-ant[^\s"']{24,})"/g, '"[REDACTED_API_KEY]"') + redacted = redacted.replace(/"(sk-ant[^\s"']{24,})"/g, '"[REDACTED_API_KEY]"'); // Then handle the cases without quotes - more general pattern redacted = redacted.replace( // eslint-disable-next-line custom-rules/no-lookbehind-regex -- .replace(re, string) on /bug path: no-match returns same string (Object.is) /(? { // Sanitize error logs to remove any API keys return getInMemoryErrors().map(errorInfo => { // Create a copy of the error info to avoid modifying the original - const errorCopy = { ...errorInfo } as { error?: string; timestamp?: string } + const errorCopy = { ...errorInfo } as { error?: string; timestamp?: string }; // Sanitize error if present and is a string if (errorCopy && typeof errorCopy.error === 'string') { - errorCopy.error = redactSensitiveInfo(errorCopy.error) + errorCopy.error = redactSensitiveInfo(errorCopy.error); } - return errorCopy - }) + return errorCopy; + }); } async function loadRawTranscriptJsonl(): Promise { try { - const transcriptPath = getTranscriptPath() - const { size } = await stat(transcriptPath) + const transcriptPath = getTranscriptPath(); + const { size } = await stat(transcriptPath); if (size > MAX_TRANSCRIPT_READ_BYTES) { - logForDebugging( - `Skipping raw transcript read: file too large (${size} bytes)`, - { level: 'warn' }, - ) - return null + logForDebugging(`Skipping raw transcript read: file too large (${size} bytes)`, { level: 'warn' }); + return null; } - return await readFile(transcriptPath, 'utf-8') + return await readFile(transcriptPath, 'utf-8'); } catch { - return null + return null; } } @@ -189,49 +171,48 @@ export function Feedback({ onDone, backgroundTasks = {}, }: Props): React.ReactNode { - const [step, setStep] = useState('userInput') - const [cursorOffset, setCursorOffset] = useState(0) - const [description, setDescription] = useState(initialDescription ?? '') - const [feedbackId, setFeedbackId] = useState(null) - const [error, setError] = useState(null) + const [step, setStep] = useState('userInput'); + const [cursorOffset, setCursorOffset] = useState(0); + const [description, setDescription] = useState(initialDescription ?? ''); + const [feedbackId, setFeedbackId] = useState(null); + const [error, setError] = useState(null); const [envInfo, setEnvInfo] = useState<{ - isGit: boolean - gitState: GitRepoState | null - }>({ isGit: false, gitState: null }) - const [title, setTitle] = useState(null) - const textInputColumns = useTerminalSize().columns - 4 + isGit: boolean; + gitState: GitRepoState | null; + }>({ isGit: false, gitState: null }); + const [title, setTitle] = useState(null); + const textInputColumns = useTerminalSize().columns - 4; useEffect(() => { async function loadEnvInfo() { - const isGit = await getIsGit() - let gitState: GitRepoState | null = null + const isGit = await getIsGit(); + let gitState: GitRepoState | null = null; if (isGit) { - gitState = await getGitState() + gitState = await getGitState(); } - setEnvInfo({ isGit, gitState }) + setEnvInfo({ isGit, gitState }); } - void loadEnvInfo() - }, []) + void loadEnvInfo(); + }, []); const submitReport = useCallback(async () => { - setStep('submitting') - setError(null) - setFeedbackId(null) + setStep('submitting'); + setError(null); + setFeedbackId(null); // Get sanitized errors for the report - const sanitizedErrors = getSanitizedErrorLogs() + const sanitizedErrors = getSanitizedErrorLogs(); // Extract last assistant message ID from messages array - const lastAssistantMessage = getLastAssistantMessage(messages) - const lastAssistantMessageId = lastAssistantMessage?.requestId ?? null + const lastAssistantMessage = getLastAssistantMessage(messages); + const lastAssistantMessageId = lastAssistantMessage?.requestId ?? null; const [diskTranscripts, rawTranscriptJsonl] = await Promise.all([ loadAllSubagentTranscriptsFromDisk(), loadRawTranscriptJsonl(), - ]) - const teammateTranscripts = - extractTeammateTranscriptsFromTasks(backgroundTasks) - const subagentTranscripts = { ...diskTranscripts, ...teammateTranscripts } + ]); + const teammateTranscripts = extractTeammateTranscriptsFromTasks(backgroundTasks); + const subagentTranscripts = { ...diskTranscripts, ...teammateTranscripts }; const reportData = { latestAssistantMessageId: lastAssistantMessageId, @@ -249,46 +230,40 @@ export function Feedback({ subagentTranscripts, }), ...(rawTranscriptJsonl && { rawTranscriptJsonl }), - } + }; const [result, t] = await Promise.all([ submitFeedback(reportData as FeedbackData, abortSignal), generateTitle(description, abortSignal), - ]) + ]); - setTitle(t) + setTitle(t); if (result.success) { if (result.feedbackId) { - setFeedbackId(result.feedbackId) + setFeedbackId(result.feedbackId); logEvent('tengu_bug_report_submitted', { - feedback_id: - result.feedbackId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + feedback_id: result.feedbackId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, last_assistant_message_id: lastAssistantMessageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + }); // 1P-only: freeform text approved for BQ. Join on feedback_id. logEventTo1P('tengu_bug_report_description', { - feedback_id: - result.feedbackId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - description: redactSensitiveInfo( - description, - ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + feedback_id: result.feedbackId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + description: redactSensitiveInfo(description) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); } - setStep('done') + setStep('done'); } else { if (result.isZdrOrg) { - setError( - 'Feedback collection is not available for organizations with custom data retention policies.', - ) + setError('Feedback collection is not available for organizations with custom data retention policies.'); } else { - setError('Could not submit feedback. Please try again later.') + setError('Could not submit feedback. Please try again later.'); } // Stay on userInput step so user can retry with their content preserved - setStep('userInput') + setStep('userInput'); } - }, [description, envInfo.isGit, messages]) + }, [description, envInfo.isGit, messages]); // Handle cancel - this will be called by Dialog's automatic Esc handling const handleCancel = useCallback(() => { @@ -297,43 +272,38 @@ export function Feedback({ if (error) { onDone('Error submitting feedback / bug report', { display: 'system', - }) + }); } else { - onDone('Feedback / bug report submitted', { display: 'system' }) + onDone('Feedback / bug report submitted', { display: 'system' }); } - return + return; } - onDone('Feedback / bug report cancelled', { display: 'system' }) - }, [step, error, onDone]) + onDone('Feedback / bug report cancelled', { display: 'system' }); + }, [step, error, onDone]); // During text input, use Settings context where only Escape (not 'n') triggers confirm:no. // This allows typing 'n' in the text field while still supporting Escape to cancel. useKeybinding('confirm:no', handleCancel, { context: 'Settings', isActive: step === 'userInput', - }) + }); useInput((input, key) => { // Allow any key press to close the dialog when done or when there's an error if (step === 'done') { if (key.return && title) { // Open GitHub issue URL when Enter is pressed - const issueUrl = createGitHubIssueUrl( - feedbackId ?? '', - title, - description, - getSanitizedErrorLogs(), - ) - void openBrowser(issueUrl) + const issueUrl = createGitHubIssueUrl(feedbackId ?? '', title, description, getSanitizedErrorLogs()); + void openBrowser(issueUrl); } if (error) { onDone('Error submitting feedback / bug report', { display: 'system', - }) + }); } else { - onDone('Feedback / bug report submitted', { display: 'system' }) + onDone('Feedback / bug report submitted', { display: 'system' }); } - return + return; } // When in userInput step with error, allow user to edit and retry @@ -341,14 +311,14 @@ export function Feedback({ if (error && step !== 'userInput') { onDone('Error submitting feedback / bug report', { display: 'system', - }) - return + }); + return; } if (step === 'consent' && (key.return || input === ' ')) { - void submitReport() + void submitReport(); } - }) + }); return ( - + ) : step === 'consent' ? ( - + ) : null } @@ -387,17 +347,15 @@ export function Feedback({ { - setDescription(value) + setDescription(value); // Clear error when user starts editing to allow retry if (error) { - setError(null) + setError(null); } }} columns={textInputColumns} onSubmit={() => setStep('consent')} - onExitMessage={() => - onDone('Feedback cancelled', { display: 'system' }) - } + onExitMessage={() => onDone('Feedback cancelled', { display: 'system' })} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} showCursor @@ -405,9 +363,7 @@ export function Feedback({ {error && ( {error} - - Edit and press Enter to retry, or Esc to cancel - + Edit and press Enter to retry, or Esc to cancel )} @@ -418,8 +374,7 @@ export function Feedback({ This report will include: - - Your feedback / bug description:{' '} - {description} + - Your feedback / bug description: {description} - Environment info:{' '} @@ -432,12 +387,8 @@ export function Feedback({ - Git repo metadata:{' '} {envInfo.gitState.branchName} - {envInfo.gitState.commitHash - ? `, ${envInfo.gitState.commitHash.slice(0, 7)}` - : ''} - {envInfo.gitState.remoteUrl - ? ` @ ${envInfo.gitState.remoteUrl}` - : ''} + {envInfo.gitState.commitHash ? `, ${envInfo.gitState.commitHash.slice(0, 7)}` : ''} + {envInfo.gitState.remoteUrl ? ` @ ${envInfo.gitState.remoteUrl}` : ''} {!envInfo.gitState.isHeadOnRemote && ', not synced'} {!envInfo.gitState.isClean && ', has local changes'} @@ -447,9 +398,8 @@ export function Feedback({ - We will use your feedback to debug related issues or to improve{' '} - Claude Code's functionality (eg. to reduce the risk of bugs - occurring in the future). + We will use your feedback to debug related issues or to improve Claude Code's functionality (eg. to + reduce the risk of bugs occurring in the future). @@ -468,24 +418,17 @@ export function Feedback({ {step === 'done' && ( - {error ? ( - {error} - ) : ( - Thank you for your report! - )} + {error ? {error} : Thank you for your report!} {feedbackId && Feedback ID: {feedbackId}} Press Enter - - to open your browser and draft a GitHub issue, or any other key to - close. - + to open your browser and draft a GitHub issue, or any other key to close. )} - ) + ); } export function createGitHubIssueUrl( @@ -493,12 +436,12 @@ export function createGitHubIssueUrl( title: string, description: string, errors: Array<{ - error?: string - timestamp?: string + error?: string; + timestamp?: string; }>, ): string { - const sanitizedTitle = redactSensitiveInfo(title) - const sanitizedDescription = redactSensitiveInfo(description) + const sanitizedTitle = redactSensitiveInfo(title); + const sanitizedDescription = redactSensitiveInfo(description); const bodyPrefix = `**Bug Description**\n${sanitizedDescription}\n\n` + @@ -507,84 +450,62 @@ export function createGitHubIssueUrl( `- Terminal: ${env.terminal}\n` + `- Version: ${MACRO.VERSION || 'unknown'}\n` + `- Feedback ID: ${feedbackId}\n` + - `\n**Errors**\n\`\`\`json\n` - const errorSuffix = `\n\`\`\`\n` - const errorsJson = jsonStringify(errors) + `\n**Errors**\n\`\`\`json\n`; + const errorSuffix = `\n\`\`\`\n`; + const errorsJson = jsonStringify(errors); - const baseUrl = `${GITHUB_ISSUES_REPO_URL}/new?title=${encodeURIComponent(sanitizedTitle)}&labels=user-reported,bug&body=` - const truncationNote = `\n**Note:** Content was truncated.\n` + const baseUrl = `${GITHUB_ISSUES_REPO_URL}/new?title=${encodeURIComponent(sanitizedTitle)}&labels=user-reported,bug&body=`; + const truncationNote = `\n**Note:** Content was truncated.\n`; - const encodedPrefix = encodeURIComponent(bodyPrefix) - const encodedSuffix = encodeURIComponent(errorSuffix) - const encodedNote = encodeURIComponent(truncationNote) - const encodedErrors = encodeURIComponent(errorsJson) + const encodedPrefix = encodeURIComponent(bodyPrefix); + const encodedSuffix = encodeURIComponent(errorSuffix); + const encodedNote = encodeURIComponent(truncationNote); + const encodedErrors = encodeURIComponent(errorsJson); // Calculate space available for errors const spaceForErrors = - GITHUB_URL_LIMIT - - baseUrl.length - - encodedPrefix.length - - encodedSuffix.length - - encodedNote.length + GITHUB_URL_LIMIT - baseUrl.length - encodedPrefix.length - encodedSuffix.length - encodedNote.length; // If description alone exceeds limit, truncate everything if (spaceForErrors <= 0) { - const ellipsis = encodeURIComponent('…') - const buffer = 50 // Extra safety margin - const maxEncodedLength = - GITHUB_URL_LIMIT - - baseUrl.length - - ellipsis.length - - encodedNote.length - - buffer - const fullBody = bodyPrefix + errorsJson + errorSuffix - let encodedFullBody = encodeURIComponent(fullBody) + const ellipsis = encodeURIComponent('…'); + const buffer = 50; // Extra safety margin + const maxEncodedLength = GITHUB_URL_LIMIT - baseUrl.length - ellipsis.length - encodedNote.length - buffer; + const fullBody = bodyPrefix + errorsJson + errorSuffix; + let encodedFullBody = encodeURIComponent(fullBody); if (encodedFullBody.length > maxEncodedLength) { - encodedFullBody = encodedFullBody.slice(0, maxEncodedLength) + encodedFullBody = encodedFullBody.slice(0, maxEncodedLength); // Don't cut in middle of %XX sequence - const lastPercent = encodedFullBody.lastIndexOf('%') + const lastPercent = encodedFullBody.lastIndexOf('%'); if (lastPercent >= encodedFullBody.length - 2) { - encodedFullBody = encodedFullBody.slice(0, lastPercent) + encodedFullBody = encodedFullBody.slice(0, lastPercent); } } - return baseUrl + encodedFullBody + ellipsis + encodedNote + return baseUrl + encodedFullBody + ellipsis + encodedNote; } // If errors fit, no truncation needed if (encodedErrors.length <= spaceForErrors) { - return baseUrl + encodedPrefix + encodedErrors + encodedSuffix + return baseUrl + encodedPrefix + encodedErrors + encodedSuffix; } // Truncate errors to fit (prioritize keeping description) // Slice encoded errors directly, then trim to avoid cutting %XX sequences - const ellipsis = encodeURIComponent('…') - const buffer = 50 // Extra safety margin - let truncatedEncodedErrors = encodedErrors.slice( - 0, - spaceForErrors - ellipsis.length - buffer, - ) + const ellipsis = encodeURIComponent('…'); + const buffer = 50; // Extra safety margin + let truncatedEncodedErrors = encodedErrors.slice(0, spaceForErrors - ellipsis.length - buffer); // If we cut in middle of %XX, back up to before the % - const lastPercent = truncatedEncodedErrors.lastIndexOf('%') + const lastPercent = truncatedEncodedErrors.lastIndexOf('%'); if (lastPercent >= truncatedEncodedErrors.length - 2) { - truncatedEncodedErrors = truncatedEncodedErrors.slice(0, lastPercent) + truncatedEncodedErrors = truncatedEncodedErrors.slice(0, lastPercent); } - return ( - baseUrl + - encodedPrefix + - truncatedEncodedErrors + - ellipsis + - encodedSuffix + - encodedNote - ) + return baseUrl + encodedPrefix + truncatedEncodedErrors + ellipsis + encodedSuffix + encodedNote; } -async function generateTitle( - description: string, - abortSignal: AbortSignal, -): Promise { +async function generateTitle(description: string, abortSignal: AbortSignal): Promise { try { const response = await queryHaiku({ systemPrompt: asSystemPrompt([ @@ -611,24 +532,21 @@ async function generateTitle( querySource: 'feedback', mcpTools: [], }, - }) + }); - const _firstBlock = response?.message?.content?.[0] as unknown as Record | undefined - const title = - _firstBlock?.type === 'text' - ? (_firstBlock.text as string) - : 'Bug Report' + const _firstBlock = response?.message?.content?.[0] as unknown as Record | undefined; + const title = _firstBlock?.type === 'text' ? (_firstBlock.text as string) : 'Bug Report'; // Check if the title contains an API error message if (startsWithApiErrorPrefix(title)) { - return createFallbackTitle(description) + return createFallbackTitle(description); } - return title + return title; } catch (error) { // If there's any error in title generation, use a fallback title - logError(error) - return createFallbackTitle(description) + logError(error); + return createFallbackTitle(description); } } @@ -636,45 +554,45 @@ function createFallbackTitle(description: string): string { // Create a safe fallback title based on the bug description // Try to extract a meaningful title from the first line - const firstLine = description.split('\n')[0] || '' + const firstLine = description.split('\n')[0] || ''; // If the first line is very short, use it directly if (firstLine.length <= 60 && firstLine.length > 5) { - return firstLine + return firstLine; } // For longer descriptions, create a truncated version // Truncate at word boundaries when possible - let truncated = firstLine.slice(0, 60) + let truncated = firstLine.slice(0, 60); if (firstLine.length > 60) { // Find the last space before the 60 char limit - const lastSpace = truncated.lastIndexOf(' ') + const lastSpace = truncated.lastIndexOf(' '); if (lastSpace > 30) { // Only trim at word if we're not cutting too much - truncated = truncated.slice(0, lastSpace) + truncated = truncated.slice(0, lastSpace); } - truncated += '...' + truncated += '...'; } - return truncated.length < 10 ? 'Bug Report' : truncated + return truncated.length < 10 ? 'Bug Report' : truncated; } // Helper function to sanitize and log errors without exposing API keys function sanitizeAndLogError(err: unknown): void { if (err instanceof Error) { // Create a copy with potentially sensitive info redacted - const safeError = new Error(redactSensitiveInfo(err.message)) + const safeError = new Error(redactSensitiveInfo(err.message)); // Also redact the stack trace if present if (err.stack) { - safeError.stack = redactSensitiveInfo(err.stack) + safeError.stack = redactSensitiveInfo(err.stack); } - logError(safeError) + logError(safeError); } else { // For non-Error objects, convert to string and redact sensitive info - const errorString = redactSensitiveInfo(String(err)) - logError(new Error(errorString)) + const errorString = redactSensitiveInfo(String(err)); + logError(new Error(errorString)); } } @@ -683,24 +601,24 @@ async function submitFeedback( signal?: AbortSignal, ): Promise<{ success: boolean; feedbackId?: string; isZdrOrg?: boolean }> { if (isEssentialTrafficOnly()) { - return { success: false } + return { success: false }; } try { // Ensure OAuth token is fresh before getting auth headers // This prevents 401 errors from stale cached tokens - await checkAndRefreshOAuthTokenIfNeeded() + await checkAndRefreshOAuthTokenIfNeeded(); - const authResult = getAuthHeaders() + const authResult = getAuthHeaders(); if (authResult.error) { - return { success: false } + return { success: false }; } const headers: Record = { 'Content-Type': 'application/json', 'User-Agent': getUserAgent(), ...authResult.headers, - } + }; const response = await axios.post( 'https://api.anthropic.com/api/claude_cli_feedback', @@ -712,47 +630,37 @@ async function submitFeedback( timeout: 30000, // 30 second timeout to prevent hanging signal, }, - ) + ); if (response.status === 200) { - const result = response.data + const result = response.data; if (result?.feedback_id) { - return { success: true, feedbackId: result.feedback_id } + return { success: true, feedbackId: result.feedback_id }; } - sanitizeAndLogError( - new Error( - 'Failed to submit feedback: request did not return feedback_id', - ), - ) - return { success: false } + sanitizeAndLogError(new Error('Failed to submit feedback: request did not return feedback_id')); + return { success: false }; } - sanitizeAndLogError( - new Error('Failed to submit feedback:' + response.status), - ) - return { success: false } + sanitizeAndLogError(new Error('Failed to submit feedback:' + response.status)); + return { success: false }; } catch (err) { // Handle cancellation/abort - don't log as error if (axios.isCancel(err)) { - return { success: false } + return { success: false }; } if (axios.isAxiosError(err) && err.response?.status === 403) { - const errorData = err.response.data + const errorData = err.response.data; if ( errorData?.error?.type === 'permission_error' && errorData?.error?.message?.includes('Custom data retention settings') ) { - sanitizeAndLogError( - new Error( - 'Cannot submit feedback because custom data retention settings are enabled', - ), - ) - return { success: false, isZdrOrg: true } + sanitizeAndLogError(new Error('Cannot submit feedback because custom data retention settings are enabled')); + return { success: false, isZdrOrg: true }; } } // Use our safe error logging function to avoid leaking API keys - sanitizeAndLogError(err) - return { success: false } + sanitizeAndLogError(err); + return { success: false }; } } diff --git a/src/components/FeedbackSurvey/FeedbackSurvey.tsx b/src/components/FeedbackSurvey/FeedbackSurvey.tsx index a92213441..6620d32a8 100644 --- a/src/components/FeedbackSurvey/FeedbackSurvey.tsx +++ b/src/components/FeedbackSurvey/FeedbackSurvey.tsx @@ -1,34 +1,25 @@ -import React from 'react' +import React from 'react'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from 'src/services/analytics/index.js' -import { Box, Text } from '@anthropic/ink' -import { - FeedbackSurveyView, - isValidResponseInput, -} from './FeedbackSurveyView.js' -import type { TranscriptShareResponse } from './TranscriptSharePrompt.js' -import { TranscriptSharePrompt } from './TranscriptSharePrompt.js' -import { useDebouncedDigitInput } from './useDebouncedDigitInput.js' -import type { FeedbackSurveyResponse } from './utils.js' +} from 'src/services/analytics/index.js'; +import { Box, Text } from '@anthropic/ink'; +import { FeedbackSurveyView, isValidResponseInput } from './FeedbackSurveyView.js'; +import type { TranscriptShareResponse } from './TranscriptSharePrompt.js'; +import { TranscriptSharePrompt } from './TranscriptSharePrompt.js'; +import { useDebouncedDigitInput } from './useDebouncedDigitInput.js'; +import type { FeedbackSurveyResponse } from './utils.js'; type Props = { - state: - | 'closed' - | 'open' - | 'thanks' - | 'transcript_prompt' - | 'submitting' - | 'submitted' - lastResponse: FeedbackSurveyResponse | null - handleSelect: (selected: FeedbackSurveyResponse) => void - handleTranscriptSelect?: (selected: TranscriptShareResponse) => void - inputValue: string - setInputValue: (value: string) => void - onRequestFeedback?: () => void - message?: string -} + state: 'closed' | 'open' | 'thanks' | 'transcript_prompt' | 'submitting' | 'submitted'; + lastResponse: FeedbackSurveyResponse | null; + handleSelect: (selected: FeedbackSurveyResponse) => void; + handleTranscriptSelect?: (selected: TranscriptShareResponse) => void; + inputValue: string; + setInputValue: (value: string) => void; + onRequestFeedback?: () => void; + message?: string; +}; export function FeedbackSurvey({ state, @@ -41,7 +32,7 @@ export function FeedbackSurvey({ message, }: Props): React.ReactNode { if (state === 'closed') { - return null + return null; } if (state === 'thanks') { @@ -52,17 +43,15 @@ export function FeedbackSurvey({ setInputValue={setInputValue} onRequestFeedback={onRequestFeedback} /> - ) + ); } if (state === 'submitted') { return ( - - {'\u2713'} Thanks for sharing your transcript! - + {'\u2713'} Thanks for sharing your transcript! - ) + ); } if (state === 'submitting') { @@ -70,24 +59,20 @@ export function FeedbackSurvey({ Sharing transcript{'\u2026'} - ) + ); } if (state === 'transcript_prompt') { if (!handleTranscriptSelect) { - return null + return null; } // Hide prompt if user is typing non-response characters if (inputValue && !['1', '2', '3'].includes(inputValue)) { - return null + return null; } return ( - - ) + + ); } // state === 'open' @@ -95,7 +80,7 @@ export function FeedbackSurvey({ // This prevents the survey from showing up when the user is typing a message, // which can result in accidental survey submissions (e.g. "s3cmd"). if (inputValue && !isValidResponseInput(inputValue)) { - return null + return null; } return ( @@ -105,17 +90,17 @@ export function FeedbackSurvey({ setInputValue={setInputValue} message={message} /> - ) + ); } type ThanksProps = { - lastResponse: FeedbackSurveyResponse | null - inputValue: string - setInputValue: (value: string) => void - onRequestFeedback?: () => void -} + lastResponse: FeedbackSurveyResponse | null; + inputValue: string; + setInputValue: (value: string) => void; + onRequestFeedback?: () => void; +}; -const isFollowUpDigit = (char: string): char is '1' => char === '1' +const isFollowUpDigit = (char: string): char is '1' => char === '1'; function FeedbackSurveyThanks({ lastResponse, @@ -123,7 +108,7 @@ function FeedbackSurveyThanks({ setInputValue, onRequestFeedback, }: ThanksProps): React.ReactNode { - const showFollowUp = onRequestFeedback && lastResponse === 'good' + const showFollowUp = onRequestFeedback && lastResponse === 'good'; // Listen for "1" keypress to launch /feedback useDebouncedDigitInput({ @@ -134,34 +119,28 @@ function FeedbackSurveyThanks({ once: true, onDigit: () => { logEvent('tengu_feedback_survey_event', { - event_type: - 'followup_accepted' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - response: - lastResponse as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - onRequestFeedback?.() + event_type: 'followup_accepted' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + response: lastResponse as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + onRequestFeedback?.(); }, - }) + }); - const feedbackCommand = - process.env.USER_TYPE === 'ant' ? '/issue' : '/feedback' + const feedbackCommand = process.env.USER_TYPE === 'ant' ? '/issue' : '/feedback'; return ( Thanks for the feedback! {showFollowUp ? ( - (Optional) Press [1] to tell us what - went well {' \u00b7 '} + (Optional) Press [1] to tell us what went well {' \u00b7 '} {feedbackCommand} ) : lastResponse === 'bad' ? ( Use /issue to report model behavior issues. ) : ( - - Use {feedbackCommand} to share detailed feedback anytime. - + Use {feedbackCommand} to share detailed feedback anytime. )} - ) + ); } diff --git a/src/components/FeedbackSurvey/FeedbackSurveyView.tsx b/src/components/FeedbackSurvey/FeedbackSurveyView.tsx index 5a8721e12..3d738e795 100644 --- a/src/components/FeedbackSurvey/FeedbackSurveyView.tsx +++ b/src/components/FeedbackSurvey/FeedbackSurveyView.tsx @@ -1,29 +1,29 @@ -import React from 'react' -import { Box, Text } from '@anthropic/ink' -import { useDebouncedDigitInput } from './useDebouncedDigitInput.js' -import type { FeedbackSurveyResponse } from './utils.js' +import React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { useDebouncedDigitInput } from './useDebouncedDigitInput.js'; +import type { FeedbackSurveyResponse } from './utils.js'; type Props = { - onSelect: (option: FeedbackSurveyResponse) => void - inputValue: string - setInputValue: (value: string) => void - message?: string -} + onSelect: (option: FeedbackSurveyResponse) => void; + inputValue: string; + setInputValue: (value: string) => void; + message?: string; +}; -const RESPONSE_INPUTS = ['0', '1', '2', '3'] as const -type ResponseInput = (typeof RESPONSE_INPUTS)[number] +const RESPONSE_INPUTS = ['0', '1', '2', '3'] as const; +type ResponseInput = (typeof RESPONSE_INPUTS)[number]; const inputToResponse: Record = { '0': 'dismissed', '1': 'bad', '2': 'fine', '3': 'good', -} as const +} as const; export const isValidResponseInput = (input: string): input is ResponseInput => - (RESPONSE_INPUTS as readonly string[]).includes(input) + (RESPONSE_INPUTS as readonly string[]).includes(input); -const DEFAULT_MESSAGE = 'How is Claude doing this session? (optional)' +const DEFAULT_MESSAGE = 'How is Claude doing this session? (optional)'; export function FeedbackSurveyView({ onSelect, @@ -36,7 +36,7 @@ export function FeedbackSurveyView({ setInputValue, isValidDigit: isValidResponseInput, onDigit: digit => onSelect(inputToResponse[digit]), - }) + }); return ( @@ -68,5 +68,5 @@ export function FeedbackSurveyView({ - ) + ); } diff --git a/src/components/FeedbackSurvey/TranscriptSharePrompt.tsx b/src/components/FeedbackSurvey/TranscriptSharePrompt.tsx index d3f39d25e..6da2ea017 100644 --- a/src/components/FeedbackSurvey/TranscriptSharePrompt.tsx +++ b/src/components/FeedbackSurvey/TranscriptSharePrompt.tsx @@ -1,55 +1,45 @@ -import React from 'react' -import { BLACK_CIRCLE } from '../../constants/figures.js' -import { Box, Text } from '@anthropic/ink' -import { useDebouncedDigitInput } from './useDebouncedDigitInput.js' +import React from 'react'; +import { BLACK_CIRCLE } from '../../constants/figures.js'; +import { Box, Text } from '@anthropic/ink'; +import { useDebouncedDigitInput } from './useDebouncedDigitInput.js'; -export type TranscriptShareResponse = 'yes' | 'no' | 'dont_ask_again' +export type TranscriptShareResponse = 'yes' | 'no' | 'dont_ask_again'; type Props = { - onSelect: (option: TranscriptShareResponse) => void - inputValue: string - setInputValue: (value: string) => void -} + onSelect: (option: TranscriptShareResponse) => void; + inputValue: string; + setInputValue: (value: string) => void; +}; -const RESPONSE_INPUTS = ['1', '2', '3'] as const -type ResponseInput = (typeof RESPONSE_INPUTS)[number] +const RESPONSE_INPUTS = ['1', '2', '3'] as const; +type ResponseInput = (typeof RESPONSE_INPUTS)[number]; const inputToResponse: Record = { '1': 'yes', '2': 'no', '3': 'dont_ask_again', -} as const +} as const; const isValidResponseInput = (input: string): input is ResponseInput => - (RESPONSE_INPUTS as readonly string[]).includes(input) + (RESPONSE_INPUTS as readonly string[]).includes(input); -export function TranscriptSharePrompt({ - onSelect, - inputValue, - setInputValue, -}: Props): React.ReactNode { +export function TranscriptSharePrompt({ onSelect, inputValue, setInputValue }: Props): React.ReactNode { useDebouncedDigitInput({ inputValue, setInputValue, isValidDigit: isValidResponseInput, onDigit: digit => onSelect(inputToResponse[digit]), - }) + }); return ( {BLACK_CIRCLE} - - Can Anthropic look at your session transcript to help us improve - Claude Code? - + Can Anthropic look at your session transcript to help us improve Claude Code? - - Learn more: - https://code.claude.com/docs/en/data-usage#session-quality-surveys - + Learn more: https://code.claude.com/docs/en/data-usage#session-quality-surveys @@ -70,5 +60,5 @@ export function TranscriptSharePrompt({ - ) + ); } diff --git a/src/components/FeedbackSurvey/src/hooks/useDynamicConfig.ts b/src/components/FeedbackSurvey/src/hooks/useDynamicConfig.ts index 5657e8465..bb1fe312d 100644 --- a/src/components/FeedbackSurvey/src/hooks/useDynamicConfig.ts +++ b/src/components/FeedbackSurvey/src/hooks/useDynamicConfig.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useDynamicConfig = any; +export type useDynamicConfig = any diff --git a/src/components/FeedbackSurvey/src/services/analytics/config.ts b/src/components/FeedbackSurvey/src/services/analytics/config.ts index 5ce8a06db..da7d58f1e 100644 --- a/src/components/FeedbackSurvey/src/services/analytics/config.ts +++ b/src/components/FeedbackSurvey/src/services/analytics/config.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type isFeedbackSurveyDisabled = any; +export type isFeedbackSurveyDisabled = any diff --git a/src/components/FeedbackSurvey/src/services/analytics/growthbook.ts b/src/components/FeedbackSurvey/src/services/analytics/growthbook.ts index 1df9f09d9..cb078eac7 100644 --- a/src/components/FeedbackSurvey/src/services/analytics/growthbook.ts +++ b/src/components/FeedbackSurvey/src/services/analytics/growthbook.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type getFeatureValue_CACHED_MAY_BE_STALE = any; -export type checkStatsigFeatureGate_CACHED_MAY_BE_STALE = any; +export type getFeatureValue_CACHED_MAY_BE_STALE = any +export type checkStatsigFeatureGate_CACHED_MAY_BE_STALE = any diff --git a/src/components/FeedbackSurvey/src/services/analytics/index.ts b/src/components/FeedbackSurvey/src/services/analytics/index.ts index 142e7b6f5..2e0f796da 100644 --- a/src/components/FeedbackSurvey/src/services/analytics/index.ts +++ b/src/components/FeedbackSurvey/src/services/analytics/index.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any; -export type logEvent = any; +export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any +export type logEvent = any diff --git a/src/components/FeedbackSurvey/useFeedbackSurvey.tsx b/src/components/FeedbackSurvey/useFeedbackSurvey.tsx index 166c2dcdd..2d5712b94 100644 --- a/src/components/FeedbackSurvey/useFeedbackSurvey.tsx +++ b/src/components/FeedbackSurvey/useFeedbackSurvey.tsx @@ -1,40 +1,37 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useDynamicConfig } from 'src/hooks/useDynamicConfig.js' -import { isFeedbackSurveyDisabled } from 'src/services/analytics/config.js' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useDynamicConfig } from 'src/hooks/useDynamicConfig.js'; +import { isFeedbackSurveyDisabled } from 'src/services/analytics/config.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from 'src/services/analytics/index.js' -import { isPolicyAllowed } from '../../services/policyLimits/index.js' -import type { Message } from '../../types/message.js' -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' -import { isEnvTruthy } from '../../utils/envUtils.js' -import { getLastAssistantMessage } from '../../utils/messages.js' -import { getMainLoopModel } from '../../utils/model/model.js' -import { getInitialSettings } from '../../utils/settings/settings.js' -import { logOTelEvent } from '../../utils/telemetry/events.js' -import { - submitTranscriptShare, - type TranscriptShareTrigger, -} from './submitTranscriptShare.js' -import type { TranscriptShareResponse } from './TranscriptSharePrompt.js' -import { useSurveyState } from './useSurveyState.js' -import type { FeedbackSurveyResponse, FeedbackSurveyType } from './utils.js' +} from 'src/services/analytics/index.js'; +import { isPolicyAllowed } from '../../services/policyLimits/index.js'; +import type { Message } from '../../types/message.js'; +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; +import { isEnvTruthy } from '../../utils/envUtils.js'; +import { getLastAssistantMessage } from '../../utils/messages.js'; +import { getMainLoopModel } from '../../utils/model/model.js'; +import { getInitialSettings } from '../../utils/settings/settings.js'; +import { logOTelEvent } from '../../utils/telemetry/events.js'; +import { submitTranscriptShare, type TranscriptShareTrigger } from './submitTranscriptShare.js'; +import type { TranscriptShareResponse } from './TranscriptSharePrompt.js'; +import { useSurveyState } from './useSurveyState.js'; +import type { FeedbackSurveyResponse, FeedbackSurveyType } from './utils.js'; type FeedbackSurveyConfig = { - minTimeBeforeFeedbackMs: number - minTimeBetweenFeedbackMs: number - minTimeBetweenGlobalFeedbackMs: number - minUserTurnsBeforeFeedback: number - minUserTurnsBetweenFeedback: number - hideThanksAfterMs: number - onForModels: string[] - probability: number -} + minTimeBeforeFeedbackMs: number; + minTimeBetweenFeedbackMs: number; + minTimeBetweenGlobalFeedbackMs: number; + minUserTurnsBeforeFeedback: number; + minUserTurnsBetweenFeedback: number; + hideThanksAfterMs: number; + onForModels: string[]; + probability: number; +}; type TranscriptAskConfig = { - probability: number -} + probability: number; +}; const DEFAULT_FEEDBACK_SURVEY_CONFIG: FeedbackSurveyConfig = { minTimeBeforeFeedbackMs: 600000, @@ -45,11 +42,11 @@ const DEFAULT_FEEDBACK_SURVEY_CONFIG: FeedbackSurveyConfig = { hideThanksAfterMs: 3000, onForModels: ['*'], probability: 0.005, -} +}; const DEFAULT_TRANSCRIPT_ASK_CONFIG: TranscriptAskConfig = { probability: 0, -} +}; export function useFeedbackSurvey( messages: Message[], @@ -58,177 +55,145 @@ export function useFeedbackSurvey( surveyType: FeedbackSurveyType = 'session', hasActivePrompt: boolean = false, ): { - state: - | 'closed' - | 'open' - | 'thanks' - | 'transcript_prompt' - | 'submitting' - | 'submitted' - lastResponse: FeedbackSurveyResponse | null - handleSelect: (selected: FeedbackSurveyResponse) => boolean - handleTranscriptSelect: (selected: TranscriptShareResponse) => void + state: 'closed' | 'open' | 'thanks' | 'transcript_prompt' | 'submitting' | 'submitted'; + lastResponse: FeedbackSurveyResponse | null; + handleSelect: (selected: FeedbackSurveyResponse) => boolean; + handleTranscriptSelect: (selected: TranscriptShareResponse) => void; } { - const lastAssistantMessageIdRef = useRef('unknown') - lastAssistantMessageIdRef.current = - getLastAssistantMessage(messages)?.message?.id || 'unknown' + const lastAssistantMessageIdRef = useRef('unknown'); + lastAssistantMessageIdRef.current = getLastAssistantMessage(messages)?.message?.id || 'unknown'; const [feedbackSurvey, setFeedbackSurvey] = useState<{ - timeLastShown: number | null - submitCountAtLastAppearance: number | null - }>(() => ({ timeLastShown: null, submitCountAtLastAppearance: null })) - const config = useDynamicConfig( - 'tengu_feedback_survey_config', - DEFAULT_FEEDBACK_SURVEY_CONFIG, - ) + timeLastShown: number | null; + submitCountAtLastAppearance: number | null; + }>(() => ({ timeLastShown: null, submitCountAtLastAppearance: null })); + const config = useDynamicConfig('tengu_feedback_survey_config', DEFAULT_FEEDBACK_SURVEY_CONFIG); const badTranscriptAskConfig = useDynamicConfig( 'tengu_bad_survey_transcript_ask_config', DEFAULT_TRANSCRIPT_ASK_CONFIG, - ) + ); const goodTranscriptAskConfig = useDynamicConfig( 'tengu_good_survey_transcript_ask_config', DEFAULT_TRANSCRIPT_ASK_CONFIG, - ) - const settingsRate = getInitialSettings().feedbackSurveyRate - const sessionStartTime = useRef(Date.now()) - const submitCountAtSessionStart = useRef(submitCount) - const submitCountRef = useRef(submitCount) - submitCountRef.current = submitCount - const messagesRef = useRef(messages) - messagesRef.current = messages + ); + const settingsRate = getInitialSettings().feedbackSurveyRate; + const sessionStartTime = useRef(Date.now()); + const submitCountAtSessionStart = useRef(submitCount); + const submitCountRef = useRef(submitCount); + submitCountRef.current = submitCount; + const messagesRef = useRef(messages); + messagesRef.current = messages; // Probability gate: roll once when eligibility conditions are met, not on every // useMemo re-evaluation. Without this, each dependency change (submitCount, // isLoading toggle, etc.) re-rolls Math.random(), making the survey almost // certain to appear after enough renders. - const probabilityPassedRef = useRef(false) - const lastEligibleSubmitCountRef = useRef(null) + const probabilityPassedRef = useRef(false); + const lastEligibleSubmitCountRef = useRef(null); - const updateLastShownTime = useCallback( - (timestamp: number, submitCountValue: number) => { - setFeedbackSurvey(prev => { - if ( - prev.timeLastShown === timestamp && - prev.submitCountAtLastAppearance === submitCountValue - ) { - return prev - } - return { - timeLastShown: timestamp, - submitCountAtLastAppearance: submitCountValue, - } - }) - // Persist cross-session pacing state (previously done by onChangeAppState observer) - if (getGlobalConfig().feedbackSurveyState?.lastShownTime !== timestamp) { - saveGlobalConfig(current => ({ - ...current, - feedbackSurveyState: { - lastShownTime: timestamp, - }, - })) + const updateLastShownTime = useCallback((timestamp: number, submitCountValue: number) => { + setFeedbackSurvey(prev => { + if (prev.timeLastShown === timestamp && prev.submitCountAtLastAppearance === submitCountValue) { + return prev; } - }, - [], - ) + return { + timeLastShown: timestamp, + submitCountAtLastAppearance: submitCountValue, + }; + }); + // Persist cross-session pacing state (previously done by onChangeAppState observer) + if (getGlobalConfig().feedbackSurveyState?.lastShownTime !== timestamp) { + saveGlobalConfig(current => ({ + ...current, + feedbackSurveyState: { + lastShownTime: timestamp, + }, + })); + } + }, []); const onOpen = useCallback( (appearanceId: string) => { - updateLastShownTime(Date.now(), submitCountRef.current) + updateLastShownTime(Date.now(), submitCountRef.current); logEvent('tengu_feedback_survey_event', { - event_type: - 'appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: - appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + event_type: 'appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, last_assistant_message_id: lastAssistantMessageIdRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - survey_type: - surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + survey_type: surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); void logOTelEvent('feedback_survey', { event_type: 'appeared', appearance_id: appearanceId, survey_type: surveyType, - }) + }); }, [updateLastShownTime, surveyType], - ) + ); const onSelect = useCallback( (appearanceId: string, selected: FeedbackSurveyResponse) => { - updateLastShownTime(Date.now(), submitCountRef.current) + updateLastShownTime(Date.now(), submitCountRef.current); logEvent('tengu_feedback_survey_event', { - event_type: - 'responded' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: - appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - response: - selected as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + event_type: 'responded' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + response: selected as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, last_assistant_message_id: lastAssistantMessageIdRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - survey_type: - surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + survey_type: surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); void logOTelEvent('feedback_survey', { event_type: 'responded', appearance_id: appearanceId, response: selected, survey_type: surveyType, - }) + }); }, [updateLastShownTime, surveyType], - ) + ); const shouldShowTranscriptPrompt = useCallback( (selected: FeedbackSurveyResponse) => { // Only bad and good ratings trigger the transcript ask if (selected !== 'bad' && selected !== 'good') { - return false + return false; } // Don't show if user previously chose "Don't ask again" if (getGlobalConfig().transcriptShareDismissed) { - return false + return false; } // Don't show if product feedback is blocked by org policy (ZDR) if (!isPolicyAllowed('allow_product_feedback')) { - return false + return false; } // Probability gate from GrowthBook config (separate per rating) - const probability = - selected === 'bad' - ? badTranscriptAskConfig.probability - : goodTranscriptAskConfig.probability - return Math.random() <= probability + const probability = selected === 'bad' ? badTranscriptAskConfig.probability : goodTranscriptAskConfig.probability; + return Math.random() <= probability; }, [badTranscriptAskConfig.probability, goodTranscriptAskConfig.probability], - ) + ); const onTranscriptPromptShown = useCallback( (appearanceId: string, surveyResponse: FeedbackSurveyResponse) => { const trigger: TranscriptShareTrigger = - surveyResponse === 'good' - ? 'good_feedback_survey' - : 'bad_feedback_survey' + surveyResponse === 'good' ? 'good_feedback_survey' : 'bad_feedback_survey'; logEvent('tengu_feedback_survey_event', { - event_type: - 'transcript_prompt_appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: - appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + event_type: 'transcript_prompt_appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, last_assistant_message_id: lastAssistantMessageIdRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - survey_type: - surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - trigger: - trigger as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + survey_type: surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + trigger: trigger as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); void logOTelEvent('feedback_survey', { event_type: 'transcript_prompt_appeared', appearance_id: appearanceId, survey_type: surveyType, - }) + }); }, [surveyType], - ) + ); const onTranscriptSelect = useCallback( async ( @@ -237,166 +202,143 @@ export function useFeedbackSurvey( surveyResponse: FeedbackSurveyResponse | null, ): Promise => { const trigger: TranscriptShareTrigger = - surveyResponse === 'good' - ? 'good_feedback_survey' - : 'bad_feedback_survey' + surveyResponse === 'good' ? 'good_feedback_survey' : 'bad_feedback_survey'; logEvent('tengu_feedback_survey_event', { - event_type: - `transcript_share_${selected}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: - appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + event_type: `transcript_share_${selected}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, last_assistant_message_id: lastAssistantMessageIdRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - survey_type: - surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - trigger: - trigger as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + survey_type: surveyType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + trigger: trigger as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); if (selected === 'dont_ask_again') { saveGlobalConfig(current => ({ ...current, transcriptShareDismissed: true, - })) + })); } if (selected === 'yes') { - const result = await submitTranscriptShare( - messagesRef.current, - trigger, - appearanceId, - ) + const result = await submitTranscriptShare(messagesRef.current, trigger, appearanceId); logEvent('tengu_feedback_survey_event', { event_type: (result.success ? 'transcript_share_submitted' : 'transcript_share_failed') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: - appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - trigger: - trigger as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - return result.success + appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + trigger: trigger as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + return result.success; } - return false + return false; }, [surveyType], - ) + ); - const { state, lastResponse, open, handleSelect, handleTranscriptSelect } = - useSurveyState({ - hideThanksAfterMs: config.hideThanksAfterMs, - onOpen, - onSelect, - shouldShowTranscriptPrompt, - onTranscriptPromptShown, - onTranscriptSelect, - }) + const { state, lastResponse, open, handleSelect, handleTranscriptSelect } = useSurveyState({ + hideThanksAfterMs: config.hideThanksAfterMs, + onOpen, + onSelect, + shouldShowTranscriptPrompt, + onTranscriptPromptShown, + onTranscriptSelect, + }); - const currentModel = getMainLoopModel() + const currentModel = getMainLoopModel(); const isModelAllowed = useMemo(() => { if (config.onForModels.length === 0) { - return false + return false; } if (config.onForModels.includes('*')) { - return true + return true; } - return config.onForModels.includes(currentModel) - }, [config.onForModels, currentModel]) + return config.onForModels.includes(currentModel); + }, [config.onForModels, currentModel]); const shouldOpen = useMemo(() => { if (state !== 'closed') { - return false + return false; } if (isLoading) { - return false + return false; } // Don't show survey when permission or ask question prompts are visible if (hasActivePrompt) { - return false + return false; } // Force display for testing - if ( - process.env.CLAUDE_FORCE_DISPLAY_SURVEY && - !feedbackSurvey.timeLastShown - ) { - return true + if (process.env.CLAUDE_FORCE_DISPLAY_SURVEY && !feedbackSurvey.timeLastShown) { + return true; } if (!isModelAllowed) { - return false + return false; } if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY)) { - return false + return false; } if (isFeedbackSurveyDisabled()) { - return false + return false; } // Check if product feedback is allowed by org policy if (!isPolicyAllowed('allow_product_feedback')) { - return false + return false; } // Check session-local pacing if (feedbackSurvey.timeLastShown) { // Check time elapsed since last appearance in this session - const timeSinceLastShown = Date.now() - feedbackSurvey.timeLastShown + const timeSinceLastShown = Date.now() - feedbackSurvey.timeLastShown; if (timeSinceLastShown < config.minTimeBetweenFeedbackMs) { - return false + return false; } // Check user turn requirement for subsequent appearances if ( feedbackSurvey.submitCountAtLastAppearance !== null && - submitCount < - feedbackSurvey.submitCountAtLastAppearance + - config.minUserTurnsBetweenFeedback + submitCount < feedbackSurvey.submitCountAtLastAppearance + config.minUserTurnsBetweenFeedback ) { - return false + return false; } } else { // First appearance in this session - const timeSinceSessionStart = Date.now() - sessionStartTime.current + const timeSinceSessionStart = Date.now() - sessionStartTime.current; if (timeSinceSessionStart < config.minTimeBeforeFeedbackMs) { - return false + return false; } - if ( - submitCount < - submitCountAtSessionStart.current + config.minUserTurnsBeforeFeedback - ) { - return false + if (submitCount < submitCountAtSessionStart.current + config.minUserTurnsBeforeFeedback) { + return false; } } // Probability check: roll once per eligibility window to avoid re-rolling // on every useMemo re-evaluation (which would make triggering near-certain). if (lastEligibleSubmitCountRef.current !== submitCount) { - lastEligibleSubmitCountRef.current = submitCount - probabilityPassedRef.current = - Math.random() <= (settingsRate ?? config.probability) + lastEligibleSubmitCountRef.current = submitCount; + probabilityPassedRef.current = Math.random() <= (settingsRate ?? config.probability); } if (!probabilityPassedRef.current) { - return false + return false; } // Check global pacing (across all sessions) // Leave this till last because it reads from the filesystem which is expensive. - const globalFeedbackState = getGlobalConfig().feedbackSurveyState + const globalFeedbackState = getGlobalConfig().feedbackSurveyState; if (globalFeedbackState?.lastShownTime) { - const timeSinceGlobalLastShown = - Date.now() - globalFeedbackState.lastShownTime + const timeSinceGlobalLastShown = Date.now() - globalFeedbackState.lastShownTime; if (timeSinceGlobalLastShown < config.minTimeBetweenGlobalFeedbackMs) { - return false + return false; } } - return true + return true; }, [ state, isLoading, @@ -412,13 +354,13 @@ export function useFeedbackSurvey( config.minUserTurnsBeforeFeedback, config.probability, settingsRate, - ]) + ]); useEffect(() => { if (shouldOpen) { - open() + open(); } - }, [shouldOpen, open]) + }, [shouldOpen, open]); - return { state, lastResponse, handleSelect, handleTranscriptSelect } + return { state, lastResponse, handleSelect, handleTranscriptSelect }; } diff --git a/src/components/FeedbackSurvey/useFrustrationDetection.ts b/src/components/FeedbackSurvey/useFrustrationDetection.ts index b2f028a34..de258fe34 100644 --- a/src/components/FeedbackSurvey/useFrustrationDetection.ts +++ b/src/components/FeedbackSurvey/useFrustrationDetection.ts @@ -5,5 +5,5 @@ export function useFrustrationDetection( _hasActivePrompt: boolean, _otherSurveyOpen: boolean, ): { state: 'closed' | 'open'; handleTranscriptSelect: () => void } { - return { state: 'closed', handleTranscriptSelect: () => {} }; + return { state: 'closed', handleTranscriptSelect: () => {} } } diff --git a/src/components/FeedbackSurvey/useMemorySurvey.tsx b/src/components/FeedbackSurvey/useMemorySurvey.tsx index 81767eac8..354377ac6 100644 --- a/src/components/FeedbackSurvey/useMemorySurvey.tsx +++ b/src/components/FeedbackSurvey/useMemorySurvey.tsx @@ -1,58 +1,52 @@ -import { useCallback, useEffect, useMemo, useRef } from 'react' -import { isFeedbackSurveyDisabled } from 'src/services/analytics/config.js' -import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js' +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { isFeedbackSurveyDisabled } from 'src/services/analytics/config.js'; +import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from 'src/services/analytics/index.js' -import { isAutoMemoryEnabled } from '../../memdir/paths.js' -import { isPolicyAllowed } from '../../services/policyLimits/index.js' -import { FILE_READ_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/FileReadTool/prompt.js' -import type { Message } from '../../types/message.js' -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' -import { isEnvTruthy } from '../../utils/envUtils.js' -import { isAutoManagedMemoryFile } from '../../utils/memoryFileDetection.js' -import { - extractTextContent, - getLastAssistantMessage, -} from '../../utils/messages.js' -import { logOTelEvent } from '../../utils/telemetry/events.js' -import { submitTranscriptShare } from './submitTranscriptShare.js' -import type { TranscriptShareResponse } from './TranscriptSharePrompt.js' -import { useSurveyState } from './useSurveyState.js' -import type { FeedbackSurveyResponse } from './utils.js' +} from 'src/services/analytics/index.js'; +import { isAutoMemoryEnabled } from '../../memdir/paths.js'; +import { isPolicyAllowed } from '../../services/policyLimits/index.js'; +import { FILE_READ_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/FileReadTool/prompt.js'; +import type { Message } from '../../types/message.js'; +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; +import { isEnvTruthy } from '../../utils/envUtils.js'; +import { isAutoManagedMemoryFile } from '../../utils/memoryFileDetection.js'; +import { extractTextContent, getLastAssistantMessage } from '../../utils/messages.js'; +import { logOTelEvent } from '../../utils/telemetry/events.js'; +import { submitTranscriptShare } from './submitTranscriptShare.js'; +import type { TranscriptShareResponse } from './TranscriptSharePrompt.js'; +import { useSurveyState } from './useSurveyState.js'; +import type { FeedbackSurveyResponse } from './utils.js'; -const HIDE_THANKS_AFTER_MS = 3000 -const MEMORY_SURVEY_GATE = 'tengu_dunwich_bell' -const MEMORY_SURVEY_EVENT = 'tengu_memory_survey_event' -const SURVEY_PROBABILITY = 0.2 -const TRANSCRIPT_SHARE_TRIGGER = 'memory_survey' +const HIDE_THANKS_AFTER_MS = 3000; +const MEMORY_SURVEY_GATE = 'tengu_dunwich_bell'; +const MEMORY_SURVEY_EVENT = 'tengu_memory_survey_event'; +const SURVEY_PROBABILITY = 0.2; +const TRANSCRIPT_SHARE_TRIGGER = 'memory_survey'; -const MEMORY_WORD_RE = /\bmemor(?:y|ies)\b/i +const MEMORY_WORD_RE = /\bmemor(?:y|ies)\b/i; function hasMemoryFileRead(messages: Message[]): boolean { for (const message of messages) { if (message.type !== 'assistant') { - continue + continue; } - const content = message.message!.content + const content = message.message!.content; if (!Array.isArray(content)) { - continue + continue; } for (const block of content) { if (block.type !== 'tool_use' || block.name !== FILE_READ_TOOL_NAME) { - continue + continue; } - const input = block.input as { file_path?: unknown } - if ( - typeof input.file_path === 'string' && - isAutoManagedMemoryFile(input.file_path) - ) { - return true + const input = block.input as { file_path?: unknown }; + if (typeof input.file_path === 'string' && isAutoManagedMemoryFile(input.file_path)) { + return true; } } } - return false + return false; } export function useMemorySurvey( @@ -61,223 +55,182 @@ export function useMemorySurvey( hasActivePrompt = false, { enabled = true }: { enabled?: boolean } = {}, ): { - state: - | 'closed' - | 'open' - | 'thanks' - | 'transcript_prompt' - | 'submitting' - | 'submitted' - lastResponse: FeedbackSurveyResponse | null - handleSelect: (selected: FeedbackSurveyResponse) => void - handleTranscriptSelect: (selected: TranscriptShareResponse) => void + state: 'closed' | 'open' | 'thanks' | 'transcript_prompt' | 'submitting' | 'submitted'; + lastResponse: FeedbackSurveyResponse | null; + handleSelect: (selected: FeedbackSurveyResponse) => void; + handleTranscriptSelect: (selected: TranscriptShareResponse) => void; } { // Track assistant message UUIDs that were already evaluated so we don't // re-roll probability on re-renders or re-scan messages for the same turn. - const seenAssistantUuids = useRef>(new Set()) + const seenAssistantUuids = useRef>(new Set()); // Once a memory file read is observed it stays true for the session — // skip the O(n) scan on subsequent turns. - const memoryReadSeen = useRef(false) - const messagesRef = useRef(messages) - messagesRef.current = messages + const memoryReadSeen = useRef(false); + const messagesRef = useRef(messages); + messagesRef.current = messages; const onOpen = useCallback((appearanceId: string) => { logEvent(MEMORY_SURVEY_EVENT, { - event_type: - 'appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: - appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + event_type: 'appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); void logOTelEvent('feedback_survey', { event_type: 'appeared', appearance_id: appearanceId, survey_type: 'memory', - }) - }, []) + }); + }, []); - const onSelect = useCallback( - (appearanceId: string, selected: FeedbackSurveyResponse) => { - logEvent(MEMORY_SURVEY_EVENT, { - event_type: - 'responded' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: - appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - response: - selected as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - void logOTelEvent('feedback_survey', { - event_type: 'responded', - appearance_id: appearanceId, - response: selected, - survey_type: 'memory', - }) - }, - [], - ) + const onSelect = useCallback((appearanceId: string, selected: FeedbackSurveyResponse) => { + logEvent(MEMORY_SURVEY_EVENT, { + event_type: 'responded' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + response: selected as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + void logOTelEvent('feedback_survey', { + event_type: 'responded', + appearance_id: appearanceId, + response: selected, + survey_type: 'memory', + }); + }, []); - const shouldShowTranscriptPrompt = useCallback( - (selected: FeedbackSurveyResponse) => { - if (process.env.USER_TYPE !== 'ant') { - return false - } - if (selected !== 'bad' && selected !== 'good') { - return false - } - if (getGlobalConfig().transcriptShareDismissed) { - return false - } - if (!isPolicyAllowed('allow_product_feedback')) { - return false - } - return true - }, - [], - ) + const shouldShowTranscriptPrompt = useCallback((selected: FeedbackSurveyResponse) => { + if (process.env.USER_TYPE !== 'ant') { + return false; + } + if (selected !== 'bad' && selected !== 'good') { + return false; + } + if (getGlobalConfig().transcriptShareDismissed) { + return false; + } + if (!isPolicyAllowed('allow_product_feedback')) { + return false; + } + return true; + }, []); const onTranscriptPromptShown = useCallback((appearanceId: string) => { logEvent(MEMORY_SURVEY_EVENT, { - event_type: - 'transcript_prompt_appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: - appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - trigger: - TRANSCRIPT_SHARE_TRIGGER as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + event_type: 'transcript_prompt_appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + trigger: TRANSCRIPT_SHARE_TRIGGER as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); void logOTelEvent('feedback_survey', { event_type: 'transcript_prompt_appeared', appearance_id: appearanceId, survey_type: 'memory', - }) - }, []) + }); + }, []); const onTranscriptSelect = useCallback( - async ( - appearanceId: string, - selected: TranscriptShareResponse, - ): Promise => { + async (appearanceId: string, selected: TranscriptShareResponse): Promise => { logEvent(MEMORY_SURVEY_EVENT, { - event_type: - `transcript_share_${selected}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: - appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - trigger: - TRANSCRIPT_SHARE_TRIGGER as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + event_type: `transcript_share_${selected}` as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + trigger: TRANSCRIPT_SHARE_TRIGGER as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); if (selected === 'dont_ask_again') { saveGlobalConfig(current => ({ ...current, transcriptShareDismissed: true, - })) + })); } if (selected === 'yes') { - const result = await submitTranscriptShare( - messagesRef.current, - TRANSCRIPT_SHARE_TRIGGER, - appearanceId, - ) + const result = await submitTranscriptShare(messagesRef.current, TRANSCRIPT_SHARE_TRIGGER, appearanceId); logEvent(MEMORY_SURVEY_EVENT, { event_type: (result.success ? 'transcript_share_submitted' : 'transcript_share_failed') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: - appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - trigger: - TRANSCRIPT_SHARE_TRIGGER as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - return result.success + appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + trigger: TRANSCRIPT_SHARE_TRIGGER as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + return result.success; } - return false + return false; }, [], - ) + ); - const { state, lastResponse, open, handleSelect, handleTranscriptSelect } = - useSurveyState({ - hideThanksAfterMs: HIDE_THANKS_AFTER_MS, - onOpen, - onSelect, - shouldShowTranscriptPrompt, - onTranscriptPromptShown, - onTranscriptSelect, - }) + const { state, lastResponse, open, handleSelect, handleTranscriptSelect } = useSurveyState({ + hideThanksAfterMs: HIDE_THANKS_AFTER_MS, + onOpen, + onSelect, + shouldShowTranscriptPrompt, + onTranscriptPromptShown, + onTranscriptSelect, + }); - const lastAssistant = useMemo( - () => getLastAssistantMessage(messages), - [messages], - ) + const lastAssistant = useMemo(() => getLastAssistantMessage(messages), [messages]); useEffect(() => { - if (!enabled) return + if (!enabled) return; // /clear resets messages but REPL stays mounted — reset refs so a memory // read from the previous conversation doesn't leak into the new one. if (messages.length === 0) { - memoryReadSeen.current = false - seenAssistantUuids.current.clear() - return + memoryReadSeen.current = false; + seenAssistantUuids.current.clear(); + return; } if (state !== 'closed' || isLoading || hasActivePrompt) { - return + return; } // 3P default: survey off (no GrowthBook on Bedrock/Vertex/Foundry). if (!getFeatureValue_CACHED_MAY_BE_STALE(MEMORY_SURVEY_GATE, false)) { - return + return; } if (!isAutoMemoryEnabled()) { - return + return; } if (isFeedbackSurveyDisabled()) { - return + return; } if (!isPolicyAllowed('allow_product_feedback')) { - return + return; } if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY)) { - return + return; } if (!lastAssistant || seenAssistantUuids.current.has(lastAssistant.uuid)) { - return + return; } - const text = extractTextContent(Array.isArray(lastAssistant.message.content) ? lastAssistant.message.content : [], ' ') + const text = extractTextContent( + Array.isArray(lastAssistant.message.content) ? lastAssistant.message.content : [], + ' ', + ); if (!MEMORY_WORD_RE.test(text)) { - return + return; } // Mark as evaluated before the memory-read scan so a turn that mentions // "memory" but has no memory read doesn't trigger repeated O(n) scans // on subsequent renders with the same last assistant message. - seenAssistantUuids.current.add(lastAssistant.uuid) + seenAssistantUuids.current.add(lastAssistant.uuid); if (!memoryReadSeen.current) { - memoryReadSeen.current = hasMemoryFileRead(messages) + memoryReadSeen.current = hasMemoryFileRead(messages); } if (!memoryReadSeen.current) { - return + return; } if (Math.random() < SURVEY_PROBABILITY) { - open() + open(); } - }, [ - enabled, - state, - isLoading, - hasActivePrompt, - lastAssistant, - messages, - open, - ]) + }, [enabled, state, isLoading, hasActivePrompt, lastAssistant, messages, open]); - return { state, lastResponse, handleSelect, handleTranscriptSelect } + return { state, lastResponse, handleSelect, handleTranscriptSelect }; } diff --git a/src/components/FeedbackSurvey/usePostCompactSurvey.tsx b/src/components/FeedbackSurvey/usePostCompactSurvey.tsx index 99e80dfed..8db492d9f 100644 --- a/src/components/FeedbackSurvey/usePostCompactSurvey.tsx +++ b/src/components/FeedbackSurvey/usePostCompactSurvey.tsx @@ -1,39 +1,36 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { isFeedbackSurveyDisabled } from 'src/services/analytics/config.js' -import { checkStatsigFeatureGate_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { isFeedbackSurveyDisabled } from 'src/services/analytics/config.js'; +import { checkStatsigFeatureGate_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from 'src/services/analytics/index.js' -import { shouldUseSessionMemoryCompaction } from '../../services/compact/sessionMemoryCompact.js' -import type { Message } from '../../types/message.js' -import { isEnvTruthy } from '../../utils/envUtils.js' -import { isCompactBoundaryMessage } from '../../utils/messages.js' -import { logOTelEvent } from '../../utils/telemetry/events.js' -import { useSurveyState } from './useSurveyState.js' -import type { FeedbackSurveyResponse } from './utils.js' +} from 'src/services/analytics/index.js'; +import { shouldUseSessionMemoryCompaction } from '../../services/compact/sessionMemoryCompact.js'; +import type { Message } from '../../types/message.js'; +import { isEnvTruthy } from '../../utils/envUtils.js'; +import { isCompactBoundaryMessage } from '../../utils/messages.js'; +import { logOTelEvent } from '../../utils/telemetry/events.js'; +import { useSurveyState } from './useSurveyState.js'; +import type { FeedbackSurveyResponse } from './utils.js'; -const HIDE_THANKS_AFTER_MS = 3000 -const POST_COMPACT_SURVEY_GATE = 'tengu_post_compact_survey' -const SURVEY_PROBABILITY = 0.2 // Show survey 20% of the time after compaction +const HIDE_THANKS_AFTER_MS = 3000; +const POST_COMPACT_SURVEY_GATE = 'tengu_post_compact_survey'; +const SURVEY_PROBABILITY = 0.2; // Show survey 20% of the time after compaction -function hasMessageAfterBoundary( - messages: Message[], - boundaryUuid: string, -): boolean { - const boundaryIndex = messages.findIndex(msg => msg.uuid === boundaryUuid) +function hasMessageAfterBoundary(messages: Message[], boundaryUuid: string): boolean { + const boundaryIndex = messages.findIndex(msg => msg.uuid === boundaryUuid); if (boundaryIndex === -1) { - return false + return false; } // Check if there's a user or assistant message after the boundary for (let i = boundaryIndex + 1; i < messages.length; i++) { - const msg = messages[i] + const msg = messages[i]; if (msg && (msg.type === 'user' || msg.type === 'assistant')) { - return true + return true; } } - return false + return false; } export function usePostCompactSurvey( @@ -42,154 +39,119 @@ export function usePostCompactSurvey( hasActivePrompt = false, { enabled = true }: { enabled?: boolean } = {}, ): { - state: - | 'closed' - | 'open' - | 'thanks' - | 'transcript_prompt' - | 'submitting' - | 'submitted' - lastResponse: FeedbackSurveyResponse | null - handleSelect: (selected: FeedbackSurveyResponse) => void + state: 'closed' | 'open' | 'thanks' | 'transcript_prompt' | 'submitting' | 'submitted'; + lastResponse: FeedbackSurveyResponse | null; + handleSelect: (selected: FeedbackSurveyResponse) => void; } { - const [gateEnabled, setGateEnabled] = useState(null) - const seenCompactBoundaries = useRef>(new Set()) + const [gateEnabled, setGateEnabled] = useState(null); + const seenCompactBoundaries = useRef>(new Set()); // Track the compact boundary we're waiting on (to show survey after next message) - const pendingCompactBoundaryUuid = useRef(null) + const pendingCompactBoundaryUuid = useRef(null); const onOpen = useCallback((appearanceId: string) => { - const smCompactionEnabled = shouldUseSessionMemoryCompaction() + const smCompactionEnabled = shouldUseSessionMemoryCompaction(); logEvent('tengu_post_compact_survey_event', { - event_type: - 'appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: - appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + event_type: 'appeared' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, session_memory_compaction_enabled: smCompactionEnabled as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + }); void logOTelEvent('feedback_survey', { event_type: 'appeared', appearance_id: appearanceId, survey_type: 'post_compact', - }) - }, []) + }); + }, []); - const onSelect = useCallback( - (appearanceId: string, selected: FeedbackSurveyResponse) => { - const smCompactionEnabled = shouldUseSessionMemoryCompaction() - logEvent('tengu_post_compact_survey_event', { - event_type: - 'responded' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - appearance_id: - appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - response: - selected as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - session_memory_compaction_enabled: - smCompactionEnabled as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - void logOTelEvent('feedback_survey', { - event_type: 'responded', - appearance_id: appearanceId, - response: selected, - survey_type: 'post_compact', - }) - }, - [], - ) + const onSelect = useCallback((appearanceId: string, selected: FeedbackSurveyResponse) => { + const smCompactionEnabled = shouldUseSessionMemoryCompaction(); + logEvent('tengu_post_compact_survey_event', { + event_type: 'responded' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + appearance_id: appearanceId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + response: selected as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + session_memory_compaction_enabled: + smCompactionEnabled as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + void logOTelEvent('feedback_survey', { + event_type: 'responded', + appearance_id: appearanceId, + response: selected, + survey_type: 'post_compact', + }); + }, []); const { state, lastResponse, open, handleSelect } = useSurveyState({ hideThanksAfterMs: HIDE_THANKS_AFTER_MS, onOpen, onSelect, - }) + }); // Check the feature gate on mount useEffect(() => { - if (!enabled) return - setGateEnabled( - checkStatsigFeatureGate_CACHED_MAY_BE_STALE(POST_COMPACT_SURVEY_GATE), - ) - }, [enabled]) + if (!enabled) return; + setGateEnabled(checkStatsigFeatureGate_CACHED_MAY_BE_STALE(POST_COMPACT_SURVEY_GATE)); + }, [enabled]); // Find compact boundary messages const currentCompactBoundaries = useMemo( - () => - new Set( - messages - .filter(msg => isCompactBoundaryMessage(msg)) - .map(msg => msg.uuid), - ), + () => new Set(messages.filter(msg => isCompactBoundaryMessage(msg)).map(msg => msg.uuid)), [messages], - ) + ); // Detect new compact boundaries and defer showing survey until next message useEffect(() => { - if (!enabled) return + if (!enabled) return; // Don't process if already showing if (state !== 'closed' || isLoading) { - return + return; } // Don't show survey when permission or ask question prompts are visible if (hasActivePrompt) { - return + return; } // Check if the gate is enabled if (gateEnabled !== true) { - return + return; } if (isFeedbackSurveyDisabled()) { - return + return; } // Check if survey is explicitly disabled if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FEEDBACK_SURVEY)) { - return + return; } // First, check if we have a pending compact and a new message has arrived if (pendingCompactBoundaryUuid.current !== null) { - if ( - hasMessageAfterBoundary(messages, pendingCompactBoundaryUuid.current) - ) { + if (hasMessageAfterBoundary(messages, pendingCompactBoundaryUuid.current)) { // A new message arrived after the compact - decide whether to show survey - pendingCompactBoundaryUuid.current = null + pendingCompactBoundaryUuid.current = null; // Only show survey 20% of the time if (Math.random() < SURVEY_PROBABILITY) { - open() + open(); } - return + return; } } // Find new compact boundaries that we haven't seen yet - const newBoundaries = Array.from(currentCompactBoundaries).filter( - uuid => !seenCompactBoundaries.current.has(uuid), - ) + const newBoundaries = Array.from(currentCompactBoundaries).filter(uuid => !seenCompactBoundaries.current.has(uuid)); if (newBoundaries.length > 0) { // Mark these boundaries as seen - seenCompactBoundaries.current = new Set(currentCompactBoundaries) + seenCompactBoundaries.current = new Set(currentCompactBoundaries); // Don't show survey immediately - wait for next message // Store the most recent new boundary UUID - pendingCompactBoundaryUuid.current = - newBoundaries[newBoundaries.length - 1]! + pendingCompactBoundaryUuid.current = newBoundaries[newBoundaries.length - 1]!; } - }, [ - enabled, - currentCompactBoundaries, - state, - isLoading, - hasActivePrompt, - gateEnabled, - messages, - open, - ]) + }, [enabled, currentCompactBoundaries, state, isLoading, hasActivePrompt, gateEnabled, messages, open]); - return { state, lastResponse, handleSelect } + return { state, lastResponse, handleSelect }; } diff --git a/src/components/FeedbackSurvey/useSurveyState.tsx b/src/components/FeedbackSurvey/useSurveyState.tsx index e00c82c0d..0d5a47c1c 100644 --- a/src/components/FeedbackSurvey/useSurveyState.tsx +++ b/src/components/FeedbackSurvey/useSurveyState.tsx @@ -1,34 +1,22 @@ -import { randomUUID } from 'crypto' -import { useCallback, useRef, useState } from 'react' -import type { TranscriptShareResponse } from './TranscriptSharePrompt.js' -import type { FeedbackSurveyResponse } from './utils.js' +import { randomUUID } from 'crypto'; +import { useCallback, useRef, useState } from 'react'; +import type { TranscriptShareResponse } from './TranscriptSharePrompt.js'; +import type { FeedbackSurveyResponse } from './utils.js'; -type SurveyState = - | 'closed' - | 'open' - | 'thanks' - | 'transcript_prompt' - | 'submitting' - | 'submitted' +type SurveyState = 'closed' | 'open' | 'thanks' | 'transcript_prompt' | 'submitting' | 'submitted'; type UseSurveyStateOptions = { - hideThanksAfterMs: number - onOpen: (appearanceId: string) => void | Promise - onSelect: ( - appearanceId: string, - selected: FeedbackSurveyResponse, - ) => void | Promise - shouldShowTranscriptPrompt?: (selected: FeedbackSurveyResponse) => boolean - onTranscriptPromptShown?: ( - appearanceId: string, - surveyResponse: FeedbackSurveyResponse, - ) => void + hideThanksAfterMs: number; + onOpen: (appearanceId: string) => void | Promise; + onSelect: (appearanceId: string, selected: FeedbackSurveyResponse) => void | Promise; + shouldShowTranscriptPrompt?: (selected: FeedbackSurveyResponse) => boolean; + onTranscriptPromptShown?: (appearanceId: string, surveyResponse: FeedbackSurveyResponse) => void; onTranscriptSelect?: ( appearanceId: string, selected: TranscriptShareResponse, surveyResponse: FeedbackSurveyResponse | null, - ) => boolean | Promise -} + ) => boolean | Promise; +}; export function useSurveyState({ hideThanksAfterMs, @@ -38,107 +26,93 @@ export function useSurveyState({ onTranscriptPromptShown, onTranscriptSelect, }: UseSurveyStateOptions): { - state: SurveyState - lastResponse: FeedbackSurveyResponse | null - open: () => void - handleSelect: (selected: FeedbackSurveyResponse) => boolean - handleTranscriptSelect: (selected: TranscriptShareResponse) => void + state: SurveyState; + lastResponse: FeedbackSurveyResponse | null; + open: () => void; + handleSelect: (selected: FeedbackSurveyResponse) => boolean; + handleTranscriptSelect: (selected: TranscriptShareResponse) => void; } { - const [state, setState] = useState('closed') - const [lastResponse, setLastResponse] = - useState(null) - const appearanceId = useRef(randomUUID()) - const lastResponseRef = useRef(null) + const [state, setState] = useState('closed'); + const [lastResponse, setLastResponse] = useState(null); + const appearanceId = useRef(randomUUID()); + const lastResponseRef = useRef(null); const showThanksThenClose = useCallback(() => { - setState('thanks') + setState('thanks'); setTimeout( (setState, setLastResponse) => { - setState('closed') - setLastResponse(null) + setState('closed'); + setLastResponse(null); }, hideThanksAfterMs, setState, setLastResponse, - ) - }, [hideThanksAfterMs]) + ); + }, [hideThanksAfterMs]); const showSubmittedThenClose = useCallback(() => { - setState('submitted') - setTimeout(setState, hideThanksAfterMs, 'closed') - }, [hideThanksAfterMs]) + setState('submitted'); + setTimeout(setState, hideThanksAfterMs, 'closed'); + }, [hideThanksAfterMs]); const open = useCallback(() => { if (state !== 'closed') { - return + return; } - setState('open') - appearanceId.current = randomUUID() - void onOpen(appearanceId.current) - }, [state, onOpen]) + setState('open'); + appearanceId.current = randomUUID(); + void onOpen(appearanceId.current); + }, [state, onOpen]); const handleSelect = useCallback( (selected: FeedbackSurveyResponse): boolean => { - setLastResponse(selected) - lastResponseRef.current = selected + setLastResponse(selected); + lastResponseRef.current = selected; // Always fire the survey response event first - void onSelect(appearanceId.current, selected) + void onSelect(appearanceId.current, selected); if (selected === 'dismissed') { - setState('closed') - setLastResponse(null) + setState('closed'); + setLastResponse(null); } else if (shouldShowTranscriptPrompt?.(selected)) { - setState('transcript_prompt') - onTranscriptPromptShown?.(appearanceId.current, selected) - return true + setState('transcript_prompt'); + onTranscriptPromptShown?.(appearanceId.current, selected); + return true; } else { - showThanksThenClose() + showThanksThenClose(); } - return false + return false; }, - [ - showThanksThenClose, - onSelect, - shouldShowTranscriptPrompt, - onTranscriptPromptShown, - ], - ) + [showThanksThenClose, onSelect, shouldShowTranscriptPrompt, onTranscriptPromptShown], + ); const handleTranscriptSelect = useCallback( (selected: TranscriptShareResponse) => { switch (selected) { case 'yes': - setState('submitting') + setState('submitting'); void (async () => { try { - const success = await onTranscriptSelect?.( - appearanceId.current, - selected, - lastResponseRef.current, - ) + const success = await onTranscriptSelect?.(appearanceId.current, selected, lastResponseRef.current); if (success) { - showSubmittedThenClose() + showSubmittedThenClose(); } else { - showThanksThenClose() + showThanksThenClose(); } } catch { - showThanksThenClose() + showThanksThenClose(); } - })() - break + })(); + break; case 'no': case 'dont_ask_again': - void onTranscriptSelect?.( - appearanceId.current, - selected, - lastResponseRef.current, - ) - showThanksThenClose() - break + void onTranscriptSelect?.(appearanceId.current, selected, lastResponseRef.current); + showThanksThenClose(); + break; } }, [showThanksThenClose, showSubmittedThenClose, onTranscriptSelect], - ) + ); - return { state, lastResponse, open, handleSelect, handleTranscriptSelect } + return { state, lastResponse, open, handleSelect, handleTranscriptSelect }; } diff --git a/src/components/FeedbackSurvey/utils.ts b/src/components/FeedbackSurvey/utils.ts index bce6606fb..501e8e41e 100644 --- a/src/components/FeedbackSurvey/utils.ts +++ b/src/components/FeedbackSurvey/utils.ts @@ -1,3 +1,3 @@ // Auto-generated stub — replace with real implementation -export type FeedbackSurveyResponse = any; -export type FeedbackSurveyType = any; +export type FeedbackSurveyResponse = any +export type FeedbackSurveyType = any diff --git a/src/components/FileEditToolDiff.tsx b/src/components/FileEditToolDiff.tsx index 4b9f6f66b..71acf4338 100644 --- a/src/components/FileEditToolDiff.tsx +++ b/src/components/FileEditToolDiff.tsx @@ -1,62 +1,42 @@ -import type { StructuredPatchHunk } from 'diff' -import * as React from 'react' -import { Suspense, use, useState } from 'react' -import { useTerminalSize } from '../hooks/useTerminalSize.js' -import { Box, Text } from '@anthropic/ink' -import type { FileEdit } from '@claude-code-best/builtin-tools/tools/FileEditTool/types.js' -import { - findActualString, - preserveQuoteStyle, -} from '@claude-code-best/builtin-tools/tools/FileEditTool/utils.js' -import { - adjustHunkLineNumbers, - CONTEXT_LINES, - getPatchForDisplay, -} from '../utils/diff.js' -import { logError } from '../utils/log.js' -import { - CHUNK_SIZE, - openForScan, - readCapped, - scanForContext, -} from '../utils/readEditContext.js' -import { firstLineOf } from '../utils/stringUtils.js' -import { StructuredDiffList } from './StructuredDiffList.js' +import type { StructuredPatchHunk } from 'diff'; +import * as React from 'react'; +import { Suspense, use, useState } from 'react'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { Box, Text } from '@anthropic/ink'; +import type { FileEdit } from '@claude-code-best/builtin-tools/tools/FileEditTool/types.js'; +import { findActualString, preserveQuoteStyle } from '@claude-code-best/builtin-tools/tools/FileEditTool/utils.js'; +import { adjustHunkLineNumbers, CONTEXT_LINES, getPatchForDisplay } from '../utils/diff.js'; +import { logError } from '../utils/log.js'; +import { CHUNK_SIZE, openForScan, readCapped, scanForContext } from '../utils/readEditContext.js'; +import { firstLineOf } from '../utils/stringUtils.js'; +import { StructuredDiffList } from './StructuredDiffList.js'; type Props = { - file_path: string - edits: FileEdit[] -} + file_path: string; + edits: FileEdit[]; +}; type DiffData = { - patch: StructuredPatchHunk[] - firstLine: string | null - fileContent: string | undefined -} + patch: StructuredPatchHunk[]; + firstLine: string | null; + fileContent: string | undefined; +}; export function FileEditToolDiff(props: Props): React.ReactNode { // Snapshot on mount — the diff must stay consistent even if the file changes // while the dialog is open. useMemo on props.edits would re-read the file on // every render because callers pass fresh array literals. - const [dataPromise] = useState(() => - loadDiffData(props.file_path, props.edits), - ) + const [dataPromise] = useState(() => loadDiffData(props.file_path, props.edits)); return ( }> - ) + ); } -function DiffBody({ - promise, - file_path, -}: { - promise: Promise - file_path: string -}): React.ReactNode { - const { patch, firstLine, fileContent } = use(promise) - const { columns } = useTerminalSize() +function DiffBody({ promise, file_path }: { promise: Promise; file_path: string }): React.ReactNode { + const { patch, firstLine, fileContent } = use(promise); + const { columns } = useTerminalSize(); return ( - ) + ); } -function DiffFrame({ - children, - placeholder, -}: { - children?: React.ReactNode - placeholder?: boolean -}): React.ReactNode { +function DiffFrame({ children, placeholder }: { children?: React.ReactNode; placeholder?: boolean }): React.ReactNode { return ( - + {placeholder ? : children} - ) + ); } -async function loadDiffData( - file_path: string, - edits: FileEdit[], -): Promise { - const valid = edits.filter(e => e.old_string != null && e.new_string != null) - const single = valid.length === 1 ? valid[0]! : undefined +async function loadDiffData(file_path: string, edits: FileEdit[]): Promise { + const valid = edits.filter(e => e.old_string != null && e.new_string != null); + const single = valid.length === 1 ? valid[0]! : undefined; // SedEditPermissionRequest passes the entire file as old_string. Scanning for // a needle ≥ CHUNK_SIZE allocates O(needle) for the overlap buffer — skip the // file read entirely and diff the inputs we already have. if (single && single.old_string.length >= CHUNK_SIZE) { - return diffToolInputsOnly(file_path, [single]) + return diffToolInputsOnly(file_path, [single]); } try { - const handle = await openForScan(file_path) - if (handle === null) return diffToolInputsOnly(file_path, valid) + const handle = await openForScan(file_path); + if (handle === null) return diffToolInputsOnly(file_path, valid); try { // Multi-edit and empty old_string genuinely need full-file for sequential // replacements — structuredPatch needs before/after strings. replace_all // routes through the chunked path below (shows first-occurrence window; // matches within the slice still replace via edit.replace_all). if (!single || single.old_string === '') { - const file = await readCapped(handle) - if (file === null) return diffToolInputsOnly(file_path, valid) - const normalized = valid.map(e => normalizeEdit(file, e)) + const file = await readCapped(handle); + if (file === null) return diffToolInputsOnly(file_path, valid); + const normalized = valid.map(e => normalizeEdit(file, e)); return { patch: getPatchForDisplay({ filePath: file_path, @@ -127,30 +92,30 @@ async function loadDiffData( }), firstLine: firstLineOf(file), fileContent: file, - } + }; } - const ctx = await scanForContext(handle, single.old_string, CONTEXT_LINES) + const ctx = await scanForContext(handle, single.old_string, CONTEXT_LINES); if (ctx.truncated || ctx.content === '') { - return diffToolInputsOnly(file_path, [single]) + return diffToolInputsOnly(file_path, [single]); } - const normalized = normalizeEdit(ctx.content, single) + const normalized = normalizeEdit(ctx.content, single); const hunks = getPatchForDisplay({ filePath: file_path, fileContents: ctx.content, edits: [normalized], - }) + }); return { patch: adjustHunkLineNumbers(hunks, ctx.lineOffset - 1), firstLine: ctx.lineOffset === 1 ? firstLineOf(ctx.content) : null, fileContent: ctx.content, - } + }; } finally { - await handle.close() + await handle.close(); } } catch (e) { - logError(e as Error) - return diffToolInputsOnly(file_path, valid) + logError(e as Error); + return diffToolInputsOnly(file_path, valid); } } @@ -165,16 +130,11 @@ function diffToolInputsOnly(filePath: string, edits: FileEdit[]): DiffData { ), firstLine: null, fileContent: undefined, - } + }; } function normalizeEdit(fileContent: string, edit: FileEdit): FileEdit { - const actualOld = - findActualString(fileContent, edit.old_string) || edit.old_string - const actualNew = preserveQuoteStyle( - edit.old_string, - actualOld, - edit.new_string, - ) - return { ...edit, old_string: actualOld, new_string: actualNew } + const actualOld = findActualString(fileContent, edit.old_string) || edit.old_string; + const actualNew = preserveQuoteStyle(edit.old_string, actualOld, edit.new_string); + return { ...edit, old_string: actualOld, new_string: actualNew }; } diff --git a/src/components/FileEditToolUpdatedMessage.tsx b/src/components/FileEditToolUpdatedMessage.tsx index 5fbc68005..f711c6271 100644 --- a/src/components/FileEditToolUpdatedMessage.tsx +++ b/src/components/FileEditToolUpdatedMessage.tsx @@ -1,20 +1,20 @@ -import type { StructuredPatchHunk } from 'diff' -import * as React from 'react' -import { useTerminalSize } from '../hooks/useTerminalSize.js' -import { Box, Text } from '@anthropic/ink' -import { count } from '../utils/array.js' -import { MessageResponse } from './MessageResponse.js' -import { StructuredDiffList } from './StructuredDiffList.js' +import type { StructuredPatchHunk } from 'diff'; +import * as React from 'react'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { Box, Text } from '@anthropic/ink'; +import { count } from '../utils/array.js'; +import { MessageResponse } from './MessageResponse.js'; +import { StructuredDiffList } from './StructuredDiffList.js'; type Props = { - filePath: string - structuredPatch: StructuredPatchHunk[] - firstLine: string | null - fileContent?: string - style?: 'condensed' - verbose: boolean - previewHint?: string -} + filePath: string; + structuredPatch: StructuredPatchHunk[]; + firstLine: string | null; + fileContent?: string; + style?: 'condensed'; + verbose: boolean; + previewHint?: string; +}; export function FileEditToolUpdatedMessage({ filePath, @@ -25,33 +25,25 @@ export function FileEditToolUpdatedMessage({ verbose, previewHint, }: Props): React.ReactNode { - const { columns } = useTerminalSize() - const numAdditions = structuredPatch.reduce( - (acc, hunk) => acc + count(hunk.lines, _ => _.startsWith('+')), - 0, - ) - const numRemovals = structuredPatch.reduce( - (acc, hunk) => acc + count(hunk.lines, _ => _.startsWith('-')), - 0, - ) + const { columns } = useTerminalSize(); + const numAdditions = structuredPatch.reduce((acc, hunk) => acc + count(hunk.lines, _ => _.startsWith('+')), 0); + const numRemovals = structuredPatch.reduce((acc, hunk) => acc + count(hunk.lines, _ => _.startsWith('-')), 0); const text = ( {numAdditions > 0 ? ( <> - Added {numAdditions}{' '} - {numAdditions > 1 ? 'lines' : 'line'} + Added {numAdditions} {numAdditions > 1 ? 'lines' : 'line'} ) : null} {numAdditions > 0 && numRemovals > 0 ? ', ' : null} {numRemovals > 0 ? ( <> - {numAdditions === 0 ? 'R' : 'r'}emoved {numRemovals}{' '} - {numRemovals > 1 ? 'lines' : 'line'} + {numAdditions === 0 ? 'R' : 'r'}emoved {numRemovals} {numRemovals > 1 ? 'lines' : 'line'} ) : null} - ) + ); // Plan files: invert condensed behavior // - Regular mode: just show the hint (user can type /plan to see full content) @@ -62,10 +54,10 @@ export function FileEditToolUpdatedMessage({ {previewHint} - ) + ); } } else if (style === 'condensed' && !verbose) { - return text + return text; } return ( @@ -82,5 +74,5 @@ export function FileEditToolUpdatedMessage({ /> - ) + ); } diff --git a/src/components/FileEditToolUseRejectedMessage.tsx b/src/components/FileEditToolUseRejectedMessage.tsx index 2d53117d2..bc09f5d57 100644 --- a/src/components/FileEditToolUseRejectedMessage.tsx +++ b/src/components/FileEditToolUseRejectedMessage.tsx @@ -1,27 +1,27 @@ -import type { StructuredPatchHunk } from 'diff' -import { relative } from 'path' -import * as React from 'react' -import { useTerminalSize } from 'src/hooks/useTerminalSize.js' -import { getCwd } from 'src/utils/cwd.js' -import { Box, Text } from '@anthropic/ink' -import { HighlightedCode } from './HighlightedCode.js' -import { MessageResponse } from './MessageResponse.js' -import { StructuredDiffList } from './StructuredDiffList.js' +import type { StructuredPatchHunk } from 'diff'; +import { relative } from 'path'; +import * as React from 'react'; +import { useTerminalSize } from 'src/hooks/useTerminalSize.js'; +import { getCwd } from 'src/utils/cwd.js'; +import { Box, Text } from '@anthropic/ink'; +import { HighlightedCode } from './HighlightedCode.js'; +import { MessageResponse } from './MessageResponse.js'; +import { StructuredDiffList } from './StructuredDiffList.js'; -const MAX_LINES_TO_RENDER = 10 +const MAX_LINES_TO_RENDER = 10; type Props = { - file_path: string - operation: 'write' | 'update' + file_path: string; + operation: 'write' | 'update'; // For updates - show diff - patch?: StructuredPatchHunk[] - firstLine: string | null - fileContent?: string + patch?: StructuredPatchHunk[]; + firstLine: string | null; + fileContent?: string; // For new file creation - show content preview - content?: string - style?: 'condensed' - verbose: boolean -} + content?: string; + style?: 'condensed'; + verbose: boolean; +}; export function FileEditToolUseRejectedMessage({ file_path, @@ -33,7 +33,7 @@ export function FileEditToolUseRejectedMessage({ style, verbose, }: Props): React.ReactNode { - const { columns } = useTerminalSize() + const { columns } = useTerminalSize(); const text = ( User rejected {operation} to @@ -41,43 +41,34 @@ export function FileEditToolUseRejectedMessage({ {verbose ? file_path : relative(getCwd(), file_path)} - ) + ); // For condensed style, just show the text if (style === 'condensed' && !verbose) { - return {text} + return {text}; } // For new file creation, show content preview (dimmed) if (operation === 'write' && content !== undefined) { - const lines = content.split('\n') - const numLines = lines.length - const plusLines = numLines - MAX_LINES_TO_RENDER - const truncatedContent = verbose - ? content - : lines.slice(0, MAX_LINES_TO_RENDER).join('\n') + const lines = content.split('\n'); + const numLines = lines.length; + const plusLines = numLines - MAX_LINES_TO_RENDER; + const truncatedContent = verbose ? content : lines.slice(0, MAX_LINES_TO_RENDER).join('\n'); return ( {text} - - {!verbose && plusLines > 0 && ( - … +{plusLines} lines - )} + + {!verbose && plusLines > 0 && … +{plusLines} lines} - ) + ); } // For updates, show diff if (!patch || patch.length === 0) { - return {text} + return {text}; } return ( @@ -94,5 +85,5 @@ export function FileEditToolUseRejectedMessage({ /> - ) + ); } diff --git a/src/components/FilePathLink.tsx b/src/components/FilePathLink.tsx index 9ab7f3cd1..b974300f0 100644 --- a/src/components/FilePathLink.tsx +++ b/src/components/FilePathLink.tsx @@ -1,13 +1,13 @@ -import React from 'react' -import { pathToFileURL } from 'url' -import { Link } from '@anthropic/ink' +import React from 'react'; +import { pathToFileURL } from 'url'; +import { Link } from '@anthropic/ink'; type Props = { /** The absolute file path */ - filePath: string + filePath: string; /** Optional display text (defaults to filePath) */ - children?: React.ReactNode -} + children?: React.ReactNode; +}; /** * Renders a file path as an OSC 8 hyperlink. @@ -15,5 +15,5 @@ type Props = { * even when they appear inside parentheses or other text. */ export function FilePathLink({ filePath, children }: Props): React.ReactNode { - return {children ?? filePath} + return {children ?? filePath}; } diff --git a/src/components/FullscreenLayout.tsx b/src/components/FullscreenLayout.tsx index 4427fa4d4..e373cf070 100644 --- a/src/components/FullscreenLayout.tsx +++ b/src/components/FullscreenLayout.tsx @@ -10,23 +10,19 @@ import React, { useRef, useState, useSyncExternalStore, -} from 'react' -import { fileURLToPath } from 'url' -import { ModalContext } from '../context/modalContext.js' -import { - PromptOverlayProvider, - usePromptOverlay, - usePromptOverlayDialog, -} from '../context/promptOverlayContext.js' -import { useTerminalSize } from '../hooks/useTerminalSize.js' -import { Box, ScrollBox, type ScrollBoxHandle, Text, instances } from '@anthropic/ink' -import type { Message } from '../types/message.js' -import { openBrowser, openPath } from '../utils/browser.js' -import { isFullscreenEnvEnabled } from '../utils/fullscreen.js' -import { plural } from '../utils/stringUtils.js' -import { isNullRenderingAttachment } from './messages/nullRenderingAttachments.js' -import PromptInputFooterSuggestions from './PromptInput/PromptInputFooterSuggestions.js' -import type { StickyPrompt } from './VirtualMessageList.js' +} from 'react'; +import { fileURLToPath } from 'url'; +import { ModalContext } from '../context/modalContext.js'; +import { PromptOverlayProvider, usePromptOverlay, usePromptOverlayDialog } from '../context/promptOverlayContext.js'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { Box, ScrollBox, type ScrollBoxHandle, Text, instances } from '@anthropic/ink'; +import type { Message } from '../types/message.js'; +import { openBrowser, openPath } from '../utils/browser.js'; +import { isFullscreenEnvEnabled } from '../utils/fullscreen.js'; +import { plural } from '../utils/stringUtils.js'; +import { isNullRenderingAttachment } from './messages/nullRenderingAttachments.js'; +import PromptInputFooterSuggestions from './PromptInput/PromptInputFooterSuggestions.js'; +import type { StickyPrompt } from './VirtualMessageList.js'; /** Rows of transcript context kept visible above the modal pane's ▔ divider. */ const MODAL_TRANSCRIPT_PEEK = 2; @@ -232,10 +228,10 @@ export function countUnseenAssistantTurns(messages: readonly Message[], dividerI } function assistantHasVisibleText(m: Message): boolean { - if (m.type !== 'assistant') return false - if (!Array.isArray(m.message!.content)) return false + if (m.type !== 'assistant') return false; + if (!Array.isArray(m.message!.content)) return false; for (const b of m.message!.content) { - if (typeof b !== 'string' && b.type === 'text' && b.text.trim() !== '') return true + if (typeof b !== 'string' && b.type === 'text' && b.text.trim() !== '') return true; } return false; } diff --git a/src/components/GlobalSearchDialog.tsx b/src/components/GlobalSearchDialog.tsx index 3a41099cd..74e7dbc91 100644 --- a/src/components/GlobalSearchDialog.tsx +++ b/src/components/GlobalSearchDialog.tsx @@ -1,123 +1,114 @@ -import { resolve as resolvePath } from 'path' -import * as React from 'react' -import { useEffect, useRef, useState } from 'react' -import { useRegisterOverlay } from '../context/overlayContext.js' -import { useTerminalSize } from '../hooks/useTerminalSize.js' -import { Text } from '@anthropic/ink' -import { logEvent } from '../services/analytics/index.js' -import { getCwd } from '../utils/cwd.js' -import { openFileInExternalEditor } from '../utils/editor.js' -import { truncatePathMiddle, truncateToWidth } from '../utils/format.js' -import { highlightMatch } from '../utils/highlightMatch.js' -import { relativePath } from '../utils/permissions/filesystem.js' -import { readFileInRange } from '../utils/readFileInRange.js' -import { ripGrepStream } from '../utils/ripgrep.js' -import { FuzzyPicker, LoadingState } from '@anthropic/ink' +import { resolve as resolvePath } from 'path'; +import * as React from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { useRegisterOverlay } from '../context/overlayContext.js'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { Text } from '@anthropic/ink'; +import { logEvent } from '../services/analytics/index.js'; +import { getCwd } from '../utils/cwd.js'; +import { openFileInExternalEditor } from '../utils/editor.js'; +import { truncatePathMiddle, truncateToWidth } from '../utils/format.js'; +import { highlightMatch } from '../utils/highlightMatch.js'; +import { relativePath } from '../utils/permissions/filesystem.js'; +import { readFileInRange } from '../utils/readFileInRange.js'; +import { ripGrepStream } from '../utils/ripgrep.js'; +import { FuzzyPicker, LoadingState } from '@anthropic/ink'; type Props = { - onDone: () => void - onInsert: (text: string) => void -} + onDone: () => void; + onInsert: (text: string) => void; +}; type Match = { - file: string - line: number - text: string -} + file: string; + line: number; + text: string; +}; -const VISIBLE_RESULTS = 12 -const DEBOUNCE_MS = 100 -const PREVIEW_CONTEXT_LINES = 4 +const VISIBLE_RESULTS = 12; +const DEBOUNCE_MS = 100; +const PREVIEW_CONTEXT_LINES = 4; // rg -m is per-file; we also cap the parsed array to keep memory bounded. -const MAX_MATCHES_PER_FILE = 10 -const MAX_TOTAL_MATCHES = 500 +const MAX_MATCHES_PER_FILE = 10; +const MAX_TOTAL_MATCHES = 500; /** * Global Search dialog (ctrl+shift+f / cmd+shift+f). * Debounced ripgrep search across the workspace. */ -export function GlobalSearchDialog({ - onDone, - onInsert, -}: Props): React.ReactNode { - useRegisterOverlay('global-search') - const { columns, rows } = useTerminalSize() - const previewOnRight = columns >= 140 +export function GlobalSearchDialog({ onDone, onInsert }: Props): React.ReactNode { + useRegisterOverlay('global-search'); + const { columns, rows } = useTerminalSize(); + const previewOnRight = columns >= 140; // Chrome (title + search + matchLabel + hints + pane border + gaps) eats // ~14 rows. Shrink the list on short terminals so the dialog doesn't clip. - const visibleResults = Math.min(VISIBLE_RESULTS, Math.max(4, rows - 14)) + const visibleResults = Math.min(VISIBLE_RESULTS, Math.max(4, rows - 14)); - const [matches, setMatches] = useState([]) - const [truncated, setTruncated] = useState(false) - const [isSearching, setIsSearching] = useState(false) - const [query, setQuery] = useState('') - const [focused, setFocused] = useState(undefined) + const [matches, setMatches] = useState([]); + const [truncated, setTruncated] = useState(false); + const [isSearching, setIsSearching] = useState(false); + const [query, setQuery] = useState(''); + const [focused, setFocused] = useState(undefined); const [preview, setPreview] = useState<{ - file: string - line: number - content: string - } | null>(null) - const abortRef = useRef(null) - const timeoutRef = useRef | null>(null) + file: string; + line: number; + content: string; + } | null>(null); + const abortRef = useRef(null); + const timeoutRef = useRef | null>(null); useEffect(() => { return () => { - if (timeoutRef.current) clearTimeout(timeoutRef.current) - abortRef.current?.abort() - } - }, []) + if (timeoutRef.current) clearTimeout(timeoutRef.current); + abortRef.current?.abort(); + }; + }, []); // Load context lines around the focused match. AbortController prevents // holding ↓ from piling up reads. useEffect(() => { if (!focused) { - setPreview(null) - return + setPreview(null); + return; } - const controller = new AbortController() - const absolute = resolvePath(getCwd(), focused.file) - const start = Math.max(0, focused.line - PREVIEW_CONTEXT_LINES - 1) - void readFileInRange( - absolute, - start, - PREVIEW_CONTEXT_LINES * 2 + 1, - undefined, - controller.signal, - ) + const controller = new AbortController(); + const absolute = resolvePath(getCwd(), focused.file); + const start = Math.max(0, focused.line - PREVIEW_CONTEXT_LINES - 1); + void readFileInRange(absolute, start, PREVIEW_CONTEXT_LINES * 2 + 1, undefined, controller.signal) .then(r => { - if (controller.signal.aborted) return + if (controller.signal.aborted) return; setPreview({ file: focused.file, line: focused.line, content: r.content, - }) + }); }) .catch(() => { - if (controller.signal.aborted) return + if (controller.signal.aborted) return; setPreview({ file: focused.file, line: focused.line, content: '(preview unavailable)', - }) - }) - return () => controller.abort() - }, [focused]) + }); + }); + return () => controller.abort(); + }, [focused]); const handleQueryChange = (q: string) => { - setQuery(q) - if (timeoutRef.current) clearTimeout(timeoutRef.current) - abortRef.current?.abort() + setQuery(q); + if (timeoutRef.current) clearTimeout(timeoutRef.current); + abortRef.current?.abort(); if (!q.trim()) { - setMatches(m => (m.length ? [] : m)) - setIsSearching(false) - setTruncated(false) - return + setMatches(m => (m.length ? [] : m)); + setIsSearching(false); + setTruncated(false); + return; } - const controller = new AbortController() - abortRef.current = controller - setIsSearching(true) - setTruncated(false) + const controller = new AbortController(); + abortRef.current = controller; + setIsSearching(true); + setTruncated(false); // Client-filter existing results while rg walks — keeps something on // screen instead of flashing blank. rg results are merged in (deduped by // file:line) rather than replaced, so the count is monotonic within a @@ -127,13 +118,11 @@ export function GlobalSearchDialog({ // includes the new query lowered. Non-narrowing (broadening/different): // filter is best-effort — may briefly show a subset until rg fills in // the rest. - const queryLower = q.toLowerCase() + const queryLower = q.toLowerCase(); setMatches(m => { - const filtered = m.filter(match => - match.text.toLowerCase().includes(queryLower), - ) - return filtered.length === m.length ? m : filtered - }) + const filtered = m.filter(match => match.text.toLowerCase().includes(queryLower)); + return filtered.length === m.length ? m : filtered; + }); timeoutRef.current = setTimeout( (query, controller, setMatches, setTruncated, setIsSearching) => { @@ -142,52 +131,41 @@ export function GlobalSearchDialog({ // display (otherwise the cwd prefix eats the width budget). // relativePath() returns POSIX-normalized output so truncatePathMiddle // (which uses lastIndexOf('/')) works on Windows too. - const cwd = getCwd() - let collected = 0 + const cwd = getCwd(); + let collected = 0; void ripGrepStream( // -e disambiguates pattern from options when the query starts with '-' // (e.g. searching for "--verbose" or "-rf"). See GrepTool.ts for the // same precaution. - [ - '-n', - '--no-heading', - '-i', - '-m', - String(MAX_MATCHES_PER_FILE), - '-F', - '-e', - query, - ], + ['-n', '--no-heading', '-i', '-m', String(MAX_MATCHES_PER_FILE), '-F', '-e', query], cwd, controller.signal, lines => { - if (controller.signal.aborted) return - const parsed: Match[] = [] + if (controller.signal.aborted) return; + const parsed: Match[] = []; for (const line of lines) { - const m = parseRipgrepLine(line) - if (!m) continue - const rel = relativePath(cwd, m.file) - parsed.push({ ...m, file: rel.startsWith('..') ? m.file : rel }) + const m = parseRipgrepLine(line); + if (!m) continue; + const rel = relativePath(cwd, m.file); + parsed.push({ ...m, file: rel.startsWith('..') ? m.file : rel }); } - if (!parsed.length) return - collected += parsed.length + if (!parsed.length) return; + collected += parsed.length; setMatches(prev => { // Append+dedupe instead of replace: prev may hold client- // filtered results that are valid matches for this query. // Replacing would drop the count to this chunk's size then // grow it back — visible as a flicker. - const seen = new Set(prev.map(matchKey)) - const fresh = parsed.filter(p => !seen.has(matchKey(p))) - if (!fresh.length) return prev - const next = prev.concat(fresh) - return next.length > MAX_TOTAL_MATCHES - ? next.slice(0, MAX_TOTAL_MATCHES) - : next - }) + const seen = new Set(prev.map(matchKey)); + const fresh = parsed.filter(p => !seen.has(matchKey(p))); + if (!fresh.length) return prev; + const next = prev.concat(fresh); + return next.length > MAX_TOTAL_MATCHES ? next.slice(0, MAX_TOTAL_MATCHES) : next; + }); if (collected >= MAX_TOTAL_MATCHES) { - controller.abort() - setTruncated(true) - setIsSearching(false) + controller.abort(); + setTruncated(true); + setIsSearching(false); } }, ) @@ -195,10 +173,10 @@ export function GlobalSearchDialog({ // Stream closed with zero chunks — clear stale results so // "No matches" renders instead of the previous query's list. .finally(() => { - if (controller.signal.aborted) return - if (collected === 0) setMatches(m => (m.length ? [] : m)) - setIsSearching(false) - }) + if (controller.signal.aborted) return; + if (collected === 0) setMatches(m => (m.length ? [] : m)); + setIsSearching(false); + }); }, DEBOUNCE_MS, q, @@ -206,45 +184,36 @@ export function GlobalSearchDialog({ setMatches, setTruncated, setIsSearching, - ) - } + ); + }; - const listWidth = previewOnRight - ? Math.floor((columns - 10) * 0.5) - : columns - 8 - const maxPathWidth = Math.max(20, Math.floor(listWidth * 0.4)) - const maxTextWidth = Math.max(20, listWidth - maxPathWidth - 4) - const previewWidth = previewOnRight - ? Math.max(40, columns - listWidth - 14) - : columns - 6 + const listWidth = previewOnRight ? Math.floor((columns - 10) * 0.5) : columns - 8; + const maxPathWidth = Math.max(20, Math.floor(listWidth * 0.4)); + const maxTextWidth = Math.max(20, listWidth - maxPathWidth - 4); + const previewWidth = previewOnRight ? Math.max(40, columns - listWidth - 14) : columns - 6; const handleOpen = (m: Match) => { - const opened = openFileInExternalEditor( - resolvePath(getCwd(), m.file), - m.line, - ) + const opened = openFileInExternalEditor(resolvePath(getCwd(), m.file), m.line); logEvent('tengu_global_search_select', { result_count: matches.length, opened_editor: opened, - }) - onDone() - } + }); + onDone(); + }; const handleInsert = (m: Match, mention: boolean) => { - onInsert(mention ? `@${m.file}#L${m.line} ` : `${m.file}:${m.line} `) + onInsert(mention ? `@${m.file}#L${m.line} ` : `${m.file}:${m.line} `); logEvent('tengu_global_search_insert', { result_count: matches.length, mention, - }) - onDone() - } + }); + onDone(); + }; // Always pass a non-empty string so the line is reserved — prevents the // searchBox from bouncing when the count appears/disappears. const matchLabel = - matches.length > 0 - ? `${matches.length}${truncated ? '+' : ''} matches${isSearching ? '…' : ''}` - : ' ' + matches.length > 0 ? `${matches.length}${truncated ? '+' : ''} matches${isSearching ? '…' : ''}` : ' '; return ( handleInsert(m, false), }} onCancel={onDone} - emptyMessage={q => - isSearching ? 'Searching…' : q ? 'No matches' : 'Type to search…' - } + emptyMessage={q => (isSearching ? 'Searching…' : q ? 'No matches' : 'Type to search…')} matchLabel={matchLabel} selectAction="open in editor" renderItem={(m, isFocused) => ( @@ -274,10 +241,7 @@ export function GlobalSearchDialog({ {truncatePathMiddle(m.file, maxPathWidth)}:{m.line} {' '} - {highlightMatch( - truncateToWidth(m.text.trimStart(), maxTextWidth), - query, - )} + {highlightMatch(truncateToWidth(m.text.trimStart(), maxTextWidth), query)} )} renderPreview={m => @@ -287,9 +251,7 @@ export function GlobalSearchDialog({ {truncatePathMiddle(m.file, previewWidth)}:{m.line} {preview.content.split('\n').map((line, i) => ( - - {highlightMatch(truncateToWidth(line, previewWidth), query)} - + {highlightMatch(truncateToWidth(line, previewWidth), query)} ))} ) : ( @@ -297,11 +259,11 @@ export function GlobalSearchDialog({ ) } /> - ) + ); } function matchKey(m: Match): string { - return `${m.file}:${m.line}` + return `${m.file}:${m.line}`; } /** @@ -312,10 +274,10 @@ function matchKey(m: Match): string { * @internal exported for testing */ export function parseRipgrepLine(line: string): Match | null { - const m = /^(.*?):(\d+):(.*)$/.exec(line) - if (!m) return null - const [, file, lineStr, text] = m - const lineNum = Number(lineStr) - if (!file || !Number.isFinite(lineNum)) return null - return { file, line: lineNum, text: text ?? '' } + const m = /^(.*?):(\d+):(.*)$/.exec(line); + if (!m) return null; + const [, file, lineStr, text] = m; + const lineNum = Number(lineStr); + if (!file || !Number.isFinite(lineNum)) return null; + return { file, line: lineNum, text: text ?? '' }; } diff --git a/src/components/HelpV2/Commands.tsx b/src/components/HelpV2/Commands.tsx index eb086e8d5..1787d4983 100644 --- a/src/components/HelpV2/Commands.tsx +++ b/src/components/HelpV2/Commands.tsx @@ -1,48 +1,41 @@ -import * as React from 'react' -import { useMemo } from 'react' -import { type Command, formatDescriptionWithSource } from '../../commands.js' -import { truncate } from '../../utils/truncate.js' -import { Box, Text, useTabHeaderFocus } from '@anthropic/ink' -import { Select } from '../CustomSelect/select.js' +import * as React from 'react'; +import { useMemo } from 'react'; +import { type Command, formatDescriptionWithSource } from '../../commands.js'; +import { truncate } from '../../utils/truncate.js'; +import { Box, Text, useTabHeaderFocus } from '@anthropic/ink'; +import { Select } from '../CustomSelect/select.js'; type Props = { - commands: Command[] - maxHeight: number - columns: number - title: string - onCancel: () => void - emptyMessage?: string -} + commands: Command[]; + maxHeight: number; + columns: number; + title: string; + onCancel: () => void; + emptyMessage?: string; +}; -export function Commands({ - commands, - maxHeight, - columns, - title, - onCancel, - emptyMessage, -}: Props): React.ReactNode { - const { headerFocused, focusHeader } = useTabHeaderFocus() - const maxWidth = Math.max(1, columns - 10) - const visibleCount = Math.max(1, Math.floor((maxHeight - 10) / 2)) +export function Commands({ commands, maxHeight, columns, title, onCancel, emptyMessage }: Props): React.ReactNode { + const { headerFocused, focusHeader } = useTabHeaderFocus(); + const maxWidth = Math.max(1, columns - 10); + const visibleCount = Math.max(1, Math.floor((maxHeight - 10) / 2)); const options = useMemo(() => { // Custom commands can appear more than once (e.g. same name at user and // project scope). Dedupe by name to avoid React key collisions in Select. - const seen = new Set() + const seen = new Set(); return commands .filter(cmd => { - if (seen.has(cmd.name)) return false - seen.add(cmd.name) - return true + if (seen.has(cmd.name)) return false; + seen.add(cmd.name); + return true; }) .sort((a, b) => a.name.localeCompare(b.name)) .map(cmd => ({ label: `/${cmd.name}`, value: cmd.name, description: truncate(formatDescriptionWithSource(cmd), maxWidth, true), - })) - }, [commands, maxWidth]) + })); + }, [commands, maxWidth]); return ( @@ -66,5 +59,5 @@ export function Commands({ )} - ) + ); } diff --git a/src/components/HelpV2/General.tsx b/src/components/HelpV2/General.tsx index 4117d9568..017b5c675 100644 --- a/src/components/HelpV2/General.tsx +++ b/src/components/HelpV2/General.tsx @@ -1,14 +1,14 @@ -import * as React from 'react' -import { Box, Text } from '@anthropic/ink' -import { PromptInputHelpMenu } from '../PromptInput/PromptInputHelpMenu.js' +import * as React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { PromptInputHelpMenu } from '../PromptInput/PromptInputHelpMenu.js'; export function General(): React.ReactNode { return ( - Claude understands your codebase, makes edits with your permission, - and executes commands — right from your terminal. + Claude understands your codebase, makes edits with your permission, and executes commands — right from your + terminal. @@ -18,5 +18,5 @@ export function General(): React.ReactNode { - ) + ); } diff --git a/src/components/HelpV2/HelpV2.tsx b/src/components/HelpV2/HelpV2.tsx index 2a3e0b4e2..4218cd628 100644 --- a/src/components/HelpV2/HelpV2.tsx +++ b/src/components/HelpV2/HelpV2.tsx @@ -1,66 +1,55 @@ -import * as React from 'react' -import { useExitOnCtrlCDWithKeybindings } from 'src/hooks/useExitOnCtrlCDWithKeybindings.js' -import { useShortcutDisplay } from 'src/keybindings/useShortcutDisplay.js' +import * as React from 'react'; +import { useExitOnCtrlCDWithKeybindings } from 'src/hooks/useExitOnCtrlCDWithKeybindings.js'; +import { useShortcutDisplay } from 'src/keybindings/useShortcutDisplay.js'; import { builtInCommandNames, type Command, type CommandResultDisplay, INTERNAL_ONLY_COMMANDS, -} from '../../commands.js' -import { useIsInsideModal } from '../../context/modalContext.js' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' -import { Box, Link, Text, Tab, Tabs, Pane } from '@anthropic/ink' -import { useKeybinding } from '../../keybindings/useKeybinding.js' -import { Commands } from './Commands.js' -import { General } from './General.js' +} from '../../commands.js'; +import { useIsInsideModal } from '../../context/modalContext.js'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import { Box, Link, Text, Tab, Tabs, Pane } from '@anthropic/ink'; +import { useKeybinding } from '../../keybindings/useKeybinding.js'; +import { Commands } from './Commands.js'; +import { General } from './General.js'; type Props = { - onClose: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void - commands: Command[] -} + onClose: (result?: string, options?: { display?: CommandResultDisplay }) => void; + commands: Command[]; +}; export function HelpV2({ onClose, commands }: Props): React.ReactNode { - const { rows, columns } = useTerminalSize() - const maxHeight = Math.floor(rows / 2) + const { rows, columns } = useTerminalSize(); + const maxHeight = Math.floor(rows / 2); // Inside the modal slot, FullscreenLayout already caps height and Pane/Tabs // use flexShrink=0 (see #23592) — our own height= constraint would clip the // footer since Tabs won't shrink to fit. Let the modal slot handle sizing. - const insideModal = useIsInsideModal() + const insideModal = useIsInsideModal(); - const close = () => onClose('Help dialog dismissed', { display: 'system' }) - useKeybinding('help:dismiss', close, { context: 'Help' }) - const exitState = useExitOnCtrlCDWithKeybindings(close) - const dismissShortcut = useShortcutDisplay('help:dismiss', 'Help', 'esc') + const close = () => onClose('Help dialog dismissed', { display: 'system' }); + useKeybinding('help:dismiss', close, { context: 'Help' }); + const exitState = useExitOnCtrlCDWithKeybindings(close); + const dismissShortcut = useShortcutDisplay('help:dismiss', 'Help', 'esc'); - const builtinNames = builtInCommandNames() - let builtinCommands = commands.filter( - cmd => builtinNames.has(cmd.name) && !cmd.isHidden, - ) - let antOnlyCommands: Command[] = [] + const builtinNames = builtInCommandNames(); + let builtinCommands = commands.filter(cmd => builtinNames.has(cmd.name) && !cmd.isHidden); + let antOnlyCommands: Command[] = []; // We have to do this in an `if` to help treeshaking if (process.env.USER_TYPE === 'ant') { - const internalOnlyNames = new Set(INTERNAL_ONLY_COMMANDS.map(_ => _.name)) - builtinCommands = builtinCommands.filter( - cmd => !internalOnlyNames.has(cmd.name), - ) - antOnlyCommands = commands.filter( - cmd => internalOnlyNames.has(cmd.name) && !cmd.isHidden, - ) + const internalOnlyNames = new Set(INTERNAL_ONLY_COMMANDS.map(_ => _.name)); + builtinCommands = builtinCommands.filter(cmd => !internalOnlyNames.has(cmd.name)); + antOnlyCommands = commands.filter(cmd => internalOnlyNames.has(cmd.name) && !cmd.isHidden); } - const customCommands = commands.filter( - cmd => !builtinNames.has(cmd.name) && !cmd.isHidden, - ) + const customCommands = commands.filter(cmd => !builtinNames.has(cmd.name) && !cmd.isHidden); const tabs = [ , - ] + ]; tabs.push( @@ -72,7 +61,7 @@ export function HelpV2({ onClose, commands }: Props): React.ReactNode { onCancel={close} /> , - ) + ); tabs.push( @@ -85,7 +74,7 @@ export function HelpV2({ onClose, commands }: Props): React.ReactNode { onCancel={close} /> , - ) + ); if (process.env.USER_TYPE === 'ant' && antOnlyCommands.length > 0) { tabs.push( @@ -98,18 +87,14 @@ export function HelpV2({ onClose, commands }: Props): React.ReactNode { onCancel={close} /> , - ) + ); } return ( @@ -117,8 +102,7 @@ export function HelpV2({ onClose, commands }: Props): React.ReactNode { - For more help:{' '} - + For more help: @@ -132,5 +116,5 @@ export function HelpV2({ onClose, commands }: Props): React.ReactNode { - ) + ); } diff --git a/src/components/HelpV2/src/hooks/useExitOnCtrlCDWithKeybindings.ts b/src/components/HelpV2/src/hooks/useExitOnCtrlCDWithKeybindings.ts index 38810d68c..317a9c014 100644 --- a/src/components/HelpV2/src/hooks/useExitOnCtrlCDWithKeybindings.ts +++ b/src/components/HelpV2/src/hooks/useExitOnCtrlCDWithKeybindings.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useExitOnCtrlCDWithKeybindings = any; +export type useExitOnCtrlCDWithKeybindings = any diff --git a/src/components/HelpV2/src/keybindings/useShortcutDisplay.ts b/src/components/HelpV2/src/keybindings/useShortcutDisplay.ts index 351fb3f9e..b28484bd5 100644 --- a/src/components/HelpV2/src/keybindings/useShortcutDisplay.ts +++ b/src/components/HelpV2/src/keybindings/useShortcutDisplay.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useShortcutDisplay = any; +export type useShortcutDisplay = any diff --git a/src/components/HighlightedCode.tsx b/src/components/HighlightedCode.tsx index 3cfe9a736..7ed23bf52 100644 --- a/src/components/HighlightedCode.tsx +++ b/src/components/HighlightedCode.tsx @@ -1,21 +1,21 @@ -import * as React from 'react' -import { memo, useEffect, useMemo, useRef, useState } from 'react' -import { useSettings } from '../hooks/useSettings.js' -import { Ansi, Box, type DOMElement, measureElement, NoSelect, Text, useTheme } from '@anthropic/ink' -import { isFullscreenEnvEnabled } from '../utils/fullscreen.js' -import sliceAnsi from '../utils/sliceAnsi.js' -import { countCharInString } from '../utils/stringUtils.js' -import { HighlightedCodeFallback } from './HighlightedCode/Fallback.js' -import { expectColorFile } from './StructuredDiff/colorDiff.js' +import * as React from 'react'; +import { memo, useEffect, useMemo, useRef, useState } from 'react'; +import { useSettings } from '../hooks/useSettings.js'; +import { Ansi, Box, type DOMElement, measureElement, NoSelect, Text, useTheme } from '@anthropic/ink'; +import { isFullscreenEnvEnabled } from '../utils/fullscreen.js'; +import sliceAnsi from '../utils/sliceAnsi.js'; +import { countCharInString } from '../utils/stringUtils.js'; +import { HighlightedCodeFallback } from './HighlightedCode/Fallback.js'; +import { expectColorFile } from './StructuredDiff/colorDiff.js'; type Props = { - code: string - filePath: string - width?: number - dim?: boolean -} + code: string; + filePath: string; + width?: number; + dim?: boolean; +}; -const DEFAULT_WIDTH = 80 +const DEFAULT_WIDTH = 80; export const HighlightedCode = memo(function HighlightedCode({ code, @@ -23,39 +23,38 @@ export const HighlightedCode = memo(function HighlightedCode({ width, dim = false, }: Props): React.ReactElement { - const ref = useRef(null) - const [measuredWidth, setMeasuredWidth] = useState(width || DEFAULT_WIDTH) - const [theme] = useTheme() - const settings = useSettings() - const syntaxHighlightingDisabled = - settings.syntaxHighlightingDisabled ?? false + const ref = useRef(null); + const [measuredWidth, setMeasuredWidth] = useState(width || DEFAULT_WIDTH); + const [theme] = useTheme(); + const settings = useSettings(); + const syntaxHighlightingDisabled = settings.syntaxHighlightingDisabled ?? false; const colorFile = useMemo(() => { if (syntaxHighlightingDisabled) { - return null + return null; } - const ColorFile = expectColorFile() + const ColorFile = expectColorFile(); if (!ColorFile) { - return null + return null; } - return new ColorFile(code, filePath) - }, [code, filePath, syntaxHighlightingDisabled]) + return new ColorFile(code, filePath); + }, [code, filePath, syntaxHighlightingDisabled]); useEffect(() => { if (!width && ref.current) { - const { width: elementWidth } = measureElement(ref.current) + const { width: elementWidth } = measureElement(ref.current); if (elementWidth > 0) { - setMeasuredWidth(elementWidth - 2) + setMeasuredWidth(elementWidth - 2); } } - }, [width]) + }, [width]); const lines = useMemo(() => { if (colorFile === null) { - return null + return null; } - return colorFile.render(theme, measuredWidth, dim) - }, [colorFile, theme, measuredWidth, dim]) + return colorFile.render(theme, measuredWidth, dim); + }, [colorFile, theme, measuredWidth, dim]); // Gutter width matches ColorFile's layout in lib.rs: space + right-aligned // line number (max_digits = lineCount.toString().length) + space. No marker @@ -64,10 +63,10 @@ export const HighlightedCode = memo(function HighlightedCode({ // (~4× DOM nodes + sliceAnsi cost); non-fullscreen uses terminal-native // selection where noSelect is meaningless. const gutterWidth = useMemo(() => { - if (!isFullscreenEnvEnabled()) return 0 - const lineCount = countCharInString(code, '\n') + 1 - return lineCount.toString().length + 2 - }, [code]) + if (!isFullscreenEnvEnabled()) return 0; + const lineCount = countCharInString(code, '\n') + 1; + return lineCount.toString().length + 2; + }, [code]); return ( @@ -84,26 +83,15 @@ export const HighlightedCode = memo(function HighlightedCode({ )} ) : ( - + )} - ) -}) + ); +}); -function CodeLine({ - line, - gutterWidth, -}: { - line: string - gutterWidth: number -}): React.ReactNode { - const gutter = sliceAnsi(line, 0, gutterWidth) - const content = sliceAnsi(line, gutterWidth) +function CodeLine({ line, gutterWidth }: { line: string; gutterWidth: number }): React.ReactNode { + const gutter = sliceAnsi(line, 0, gutterWidth); + const content = sliceAnsi(line, gutterWidth); return ( @@ -115,5 +103,5 @@ function CodeLine({ {content} - ) + ); } diff --git a/src/components/HighlightedCode/Fallback.tsx b/src/components/HighlightedCode/Fallback.tsx index e81d44f3d..cc71b7f3d 100644 --- a/src/components/HighlightedCode/Fallback.tsx +++ b/src/components/HighlightedCode/Fallback.tsx @@ -1,42 +1,42 @@ -import { extname } from 'path' -import React, { Suspense, use, useMemo } from 'react' -import { Ansi, Text } from '@anthropic/ink' -import { getCliHighlightPromise } from '../../utils/cliHighlight.js' -import { logForDebugging } from '../../utils/debug.js' -import { convertLeadingTabsToSpaces } from '../../utils/file.js' -import { hashPair } from '../../utils/hash.js' +import { extname } from 'path'; +import React, { Suspense, use, useMemo } from 'react'; +import { Ansi, Text } from '@anthropic/ink'; +import { getCliHighlightPromise } from '../../utils/cliHighlight.js'; +import { logForDebugging } from '../../utils/debug.js'; +import { convertLeadingTabsToSpaces } from '../../utils/file.js'; +import { hashPair } from '../../utils/hash.js'; type Props = { - code: string - filePath: string - dim?: boolean - skipColoring?: boolean -} + code: string; + filePath: string; + dim?: boolean; + skipColoring?: boolean; +}; // Module-level highlight cache — hl.highlight() is the hot cost on virtual- // scroll remounts. useMemo doesn't survive unmount→remount. Keyed by hash // of code+language to avoid retaining full source strings (#24180 RSS fix). -const HL_CACHE_MAX = 500 -const hlCache = new Map() +const HL_CACHE_MAX = 500; +const hlCache = new Map(); function cachedHighlight( hl: NonNullable>>, code: string, language: string, ): string { - const key = hashPair(language, code) - const hit = hlCache.get(key) + const key = hashPair(language, code); + const hit = hlCache.get(key); if (hit !== undefined) { - hlCache.delete(key) - hlCache.set(key, hit) - return hit + hlCache.delete(key); + hlCache.set(key, hit); + return hit; } - const out = hl.highlight(code, { language }) + const out = hl.highlight(code, { language }); if (hlCache.size >= HL_CACHE_MAX) { - const first = hlCache.keys().next().value - if (first !== undefined) hlCache.delete(first) + const first = hlCache.keys().next().value; + if (first !== undefined) hlCache.delete(first); } - hlCache.set(key, out) - return out + hlCache.set(key, out); + return out; } export function HighlightedCodeFallback({ @@ -45,55 +45,45 @@ export function HighlightedCodeFallback({ dim = false, skipColoring = false, }: Props): React.ReactElement { - const codeWithSpaces = convertLeadingTabsToSpaces(code) + const codeWithSpaces = convertLeadingTabsToSpaces(code); if (skipColoring) { return ( {codeWithSpaces} - ) + ); } - const language = extname(filePath).slice(1) + const language = extname(filePath).slice(1); return ( {codeWithSpaces}}> - ) + ); } -function Highlighted({ - codeWithSpaces, - language, -}: { - codeWithSpaces: string - language: string -}): React.ReactElement { - const hl = use(getCliHighlightPromise()) +function Highlighted({ codeWithSpaces, language }: { codeWithSpaces: string; language: string }): React.ReactElement { + const hl = use(getCliHighlightPromise()); const out = useMemo(() => { - if (!hl) return codeWithSpaces - let highlightLang = 'markdown' + if (!hl) return codeWithSpaces; + let highlightLang = 'markdown'; if (language) { if (hl.supportsLanguage(language)) { - highlightLang = language + highlightLang = language; } else { - logForDebugging( - `Language not supported while highlighting code, falling back to markdown: ${language}`, - ) + logForDebugging(`Language not supported while highlighting code, falling back to markdown: ${language}`); } } try { - return cachedHighlight(hl, codeWithSpaces, highlightLang) + return cachedHighlight(hl, codeWithSpaces, highlightLang); } catch (e) { if (e instanceof Error && e.message.includes('Unknown language')) { - logForDebugging( - `Language not supported while highlighting code, falling back to markdown: ${e}`, - ) - return cachedHighlight(hl, codeWithSpaces, 'markdown') + logForDebugging(`Language not supported while highlighting code, falling back to markdown: ${e}`); + return cachedHighlight(hl, codeWithSpaces, 'markdown'); } - return codeWithSpaces + return codeWithSpaces; } - }, [codeWithSpaces, language, hl]) - return {out} + }, [codeWithSpaces, language, hl]); + return {out}; } diff --git a/src/components/HistorySearchDialog.tsx b/src/components/HistorySearchDialog.tsx index 54bf98372..e2c557c9f 100644 --- a/src/components/HistorySearchDialog.tsx +++ b/src/components/HistorySearchDialog.tsx @@ -1,97 +1,86 @@ -import * as React from 'react' -import { useEffect, useMemo, useState } from 'react' -import { useRegisterOverlay } from '../context/overlayContext.js' -import { - getTimestampedHistory, - type TimestampedHistoryEntry, -} from '../history.js' -import { useTerminalSize } from '../hooks/useTerminalSize.js' -import { Box, Text, stringWidth, wrapAnsi } from '@anthropic/ink' -import { logEvent } from '../services/analytics/index.js' -import type { HistoryEntry } from '../utils/config.js' -import { formatRelativeTimeAgo, truncateToWidth } from '../utils/format.js' -import { FuzzyPicker } from '@anthropic/ink' +import * as React from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { useRegisterOverlay } from '../context/overlayContext.js'; +import { getTimestampedHistory, type TimestampedHistoryEntry } from '../history.js'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { Box, Text, stringWidth, wrapAnsi } from '@anthropic/ink'; +import { logEvent } from '../services/analytics/index.js'; +import type { HistoryEntry } from '../utils/config.js'; +import { formatRelativeTimeAgo, truncateToWidth } from '../utils/format.js'; +import { FuzzyPicker } from '@anthropic/ink'; type Props = { - initialQuery?: string - onSelect: (entry: HistoryEntry) => void - onCancel: () => void -} + initialQuery?: string; + onSelect: (entry: HistoryEntry) => void; + onCancel: () => void; +}; -const PREVIEW_ROWS = 6 -const AGE_WIDTH = 8 +const PREVIEW_ROWS = 6; +const AGE_WIDTH = 8; type Item = { - entry: TimestampedHistoryEntry - display: string - lower: string - firstLine: string - age: string -} + entry: TimestampedHistoryEntry; + display: string; + lower: string; + firstLine: string; + age: string; +}; -export function HistorySearchDialog({ - initialQuery, - onSelect, - onCancel, -}: Props): React.ReactNode { - useRegisterOverlay('history-search') - const { columns } = useTerminalSize() +export function HistorySearchDialog({ initialQuery, onSelect, onCancel }: Props): React.ReactNode { + useRegisterOverlay('history-search'); + const { columns } = useTerminalSize(); - const [items, setItems] = useState(null) - const [query, setQuery] = useState(initialQuery ?? '') + const [items, setItems] = useState(null); + const [query, setQuery] = useState(initialQuery ?? ''); useEffect(() => { - let cancelled = false + let cancelled = false; void (async () => { - const reader = getTimestampedHistory() - const loaded: Item[] = [] + const reader = getTimestampedHistory(); + const loaded: Item[] = []; for await (const entry of reader) { if (cancelled) { - void reader.return(undefined) - return + void reader.return(undefined); + return; } - const display = entry.display - const nl = display.indexOf('\n') - const age = formatRelativeTimeAgo(new Date(entry.timestamp)) + const display = entry.display; + const nl = display.indexOf('\n'); + const age = formatRelativeTimeAgo(new Date(entry.timestamp)); loaded.push({ entry, display, lower: display.toLowerCase(), firstLine: nl === -1 ? display : display.slice(0, nl), age: age + ' '.repeat(Math.max(0, AGE_WIDTH - stringWidth(age))), - }) + }); } - if (!cancelled) setItems(loaded) - })() + if (!cancelled) setItems(loaded); + })(); return () => { - cancelled = true - } - }, []) + cancelled = true; + }; + }, []); const filtered = useMemo(() => { - if (!items) return [] - const q = query.trim().toLowerCase() - if (!q) return items - const exact: Item[] = [] - const fuzzy: Item[] = [] + if (!items) return []; + const q = query.trim().toLowerCase(); + if (!q) return items; + const exact: Item[] = []; + const fuzzy: Item[] = []; for (const item of items) { if (item.lower.includes(q)) { - exact.push(item) + exact.push(item); } else if (isSubsequence(item.lower, q)) { - fuzzy.push(item) + fuzzy.push(item); } } - return exact.concat(fuzzy) - }, [items, query]) + return exact.concat(fuzzy); + }, [items, query]); - const previewOnRight = columns >= 100 - const listWidth = previewOnRight - ? Math.floor((columns - 6) * 0.5) - : columns - 6 - const rowWidth = Math.max(20, listWidth - AGE_WIDTH - 1) - const previewWidth = previewOnRight - ? Math.max(20, columns - listWidth - 12) - : Math.max(20, columns - 10) + const previewOnRight = columns >= 100; + const listWidth = previewOnRight ? Math.floor((columns - 6) * 0.5) : columns - 6; + const rowWidth = Math.max(20, listWidth - AGE_WIDTH - 1); + const previewWidth = previewOnRight ? Math.max(20, columns - listWidth - 12) : Math.max(20, columns - 10); return ( - items === null - ? 'Loading…' - : q - ? 'No matching prompts' - : 'No history yet' - } + emptyMessage={q => (items === null ? 'Loading…' : q ? 'No matching prompts' : 'No history yet')} selectAction="use" direction="up" previewPosition={previewOnRight ? 'right' : 'bottom'} renderItem={(item, isFocused) => ( {item.age} - - {' '} - {truncateToWidth(item.firstLine, rowWidth)} - + {truncateToWidth(item.firstLine, rowWidth)} )} renderPreview={item => { const wrapped = wrapAnsi(item.display, previewWidth, { hard: true }) .split('\n') - .filter(l => l.trim() !== '') - const overflow = wrapped.length > PREVIEW_ROWS - const shown = wrapped.slice( - 0, - overflow ? PREVIEW_ROWS - 1 : PREVIEW_ROWS, - ) - const more = wrapped.length - shown.length + .filter(l => l.trim() !== ''); + const overflow = wrapped.length > PREVIEW_ROWS; + const shown = wrapped.slice(0, overflow ? PREVIEW_ROWS - 1 : PREVIEW_ROWS); + const more = wrapped.length - shown.length; return ( - + {shown.map((row, i) => ( {row} @@ -153,16 +124,16 @@ export function HistorySearchDialog({ ))} {more > 0 && {`… +${more} more lines`}} - ) + ); }} /> - ) + ); } function isSubsequence(text: string, query: string): boolean { - let j = 0 + let j = 0; for (let i = 0; i < text.length && j < query.length; i++) { - if (text[i] === query[j]) j++ + if (text[i] === query[j]) j++; } - return j === query.length + return j === query.length; } diff --git a/src/components/IdeAutoConnectDialog.tsx b/src/components/IdeAutoConnectDialog.tsx index f262cc465..eb0e3f53a 100644 --- a/src/components/IdeAutoConnectDialog.tsx +++ b/src/components/IdeAutoConnectDialog.tsx @@ -1,91 +1,77 @@ -import React, { useCallback } from 'react' -import { Text, Dialog } from '@anthropic/ink' -import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' -import { isSupportedTerminal } from '../utils/ide.js' -import { Select } from './CustomSelect/index.js' +import React, { useCallback } from 'react'; +import { Text, Dialog } from '@anthropic/ink'; +import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'; +import { isSupportedTerminal } from '../utils/ide.js'; +import { Select } from './CustomSelect/index.js'; type IdeAutoConnectDialogProps = { - onComplete: () => void -} + onComplete: () => void; +}; -export function IdeAutoConnectDialog({ - onComplete, -}: IdeAutoConnectDialogProps): React.ReactNode { +export function IdeAutoConnectDialog({ onComplete }: IdeAutoConnectDialogProps): React.ReactNode { const handleSelect = useCallback( async (value: string) => { - const autoConnect = value === 'yes' + const autoConnect = value === 'yes'; // Save the preference and mark dialog as shown saveGlobalConfig(current => ({ ...current, autoConnectIde: autoConnect, hasIdeAutoConnectDialogBeenShown: true, - })) + })); - onComplete() + onComplete(); }, [onComplete], - ) + ); const options = [ { label: 'Yes', value: 'yes' }, { label: 'No', value: 'no' }, - ] + ]; return ( - + - ) + ); } export function shouldShowDisableAutoConnectDialog(): boolean { - const config = getGlobalConfig() - return !isSupportedTerminal() && config.autoConnectIde === true + const config = getGlobalConfig(); + return !isSupportedTerminal() && config.autoConnectIde === true; } diff --git a/src/components/IdeOnboardingDialog.tsx b/src/components/IdeOnboardingDialog.tsx index aae0b1742..13354971b 100644 --- a/src/components/IdeOnboardingDialog.tsx +++ b/src/components/IdeOnboardingDialog.tsx @@ -1,27 +1,24 @@ -import React from 'react' -import { envDynamic } from 'src/utils/envDynamic.js' -import { Box, Text } from '@anthropic/ink' -import { useKeybindings } from '../keybindings/useKeybinding.js' -import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' -import { env } from '../utils/env.js' +import React from 'react'; +import { envDynamic } from 'src/utils/envDynamic.js'; +import { Box, Text } from '@anthropic/ink'; +import { useKeybindings } from '../keybindings/useKeybinding.js'; +import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'; +import { env } from '../utils/env.js'; import { getTerminalIdeType, type IDEExtensionInstallationStatus, isJetBrainsIde, toIDEDisplayName, -} from '../utils/ide.js' -import { Dialog } from '@anthropic/ink' +} from '../utils/ide.js'; +import { Dialog } from '@anthropic/ink'; interface Props { - onDone: () => void - installationStatus: IDEExtensionInstallationStatus | null + onDone: () => void; + installationStatus: IDEExtensionInstallationStatus | null; } -export function IdeOnboardingDialog({ - onDone, - installationStatus, -}: Props): React.ReactNode { - markDialogAsShown() +export function IdeOnboardingDialog({ onDone, installationStatus }: Props): React.ReactNode { + markDialogAsShown(); // Handle Enter/Escape to dismiss useKeybindings( @@ -30,16 +27,15 @@ export function IdeOnboardingDialog({ 'confirm:no': onDone, }, { context: 'Confirmation' }, - ) + ); - const ideType = installationStatus?.ideType ?? getTerminalIdeType() - const isJetBrains = isJetBrainsIde(ideType) + const ideType = installationStatus?.ideType ?? getTerminalIdeType(); + const isJetBrains = isJetBrainsIde(ideType); - const ideName = toIDEDisplayName(ideType) - const installedVersion = installationStatus?.installedVersion - const pluginOrExtension = isJetBrains ? 'plugin' : 'extension' - const mentionShortcut = - env.platform === 'darwin' ? 'Cmd+Option+K' : 'Ctrl+Alt+K' + const ideName = toIDEDisplayName(ideType); + const installedVersion = installationStatus?.installedVersion; + const pluginOrExtension = isJetBrains ? 'plugin' : 'extension'; + const mentionShortcut = env.platform === 'darwin' ? 'Cmd+Option+K' : 'Ctrl+Alt+K'; return ( <> @@ -50,23 +46,18 @@ export function IdeOnboardingDialog({ Welcome to Claude Code for {ideName} } - subtitle={ - installedVersion - ? `installed ${pluginOrExtension} v${installedVersion}` - : undefined - } + subtitle={installedVersion ? `installed ${pluginOrExtension} v${installedVersion}` : undefined} color="ide" onCancel={onDone} hideInputGuide > - • Claude has context of ⧉ open files{' '} - and ⧉ selected lines + • Claude has context of ⧉ open files and{' '} + ⧉ selected lines - • Review Claude Code's changes{' '} - +11{' '} + • Review Claude Code's changes +11{' '} -22 in the comfort of your IDE @@ -84,25 +75,25 @@ export function IdeOnboardingDialog({ - ) + ); } export function hasIdeOnboardingDialogBeenShown(): boolean { - const config = getGlobalConfig() - const terminal = envDynamic.terminal || 'unknown' - return config.hasIdeOnboardingBeenShown?.[terminal] === true + const config = getGlobalConfig(); + const terminal = envDynamic.terminal || 'unknown'; + return config.hasIdeOnboardingBeenShown?.[terminal] === true; } function markDialogAsShown(): void { if (hasIdeOnboardingDialogBeenShown()) { - return + return; } - const terminal = envDynamic.terminal || 'unknown' + const terminal = envDynamic.terminal || 'unknown'; saveGlobalConfig(current => ({ ...current, hasIdeOnboardingBeenShown: { ...current.hasIdeOnboardingBeenShown, [terminal]: true, }, - })) + })); } diff --git a/src/components/IdeStatusIndicator.tsx b/src/components/IdeStatusIndicator.tsx index 13096b120..426f00094 100644 --- a/src/components/IdeStatusIndicator.tsx +++ b/src/components/IdeStatusIndicator.tsx @@ -1,38 +1,32 @@ -import { basename } from 'path' -import * as React from 'react' -import { useIdeConnectionStatus } from '../hooks/useIdeConnectionStatus.js' -import type { IDESelection } from '../hooks/useIdeSelection.js' -import { Text } from '@anthropic/ink' -import type { MCPServerConnection } from '../services/mcp/types.js' +import { basename } from 'path'; +import * as React from 'react'; +import { useIdeConnectionStatus } from '../hooks/useIdeConnectionStatus.js'; +import type { IDESelection } from '../hooks/useIdeSelection.js'; +import { Text } from '@anthropic/ink'; +import type { MCPServerConnection } from '../services/mcp/types.js'; type IdeStatusIndicatorProps = { - ideSelection: IDESelection | undefined - mcpClients?: MCPServerConnection[] -} + ideSelection: IDESelection | undefined; + mcpClients?: MCPServerConnection[]; +}; -export function IdeStatusIndicator({ - ideSelection, - mcpClients, -}: IdeStatusIndicatorProps): React.ReactNode { - const { status: ideStatus } = useIdeConnectionStatus(mcpClients) +export function IdeStatusIndicator({ ideSelection, mcpClients }: IdeStatusIndicatorProps): React.ReactNode { + const { status: ideStatus } = useIdeConnectionStatus(mcpClients); // Check if we should show the IDE selection indicator const shouldShowIdeSelection = - ideStatus === 'connected' && - (ideSelection?.filePath || - (ideSelection?.text && ideSelection.lineCount > 0)) + ideStatus === 'connected' && (ideSelection?.filePath || (ideSelection?.text && ideSelection.lineCount > 0)); if (ideStatus === null || !shouldShowIdeSelection || !ideSelection) { - return null + return null; } if (ideSelection.text && ideSelection.lineCount > 0) { return ( - ⧉ {ideSelection.lineCount}{' '} - {ideSelection.lineCount === 1 ? 'line' : 'lines'} selected + ⧉ {ideSelection.lineCount} {ideSelection.lineCount === 1 ? 'line' : 'lines'} selected - ) + ); } if (ideSelection.filePath) { @@ -40,6 +34,6 @@ export function IdeStatusIndicator({ ⧉ In {basename(ideSelection.filePath)} - ) + ); } } diff --git a/src/components/IdleReturnDialog.tsx b/src/components/IdleReturnDialog.tsx index 9ffc8893a..3ea6d7b4f 100644 --- a/src/components/IdleReturnDialog.tsx +++ b/src/components/IdleReturnDialog.tsx @@ -1,24 +1,20 @@ -import React from 'react' -import { Box, Text } from '@anthropic/ink' -import { formatTokens } from '../utils/format.js' -import { Select } from './CustomSelect/index.js' -import { Dialog } from '@anthropic/ink' +import React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { formatTokens } from '../utils/format.js'; +import { Select } from './CustomSelect/index.js'; +import { Dialog } from '@anthropic/ink'; -type IdleReturnAction = 'continue' | 'clear' | 'dismiss' | 'never' +type IdleReturnAction = 'continue' | 'clear' | 'dismiss' | 'never'; type Props = { - idleMinutes: number - totalInputTokens: number - onDone: (action: IdleReturnAction) => void -} + idleMinutes: number; + totalInputTokens: number; + onDone: (action: IdleReturnAction) => void; +}; -export function IdleReturnDialog({ - idleMinutes, - totalInputTokens, - onDone, -}: Props): React.ReactNode { - const formattedIdle = formatIdleDuration(idleMinutes) - const formattedTokens = formatTokens(totalInputTokens) +export function IdleReturnDialog({ idleMinutes, totalInputTokens, onDone }: Props): React.ReactNode { + const formattedIdle = formatIdleDuration(idleMinutes); + const formattedTokens = formatTokens(totalInputTokens); return ( onDone('dismiss')} > - - If this is a new task, clearing context will save usage and be faster. - + If this is a new task, clearing context will save usage and be faster. - ) + ); } diff --git a/src/components/KeybindingWarnings.tsx b/src/components/KeybindingWarnings.tsx index dc1b5a74a..9fc161dd0 100644 --- a/src/components/KeybindingWarnings.tsx +++ b/src/components/KeybindingWarnings.tsx @@ -1,10 +1,10 @@ -import React from 'react' -import { Box, Text } from '@anthropic/ink' +import React from 'react'; +import { Box, Text } from '@anthropic/ink'; import { getCachedKeybindingWarnings, getKeybindingsPath, isKeybindingCustomizationEnabled, -} from '../keybindings/loadUserBindings.js' +} from '../keybindings/loadUserBindings.js'; /** * Displays keybinding validation warnings in the UI. @@ -16,17 +16,17 @@ import { export function KeybindingWarnings(): React.ReactNode { // Only show warnings when keybinding customization is enabled if (!isKeybindingCustomizationEnabled()) { - return null + return null; } - const warnings = getCachedKeybindingWarnings() + const warnings = getCachedKeybindingWarnings(); if (warnings.length === 0) { - return null + return null; } - const errors = warnings.filter(w => w.severity === 'error') - const warns = warnings.filter(w => w.severity === 'warning') + const errors = warnings.filter(w => w.severity === 'error'); + const warns = warnings.filter(w => w.severity === 'warning'); return ( @@ -68,5 +68,5 @@ export function KeybindingWarnings(): React.ReactNode { ))} - ) + ); } diff --git a/src/components/LanguagePicker.tsx b/src/components/LanguagePicker.tsx index ae357ff8e..fa4dbfdd1 100644 --- a/src/components/LanguagePicker.tsx +++ b/src/components/LanguagePicker.tsx @@ -1,32 +1,26 @@ -import figures from 'figures' -import React, { useState } from 'react' -import { Box, Text } from '@anthropic/ink' -import { useKeybinding } from '../keybindings/useKeybinding.js' -import TextInput from './TextInput.js' +import figures from 'figures'; +import React, { useState } from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { useKeybinding } from '../keybindings/useKeybinding.js'; +import TextInput from './TextInput.js'; type Props = { - initialLanguage: string | undefined - onComplete: (language: string | undefined) => void - onCancel: () => void -} + initialLanguage: string | undefined; + onComplete: (language: string | undefined) => void; + onCancel: () => void; +}; -export function LanguagePicker({ - initialLanguage, - onComplete, - onCancel, -}: Props): React.ReactNode { - const [language, setLanguage] = useState(initialLanguage) - const [cursorOffset, setCursorOffset] = useState( - (initialLanguage ?? '').length, - ) +export function LanguagePicker({ initialLanguage, onComplete, onCancel }: Props): React.ReactNode { + const [language, setLanguage] = useState(initialLanguage); + const [cursorOffset, setCursorOffset] = useState((initialLanguage ?? '').length); // Use configurable keybinding for ESC to cancel // Use Settings context so 'n' key doesn't trigger cancel (allows typing 'n' in input) - useKeybinding('confirm:no', onCancel, { context: 'Settings' }) + useKeybinding('confirm:no', onCancel, { context: 'Settings' }); function handleSubmit(): void { - const trimmed = language?.trim() - onComplete(trimmed || undefined) + const trimmed = language?.trim(); + onComplete(trimmed || undefined); } return ( @@ -48,5 +42,5 @@ export function LanguagePicker({ Leave empty for default (English) - ) + ); } diff --git a/src/components/LogSelector.tsx b/src/components/LogSelector.tsx index 806a0082d..ca64d666d 100644 --- a/src/components/LogSelector.tsx +++ b/src/components/LogSelector.tsx @@ -1,171 +1,145 @@ -import chalk from 'chalk' -import figures from 'figures' -import Fuse from 'fuse.js' -import React from 'react' -import { getOriginalCwd, getSessionId } from '../bootstrap/state.js' -import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js' -import { useSearchInput } from '../hooks/useSearchInput.js' -import { useTerminalSize } from '../hooks/useTerminalSize.js' -import { applyColor, Box, Text, useInput, useTerminalFocus, useTheme, type Color, Byline, Divider, KeyboardShortcutHint } from '@anthropic/ink' -import { useKeybinding } from '../keybindings/useKeybinding.js' -import { logEvent } from '../services/analytics/index.js' -import type { LogOption, SerializedMessage } from '../types/logs.js' -import { formatLogMetadata, truncateToWidth } from '../utils/format.js' -import { getWorktreePaths } from '../utils/getWorktreePaths.js' -import { getBranch } from '../utils/git.js' -import { getLogDisplayTitle } from '../utils/log.js' +import chalk from 'chalk'; +import figures from 'figures'; +import Fuse from 'fuse.js'; +import React from 'react'; +import { getOriginalCwd, getSessionId } from '../bootstrap/state.js'; +import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js'; +import { useSearchInput } from '../hooks/useSearchInput.js'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { + applyColor, + Box, + Text, + useInput, + useTerminalFocus, + useTheme, + type Color, + Byline, + Divider, + KeyboardShortcutHint, +} from '@anthropic/ink'; +import { useKeybinding } from '../keybindings/useKeybinding.js'; +import { logEvent } from '../services/analytics/index.js'; +import type { LogOption, SerializedMessage } from '../types/logs.js'; +import { formatLogMetadata, truncateToWidth } from '../utils/format.js'; +import { getWorktreePaths } from '../utils/getWorktreePaths.js'; +import { getBranch } from '../utils/git.js'; +import { getLogDisplayTitle } from '../utils/log.js'; import { getFirstMeaningfulUserMessageTextContent, getSessionIdFromLog, isCustomTitleEnabled, saveCustomTitle, -} from '../utils/sessionStorage.js' -import { getTheme } from '../utils/theme.js' -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' -import { Select } from './CustomSelect/select.js' -import { SearchBox } from './SearchBox.js' -import { SessionPreview } from './SessionPreview.js' -import { Spinner } from './Spinner.js' -import { TagTabs } from './TagTabs.js' -import TextInput from './TextInput.js' -import { type TreeNode, TreeSelect } from './ui/TreeSelect.js' +} from '../utils/sessionStorage.js'; +import { getTheme } from '../utils/theme.js'; +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; +import { Select } from './CustomSelect/select.js'; +import { SearchBox } from './SearchBox.js'; +import { SessionPreview } from './SessionPreview.js'; +import { Spinner } from './Spinner.js'; +import { TagTabs } from './TagTabs.js'; +import TextInput from './TextInput.js'; +import { type TreeNode, TreeSelect } from './ui/TreeSelect.js'; type AgenticSearchState = | { status: 'idle' } | { status: 'searching' } | { status: 'results'; results: LogOption[]; query: string } - | { status: 'error'; message: string } + | { status: 'error'; message: string }; export type LogSelectorProps = { - logs: LogOption[] - maxHeight?: number - forceWidth?: number - onCancel?: () => void - onSelect: (log: LogOption) => void - onLogsChanged?: () => void - onLoadMore?: (count: number) => void - initialSearchQuery?: string - showAllProjects?: boolean - onToggleAllProjects?: () => void - onAgenticSearch?: ( - query: string, - logs: LogOption[], - signal?: AbortSignal, - ) => Promise -} + logs: LogOption[]; + maxHeight?: number; + forceWidth?: number; + onCancel?: () => void; + onSelect: (log: LogOption) => void; + onLogsChanged?: () => void; + onLoadMore?: (count: number) => void; + initialSearchQuery?: string; + showAllProjects?: boolean; + onToggleAllProjects?: () => void; + onAgenticSearch?: (query: string, logs: LogOption[], signal?: AbortSignal) => Promise; +}; -type LogTreeNode = TreeNode<{ log: LogOption; indexInFiltered: number }> +type LogTreeNode = TreeNode<{ log: LogOption; indexInFiltered: number }>; function normalizeAndTruncateToWidth(text: string, maxWidth: number): string { - const normalized = text.replace(/\s+/g, ' ').trim() - return truncateToWidth(normalized, maxWidth) + const normalized = text.replace(/\s+/g, ' ').trim(); + return truncateToWidth(normalized, maxWidth); } // Width of prefixes that TreeSelect will add -const PARENT_PREFIX_WIDTH = 2 // '▼ ' or '▶ ' -const CHILD_PREFIX_WIDTH = 4 // ' ▸ ' +const PARENT_PREFIX_WIDTH = 2; // '▼ ' or '▶ ' +const CHILD_PREFIX_WIDTH = 4; // ' ▸ ' // Deep search constants -const DEEP_SEARCH_MAX_MESSAGES = 2000 -const DEEP_SEARCH_CROP_SIZE = 1000 -const DEEP_SEARCH_MAX_TEXT_LENGTH = 50000 // Cap searchable text per session -const FUSE_THRESHOLD = 0.3 -const DATE_TIE_THRESHOLD_MS = 60 * 1000 // 1 minute - use relevance as tie-breaker within this window -const SNIPPET_CONTEXT_CHARS = 50 // Characters to show before/after match +const DEEP_SEARCH_MAX_MESSAGES = 2000; +const DEEP_SEARCH_CROP_SIZE = 1000; +const DEEP_SEARCH_MAX_TEXT_LENGTH = 50000; // Cap searchable text per session +const FUSE_THRESHOLD = 0.3; +const DATE_TIE_THRESHOLD_MS = 60 * 1000; // 1 minute - use relevance as tie-breaker within this window +const SNIPPET_CONTEXT_CHARS = 50; // Characters to show before/after match -type Snippet = { before: string; match: string; after: string } +type Snippet = { before: string; match: string; after: string }; -function formatSnippet( - { before, match, after }: Snippet, - highlightColor: (text: string) => string, -): string { - return chalk.dim(before) + highlightColor(match) + chalk.dim(after) +function formatSnippet({ before, match, after }: Snippet, highlightColor: (text: string) => string): string { + return chalk.dim(before) + highlightColor(match) + chalk.dim(after); } -function extractSnippet( - text: string, - query: string, - contextChars: number, -): Snippet | null { +function extractSnippet(text: string, query: string, contextChars: number): Snippet | null { // Find exact query occurrence (case-insensitive). // Note: Fuse does fuzzy matching, so this may miss some fuzzy matches. // This is acceptable for now - in the future we could use Fuse's includeMatches // option and work with the match indices directly. - const matchIndex = text.toLowerCase().indexOf(query.toLowerCase()) - if (matchIndex === -1) return null + const matchIndex = text.toLowerCase().indexOf(query.toLowerCase()); + if (matchIndex === -1) return null; - const matchEnd = matchIndex + query.length - const snippetStart = Math.max(0, matchIndex - contextChars) - const snippetEnd = Math.min(text.length, matchEnd + contextChars) + const matchEnd = matchIndex + query.length; + const snippetStart = Math.max(0, matchIndex - contextChars); + const snippetEnd = Math.min(text.length, matchEnd + contextChars); - const beforeRaw = text.slice(snippetStart, matchIndex) - const matchText = text.slice(matchIndex, matchEnd) - const afterRaw = text.slice(matchEnd, snippetEnd) + const beforeRaw = text.slice(snippetStart, matchIndex); + const matchText = text.slice(matchIndex, matchEnd); + const afterRaw = text.slice(matchEnd, snippetEnd); return { - before: - (snippetStart > 0 ? '…' : '') + - beforeRaw.replace(/\s+/g, ' ').trimStart(), + before: (snippetStart > 0 ? '…' : '') + beforeRaw.replace(/\s+/g, ' ').trimStart(), match: matchText.trim(), - after: - afterRaw.replace(/\s+/g, ' ').trimEnd() + - (snippetEnd < text.length ? '…' : ''), - } + after: afterRaw.replace(/\s+/g, ' ').trimEnd() + (snippetEnd < text.length ? '…' : ''), + }; } function buildLogLabel( log: LogOption, maxLabelWidth: number, options?: { - isGroupHeader?: boolean - isChild?: boolean - forkCount?: number + isGroupHeader?: boolean; + isChild?: boolean; + forkCount?: number; }, ): string { - const { - isGroupHeader = false, - isChild = false, - forkCount = 0, - } = options || {} + const { isGroupHeader = false, isChild = false, forkCount = 0 } = options || {}; // TreeSelect will add the prefix, so we just need to account for its width - const prefixWidth = - isGroupHeader && forkCount > 0 - ? PARENT_PREFIX_WIDTH - : isChild - ? CHILD_PREFIX_WIDTH - : 0 + const prefixWidth = isGroupHeader && forkCount > 0 ? PARENT_PREFIX_WIDTH : isChild ? CHILD_PREFIX_WIDTH : 0; const sessionCountSuffix = - isGroupHeader && forkCount > 0 - ? ` (+${forkCount} other ${forkCount === 1 ? 'session' : 'sessions'})` - : '' + isGroupHeader && forkCount > 0 ? ` (+${forkCount} other ${forkCount === 1 ? 'session' : 'sessions'})` : ''; - const sidechainSuffix = log.isSidechain ? ' (sidechain)' : '' + const sidechainSuffix = log.isSidechain ? ' (sidechain)' : ''; - const maxSummaryWidth = - maxLabelWidth - - prefixWidth - - sidechainSuffix.length - - sessionCountSuffix.length - const truncatedSummary = normalizeAndTruncateToWidth( - getLogDisplayTitle(log), - maxSummaryWidth, - ) - return `${truncatedSummary}${sidechainSuffix}${sessionCountSuffix}` + const maxSummaryWidth = maxLabelWidth - prefixWidth - sidechainSuffix.length - sessionCountSuffix.length; + const truncatedSummary = normalizeAndTruncateToWidth(getLogDisplayTitle(log), maxSummaryWidth); + return `${truncatedSummary}${sidechainSuffix}${sessionCountSuffix}`; } -function buildLogMetadata( - log: LogOption, - options?: { isChild?: boolean; showProjectPath?: boolean }, -): string { - const { isChild = false, showProjectPath = false } = options || {} +function buildLogMetadata(log: LogOption, options?: { isChild?: boolean; showProjectPath?: boolean }): string { + const { isChild = false, showProjectPath = false } = options || {}; // Match the child prefix width for proper alignment - const childPadding = isChild ? ' ' : '' // 4 spaces to match ' ▸ ' - const baseMetadata = formatLogMetadata(log) - const projectSuffix = - showProjectPath && log.projectPath ? ` · ${log.projectPath}` : '' - return childPadding + baseMetadata + projectSuffix + const childPadding = isChild ? ' ' : ''; // 4 spaces to match ' ▸ ' + const baseMetadata = formatLogMetadata(log); + const projectSuffix = showProjectPath && log.projectPath ? ` · ${log.projectPath}` : ''; + return childPadding + baseMetadata + projectSuffix; } export function LogSelector({ @@ -181,192 +155,168 @@ export function LogSelector({ onToggleAllProjects, onAgenticSearch, }: LogSelectorProps): React.ReactNode { - const terminalSize = useTerminalSize() - const columns = forceWidth === undefined ? terminalSize.columns : forceWidth - const exitState = useExitOnCtrlCDWithKeybindings(onCancel) - const isTerminalFocused = useTerminalFocus() - const isResumeWithRenameEnabled = isCustomTitleEnabled() - const isDeepSearchEnabled = process.env.USER_TYPE === 'ant' - const [themeName] = useTheme() - const theme = getTheme(themeName) + const terminalSize = useTerminalSize(); + const columns = forceWidth === undefined ? terminalSize.columns : forceWidth; + const exitState = useExitOnCtrlCDWithKeybindings(onCancel); + const isTerminalFocused = useTerminalFocus(); + const isResumeWithRenameEnabled = isCustomTitleEnabled(); + const isDeepSearchEnabled = process.env.USER_TYPE === 'ant'; + const [themeName] = useTheme(); + const theme = getTheme(themeName); const highlightColor = React.useMemo( () => (text: string) => applyColor(text, theme.warning as Color), [theme.warning], - ) - const isAgenticSearchEnabled = process.env.USER_TYPE === 'ant' + ); + const isAgenticSearchEnabled = process.env.USER_TYPE === 'ant'; - const [currentBranch, setCurrentBranch] = React.useState(null) - const [branchFilterEnabled, setBranchFilterEnabled] = React.useState(false) - const [showAllWorktrees, setShowAllWorktrees] = React.useState(false) - const [hasMultipleWorktrees, setHasMultipleWorktrees] = React.useState(false) - const currentCwd = React.useMemo(() => getOriginalCwd(), []) - const [renameValue, setRenameValue] = React.useState('') - const [renameCursorOffset, setRenameCursorOffset] = React.useState(0) - const [expandedGroupSessionIds, setExpandedGroupSessionIds] = React.useState< - Set - >(new Set()) - const [focusedNode, setFocusedNode] = React.useState(null) + const [currentBranch, setCurrentBranch] = React.useState(null); + const [branchFilterEnabled, setBranchFilterEnabled] = React.useState(false); + const [showAllWorktrees, setShowAllWorktrees] = React.useState(false); + const [hasMultipleWorktrees, setHasMultipleWorktrees] = React.useState(false); + const currentCwd = React.useMemo(() => getOriginalCwd(), []); + const [renameValue, setRenameValue] = React.useState(''); + const [renameCursorOffset, setRenameCursorOffset] = React.useState(0); + const [expandedGroupSessionIds, setExpandedGroupSessionIds] = React.useState>(new Set()); + const [focusedNode, setFocusedNode] = React.useState(null); // Track focused index for scroll position display in title - const [focusedIndex, setFocusedIndex] = React.useState(1) - const [viewMode, setViewMode] = React.useState< - 'list' | 'preview' | 'rename' | 'search' - >('list') - const [previewLog, setPreviewLog] = React.useState(null) - const prevFocusedIdRef = React.useRef(null) - const [selectedTagIndex, setSelectedTagIndex] = React.useState(0) + const [focusedIndex, setFocusedIndex] = React.useState(1); + const [viewMode, setViewMode] = React.useState<'list' | 'preview' | 'rename' | 'search'>('list'); + const [previewLog, setPreviewLog] = React.useState(null); + const prevFocusedIdRef = React.useRef(null); + const [selectedTagIndex, setSelectedTagIndex] = React.useState(0); // Agentic search state - const [agenticSearchState, setAgenticSearchState] = - React.useState({ status: 'idle' }) + const [agenticSearchState, setAgenticSearchState] = React.useState({ status: 'idle' }); // Track if the "Search deeply using Claude" option is focused - const [isAgenticSearchOptionFocused, setIsAgenticSearchOptionFocused] = - React.useState(false) + const [isAgenticSearchOptionFocused, setIsAgenticSearchOptionFocused] = React.useState(false); // AbortController for cancelling agentic search - const agenticSearchAbortRef = React.useRef(null) + const agenticSearchAbortRef = React.useRef(null); const { query: searchQuery, setQuery: setSearchQuery, cursorOffset: searchCursorOffset, } = useSearchInput({ - isActive: - viewMode === 'search' && agenticSearchState.status !== 'searching', + isActive: viewMode === 'search' && agenticSearchState.status !== 'searching', onExit: () => { - setViewMode('list') - logEvent('tengu_session_search_toggled', { enabled: false }) + setViewMode('list'); + logEvent('tengu_session_search_toggled', { enabled: false }); }, onExitUp: () => { - setViewMode('list') - logEvent('tengu_session_search_toggled', { enabled: false }) + setViewMode('list'); + logEvent('tengu_session_search_toggled', { enabled: false }); }, passthroughCtrlKeys: ['n'], initialQuery: initialSearchQuery || '', - }) + }); // Debounce transcript search for performance (title search is instant) - const deferredSearchQuery = React.useDeferredValue(searchQuery) + const deferredSearchQuery = React.useDeferredValue(searchQuery); // Additional debounce for deep search - wait 300ms after typing stops - const [debouncedDeepSearchQuery, setDebouncedDeepSearchQuery] = - React.useState('') + const [debouncedDeepSearchQuery, setDebouncedDeepSearchQuery] = React.useState(''); React.useEffect(() => { if (!deferredSearchQuery) { - setDebouncedDeepSearchQuery('') - return + setDebouncedDeepSearchQuery(''); + return; } - const timeoutId = setTimeout( - setDebouncedDeepSearchQuery, - 300, - deferredSearchQuery, - ) - return () => clearTimeout(timeoutId) - }, [deferredSearchQuery]) + const timeoutId = setTimeout(setDebouncedDeepSearchQuery, 300, deferredSearchQuery); + return () => clearTimeout(timeoutId); + }, [deferredSearchQuery]); // State for async deep search results const [deepSearchResults, setDeepSearchResults] = React.useState<{ - results: Array<{ log: LogOption; score?: number; searchableText: string }> - query: string - } | null>(null) - const [isSearching, setIsSearching] = React.useState(false) + results: Array<{ log: LogOption; score?: number; searchableText: string }>; + query: string; + } | null>(null); + const [isSearching, setIsSearching] = React.useState(false); React.useEffect(() => { - void getBranch().then(branch => setCurrentBranch(branch)) + void getBranch().then(branch => setCurrentBranch(branch)); void getWorktreePaths(currentCwd).then(paths => { - setHasMultipleWorktrees(paths.length > 1) - }) - }, [currentCwd]) + setHasMultipleWorktrees(paths.length > 1); + }); + }, [currentCwd]); // Memoize searchable text extraction - only recompute when logs change - const searchableTextByLog = React.useMemo( - () => new Map(logs.map(log => [log, buildSearchableText(log)])), - [logs], - ) + const searchableTextByLog = React.useMemo(() => new Map(logs.map(log => [log, buildSearchableText(log)])), [logs]); // Pre-build Fuse index once when logs change (not on every search query) const fuseIndex = React.useMemo(() => { - if (!isDeepSearchEnabled) return null + if (!isDeepSearchEnabled) return null; const logsWithText = logs .map(log => ({ log, searchableText: searchableTextByLog.get(log) ?? '', })) - .filter(item => item.searchableText) + .filter(item => item.searchableText); return new Fuse(logsWithText, { keys: ['searchableText'], threshold: FUSE_THRESHOLD, ignoreLocation: true, includeScore: true, - }) - }, [logs, searchableTextByLog, isDeepSearchEnabled]) + }); + }, [logs, searchableTextByLog, isDeepSearchEnabled]); // Compute unique tags from logs (before any filtering) - const uniqueTags = React.useMemo(() => getUniqueTags(logs), [logs]) - const hasTags = uniqueTags.length > 0 - const tagTabs = React.useMemo( - () => (hasTags ? ['All', ...uniqueTags] : []), - [hasTags, uniqueTags], - ) + const uniqueTags = React.useMemo(() => getUniqueTags(logs), [logs]); + const hasTags = uniqueTags.length > 0; + const tagTabs = React.useMemo(() => (hasTags ? ['All', ...uniqueTags] : []), [hasTags, uniqueTags]); // Clamp out-of-bounds index (e.g., after logs change) without an extra render - const effectiveTagIndex = - tagTabs.length > 0 && selectedTagIndex < tagTabs.length - ? selectedTagIndex - : 0 - const selectedTab = tagTabs[effectiveTagIndex] - const tagFilter = selectedTab === 'All' ? undefined : selectedTab + const effectiveTagIndex = tagTabs.length > 0 && selectedTagIndex < tagTabs.length ? selectedTagIndex : 0; + const selectedTab = tagTabs[effectiveTagIndex]; + const tagFilter = selectedTab === 'All' ? undefined : selectedTab; // Tag tabs are now a single line with horizontal scrolling - const tagTabsLines = hasTags ? 1 : 0 + const tagTabsLines = hasTags ? 1 : 0; // Base filtering (instant) - applies tag, branch, and resume filters const baseFilteredLogs = React.useMemo(() => { - let filtered = logs + let filtered = logs; if (isResumeWithRenameEnabled) { filtered = logs.filter(log => { - const currentSessionId = getSessionId() - const logSessionId = getSessionIdFromLog(log) - const isCurrentSession = - currentSessionId && logSessionId === currentSessionId + const currentSessionId = getSessionId(); + const logSessionId = getSessionIdFromLog(log); + const isCurrentSession = currentSessionId && logSessionId === currentSessionId; // Always show current session if (isCurrentSession) { - return true + return true; } // Always show sessions with custom titles (e.g., loop mode sessions) if (log.customTitle) { - return true + return true; } // For full logs, check messages array - const fromMessages = getFirstMeaningfulUserMessageTextContent( - log.messages, - ) + const fromMessages = getFirstMeaningfulUserMessageTextContent(log.messages); if (fromMessages) { - return true + return true; } // All logs reaching this component are enriched — include if // they have a prompt or custom title if (log.firstPrompt || log.customTitle) { - return true + return true; } - return false - }) + return false; + }); } // Apply tag filter if specified if (tagFilter !== undefined) { - filtered = filtered.filter(log => log.tag === tagFilter) + filtered = filtered.filter(log => log.tag === tagFilter); } if (branchFilterEnabled && currentBranch) { - filtered = filtered.filter(log => log.gitBranch === currentBranch) + filtered = filtered.filter(log => log.gitBranch === currentBranch); } if (hasMultipleWorktrees && !showAllWorktrees) { - filtered = filtered.filter(log => log.projectPath === currentCwd) + filtered = filtered.filter(log => log.projectPath === currentCwd); } - return filtered + return filtered; }, [ logs, isResumeWithRenameEnabled, @@ -376,70 +326,54 @@ export function LogSelector({ hasMultipleWorktrees, showAllWorktrees, currentCwd, - ]) + ]); // Instant title/branch/tag/PR filtering (runs on every keystroke, but is fast) const titleFilteredLogs = React.useMemo(() => { if (!searchQuery) { - return baseFilteredLogs + return baseFilteredLogs; } - const query = searchQuery.toLowerCase() + const query = searchQuery.toLowerCase(); return baseFilteredLogs.filter(log => { - const displayedTitle = getLogDisplayTitle(log).toLowerCase() - const branch = (log.gitBranch || '').toLowerCase() - const tag = (log.tag || '').toLowerCase() - const prInfo = log.prNumber - ? `pr #${log.prNumber} ${log.prRepository || ''}`.toLowerCase() - : '' - return ( - displayedTitle.includes(query) || - branch.includes(query) || - tag.includes(query) || - prInfo.includes(query) - ) - }) - }, [baseFilteredLogs, searchQuery]) + const displayedTitle = getLogDisplayTitle(log).toLowerCase(); + const branch = (log.gitBranch || '').toLowerCase(); + const tag = (log.tag || '').toLowerCase(); + const prInfo = log.prNumber ? `pr #${log.prNumber} ${log.prRepository || ''}`.toLowerCase() : ''; + return displayedTitle.includes(query) || branch.includes(query) || tag.includes(query) || prInfo.includes(query); + }); + }, [baseFilteredLogs, searchQuery]); // Show searching indicator when query is pending debounce React.useEffect(() => { - if ( - isDeepSearchEnabled && - deferredSearchQuery && - deferredSearchQuery !== debouncedDeepSearchQuery - ) { - setIsSearching(true) + if (isDeepSearchEnabled && deferredSearchQuery && deferredSearchQuery !== debouncedDeepSearchQuery) { + setIsSearching(true); } - }, [deferredSearchQuery, debouncedDeepSearchQuery, isDeepSearchEnabled]) + }, [deferredSearchQuery, debouncedDeepSearchQuery, isDeepSearchEnabled]); // Async deep search effect - runs after 300ms debounce React.useEffect(() => { if (!isDeepSearchEnabled || !debouncedDeepSearchQuery || !fuseIndex) { - setDeepSearchResults(null) - setIsSearching(false) - return + setDeepSearchResults(null); + setIsSearching(false); + return; } // Use setTimeout(0) to yield to the event loop - prevents UI freeze const timeoutId = setTimeout( - ( - fuseIndex, - debouncedDeepSearchQuery, - setDeepSearchResults, - setIsSearching, - ) => { - const results = fuseIndex.search(debouncedDeepSearchQuery) + (fuseIndex, debouncedDeepSearchQuery, setDeepSearchResults, setIsSearching) => { + const results = fuseIndex.search(debouncedDeepSearchQuery); // Sort by date (newest first), with relevance as tie-breaker within same minute results.sort((a, b) => { - const aTime = new Date(a.item.log.modified).getTime() - const bTime = new Date(b.item.log.modified).getTime() - const timeDiff = bTime - aTime + const aTime = new Date(a.item.log.modified).getTime(); + const bTime = new Date(b.item.log.modified).getTime(); + const timeDiff = bTime - aTime; if (Math.abs(timeDiff) > DATE_TIE_THRESHOLD_MS) { - return timeDiff + return timeDiff; } // Within same minute window, use relevance score (lower is better) - return (a.score ?? 1) - (b.score ?? 1) - }) + return (a.score ?? 1) - (b.score ?? 1); + }); setDeepSearchResults({ results: results.map(r => ({ @@ -448,174 +382,141 @@ export function LogSelector({ searchableText: r.item.searchableText, })), query: debouncedDeepSearchQuery, - }) - setIsSearching(false) + }); + setIsSearching(false); }, 0, fuseIndex, debouncedDeepSearchQuery, setDeepSearchResults, setIsSearching, - ) + ); return () => { - clearTimeout(timeoutId) - } - }, [debouncedDeepSearchQuery, fuseIndex, isDeepSearchEnabled]) + clearTimeout(timeoutId); + }; + }, [debouncedDeepSearchQuery, fuseIndex, isDeepSearchEnabled]); // Merge title matches with async deep search results const { filteredLogs, snippets } = React.useMemo(() => { - const snippetMap = new Map() + const snippetMap = new Map(); // Start with instant title matches - let filtered = titleFilteredLogs + let filtered = titleFilteredLogs; // Merge in deep search results if available and query matches - if ( - deepSearchResults && - debouncedDeepSearchQuery && - deepSearchResults.query === debouncedDeepSearchQuery - ) { + if (deepSearchResults && debouncedDeepSearchQuery && deepSearchResults.query === debouncedDeepSearchQuery) { // Extract snippets from deep search results for (const result of deepSearchResults.results) { if (result.searchableText) { - const snippet = extractSnippet( - result.searchableText, - debouncedDeepSearchQuery, - SNIPPET_CONTEXT_CHARS, - ) + const snippet = extractSnippet(result.searchableText, debouncedDeepSearchQuery, SNIPPET_CONTEXT_CHARS); if (snippet) { - snippetMap.set(result.log, snippet) + snippetMap.set(result.log, snippet); } } } // Add transcript-only matches (not already in title matches) - const titleMatchIds = new Set(filtered.map(log => log.messages[0]?.uuid)) + const titleMatchIds = new Set(filtered.map(log => log.messages[0]?.uuid)); const transcriptOnlyMatches = deepSearchResults.results .map(r => r.log) - .filter(log => !titleMatchIds.has(log.messages[0]?.uuid)) - filtered = [...filtered, ...transcriptOnlyMatches] + .filter(log => !titleMatchIds.has(log.messages[0]?.uuid)); + filtered = [...filtered, ...transcriptOnlyMatches]; } - return { filteredLogs: filtered, snippets: snippetMap } - }, [titleFilteredLogs, deepSearchResults, debouncedDeepSearchQuery]) + return { filteredLogs: filtered, snippets: snippetMap }; + }, [titleFilteredLogs, deepSearchResults, debouncedDeepSearchQuery]); // Use agentic search results when available and non-empty, otherwise use regular filtered logs const displayedLogs = React.useMemo(() => { - if ( - agenticSearchState.status === 'results' && - agenticSearchState.results.length > 0 - ) { - return agenticSearchState.results + if (agenticSearchState.status === 'results' && agenticSearchState.results.length > 0) { + return agenticSearchState.results; } - return filteredLogs - }, [agenticSearchState, filteredLogs]) + return filteredLogs; + }, [agenticSearchState, filteredLogs]); // Calculate available width for the summary text - const maxLabelWidth = Math.max(30, columns - 4) + const maxLabelWidth = Math.max(30, columns - 4); // Build tree nodes for grouped view const treeNodes = React.useMemo(() => { if (!isResumeWithRenameEnabled) { - return [] + return []; } - const sessionGroups = groupLogsBySessionId(displayedLogs) + const sessionGroups = groupLogsBySessionId(displayedLogs); - return Array.from(sessionGroups.entries()).map( - ([sessionId, groupLogs]): LogTreeNode => { - const latestLog = groupLogs[0]! - const indexInFiltered = displayedLogs.indexOf(latestLog) - const snippet = snippets.get(latestLog) - const snippetStr = snippet - ? formatSnippet(snippet, highlightColor) - : null + return Array.from(sessionGroups.entries()).map(([sessionId, groupLogs]): LogTreeNode => { + const latestLog = groupLogs[0]!; + const indexInFiltered = displayedLogs.indexOf(latestLog); + const snippet = snippets.get(latestLog); + const snippetStr = snippet ? formatSnippet(snippet, highlightColor) : null; - if (groupLogs.length === 1) { - // Single log - no children - const metadata = buildLogMetadata(latestLog, { - showProjectPath: showAllProjects, - }) - return { - id: `log:${sessionId}:0`, - value: { log: latestLog, indexInFiltered }, - label: buildLogLabel(latestLog, maxLabelWidth), - description: snippetStr ? `${metadata}\n ${snippetStr}` : metadata, - dimDescription: true, - } - } - - // Multiple logs - parent with children - const forkCount = groupLogs.length - 1 - const children: LogTreeNode[] = groupLogs.slice(1).map((log, index) => { - const childIndexInFiltered = displayedLogs.indexOf(log) - const childSnippet = snippets.get(log) - const childSnippetStr = childSnippet - ? formatSnippet(childSnippet, highlightColor) - : null - const childMetadata = buildLogMetadata(log, { - isChild: true, - showProjectPath: showAllProjects, - }) - return { - id: `log:${sessionId}:${index + 1}`, - value: { log, indexInFiltered: childIndexInFiltered }, - label: buildLogLabel(log, maxLabelWidth, { isChild: true }), - description: childSnippetStr - ? `${childMetadata}\n ${childSnippetStr}` - : childMetadata, - dimDescription: true, - } - }) - - const parentMetadata = buildLogMetadata(latestLog, { + if (groupLogs.length === 1) { + // Single log - no children + const metadata = buildLogMetadata(latestLog, { showProjectPath: showAllProjects, - }) + }); return { - id: `group:${sessionId}`, + id: `log:${sessionId}:0`, value: { log: latestLog, indexInFiltered }, - label: buildLogLabel(latestLog, maxLabelWidth, { - isGroupHeader: true, - forkCount, - }), - description: snippetStr - ? `${parentMetadata}\n ${snippetStr}` - : parentMetadata, + label: buildLogLabel(latestLog, maxLabelWidth), + description: snippetStr ? `${metadata}\n ${snippetStr}` : metadata, dimDescription: true, - children, - } - }, - ) - }, [ - isResumeWithRenameEnabled, - displayedLogs, - maxLabelWidth, - showAllProjects, - snippets, - highlightColor, - ]) + }; + } + + // Multiple logs - parent with children + const forkCount = groupLogs.length - 1; + const children: LogTreeNode[] = groupLogs.slice(1).map((log, index) => { + const childIndexInFiltered = displayedLogs.indexOf(log); + const childSnippet = snippets.get(log); + const childSnippetStr = childSnippet ? formatSnippet(childSnippet, highlightColor) : null; + const childMetadata = buildLogMetadata(log, { + isChild: true, + showProjectPath: showAllProjects, + }); + return { + id: `log:${sessionId}:${index + 1}`, + value: { log, indexInFiltered: childIndexInFiltered }, + label: buildLogLabel(log, maxLabelWidth, { isChild: true }), + description: childSnippetStr ? `${childMetadata}\n ${childSnippetStr}` : childMetadata, + dimDescription: true, + }; + }); + + const parentMetadata = buildLogMetadata(latestLog, { + showProjectPath: showAllProjects, + }); + return { + id: `group:${sessionId}`, + value: { log: latestLog, indexInFiltered }, + label: buildLogLabel(latestLog, maxLabelWidth, { + isGroupHeader: true, + forkCount, + }), + description: snippetStr ? `${parentMetadata}\n ${snippetStr}` : parentMetadata, + dimDescription: true, + children, + }; + }); + }, [isResumeWithRenameEnabled, displayedLogs, maxLabelWidth, showAllProjects, snippets, highlightColor]); // Build options for old flat list view const flatOptions = React.useMemo(() => { if (isResumeWithRenameEnabled) { - return [] + return []; } return displayedLogs.map((log, index) => { - const rawSummary = getLogDisplayTitle(log) - const summaryWithSidechain = - rawSummary + (log.isSidechain ? ' (sidechain)' : '') - const summary = normalizeAndTruncateToWidth( - summaryWithSidechain, - maxLabelWidth, - ) + const rawSummary = getLogDisplayTitle(log); + const summaryWithSidechain = rawSummary + (log.isSidechain ? ' (sidechain)' : ''); + const summary = normalizeAndTruncateToWidth(summaryWithSidechain, maxLabelWidth); - const baseDescription = formatLogMetadata(log) - const projectSuffix = - showAllProjects && log.projectPath ? ` · ${log.projectPath}` : '' - const snippet = snippets.get(log) - const snippetStr = snippet ? formatSnippet(snippet, highlightColor) : null + const baseDescription = formatLogMetadata(log); + const projectSuffix = showAllProjects && log.projectPath ? ` · ${log.projectPath}` : ''; + const snippet = snippets.get(log); + const snippetStr = snippet ? formatSnippet(snippet, highlightColor) : null; return { label: summary, @@ -624,236 +525,210 @@ export function LogSelector({ : baseDescription + projectSuffix, dimDescription: true, value: index.toString(), - } - }) - }, [ - isResumeWithRenameEnabled, - displayedLogs, - highlightColor, - maxLabelWidth, - showAllProjects, - snippets, - ]) + }; + }); + }, [isResumeWithRenameEnabled, displayedLogs, highlightColor, maxLabelWidth, showAllProjects, snippets]); // Derive the focused log from focusedNode - const focusedLog = focusedNode?.value.log ?? null + const focusedLog = focusedNode?.value.log ?? null; const getExpandCollapseHint = (): string => { - if (!isResumeWithRenameEnabled || !focusedLog) return '' - const sessionId = getSessionIdFromLog(focusedLog) - if (!sessionId) return '' + if (!isResumeWithRenameEnabled || !focusedLog) return ''; + const sessionId = getSessionIdFromLog(focusedLog); + if (!sessionId) return ''; - const sessionLogs = displayedLogs.filter( - log => getSessionIdFromLog(log) === sessionId, - ) - const hasMultipleLogs = sessionLogs.length > 1 + const sessionLogs = displayedLogs.filter(log => getSessionIdFromLog(log) === sessionId); + const hasMultipleLogs = sessionLogs.length > 1; - if (!hasMultipleLogs) return '' + if (!hasMultipleLogs) return ''; - const isExpanded = expandedGroupSessionIds.has(sessionId) - const isChildNode = sessionLogs.indexOf(focusedLog) > 0 + const isExpanded = expandedGroupSessionIds.has(sessionId); + const isChildNode = sessionLogs.indexOf(focusedLog) > 0; if (isChildNode) { - return '← to collapse' + return '← to collapse'; } - return isExpanded ? '← to collapse' : '→ to expand' - } + return isExpanded ? '← to collapse' : '→ to expand'; + }; const handleRenameSubmit = React.useCallback(async () => { - const sessionId = focusedLog ? getSessionIdFromLog(focusedLog) : undefined + const sessionId = focusedLog ? getSessionIdFromLog(focusedLog) : undefined; if (!focusedLog || !sessionId) { - setViewMode('list') - setRenameValue('') - return + setViewMode('list'); + setRenameValue(''); + return; } if (renameValue.trim()) { // Pass fullPath for cross-project sessions (different worktrees) - await saveCustomTitle(sessionId, renameValue.trim(), focusedLog.fullPath) + await saveCustomTitle(sessionId, renameValue.trim(), focusedLog.fullPath); if (isResumeWithRenameEnabled && onLogsChanged) { - onLogsChanged() + onLogsChanged(); } } - setViewMode('list') - setRenameValue('') - }, [focusedLog, renameValue, onLogsChanged, isResumeWithRenameEnabled]) + setViewMode('list'); + setRenameValue(''); + }, [focusedLog, renameValue, onLogsChanged, isResumeWithRenameEnabled]); const exitSearchMode = React.useCallback(() => { - setViewMode('list') - logEvent('tengu_session_search_toggled', { enabled: false }) - }, []) + setViewMode('list'); + logEvent('tengu_session_search_toggled', { enabled: false }); + }, []); const enterSearchMode = React.useCallback(() => { - setViewMode('search') - logEvent('tengu_session_search_toggled', { enabled: true }) - }, []) + setViewMode('search'); + logEvent('tengu_session_search_toggled', { enabled: true }); + }, []); // Handler for triggering agentic search const handleAgenticSearch = React.useCallback(async () => { if (!searchQuery.trim() || !onAgenticSearch || !isAgenticSearchEnabled) { - return + return; } // Abort any previous search - agenticSearchAbortRef.current?.abort() - const abortController = new AbortController() - agenticSearchAbortRef.current = abortController + agenticSearchAbortRef.current?.abort(); + const abortController = new AbortController(); + agenticSearchAbortRef.current = abortController; - setAgenticSearchState({ status: 'searching' }) + setAgenticSearchState({ status: 'searching' }); logEvent('tengu_agentic_search_started', { query_length: searchQuery.length, - }) + }); try { - const results = await onAgenticSearch( - searchQuery, - logs, - abortController.signal, - ) + const results = await onAgenticSearch(searchQuery, logs, abortController.signal); // Check if aborted before updating state if (abortController.signal.aborted) { - return + return; } - setAgenticSearchState({ status: 'results', results, query: searchQuery }) + setAgenticSearchState({ status: 'results', results, query: searchQuery }); logEvent('tengu_agentic_search_completed', { query_length: searchQuery.length, results_count: results.length, - }) + }); } catch (error) { // Don't show error for aborted requests if (abortController.signal.aborted) { - return + return; } setAgenticSearchState({ status: 'error', message: error instanceof Error ? error.message : 'Search failed', - }) + }); logEvent('tengu_agentic_search_error', { query_length: searchQuery.length, - }) + }); } - }, [searchQuery, onAgenticSearch, isAgenticSearchEnabled, logs]) + }, [searchQuery, onAgenticSearch, isAgenticSearchEnabled, logs]); // Clear agentic search results/error when query changes React.useEffect(() => { - if ( - agenticSearchState.status !== 'idle' && - agenticSearchState.status !== 'searching' - ) { + if (agenticSearchState.status !== 'idle' && agenticSearchState.status !== 'searching') { // Clear if the query has changed from the one used for results/error if ( - (agenticSearchState.status === 'results' && - agenticSearchState.query !== searchQuery) || + (agenticSearchState.status === 'results' && agenticSearchState.query !== searchQuery) || agenticSearchState.status === 'error' ) { - setAgenticSearchState({ status: 'idle' }) + setAgenticSearchState({ status: 'idle' }); } } - }, [searchQuery, agenticSearchState]) + }, [searchQuery, agenticSearchState]); // Cleanup: abort any in-progress agentic search on unmount React.useEffect(() => { return () => { - agenticSearchAbortRef.current?.abort() - } - }, []) + agenticSearchAbortRef.current?.abort(); + }; + }, []); // Focus first item when agentic search completes with results - const prevAgenticStatusRef = React.useRef(agenticSearchState.status) + const prevAgenticStatusRef = React.useRef(agenticSearchState.status); React.useEffect(() => { - const prevStatus = prevAgenticStatusRef.current - prevAgenticStatusRef.current = agenticSearchState.status + const prevStatus = prevAgenticStatusRef.current; + prevAgenticStatusRef.current = agenticSearchState.status; // When search just completed, focus the first item in the list if (prevStatus === 'searching' && agenticSearchState.status === 'results') { if (isResumeWithRenameEnabled && treeNodes.length > 0) { - setFocusedNode(treeNodes[0]!) + setFocusedNode(treeNodes[0]!); } else if (!isResumeWithRenameEnabled && displayedLogs.length > 0) { - const firstLog = displayedLogs[0]! + const firstLog = displayedLogs[0]!; setFocusedNode({ id: '0', value: { log: firstLog, indexInFiltered: 0 }, label: '', - }) + }); } } - }, [ - agenticSearchState.status, - isResumeWithRenameEnabled, - treeNodes, - displayedLogs, - ]) + }, [agenticSearchState.status, isResumeWithRenameEnabled, treeNodes, displayedLogs]); const handleFlatOptionsSelectFocus = React.useCallback( (value: string) => { - const index = parseInt(value, 10) - const log = displayedLogs[index] + const index = parseInt(value, 10); + const log = displayedLogs[index]; if (!log || prevFocusedIdRef.current === index.toString()) { - return + return; } - prevFocusedIdRef.current = index.toString() + prevFocusedIdRef.current = index.toString(); setFocusedNode({ id: index.toString(), value: { log, indexInFiltered: index }, label: '', - }) - setFocusedIndex(index + 1) + }); + setFocusedIndex(index + 1); }, [displayedLogs], - ) + ); const handleTreeSelectFocus = React.useCallback( (node: LogTreeNode) => { - setFocusedNode(node) + setFocusedNode(node); // Update focused index for scroll position display - const index = displayedLogs.findIndex( - log => getSessionIdFromLog(log) === getSessionIdFromLog(node.value.log), - ) + const index = displayedLogs.findIndex(log => getSessionIdFromLog(log) === getSessionIdFromLog(node.value.log)); if (index >= 0) { - setFocusedIndex(index + 1) + setFocusedIndex(index + 1); } }, [displayedLogs], - ) + ); // Escape to abort agentic search in progress useKeybinding( 'confirm:no', () => { - agenticSearchAbortRef.current?.abort() - setAgenticSearchState({ status: 'idle' }) - logEvent('tengu_agentic_search_cancelled', {}) + agenticSearchAbortRef.current?.abort(); + setAgenticSearchState({ status: 'idle' }); + logEvent('tengu_agentic_search_cancelled', {}); }, { context: 'Confirmation', - isActive: - viewMode !== 'preview' && agenticSearchState.status === 'searching', + isActive: viewMode !== 'preview' && agenticSearchState.status === 'searching', }, - ) + ); // Escape in rename mode - exit rename mode // Use Settings context so 'n' key doesn't exit (allows typing 'n' in rename input) useKeybinding( 'confirm:no', () => { - setViewMode('list') - setRenameValue('') + setViewMode('list'); + setRenameValue(''); }, { context: 'Settings', - isActive: - viewMode === 'rename' && agenticSearchState.status !== 'searching', + isActive: viewMode === 'rename' && agenticSearchState.status !== 'searching', }, - ) + ); // Escape when agentic search option focused - clear and cancel useKeybinding( 'confirm:no', () => { - setSearchQuery('') - setIsAgenticSearchOptionFocused(false) - onCancel?.() + setSearchQuery(''); + setIsAgenticSearchOptionFocused(false); + onCancel?.(); }, { context: 'Confirmation', @@ -864,19 +739,19 @@ export function LogSelector({ isAgenticSearchOptionFocused && agenticSearchState.status !== 'searching', }, - ) + ); // Handle non-escape input useInput( (input, key) => { if (viewMode === 'preview') { // Preview mode handles its own input - return + return; } // Agentic search abort is now handled via keybinding if (agenticSearchState.status === 'searching') { - return + return; } if (viewMode === 'rename') { @@ -885,7 +760,7 @@ export function LogSelector({ } else if (viewMode === 'search') { // Text input is handled by useSearchInput hook if (input.toLowerCase() === 'n' && key.ctrl) { - exitSearchMode() + exitSearchMode(); } else if (key.return || key.downArrow) { // Focus agentic search option if applicable if ( @@ -894,7 +769,7 @@ export function LogSelector({ isAgenticSearchEnabled && agenticSearchState.status !== 'results' ) { - setIsAgenticSearchOptionFocused(true) + setIsAgenticSearchOptionFocused(true); } } } else { @@ -902,120 +777,109 @@ export function LogSelector({ if (isAgenticSearchOptionFocused) { if (key.return) { // Trigger agentic search - void handleAgenticSearch() - setIsAgenticSearchOptionFocused(false) - return + void handleAgenticSearch(); + setIsAgenticSearchOptionFocused(false); + return; } else if (key.downArrow) { // Move focus to the session list - setIsAgenticSearchOptionFocused(false) - return + setIsAgenticSearchOptionFocused(false); + return; } else if (key.upArrow) { // Go back to search mode - setViewMode('search') - setIsAgenticSearchOptionFocused(false) - return + setViewMode('search'); + setIsAgenticSearchOptionFocused(false); + return; } } // Handle tab cycling for tag tabs if (hasTags && key.tab) { - const offset = key.shift ? -1 : 1 + const offset = key.shift ? -1 : 1; setSelectedTagIndex(prev => { - const current = prev < tagTabs.length ? prev : 0 - const newIndex = - (current + tagTabs.length + offset) % tagTabs.length - const newTab = tagTabs[newIndex] + const current = prev < tagTabs.length ? prev : 0; + const newIndex = (current + tagTabs.length + offset) % tagTabs.length; + const newTab = tagTabs[newIndex]; logEvent('tengu_session_tag_filter_changed', { is_all: newTab === 'All', tag_count: uniqueTags.length, - }) - return newIndex - }) - return + }); + return newIndex; + }); + return; } - const keyIsNotCtrlOrMeta = !key.ctrl && !key.meta - const lowerInput = input.toLowerCase() + const keyIsNotCtrlOrMeta = !key.ctrl && !key.meta; + const lowerInput = input.toLowerCase(); // Ctrl+letter shortcuts for actions (freeing up plain letters for type-to-search) if (lowerInput === 'a' && key.ctrl && onToggleAllProjects) { - onToggleAllProjects() + onToggleAllProjects(); logEvent('tengu_session_all_projects_toggled', { enabled: !showAllProjects, - }) + }); } else if (lowerInput === 'b' && key.ctrl) { - const newEnabled = !branchFilterEnabled - setBranchFilterEnabled(newEnabled) + const newEnabled = !branchFilterEnabled; + setBranchFilterEnabled(newEnabled); logEvent('tengu_session_branch_filter_toggled', { enabled: newEnabled, - }) + }); } else if (lowerInput === 'w' && key.ctrl && hasMultipleWorktrees) { - const newValue = !showAllWorktrees - setShowAllWorktrees(newValue) + const newValue = !showAllWorktrees; + setShowAllWorktrees(newValue); logEvent('tengu_session_worktree_filter_toggled', { enabled: newValue, - }) + }); } else if (lowerInput === '/' && keyIsNotCtrlOrMeta) { - setViewMode('search') - logEvent('tengu_session_search_toggled', { enabled: true }) + setViewMode('search'); + logEvent('tengu_session_search_toggled', { enabled: true }); } else if (lowerInput === 'r' && key.ctrl && focusedLog) { - setViewMode('rename') - setRenameValue('') - logEvent('tengu_session_rename_started', {}) + setViewMode('rename'); + setRenameValue(''); + logEvent('tengu_session_rename_started', {}); } else if (lowerInput === 'v' && key.ctrl && focusedLog) { - setPreviewLog(focusedLog) - setViewMode('preview') + setPreviewLog(focusedLog); + setViewMode('preview'); logEvent('tengu_session_preview_opened', { messageCount: focusedLog.messageCount, - }) - } else if ( - focusedLog && - keyIsNotCtrlOrMeta && - input.length > 0 && - !/^\s+$/.test(input) - ) { + }); + } else if (focusedLog && keyIsNotCtrlOrMeta && input.length > 0 && !/^\s+$/.test(input)) { // Any printable character enters search mode and starts typing - setViewMode('search') - setSearchQuery(input) - logEvent('tengu_session_search_toggled', { enabled: true }) + setViewMode('search'); + setSearchQuery(input); + logEvent('tengu_session_search_toggled', { enabled: true }); } } }, { isActive: true }, - ) + ); - const filterIndicators = [] + const filterIndicators = []; if (branchFilterEnabled && currentBranch) { - filterIndicators.push(currentBranch) + filterIndicators.push(currentBranch); } if (hasMultipleWorktrees && !showAllWorktrees) { - filterIndicators.push('current worktree') + filterIndicators.push('current worktree'); } - const showAdditionalFilterLine = - filterIndicators.length > 0 && viewMode !== 'search' + const showAdditionalFilterLine = filterIndicators.length > 0 && viewMode !== 'search'; // Search box takes 3 lines (border top, content, border bottom) - const searchBoxLines = 3 - const headerLines = - 5 + searchBoxLines + (showAdditionalFilterLine ? 1 : 0) + tagTabsLines - const footerLines = 2 - const visibleCount = Math.max( - 1, - Math.floor((maxHeight - headerLines - footerLines) / 3), - ) + const searchBoxLines = 3; + const headerLines = 5 + searchBoxLines + (showAdditionalFilterLine ? 1 : 0) + tagTabsLines; + const footerLines = 2; + const visibleCount = Math.max(1, Math.floor((maxHeight - headerLines - footerLines) / 3)); // Progressive loading: request more logs when user scrolls near the end React.useEffect(() => { - if (!onLoadMore) return - const buffer = visibleCount * 2 + if (!onLoadMore) return; + const buffer = visibleCount * 2; if (focusedIndex + buffer >= displayedLogs.length) { - onLoadMore(visibleCount * 3) + onLoadMore(visibleCount * 3); } - }, [focusedIndex, visibleCount, displayedLogs.length, onLoadMore]) + }, [focusedIndex, visibleCount, displayedLogs.length, onLoadMore]); // Early return if no logs if (logs.length === 0) { - return null + return null; } // Show preview mode if active @@ -1024,12 +888,12 @@ export function LogSelector({ { - setViewMode('list') - setPreviewLog(null) + setViewMode('list'); + setPreviewLog(null); }} onSelect={onSelect} /> - ) + ); } return ( @@ -1087,14 +951,13 @@ export function LogSelector({ )} {/* Results header when agentic search completed with results */} - {agenticSearchState.status === 'results' && - agenticSearchState.results.length > 0 && ( - - - Claude found these results: - - - )} + {agenticSearchState.status === 'results' && agenticSearchState.results.length > 0 && ( + + + Claude found these results: + + + )} {/* Fallback message when agentic search found no results and deep search also has nothing */} {agenticSearchState.status === 'results' && @@ -1125,15 +988,10 @@ export function LogSelector({ agenticSearchState.status !== 'error' && ( - + {isAgenticSearchOptionFocused ? figures.pointer : ' '} - + Search deeply using Claude → @@ -1142,8 +1000,7 @@ export function LogSelector({ )} {/* Hide session list when agentic search is in progress */} - {agenticSearchState.status === 'searching' ? null : viewMode === - 'rename' && focusedLog ? ( + {agenticSearchState.status === 'searching' ? null : viewMode === 'rename' && focusedLog ? ( Rename session: @@ -1151,10 +1008,7 @@ export function LogSelector({ value={renameValue} onChange={setRenameValue} onSubmit={handleRenameSubmit} - placeholder={getLogDisplayTitle( - focusedLog!, - 'Enter new session name', - )} + placeholder={getLogDisplayTitle(focusedLog!, 'Enter new session name')} columns={columns} cursorOffset={renameCursorOffset} onChangeCursorOffset={setRenameCursorOffset} @@ -1166,7 +1020,7 @@ export function LogSelector({ { - onSelect(node.value.log) + onSelect(node.value.log); }} onFocus={handleTreeSelectFocus} onCancel={onCancel} @@ -1178,36 +1032,27 @@ export function LogSelector({ isNodeExpanded={nodeId => { // Always expand if in search or branch filter mode if (viewMode === 'search' || branchFilterEnabled) { - return true + return true; } // Extract sessionId from node ID (format: "group:sessionId") - const sessionId = - typeof nodeId === 'string' && nodeId.startsWith('group:') - ? nodeId.substring(6) - : null - return sessionId ? expandedGroupSessionIds.has(sessionId) : false + const sessionId = typeof nodeId === 'string' && nodeId.startsWith('group:') ? nodeId.substring(6) : null; + return sessionId ? expandedGroupSessionIds.has(sessionId) : false; }} onExpand={nodeId => { - const sessionId = - typeof nodeId === 'string' && nodeId.startsWith('group:') - ? nodeId.substring(6) - : null + const sessionId = typeof nodeId === 'string' && nodeId.startsWith('group:') ? nodeId.substring(6) : null; if (sessionId) { - setExpandedGroupSessionIds(prev => new Set(prev).add(sessionId)) - logEvent('tengu_session_group_expanded', {}) + setExpandedGroupSessionIds(prev => new Set(prev).add(sessionId)); + logEvent('tengu_session_group_expanded', {}); } }} onCollapse={nodeId => { - const sessionId = - typeof nodeId === 'string' && nodeId.startsWith('group:') - ? nodeId.substring(6) - : null + const sessionId = typeof nodeId === 'string' && nodeId.startsWith('group:') ? nodeId.substring(6) : null; if (sessionId) { setExpandedGroupSessionIds(prev => { - const newSet = new Set(prev) - newSet.delete(sessionId) - return newSet - }) + const newSet = new Set(prev); + newSet.delete(sessionId); + return newSet; + }); } }} onUpFromFirstItem={enterSearchMode} @@ -1217,10 +1062,10 @@ export function LogSelector({ options={flatOptions} onChange={value => { // Old flat list mode - index directly maps to displayedLogs - const itemIndex = parseInt(value, 10) - const log = displayedLogs[itemIndex] + const itemIndex = parseInt(value, 10); + const log = displayedLogs[itemIndex]; if (log) { - onSelect(log) + onSelect(log); } }} visibleOptionCount={visibleCount} @@ -1275,18 +1120,9 @@ export function LogSelector({ ) : viewMode === 'search' ? ( - - {isSearching && isDeepSearchEnabled - ? 'Searching…' - : 'Type to Search'} - + {isSearching && isDeepSearchEnabled ? 'Searching…' : 'Type to Search'} - + ) : ( @@ -1298,12 +1134,7 @@ export function LogSelector({ action={`show ${showAllProjects ? 'current dir' : 'all projects'}`} /> )} - {currentBranch && ( - - )} + {currentBranch && } {hasMultipleWorktrees && ( - {getExpandCollapseHint() && ( - {getExpandCollapseHint()} - )} + {getExpandCollapseHint() && {getExpandCollapseHint()}} )} - ) + ); } /** @@ -1337,32 +1166,32 @@ export function LogSelector({ function extractSearchableText(message: SerializedMessage): string { // Only extract from user and assistant messages that have content if (message.type !== 'user' && message.type !== 'assistant') { - return '' + return ''; } - const content = 'message' in message ? message.message?.content : undefined - if (!content) return '' + const content = 'message' in message ? message.message?.content : undefined; + if (!content) return ''; // Handle string content (simple messages) if (typeof content === 'string') { - return content + return content; } // Handle array of content blocks if (Array.isArray(content)) { return content .map(block => { - if (typeof block === 'string') return block - if ('text' in block && typeof block.text === 'string') return block.text - return '' + if (typeof block === 'string') return block; + if ('text' in block && typeof block.text === 'string') return block.text; + return ''; // we don't return thinking blocks and tool names here; // they're not useful for search, as they can add noise to the fuzzy matching }) .filter(Boolean) - .join(' ') + .join(' '); } - return '' + return ''; } /** @@ -1373,14 +1202,8 @@ function buildSearchableText(log: LogOption): string { const searchableMessages = log.messages.length <= DEEP_SEARCH_MAX_MESSAGES ? log.messages - : [ - ...log.messages.slice(0, DEEP_SEARCH_CROP_SIZE), - ...log.messages.slice(-DEEP_SEARCH_CROP_SIZE), - ] - const messageText = searchableMessages - .map(extractSearchableText) - .filter(Boolean) - .join(' ') + : [...log.messages.slice(0, DEEP_SEARCH_CROP_SIZE), ...log.messages.slice(-DEEP_SEARCH_CROP_SIZE)]; + const messageText = searchableMessages.map(extractSearchableText).filter(Boolean).join(' '); const metadata = [ log.customTitle, @@ -1392,50 +1215,42 @@ function buildSearchableText(log: LogOption): string { log.prRepository, ] .filter(Boolean) - .join(' ') + .join(' '); - const fullText = `${metadata} ${messageText}`.trim() - return fullText.length > DEEP_SEARCH_MAX_TEXT_LENGTH - ? fullText.slice(0, DEEP_SEARCH_MAX_TEXT_LENGTH) - : fullText + const fullText = `${metadata} ${messageText}`.trim(); + return fullText.length > DEEP_SEARCH_MAX_TEXT_LENGTH ? fullText.slice(0, DEEP_SEARCH_MAX_TEXT_LENGTH) : fullText; } -function groupLogsBySessionId( - filteredLogs: LogOption[], -): Map { - const groups = new Map() +function groupLogsBySessionId(filteredLogs: LogOption[]): Map { + const groups = new Map(); for (const log of filteredLogs) { - const sessionId = getSessionIdFromLog(log) + const sessionId = getSessionIdFromLog(log); if (sessionId) { - const existing = groups.get(sessionId) + const existing = groups.get(sessionId); if (existing) { - existing.push(log) + existing.push(log); } else { - groups.set(sessionId, [log]) + groups.set(sessionId, [log]); } } } // Sort logs within each group by modified date (newest first) - groups.forEach(logs => - logs.sort( - (a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime(), - ), - ) + groups.forEach(logs => logs.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime())); - return groups + return groups; } /** * Get unique tags from a list of logs, sorted alphabetically */ function getUniqueTags(logs: LogOption[]): string[] { - const tags = new Set() + const tags = new Set(); for (const log of logs) { if (log.tag) { - tags.add(log.tag) + tags.add(log.tag); } } - return Array.from(tags).sort((a, b) => a.localeCompare(b)) + return Array.from(tags).sort((a, b) => a.localeCompare(b)); } diff --git a/src/components/LogoV2/AnimatedAsterisk.tsx b/src/components/LogoV2/AnimatedAsterisk.tsx index 9e72baaa2..d8bb2b783 100644 --- a/src/components/LogoV2/AnimatedAsterisk.tsx +++ b/src/components/LogoV2/AnimatedAsterisk.tsx @@ -1,57 +1,51 @@ -import * as React from 'react' -import { useEffect, useRef, useState } from 'react' -import { TEARDROP_ASTERISK } from '../../constants/figures.js' -import { Box, Text, useAnimationFrame } from '@anthropic/ink' -import { getInitialSettings } from '../../utils/settings/settings.js' -import { hueToRgb, toRGBColor } from '../Spinner/utils.js' +import * as React from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { TEARDROP_ASTERISK } from '../../constants/figures.js'; +import { Box, Text, useAnimationFrame } from '@anthropic/ink'; +import { getInitialSettings } from '../../utils/settings/settings.js'; +import { hueToRgb, toRGBColor } from '../Spinner/utils.js'; -const SWEEP_DURATION_MS = 1500 -const SWEEP_COUNT = 2 -const TOTAL_ANIMATION_MS = SWEEP_DURATION_MS * SWEEP_COUNT -const SETTLED_GREY = toRGBColor({ r: 153, g: 153, b: 153 }) +const SWEEP_DURATION_MS = 1500; +const SWEEP_COUNT = 2; +const TOTAL_ANIMATION_MS = SWEEP_DURATION_MS * SWEEP_COUNT; +const SETTLED_GREY = toRGBColor({ r: 153, g: 153, b: 153 }); -export function AnimatedAsterisk({ - char = TEARDROP_ASTERISK, -}: { - char?: string -}): React.ReactNode { +export function AnimatedAsterisk({ char = TEARDROP_ASTERISK }: { char?: string }): React.ReactNode { // Read prefersReducedMotion once at mount — no useSettings() subscription, // since that would re-render whenever settings change. - const [reducedMotion] = useState( - () => getInitialSettings().prefersReducedMotion ?? false, - ) - const [done, setDone] = useState(reducedMotion) + const [reducedMotion] = useState(() => getInitialSettings().prefersReducedMotion ?? false); + const [done, setDone] = useState(reducedMotion); // useAnimationFrame's clock is shared — capture our start offset so the // sweep always begins at hue 0 regardless of when we mount. - const startTimeRef = useRef(null) + const startTimeRef = useRef(null); // Wire the ref so useAnimationFrame's viewport-pause kicks in: if the // user submits a message before the sweep finishes, the clock stops // automatically once this row enters scrollback (prevents flicker). - const [ref, time] = useAnimationFrame(done ? null : 50) + const [ref, time] = useAnimationFrame(done ? null : 50); useEffect(() => { - if (done) return - const t = setTimeout(setDone, TOTAL_ANIMATION_MS, true) - return () => clearTimeout(t) - }, [done]) + if (done) return; + const t = setTimeout(setDone, TOTAL_ANIMATION_MS, true); + return () => clearTimeout(t); + }, [done]); if (done) { return ( {char} - ) + ); } if (startTimeRef.current === null) { - startTimeRef.current = time + startTimeRef.current = time; } - const elapsed = time - startTimeRef.current - const hue = ((elapsed / SWEEP_DURATION_MS) * 360) % 360 + const elapsed = time - startTimeRef.current; + const hue = ((elapsed / SWEEP_DURATION_MS) * 360) % 360; return ( {char} - ) + ); } diff --git a/src/components/LogoV2/AnimatedClawd.tsx b/src/components/LogoV2/AnimatedClawd.tsx index 5ad68babb..741f801f0 100644 --- a/src/components/LogoV2/AnimatedClawd.tsx +++ b/src/components/LogoV2/AnimatedClawd.tsx @@ -1,14 +1,14 @@ -import * as React from 'react' -import { useEffect, useRef, useState } from 'react' -import { Box } from '@anthropic/ink' -import { getInitialSettings } from '../../utils/settings/settings.js' -import { Clawd, type ClawdPose } from './Clawd.js' +import * as React from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { Box } from '@anthropic/ink'; +import { getInitialSettings } from '../../utils/settings/settings.js'; +import { Clawd, type ClawdPose } from './Clawd.js'; -type Frame = { pose: ClawdPose; offset: number } +type Frame = { pose: ClawdPose; offset: number }; /** Hold a pose for n frames (60ms each). */ function hold(pose: ClawdPose, offset: number, frames: number): Frame[] { - return Array.from({ length: frames }, () => ({ pose, offset })) + return Array.from({ length: frames }, () => ({ pose, offset })); } // Offset semantics: marginTop in a fixed-height-3 container. 0 = normal, @@ -24,21 +24,21 @@ const JUMP_WAVE: readonly Frame[] = [ ...hold('default', 1, 2), // crouch again ...hold('arms-up', 0, 3), // spring! ...hold('default', 0, 1), -] +]; // Click animation: glance right, then left, then back. const LOOK_AROUND: readonly Frame[] = [ ...hold('look-right', 0, 5), ...hold('look-left', 0, 5), ...hold('default', 0, 1), -] +]; -const CLICK_ANIMATIONS: readonly (readonly Frame[])[] = [JUMP_WAVE, LOOK_AROUND] +const CLICK_ANIMATIONS: readonly (readonly Frame[])[] = [JUMP_WAVE, LOOK_AROUND]; -const IDLE: Frame = { pose: 'default', offset: 0 } -const FRAME_MS = 60 -const incrementFrame = (i: number) => i + 1 -const CLAWD_HEIGHT = 3 +const IDLE: Frame = { pose: 'default', offset: 0 }; +const FRAME_MS = 60; +const incrementFrame = (i: number) => i + 1; +const CLAWD_HEIGHT = 3; /** * Clawd with click-triggered animations (crouch-jump with arms up, or @@ -49,48 +49,44 @@ const CLAWD_HEIGHT = 3 * elsewhere this renders and behaves identically to plain ``. */ export function AnimatedClawd(): React.ReactNode { - const { pose, bounceOffset, onClick } = useClawdAnimation() + const { pose, bounceOffset, onClick } = useClawdAnimation(); return ( - ) + ); } function useClawdAnimation(): { - pose: ClawdPose - bounceOffset: number - onClick: () => void + pose: ClawdPose; + bounceOffset: number; + onClick: () => void; } { // Read once at mount — no useSettings() subscription, since that would // re-render on any settings change. - const [reducedMotion] = useState( - () => getInitialSettings().prefersReducedMotion ?? false, - ) - const [frameIndex, setFrameIndex] = useState(-1) - const sequenceRef = useRef(JUMP_WAVE) + const [reducedMotion] = useState(() => getInitialSettings().prefersReducedMotion ?? false); + const [frameIndex, setFrameIndex] = useState(-1); + const sequenceRef = useRef(JUMP_WAVE); const onClick = () => { - if (reducedMotion || frameIndex !== -1) return - sequenceRef.current = - CLICK_ANIMATIONS[Math.floor(Math.random() * CLICK_ANIMATIONS.length)]! - setFrameIndex(0) - } + if (reducedMotion || frameIndex !== -1) return; + sequenceRef.current = CLICK_ANIMATIONS[Math.floor(Math.random() * CLICK_ANIMATIONS.length)]!; + setFrameIndex(0); + }; useEffect(() => { - if (frameIndex === -1) return + if (frameIndex === -1) return; if (frameIndex >= sequenceRef.current.length) { - setFrameIndex(-1) - return + setFrameIndex(-1); + return; } - const timer = setTimeout(setFrameIndex, FRAME_MS, incrementFrame) - return () => clearTimeout(timer) - }, [frameIndex]) + const timer = setTimeout(setFrameIndex, FRAME_MS, incrementFrame); + return () => clearTimeout(timer); + }, [frameIndex]); - const seq = sequenceRef.current - const current = - frameIndex >= 0 && frameIndex < seq.length ? seq[frameIndex]! : IDLE - return { pose: current.pose, bounceOffset: current.offset, onClick } + const seq = sequenceRef.current; + const current = frameIndex >= 0 && frameIndex < seq.length ? seq[frameIndex]! : IDLE; + return { pose: current.pose, bounceOffset: current.offset, onClick }; } diff --git a/src/components/LogoV2/ChannelsNotice.tsx b/src/components/LogoV2/ChannelsNotice.tsx index c58f400c1..7740f320c 100644 --- a/src/components/LogoV2/ChannelsNotice.tsx +++ b/src/components/LogoV2/ChannelsNotice.tsx @@ -4,23 +4,16 @@ // docs/feature-gating.md). Do NOT import this module statically from // unguarded code. -import * as React from 'react' -import { useState } from 'react' -import { - type ChannelEntry, - getAllowedChannels, - getHasDevChannels, -} from '../../bootstrap/state.js' -import { Box, Text } from '@anthropic/ink' -import { isChannelsEnabled } from '../../services/mcp/channelAllowlist.js' -import { getEffectiveChannelAllowlist } from '../../services/mcp/channelNotification.js' -import { getMcpConfigsByScope } from '../../services/mcp/config.js' -import { - getClaudeAIOAuthTokens, - getSubscriptionType, -} from '../../utils/auth.js' -import { loadInstalledPluginsV2 } from '../../utils/plugins/installedPluginsManager.js' -import { getSettingsForSource } from '../../utils/settings/settings.js' +import * as React from 'react'; +import { useState } from 'react'; +import { type ChannelEntry, getAllowedChannels, getHasDevChannels } from '../../bootstrap/state.js'; +import { Box, Text } from '@anthropic/ink'; +import { isChannelsEnabled } from '../../services/mcp/channelAllowlist.js'; +import { getEffectiveChannelAllowlist } from '../../services/mcp/channelNotification.js'; +import { getMcpConfigsByScope } from '../../services/mcp/config.js'; +import { getClaudeAIOAuthTokens, getSubscriptionType } from '../../utils/auth.js'; +import { loadInstalledPluginsV2 } from '../../utils/plugins/installedPluginsManager.js'; +import { getSettingsForSource } from '../../utils/settings/settings.js'; export function ChannelsNotice(): React.ReactNode { // Snapshot all reads at mount. This notice enters scrollback immediately @@ -29,46 +22,42 @@ export function ChannelsNotice(): React.ReactNode { // (session cache updated by background polling / /login), and // isChannelsEnabled (GrowthBook 5-min refresh) must be captured once // so a later re-render cannot flip branches. - const [{ channels, disabled, noAuth, policyBlocked, list, unmatched }] = - useState(() => { - const ch = getAllowedChannels() - if (ch.length === 0) - return { - channels: ch, - disabled: false, - noAuth: false, - policyBlocked: false, - list: '', - unmatched: [] as Unmatched[], - } - const l = ch.map(formatEntry).join(', ') - const sub = getSubscriptionType() - const managed = sub === 'team' || sub === 'enterprise' - const policy = getSettingsForSource('policySettings') - const allowlist = getEffectiveChannelAllowlist( - sub, - policy?.allowedChannelPlugins, - ) + const [{ channels, disabled, noAuth, policyBlocked, list, unmatched }] = useState(() => { + const ch = getAllowedChannels(); + if (ch.length === 0) return { channels: ch, - disabled: !isChannelsEnabled(), - noAuth: !getClaudeAIOAuthTokens()?.accessToken, - policyBlocked: managed && policy?.channelsEnabled !== true, - list: l, - unmatched: findUnmatched(ch, allowlist), - } - }) - if (channels.length === 0) return null + disabled: false, + noAuth: false, + policyBlocked: false, + list: '', + unmatched: [] as Unmatched[], + }; + const l = ch.map(formatEntry).join(', '); + const sub = getSubscriptionType(); + const managed = sub === 'team' || sub === 'enterprise'; + const policy = getSettingsForSource('policySettings'); + const allowlist = getEffectiveChannelAllowlist(sub, policy?.allowedChannelPlugins); + return { + channels: ch, + disabled: !isChannelsEnabled(), + noAuth: !getClaudeAIOAuthTokens()?.accessToken, + policyBlocked: managed && policy?.channelsEnabled !== true, + list: l, + unmatched: findUnmatched(ch, allowlist), + }; + }); + if (channels.length === 0) return null; // When both flags are passed, the list mixes entries and a single flag // name would be wrong for half of it. entry.dev distinguishes origin. - const hasNonDev = channels.some(c => !c.dev) + const hasNonDev = channels.some(c => !c.dev); const flag = getHasDevChannels() && hasNonDev ? 'Channels' : getHasDevChannels() ? '--dangerously-load-development-channels' - : '--channels' + : '--channels'; if (disabled) { return ( @@ -78,7 +67,7 @@ export function ChannelsNotice(): React.ReactNode { Channels are not currently available - ) + ); } if (noAuth) { @@ -87,11 +76,9 @@ export function ChannelsNotice(): React.ReactNode { {flag} ignored ({list}) - - Channels require claude.ai authentication · run /login, then restart - + Channels require claude.ai authentication · run /login, then restart - ) + ); } if (policyBlocked) { @@ -101,17 +88,14 @@ export function ChannelsNotice(): React.ReactNode { {flag} blocked by org policy ({list}) Inbound messages will be silently dropped - - Have an administrator set channelsEnabled: true in managed settings to - enable - + Have an administrator set channelsEnabled: true in managed settings to enable {unmatched.map(u => ( {formatEntry(u.entry)} · {u.why} ))} - ) + ); } // "Listening for" not "active" — at this point we only know the allowlist @@ -121,9 +105,8 @@ export function ChannelsNotice(): React.ReactNode { Listening for channel messages from: {list} - Experimental · inbound messages will be pushed into this session, this - carries prompt injection risks. Restart Claude Code without {flag} to - disable. + Experimental · inbound messages will be pushed into this session, this carries prompt injection risks. Restart + Claude Code without {flag} to disable. {unmatched.map(u => ( @@ -131,16 +114,14 @@ export function ChannelsNotice(): React.ReactNode { ))} - ) + ); } function formatEntry(c: ChannelEntry): string { - return c.kind === 'plugin' - ? `plugin:${c.name}@${c.marketplace}` - : `server:${c.name}` + return c.kind === 'plugin' ? `plugin:${c.name}@${c.marketplace}` : `server:${c.name}`; } -type Unmatched = { entry: ChannelEntry; why: string } +type Unmatched = { entry: ChannelEntry; why: string }; function findUnmatched( entries: readonly ChannelEntry[], @@ -149,60 +130,50 @@ function findUnmatched( // Server-kind: build one Set from all scopes up front. getMcpConfigsByScope // is not cached (project scope walks the dir tree); getMcpConfigByName would // redo that walk per entry. - const scopes = ['enterprise', 'user', 'project', 'local'] as const - const configured = new Set() + const scopes = ['enterprise', 'user', 'project', 'local'] as const; + const configured = new Set(); for (const scope of scopes) { for (const name of Object.keys(getMcpConfigsByScope(scope).servers)) { - configured.add(name) + configured.add(name); } } // Plugin-kind installed check: installed_plugins.json keys are // `name@marketplace`. loadInstalledPluginsV2 is cached. - const installedPluginIds = new Set( - Object.keys(loadInstalledPluginsV2().plugins), - ) + const installedPluginIds = new Set(Object.keys(loadInstalledPluginsV2().plugins)); // Plugin-kind allowlist check: same {marketplace, plugin} test as the // gate at channelNotification.ts. entry.dev bypasses (dev flag opts out // of the allowlist). Org list replaces ledger when set (team/enterprise). // GrowthBook _CACHED_MAY_BE_STALE — cold cache yields [] so every plugin // entry warns; same tradeoff the gate already accepts. - const { entries: allowed, source } = allowlist + const { entries: allowed, source } = allowlist; // Independent ifs — a plugin entry that's both uninstalled AND // unlisted shows two lines. Server kind checks config + dev flag. - const out: Unmatched[] = [] + const out: Unmatched[] = []; for (const entry of entries) { if (entry.kind === 'server') { if (!configured.has(entry.name)) { - out.push({ entry, why: 'no MCP server configured with that name' }) + out.push({ entry, why: 'no MCP server configured with that name' }); } if (!entry.dev) { out.push({ entry, why: 'server: entries need --dangerously-load-development-channels', - }) + }); } - continue + continue; } if (!installedPluginIds.has(`${entry.name}@${entry.marketplace}`)) { - out.push({ entry, why: 'plugin not installed' }) + out.push({ entry, why: 'plugin not installed' }); } - if ( - !entry.dev && - !allowed.some( - e => e.plugin === entry.name && e.marketplace === entry.marketplace, - ) - ) { + if (!entry.dev && !allowed.some(e => e.plugin === entry.name && e.marketplace === entry.marketplace)) { out.push({ entry, - why: - source === 'org' - ? "not on your org's approved channels list" - : 'not on the approved channels allowlist', - }) + why: source === 'org' ? "not on your org's approved channels list" : 'not on the approved channels allowlist', + }); } } - return out + return out; } diff --git a/src/components/LogoV2/Clawd.tsx b/src/components/LogoV2/Clawd.tsx index 6969466bc..55bf67cb6 100644 --- a/src/components/LogoV2/Clawd.tsx +++ b/src/components/LogoV2/Clawd.tsx @@ -1,16 +1,16 @@ -import * as React from 'react' -import { Box, Text } from '@anthropic/ink' -import { env } from '../../utils/env.js' +import * as React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { env } from '../../utils/env.js'; export type ClawdPose = | 'default' | 'arms-up' // both arms raised (used during jump) | 'look-left' // both pupils shifted left - | 'look-right' // both pupils shifted right + | 'look-right'; // both pupils shifted right type Props = { - pose?: ClawdPose -} + pose?: ClawdPose; +}; // Standard-terminal pose fragments. Each row is split into segments so we can // vary only the parts that change (eyes, arms) while keeping the body/bg spans @@ -23,23 +23,23 @@ type Props = { // default (▛/▜, bottom pupils) — otherwise only one eye would appear to move. type Segments = { /** row 1 left (no bg): optional raised arm + side */ - r1L: string + r1L: string; /** row 1 eyes (with bg): left-eye, forehead, right-eye */ - r1E: string + r1E: string; /** row 1 right (no bg): side + optional raised arm */ - r1R: string + r1R: string; /** row 2 left (no bg): arm + body curve */ - r2L: string + r2L: string; /** row 2 right (no bg): body curve + arm */ - r2R: string -} + r2R: string; +}; const POSES: Record = { default: { r1L: ' ▐', r1E: '▛███▜', r1R: '▌', r2L: '▝▜', r2R: '▛▘' }, 'look-left': { r1L: ' ▐', r1E: '▟███▟', r1R: '▌', r2L: '▝▜', r2R: '▛▘' }, 'look-right': { r1L: ' ▐', r1E: '▙███▙', r1R: '▌', r2L: '▝▜', r2R: '▛▘' }, 'arms-up': { r1L: '▗▟', r1E: '▛███▜', r1R: '▙▖', r2L: ' ▜', r2R: '▛ ' }, -} +}; // Apple Terminal uses a bg-fill trick (see below), so only eye poses make // sense. Arm poses fall back to default. @@ -48,13 +48,13 @@ const APPLE_EYES: Record = { 'look-left': ' ▘ ▘ ', 'look-right': ' ▝ ▝ ', 'arms-up': ' ▗ ▖ ', -} +}; export function Clawd({ pose = 'default' }: Props = {}): React.ReactNode { if (env.terminal === 'Apple_Terminal') { - return + return ; } - const p = POSES[pose] + const p = POSES[pose]; return ( @@ -75,7 +75,7 @@ export function Clawd({ pose = 'default' }: Props = {}): React.ReactNode { {' '}▘▘ ▝▝{' '} - ) + ); } function AppleTerminalClawd({ pose }: { pose: ClawdPose }): React.ReactNode { @@ -94,5 +94,5 @@ function AppleTerminalClawd({ pose }: { pose: ClawdPose }): React.ReactNode { {' '.repeat(7)} ▘▘ ▝▝ - ) + ); } diff --git a/src/components/LogoV2/CondensedLogo.tsx b/src/components/LogoV2/CondensedLogo.tsx index eb048ec2d..50ffb18e2 100644 --- a/src/components/LogoV2/CondensedLogo.tsx +++ b/src/components/LogoV2/CondensedLogo.tsx @@ -1,83 +1,71 @@ -import * as React from 'react' -import { type ReactNode, useEffect } from 'react' -import { useMainLoopModel } from '../../hooks/useMainLoopModel.js' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' -import { Box, Text, stringWidth } from '@anthropic/ink' -import { useAppState } from '../../state/AppState.js' -import { getEffortSuffix } from '../../utils/effort.js' -import { truncate } from '../../utils/format.js' -import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js' -import { - formatModelAndBilling, - getLogoDisplayData, - truncatePath, -} from '../../utils/logoV2Utils.js' -import { renderModelSetting } from '../../utils/model/model.js' -import { OffscreenFreeze } from '../OffscreenFreeze.js' -import { AnimatedClawd } from './AnimatedClawd.js' -import { Clawd } from './Clawd.js' -import { - GuestPassesUpsell, - incrementGuestPassesSeenCount, - useShowGuestPassesUpsell, -} from './GuestPassesUpsell.js' +import * as React from 'react'; +import { type ReactNode, useEffect } from 'react'; +import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import { Box, Text, stringWidth } from '@anthropic/ink'; +import { useAppState } from '../../state/AppState.js'; +import { getEffortSuffix } from '../../utils/effort.js'; +import { truncate } from '../../utils/format.js'; +import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'; +import { formatModelAndBilling, getLogoDisplayData, truncatePath } from '../../utils/logoV2Utils.js'; +import { renderModelSetting } from '../../utils/model/model.js'; +import { OffscreenFreeze } from '../OffscreenFreeze.js'; +import { AnimatedClawd } from './AnimatedClawd.js'; +import { Clawd } from './Clawd.js'; +import { GuestPassesUpsell, incrementGuestPassesSeenCount, useShowGuestPassesUpsell } from './GuestPassesUpsell.js'; import { incrementOverageCreditUpsellSeenCount, OverageCreditUpsell, useShowOverageCreditUpsell, -} from './OverageCreditUpsell.js' +} from './OverageCreditUpsell.js'; export function CondensedLogo(): ReactNode { - const { columns } = useTerminalSize() - const agent = useAppState(s => s.agent) - const effortValue = useAppState(s => s.effortValue) - const model = useMainLoopModel() - const modelDisplayName = renderModelSetting(model) - const { version, cwd, billingType, agentName: agentNameFromSettings } = getLogoDisplayData() + const { columns } = useTerminalSize(); + const agent = useAppState(s => s.agent); + const effortValue = useAppState(s => s.effortValue); + const model = useMainLoopModel(); + const modelDisplayName = renderModelSetting(model); + const { version, cwd, billingType, agentName: agentNameFromSettings } = getLogoDisplayData(); // Prefer AppState.agent (set from --agent CLI flag) over settings - const agentName = agent ?? agentNameFromSettings - const showGuestPassesUpsell = useShowGuestPassesUpsell() - const showOverageCreditUpsell = useShowOverageCreditUpsell() + const agentName = agent ?? agentNameFromSettings; + const showGuestPassesUpsell = useShowGuestPassesUpsell(); + const showOverageCreditUpsell = useShowOverageCreditUpsell(); useEffect(() => { if (showGuestPassesUpsell) { - incrementGuestPassesSeenCount() + incrementGuestPassesSeenCount(); } - }, [showGuestPassesUpsell]) + }, [showGuestPassesUpsell]); useEffect(() => { if (showOverageCreditUpsell && !showGuestPassesUpsell) { - incrementOverageCreditUpsellSeenCount() + incrementOverageCreditUpsellSeenCount(); } - }, [showOverageCreditUpsell, showGuestPassesUpsell]) + }, [showOverageCreditUpsell, showGuestPassesUpsell]); // Calculate available width for text content // Account for: condensed clawd width (11 chars) + gap (2) + padding (2) = 15 chars - const textWidth = Math.max(columns - 15, 20) + const textWidth = Math.max(columns - 15, 20); // Truncate version to fit within available width, accounting for "Claude Code v" prefix - const versionPrefix = 'Claude Code v' - const truncatedVersion = truncate( - version, - Math.max(textWidth - versionPrefix.length, 6), - ) + const versionPrefix = 'Claude Code v'; + const truncatedVersion = truncate(version, Math.max(textWidth - versionPrefix.length, 6)); - const effortSuffix = getEffortSuffix(model, effortValue) - const { shouldSplit, truncatedModel, truncatedBilling } = - formatModelAndBilling( - modelDisplayName + effortSuffix, - billingType, - textWidth, - ) + const effortSuffix = getEffortSuffix(model, effortValue); + const { shouldSplit, truncatedModel, truncatedBilling } = formatModelAndBilling( + modelDisplayName + effortSuffix, + billingType, + textWidth, + ); // Truncate path, accounting for agent name if present - const separator = ' · ' - const atPrefix = '@' + const separator = ' · '; + const atPrefix = '@'; const cwdAvailableWidth = agentName ? textWidth - atPrefix.length - stringWidth(agentName) - separator.length - : textWidth - const truncatedCwd = truncatePath(cwd, Math.max(cwdAvailableWidth, 10)) + : textWidth; + const truncatedCwd = truncatePath(cwd, Math.max(cwdAvailableWidth, 10)); // OffscreenFreeze: the logo sits at the top of the message list and is the // first thing to enter scrollback. useMainLoopModel() subscribes to model @@ -86,33 +74,28 @@ export function CondensedLogo(): ReactNode { return ( - {isFullscreenEnvEnabled() ? : } + {isFullscreenEnvEnabled() ? : } - {/* Info */} - - - Claude Code{' '} - v{truncatedVersion} - - {shouldSplit ? ( - <> - {truncatedModel} - {truncatedBilling} - - ) : ( - - {truncatedModel} · {truncatedBilling} + {/* Info */} + + + Claude Code v{truncatedVersion} - )} - - {agentName ? `@${agentName} · ${truncatedCwd}` : truncatedCwd} - - {showGuestPassesUpsell && } - {!showGuestPassesUpsell && showOverageCreditUpsell && ( - - )} - + {shouldSplit ? ( + <> + {truncatedModel} + {truncatedBilling} + + ) : ( + + {truncatedModel} · {truncatedBilling} + + )} + {agentName ? `@${agentName} · ${truncatedCwd}` : truncatedCwd} + {showGuestPassesUpsell && } + {!showGuestPassesUpsell && showOverageCreditUpsell && } + - ) + ); } diff --git a/src/components/LogoV2/EmergencyTip.tsx b/src/components/LogoV2/EmergencyTip.tsx index 33280bcf7..52a373e71 100644 --- a/src/components/LogoV2/EmergencyTip.tsx +++ b/src/components/LogoV2/EmergencyTip.tsx @@ -1,34 +1,31 @@ -import * as React from 'react' -import { useEffect, useMemo } from 'react' -import { Box, Text } from '@anthropic/ink' -import { getDynamicConfig_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js' -import { getGlobalConfig, saveGlobalConfig } from 'src/utils/config.js' +import * as React from 'react'; +import { useEffect, useMemo } from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { getDynamicConfig_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'; +import { getGlobalConfig, saveGlobalConfig } from 'src/utils/config.js'; -const CONFIG_NAME = 'tengu-top-of-feed-tip' +const CONFIG_NAME = 'tengu-top-of-feed-tip'; export function EmergencyTip(): React.ReactNode { - const tip = useMemo(getTipOfFeed, []) + const tip = useMemo(getTipOfFeed, []); // Memoize to prevent re-reads after we save - we want the value at mount time - const lastShownTip = useMemo( - () => getGlobalConfig().lastShownEmergencyTip, - [], - ) + const lastShownTip = useMemo(() => getGlobalConfig().lastShownEmergencyTip, []); // Only show if this is a new/different tip - const shouldShow = tip.tip && tip.tip !== lastShownTip + const shouldShow = tip.tip && tip.tip !== lastShownTip; // Save the tip we're showing so we don't show it again useEffect(() => { if (shouldShow) { saveGlobalConfig(current => { - if (current.lastShownEmergencyTip === tip.tip) return current - return { ...current, lastShownEmergencyTip: tip.tip } - }) + if (current.lastShownEmergencyTip === tip.tip) return current; + return { ...current, lastShownEmergencyTip: tip.tip }; + }); } - }, [shouldShow, tip.tip]) + }, [shouldShow, tip.tip]); if (!shouldShow) { - return null + return null; } return ( @@ -43,23 +40,20 @@ export function EmergencyTip(): React.ReactNode { {tip.tip} - ) + ); } type TipOfFeed = { - tip: string - color?: 'dim' | 'warning' | 'error' -} + tip: string; + color?: 'dim' | 'warning' | 'error'; +}; -const DEFAULT_TIP: TipOfFeed = { tip: '', color: 'dim' } +const DEFAULT_TIP: TipOfFeed = { tip: '', color: 'dim' }; /** * Get the tip of the feed from dynamic config with caching * Returns cached value immediately, updates in background */ function getTipOfFeed(): TipOfFeed { - return getDynamicConfig_CACHED_MAY_BE_STALE( - CONFIG_NAME, - DEFAULT_TIP, - ) + return getDynamicConfig_CACHED_MAY_BE_STALE(CONFIG_NAME, DEFAULT_TIP); } diff --git a/src/components/LogoV2/ExperimentEnrollmentNotice.tsx b/src/components/LogoV2/ExperimentEnrollmentNotice.tsx index 6210eb20c..d2a41459e 100644 --- a/src/components/LogoV2/ExperimentEnrollmentNotice.tsx +++ b/src/components/LogoV2/ExperimentEnrollmentNotice.tsx @@ -1,9 +1,9 @@ -import * as React from 'react' +import * as React from 'react'; /** * Internal-only component. Shows experiment enrollment status for internal * users. Stubbed — returns null in non-internal builds. */ export function ExperimentEnrollmentNotice(): React.ReactNode { - return null + return null; } diff --git a/src/components/LogoV2/Feed.tsx b/src/components/LogoV2/Feed.tsx index be849bfd7..06fe49031 100644 --- a/src/components/LogoV2/Feed.tsx +++ b/src/components/LogoV2/Feed.tsx @@ -1,65 +1,57 @@ -import * as React from 'react' -import { Box, Text, stringWidth } from '@anthropic/ink' -import { truncate } from '../../utils/format.js' +import * as React from 'react'; +import { Box, Text, stringWidth } from '@anthropic/ink'; +import { truncate } from '../../utils/format.js'; export type FeedLine = { - text: string - timestamp?: string -} + text: string; + timestamp?: string; +}; export type FeedConfig = { - title: string - lines: FeedLine[] - footer?: string - emptyMessage?: string - customContent?: { content: React.ReactNode; width: number } -} + title: string; + lines: FeedLine[]; + footer?: string; + emptyMessage?: string; + customContent?: { content: React.ReactNode; width: number }; +}; type FeedProps = { - config: FeedConfig - actualWidth: number -} + config: FeedConfig; + actualWidth: number; +}; export function calculateFeedWidth(config: FeedConfig): number { - const { title, lines, footer, emptyMessage, customContent } = config + const { title, lines, footer, emptyMessage, customContent } = config; - let maxWidth = stringWidth(title) + let maxWidth = stringWidth(title); if (customContent !== undefined) { - maxWidth = Math.max(maxWidth, customContent.width) + maxWidth = Math.max(maxWidth, customContent.width); } else if (lines.length === 0 && emptyMessage) { - maxWidth = Math.max(maxWidth, stringWidth(emptyMessage)) + maxWidth = Math.max(maxWidth, stringWidth(emptyMessage)); } else { - const gap = ' ' - const maxTimestampWidth = Math.max( - 0, - ...lines.map(line => (line.timestamp ? stringWidth(line.timestamp) : 0)), - ) + const gap = ' '; + const maxTimestampWidth = Math.max(0, ...lines.map(line => (line.timestamp ? stringWidth(line.timestamp) : 0))); for (const line of lines) { - const timestampWidth = maxTimestampWidth > 0 ? maxTimestampWidth : 0 - const lineWidth = - stringWidth(line.text) + - (timestampWidth > 0 ? timestampWidth + gap.length : 0) - maxWidth = Math.max(maxWidth, lineWidth) + const timestampWidth = maxTimestampWidth > 0 ? maxTimestampWidth : 0; + const lineWidth = stringWidth(line.text) + (timestampWidth > 0 ? timestampWidth + gap.length : 0); + maxWidth = Math.max(maxWidth, lineWidth); } } if (footer) { - maxWidth = Math.max(maxWidth, stringWidth(footer)) + maxWidth = Math.max(maxWidth, stringWidth(footer)); } - return maxWidth + return maxWidth; } export function Feed({ config, actualWidth }: FeedProps): React.ReactNode { - const { title, lines, footer, emptyMessage, customContent } = config + const { title, lines, footer, emptyMessage, customContent } = config; - const gap = ' ' - const maxTimestampWidth = Math.max( - 0, - ...lines.map(line => (line.timestamp ? stringWidth(line.timestamp) : 0)), - ) + const gap = ' '; + const maxTimestampWidth = Math.max(0, ...lines.map(line => (line.timestamp ? stringWidth(line.timestamp) : 0))); return ( @@ -80,25 +72,19 @@ export function Feed({ config, actualWidth }: FeedProps): React.ReactNode { ) : ( <> {lines.map((line, index) => { - const textWidth = Math.max( - 10, - actualWidth - - (maxTimestampWidth > 0 ? maxTimestampWidth + gap.length : 0), - ) + const textWidth = Math.max(10, actualWidth - (maxTimestampWidth > 0 ? maxTimestampWidth + gap.length : 0)); return ( {maxTimestampWidth > 0 && ( <> - - {(line.timestamp || '').padEnd(maxTimestampWidth)} - + {(line.timestamp || '').padEnd(maxTimestampWidth)} {gap} )} {truncate(line.text, textWidth)} - ) + ); })} {footer && ( @@ -108,5 +94,5 @@ export function Feed({ config, actualWidth }: FeedProps): React.ReactNode { )} - ) + ); } diff --git a/src/components/LogoV2/FeedColumn.tsx b/src/components/LogoV2/FeedColumn.tsx index 4c6ae84f5..9fc36b82f 100644 --- a/src/components/LogoV2/FeedColumn.tsx +++ b/src/components/LogoV2/FeedColumn.tsx @@ -1,32 +1,27 @@ -import * as React from 'react' -import { Box } from '@anthropic/ink' -import { Divider } from '@anthropic/ink' -import type { FeedConfig } from './Feed.js' -import { calculateFeedWidth, Feed } from './Feed.js' +import * as React from 'react'; +import { Box } from '@anthropic/ink'; +import { Divider } from '@anthropic/ink'; +import type { FeedConfig } from './Feed.js'; +import { calculateFeedWidth, Feed } from './Feed.js'; type FeedColumnProps = { - feeds: FeedConfig[] - maxWidth: number -} + feeds: FeedConfig[]; + maxWidth: number; +}; -export function FeedColumn({ - feeds, - maxWidth, -}: FeedColumnProps): React.ReactNode { - const feedWidths = feeds.map(feed => calculateFeedWidth(feed)) - const maxOfAllFeeds = Math.max(...feedWidths) - const actualWidth = Math.min(maxOfAllFeeds, maxWidth) +export function FeedColumn({ feeds, maxWidth }: FeedColumnProps): React.ReactNode { + const feedWidths = feeds.map(feed => calculateFeedWidth(feed)); + const maxOfAllFeeds = Math.max(...feedWidths); + const actualWidth = Math.min(maxOfAllFeeds, maxWidth); return ( {feeds.map((feed, index) => ( - {index < feeds.length - 1 && ( - - )} + {index < feeds.length - 1 && } ))} - ) + ); } diff --git a/src/components/LogoV2/GateOverridesWarning.tsx b/src/components/LogoV2/GateOverridesWarning.tsx index 32325eefa..603ca2ce9 100644 --- a/src/components/LogoV2/GateOverridesWarning.tsx +++ b/src/components/LogoV2/GateOverridesWarning.tsx @@ -1,4 +1,4 @@ -import * as React from 'react' +import * as React from 'react'; /** * Internal-only component. Displays a warning when feature-gate overrides @@ -6,5 +6,5 @@ import * as React from 'react' * non-internal builds. */ export function GateOverridesWarning(): React.ReactNode { - return null + return null; } diff --git a/src/components/LogoV2/GuestPassesUpsell.tsx b/src/components/LogoV2/GuestPassesUpsell.tsx index c1bae01b4..4f98c06b1 100644 --- a/src/components/LogoV2/GuestPassesUpsell.tsx +++ b/src/components/LogoV2/GuestPassesUpsell.tsx @@ -1,73 +1,72 @@ -import * as React from 'react' -import { useState } from 'react' -import { Text } from '@anthropic/ink' -import { logEvent } from '../../services/analytics/index.js' +import * as React from 'react'; +import { useState } from 'react'; +import { Text } from '@anthropic/ink'; +import { logEvent } from '../../services/analytics/index.js'; import { checkCachedPassesEligibility, formatCreditAmount, getCachedReferrerReward, getCachedRemainingPasses, -} from '../../services/api/referral.js' -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' +} from '../../services/api/referral.js'; +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; function resetIfPassesRefreshed(): void { - const remaining = getCachedRemainingPasses() - if (remaining == null || remaining <= 0) return - const config = getGlobalConfig() - const lastSeen = config.passesLastSeenRemaining ?? 0 + const remaining = getCachedRemainingPasses(); + if (remaining == null || remaining <= 0) return; + const config = getGlobalConfig(); + const lastSeen = config.passesLastSeenRemaining ?? 0; if (remaining > lastSeen) { saveGlobalConfig(prev => ({ ...prev, passesUpsellSeenCount: 0, hasVisitedPasses: false, passesLastSeenRemaining: remaining, - })) + })); } } function shouldShowGuestPassesUpsell(): boolean { - const { eligible, hasCache } = checkCachedPassesEligibility() + const { eligible, hasCache } = checkCachedPassesEligibility(); // Only show if eligible and cache exists (don't block on fetch) - if (!eligible || !hasCache) return false + if (!eligible || !hasCache) return false; // Reset upsell counters if passes were refreshed (covers both campaign change and pass refresh) - resetIfPassesRefreshed() + resetIfPassesRefreshed(); - const config = getGlobalConfig() - if ((config.passesUpsellSeenCount ?? 0) >= 3) return false - if (config.hasVisitedPasses) return false + const config = getGlobalConfig(); + if ((config.passesUpsellSeenCount ?? 0) >= 3) return false; + if (config.hasVisitedPasses) return false; - return true + return true; } export function useShowGuestPassesUpsell(): boolean { - const [show] = useState(() => shouldShowGuestPassesUpsell()) - return show + const [show] = useState(() => shouldShowGuestPassesUpsell()); + return show; } export function incrementGuestPassesSeenCount(): void { - let newCount = 0 + let newCount = 0; saveGlobalConfig(prev => { - newCount = (prev.passesUpsellSeenCount ?? 0) + 1 + newCount = (prev.passesUpsellSeenCount ?? 0) + 1; return { ...prev, passesUpsellSeenCount: newCount, - } - }) + }; + }); logEvent('tengu_guest_passes_upsell_shown', { seen_count: newCount, - }) + }); } // Condensed layout for mini welcome screen export function GuestPassesUpsell(): React.ReactNode { - const reward = getCachedReferrerReward() + const reward = getCachedReferrerReward(); return ( - [✻] [✻]{' '} - [✻] ·{' '} + [✻] [✻] [✻] ·{' '} {reward ? `Share Claude Code and earn ${formatCreditAmount(reward)} of extra usage · /passes` : '3 guest passes at /passes'} - ) + ); } diff --git a/src/components/LogoV2/LogoV2.tsx b/src/components/LogoV2/LogoV2.tsx index c7dcf4139..f0a9db030 100644 --- a/src/components/LogoV2/LogoV2.tsx +++ b/src/components/LogoV2/LogoV2.tsx @@ -1,7 +1,7 @@ // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered -import * as React from 'react' -import { Box, Text, color, stringWidth } from '@anthropic/ink' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import * as React from 'react'; +import { Box, Text, color, stringWidth } from '@anthropic/ink'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; import { getLayoutMode, calculateLayoutDimensions, @@ -11,46 +11,39 @@ import { getRecentActivitySync, getRecentReleaseNotesSync, getLogoDisplayData, -} from '../../utils/logoV2Utils.js' -import { truncate } from '../../utils/format.js' -import { getDisplayPath } from '../../utils/file.js' -import { Clawd } from './Clawd.js' -import { FeedColumn } from './FeedColumn.js' +} from '../../utils/logoV2Utils.js'; +import { truncate } from '../../utils/format.js'; +import { getDisplayPath } from '../../utils/file.js'; +import { Clawd } from './Clawd.js'; +import { FeedColumn } from './FeedColumn.js'; import { createRecentActivityFeed, createWhatsNewFeed, createProjectOnboardingFeed, createGuestPassesFeed, -} from './feedConfigs.js' -import { getGlobalConfig, saveGlobalConfig } from 'src/utils/config.js' -import { resolveThemeSetting } from 'src/utils/systemTheme.js' -import { getInitialSettings } from 'src/utils/settings/settings.js' -import { - isDebugMode, - isDebugToStdErr, - getDebugLogPath, -} from 'src/utils/debug.js' -import { useEffect, useState } from 'react' +} from './feedConfigs.js'; +import { getGlobalConfig, saveGlobalConfig } from 'src/utils/config.js'; +import { resolveThemeSetting } from 'src/utils/systemTheme.js'; +import { getInitialSettings } from 'src/utils/settings/settings.js'; +import { isDebugMode, isDebugToStdErr, getDebugLogPath } from 'src/utils/debug.js'; +import { useEffect, useState } from 'react'; import { getSteps, shouldShowProjectOnboarding, incrementProjectOnboardingSeenCount, -} from '../../projectOnboardingState.js' -import { CondensedLogo } from './CondensedLogo.js' -import { OffscreenFreeze } from '../OffscreenFreeze.js' -import { checkForReleaseNotesSync } from '../../utils/releaseNotes.js' -import { getDumpPromptsPath } from 'src/services/api/dumpPrompts.js' -import { isEnvTruthy } from 'src/utils/envUtils.js' -import { - getStartupPerfLogPath, - isDetailedProfilingEnabled, -} from 'src/utils/startupProfiler.js' -import { EmergencyTip } from './EmergencyTip.js' -import { VoiceModeNotice } from './VoiceModeNotice.js' -import { Opus1mMergeNotice } from './Opus1mMergeNotice.js' -import { GateOverridesWarning } from './GateOverridesWarning.js' -import { ExperimentEnrollmentNotice } from './ExperimentEnrollmentNotice.js' -import { feature } from 'bun:bundle' +} from '../../projectOnboardingState.js'; +import { CondensedLogo } from './CondensedLogo.js'; +import { OffscreenFreeze } from '../OffscreenFreeze.js'; +import { checkForReleaseNotesSync } from '../../utils/releaseNotes.js'; +import { getDumpPromptsPath } from 'src/services/api/dumpPrompts.js'; +import { isEnvTruthy } from 'src/utils/envUtils.js'; +import { getStartupPerfLogPath, isDetailedProfilingEnabled } from 'src/utils/startupProfiler.js'; +import { EmergencyTip } from './EmergencyTip.js'; +import { VoiceModeNotice } from './VoiceModeNotice.js'; +import { Opus1mMergeNotice } from './Opus1mMergeNotice.js'; +import { GateOverridesWarning } from './GateOverridesWarning.js'; +import { ExperimentEnrollmentNotice } from './ExperimentEnrollmentNotice.js'; +import { feature } from 'bun:bundle'; // Conditional require so ChannelsNotice.tsx tree-shakes when both flags are // false. A module-scope helper component inside a feature() ternary does NOT @@ -61,128 +54,98 @@ import { feature } from 'bun:bundle' const ChannelsNoticeModule = feature('KAIROS') || feature('KAIROS_CHANNELS') ? (require('./ChannelsNotice.js') as typeof import('./ChannelsNotice.js')) - : null + : null; /* eslint-enable @typescript-eslint/no-require-imports */ -import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js' -import { - useShowGuestPassesUpsell, - incrementGuestPassesSeenCount, -} from './GuestPassesUpsell.js' +import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js'; +import { useShowGuestPassesUpsell, incrementGuestPassesSeenCount } from './GuestPassesUpsell.js'; import { useShowOverageCreditUpsell, incrementOverageCreditUpsellSeenCount, createOverageCreditFeed, -} from './OverageCreditUpsell.js' -import { plural } from '../../utils/stringUtils.js' -import { useAppState } from '../../state/AppState.js' -import { getEffortSuffix } from '../../utils/effort.js' -import { useMainLoopModel } from '../../hooks/useMainLoopModel.js' -import { renderModelSetting } from '../../utils/model/model.js' +} from './OverageCreditUpsell.js'; +import { plural } from '../../utils/stringUtils.js'; +import { useAppState } from '../../state/AppState.js'; +import { getEffortSuffix } from '../../utils/effort.js'; +import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'; +import { renderModelSetting } from '../../utils/model/model.js'; -const LEFT_PANEL_MAX_WIDTH = 50 +const LEFT_PANEL_MAX_WIDTH = 50; export function LogoV2(): React.ReactNode { - const activities = getRecentActivitySync() - const username = getGlobalConfig().oauthAccount?.displayName ?? '' + const activities = getRecentActivitySync(); + const username = getGlobalConfig().oauthAccount?.displayName ?? ''; - const { columns } = useTerminalSize() - const showOnboarding = shouldShowProjectOnboarding() - const showSandboxStatus = SandboxManager.isSandboxingEnabled() - const showGuestPassesUpsell = useShowGuestPassesUpsell() - const showOverageCreditUpsell = useShowOverageCreditUpsell() - const agent = useAppState(s => s.agent) - const effortValue = useAppState(s => s.effortValue) + const { columns } = useTerminalSize(); + const showOnboarding = shouldShowProjectOnboarding(); + const showSandboxStatus = SandboxManager.isSandboxingEnabled(); + const showGuestPassesUpsell = useShowGuestPassesUpsell(); + const showOverageCreditUpsell = useShowOverageCreditUpsell(); + const agent = useAppState(s => s.agent); + const effortValue = useAppState(s => s.effortValue); - const config = getGlobalConfig() + const config = getGlobalConfig(); - let changelog: string[] + let changelog: string[]; try { - changelog = getRecentReleaseNotesSync(3) + changelog = getRecentReleaseNotesSync(3); } catch { - changelog = [] + changelog = []; } // Get company announcements and select one: // - First startup (numStartups === 1): show first announcement // - All other startups: randomly select from announcements const [announcement] = useState(() => { - const announcements = getInitialSettings().companyAnnouncements - if (!announcements || announcements.length === 0) return undefined + const announcements = getInitialSettings().companyAnnouncements; + if (!announcements || announcements.length === 0) return undefined; return config.numStartups === 1 ? announcements[0] - : announcements[Math.floor(Math.random() * announcements.length)] - }) - const { hasReleaseNotes } = checkForReleaseNotesSync( - config.lastReleaseNotesSeen, - ) + : announcements[Math.floor(Math.random() * announcements.length)]; + }); + const { hasReleaseNotes } = checkForReleaseNotesSync(config.lastReleaseNotesSeen); useEffect(() => { - const currentConfig = getGlobalConfig() + const currentConfig = getGlobalConfig(); if (currentConfig.lastReleaseNotesSeen === MACRO.VERSION) { - return + return; } saveGlobalConfig(current => { - if (current.lastReleaseNotesSeen === MACRO.VERSION) return current - return { ...current, lastReleaseNotesSeen: MACRO.VERSION } - }) + if (current.lastReleaseNotesSeen === MACRO.VERSION) return current; + return { ...current, lastReleaseNotesSeen: MACRO.VERSION }; + }); if (showOnboarding) { - incrementProjectOnboardingSeenCount() + incrementProjectOnboardingSeenCount(); } - }, [config, showOnboarding]) + }, [config, showOnboarding]); // In condensed mode (early-return below renders ), // CondensedLogo's own useEffect handles the impression count. Skipping // here avoids double-counting since hooks fire before the early return. - const isCondensedMode = - !hasReleaseNotes && - !showOnboarding && - !isEnvTruthy(process.env.CLAUDE_CODE_FORCE_FULL_LOGO) + const isCondensedMode = !hasReleaseNotes && !showOnboarding && !isEnvTruthy(process.env.CLAUDE_CODE_FORCE_FULL_LOGO); useEffect(() => { if (showGuestPassesUpsell && !showOnboarding && !isCondensedMode) { - incrementGuestPassesSeenCount() + incrementGuestPassesSeenCount(); } - }, [showGuestPassesUpsell, showOnboarding, isCondensedMode]) + }, [showGuestPassesUpsell, showOnboarding, isCondensedMode]); useEffect(() => { - if ( - showOverageCreditUpsell && - !showOnboarding && - !showGuestPassesUpsell && - !isCondensedMode - ) { - incrementOverageCreditUpsellSeenCount() + if (showOverageCreditUpsell && !showOnboarding && !showGuestPassesUpsell && !isCondensedMode) { + incrementOverageCreditUpsellSeenCount(); } - }, [ - showOverageCreditUpsell, - showOnboarding, - showGuestPassesUpsell, - isCondensedMode, - ]) + }, [showOverageCreditUpsell, showOnboarding, showGuestPassesUpsell, isCondensedMode]); - const model = useMainLoopModel() - const fullModelDisplayName = renderModelSetting(model) - const { - version, - cwd, - billingType, - agentName: agentNameFromSettings, - } = getLogoDisplayData() + const model = useMainLoopModel(); + const fullModelDisplayName = renderModelSetting(model); + const { version, cwd, billingType, agentName: agentNameFromSettings } = getLogoDisplayData(); // Prefer AppState.agent (set from --agent CLI flag) over settings - const agentName = agent ?? agentNameFromSettings + const agentName = agent ?? agentNameFromSettings; // -20 to account for the max length of subscription name " · Claude Enterprise". - const effortSuffix = getEffortSuffix(model, effortValue) - const modelDisplayName = truncate( - fullModelDisplayName + effortSuffix, - LEFT_PANEL_MAX_WIDTH - 20, - ) + const effortSuffix = getEffortSuffix(model, effortValue); + const modelDisplayName = truncate(fullModelDisplayName + effortSuffix, LEFT_PANEL_MAX_WIDTH - 20); // Show condensed logo if no new changelog and not showing onboarding and not forcing full logo - if ( - !hasReleaseNotes && - !showOnboarding && - !isEnvTruthy(process.env.CLAUDE_CODE_FORCE_FULL_LOGO) - ) { + if (!hasReleaseNotes && !showOnboarding && !isEnvTruthy(process.env.CLAUDE_CODE_FORCE_FULL_LOGO)) { return ( <> @@ -192,17 +155,13 @@ export function LogoV2(): React.ReactNode { {isDebugMode() && ( Debug mode enabled - - Logging to: {isDebugToStdErr() ? 'stderr' : getDebugLogPath()} - + Logging to: {isDebugToStdErr() ? 'stderr' : getDebugLogPath()} )} {process.env.CLAUDE_CODE_TMUX_SESSION && ( - - tmux session: {process.env.CLAUDE_CODE_TMUX_SESSION} - + tmux session: {process.env.CLAUDE_CODE_TMUX_SESSION} {process.env.CLAUDE_CODE_TMUX_PREFIX_CONFLICTS ? `Detach: ${process.env.CLAUDE_CODE_TMUX_PREFIX} ${process.env.CLAUDE_CODE_TMUX_PREFIX} d (press prefix twice - Claude uses ${process.env.CLAUDE_CODE_TMUX_PREFIX})` @@ -213,9 +172,7 @@ export function LogoV2(): React.ReactNode { {announcement && ( {!process.env.IS_DEMO && config.oauthAccount?.organizationName && ( - - Message from {config.oauthAccount.organizationName}: - + Message from {config.oauthAccount.organizationName}: )} {announcement} @@ -228,51 +185,41 @@ export function LogoV2(): React.ReactNode { {process.env.USER_TYPE === 'ant' && !process.env.DEMO_VERSION && ( [ANT-ONLY] Logs: - - API calls: {getDisplayPath(getDumpPromptsPath())} - - - Debug logs: {getDisplayPath(getDebugLogPath())} - + API calls: {getDisplayPath(getDumpPromptsPath())} + Debug logs: {getDisplayPath(getDebugLogPath())} {isDetailedProfilingEnabled() && ( - - Startup Perf: {getDisplayPath(getStartupPerfLogPath())} - + Startup Perf: {getDisplayPath(getStartupPerfLogPath())} )} )} {process.env.USER_TYPE === 'ant' && } {process.env.USER_TYPE === 'ant' && } - ) + ); } // Calculate layout and display values - const layoutMode = getLayoutMode(columns) + const layoutMode = getLayoutMode(columns); - const userTheme = resolveThemeSetting(getGlobalConfig().theme) - const borderTitle = ` ${color('claude', userTheme)('Claude Code')} ${color('inactive', userTheme)(`v${version}`)} ` - const compactBorderTitle = color('claude', userTheme)(' Claude Code ') + const userTheme = resolveThemeSetting(getGlobalConfig().theme); + const borderTitle = ` ${color('claude', userTheme)('Claude Code')} ${color('inactive', userTheme)(`v${version}`)} `; + const compactBorderTitle = color('claude', userTheme)(' Claude Code '); // Early return for compact mode if (layoutMode === 'compact') { - const layoutWidth = 4 // border + padding - let welcomeMessage = formatWelcomeMessage(username) + const layoutWidth = 4; // border + padding + let welcomeMessage = formatWelcomeMessage(username); if (stringWidth(welcomeMessage) > columns - layoutWidth) { - welcomeMessage = formatWelcomeMessage(null) + welcomeMessage = formatWelcomeMessage(null); } // Calculate cwd width accounting for agent name if present - const separator = ' · ' - const atPrefix = '@' + const separator = ' · '; + const atPrefix = '@'; const cwdAvailableWidth = agentName - ? columns - - layoutWidth - - atPrefix.length - - stringWidth(agentName) - - separator.length - : columns - layoutWidth - const truncatedCwd = truncatePath(cwd, Math.max(cwdAvailableWidth, 10)) + ? columns - layoutWidth - atPrefix.length - stringWidth(agentName) - separator.length + : columns - layoutWidth; + const truncatedCwd = truncatePath(cwd, Math.max(cwdAvailableWidth, 10)); // OffscreenFreeze: logo is the first thing to enter scrollback; useMainLoopModel() // subscribes to model changes and getLogoDisplayData() reads cwd/subscription — // any change while in scrollback forces a full reset. @@ -300,9 +247,7 @@ export function LogoV2(): React.ReactNode { {modelDisplayName} {billingType} - - {agentName ? `@${agentName} · ${truncatedCwd}` : truncatedCwd} - + {agentName ? `@${agentName} · ${truncatedCwd}` : truncatedCwd} @@ -310,45 +255,32 @@ export function LogoV2(): React.ReactNode { {ChannelsNoticeModule && } {showSandboxStatus && ( - - Your bash commands will be sandboxed. Disable with /sandbox. - + Your bash commands will be sandboxed. Disable with /sandbox. )} {process.env.USER_TYPE === 'ant' && } {process.env.USER_TYPE === 'ant' && } - ) + ); } - const welcomeMessage = formatWelcomeMessage(username) + const welcomeMessage = formatWelcomeMessage(username); const modelLine = !process.env.IS_DEMO && config.oauthAccount?.organizationName ? `${modelDisplayName} · ${billingType} · ${config.oauthAccount.organizationName}` - : `${modelDisplayName} · ${billingType}` + : `${modelDisplayName} · ${billingType}`; // Calculate cwd width accounting for agent name if present - const cwdSeparator = ' · ' - const cwdAtPrefix = '@' + const cwdSeparator = ' · '; + const cwdAtPrefix = '@'; const cwdAvailableWidth = agentName - ? LEFT_PANEL_MAX_WIDTH - - cwdAtPrefix.length - - stringWidth(agentName) - - cwdSeparator.length - : LEFT_PANEL_MAX_WIDTH - const truncatedCwd = truncatePath(cwd, Math.max(cwdAvailableWidth, 10)) - const cwdLine = agentName ? `@${agentName} · ${truncatedCwd}` : truncatedCwd - const optimalLeftWidth = calculateOptimalLeftWidth( - welcomeMessage, - cwdLine, - modelLine, - ) + ? LEFT_PANEL_MAX_WIDTH - cwdAtPrefix.length - stringWidth(agentName) - cwdSeparator.length + : LEFT_PANEL_MAX_WIDTH; + const truncatedCwd = truncatePath(cwd, Math.max(cwdAvailableWidth, 10)); + const cwdLine = agentName ? `@${agentName} · ${truncatedCwd}` : truncatedCwd; + const optimalLeftWidth = calculateOptimalLeftWidth(welcomeMessage, cwdLine, modelLine); // Calculate layout dimensions - const { leftWidth, rightWidth } = calculateLayoutDimensions( - columns, - layoutMode, - optimalLeftWidth, - ) + const { leftWidth, rightWidth } = calculateLayoutDimensions(columns, layoutMode, optimalLeftWidth); return ( <> @@ -365,11 +297,7 @@ export function LogoV2(): React.ReactNode { }} > {/* Main content */} - + {/* Left Panel */} @@ -439,17 +355,13 @@ export function LogoV2(): React.ReactNode { {isDebugMode() && ( Debug mode enabled - - Logging to: {isDebugToStdErr() ? 'stderr' : getDebugLogPath()} - + Logging to: {isDebugToStdErr() ? 'stderr' : getDebugLogPath()} )} {process.env.CLAUDE_CODE_TMUX_SESSION && ( - - tmux session: {process.env.CLAUDE_CODE_TMUX_SESSION} - + tmux session: {process.env.CLAUDE_CODE_TMUX_SESSION} {process.env.CLAUDE_CODE_TMUX_PREFIX_CONFLICTS ? `Detach: ${process.env.CLAUDE_CODE_TMUX_PREFIX} ${process.env.CLAUDE_CODE_TMUX_PREFIX} d (press prefix twice - Claude uses ${process.env.CLAUDE_CODE_TMUX_PREFIX})` @@ -460,18 +372,14 @@ export function LogoV2(): React.ReactNode { {announcement && ( {!process.env.IS_DEMO && config.oauthAccount?.organizationName && ( - - Message from {config.oauthAccount.organizationName}: - + Message from {config.oauthAccount.organizationName}: )} {announcement} )} {showSandboxStatus && ( - - Your bash commands will be sandboxed. Disable with /sandbox. - + Your bash commands will be sandboxed. Disable with /sandbox. )} {process.env.USER_TYPE === 'ant' && !process.env.DEMO_VERSION && ( @@ -482,20 +390,15 @@ export function LogoV2(): React.ReactNode { {process.env.USER_TYPE === 'ant' && !process.env.DEMO_VERSION && ( [ANT-ONLY] Logs: - - API calls: {getDisplayPath(getDumpPromptsPath())} - + API calls: {getDisplayPath(getDumpPromptsPath())} Debug logs: {getDisplayPath(getDebugLogPath())} {isDetailedProfilingEnabled() && ( - - Startup Perf: {getDisplayPath(getStartupPerfLogPath())} - + Startup Perf: {getDisplayPath(getStartupPerfLogPath())} )} )} {process.env.USER_TYPE === 'ant' && } {process.env.USER_TYPE === 'ant' && } - ) + ); } - diff --git a/src/components/LogoV2/Opus1mMergeNotice.tsx b/src/components/LogoV2/Opus1mMergeNotice.tsx index 9bbf84752..318ab1255 100644 --- a/src/components/LogoV2/Opus1mMergeNotice.tsx +++ b/src/components/LogoV2/Opus1mMergeNotice.tsx @@ -1,41 +1,35 @@ -import * as React from 'react' -import { useEffect, useState } from 'react' -import { UP_ARROW } from '../../constants/figures.js' -import { Box, Text } from '@anthropic/ink' -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' -import { isOpus1mMergeEnabled } from '../../utils/model/model.js' -import { AnimatedAsterisk } from './AnimatedAsterisk.js' +import * as React from 'react'; +import { useEffect, useState } from 'react'; +import { UP_ARROW } from '../../constants/figures.js'; +import { Box, Text } from '@anthropic/ink'; +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; +import { isOpus1mMergeEnabled } from '../../utils/model/model.js'; +import { AnimatedAsterisk } from './AnimatedAsterisk.js'; -const MAX_SHOW_COUNT = 6 +const MAX_SHOW_COUNT = 6; export function shouldShowOpus1mMergeNotice(): boolean { - return ( - isOpus1mMergeEnabled() && - (getGlobalConfig().opus1mMergeNoticeSeenCount ?? 0) < MAX_SHOW_COUNT - ) + return isOpus1mMergeEnabled() && (getGlobalConfig().opus1mMergeNoticeSeenCount ?? 0) < MAX_SHOW_COUNT; } export function Opus1mMergeNotice(): React.ReactNode { - const [show] = useState(shouldShowOpus1mMergeNotice) + const [show] = useState(shouldShowOpus1mMergeNotice); useEffect(() => { - if (!show) return - const newCount = (getGlobalConfig().opus1mMergeNoticeSeenCount ?? 0) + 1 + if (!show) return; + const newCount = (getGlobalConfig().opus1mMergeNoticeSeenCount ?? 0) + 1; saveGlobalConfig(prev => { - if ((prev.opus1mMergeNoticeSeenCount ?? 0) >= newCount) return prev - return { ...prev, opus1mMergeNoticeSeenCount: newCount } - }) - }, [show]) + if ((prev.opus1mMergeNoticeSeenCount ?? 0) >= newCount) return prev; + return { ...prev, opus1mMergeNoticeSeenCount: newCount }; + }); + }, [show]); - if (!show) return null + if (!show) return null; return ( - - {' '} - Opus now defaults to 1M context · 5x more room, same pricing - + Opus now defaults to 1M context · 5x more room, same pricing - ) + ); } diff --git a/src/components/LogoV2/OverageCreditUpsell.tsx b/src/components/LogoV2/OverageCreditUpsell.tsx index c08140645..194488557 100644 --- a/src/components/LogoV2/OverageCreditUpsell.tsx +++ b/src/components/LogoV2/OverageCreditUpsell.tsx @@ -1,17 +1,17 @@ -import * as React from 'react' -import { useState } from 'react' -import { Text } from '@anthropic/ink' -import { logEvent } from '../../services/analytics/index.js' +import * as React from 'react'; +import { useState } from 'react'; +import { Text } from '@anthropic/ink'; +import { logEvent } from '../../services/analytics/index.js'; import { formatGrantAmount, getCachedOverageCreditGrant, refreshOverageCreditGrantCache, -} from '../../services/api/overageCreditGrant.js' -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' -import { truncate } from '../../utils/format.js' -import type { FeedConfig } from './Feed.js' +} from '../../services/api/overageCreditGrant.js'; +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; +import { truncate } from '../../utils/format.js'; +import type { FeedConfig } from './Feed.js'; -const MAX_IMPRESSIONS = 3 +const MAX_IMPRESSIONS = 3; /** * Whether to show the overage credit upsell on any surface. @@ -29,20 +29,19 @@ const MAX_IMPRESSIONS = 3 * (welcome feed, tips). */ export function isEligibleForOverageCreditGrant(): boolean { - const info = getCachedOverageCreditGrant() - if (!info || !info.available || info.granted) return false - return formatGrantAmount(info) !== null + const info = getCachedOverageCreditGrant(); + if (!info || !info.available || info.granted) return false; + return formatGrantAmount(info) !== null; } export function shouldShowOverageCreditUpsell(): boolean { - if (!isEligibleForOverageCreditGrant()) return false + if (!isEligibleForOverageCreditGrant()) return false; - const config = getGlobalConfig() - if (config.hasVisitedExtraUsage) return false - if ((config.overageCreditUpsellSeenCount ?? 0) >= MAX_IMPRESSIONS) - return false + const config = getGlobalConfig(); + if (config.hasVisitedExtraUsage) return false; + if ((config.overageCreditUpsellSeenCount ?? 0) >= MAX_IMPRESSIONS) return false; - return true + return true; } /** @@ -50,78 +49,71 @@ export function shouldShowOverageCreditUpsell(): boolean { * unconditionally on mount — it no-ops if cache is fresh. */ export function maybeRefreshOverageCreditCache(): void { - if (getCachedOverageCreditGrant() !== null) return - void refreshOverageCreditGrantCache() + if (getCachedOverageCreditGrant() !== null) return; + void refreshOverageCreditGrantCache(); } export function useShowOverageCreditUpsell(): boolean { const [show] = useState(() => { - maybeRefreshOverageCreditCache() - return shouldShowOverageCreditUpsell() - }) - return show + maybeRefreshOverageCreditCache(); + return shouldShowOverageCreditUpsell(); + }); + return show; } export function incrementOverageCreditUpsellSeenCount(): void { - let newCount = 0 + let newCount = 0; saveGlobalConfig(prev => { - newCount = (prev.overageCreditUpsellSeenCount ?? 0) + 1 + newCount = (prev.overageCreditUpsellSeenCount ?? 0) + 1; return { ...prev, overageCreditUpsellSeenCount: newCount, - } - }) - logEvent('tengu_overage_credit_upsell_shown', { seen_count: newCount }) + }; + }); + logEvent('tengu_overage_credit_upsell_shown', { seen_count: newCount }); } // Copy from "OC & Bulk Overages copy" doc (#6 — CLI /usage) function getUsageText(amount: string): string { - return `${amount} in extra usage for third-party apps · /extra-usage` + return `${amount} in extra usage for third-party apps · /extra-usage`; } // Copy from "OC & Bulk Overages copy" doc (#4 — CLI Welcome screen). // Char budgets: title ≤19, subtitle ≤48. -const FEED_SUBTITLE = 'On us. Works on third-party apps · /extra-usage' +const FEED_SUBTITLE = 'On us. Works on third-party apps · /extra-usage'; function getFeedTitle(amount: string): string { - return `${amount} in extra usage` + return `${amount} in extra usage`; } -type Props = { maxWidth?: number; twoLine?: boolean } +type Props = { maxWidth?: number; twoLine?: boolean }; -export function OverageCreditUpsell({ - maxWidth, - twoLine, -}: Props): React.ReactNode { - const info = getCachedOverageCreditGrant() - if (!info) return null - const amount = formatGrantAmount(info) - if (!amount) return null +export function OverageCreditUpsell({ maxWidth, twoLine }: Props): React.ReactNode { + const info = getCachedOverageCreditGrant(); + if (!info) return null; + const amount = formatGrantAmount(info); + if (!amount) return null; if (twoLine) { - const title = getFeedTitle(amount) + const title = getFeedTitle(amount); return ( <> - - {maxWidth ? truncate(title, maxWidth) : title} - - - {maxWidth ? truncate(FEED_SUBTITLE, maxWidth) : FEED_SUBTITLE} - + {maxWidth ? truncate(title, maxWidth) : title} + {maxWidth ? truncate(FEED_SUBTITLE, maxWidth) : FEED_SUBTITLE} - ) + ); } - const text = getUsageText(amount) - const display = maxWidth ? truncate(text, maxWidth) : text - const highlightLen = Math.min(getFeedTitle(amount).length, display.length) + const text = getUsageText(amount); + const display = maxWidth ? truncate(text, maxWidth) : text; + const highlightLen = Math.min(getFeedTitle(amount).length, display.length); return ( {display.slice(0, highlightLen)} {display.slice(highlightLen)} - ) + ); } /** @@ -132,9 +124,9 @@ export function OverageCreditUpsell({ * Char budgets: title ≤19, subtitle ≤48. */ export function createOverageCreditFeed(): FeedConfig { - const info = getCachedOverageCreditGrant() - const amount = info ? formatGrantAmount(info) : null - const title = amount ? getFeedTitle(amount) : 'extra usage credit' + const info = getCachedOverageCreditGrant(); + const amount = info ? formatGrantAmount(info) : null; + const title = amount ? getFeedTitle(amount) : 'extra usage credit'; return { title, lines: [], @@ -142,5 +134,5 @@ export function createOverageCreditFeed(): FeedConfig { content: {FEED_SUBTITLE}, width: Math.max(title.length, FEED_SUBTITLE.length), }, - } + }; } diff --git a/src/components/LogoV2/VoiceModeNotice.tsx b/src/components/LogoV2/VoiceModeNotice.tsx index b8e74e3c6..b2512026d 100644 --- a/src/components/LogoV2/VoiceModeNotice.tsx +++ b/src/components/LogoV2/VoiceModeNotice.tsx @@ -1,19 +1,19 @@ -import { feature } from 'bun:bundle' -import * as React from 'react' -import { useEffect, useState } from 'react' -import { Box, Text } from '@anthropic/ink' -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' -import { getInitialSettings } from '../../utils/settings/settings.js' -import { isVoiceModeEnabled } from '../../voice/voiceModeEnabled.js' -import { AnimatedAsterisk } from './AnimatedAsterisk.js' -import { shouldShowOpus1mMergeNotice } from './Opus1mMergeNotice.js' +import { feature } from 'bun:bundle'; +import * as React from 'react'; +import { useEffect, useState } from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; +import { getInitialSettings } from '../../utils/settings/settings.js'; +import { isVoiceModeEnabled } from '../../voice/voiceModeEnabled.js'; +import { AnimatedAsterisk } from './AnimatedAsterisk.js'; +import { shouldShowOpus1mMergeNotice } from './Opus1mMergeNotice.js'; -const MAX_SHOW_COUNT = 3 +const MAX_SHOW_COUNT = 3; export function VoiceModeNotice(): React.ReactNode { // Positive ternary pattern — see docs/feature-gating.md. // All strings must be inside the guarded branch for dead-code elimination. - return feature('VOICE_MODE') ? : null + return feature('VOICE_MODE') ? : null; } function VoiceModeNoticeInner(): React.ReactNode { @@ -28,24 +28,24 @@ function VoiceModeNoticeInner(): React.ReactNode { getInitialSettings().voiceEnabled !== true && (getGlobalConfig().voiceNoticeSeenCount ?? 0) < MAX_SHOW_COUNT && !shouldShowOpus1mMergeNotice(), - ) + ); useEffect(() => { - if (!show) return + if (!show) return; // Capture outside the updater so StrictMode's second invocation is a no-op. - const newCount = (getGlobalConfig().voiceNoticeSeenCount ?? 0) + 1 + const newCount = (getGlobalConfig().voiceNoticeSeenCount ?? 0) + 1; saveGlobalConfig(prev => { - if ((prev.voiceNoticeSeenCount ?? 0) >= newCount) return prev - return { ...prev, voiceNoticeSeenCount: newCount } - }) - }, [show]) + if ((prev.voiceNoticeSeenCount ?? 0) >= newCount) return prev; + return { ...prev, voiceNoticeSeenCount: newCount }; + }); + }, [show]); - if (!show) return null + if (!show) return null; return ( Voice mode is now available · /voice to enable - ) + ); } diff --git a/src/components/LogoV2/WelcomeV2.tsx b/src/components/LogoV2/WelcomeV2.tsx index ccbbcbf44..85d804cba 100644 --- a/src/components/LogoV2/WelcomeV2.tsx +++ b/src/components/LogoV2/WelcomeV2.tsx @@ -1,17 +1,15 @@ -import React from 'react' -import { Box, Text, useTheme } from '@anthropic/ink' -import { env } from '../../utils/env.js' +import React from 'react'; +import { Box, Text, useTheme } from '@anthropic/ink'; +import { env } from '../../utils/env.js'; -const WELCOME_V2_WIDTH = 58 +const WELCOME_V2_WIDTH = 58; export function WelcomeV2(): React.ReactNode { - const [theme] = useTheme() - const welcomeMessage = 'Welcome to Claude Code' + const [theme] = useTheme(); + const welcomeMessage = 'Welcome to Claude Code'; if (env.terminal === 'Apple_Terminal') { - return ( - - ) + return ; } if (['light', 'light-daltonized', 'light-ansi'].includes(theme)) { @@ -22,30 +20,14 @@ export function WelcomeV2(): React.ReactNode { {welcomeMessage} v{MACRO.VERSION} - - {'…………………………………………………………………………………………………………………………………………………………'} - - - {' '} - - - {' '} - - - {' '} - - - {' ░░░░░░ '} - - - {' ░░░ ░░░░░░░░░░ '} - - - {' ░░░░░░░░░░░░░░░░░░░ '} - - - {' '} - + {'…………………………………………………………………………………………………………………………………………………………'} + {' '} + {' '} + {' '} + {' ░░░░░░ '} + {' ░░░ ░░░░░░░░░░ '} + {' ░░░░░░░░░░░░░░░░░░░ '} + {' '} {' ░░░░'} {' ██ '} @@ -54,9 +36,7 @@ export function WelcomeV2(): React.ReactNode { {' ░░░░░░░░░░'} {' ██▒▒██ '} - - {' ▒▒ ██ ▒'} - + {' ▒▒ ██ ▒'} {' '} █████████ @@ -81,7 +61,7 @@ export function WelcomeV2(): React.ReactNode { - ) + ); } return ( @@ -91,41 +71,21 @@ export function WelcomeV2(): React.ReactNode { {welcomeMessage} v{MACRO.VERSION} - - {'…………………………………………………………………………………………………………………………………………………………'} - - - {' '} - - - {' * █████▓▓░ '} - - - {' * ███▓░ ░░ '} - - - {' ░░░░░░ ███▓░ '} - - - {' ░░░ ░░░░░░░░░░ ███▓░ '} - + {'…………………………………………………………………………………………………………………………………………………………'} + {' '} + {' * █████▓▓░ '} + {' * ███▓░ ░░ '} + {' ░░░░░░ ███▓░ '} + {' ░░░ ░░░░░░░░░░ ███▓░ '} {' ░░░░░░░░░░░░░░░░░░░ '} * {' ██▓░░ ▓ '} - - {' ░▓▓███▓▓░ '} - - - {' * ░░░░ '} - - - {' ░░░░░░░░ '} - - - {' ░░░░░░░░░░░░░░░░ '} - + {' ░▓▓███▓▓░ '} + {' * ░░░░ '} + {' ░░░░░░░░ '} + {' ░░░░░░░░░░░░░░░░ '} {' '} █████████ @@ -152,21 +112,16 @@ export function WelcomeV2(): React.ReactNode { - ) + ); } type AppleTerminalWelcomeV2Props = { - theme: string - welcomeMessage: string -} + theme: string; + welcomeMessage: string; +}; -function AppleTerminalWelcomeV2({ - theme, - welcomeMessage, -}: AppleTerminalWelcomeV2Props): React.ReactNode { - const isLightTheme = ['light', 'light-daltonized', 'light-ansi'].includes( - theme, - ) +function AppleTerminalWelcomeV2({ theme, welcomeMessage }: AppleTerminalWelcomeV2Props): React.ReactNode { + const isLightTheme = ['light', 'light-daltonized', 'light-ansi'].includes(theme); if (isLightTheme) { return ( @@ -176,30 +131,14 @@ function AppleTerminalWelcomeV2({ {welcomeMessage} v{MACRO.VERSION} - - {'…………………………………………………………………………………………………………………………………………………………'} - - - {' '} - - - {' '} - - - {' '} - - - {' ░░░░░░ '} - - - {' ░░░ ░░░░░░░░░░ '} - - - {' ░░░░░░░░░░░░░░░░░░░ '} - - - {' '} - + {'…………………………………………………………………………………………………………………………………………………………'} + {' '} + {' '} + {' '} + {' ░░░░░░ '} + {' ░░░ ░░░░░░░░░░ '} + {' ░░░░░░░░░░░░░░░░░░░ '} + {' '} {' ░░░░'} {' ██ '} @@ -208,12 +147,8 @@ function AppleTerminalWelcomeV2({ {' ░░░░░░░░░░'} {' ██▒▒██ '} - - {' ▒▒ ██ ▒'} - - - {' ▒▒░░▒▒ ▒ ▒▒'} - + {' ▒▒ ██ ▒'} + {' ▒▒░░▒▒ ▒ ▒▒'} {' '} @@ -242,7 +177,7 @@ function AppleTerminalWelcomeV2({ - ) + ); } return ( @@ -252,41 +187,21 @@ function AppleTerminalWelcomeV2({ {welcomeMessage} v{MACRO.VERSION} - - {'…………………………………………………………………………………………………………………………………………………………'} - - - {' '} - - - {' * █████▓▓░ '} - - - {' * ███▓░ ░░ '} - - - {' ░░░░░░ ███▓░ '} - - - {' ░░░ ░░░░░░░░░░ ███▓░ '} - + {'…………………………………………………………………………………………………………………………………………………………'} + {' '} + {' * █████▓▓░ '} + {' * ███▓░ ░░ '} + {' ░░░░░░ ███▓░ '} + {' ░░░ ░░░░░░░░░░ ███▓░ '} {' ░░░░░░░░░░░░░░░░░░░ '} * {' ██▓░░ ▓ '} - - {' ░▓▓███▓▓░ '} - - - {' * ░░░░ '} - - - {' ░░░░░░░░ '} - - - {' ░░░░░░░░░░░░░░░░ '} - + {' ░▓▓███▓▓░ '} + {' * ░░░░ '} + {' ░░░░░░░░ '} + {' ░░░░░░░░░░░░░░░░ '} {' '} * @@ -322,5 +237,5 @@ function AppleTerminalWelcomeV2({ - ) + ); } diff --git a/src/components/LogoV2/feedConfigs.tsx b/src/components/LogoV2/feedConfigs.tsx index 8f6652f3c..9a326bd17 100644 --- a/src/components/LogoV2/feedConfigs.tsx +++ b/src/components/LogoV2/feedConfigs.tsx @@ -1,103 +1,96 @@ -import figures from 'figures' -import { homedir } from 'os' -import * as React from 'react' -import { Box, Text } from '@anthropic/ink' -import type { Step } from '../../projectOnboardingState.js' -import { - formatCreditAmount, - getCachedReferrerReward, -} from '../../services/api/referral.js' -import type { LogOption } from '../../types/logs.js' -import { getCwd } from '../../utils/cwd.js' -import { formatRelativeTimeAgo } from '../../utils/format.js' -import type { FeedConfig, FeedLine } from './Feed.js' +import figures from 'figures'; +import { homedir } from 'os'; +import * as React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import type { Step } from '../../projectOnboardingState.js'; +import { formatCreditAmount, getCachedReferrerReward } from '../../services/api/referral.js'; +import type { LogOption } from '../../types/logs.js'; +import { getCwd } from '../../utils/cwd.js'; +import { formatRelativeTimeAgo } from '../../utils/format.js'; +import type { FeedConfig, FeedLine } from './Feed.js'; export function createRecentActivityFeed(activities: LogOption[]): FeedConfig { const lines: FeedLine[] = activities.map(log => { - const time = formatRelativeTimeAgo(log.modified) - const description = - log.summary && log.summary !== 'No prompt' ? log.summary : log.firstPrompt + const time = formatRelativeTimeAgo(log.modified); + const description = log.summary && log.summary !== 'No prompt' ? log.summary : log.firstPrompt; return { text: description || '', timestamp: time, - } - }) + }; + }); return { title: 'Recent activity', lines, footer: lines.length > 0 ? '/resume for more' : undefined, emptyMessage: 'No recent activity', - } + }; } export function createWhatsNewFeed(releaseNotes: string[]): FeedConfig { const lines: FeedLine[] = releaseNotes.map(note => { if (process.env.USER_TYPE === 'ant') { - const match = note.match(/^(\d+\s+\w+\s+ago)\s+(.+)$/) + const match = note.match(/^(\d+\s+\w+\s+ago)\s+(.+)$/); if (match) { return { timestamp: match[1], text: match[2] || '', - } + }; } } return { text: note, - } - }) + }; + }); const emptyMessage = process.env.USER_TYPE === 'ant' ? 'Unable to fetch latest claude-cli-internal commits' - : 'Check the Claude Code changelog for updates' + : 'Check the Claude Code changelog for updates'; return { - title: - process.env.USER_TYPE === 'ant' - ? "What's new [ANT-ONLY: Latest CC commits]" - : "What's new", + title: process.env.USER_TYPE === 'ant' ? "What's new [ANT-ONLY: Latest CC commits]" : "What's new", lines, footer: lines.length > 0 ? '/release-notes for more' : undefined, emptyMessage, - } + }; } export function createProjectOnboardingFeed(steps: Step[]): FeedConfig { const enabledSteps = steps .filter(({ isEnabled }) => isEnabled) - .sort((a, b) => Number(a.isComplete) - Number(b.isComplete)) + .sort((a, b) => Number(a.isComplete) - Number(b.isComplete)); const lines: FeedLine[] = enabledSteps.map(({ text, isComplete }) => { - const checkmark = isComplete ? `${figures.tick} ` : '' + const checkmark = isComplete ? `${figures.tick} ` : ''; return { text: `${checkmark}${text}`, - } - }) + }; + }); const warningText = getCwd() === homedir() ? 'Note: You have launched claude in your home directory. For the best experience, launch it in a project directory instead.' - : undefined + : undefined; if (warningText) { lines.push({ text: warningText, - }) + }); } return { title: 'Tips for getting started', lines, - } + }; } export function createGuestPassesFeed(): FeedConfig { - const reward = getCachedReferrerReward() + const reward = getCachedReferrerReward(); const subtitle = reward ? `Share Claude Code and earn ${formatCreditAmount(reward)} of extra usage` - : 'Share Claude Code with friends' + : 'Share Claude Code with friends'; return { title: '3 guest passes', lines: [], @@ -113,5 +106,5 @@ export function createGuestPassesFeed(): FeedConfig { width: 48, }, footer: '/passes', - } + }; } diff --git a/src/components/LogoV2/src/ink.ts b/src/components/LogoV2/src/ink.ts index d373e1b93..3b3a26275 100644 --- a/src/components/LogoV2/src/ink.ts +++ b/src/components/LogoV2/src/ink.ts @@ -1,4 +1,4 @@ // Auto-generated type stub — replace with real implementation -export type Box = any; -export type Text = any; -export type useTheme = any; +export type Box = any +export type Text = any +export type useTheme = any diff --git a/src/components/LogoV2/src/services/analytics/growthbook.ts b/src/components/LogoV2/src/services/analytics/growthbook.ts index 7e9a775d0..5416d7f06 100644 --- a/src/components/LogoV2/src/services/analytics/growthbook.ts +++ b/src/components/LogoV2/src/services/analytics/growthbook.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getDynamicConfig_CACHED_MAY_BE_STALE = any; +export type getDynamicConfig_CACHED_MAY_BE_STALE = any diff --git a/src/components/LogoV2/src/services/api/dumpPrompts.ts b/src/components/LogoV2/src/services/api/dumpPrompts.ts index bba0c3d9a..581217586 100644 --- a/src/components/LogoV2/src/services/api/dumpPrompts.ts +++ b/src/components/LogoV2/src/services/api/dumpPrompts.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getDumpPromptsPath = any; +export type getDumpPromptsPath = any diff --git a/src/components/LogoV2/src/utils/config.ts b/src/components/LogoV2/src/utils/config.ts index 7cf15deca..b3f5e0f8c 100644 --- a/src/components/LogoV2/src/utils/config.ts +++ b/src/components/LogoV2/src/utils/config.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type getGlobalConfig = any; -export type saveGlobalConfig = any; +export type getGlobalConfig = any +export type saveGlobalConfig = any diff --git a/src/components/LogoV2/src/utils/debug.ts b/src/components/LogoV2/src/utils/debug.ts index 75bad1b41..7ad69514c 100644 --- a/src/components/LogoV2/src/utils/debug.ts +++ b/src/components/LogoV2/src/utils/debug.ts @@ -1,4 +1,4 @@ // Auto-generated type stub — replace with real implementation -export type isDebugMode = any; -export type isDebugToStdErr = any; -export type getDebugLogPath = any; +export type isDebugMode = any +export type isDebugToStdErr = any +export type getDebugLogPath = any diff --git a/src/components/LogoV2/src/utils/envUtils.ts b/src/components/LogoV2/src/utils/envUtils.ts index deb349096..dfe8dd636 100644 --- a/src/components/LogoV2/src/utils/envUtils.ts +++ b/src/components/LogoV2/src/utils/envUtils.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type isEnvTruthy = any; +export type isEnvTruthy = any diff --git a/src/components/LogoV2/src/utils/sandbox/sandbox-adapter.ts b/src/components/LogoV2/src/utils/sandbox/sandbox-adapter.ts index edebe2640..e9f663b72 100644 --- a/src/components/LogoV2/src/utils/sandbox/sandbox-adapter.ts +++ b/src/components/LogoV2/src/utils/sandbox/sandbox-adapter.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type SandboxManager = any; +export type SandboxManager = any diff --git a/src/components/LogoV2/src/utils/settings/settings.ts b/src/components/LogoV2/src/utils/settings/settings.ts index 7765101ae..324f43196 100644 --- a/src/components/LogoV2/src/utils/settings/settings.ts +++ b/src/components/LogoV2/src/utils/settings/settings.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getInitialSettings = any; +export type getInitialSettings = any diff --git a/src/components/LogoV2/src/utils/startupProfiler.ts b/src/components/LogoV2/src/utils/startupProfiler.ts index 16c5c6e4a..f424889b6 100644 --- a/src/components/LogoV2/src/utils/startupProfiler.ts +++ b/src/components/LogoV2/src/utils/startupProfiler.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type getStartupPerfLogPath = any; -export type isDetailedProfilingEnabled = any; +export type getStartupPerfLogPath = any +export type isDetailedProfilingEnabled = any diff --git a/src/components/LogoV2/src/utils/systemTheme.ts b/src/components/LogoV2/src/utils/systemTheme.ts index c69869078..942450b66 100644 --- a/src/components/LogoV2/src/utils/systemTheme.ts +++ b/src/components/LogoV2/src/utils/systemTheme.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type resolveThemeSetting = any; +export type resolveThemeSetting = any diff --git a/src/components/LspRecommendation/LspRecommendationMenu.tsx b/src/components/LspRecommendation/LspRecommendationMenu.tsx index 3ea815069..579cfa835 100644 --- a/src/components/LspRecommendation/LspRecommendationMenu.tsx +++ b/src/components/LspRecommendation/LspRecommendationMenu.tsx @@ -1,16 +1,16 @@ -import * as React from 'react' -import { Box, Text } from '@anthropic/ink' -import { Select } from '../CustomSelect/select.js' -import { PermissionDialog } from '../permissions/PermissionDialog.js' +import * as React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { Select } from '../CustomSelect/select.js'; +import { PermissionDialog } from '../permissions/PermissionDialog.js'; type Props = { - pluginName: string - pluginDescription?: string - fileExtension: string - onResponse: (response: 'yes' | 'no' | 'never' | 'disable') => void -} + pluginName: string; + pluginDescription?: string; + fileExtension: string; + onResponse: (response: 'yes' | 'no' | 'never' | 'disable') => void; +}; -const AUTO_DISMISS_MS = 30_000 +const AUTO_DISMISS_MS = 30_000; export function LspRecommendationMenu({ pluginName, @@ -19,33 +19,29 @@ export function LspRecommendationMenu({ onResponse, }: Props): React.ReactNode { // Use ref to avoid timer reset when onResponse changes - const onResponseRef = React.useRef(onResponse) - onResponseRef.current = onResponse + const onResponseRef = React.useRef(onResponse); + onResponseRef.current = onResponse; // 30-second auto-dismiss timer - counts as ignored (no) React.useEffect(() => { - const timeoutId = setTimeout( - ref => ref.current('no'), - AUTO_DISMISS_MS, - onResponseRef, - ) - return () => clearTimeout(timeoutId) - }, []) + const timeoutId = setTimeout(ref => ref.current('no'), AUTO_DISMISS_MS, onResponseRef); + return () => clearTimeout(timeoutId); + }, []); function onSelect(value: string): void { switch (value) { case 'yes': - onResponse('yes') - break + onResponse('yes'); + break; case 'no': - onResponse('no') - break + onResponse('no'); + break; case 'never': - onResponse('never') - break + onResponse('never'); + break; case 'disable': - onResponse('disable') - break + onResponse('disable'); + break; } } @@ -74,16 +70,13 @@ export function LspRecommendationMenu({ label: 'Disable all LSP recommendations', value: 'disable', }, - ] + ]; return ( - - LSP provides code intelligence like go-to-definition and error - checking - + LSP provides code intelligence like go-to-definition and error checking Plugin: @@ -102,13 +95,9 @@ export function LspRecommendationMenu({ Would you like to install this LSP plugin? - onResponse('no')} /> - ) + ); } diff --git a/src/components/MCPServerApprovalDialog.tsx b/src/components/MCPServerApprovalDialog.tsx index f80d42008..4f35a929b 100644 --- a/src/components/MCPServerApprovalDialog.tsx +++ b/src/components/MCPServerApprovalDialog.tsx @@ -1,76 +1,65 @@ -import React from 'react' +import React from 'react'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from 'src/services/analytics/index.js' -import { - getSettings_DEPRECATED, - updateSettingsForSource, -} from '../utils/settings/settings.js' -import { Select } from './CustomSelect/index.js' -import { Dialog } from '@anthropic/ink' -import { MCPServerDialogCopy } from './MCPServerDialogCopy.js' +} from 'src/services/analytics/index.js'; +import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js'; +import { Select } from './CustomSelect/index.js'; +import { Dialog } from '@anthropic/ink'; +import { MCPServerDialogCopy } from './MCPServerDialogCopy.js'; type Props = { - serverName: string - onDone(): void -} + serverName: string; + onDone(): void; +}; -export function MCPServerApprovalDialog({ - serverName, - onDone, -}: Props): React.ReactNode { +export function MCPServerApprovalDialog({ serverName, onDone }: Props): React.ReactNode { function onChange(value: 'yes' | 'yes_all' | 'no') { logEvent('tengu_mcp_dialog_choice', { - choice: - value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + choice: value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); switch (value) { case 'yes': case 'yes_all': { // Get current enabled servers from settings - const currentSettings = getSettings_DEPRECATED() || {} - const enabledServers = currentSettings.enabledMcpjsonServers || [] + const currentSettings = getSettings_DEPRECATED() || {}; + const enabledServers = currentSettings.enabledMcpjsonServers || []; // Add server if not already enabled if (!enabledServers.includes(serverName)) { updateSettingsForSource('localSettings', { enabledMcpjsonServers: [...enabledServers, serverName], - }) + }); } if (value === 'yes_all') { updateSettingsForSource('localSettings', { enableAllProjectMcpServers: true, - }) + }); } - onDone() - break + onDone(); + break; } case 'no': { // Get current disabled servers from settings - const currentSettings = getSettings_DEPRECATED() || {} - const disabledServers = currentSettings.disabledMcpjsonServers || [] + const currentSettings = getSettings_DEPRECATED() || {}; + const disabledServers = currentSettings.disabledMcpjsonServers || []; // Add server if not already disabled if (!disabledServers.includes(serverName)) { updateSettingsForSource('localSettings', { disabledMcpjsonServers: [...disabledServers, serverName], - }) + }); } - onDone() - break + onDone(); + break; } } } return ( - onChange('no')} - > + onChange('no')}> - {exitState.pending ? ( - <>Press {exitState.keyName} again to exit - ) : ( - <>Enter to confirm · Esc to exit - )} + {exitState.pending ? <>Press {exitState.keyName} again to exit : <>Enter to confirm · Esc to exit} - ) + ); } diff --git a/src/components/Markdown.tsx b/src/components/Markdown.tsx index 063404baf..0ef2f18db 100644 --- a/src/components/Markdown.tsx +++ b/src/components/Markdown.tsx @@ -1,29 +1,26 @@ -import { marked, type Token, type Tokens } from 'marked' -import React, { Suspense, use, useMemo, useRef } from 'react' -import { useSettings } from '../hooks/useSettings.js' -import { Ansi, Box, useTheme } from '@anthropic/ink' -import { - type CliHighlight, - getCliHighlightPromise, -} from '../utils/cliHighlight.js' -import { hashContent } from '../utils/hash.js' -import { configureMarked, formatToken } from '../utils/markdown.js' -import { stripPromptXMLTags } from '../utils/messages.js' -import { MarkdownTable } from './MarkdownTable.js' +import { marked, type Token, type Tokens } from 'marked'; +import React, { Suspense, use, useMemo, useRef } from 'react'; +import { useSettings } from '../hooks/useSettings.js'; +import { Ansi, Box, useTheme } from '@anthropic/ink'; +import { type CliHighlight, getCliHighlightPromise } from '../utils/cliHighlight.js'; +import { hashContent } from '../utils/hash.js'; +import { configureMarked, formatToken } from '../utils/markdown.js'; +import { stripPromptXMLTags } from '../utils/messages.js'; +import { MarkdownTable } from './MarkdownTable.js'; type Props = { - children: string + children: string; /** When true, render all text content as dim */ - dimColor?: boolean -} + dimColor?: boolean; +}; // Module-level token cache — marked.lexer is the hot cost on virtual-scroll // remounts (~3ms per message). useMemo doesn't survive unmount→remount, so // scrolling back to a previously-visible message re-parses. Messages are // immutable in history; same content → same tokens. Keyed by hash to avoid // retaining full content strings (turn50→turn99 RSS regression, #24180). -const TOKEN_CACHE_MAX = 500 -const tokenCache = new Map() +const TOKEN_CACHE_MAX = 500; +const tokenCache = new Map(); // Characters that indicate markdown syntax. If none are present, skip the // ~3ms marked.lexer call entirely — render as a single paragraph. Covers @@ -31,11 +28,11 @@ const tokenCache = new Map() // plain sentences. Checked via indexOf (not regex) for speed. // Single regex: matches any MD marker or ordered-list start (N. at line start). // One pass instead of 10× includes scans. -const MD_SYNTAX_RE = /[#*`|[>\-_~]|\n\n|^\d+\. |\n\d+\. / +const MD_SYNTAX_RE = /[#*`|[>\-_~]|\n\n|^\d+\. |\n\d+\. /; function hasMarkdownSyntax(s: string): boolean { // Sample first 500 chars — if markdown exists it's usually early (headers, // code fence, list). Long tool outputs are mostly plain text tails. - return MD_SYNTAX_RE.test(s.length > 500 ? s.slice(0, 500) : s) + return MD_SYNTAX_RE.test(s.length > 500 ? s.slice(0, 500) : s); } function cachedLexer(content: string): Token[] { @@ -51,25 +48,25 @@ function cachedLexer(content: string): Token[] { text: content, tokens: [{ type: 'text', raw: content, text: content }], } as Token, - ] + ]; } - const key = hashContent(content) - const hit = tokenCache.get(key) + const key = hashContent(content); + const hit = tokenCache.get(key); if (hit) { // Promote to MRU — without this the eviction is FIFO (scrolling back to // an early message evicts the very item you're looking at). - tokenCache.delete(key) - tokenCache.set(key, hit) - return hit + tokenCache.delete(key); + tokenCache.set(key, hit); + return hit; } - const tokens = marked.lexer(content) + const tokens = marked.lexer(content); if (tokenCache.size >= TOKEN_CACHE_MAX) { // LRU-ish: drop oldest. Map preserves insertion order. - const first = tokenCache.keys().next().value - if (first !== undefined) tokenCache.delete(first) + const first = tokenCache.keys().next().value; + if (first !== undefined) tokenCache.delete(first); } - tokenCache.set(key, tokens) - return tokens + tokenCache.set(key, tokens); + return tokens; } /** @@ -78,9 +75,9 @@ function cachedLexer(content: string): Token[] { * - Other content is rendered as ANSI strings via formatToken */ export function Markdown(props: Props): React.ReactNode { - const settings = useSettings() + const settings = useSettings(); if (settings.syntaxHighlightingDisabled) { - return + return ; } // Suspense fallback renders with highlight=null — plain markdown shows // for ~50ms on first ever render while cli-highlight loads. @@ -88,26 +85,22 @@ export function Markdown(props: Props): React.ReactNode { }> - ) + ); } function MarkdownWithHighlight(props: Props): React.ReactNode { - const highlight = use(getCliHighlightPromise()) - return + const highlight = use(getCliHighlightPromise()); + return ; } -function MarkdownBody({ - children, - dimColor, - highlight, -}: Props & { highlight: CliHighlight | null }): React.ReactNode { - const [theme] = useTheme() - configureMarked() +function MarkdownBody({ children, dimColor, highlight }: Props & { highlight: CliHighlight | null }): React.ReactNode { + const [theme] = useTheme(); + configureMarked(); const elements = useMemo(() => { - const tokens = cachedLexer(stripPromptXMLTags(children)) - const elements: React.ReactNode[] = [] - let nonTableContent = '' + const tokens = cachedLexer(stripPromptXMLTags(children)); + const elements: React.ReactNode[] = []; + let nonTableContent = ''; function flushNonTableContent(): void { if (nonTableContent) { @@ -115,40 +108,34 @@ function MarkdownBody({ {nonTableContent.trim()} , - ) - nonTableContent = '' + ); + nonTableContent = ''; } } for (const token of tokens) { if (token.type === 'table') { - flushNonTableContent() - elements.push( - , - ) + flushNonTableContent(); + elements.push(); } else { - nonTableContent += formatToken(token, theme, 0, null, null, highlight) + nonTableContent += formatToken(token, theme, 0, null, null, highlight); } } - flushNonTableContent() - return elements - }, [children, dimColor, highlight, theme]) + flushNonTableContent(); + return elements; + }, [children, dimColor, highlight, theme]); return ( {elements} - ) + ); } type StreamingProps = { - children: string -} + children: string; +}; /** * Renders markdown during streaming by splitting at the last top-level block @@ -160,49 +147,47 @@ type StreamingProps = { * is idempotent and safe under StrictMode double-rendering. Component unmounts * between turns (streamingText → null), resetting the ref. */ -export function StreamingMarkdown({ - children, -}: StreamingProps): React.ReactNode { +export function StreamingMarkdown({ children }: StreamingProps): React.ReactNode { // React Compiler: this component reads and writes stablePrefixRef.current // during render by design. The boundary only advances (monotonic), so // the ref mutation is idempotent under StrictMode double-render — but the // compiler can't prove that, and memoizing around the ref reads would // break the algorithm (stale boundary). Opt out. - 'use no memo' - configureMarked() + 'use no memo'; + configureMarked(); // Strip before boundary tracking so it matches 's stripping // (line 29). When a closing tag arrives, stripped(N+1) is not a prefix // of stripped(N), but the startsWith reset below handles that with a // one-time re-lex on the smaller stripped string. - const stripped = stripPromptXMLTags(children) + const stripped = stripPromptXMLTags(children); - const stablePrefixRef = useRef('') + const stablePrefixRef = useRef(''); // Reset if text was replaced (defensive; normally unmount handles this) if (!stripped.startsWith(stablePrefixRef.current)) { - stablePrefixRef.current = '' + stablePrefixRef.current = ''; } // Lex only from current boundary — O(unstable length), not O(full text) - const boundary = stablePrefixRef.current.length - const tokens = marked.lexer(stripped.substring(boundary)) + const boundary = stablePrefixRef.current.length; + const tokens = marked.lexer(stripped.substring(boundary)); // Last non-space token is the growing block; everything before is final - let lastContentIdx = tokens.length - 1 + let lastContentIdx = tokens.length - 1; while (lastContentIdx >= 0 && tokens[lastContentIdx]!.type === 'space') { - lastContentIdx-- + lastContentIdx--; } - let advance = 0 + let advance = 0; for (let i = 0; i < lastContentIdx; i++) { - advance += tokens[i]!.raw.length + advance += tokens[i]!.raw.length; } if (advance > 0) { - stablePrefixRef.current = stripped.substring(0, boundary + advance) + stablePrefixRef.current = stripped.substring(0, boundary + advance); } - const stablePrefix = stablePrefixRef.current - const unstableSuffix = stripped.substring(stablePrefix.length) + const stablePrefix = stablePrefixRef.current; + const unstableSuffix = stripped.substring(stablePrefix.length); // stablePrefix is memoized inside via useMemo([children, ...]) // so it never re-parses as the unstable suffix grows @@ -211,5 +196,5 @@ export function StreamingMarkdown({ {stablePrefix && {stablePrefix}} {unstableSuffix && {unstableSuffix}} - ) + ); } diff --git a/src/components/MarkdownTable.tsx b/src/components/MarkdownTable.tsx index 0b3473d43..a6ece910c 100644 --- a/src/components/MarkdownTable.tsx +++ b/src/components/MarkdownTable.tsx @@ -1,37 +1,37 @@ -import type { Token, Tokens } from 'marked' -import React from 'react' -import stripAnsi from 'strip-ansi' -import { useTerminalSize } from '../hooks/useTerminalSize.js' -import { Ansi, stringWidth, useTheme, wrapAnsi } from '@anthropic/ink' -import type { CliHighlight } from '../utils/cliHighlight.js' -import { formatToken, padAligned } from '../utils/markdown.js' +import type { Token, Tokens } from 'marked'; +import React from 'react'; +import stripAnsi from 'strip-ansi'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { Ansi, stringWidth, useTheme, wrapAnsi } from '@anthropic/ink'; +import type { CliHighlight } from '../utils/cliHighlight.js'; +import { formatToken, padAligned } from '../utils/markdown.js'; /** Accounts for parent indentation (e.g. message dot prefix) and terminal * resize races. Without enough margin the table overflows its layout box * and Ink's clip truncates differently on alternating frames, causing an * infinite flicker loop in scrollback. */ -const SAFETY_MARGIN = 4 +const SAFETY_MARGIN = 4; /** Minimum column width to prevent degenerate layouts */ -const MIN_COLUMN_WIDTH = 3 +const MIN_COLUMN_WIDTH = 3; /** * Maximum number of lines per row before switching to vertical format. * When wrapping would make rows taller than this, vertical (key-value) * format provides better readability. */ -const MAX_ROW_LINES = 4 +const MAX_ROW_LINES = 4; /** ANSI escape codes for text formatting */ -const ANSI_BOLD_START = '\x1b[1m' -const ANSI_BOLD_END = '\x1b[22m' +const ANSI_BOLD_START = '\x1b[1m'; +const ANSI_BOLD_END = '\x1b[22m'; type Props = { - token: Tokens.Table - highlight: CliHighlight | null + token: Tokens.Table; + highlight: CliHighlight | null; /** Override terminal width (useful for testing) */ - forceWidth?: number -} + forceWidth?: number; +}; /** * Wrap text to fit within a given width, returning array of lines. @@ -40,26 +40,22 @@ type Props = { * @param hard - If true, break words that exceed width (needed when columns * are narrower than the longest word). Default false. */ -function wrapText( - text: string, - width: number, - options?: { hard?: boolean }, -): string[] { - if (width <= 0) return [text] +function wrapText(text: string, width: number, options?: { hard?: boolean }): string[] { + if (width <= 0) return [text]; // Strip trailing whitespace/newlines before wrapping. // formatToken() adds EOL to paragraphs and other token types, // which would otherwise create extra blank lines in table cells. - const trimmedText = text.trimEnd() + const trimmedText = text.trimEnd(); const wrapped = wrapAnsi(trimmedText, width, { hard: options?.hard ?? false, trim: false, wordWrap: true, - }) + }); // Filter out empty lines that result from trailing newlines or // multiple consecutive newlines in the source content. - const lines = wrapped.split('\n').filter(line => line.length > 0) + const lines = wrapped.split('\n').filter(line => line.length > 0); // Ensure we always return at least one line (empty string for empty cells) - return lines.length > 0 ? lines : [''] + return lines.length > 0 ? lines : ['']; } /** @@ -70,174 +66,152 @@ function wrapText( * 3. Wrapping text within cells (no truncation) * 4. Properly aligning multi-line rows with borders */ -export function MarkdownTable({ - token, - highlight, - forceWidth, -}: Props): React.ReactNode { - const [theme] = useTheme() - const { columns: actualTerminalWidth } = useTerminalSize() - const terminalWidth = forceWidth ?? actualTerminalWidth +export function MarkdownTable({ token, highlight, forceWidth }: Props): React.ReactNode { + const [theme] = useTheme(); + const { columns: actualTerminalWidth } = useTerminalSize(); + const terminalWidth = forceWidth ?? actualTerminalWidth; // Format cell content to ANSI string function formatCell(tokens: Token[] | undefined): string { - return ( - tokens - ?.map(_ => formatToken(_, theme, 0, null, null, highlight)) - .join('') ?? '' - ) + return tokens?.map(_ => formatToken(_, theme, 0, null, null, highlight)).join('') ?? ''; } // Get plain text (stripped of ANSI codes) function getPlainText(tokens: Token[] | undefined): string { - return stripAnsi(formatCell(tokens)) + return stripAnsi(formatCell(tokens)); } // Get the longest word width in a cell (minimum width to avoid breaking words) function getMinWidth(tokens: Token[] | undefined): number { - const text = getPlainText(tokens) - const words = text.split(/\s+/).filter(w => w.length > 0) - if (words.length === 0) return MIN_COLUMN_WIDTH - return Math.max(...words.map(w => stringWidth(w)), MIN_COLUMN_WIDTH) + const text = getPlainText(tokens); + const words = text.split(/\s+/).filter(w => w.length > 0); + if (words.length === 0) return MIN_COLUMN_WIDTH; + return Math.max(...words.map(w => stringWidth(w)), MIN_COLUMN_WIDTH); } // Get ideal width (full content without wrapping) function getIdealWidth(tokens: Token[] | undefined): number { - return Math.max(stringWidth(getPlainText(tokens)), MIN_COLUMN_WIDTH) + return Math.max(stringWidth(getPlainText(tokens)), MIN_COLUMN_WIDTH); } // Calculate column widths // Step 1: Get minimum (longest word) and ideal (full content) widths const minWidths = token.header.map((header, colIndex) => { - let maxMinWidth = getMinWidth(header.tokens) + let maxMinWidth = getMinWidth(header.tokens); for (const row of token.rows) { - maxMinWidth = Math.max(maxMinWidth, getMinWidth(row[colIndex]?.tokens)) + maxMinWidth = Math.max(maxMinWidth, getMinWidth(row[colIndex]?.tokens)); } - return maxMinWidth - }) + return maxMinWidth; + }); const idealWidths = token.header.map((header, colIndex) => { - let maxIdeal = getIdealWidth(header.tokens) + let maxIdeal = getIdealWidth(header.tokens); for (const row of token.rows) { - maxIdeal = Math.max(maxIdeal, getIdealWidth(row[colIndex]?.tokens)) + maxIdeal = Math.max(maxIdeal, getIdealWidth(row[colIndex]?.tokens)); } - return maxIdeal - }) + return maxIdeal; + }); // Step 2: Calculate available space // Border overhead: │ content │ content │ = 1 + (width + 3) per column - const numCols = token.header.length - const borderOverhead = 1 + numCols * 3 // │ + (2 padding + 1 border) per col + const numCols = token.header.length; + const borderOverhead = 1 + numCols * 3; // │ + (2 padding + 1 border) per col // Account for SAFETY_MARGIN to avoid triggering the fallback safety check - const availableWidth = Math.max( - terminalWidth - borderOverhead - SAFETY_MARGIN, - numCols * MIN_COLUMN_WIDTH, - ) + const availableWidth = Math.max(terminalWidth - borderOverhead - SAFETY_MARGIN, numCols * MIN_COLUMN_WIDTH); // Step 3: Calculate column widths that fit available space - const totalMin = minWidths.reduce((sum, w) => sum + w, 0) - const totalIdeal = idealWidths.reduce((sum, w) => sum + w, 0) + const totalMin = minWidths.reduce((sum, w) => sum + w, 0); + const totalIdeal = idealWidths.reduce((sum, w) => sum + w, 0); // Track whether columns are narrower than longest words (needs hard wrap) - let needsHardWrap = false + let needsHardWrap = false; - let columnWidths: number[] + let columnWidths: number[]; if (totalIdeal <= availableWidth) { // Everything fits - use ideal widths - columnWidths = idealWidths + columnWidths = idealWidths; } else if (totalMin <= availableWidth) { // Need to shrink - give each column its min, distribute remaining space - const extraSpace = availableWidth - totalMin - const overflows = idealWidths.map((ideal, i) => ideal - minWidths[i]!) - const totalOverflow = overflows.reduce((sum, o) => sum + o, 0) + const extraSpace = availableWidth - totalMin; + const overflows = idealWidths.map((ideal, i) => ideal - minWidths[i]!); + const totalOverflow = overflows.reduce((sum, o) => sum + o, 0); columnWidths = minWidths.map((min, i) => { - if (totalOverflow === 0) return min - const extra = Math.floor((overflows[i]! / totalOverflow) * extraSpace) - return min + extra - }) + if (totalOverflow === 0) return min; + const extra = Math.floor((overflows[i]! / totalOverflow) * extraSpace); + return min + extra; + }); } else { // Table wider than terminal at minimum widths // Shrink columns proportionally to fit, allowing word breaks - needsHardWrap = true - const scaleFactor = availableWidth / totalMin - columnWidths = minWidths.map(w => - Math.max(Math.floor(w * scaleFactor), MIN_COLUMN_WIDTH), - ) + needsHardWrap = true; + const scaleFactor = availableWidth / totalMin; + columnWidths = minWidths.map(w => Math.max(Math.floor(w * scaleFactor), MIN_COLUMN_WIDTH)); } // Step 4: Calculate max row lines to determine if vertical format is needed function calculateMaxRowLines(): number { - let maxLines = 1 + let maxLines = 1; // Check header for (let i = 0; i < token.header.length; i++) { - const content = formatCell(token.header[i]!.tokens) + const content = formatCell(token.header[i]!.tokens); const wrapped = wrapText(content, columnWidths[i]!, { hard: needsHardWrap, - }) - maxLines = Math.max(maxLines, wrapped.length) + }); + maxLines = Math.max(maxLines, wrapped.length); } // Check rows for (const row of token.rows) { for (let i = 0; i < row.length; i++) { - const content = formatCell(row[i]?.tokens) + const content = formatCell(row[i]?.tokens); const wrapped = wrapText(content, columnWidths[i]!, { hard: needsHardWrap, - }) - maxLines = Math.max(maxLines, wrapped.length) + }); + maxLines = Math.max(maxLines, wrapped.length); } } - return maxLines + return maxLines; } // Use vertical format if wrapping would make rows too tall - const maxRowLines = calculateMaxRowLines() - const useVerticalFormat = maxRowLines > MAX_ROW_LINES + const maxRowLines = calculateMaxRowLines(); + const useVerticalFormat = maxRowLines > MAX_ROW_LINES; // Render a single row with potential multi-line cells // Returns an array of strings, one per line of the row - function renderRowLines( - cells: Array<{ tokens?: Token[] }>, - isHeader: boolean, - ): string[] { + function renderRowLines(cells: Array<{ tokens?: Token[] }>, isHeader: boolean): string[] { // Get wrapped lines for each cell (preserving ANSI formatting) const cellLines = cells.map((cell, colIndex) => { - const formattedText = formatCell(cell.tokens) - const width = columnWidths[colIndex]! - return wrapText(formattedText, width, { hard: needsHardWrap }) - }) + const formattedText = formatCell(cell.tokens); + const width = columnWidths[colIndex]!; + return wrapText(formattedText, width, { hard: needsHardWrap }); + }); // Find max number of lines in this row - const maxLines = Math.max(...cellLines.map(lines => lines.length), 1) + const maxLines = Math.max(...cellLines.map(lines => lines.length), 1); // Calculate vertical offset for each cell (to center vertically) - const verticalOffsets = cellLines.map(lines => - Math.floor((maxLines - lines.length) / 2), - ) + const verticalOffsets = cellLines.map(lines => Math.floor((maxLines - lines.length) / 2)); // Build each line of the row as a single string - const result: string[] = [] + const result: string[] = []; for (let lineIdx = 0; lineIdx < maxLines; lineIdx++) { - let line = '│' + let line = '│'; for (let colIndex = 0; colIndex < cells.length; colIndex++) { - const lines = cellLines[colIndex]! - const offset = verticalOffsets[colIndex]! - const contentLineIdx = lineIdx - offset - const lineText = - contentLineIdx >= 0 && contentLineIdx < lines.length - ? lines[contentLineIdx]! - : '' - const width = columnWidths[colIndex]! + const lines = cellLines[colIndex]!; + const offset = verticalOffsets[colIndex]!; + const contentLineIdx = lineIdx - offset; + const lineText = contentLineIdx >= 0 && contentLineIdx < lines.length ? lines[contentLineIdx]! : ''; + const width = columnWidths[colIndex]!; // Headers always centered; data uses table alignment - const align = isHeader ? 'center' : (token.align?.[colIndex] ?? 'left') + const align = isHeader ? 'center' : (token.align?.[colIndex] ?? 'left'); - line += - ' ' + padAligned(lineText, stringWidth(lineText), width, align) + ' │' + line += ' ' + padAligned(lineText, stringWidth(lineText), width, align) + ' │'; } - result.push(line) + result.push(line); } - return result + return result; } // Render horizontal border as a single string @@ -246,109 +220,102 @@ export function MarkdownTable({ top: ['┌', '─', '┬', '┐'], middle: ['├', '─', '┼', '┤'], bottom: ['└', '─', '┴', '┘'], - }[type] as [string, string, string, string] + }[type] as [string, string, string, string]; - let line = left + let line = left; columnWidths.forEach((width, colIndex) => { - line += mid.repeat(width + 2) - line += colIndex < columnWidths.length - 1 ? cross : right - }) - return line + line += mid.repeat(width + 2); + line += colIndex < columnWidths.length - 1 ? cross : right; + }); + return line; } // Render vertical format (key-value pairs) for extra-narrow terminals function renderVerticalFormat(): string { - const lines: string[] = [] - const headers = token.header.map(h => getPlainText(h.tokens)) - const separatorWidth = Math.min(terminalWidth - 1, 40) - const separator = '─'.repeat(separatorWidth) + const lines: string[] = []; + const headers = token.header.map(h => getPlainText(h.tokens)); + const separatorWidth = Math.min(terminalWidth - 1, 40); + const separator = '─'.repeat(separatorWidth); // Small indent for wrapped lines (just 2 spaces) - const wrapIndent = ' ' + const wrapIndent = ' '; token.rows.forEach((row, rowIndex) => { if (rowIndex > 0) { - lines.push(separator) + lines.push(separator); } row.forEach((cell, colIndex) => { - const label = headers[colIndex] || `Column ${colIndex + 1}` + const label = headers[colIndex] || `Column ${colIndex + 1}`; // Clean value: trim, remove extra internal whitespace/newlines - const rawValue = formatCell(cell.tokens).trimEnd() - const value = rawValue.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim() + const rawValue = formatCell(cell.tokens).trimEnd(); + const value = rawValue.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim(); // Wrap value to fit terminal, accounting for label on first line - const firstLineWidth = terminalWidth - stringWidth(label) - 3 - const subsequentLineWidth = terminalWidth - wrapIndent.length - 1 + const firstLineWidth = terminalWidth - stringWidth(label) - 3; + const subsequentLineWidth = terminalWidth - wrapIndent.length - 1; // Two-pass wrap: first line is narrower (label takes space), // continuation lines get the full width minus indent. - const firstPassLines = wrapText(value, Math.max(firstLineWidth, 10)) - const firstLine = firstPassLines[0] || '' + const firstPassLines = wrapText(value, Math.max(firstLineWidth, 10)); + const firstLine = firstPassLines[0] || ''; - let wrappedValue: string[] - if ( - firstPassLines.length <= 1 || - subsequentLineWidth <= firstLineWidth - ) { - wrappedValue = firstPassLines + let wrappedValue: string[]; + if (firstPassLines.length <= 1 || subsequentLineWidth <= firstLineWidth) { + wrappedValue = firstPassLines; } else { // Re-join remaining text and re-wrap to the wider continuation width const remainingText = firstPassLines .slice(1) .map(l => l.trim()) - .join(' ') - const rewrapped = wrapText(remainingText, subsequentLineWidth) - wrappedValue = [firstLine, ...rewrapped] + .join(' '); + const rewrapped = wrapText(remainingText, subsequentLineWidth); + wrappedValue = [firstLine, ...rewrapped]; } // First line: bold label + value - lines.push( - `${ANSI_BOLD_START}${label}:${ANSI_BOLD_END} ${wrappedValue[0] || ''}`, - ) + lines.push(`${ANSI_BOLD_START}${label}:${ANSI_BOLD_END} ${wrappedValue[0] || ''}`); // Subsequent lines with small indent (skip empty lines) for (let i = 1; i < wrappedValue.length; i++) { - const line = wrappedValue[i]! - if (!line.trim()) continue - lines.push(`${wrapIndent}${line}`) + const line = wrappedValue[i]!; + if (!line.trim()) continue; + lines.push(`${wrapIndent}${line}`); } - }) - }) + }); + }); - return lines.join('\n') + return lines.join('\n'); } // Choose format based on available width if (useVerticalFormat) { - return {renderVerticalFormat()} + return {renderVerticalFormat()}; } // Build the complete horizontal table as an array of strings - const tableLines: string[] = [] - tableLines.push(renderBorderLine('top')) - tableLines.push(...renderRowLines(token.header, true)) - tableLines.push(renderBorderLine('middle')) + const tableLines: string[] = []; + tableLines.push(renderBorderLine('top')); + tableLines.push(...renderRowLines(token.header, true)); + tableLines.push(renderBorderLine('middle')); token.rows.forEach((row, rowIndex) => { - tableLines.push(...renderRowLines(row, false)) + tableLines.push(...renderRowLines(row, false)); if (rowIndex < token.rows.length - 1) { - tableLines.push(renderBorderLine('middle')) + tableLines.push(renderBorderLine('middle')); } - }) - tableLines.push(renderBorderLine('bottom')) + }); + tableLines.push(renderBorderLine('bottom')); // Safety check: verify no line exceeds terminal width. // This catches edge cases during terminal resize where calculations // were based on a different width than the current render target. - const maxLineWidth = Math.max( - ...tableLines.map(line => stringWidth(stripAnsi(line))), - ) + const maxLineWidth = Math.max(...tableLines.map(line => stringWidth(stripAnsi(line)))); // If we're within SAFETY_MARGIN characters of the edge, use vertical format // to account for terminal resize race conditions. if (maxLineWidth > terminalWidth - SAFETY_MARGIN) { - return {renderVerticalFormat()} + return {renderVerticalFormat()}; } // Render as a single Ansi block to prevent Ink from wrapping mid-row - return {tableLines.join('\n')} + return {tableLines.join('\n')}; } diff --git a/src/components/MemoryUsageIndicator.tsx b/src/components/MemoryUsageIndicator.tsx index 92bc9e419..8945a730d 100644 --- a/src/components/MemoryUsageIndicator.tsx +++ b/src/components/MemoryUsageIndicator.tsx @@ -1,7 +1,7 @@ -import * as React from 'react' -import { useMemoryUsage } from '../hooks/useMemoryUsage.js' -import { Box, Text } from '@anthropic/ink' -import { formatFileSize } from '../utils/format.js' +import * as React from 'react'; +import { useMemoryUsage } from '../hooks/useMemoryUsage.js'; +import { Box, Text } from '@anthropic/ink'; +import { formatFileSize } from '../utils/format.js'; export function MemoryUsageIndicator(): React.ReactNode { // Ant-only: the /heapdump link is an internal debugging aid. Gating before @@ -9,26 +9,25 @@ export function MemoryUsageIndicator(): React.ReactNode { // USER_TYPE is a build-time constant, so the hook call below is either always // reached or dead-code-eliminated — never conditional at runtime. if (process.env.USER_TYPE !== 'ant') { - return null + return null; } // eslint-disable-next-line react-hooks/rules-of-hooks - // biome-ignore lint/correctness/useHookAtTopLevel: USER_TYPE is a build-time constant - const memoryUsage = useMemoryUsage() + const memoryUsage = useMemoryUsage(); if (!memoryUsage) { - return null + return null; } - const { heapUsed, status } = memoryUsage + const { heapUsed, status } = memoryUsage; // Only show indicator when memory usage is high or critical if (status === 'normal') { - return null + return null; } - const formattedSize = formatFileSize(heapUsed) - const color = status === 'critical' ? 'error' : 'warning' + const formattedSize = formatFileSize(heapUsed); + const color = status === 'critical' ? 'error' : 'warning'; return ( @@ -36,5 +35,5 @@ export function MemoryUsageIndicator(): React.ReactNode { High memory usage ({formattedSize}) · /heapdump - ) + ); } diff --git a/src/components/Message.tsx b/src/components/Message.tsx index c069a4e1d..6a1653e15 100644 --- a/src/components/Message.tsx +++ b/src/components/Message.tsx @@ -1,21 +1,18 @@ -import { feature } from 'bun:bundle' -import type { BetaContentBlock } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' +import { feature } from 'bun:bundle'; +import type { BetaContentBlock } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'; import type { ImageBlockParam, TextBlockParam, ThinkingBlockParam, ToolResultBlockParam, ToolUseBlockParam, -} from '@anthropic-ai/sdk/resources/index.mjs' -import * as React from 'react' -import type { Command } from '../commands.js' -import { useTerminalSize } from '../hooks/useTerminalSize.js' -import { Box } from '@anthropic/ink' -import type { Tools } from '../Tool.js' -import { - type ConnectorTextBlock, - isConnectorTextBlock, -} from '../types/connectorText.js' +} from '@anthropic-ai/sdk/resources/index.mjs'; +import * as React from 'react'; +import type { Command } from '../commands.js'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { Box } from '@anthropic/ink'; +import type { Tools } from '../Tool.js'; +import { type ConnectorTextBlock, isConnectorTextBlock } from '../types/connectorText.js'; import type { AssistantMessage, AttachmentMessage as AttachmentMessageType, @@ -24,27 +21,27 @@ import type { NormalizedUserMessage, ProgressMessage, SystemMessage, -} from '../types/message.js' -import { type AdvisorBlock, isAdvisorBlock } from '../utils/advisor.js' -import { isFullscreenEnvEnabled } from '../utils/fullscreen.js' -import { logError } from '../utils/log.js' -import type { buildMessageLookups } from '../utils/messages.js' -import { CompactSummary } from './CompactSummary.js' -import { AdvisorMessage } from './messages/AdvisorMessage.js' -import { AssistantRedactedThinkingMessage } from './messages/AssistantRedactedThinkingMessage.js' -import { AssistantTextMessage } from './messages/AssistantTextMessage.js' -import { AssistantThinkingMessage } from './messages/AssistantThinkingMessage.js' -import { AssistantToolUseMessage } from './messages/AssistantToolUseMessage.js' -import { AttachmentMessage } from './messages/AttachmentMessage.js' -import { CollapsedReadSearchContent } from './messages/CollapsedReadSearchContent.js' -import { CompactBoundaryMessage } from './messages/CompactBoundaryMessage.js' -import { GroupedToolUseContent } from './messages/GroupedToolUseContent.js' -import { SystemTextMessage } from './messages/SystemTextMessage.js' -import { UserImageMessage } from './messages/UserImageMessage.js' -import { UserTextMessage } from './messages/UserTextMessage.js' -import { UserToolResultMessage } from './messages/UserToolResultMessage/UserToolResultMessage.js' -import { OffscreenFreeze } from './OffscreenFreeze.js' -import { ExpandShellOutputProvider } from './shell/ExpandShellOutputContext.js' +} from '../types/message.js'; +import { type AdvisorBlock, isAdvisorBlock } from '../utils/advisor.js'; +import { isFullscreenEnvEnabled } from '../utils/fullscreen.js'; +import { logError } from '../utils/log.js'; +import type { buildMessageLookups } from '../utils/messages.js'; +import { CompactSummary } from './CompactSummary.js'; +import { AdvisorMessage } from './messages/AdvisorMessage.js'; +import { AssistantRedactedThinkingMessage } from './messages/AssistantRedactedThinkingMessage.js'; +import { AssistantTextMessage } from './messages/AssistantTextMessage.js'; +import { AssistantThinkingMessage } from './messages/AssistantThinkingMessage.js'; +import { AssistantToolUseMessage } from './messages/AssistantToolUseMessage.js'; +import { AttachmentMessage } from './messages/AttachmentMessage.js'; +import { CollapsedReadSearchContent } from './messages/CollapsedReadSearchContent.js'; +import { CompactBoundaryMessage } from './messages/CompactBoundaryMessage.js'; +import { GroupedToolUseContent } from './messages/GroupedToolUseContent.js'; +import { SystemTextMessage } from './messages/SystemTextMessage.js'; +import { UserImageMessage } from './messages/UserImageMessage.js'; +import { UserTextMessage } from './messages/UserTextMessage.js'; +import { UserToolResultMessage } from './messages/UserToolResultMessage/UserToolResultMessage.js'; +import { OffscreenFreeze } from './OffscreenFreeze.js'; +import { ExpandShellOutputProvider } from './shell/ExpandShellOutputContext.js'; export type Props = { message: @@ -53,31 +50,31 @@ export type Props = { | AttachmentMessageType | SystemMessage | GroupedToolUseMessageType - | CollapsedReadSearchGroupType - lookups: ReturnType + | CollapsedReadSearchGroupType; + lookups: ReturnType; // TODO: Find a way to remove this, and leave spacing to the consumer /** Absolute width for the container Box. When provided, eliminates a wrapper Box in the caller. */ - containerWidth?: number - addMargin: boolean - tools: Tools - commands: Command[] - verbose: boolean - inProgressToolUseIDs: Set - progressMessagesForMessage: ProgressMessage[] - shouldAnimate: boolean - shouldShowDot: boolean - style?: 'condensed' - width?: number | string - isTranscriptMode: boolean - isStatic: boolean - onOpenRateLimitOptions?: () => void - isActiveCollapsedGroup?: boolean - isUserContinuation?: boolean + containerWidth?: number; + addMargin: boolean; + tools: Tools; + commands: Command[]; + verbose: boolean; + inProgressToolUseIDs: Set; + progressMessagesForMessage: ProgressMessage[]; + shouldAnimate: boolean; + shouldShowDot: boolean; + style?: 'condensed'; + width?: number | string; + isTranscriptMode: boolean; + isStatic: boolean; + onOpenRateLimitOptions?: () => void; + isActiveCollapsedGroup?: boolean; + isUserContinuation?: boolean; /** ID of the last thinking block (uuid:index) to show, used for hiding past thinking in transcript mode */ - lastThinkingBlockId?: string | null + lastThinkingBlockId?: string | null; /** UUID of the latest user bash output message (for auto-expanding) */ - latestBashOutputUUID?: string | null -} + latestBashOutputUUID?: string | null; +}; function MessageImpl({ message, @@ -109,7 +106,7 @@ function MessageImpl({ verbose={verbose} isTranscriptMode={isTranscriptMode} /> - ) + ); case 'assistant': return ( @@ -136,38 +133,37 @@ function MessageImpl({ /> ))} - ) + ); case 'user': { if (message.isCompactSummary) { - return ( - - ) + return ; } // Precompute the imageIndex prop for each content block. The previous // version incremented a counter inside the .map() callback, which // React Compiler bails on ("UpdateExpression to variables captured // within lambdas"). A plain for loop keeps the mutation out of a // closure so the compiler can memoize MessageImpl. - const imageIndices: number[] = [] - let imagePosition = 0 + const imageIndices: number[] = []; + let imagePosition = 0; for (const param of message.message.content as Array<{ type: string }>) { if (param.type === 'image') { - const id = message.imagePasteIds?.[imagePosition] - imagePosition++ - imageIndices.push(id ?? imagePosition) + const id = message.imagePasteIds?.[imagePosition]; + imagePosition++; + imageIndices.push(id ?? imagePosition); } else { - imageIndices.push(imagePosition) + imageIndices.push(imagePosition); } } // Check if this message is the latest bash output - if so, wrap content // with provider so OutputLine can show full output via context - const isLatestBashOutput = latestBashOutputUUID === message.uuid + const isLatestBashOutput = latestBashOutputUUID === message.uuid; const content = ( - {(message.message.content as Array).map((param, index) => ( + {( + message.message.content as Array< + TextBlockParam | ImageBlockParam | ToolUseBlockParam | ToolResultBlockParam + > + ).map((param, index) => ( ))} - ) - return isLatestBashOutput ? ( - {content} - ) : ( - content - ) + ); + return isLatestBashOutput ? {content} : content; } case 'system': if (message.subtype === 'compact_boundary') { @@ -197,32 +189,32 @@ function MessageImpl({ // appends instead of resetting, Messages.tsx skips the boundary // filter) — scroll up for history, no need for the ctrl+o hint. if (isFullscreenEnvEnabled()) { - return null + return null; } - return + return ; } if (message.subtype === 'microcompact_boundary') { // Logged at creation time in createMicrocompactBoundaryMessage - return null + return null; } if (feature('HISTORY_SNIP')) { /* eslint-disable @typescript-eslint/no-require-imports */ const { isSnipBoundaryMessage } = - require('../services/compact/snipProjection.js') as typeof import('../services/compact/snipProjection.js') + require('../services/compact/snipProjection.js') as typeof import('../services/compact/snipProjection.js'); const { isSnipMarkerMessage } = - require('../services/compact/snipCompact.js') as typeof import('../services/compact/snipCompact.js') + require('../services/compact/snipCompact.js') as typeof import('../services/compact/snipCompact.js'); /* eslint-enable @typescript-eslint/no-require-imports */ if (isSnipBoundaryMessage(message)) { /* eslint-disable @typescript-eslint/no-require-imports */ const { SnipBoundaryMessage } = - require('./messages/SnipBoundaryMessage.js') as typeof import('./messages/SnipBoundaryMessage.js') + require('./messages/SnipBoundaryMessage.js') as typeof import('./messages/SnipBoundaryMessage.js'); /* eslint-enable @typescript-eslint/no-require-imports */ - return + return ; } if (isSnipMarkerMessage(message)) { // Internal registration marker — not user-facing. The boundary // message (above) is what shows when snips actually execute. - return null + return null; } } if (message.subtype === 'local_command') { @@ -233,7 +225,7 @@ function MessageImpl({ verbose={verbose} isTranscriptMode={isTranscriptMode} /> - ) + ); } return ( - ) + ); case 'grouped_tool_use': return ( - ) + ); case 'collapsed_read_search': // OffscreenFreeze: the verb flips "Reading…"→"Read" when tools complete. // If the group has scrolled into scrollback by then, the update triggers @@ -277,7 +269,7 @@ function MessageImpl({ isActiveGroup={isActiveCollapsedGroup} /> - ) + ); } } @@ -294,23 +286,19 @@ function UserMessage({ lookups, isTranscriptMode, }: { - message: NormalizedUserMessage - addMargin: boolean - tools: Tools - progressMessagesForMessage: ProgressMessage[] - param: - | TextBlockParam - | ImageBlockParam - | ToolUseBlockParam - | ToolResultBlockParam - style?: 'condensed' - verbose: boolean - imageIndex?: number - isUserContinuation: boolean - lookups: ReturnType - isTranscriptMode: boolean + message: NormalizedUserMessage; + addMargin: boolean; + tools: Tools; + progressMessagesForMessage: ProgressMessage[]; + param: TextBlockParam | ImageBlockParam | ToolUseBlockParam | ToolResultBlockParam; + style?: 'condensed'; + verbose: boolean; + imageIndex?: number; + isUserContinuation: boolean; + lookups: ReturnType; + isTranscriptMode: boolean; }): React.ReactNode { - const { columns } = useTerminalSize() + const { columns } = useTerminalSize(); switch (param.type) { case 'text': return ( @@ -322,16 +310,11 @@ function UserMessage({ isTranscriptMode={isTranscriptMode} timestamp={message.timestamp as string | undefined} /> - ) + ); case 'image': // If previous message is user (text or image), this is a continuation - use connector // Otherwise this image starts a new user turn - use margin - return ( - - ) + return ; case 'tool_result': return ( - ) + ); default: - return undefined + return undefined; } } @@ -378,25 +361,25 @@ function AssistantMessageBlock({ | ImageBlockParam | ThinkingBlockParam | ToolUseBlockParam - | ToolResultBlockParam - addMargin: boolean - tools: Tools - commands: Command[] - verbose: boolean - inProgressToolUseIDs: Set - progressMessagesForMessage: ProgressMessage[] - shouldAnimate: boolean - shouldShowDot: boolean - width?: number | string - inProgressToolCallCount?: number - isTranscriptMode: boolean - lookups: ReturnType - onOpenRateLimitOptions?: () => void + | ToolResultBlockParam; + addMargin: boolean; + tools: Tools; + commands: Command[]; + verbose: boolean; + inProgressToolUseIDs: Set; + progressMessagesForMessage: ProgressMessage[]; + shouldAnimate: boolean; + shouldShowDot: boolean; + width?: number | string; + inProgressToolCallCount?: number; + isTranscriptMode: boolean; + lookups: ReturnType; + onOpenRateLimitOptions?: () => void; /** ID of this content block's message:index for thinking block comparison */ - thinkingBlockId: string + thinkingBlockId: string; /** ID of the last thinking block to show, null means show all */ - lastThinkingBlockId?: string | null - advisorModel?: string + lastThinkingBlockId?: string | null; + advisorModel?: string; }): React.ReactNode { if (feature('CONNECTOR_TEXT')) { if (isConnectorTextBlock(param)) { @@ -409,7 +392,7 @@ function AssistantMessageBlock({ width={width} onOpenRateLimitOptions={onOpenRateLimitOptions} /> - ) + ); } } switch (param.type) { @@ -429,7 +412,7 @@ function AssistantMessageBlock({ lookups={lookups} isTranscriptMode={isTranscriptMode} /> - ) + ); case 'text': return ( - ) + ); case 'redacted_thinking': if (!isTranscriptMode && !verbose) { - return null + return null; } - return + return ; case 'thinking': { if (!isTranscriptMode && !verbose) { - return null + return null; } // In transcript mode with hidePastThinking, only show the last thinking block - const isLastThinking = - !lastThinkingBlockId || thinkingBlockId === lastThinkingBlockId + const isLastThinking = !lastThinkingBlockId || thinkingBlockId === lastThinkingBlockId; return ( - ) + ); } case 'server_tool_use': case 'advisor_tool_result': @@ -476,29 +458,24 @@ function AssistantMessageBlock({ verbose={verbose || isTranscriptMode} advisorModel={advisorModel} /> - ) + ); } - logError(new Error(`Unable to render server tool block: ${param.type}`)) - return null + logError(new Error(`Unable to render server tool block: ${param.type}`)); + return null; default: - logError(new Error(`Unable to render message type: ${param.type}`)) - return null + logError(new Error(`Unable to render message type: ${param.type}`)); + return null; } } -export function hasThinkingContent(m: { - type: string - message?: { content: Array<{ type: string }> } -}): boolean { - if (m.type !== 'assistant' || !m.message) return false - return m.message.content.some( - b => b.type === 'thinking' || b.type === 'redacted_thinking', - ) +export function hasThinkingContent(m: { type: string; message?: { content: Array<{ type: string }> } }): boolean { + if (m.type !== 'assistant' || !m.message) return false; + return m.message.content.some(b => b.type === 'thinking' || b.type === 'redacted_thinking'); } /** Exported for testing */ export function areMessagePropsEqual(prev: Props, next: Props): boolean { - if (prev.message.uuid !== next.message.uuid) return false + if (prev.message.uuid !== next.message.uuid) return false; // Only re-render on lastThinkingBlockId change if this message actually // has thinking content — otherwise every message in scrollback re-renders // whenever streaming thinking starts/stops (CC-941). @@ -506,21 +483,21 @@ export function areMessagePropsEqual(prev: Props, next: Props): boolean { prev.lastThinkingBlockId !== next.lastThinkingBlockId && hasThinkingContent(next.message as Parameters[0]) ) { - return false + return false; } // Verbose toggle changes thinking block visibility/expansion - if (prev.verbose !== next.verbose) return false + if (prev.verbose !== next.verbose) return false; // Only re-render if this message's "is latest bash output" status changed, // not when the global latestBashOutputUUID changes to a different message - const prevIsLatest = prev.latestBashOutputUUID === prev.message.uuid - const nextIsLatest = next.latestBashOutputUUID === next.message.uuid - if (prevIsLatest !== nextIsLatest) return false - if (prev.isTranscriptMode !== next.isTranscriptMode) return false + const prevIsLatest = prev.latestBashOutputUUID === prev.message.uuid; + const nextIsLatest = next.latestBashOutputUUID === next.message.uuid; + if (prevIsLatest !== nextIsLatest) return false; + if (prev.isTranscriptMode !== next.isTranscriptMode) return false; // containerWidth is an absolute number in the no-metadata path (wrapper // Box is skipped). Static messages must re-render on terminal resize. - if (prev.containerWidth !== next.containerWidth) return false - if (prev.isStatic && next.isStatic) return true - return false + if (prev.containerWidth !== next.containerWidth) return false; + if (prev.isStatic && next.isStatic) return true; + return false; } -export const Message = React.memo(MessageImpl, areMessagePropsEqual) +export const Message = React.memo(MessageImpl, areMessagePropsEqual); diff --git a/src/components/MessageModel.tsx b/src/components/MessageModel.tsx index d57c7ef6a..2dd8c98e0 100644 --- a/src/components/MessageModel.tsx +++ b/src/components/MessageModel.tsx @@ -1,33 +1,30 @@ -import React from 'react' -import { Box, Text, stringWidth } from '@anthropic/ink' -import type { NormalizedMessage } from '../types/message.js' +import React from 'react'; +import { Box, Text, stringWidth } from '@anthropic/ink'; +import type { NormalizedMessage } from '../types/message.js'; type Props = { - message: NormalizedMessage - isTranscriptMode: boolean -} + message: NormalizedMessage; + isTranscriptMode: boolean; +}; -export function MessageModel({ - message, - isTranscriptMode, -}: Props): React.ReactNode { - const content = message.message?.content - const contentArray = Array.isArray(content) ? content : [] +export function MessageModel({ message, isTranscriptMode }: Props): React.ReactNode { + const content = message.message?.content; + const contentArray = Array.isArray(content) ? content : []; const shouldShowModel = isTranscriptMode && message.type === 'assistant' && message.message?.model && - contentArray.some((c: any) => c?.type === 'text') + contentArray.some((c: any) => c?.type === 'text'); if (!shouldShowModel) { - return null + return null; } - const model = message.message!.model as string + const model = message.message!.model as string; return ( {model} - ) + ); } diff --git a/src/components/MessageResponse.tsx b/src/components/MessageResponse.tsx index a8dd8613c..00852fe8c 100644 --- a/src/components/MessageResponse.tsx +++ b/src/components/MessageResponse.tsx @@ -1,16 +1,16 @@ -import * as React from 'react' -import { useContext } from 'react' -import { Box, NoSelect, Text, Ratchet } from '@anthropic/ink' +import * as React from 'react'; +import { useContext } from 'react'; +import { Box, NoSelect, Text, Ratchet } from '@anthropic/ink'; type Props = { - children: React.ReactNode - height?: number -} + children: React.ReactNode; + height?: number; +}; export function MessageResponse({ children, height }: Props): React.ReactNode { - const isMessageResponse = useContext(MessageResponseContext) + const isMessageResponse = useContext(MessageResponseContext); if (isMessageResponse) { - return children + return children; } const content = ( @@ -23,26 +23,18 @@ export function MessageResponse({ children, height }: Props): React.ReactNode { - ) + ); if (height !== undefined) { - return content + return content; } - return {content} + return {content}; } // This is a context that is used to determine if the message response // is rendered as a descendant of another MessageResponse. We use it // to avoid rendering nested ⎿ characters. -const MessageResponseContext = React.createContext(false) +const MessageResponseContext = React.createContext(false); -function MessageResponseProvider({ - children, -}: { - children: React.ReactNode -}): React.ReactNode { - return ( - - {children} - - ) +function MessageResponseProvider({ children }: { children: React.ReactNode }): React.ReactNode { + return {children}; } diff --git a/src/components/MessageRow.tsx b/src/components/MessageRow.tsx index dbcfe8e4e..1acad7ca0 100644 --- a/src/components/MessageRow.tsx +++ b/src/components/MessageRow.tsx @@ -1,61 +1,68 @@ -import * as React from 'react' -import type { Command } from '../commands.js' -import { Box } from '@anthropic/ink' -import type { Screen } from '../screens/REPL.js' -import type { Tools } from '../Tool.js' -import type { RenderableMessage } from '../types/message.js' +import * as React from 'react'; +import type { Command } from '../commands.js'; +import { Box } from '@anthropic/ink'; +import type { Screen } from '../screens/REPL.js'; +import type { Tools } from '../Tool.js'; +import type { RenderableMessage } from '../types/message.js'; import { getDisplayMessageFromCollapsed, getToolSearchOrReadInfo, getToolUseIdsFromCollapsedGroup, hasAnyToolInProgress, -} from '../utils/collapseReadSearch.js' +} from '../utils/collapseReadSearch.js'; import { type buildMessageLookups, EMPTY_STRING_SET, getProgressMessagesFromLookup, getSiblingToolUseIDsFromLookup, getToolUseID, -} from '../utils/messages.js' -import { hasThinkingContent, Message } from './Message.js' +} from '../utils/messages.js'; +import { hasThinkingContent, Message } from './Message.js'; // Narrow the first element of MessageContent to a block with known shape. -type ContentBlock = { type: string; name?: string; input?: unknown; id?: string; text?: string; [key: string]: unknown } +type ContentBlock = { + type: string; + name?: string; + input?: unknown; + id?: string; + text?: string; + [key: string]: unknown; +}; const firstBlock = (content: unknown): ContentBlock | undefined => { - if (!Array.isArray(content)) return undefined - const b = content[0] - if (b == null || typeof b === 'string') return undefined - return b as ContentBlock -} -import { MessageModel } from './MessageModel.js' -import { shouldRenderStatically } from './Messages.js' -import { MessageTimestamp } from './MessageTimestamp.js' -import { OffscreenFreeze } from './OffscreenFreeze.js' + if (!Array.isArray(content)) return undefined; + const b = content[0]; + if (b == null || typeof b === 'string') return undefined; + return b as ContentBlock; +}; +import { MessageModel } from './MessageModel.js'; +import { shouldRenderStatically } from './Messages.js'; +import { MessageTimestamp } from './MessageTimestamp.js'; +import { OffscreenFreeze } from './OffscreenFreeze.js'; export type Props = { - message: RenderableMessage + message: RenderableMessage; /** Whether the previous message in renderableMessages is also a user message. */ - isUserContinuation: boolean + isUserContinuation: boolean; /** * Whether there is non-skippable content after this message in renderableMessages. * Only needs to be accurate for `collapsed_read_search` messages — used to decide * if the collapsed group spinner should stay active. Pass `false` otherwise. */ - hasContentAfter: boolean - tools: Tools - commands: Command[] - verbose: boolean - inProgressToolUseIDs: Set - streamingToolUseIDs: Set - screen: Screen - canAnimate: boolean - onOpenRateLimitOptions?: () => void - lastThinkingBlockId: string | null - latestBashOutputUUID: string | null - columns: number - isLoading: boolean - lookups: ReturnType -} + hasContentAfter: boolean; + tools: Tools; + commands: Command[]; + verbose: boolean; + inProgressToolUseIDs: Set; + streamingToolUseIDs: Set; + screen: Screen; + canAnimate: boolean; + onOpenRateLimitOptions?: () => void; + lastThinkingBlockId: string | null; + latestBashOutputUUID: string | null; + columns: number; + isLoading: boolean; + lookups: ReturnType; +}; /** * Scans forward from `index+1` to check if any "real" content follows. Used to @@ -74,54 +81,46 @@ export function hasContentAfterIndex( streamingToolUseIDs: Set, ): boolean { for (let i = index + 1; i < messages.length; i++) { - const msg = messages[i] + const msg = messages[i]; if (msg?.type === 'assistant') { - const content = firstBlock(msg.message.content) - if ( - content?.type === 'thinking' || - content?.type === 'redacted_thinking' - ) { - continue + const content = firstBlock(msg.message.content); + if (content?.type === 'thinking' || content?.type === 'redacted_thinking') { + continue; } if (content?.type === 'tool_use') { - if ( - getToolSearchOrReadInfo(content.name!, content.input, tools) - .isCollapsible - ) { - continue + if (getToolSearchOrReadInfo(content.name!, content.input, tools).isCollapsible) { + continue; } // Non-collapsible tool uses appear in syntheticStreamingToolUseMessages // before their ID is added to inProgressToolUseIDs. Skip while streaming // to avoid briefly finalizing the read group. if (streamingToolUseIDs.has(content.id!)) { - continue + continue; } } - return true + return true; } if (msg?.type === 'system' || msg?.type === 'attachment') { - continue + continue; } // Tool results arrive while the collapsed group is still being built if (msg?.type === 'user') { - const content = firstBlock(msg.message.content) + const content = firstBlock(msg.message.content); if (content?.type === 'tool_result') { - continue + continue; } } // Collapsible grouped_tool_use messages arrive transiently before being // merged into the current collapsed group on the next render cycle if (msg?.type === 'grouped_tool_use') { - const firstInput = firstBlock(msg.messages[0]?.message.content)?.input - if ( - getToolSearchOrReadInfo(msg.toolName, firstInput, tools).isCollapsible - ) { - continue + const firstInput = firstBlock(msg.messages[0]?.message.content)?.input; + if (getToolSearchOrReadInfo(msg.toolName, firstInput, tools).isCollapsible) { + continue; } } - return true + return true; } - return false + return false; } function MessageRowImpl({ @@ -142,32 +141,22 @@ function MessageRowImpl({ isLoading, lookups, }: Props): React.ReactNode { - const isTranscriptMode = screen === 'transcript' - const isGrouped = msg.type === 'grouped_tool_use' - const isCollapsed = msg.type === 'collapsed_read_search' + const isTranscriptMode = screen === 'transcript'; + const isGrouped = msg.type === 'grouped_tool_use'; + const isCollapsed = msg.type === 'collapsed_read_search'; // A collapsed group is "active" (grey dot, present tense "Reading…") when its tools // are still executing OR when the overall query is still running with nothing after it. // hasAnyToolInProgress takes priority: if tools are running, always show active regardless // of what else is in the message list (avoids false finalization during parallel execution). const isActiveCollapsedGroup = - isCollapsed && - (hasAnyToolInProgress(msg, inProgressToolUseIDs) || - (isLoading && !hasContentAfter)) + isCollapsed && (hasAnyToolInProgress(msg, inProgressToolUseIDs) || (isLoading && !hasContentAfter)); - const displayMsg = isGrouped - ? msg.displayMessage - : isCollapsed - ? getDisplayMessageFromCollapsed(msg) - : msg + const displayMsg = isGrouped ? msg.displayMessage : isCollapsed ? getDisplayMessageFromCollapsed(msg) : msg; - const progressMessagesForMessage = - isGrouped || isCollapsed ? [] : getProgressMessagesFromLookup(msg, lookups) + const progressMessagesForMessage = isGrouped || isCollapsed ? [] : getProgressMessagesFromLookup(msg, lookups); - const siblingToolUseIDs = - isGrouped || isCollapsed - ? EMPTY_STRING_SET - : getSiblingToolUseIDsFromLookup(msg, lookups) + const siblingToolUseIDs = isGrouped || isCollapsed ? EMPTY_STRING_SET : getSiblingToolUseIDsFromLookup(msg, lookups); const isStatic = shouldRenderStatically( msg, @@ -176,30 +165,29 @@ function MessageRowImpl({ siblingToolUseIDs, screen, lookups, - ) + ); - let shouldAnimate = false + let shouldAnimate = false; if (canAnimate) { if (isGrouped) { shouldAnimate = msg.messages.some(m => { - const content = firstBlock(m.message.content) - return ( - content?.type === 'tool_use' && inProgressToolUseIDs.has(content.id!) - ) - }) + const content = firstBlock(m.message.content); + return content?.type === 'tool_use' && inProgressToolUseIDs.has(content.id!); + }); } else if (isCollapsed) { - shouldAnimate = hasAnyToolInProgress(msg, inProgressToolUseIDs) + shouldAnimate = hasAnyToolInProgress(msg, inProgressToolUseIDs); } else { - const toolUseID = getToolUseID(msg) - shouldAnimate = !toolUseID || inProgressToolUseIDs.has(toolUseID) + const toolUseID = getToolUseID(msg); + shouldAnimate = !toolUseID || inProgressToolUseIDs.has(toolUseID); } } const hasMetadata = isTranscriptMode && displayMsg.type === 'assistant' && - (Array.isArray(displayMsg.message.content) && (displayMsg.message.content as Array<{ type: string }>).some(c => c.type === 'text')) && - (displayMsg.timestamp || displayMsg.message.model) + Array.isArray(displayMsg.message.content) && + (displayMsg.message.content as Array<{ type: string }>).some(c => c.type === 'text') && + (displayMsg.timestamp || displayMsg.message.model); const messageEl = ( - ) + ); // OffscreenFreeze: the outer React.memo already bails for static messages, // so this only wraps rows that DO re-render — in-progress tools, collapsed // read/search spinners, bash elapsed timers. When those rows have scrolled @@ -230,81 +218,64 @@ function MessageRowImpl({ // change forces log-update.ts into a full terminal reset per tick. Freezing // returns the cached element ref so React bails and produces zero diff. if (!hasMetadata) { - return {messageEl} + return {messageEl}; } // Margin on children, not here — else null items (hook_success etc.) get phantom 1-row spacing. return ( - - - + + + {messageEl} - ) + ); } /** * Checks if a message is "streaming" - i.e., its content may still be changing. * Exported for testing. */ -export function isMessageStreaming( - msg: RenderableMessage, - streamingToolUseIDs: Set, -): boolean { +export function isMessageStreaming(msg: RenderableMessage, streamingToolUseIDs: Set): boolean { if (msg.type === 'grouped_tool_use') { return msg.messages.some(m => { - const content = firstBlock(m.message.content) - return content?.type === 'tool_use' && streamingToolUseIDs.has(content.id!) - }) + const content = firstBlock(m.message.content); + return content?.type === 'tool_use' && streamingToolUseIDs.has(content.id!); + }); } if (msg.type === 'collapsed_read_search') { - const toolIds = getToolUseIdsFromCollapsedGroup(msg) - return toolIds.some(id => streamingToolUseIDs.has(id)) + const toolIds = getToolUseIdsFromCollapsedGroup(msg); + return toolIds.some(id => streamingToolUseIDs.has(id)); } - const toolUseID = getToolUseID(msg) - return !!toolUseID && streamingToolUseIDs.has(toolUseID) + const toolUseID = getToolUseID(msg); + return !!toolUseID && streamingToolUseIDs.has(toolUseID); } /** * Checks if all tools in a message are resolved. * Exported for testing. */ -export function allToolsResolved( - msg: RenderableMessage, - resolvedToolUseIDs: Set, -): boolean { +export function allToolsResolved(msg: RenderableMessage, resolvedToolUseIDs: Set): boolean { if (msg.type === 'grouped_tool_use') { return msg.messages.every(m => { - const content = firstBlock(m.message.content) - return content?.type === 'tool_use' && resolvedToolUseIDs.has(content.id!) - }) + const content = firstBlock(m.message.content); + return content?.type === 'tool_use' && resolvedToolUseIDs.has(content.id!); + }); } if (msg.type === 'collapsed_read_search') { - const toolIds = getToolUseIdsFromCollapsedGroup(msg) - return toolIds.every(id => resolvedToolUseIDs.has(id)) + const toolIds = getToolUseIdsFromCollapsedGroup(msg); + return toolIds.every(id => resolvedToolUseIDs.has(id)); } if (msg.type === 'assistant') { - const block = firstBlock(msg.message.content) + const block = firstBlock(msg.message.content); if (block?.type === 'server_tool_use') { - return resolvedToolUseIDs.has(block.id!) + return resolvedToolUseIDs.has(block.id!); } } - const toolUseID = getToolUseID(msg) - return !toolUseID || resolvedToolUseIDs.has(toolUseID) + const toolUseID = getToolUseID(msg); + return !toolUseID || resolvedToolUseIDs.has(toolUseID); } /** @@ -315,29 +286,26 @@ export function allToolsResolved( */ export function areMessageRowPropsEqual(prev: Props, next: Props): boolean { // Different message reference = content may have changed, must re-render - if (prev.message !== next.message) return false + if (prev.message !== next.message) return false; // Screen mode change = re-render - if (prev.screen !== next.screen) return false + if (prev.screen !== next.screen) return false; // Verbose toggle changes thinking block visibility - if (prev.verbose !== next.verbose) return false + if (prev.verbose !== next.verbose) return false; // collapsed_read_search is never static in prompt mode (matches shouldRenderStatically) - if ( - prev.message.type === 'collapsed_read_search' && - next.screen !== 'transcript' - ) { - return false + if (prev.message.type === 'collapsed_read_search' && next.screen !== 'transcript') { + return false; } // Width change affects Box layout - if (prev.columns !== next.columns) return false + if (prev.columns !== next.columns) return false; // latestBashOutputUUID affects rendering (full vs truncated output) - const prevIsLatestBash = prev.latestBashOutputUUID === prev.message.uuid - const nextIsLatestBash = next.latestBashOutputUUID === next.message.uuid - if (prevIsLatestBash !== nextIsLatestBash) return false + const prevIsLatestBash = prev.latestBashOutputUUID === prev.message.uuid; + const nextIsLatestBash = next.latestBashOutputUUID === next.message.uuid; + if (prevIsLatestBash !== nextIsLatestBash) return false; // lastThinkingBlockId affects thinking block visibility — but only for // messages that HAVE thinking content. Checking unconditionally busts the @@ -346,21 +314,18 @@ export function areMessageRowPropsEqual(prev: Props, next: Props): boolean { prev.lastThinkingBlockId !== next.lastThinkingBlockId && hasThinkingContent(next.message as Parameters[0]) ) { - return false + return false; } // Check if this message is still "in flight" - const isStreaming = isMessageStreaming(prev.message, prev.streamingToolUseIDs) - const isResolved = allToolsResolved( - prev.message, - prev.lookups.resolvedToolUseIDs, - ) + const isStreaming = isMessageStreaming(prev.message, prev.streamingToolUseIDs); + const isResolved = allToolsResolved(prev.message, prev.lookups.resolvedToolUseIDs); // Only bail out for truly static messages - if (isStreaming || !isResolved) return false + if (isStreaming || !isResolved) return false; // Static message - safe to skip re-render - return true + return true; } -export const MessageRow = React.memo(MessageRowImpl, areMessageRowPropsEqual) +export const MessageRow = React.memo(MessageRowImpl, areMessageRowPropsEqual); diff --git a/src/components/MessageSelector.tsx b/src/components/MessageSelector.tsx index caca52aa7..a54a84b03 100644 --- a/src/components/MessageSelector.tsx +++ b/src/components/MessageSelector.tsx @@ -1,50 +1,43 @@ -import type { - ContentBlockParam, - TextBlockParam, -} from '@anthropic-ai/sdk/resources/index.mjs' -import { randomUUID, type UUID } from 'crypto' -import figures from 'figures' -import * as React from 'react' -import { useCallback, useEffect, useMemo, useState } from 'react' +import type { ContentBlockParam, TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; +import { randomUUID, type UUID } from 'crypto'; +import figures from 'figures'; +import * as React from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from 'src/services/analytics/index.js' -import { useAppState } from 'src/state/AppState.js' +} from 'src/services/analytics/index.js'; +import { useAppState } from 'src/state/AppState.js'; import { type DiffStats, fileHistoryCanRestore, fileHistoryEnabled, fileHistoryGetDiffStats, -} from 'src/utils/fileHistory.js' -import { logError } from 'src/utils/log.js' -import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js' -import { Box, Text, Divider } from '@anthropic/ink' -import { useKeybinding, useKeybindings } from '../keybindings/useKeybinding.js' -import type { - Message, - PartialCompactDirection, - UserMessage, -} from '../types/message.js' -import { stripDisplayTags } from '../utils/displayTags.js' +} from 'src/utils/fileHistory.js'; +import { logError } from 'src/utils/log.js'; +import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js'; +import { Box, Text, Divider } from '@anthropic/ink'; +import { useKeybinding, useKeybindings } from '../keybindings/useKeybinding.js'; +import type { Message, PartialCompactDirection, UserMessage } from '../types/message.js'; +import { stripDisplayTags } from '../utils/displayTags.js'; import { createUserMessage, extractTag, isEmptyMessageText, isSyntheticMessage, isToolUseResultMessage, -} from '../utils/messages.js' -import { type OptionWithDescription, Select } from './CustomSelect/select.js' -import { Spinner } from './Spinner.js' +} from '../utils/messages.js'; +import { type OptionWithDescription, Select } from './CustomSelect/select.js'; +import { Spinner } from './Spinner.js'; function isTextBlock(block: ContentBlockParam): block is TextBlockParam { - return block.type === 'text' + return block.type === 'text'; } -import * as path from 'path' -import { useTerminalSize } from 'src/hooks/useTerminalSize.js' -import type { FileEditOutput } from '@claude-code-best/builtin-tools/tools/FileEditTool/types.js' -import type { Output as FileWriteToolOutput } from '@claude-code-best/builtin-tools/tools/FileWriteTool/FileWriteTool.js' +import * as path from 'path'; +import { useTerminalSize } from 'src/hooks/useTerminalSize.js'; +import type { FileEditOutput } from '@claude-code-best/builtin-tools/tools/FileEditTool/types.js'; +import type { Output as FileWriteToolOutput } from '@claude-code-best/builtin-tools/tools/FileWriteTool/FileWriteTool.js'; import { BASH_STDERR_TAG, BASH_STDOUT_TAG, @@ -54,40 +47,28 @@ import { TASK_NOTIFICATION_TAG, TEAMMATE_MESSAGE_TAG, TICK_TAG, -} from '../constants/xml.js' -import { count } from '../utils/array.js' -import { formatRelativeTimeAgo, truncate } from '../utils/format.js' -import type { Theme } from '../utils/theme.js' -type RestoreOption = - | 'both' - | 'conversation' - | 'code' - | 'summarize' - | 'summarize_up_to' - | 'nevermind' +} from '../constants/xml.js'; +import { count } from '../utils/array.js'; +import { formatRelativeTimeAgo, truncate } from '../utils/format.js'; +import type { Theme } from '../utils/theme.js'; +type RestoreOption = 'both' | 'conversation' | 'code' | 'summarize' | 'summarize_up_to' | 'nevermind'; -function isSummarizeOption( - option: RestoreOption | null, -): option is 'summarize' | 'summarize_up_to' { - return option === 'summarize' || option === 'summarize_up_to' +function isSummarizeOption(option: RestoreOption | null): option is 'summarize' | 'summarize_up_to' { + return option === 'summarize' || option === 'summarize_up_to'; } type Props = { - messages: Message[] - onPreRestore: () => void - onRestoreMessage: (message: UserMessage) => Promise - onRestoreCode: (message: UserMessage) => Promise - onSummarize: ( - message: UserMessage, - feedback?: string, - direction?: PartialCompactDirection, - ) => Promise - onClose: () => void + messages: Message[]; + onPreRestore: () => void; + onRestoreMessage: (message: UserMessage) => Promise; + onRestoreCode: (message: UserMessage) => Promise; + onSummarize: (message: UserMessage, feedback?: string, direction?: PartialCompactDirection) => Promise; + onClose: () => void; /** Skip pick-list, land on confirm. Caller ran skip-check first. Esc closes fully (no back-to-list). */ - preselectedMessage?: UserMessage -} + preselectedMessage?: UserMessage; +}; -const MAX_VISIBLE_MESSAGES = 7 +const MAX_VISIBLE_MESSAGES = 7; export function MessageSelector({ messages, @@ -98,12 +79,12 @@ export function MessageSelector({ onClose, preselectedMessage, }: Props): React.ReactNode { - const fileHistory = useAppState(s => s.fileHistory) - const [error, setError] = useState(undefined) - const isFileHistoryEnabled = fileHistoryEnabled() + const fileHistory = useAppState(s => s.fileHistory); + const [error, setError] = useState(undefined); + const isFileHistoryEnabled = fileHistoryEnabled(); // Add current prompt as a virtual message - const currentUUID = useMemo(randomUUID, []) + const currentUUID = useMemo(randomUUID, []); const messageOptions = useMemo( () => [ ...messages.filter(selectableUserMessagesFilter), @@ -115,62 +96,48 @@ export function MessageSelector({ } as UserMessage, ], [messages, currentUUID], - ) - const [selectedIndex, setSelectedIndex] = useState(messageOptions.length - 1) + ); + const [selectedIndex, setSelectedIndex] = useState(messageOptions.length - 1); // Orient the selected message as the middle of the visible options const firstVisibleIndex = Math.max( 0, - Math.min( - selectedIndex - Math.floor(MAX_VISIBLE_MESSAGES / 2), - messageOptions.length - MAX_VISIBLE_MESSAGES, - ), - ) + Math.min(selectedIndex - Math.floor(MAX_VISIBLE_MESSAGES / 2), messageOptions.length - MAX_VISIBLE_MESSAGES), + ); - const hasMessagesToSelect = messageOptions.length > 1 + const hasMessagesToSelect = messageOptions.length > 1; - const [messageToRestore, setMessageToRestore] = useState< - UserMessage | undefined - >(preselectedMessage) - const [diffStatsForRestore, setDiffStatsForRestore] = useState< - DiffStats | undefined - >(undefined) + const [messageToRestore, setMessageToRestore] = useState(preselectedMessage); + const [diffStatsForRestore, setDiffStatsForRestore] = useState(undefined); useEffect(() => { - if (!preselectedMessage || !isFileHistoryEnabled) return - let cancelled = false - void fileHistoryGetDiffStats(fileHistory, preselectedMessage.uuid).then( - stats => { - if (!cancelled) setDiffStatsForRestore(stats) - }, - ) + if (!preselectedMessage || !isFileHistoryEnabled) return; + let cancelled = false; + void fileHistoryGetDiffStats(fileHistory, preselectedMessage.uuid).then(stats => { + if (!cancelled) setDiffStatsForRestore(stats); + }); return () => { - cancelled = true - } - }, [preselectedMessage, isFileHistoryEnabled, fileHistory]) + cancelled = true; + }; + }, [preselectedMessage, isFileHistoryEnabled, fileHistory]); - const [isRestoring, setIsRestoring] = useState(false) - const [restoringOption, setRestoringOption] = useState( - null, - ) - const [selectedRestoreOption, setSelectedRestoreOption] = - useState('both') + const [isRestoring, setIsRestoring] = useState(false); + const [restoringOption, setRestoringOption] = useState(null); + const [selectedRestoreOption, setSelectedRestoreOption] = useState('both'); // Per-option feedback state; Select's internal inputValues Map persists // per-option text independently, so sharing one variable would desync. - const [summarizeFromFeedback, setSummarizeFromFeedback] = useState('') - const [summarizeUpToFeedback, setSummarizeUpToFeedback] = useState('') + const [summarizeFromFeedback, setSummarizeFromFeedback] = useState(''); + const [summarizeUpToFeedback, setSummarizeUpToFeedback] = useState(''); // Generate options with summarize as input type for inline context - function getRestoreOptions( - canRestoreCode: boolean, - ): OptionWithDescription[] { + function getRestoreOptions(canRestoreCode: boolean): OptionWithDescription[] { const baseOptions: OptionWithDescription[] = canRestoreCode ? [ { value: 'both', label: 'Restore code and conversation' }, { value: 'conversation', label: 'Restore conversation' }, { value: 'code', label: 'Restore code' }, ] - : [{ value: 'conversation', label: 'Restore conversation' }] + : [{ value: 'conversation', label: 'Restore conversation' }]; const summarizeInputProps = { type: 'input' as const, @@ -179,196 +146,181 @@ export function MessageSelector({ allowEmptySubmitToCancel: true, showLabelWithValue: true, labelValueSeparator: ': ', - } + }; baseOptions.push({ value: 'summarize', label: 'Summarize from here', ...summarizeInputProps, onChange: setSummarizeFromFeedback, - }) + }); if (process.env.USER_TYPE === 'ant') { baseOptions.push({ value: 'summarize_up_to', label: 'Summarize up to here', ...summarizeInputProps, onChange: setSummarizeUpToFeedback, - }) + }); } - baseOptions.push({ value: 'nevermind', label: 'Never mind' }) - return baseOptions + baseOptions.push({ value: 'nevermind', label: 'Never mind' }); + return baseOptions; } // Log when selector is opened useEffect(() => { - logEvent('tengu_message_selector_opened', {}) - }, []) + logEvent('tengu_message_selector_opened', {}); + }, []); // Helper to restore conversation without confirmation async function restoreConversationDirectly(message: UserMessage) { - onPreRestore() - setIsRestoring(true) + onPreRestore(); + setIsRestoring(true); try { - await onRestoreMessage(message) - setIsRestoring(false) - onClose() + await onRestoreMessage(message); + setIsRestoring(false); + onClose(); } catch (error) { - logError(error as Error) - setIsRestoring(false) - setError(`Failed to restore the conversation:\n${error}`) + logError(error as Error); + setIsRestoring(false); + setError(`Failed to restore the conversation:\n${error}`); } } async function handleSelect(message: UserMessage) { - const index = messages.indexOf(message) - const indexFromEnd = messages.length - 1 - index + const index = messages.indexOf(message); + const indexFromEnd = messages.length - 1 - index; logEvent('tengu_message_selector_selected', { index_from_end: indexFromEnd, - message_type: - message.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + message_type: message.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, is_current_prompt: false, - }) + }); // Do nothing if the message is not found if (!messages.includes(message)) { - onClose() - return + onClose(); + return; } if (!isFileHistoryEnabled) { - await restoreConversationDirectly(message) - return + await restoreConversationDirectly(message); + return; } - const diffStats = await fileHistoryGetDiffStats(fileHistory, message.uuid) - setMessageToRestore(message) - setDiffStatsForRestore(diffStats) + const diffStats = await fileHistoryGetDiffStats(fileHistory, message.uuid); + setMessageToRestore(message); + setDiffStatsForRestore(diffStats); } async function onSelectRestoreOption(option: RestoreOption) { logEvent('tengu_message_selector_restore_option_selected', { - option: - option as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + option: option as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); if (!messageToRestore) { - setError('Message not found.') - return + setError('Message not found.'); + return; } if (option === 'nevermind') { - if (preselectedMessage) onClose() - else setMessageToRestore(undefined) - return + if (preselectedMessage) onClose(); + else setMessageToRestore(undefined); + return; } if (isSummarizeOption(option)) { - onPreRestore() - setIsRestoring(true) - setRestoringOption(option) - setError(undefined) + onPreRestore(); + setIsRestoring(true); + setRestoringOption(option); + setError(undefined); try { - const direction = option === 'summarize_up_to' ? 'up_to' : 'from' - const feedback = - (direction === 'up_to' - ? summarizeUpToFeedback - : summarizeFromFeedback - ).trim() || undefined - await onSummarize(messageToRestore, feedback, direction) - setIsRestoring(false) - setRestoringOption(null) - setMessageToRestore(undefined) - onClose() + const direction = option === 'summarize_up_to' ? 'up_to' : 'from'; + const feedback = (direction === 'up_to' ? summarizeUpToFeedback : summarizeFromFeedback).trim() || undefined; + await onSummarize(messageToRestore, feedback, direction); + setIsRestoring(false); + setRestoringOption(null); + setMessageToRestore(undefined); + onClose(); } catch (error) { - logError(error as Error) - setIsRestoring(false) - setRestoringOption(null) - setMessageToRestore(undefined) - setError(`Failed to summarize:\n${error}`) + logError(error as Error); + setIsRestoring(false); + setRestoringOption(null); + setMessageToRestore(undefined); + setError(`Failed to summarize:\n${error}`); } - return + return; } - onPreRestore() - setIsRestoring(true) - setError(undefined) + onPreRestore(); + setIsRestoring(true); + setError(undefined); - let codeError: Error | null = null - let conversationError: Error | null = null + let codeError: Error | null = null; + let conversationError: Error | null = null; if (option === 'code' || option === 'both') { try { - await onRestoreCode(messageToRestore) + await onRestoreCode(messageToRestore); } catch (error) { - codeError = error as Error - logError(codeError) + codeError = error as Error; + logError(codeError); } } if (option === 'conversation' || option === 'both') { try { - await onRestoreMessage(messageToRestore) + await onRestoreMessage(messageToRestore); } catch (error) { - conversationError = error as Error - logError(conversationError) + conversationError = error as Error; + logError(conversationError); } } - setIsRestoring(false) - setMessageToRestore(undefined) + setIsRestoring(false); + setMessageToRestore(undefined); // Handle errors if (conversationError && codeError) { - setError( - `Failed to restore the conversation and code:\n${conversationError}\n${codeError}`, - ) + setError(`Failed to restore the conversation and code:\n${conversationError}\n${codeError}`); } else if (conversationError) { - setError(`Failed to restore the conversation:\n${conversationError}`) + setError(`Failed to restore the conversation:\n${conversationError}`); } else if (codeError) { - setError(`Failed to restore the code:\n${codeError}`) + setError(`Failed to restore the code:\n${codeError}`); } else { // Success - close the selector - onClose() + onClose(); } } - const exitState = useExitOnCtrlCDWithKeybindings() + const exitState = useExitOnCtrlCDWithKeybindings(); const handleEscape = useCallback(() => { if (messageToRestore && !preselectedMessage) { // Go back to message list instead of closing entirely - setMessageToRestore(undefined) - return + setMessageToRestore(undefined); + return; } - logEvent('tengu_message_selector_cancelled', {}) - onClose() - }, [onClose, messageToRestore, preselectedMessage]) + logEvent('tengu_message_selector_cancelled', {}); + onClose(); + }, [onClose, messageToRestore, preselectedMessage]); - const moveUp = useCallback( - () => setSelectedIndex(prev => Math.max(0, prev - 1)), - [], - ) + const moveUp = useCallback(() => setSelectedIndex(prev => Math.max(0, prev - 1)), []); const moveDown = useCallback( - () => - setSelectedIndex(prev => Math.min(messageOptions.length - 1, prev + 1)), + () => setSelectedIndex(prev => Math.min(messageOptions.length - 1, prev + 1)), [messageOptions.length], - ) - const jumpToTop = useCallback(() => setSelectedIndex(0), []) - const jumpToBottom = useCallback( - () => setSelectedIndex(messageOptions.length - 1), - [messageOptions.length], - ) + ); + const jumpToTop = useCallback(() => setSelectedIndex(0), []); + const jumpToBottom = useCallback(() => setSelectedIndex(messageOptions.length - 1), [messageOptions.length]); const handleSelectCurrent = useCallback(() => { - const selected = messageOptions[selectedIndex] + const selected = messageOptions[selectedIndex]; if (selected) { - void handleSelect(selected) + void handleSelect(selected); } - }, [messageOptions, selectedIndex, handleSelect]) + }, [messageOptions, selectedIndex, handleSelect]); // Escape to close - uses Confirmation context where escape is bound useKeybinding('confirm:no', handleEscape, { context: 'Confirmation', isActive: !messageToRestore, - }) + }); // Message selector navigation keybindings useKeybindings( @@ -381,64 +333,53 @@ export function MessageSelector({ }, { context: 'MessageSelector', - isActive: - !isRestoring && !error && !messageToRestore && hasMessagesToSelect, + isActive: !isRestoring && !error && !messageToRestore && hasMessagesToSelect, }, - ) + ); - const [fileHistoryMetadata, setFileHistoryMetadata] = useState< - Record - >({}) + const [fileHistoryMetadata, setFileHistoryMetadata] = useState>({}); useEffect(() => { async function loadFileHistoryMetadata() { if (!isFileHistoryEnabled) { - return + return; } // Load file snapshot metadata void Promise.all( messageOptions.map(async (userMessage, itemIndex) => { if (userMessage.uuid !== currentUUID) { - const canRestore = fileHistoryCanRestore( - fileHistory, - userMessage.uuid, - ) + const canRestore = fileHistoryCanRestore(fileHistory, userMessage.uuid); - const nextUserMessage = messageOptions.at(itemIndex + 1) + const nextUserMessage = messageOptions.at(itemIndex + 1); const diffStats = canRestore ? computeDiffStatsBetweenMessages( messages, userMessage.uuid, - nextUserMessage?.uuid !== currentUUID - ? nextUserMessage?.uuid - : undefined, + nextUserMessage?.uuid !== currentUUID ? nextUserMessage?.uuid : undefined, ) - : undefined + : undefined; if (diffStats !== undefined) { setFileHistoryMetadata(prev => ({ ...prev, [itemIndex]: diffStats, - })) + })); } else { setFileHistoryMetadata(prev => ({ ...prev, [itemIndex]: undefined, - })) + })); } } }), - ) + ); } - void loadFileHistoryMetadata() - }, [messageOptions, messages, currentUUID, fileHistory, isFileHistoryEnabled]) + void loadFileHistoryMetadata(); + }, [messageOptions, messages, currentUUID, fileHistory, isFileHistoryEnabled]); const canRestoreCode = - isFileHistoryEnabled && - diffStatsForRestore?.filesChanged && - diffStatsForRestore.filesChanged.length > 0 - const showPickList = - !error && !messageToRestore && !preselectedMessage && hasMessagesToSelect + isFileHistoryEnabled && diffStatsForRestore?.filesChanged && diffStatsForRestore.filesChanged.length > 0; + const showPickList = !error && !messageToRestore && !preselectedMessage && hasMessagesToSelect; return ( @@ -461,9 +402,8 @@ export function MessageSelector({ {!error && messageToRestore && hasMessagesToSelect && ( <> - Confirm you want to restore{' '} - {!diffStatsForRestore && 'the conversation '}to the point before - you sent this message: + Confirm you want to restore {!diffStatsForRestore && 'the conversation '}to the point before you sent this + message: - + ({formatRelativeTimeAgo(new Date(messageToRestore.timestamp as string | number | Date))}) @@ -499,25 +435,14 @@ export function MessageSelector({ isDisabled={isRestoring} options={getRestoreOptions(!!canRestoreCode)} defaultFocusValue={canRestoreCode ? 'both' : 'conversation'} - onFocus={value => - setSelectedRestoreOption(value as RestoreOption) - } - onChange={value => - onSelectRestoreOption(value as RestoreOption) - } - onCancel={() => - preselectedMessage - ? onClose() - : setMessageToRestore(undefined) - } + onFocus={value => setSelectedRestoreOption(value as RestoreOption)} + onChange={value => onSelectRestoreOption(value as RestoreOption)} + onCancel={() => (preselectedMessage ? onClose() : setMessageToRestore(undefined))} /> )} {canRestoreCode && ( - - {figures.warning} Rewinding does not affect files edited - manually or via bash. - + {figures.warning} Rewinding does not affect files edited manually or via bash. )} @@ -525,29 +450,21 @@ export function MessageSelector({ {showPickList && ( <> {isFileHistoryEnabled ? ( - - Restore the code and/or conversation to the point before… - + Restore the code and/or conversation to the point before… ) : ( - - Restore and fork the conversation to the point before… - + Restore and fork the conversation to the point before… )} {messageOptions - .slice( - firstVisibleIndex, - firstVisibleIndex + MAX_VISIBLE_MESSAGES, - ) + .slice(firstVisibleIndex, firstVisibleIndex + MAX_VISIBLE_MESSAGES) .map((msg, visibleOptionIndex) => { - const optionIndex = firstVisibleIndex + visibleOptionIndex - const isSelected = optionIndex === selectedIndex - const isCurrent = msg.uuid === currentUUID + const optionIndex = firstVisibleIndex + visibleOptionIndex; + const isSelected = optionIndex === selectedIndex; + const isCurrent = msg.uuid === currentUUID; - const metadataLoaded = optionIndex in fileHistoryMetadata - const metadata = fileHistoryMetadata[optionIndex] - const numFilesChanged = - metadata?.filesChanged && metadata.filesChanged.length + const metadataLoaded = optionIndex in fileHistoryMetadata; + const metadata = fileHistoryMetadata[optionIndex]; + const numFilesChanged = metadata?.filesChanged && metadata.filesChanged.length; return ( {numFilesChanged ? ( <> - {numFilesChanged === 1 && - metadata.filesChanged![0] + {numFilesChanged === 1 && metadata.filesChanged![0] ? `${path.basename(metadata.filesChanged![0])} ` : `${numFilesChanged} files changed `} @@ -602,7 +518,7 @@ export function MessageSelector({ )} - ) + ); })} @@ -612,30 +528,27 @@ export function MessageSelector({ {exitState.pending ? ( <>Press {exitState.keyName} again to exit ) : ( - <> - {!error && hasMessagesToSelect && 'Enter to continue · '}Esc to - exit - + <>{!error && hasMessagesToSelect && 'Enter to continue · '}Esc to exit )} )} - ) + ); } function getRestoreOptionConversationText(option: RestoreOption): string { switch (option) { case 'summarize': - return 'Messages after this point will be summarized.' + return 'Messages after this point will be summarized.'; case 'summarize_up_to': - return 'Preceding messages will be summarized. This and subsequent messages will remain unchanged — you will stay at the end of the conversation.' + return 'Preceding messages will be summarized. This and subsequent messages will remain unchanged — you will stay at the end of the conversation.'; case 'both': case 'conversation': - return 'The conversation will be forked.' + return 'The conversation will be forked.'; case 'code': case 'nevermind': - return 'The conversation will be unchanged.' + return 'The conversation will be unchanged.'; } } @@ -644,19 +557,15 @@ function RestoreOptionDescription({ canRestoreCode, diffStatsForRestore, }: { - selectedRestoreOption: RestoreOption - canRestoreCode: boolean - diffStatsForRestore: DiffStats | undefined + selectedRestoreOption: RestoreOption; + canRestoreCode: boolean; + diffStatsForRestore: DiffStats | undefined; }): React.ReactNode { - const showCodeRestore = - canRestoreCode && - (selectedRestoreOption === 'both' || selectedRestoreOption === 'code') + const showCodeRestore = canRestoreCode && (selectedRestoreOption === 'both' || selectedRestoreOption === 'code'); return ( - - {getRestoreOptionConversationText(selectedRestoreOption)} - + {getRestoreOptionConversationText(selectedRestoreOption)} {!isSummarizeOption(selectedRestoreOption) && (showCodeRestore ? ( @@ -664,64 +573,54 @@ function RestoreOptionDescription({ The code will be unchanged. ))} - ) + ); } function RestoreCodeConfirmation({ diffStatsForRestore, }: { - diffStatsForRestore: DiffStats | undefined + diffStatsForRestore: DiffStats | undefined; }): React.ReactNode { if (diffStatsForRestore === undefined) { - return undefined + return undefined; } - if ( - !diffStatsForRestore.filesChanged || - !diffStatsForRestore.filesChanged[0] - ) { - return ( - The code has not changed (nothing will be restored). - ) + if (!diffStatsForRestore.filesChanged || !diffStatsForRestore.filesChanged[0]) { + return The code has not changed (nothing will be restored).; } - const numFilesChanged = diffStatsForRestore.filesChanged.length + const numFilesChanged = diffStatsForRestore.filesChanged.length; - let fileLabel = '' + let fileLabel = ''; if (numFilesChanged === 1) { - fileLabel = path.basename(diffStatsForRestore.filesChanged[0] || '') + fileLabel = path.basename(diffStatsForRestore.filesChanged[0] || ''); } else if (numFilesChanged === 2) { - const file1 = path.basename(diffStatsForRestore.filesChanged[0] || '') - const file2 = path.basename(diffStatsForRestore.filesChanged[1] || '') - fileLabel = `${file1} and ${file2}` + const file1 = path.basename(diffStatsForRestore.filesChanged[0] || ''); + const file2 = path.basename(diffStatsForRestore.filesChanged[1] || ''); + fileLabel = `${file1} and ${file2}`; } else { - const file1 = path.basename(diffStatsForRestore.filesChanged[0] || '') - fileLabel = `${file1} and ${diffStatsForRestore.filesChanged.length - 1} other files` + const file1 = path.basename(diffStatsForRestore.filesChanged[0] || ''); + fileLabel = `${file1} and ${diffStatsForRestore.filesChanged.length - 1} other files`; } return ( <> - The code will be restored{' '} - in {fileLabel}. + The code will be restored in {fileLabel}. - ) + ); } -function DiffStatsText({ - diffStats, -}: { - diffStats: DiffStats | undefined -}): React.ReactNode { +function DiffStatsText({ diffStats }: { diffStats: DiffStats | undefined }): React.ReactNode { if (!diffStats || !diffStats.filesChanged) { - return undefined + return undefined; } return ( <> +{diffStats.insertions} -{diffStats.deletions} - ) + ); } function UserMessageOption({ @@ -731,13 +630,13 @@ function UserMessageOption({ isCurrent, paddingRight, }: { - userMessage: UserMessage - color?: keyof Theme - dimColor?: boolean - isCurrent: boolean - paddingRight?: number + userMessage: UserMessage; + color?: keyof Theme; + dimColor?: boolean; + isCurrent: boolean; + paddingRight?: number; }): React.ReactNode { - const { columns } = useTerminalSize() + const { columns } = useTerminalSize(); if (isCurrent) { return ( @@ -745,21 +644,20 @@ function UserMessageOption({ (current) - ) + ); } - const content = userMessage.message!.content - const lastBlock = - typeof content === 'string' ? null : content![content!.length - 1] + const content = userMessage.message!.content; + const lastBlock = typeof content === 'string' ? null : content![content!.length - 1]; const rawMessageText = typeof content === 'string' ? content.trim() : lastBlock && isTextBlock(lastBlock) ? lastBlock.text.trim() - : '(no prompt)' + : '(no prompt)'; // Strip display-unfriendly tags (like ) before showing in the list - const messageText = stripDisplayTags(rawMessageText) + const messageText = stripDisplayTags(rawMessageText); if (isEmptyMessageText(messageText)) { return ( @@ -768,12 +666,12 @@ function UserMessageOption({ ((empty message)) - ) + ); } // Bash inputs if (messageText.includes('')) { - const input = extractTag(messageText, 'bash-input') + const input = extractTag(messageText, 'bash-input'); if (input) { return ( @@ -783,15 +681,15 @@ function UserMessageOption({ {input} - ) + ); } } // Skills and slash commands if (messageText.includes(`<${COMMAND_MESSAGE_TAG}>`)) { - const commandMessage = extractTag(messageText, COMMAND_MESSAGE_TAG) - const args = extractTag(messageText, 'command-args') - const isSkillFormat = extractTag(messageText, 'skill-format') === 'true' + const commandMessage = extractTag(messageText, COMMAND_MESSAGE_TAG); + const args = extractTag(messageText, 'command-args'); + const isSkillFormat = extractTag(messageText, 'skill-format') === 'true'; if (commandMessage) { if (isSkillFormat) { // Skills: Display as "Skill(name)" @@ -801,7 +699,7 @@ function UserMessageOption({ Skill({commandMessage}) - ) + ); } else { // Slash commands: Add "/" prefix and include args return ( @@ -810,7 +708,7 @@ function UserMessageOption({ /{commandMessage} {args} - ) + ); } } } @@ -824,7 +722,7 @@ function UserMessageOption({ : messageText.slice(0, 500).split('\n').slice(0, 4).join('\n')} - ) + ); } /** @@ -835,92 +733,78 @@ function computeDiffStatsBetweenMessages( fromMessageId: UUID, toMessageId: UUID | undefined, ): DiffStats | undefined { - const startIndex = messages.findIndex(msg => msg.uuid === fromMessageId) + const startIndex = messages.findIndex(msg => msg.uuid === fromMessageId); if (startIndex === -1) { - return undefined + return undefined; } - let endIndex = toMessageId - ? messages.findIndex(msg => msg.uuid === toMessageId) - : messages.length + let endIndex = toMessageId ? messages.findIndex(msg => msg.uuid === toMessageId) : messages.length; if (endIndex === -1) { - endIndex = messages.length + endIndex = messages.length; } - const filesChanged: string[] = [] - let insertions = 0 - let deletions = 0 + const filesChanged: string[] = []; + let insertions = 0; + let deletions = 0; for (let i = startIndex + 1; i < endIndex; i++) { - const msg = messages[i] + const msg = messages[i]; if (!msg || !isToolUseResultMessage(msg)) { - continue + continue; } - const result = msg.toolUseResult as FileEditOutput | FileWriteToolOutput + const result = msg.toolUseResult as FileEditOutput | FileWriteToolOutput; if (!result || !result.filePath || !result.structuredPatch) { - continue + continue; } if (!filesChanged.includes(result.filePath)) { - filesChanged.push(result.filePath) + filesChanged.push(result.filePath); } try { if ('type' in result && result.type === 'create') { - insertions += result.content.split(/\r?\n/).length + insertions += result.content.split(/\r?\n/).length; } else { for (const hunk of result.structuredPatch) { - const additions = count(hunk.lines, line => line.startsWith('+')) - const removals = count(hunk.lines, line => line.startsWith('-')) + const additions = count(hunk.lines, line => line.startsWith('+')); + const removals = count(hunk.lines, line => line.startsWith('-')); - insertions += additions - deletions += removals + insertions += additions; + deletions += removals; } } - } catch { - continue - } + } catch {} } return { filesChanged, insertions, deletions, - } + }; } -export function selectableUserMessagesFilter( - message: Message, -): message is UserMessage { +export function selectableUserMessagesFilter(message: Message): message is UserMessage { if (message.type !== 'user') { - return false + return false; } - if ( - Array.isArray(message.message!.content) && - message.message!.content[0]?.type === 'tool_result' - ) { - return false + if (Array.isArray(message.message!.content) && message.message!.content[0]?.type === 'tool_result') { + return false; } if (isSyntheticMessage(message)) { - return false + return false; } if (message.isMeta) { - return false + return false; } if (message.isCompactSummary || message.isVisibleInTranscriptOnly) { - return false + return false; } - const content = message.message!.content - const lastBlock = - typeof content === 'string' ? null : content![content!.length - 1] + const content = message.message!.content; + const lastBlock = typeof content === 'string' ? null : content![content!.length - 1]; const messageText = - typeof content === 'string' - ? content.trim() - : lastBlock && isTextBlock(lastBlock) - ? lastBlock.text.trim() - : '' + typeof content === 'string' ? content.trim() : lastBlock && isTextBlock(lastBlock) ? lastBlock.text.trim() : ''; // Filter out non-user-authored messages (command outputs, task notifications, ticks). if ( @@ -932,9 +816,9 @@ export function selectableUserMessagesFilter( messageText.indexOf(`<${TICK_TAG}>`) !== -1 || messageText.indexOf(`<${TEAMMATE_MESSAGE_TAG}`) !== -1 ) { - return false + return false; } - return true + return true; } /** @@ -942,42 +826,37 @@ export function selectableUserMessagesFilter( * or non-meaningful content. Returns true if there's nothing meaningful to confirm - * for example, if the user hit enter then immediately cancelled. */ -export function messagesAfterAreOnlySynthetic( - messages: Message[], - fromIndex: number, -): boolean { +export function messagesAfterAreOnlySynthetic(messages: Message[], fromIndex: number): boolean { for (let i = fromIndex + 1; i < messages.length; i++) { - const msg = messages[i] - if (!msg) continue + const msg = messages[i]; + if (!msg) continue; // Skip known non-meaningful message types - if (isSyntheticMessage(msg)) continue - if (isToolUseResultMessage(msg)) continue - if (msg.type === 'progress') continue - if (msg.type === 'system') continue - if (msg.type === 'attachment') continue - if (msg.type === 'user' && msg.isMeta) continue + if (isSyntheticMessage(msg)) continue; + if (isToolUseResultMessage(msg)) continue; + if (msg.type === 'progress') continue; + if (msg.type === 'system') continue; + if (msg.type === 'attachment') continue; + if (msg.type === 'user' && msg.isMeta) continue; // Assistant with actual content = meaningful if (msg.type === 'assistant') { - const content = msg.message!.content + const content = msg.message!.content; if (Array.isArray(content)) { const hasMeaningfulContent = content.some( - block => - (block.type === 'text' && block.text.trim()) || - block.type === 'tool_use', - ) - if (hasMeaningfulContent) return false + block => (block.type === 'text' && block.text.trim()) || block.type === 'tool_use', + ); + if (hasMeaningfulContent) return false; } - continue + continue; } // User messages that aren't synthetic or meta = meaningful if (msg.type === 'user') { - return false + return false; } // Other types (e.g., tombstone) are non-meaningful, continue } - return true + return true; } diff --git a/src/components/MessageTimestamp.tsx b/src/components/MessageTimestamp.tsx index 0aa93dc5c..650d747b2 100644 --- a/src/components/MessageTimestamp.tsx +++ b/src/components/MessageTimestamp.tsx @@ -1,38 +1,34 @@ -import React from 'react' -import { Box, Text, stringWidth } from '@anthropic/ink' -import type { NormalizedMessage } from '../types/message.js' +import React from 'react'; +import { Box, Text, stringWidth } from '@anthropic/ink'; +import type { NormalizedMessage } from '../types/message.js'; type Props = { - message: NormalizedMessage - isTranscriptMode: boolean -} + message: NormalizedMessage; + isTranscriptMode: boolean; +}; -export function MessageTimestamp({ - message, - isTranscriptMode, -}: Props): React.ReactNode { +export function MessageTimestamp({ message, isTranscriptMode }: Props): React.ReactNode { const shouldShowTimestamp = isTranscriptMode && message.timestamp && message.type === 'assistant' && - (Array.isArray(message.message!.content) ? (message.message!.content as {type: string}[]).some(c => c.type === 'text') : false) + (Array.isArray(message.message!.content) + ? (message.message!.content as { type: string }[]).some(c => c.type === 'text') + : false); if (!shouldShowTimestamp) { - return null + return null; } - const formattedTimestamp = new Date(message.timestamp as string | number | Date).toLocaleTimeString( - 'en-US', - { - hour: '2-digit', - minute: '2-digit', - hour12: true, - }, - ) + const formattedTimestamp = new Date(message.timestamp as string | number | Date).toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: true, + }); return ( {formattedTimestamp} - ) + ); } diff --git a/src/components/Messages.tsx b/src/components/Messages.tsx index 7bcbe96a5..bff0a835e 100644 --- a/src/components/Messages.tsx +++ b/src/components/Messages.tsx @@ -1,37 +1,37 @@ -import { feature } from 'bun:bundle' -import chalk from 'chalk' -import type { UUID } from 'crypto' -import type { RefObject } from 'react' -import * as React from 'react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { every } from 'src/utils/set.js' -import { getIsRemoteMode } from '../bootstrap/state.js' -import type { Command } from '../commands.js' -import { BLACK_CIRCLE } from '../constants/figures.js' -import { useTerminalSize } from '../hooks/useTerminalSize.js' -import type { ScrollBoxHandle } from '@anthropic/ink' -import { useTerminalNotification } from '@anthropic/ink' -import { Box, Text } from '@anthropic/ink' -import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js' -import type { Screen } from '../screens/REPL.js' -import type { Tools } from '../Tool.js' -import { findToolByName } from '../Tool.js' -import type { AgentDefinitionsResult } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js' +import { feature } from 'bun:bundle'; +import chalk from 'chalk'; +import type { UUID } from 'crypto'; +import type { RefObject } from 'react'; +import * as React from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { every } from 'src/utils/set.js'; +import { getIsRemoteMode } from '../bootstrap/state.js'; +import type { Command } from '../commands.js'; +import { BLACK_CIRCLE } from '../constants/figures.js'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import type { ScrollBoxHandle } from '@anthropic/ink'; +import { useTerminalNotification } from '@anthropic/ink'; +import { Box, Text } from '@anthropic/ink'; +import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'; +import type { Screen } from '../screens/REPL.js'; +import type { Tools } from '../Tool.js'; +import { findToolByName } from '../Tool.js'; +import type { AgentDefinitionsResult } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'; import type { Message as MessageType, NormalizedMessage, ProgressMessage as ProgressMessageType, RenderableMessage, -} from '../types/message.js' -import { type AdvisorBlock, isAdvisorBlock } from '../utils/advisor.js' -import { collapseBackgroundBashNotifications } from '../utils/collapseBackgroundBashNotifications.js' -import { collapseHookSummaries } from '../utils/collapseHookSummaries.js' -import { collapseReadSearchGroups } from '../utils/collapseReadSearch.js' -import { collapseTeammateShutdowns } from '../utils/collapseTeammateShutdowns.js' -import { getGlobalConfig } from '../utils/config.js' -import { isEnvTruthy } from '../utils/envUtils.js' -import { isFullscreenEnvEnabled } from '../utils/fullscreen.js' -import { applyGrouping } from '../utils/groupToolUses.js' +} from '../types/message.js'; +import { type AdvisorBlock, isAdvisorBlock } from '../utils/advisor.js'; +import { collapseBackgroundBashNotifications } from '../utils/collapseBackgroundBashNotifications.js'; +import { collapseHookSummaries } from '../utils/collapseHookSummaries.js'; +import { collapseReadSearchGroups } from '../utils/collapseReadSearch.js'; +import { collapseTeammateShutdowns } from '../utils/collapseTeammateShutdowns.js'; +import { getGlobalConfig } from '../utils/config.js'; +import { isEnvTruthy } from '../utils/envUtils.js'; +import { isFullscreenEnvEnabled } from '../utils/fullscreen.js'; +import { applyGrouping } from '../utils/groupToolUses.js'; import { buildMessageLookups, createAssistantMessage, @@ -46,26 +46,26 @@ import { type StreamingThinking, type StreamingToolUse, shouldShowUserMessage, -} from '../utils/messages.js' -import { plural } from '../utils/stringUtils.js' -import { renderableSearchText } from '../utils/transcriptSearch.js' -import { Divider } from '@anthropic/ink' -import type { UnseenDivider } from './FullscreenLayout.js' -import { LogoV2 } from './LogoV2/LogoV2.js' -import { StreamingMarkdown } from './Markdown.js' -import { hasContentAfterIndex, MessageRow } from './MessageRow.js' +} from '../utils/messages.js'; +import { plural } from '../utils/stringUtils.js'; +import { renderableSearchText } from '../utils/transcriptSearch.js'; +import { Divider } from '@anthropic/ink'; +import type { UnseenDivider } from './FullscreenLayout.js'; +import { LogoV2 } from './LogoV2/LogoV2.js'; +import { StreamingMarkdown } from './Markdown.js'; +import { hasContentAfterIndex, MessageRow } from './MessageRow.js'; import { InVirtualListContext, type MessageActionsNav, MessageActionsSelectedContext, type MessageActionsState, -} from './messageActions.js' -import { AssistantThinkingMessage } from './messages/AssistantThinkingMessage.js' -import { isNullRenderingAttachment } from './messages/nullRenderingAttachments.js' -import { OffscreenFreeze } from './OffscreenFreeze.js' -import type { ToolUseConfirm } from './permissions/PermissionRequest.js' -import { StatusNotices } from './StatusNotices.js' -import type { JumpHandle } from './VirtualMessageList.js' +} from './messageActions.js'; +import { AssistantThinkingMessage } from './messages/AssistantThinkingMessage.js'; +import { isNullRenderingAttachment } from './messages/nullRenderingAttachments.js'; +import { OffscreenFreeze } from './OffscreenFreeze.js'; +import type { ToolUseConfirm } from './permissions/PermissionRequest.js'; +import { StatusNotices } from './StatusNotices.js'; +import type { JumpHandle } from './VirtualMessageList.js'; // Memoed logo header: this box is the FIRST sibling before all MessageRows // in main-screen mode. If it becomes dirty on every Messages re-render, @@ -78,7 +78,7 @@ import type { JumpHandle } from './VirtualMessageList.js' const LogoHeader = React.memo(function LogoHeader({ agentDefinitions, }: { - agentDefinitions: AgentDefinitionsResult | undefined + agentDefinitions: AgentDefinitionsResult | undefined; }): React.ReactNode { // LogoV2 has its own internal OffscreenFreeze (catches its useAppState // re-renders). This outer freeze catches agentDefinitions changes and any @@ -92,29 +92,26 @@ const LogoHeader = React.memo(function LogoHeader({ - ) -}) + ); +}); // Dead code elimination: conditional import for proactive mode /* eslint-disable @typescript-eslint/no-require-imports */ -const proactiveModule = - feature('PROACTIVE') || feature('KAIROS') - ? require('../proactive/index.js') - : null +const proactiveModule = feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/index.js') : null; const BRIEF_TOOL_NAME: string | null = feature('KAIROS') || feature('KAIROS_BRIEF') ? ( require('@claude-code-best/builtin-tools/tools/BriefTool/prompt.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/prompt.js') ).BRIEF_TOOL_NAME - : null + : null; const SEND_USER_FILE_TOOL_NAME: string | null = feature('KAIROS') ? ( require('@claude-code-best/builtin-tools/tools/SendUserFileTool/prompt.js') as typeof import('@claude-code-best/builtin-tools/tools/SendUserFileTool/prompt.js') ).SEND_USER_FILE_TOOL_NAME - : null + : null; /* eslint-enable @typescript-eslint/no-require-imports */ -import { VirtualMessageList } from './VirtualMessageList.js' +import { VirtualMessageList } from './VirtualMessageList.js'; /** * In brief-only mode, filter messages to show ONLY Brief tool_use blocks, @@ -124,59 +121,56 @@ import { VirtualMessageList } from './VirtualMessageList.js' */ export function filterForBriefTool< T extends { - type: string - subtype?: string - isMeta?: boolean - isApiErrorMessage?: boolean + type: string; + subtype?: string; + isMeta?: boolean; + isApiErrorMessage?: boolean; message?: { content: Array<{ - type: string - name?: string - tool_use_id?: string - }> - } + type: string; + name?: string; + tool_use_id?: string; + }>; + }; attachment?: { - type: string - isMeta?: boolean - origin?: unknown - commandMode?: string - } + type: string; + isMeta?: boolean; + origin?: unknown; + commandMode?: string; + }; }, >(messages: T[], briefToolNames: string[]): T[] { - const nameSet = new Set(briefToolNames) + const nameSet = new Set(briefToolNames); // tool_use always precedes its tool_result in the array, so we can collect // IDs and match against them in a single pass. - const briefToolUseIDs = new Set() + const briefToolUseIDs = new Set(); return messages.filter(msg => { // System messages (attach confirmation, remote errors, compact boundaries) // must stay visible — dropping them leaves the viewer with no feedback. // Exception: api_metrics is per-turn debug noise (TTFT, config writes, // hook timing) that defeats the point of brief mode. Still visible in // transcript mode (ctrl+o) which bypasses this filter. - if (msg.type === 'system') return msg.subtype !== 'api_metrics' - const block = msg.message?.content[0] + if (msg.type === 'system') return msg.subtype !== 'api_metrics'; + const block = msg.message?.content[0]; if (msg.type === 'assistant') { // API error messages (auth failures, rate limits, etc.) must stay visible - if (msg.isApiErrorMessage) return true + if (msg.isApiErrorMessage) return true; // Keep Brief tool_use blocks (renders with standard tool call chrome, // and must be in the list so buildMessageLookups can resolve tool results) if (block?.type === 'tool_use' && block.name && nameSet.has(block.name)) { if ('id' in block) { - briefToolUseIDs.add((block as { id: string }).id) + briefToolUseIDs.add((block as { id: string }).id); } - return true + return true; } - return false + return false; } if (msg.type === 'user') { if (block?.type === 'tool_result') { - return ( - block.tool_use_id !== undefined && - briefToolUseIDs.has(block.tool_use_id) - ) + return block.tool_use_id !== undefined && briefToolUseIDs.has(block.tool_use_id); } // Real user input only — drop meta/tick messages. - return !msg.isMeta + return !msg.isMeta; } if (msg.type === 'attachment') { // Human input drained mid-turn arrives as a queued_command attachment @@ -185,16 +179,11 @@ export function filterForBriefTool< // identifies human-typed input; task-notification callers set // mode: 'task-notification' but not origin/isMeta, so the positive // commandMode check is required to exclude them. - const att = msg.attachment - return ( - att?.type === 'queued_command' && - att.commandMode === 'prompt' && - !att.isMeta && - att.origin === undefined - ) + const att = msg.attachment; + return att?.type === 'queued_command' && att.commandMode === 'prompt' && !att.isMeta && att.origin === undefined; } - return false - }) + return false; + }); } /** @@ -208,119 +197,113 @@ export function filterForBriefTool< */ export function dropTextInBriefTurns< T extends { - type: string - isMeta?: boolean - message?: { content: Array<{ type: string; name?: string }> } + type: string; + isMeta?: boolean; + message?: { content: Array<{ type: string; name?: string }> }; }, >(messages: T[], briefToolNames: string[]): T[] { - const nameSet = new Set(briefToolNames) + const nameSet = new Set(briefToolNames); // First pass: find which turns (bounded by non-meta user messages) contain // a Brief tool_use. Tag each assistant text block with its turn index. - const turnsWithBrief = new Set() - const textIndexToTurn: number[] = [] - let turn = 0 + const turnsWithBrief = new Set(); + const textIndexToTurn: number[] = []; + let turn = 0; for (let i = 0; i < messages.length; i++) { - const msg = messages[i]! - const block = msg.message?.content[0] + const msg = messages[i]!; + const block = msg.message?.content[0]; if (msg.type === 'user' && block?.type !== 'tool_result' && !msg.isMeta) { - turn++ - continue + turn++; + continue; } if (msg.type === 'assistant') { if (block?.type === 'text') { - textIndexToTurn[i] = turn - } else if ( - block?.type === 'tool_use' && - block.name && - nameSet.has(block.name) - ) { - turnsWithBrief.add(turn) + textIndexToTurn[i] = turn; + } else if (block?.type === 'tool_use' && block.name && nameSet.has(block.name)) { + turnsWithBrief.add(turn); } } } - if (turnsWithBrief.size === 0) return messages + if (turnsWithBrief.size === 0) return messages; // Second pass: drop text blocks whose turn called Brief. return messages.filter((_, i) => { - const t = textIndexToTurn[i] - return t === undefined || !turnsWithBrief.has(t) - }) + const t = textIndexToTurn[i]; + return t === undefined || !turnsWithBrief.has(t); + }); } type Props = { - messages: MessageType[] - tools: Tools - commands: Command[] - verbose: boolean + messages: MessageType[]; + tools: Tools; + commands: Command[]; + verbose: boolean; toolJSX: { - jsx: React.ReactNode | null - shouldHidePromptInput: boolean - shouldContinueAnimation?: true - } | null - toolUseConfirmQueue: ToolUseConfirm[] - inProgressToolUseIDs: Set - isMessageSelectorVisible: boolean - conversationId: string - screen: Screen - streamingToolUses: StreamingToolUse[] - showAllInTranscript?: boolean - agentDefinitions?: AgentDefinitionsResult - onOpenRateLimitOptions?: () => void + jsx: React.ReactNode | null; + shouldHidePromptInput: boolean; + shouldContinueAnimation?: true; + } | null; + toolUseConfirmQueue: ToolUseConfirm[]; + inProgressToolUseIDs: Set; + isMessageSelectorVisible: boolean; + conversationId: string; + screen: Screen; + streamingToolUses: StreamingToolUse[]; + showAllInTranscript?: boolean; + agentDefinitions?: AgentDefinitionsResult; + onOpenRateLimitOptions?: () => void; /** Hide the logo/header - used for subagent zoom view */ - hideLogo?: boolean - isLoading: boolean + hideLogo?: boolean; + isLoading: boolean; /** In transcript mode, hide all thinking blocks except the last one */ - hidePastThinking?: boolean + hidePastThinking?: boolean; /** Streaming thinking content (live updates, not frozen) */ - streamingThinking?: StreamingThinking | null + streamingThinking?: StreamingThinking | null; /** Streaming text preview (rendered as last item so transition to final message is positionally seamless) */ - streamingText?: string | null + streamingText?: string | null; /** When true, only show Brief tool output (hide everything else) */ - isBriefOnly?: boolean + isBriefOnly?: boolean; /** Fullscreen-mode "─── N new ───" divider. Renders before the first * renderableMessage derived from firstUnseenUuid (matched by the 24-char * prefix that deriveUUID preserves). */ - unseenDivider?: UnseenDivider + unseenDivider?: UnseenDivider; /** Fullscreen-mode ScrollBox handle. Enables React-level virtualization when present. */ - scrollRef?: RefObject + scrollRef?: RefObject; /** Fullscreen-mode: enable sticky-prompt tracking (writes via ScrollChromeContext). */ - trackStickyPrompt?: boolean + trackStickyPrompt?: boolean; /** Transcript search: jump-to-index + setSearchQuery/nextMatch/prevMatch. */ - jumpRef?: RefObject + jumpRef?: RefObject; /** Transcript search: fires when match count/position changes. */ - onSearchMatchesChange?: (count: number, current: number) => void + onSearchMatchesChange?: (count: number, current: number) => void; /** Paint an existing DOM subtree to fresh Screen, scan. Element comes * from the main tree (all real providers). Message-relative positions. */ - scanElement?: ( - el: import('@anthropic/ink').DOMElement, - ) => import('@anthropic/ink').MatchPosition[] + scanElement?: (el: import('@anthropic/ink').DOMElement) => import('@anthropic/ink').MatchPosition[]; /** Position-based CURRENT highlight. positions stable (msg-relative), * rowOffset tracks scroll. null clears. */ setPositions?: ( state: { - positions: import('@anthropic/ink').MatchPosition[] - rowOffset: number - currentIdx: number + positions: import('@anthropic/ink').MatchPosition[]; + rowOffset: number; + currentIdx: number; } | null, - ) => void + ) => void; /** Bypass MAX_MESSAGES_WITHOUT_VIRTUALIZATION. For one-shot headless renders * (e.g. /export via renderToString) where the memory concern doesn't apply * and the "already in scrollback" justification doesn't hold. */ - disableRenderCap?: boolean + disableRenderCap?: boolean; /** In-transcript cursor; expanded overrides verbose for selected message. */ - cursor?: MessageActionsState | null - setCursor?: (cursor: MessageActionsState | null) => void + cursor?: MessageActionsState | null; + setCursor?: (cursor: MessageActionsState | null) => void; /** Passed through to VirtualMessageList (heightCache owns visibility). */ - cursorNavRef?: React.Ref + cursorNavRef?: React.Ref; /** Render only collapsed.slice(start, end). For chunked headless export * (streamRenderedMessages in exportRenderer.tsx): prep runs on the FULL * messages array so grouping/lookups are correct, but only this slice * chunk instead of the full session. The logo renders only for chunk 0 * (start === 0); later chunks are mid-stream continuations. * Measured Mar 2026: 538-msg session, 20 slices → −55% plateau RSS. */ - renderRange?: readonly [start: number, end: number] -} + renderRange?: readonly [start: number, end: number]; +}; -const MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE = 30 +const MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE = 30; // Safety cap for the non-virtualized render path (fullscreen off or // explicitly disabled). Ink mounts a full fiber tree per message (~250 KB @@ -351,10 +334,10 @@ const MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE = 30 // slice roughly where it was instead of resetting to 0 — which would // jump from ~200 rendered messages to the full history, orphaning // in-progress badge snapshots in scrollback. -const MAX_MESSAGES_WITHOUT_VIRTUALIZATION = 200 -const MESSAGE_CAP_STEP = 50 +const MAX_MESSAGES_WITHOUT_VIRTUALIZATION = 200; +const MESSAGE_CAP_STEP = 50; -export type SliceAnchor = { uuid: string; idx: number } | null +export type SliceAnchor = { uuid: string; idx: number } | null; /** Exported for testing. Mutates anchorRef when the window needs to advance. */ export function computeSliceStart( @@ -363,33 +346,23 @@ export function computeSliceStart( cap = MAX_MESSAGES_WITHOUT_VIRTUALIZATION, step = MESSAGE_CAP_STEP, ): number { - const anchor = anchorRef.current - const anchorIdx = anchor - ? collapsed.findIndex(m => m.uuid === anchor.uuid) - : -1 + const anchor = anchorRef.current; + const anchorIdx = anchor ? collapsed.findIndex(m => m.uuid === anchor.uuid) : -1; // Anchor found → use it. Anchor lost → fall back to stored index // (clamped) so collapse-regrouping uuid churn doesn't reset to 0. - let start = - anchorIdx >= 0 - ? anchorIdx - : anchor - ? Math.min(anchor.idx, Math.max(0, collapsed.length - cap)) - : 0 + let start = anchorIdx >= 0 ? anchorIdx : anchor ? Math.min(anchor.idx, Math.max(0, collapsed.length - cap)) : 0; if (collapsed.length - start > cap + step) { - start = collapsed.length - cap + start = collapsed.length - cap; } // Refresh anchor from whatever lives at the current start — heals a // stale uuid after fallback and captures a new one after advancement. - const msgAtStart = collapsed[start] - if ( - msgAtStart && - (anchor?.uuid !== msgAtStart.uuid || anchor.idx !== start) - ) { - anchorRef.current = { uuid: msgAtStart.uuid, idx: start } + const msgAtStart = collapsed[start]; + if (msgAtStart && (anchor?.uuid !== msgAtStart.uuid || anchor.idx !== start)) { + anchorRef.current = { uuid: msgAtStart.uuid, idx: start }; } else if (!msgAtStart && anchor) { - anchorRef.current = null + anchorRef.current = null; } - return start + return start; } const MessagesImpl = ({ @@ -426,139 +399,118 @@ const MessagesImpl = ({ cursorNavRef, renderRange, }: Props): React.ReactNode => { - const { columns } = useTerminalSize() - const toggleShowAllShortcut = useShortcutDisplay( - 'transcript:toggleShowAll', - 'Transcript', - 'Ctrl+E', - ) + const { columns } = useTerminalSize(); + const toggleShowAllShortcut = useShortcutDisplay('transcript:toggleShowAll', 'Transcript', 'Ctrl+E'); - const normalizedMessages = useMemo( - () => normalizeMessages(messages).filter(isNotEmptyMessage), - [messages], - ) + const normalizedMessages = useMemo(() => normalizeMessages(messages).filter(isNotEmptyMessage), [messages]); // Check if streaming thinking should be visible (streaming or within 30s timeout) const isStreamingThinkingVisible = useMemo(() => { - if (!streamingThinking) return false - if (streamingThinking.isStreaming) return true + if (!streamingThinking) return false; + if (streamingThinking.isStreaming) return true; if (streamingThinking.streamingEndedAt) { - return Date.now() - streamingThinking.streamingEndedAt < 30000 + return Date.now() - streamingThinking.streamingEndedAt < 30000; } - return false - }, [streamingThinking]) + return false; + }, [streamingThinking]); // Find the last thinking block (message UUID + content index) for hiding past thinking in transcript mode // When streaming thinking is visible, use a special ID that won't match any completed thinking block // With adaptive thinking, only consider thinking blocks from the current turn and stop searching once we // hit the last user message. const lastThinkingBlockId = useMemo(() => { - if (!hidePastThinking) return null + if (!hidePastThinking) return null; // If streaming thinking is visible, hide all completed thinking blocks by using a non-matching ID - if (isStreamingThinkingVisible) return 'streaming' + if (isStreamingThinkingVisible) return 'streaming'; // Iterate backwards to find the last message with a thinking block for (let i = normalizedMessages.length - 1; i >= 0; i--) { - const msg = normalizedMessages[i] + const msg = normalizedMessages[i]; if (msg?.type === 'assistant') { - const content = msg.message!.content as Array<{ type: string }> + const content = msg.message!.content as Array<{ type: string }>; // Find the last thinking block in this message for (let j = content.length - 1; j >= 0; j--) { if (content[j]?.type === 'thinking') { - return `${msg.uuid}:${j}` + return `${msg.uuid}:${j}`; } } } else if (msg?.type === 'user') { - const content = msg.message!.content as Array<{ type: string }> - const hasToolResult = content.some( - block => block.type === 'tool_result', - ) + const content = msg.message!.content as Array<{ type: string }>; + const hasToolResult = content.some(block => block.type === 'tool_result'); if (!hasToolResult) { // Reached a previous user turn so don't show stale thinking from before - return 'no-thinking' + return 'no-thinking'; } } } - return null - }, [normalizedMessages, hidePastThinking, isStreamingThinkingVisible]) + return null; + }, [normalizedMessages, hidePastThinking, isStreamingThinkingVisible]); // Find the latest user bash output message (from ! commands) // This allows us to show full output for the most recent bash command const latestBashOutputUUID = useMemo(() => { // Iterate backwards to find the last user message with bash output for (let i = normalizedMessages.length - 1; i >= 0; i--) { - const msg = normalizedMessages[i] + const msg = normalizedMessages[i]; if (msg?.type === 'user') { - const content = msg.message!.content as Array<{ type: string; text?: string }> + const content = msg.message!.content as Array<{ type: string; text?: string }>; // Check if any text content is bash output for (const block of content) { if (block.type === 'text') { - const text = block.text ?? '' - if ( - text.startsWith(' getToolUseIDs(normalizedMessages), - [normalizedMessages], - ) + const normalizedToolUseIDs = useMemo(() => getToolUseIDs(normalizedMessages), [normalizedMessages]); const streamingToolUsesWithoutInProgress = useMemo( () => streamingToolUses.filter( - stu => - !inProgressToolUseIDs.has(stu.contentBlock.id) && - !normalizedToolUseIDs.has(stu.contentBlock.id), + stu => !inProgressToolUseIDs.has(stu.contentBlock.id) && !normalizedToolUseIDs.has(stu.contentBlock.id), ), [streamingToolUses, inProgressToolUseIDs, normalizedToolUseIDs], - ) + ); const syntheticStreamingToolUseMessages = useMemo( () => streamingToolUsesWithoutInProgress.flatMap(streamingToolUse => { const msg = createAssistantMessage({ content: [streamingToolUse.contentBlock], - }) + }); // Override randomUUID with deterministic value derived from content // block ID to prevent React key changes on every memo recomputation. // Same class of bug fixed in normalizeMessages (commit 383326e613): // fresh randomUUID → unstable React keys → component remounts → // Ink rendering corruption (overlapping text from stale DOM nodes). - msg.uuid = deriveUUID(streamingToolUse.contentBlock.id as UUID, 0) - return normalizeMessages([msg]) + msg.uuid = deriveUUID(streamingToolUse.contentBlock.id as UUID, 0); + return normalizeMessages([msg]); }), [streamingToolUsesWithoutInProgress], - ) + ); - const isTranscriptMode = screen === 'transcript' + const isTranscriptMode = screen === 'transcript'; // Hoisted to mount-time — this component re-renders on every scroll. - const disableVirtualScroll = useMemo( - () => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL), - [], - ) + const disableVirtualScroll = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL), []); // Virtual scroll replaces the transcript cap: everything is scrollable and // memory is bounded by the mounted-item count, not the total. scrollRef is // only passed when isFullscreenEnvEnabled() is true (REPL.tsx gates it), // so scrollRef's presence is the signal. - const virtualScrollRuntimeGate = scrollRef != null && !disableVirtualScroll - const shouldTruncate = - isTranscriptMode && !showAllInTranscript && !virtualScrollRuntimeGate + const virtualScrollRuntimeGate = scrollRef != null && !disableVirtualScroll; + const shouldTruncate = isTranscriptMode && !showAllInTranscript && !virtualScrollRuntimeGate; // Anchor for the first rendered message in the non-virtualized cap slice. // Monotonic advance only — mutation during render is idempotent (safe // under StrictMode double-render). See MAX_MESSAGES_WITHOUT_VIRTUALIZATION // comment above for why this replaced count-based slicing. - const sliceAnchorRef = useRef(null) + const sliceAnchorRef = useRef(null); // Expensive message transforms — filter, reorder, group, collapse, lookups. // All O(n) over 27k messages. Split from the renderRange slice so scrolling @@ -566,107 +518,87 @@ const MessagesImpl = ({ // useMemo included renderRange → every scroll rebuilt 6 Maps over 27k // messages + 4 filter/map passes = ~50ms alloc per scroll → GC pressure → // 100-173ms stop-the-world pauses on the 1GB heap. - const { collapsed, lookups, hasTruncatedMessages, hiddenMessageCount } = - useMemo(() => { - // In fullscreen mode the alt buffer has no native scrollback, so the - // compact-boundary filter just hides history the ScrollBox could - // otherwise scroll to. Main-screen mode keeps the filter — pre-compact - // rows live above the viewport in native scrollback there, and - // re-rendering them triggers full resets. - // includeSnipped: UI rendering keeps snipped messages for scrollback - // (this PR's core goal — full history in UI, filter only for the model). - // Also avoids a UUID mismatch: normalizeMessages derives new UUIDs, so - // projectSnippedView's check against original removedUuids would fail. - const compactAwareMessages = - verbose || isFullscreenEnvEnabled() - ? normalizedMessages - : getMessagesAfterCompactBoundary(normalizedMessages, { - includeSnipped: true, - }) + const { collapsed, lookups, hasTruncatedMessages, hiddenMessageCount } = useMemo(() => { + // In fullscreen mode the alt buffer has no native scrollback, so the + // compact-boundary filter just hides history the ScrollBox could + // otherwise scroll to. Main-screen mode keeps the filter — pre-compact + // rows live above the viewport in native scrollback there, and + // re-rendering them triggers full resets. + // includeSnipped: UI rendering keeps snipped messages for scrollback + // (this PR's core goal — full history in UI, filter only for the model). + // Also avoids a UUID mismatch: normalizeMessages derives new UUIDs, so + // projectSnippedView's check against original removedUuids would fail. + const compactAwareMessages = + verbose || isFullscreenEnvEnabled() + ? normalizedMessages + : getMessagesAfterCompactBoundary(normalizedMessages, { + includeSnipped: true, + }); - const messagesToShowNotTruncated = reorderMessagesInUI( - compactAwareMessages - .filter( - (msg): msg is Exclude => - msg.type !== 'progress', - ) - // CC-724: drop attachment messages that AttachmentMessage renders as - // null (hook_success, hook_additional_context, hook_cancelled, etc.) - // BEFORE counting/slicing so they don't inflate the "N messages" - // count in ctrl-o or consume slots in the 200-message render cap. - .filter(msg => !isNullRenderingAttachment(msg)) - .filter(_ => shouldShowUserMessage(_, isTranscriptMode)) as Parameters[0], - syntheticStreamingToolUseMessages, - ) - // Three-tier filtering. Transcript mode (ctrl+o screen) is truly unfiltered. - // Brief-only: SendUserMessage + user input only. Default: drop redundant - // assistant text in turns where SendUserMessage was called (the model's - // text is working-notes that duplicate the SendUserMessage content). - const briefToolNames = [BRIEF_TOOL_NAME, SEND_USER_FILE_TOOL_NAME].filter( - (n): n is string => n !== null, - ) - // dropTextInBriefTurns should only trigger on SendUserMessage turns — - // SendUserFile delivers a file without replacement text, so dropping - // assistant text for file-only turns would leave the user with no context. - const dropTextToolNames = [BRIEF_TOOL_NAME].filter( - (n): n is string => n !== null, - ) - const briefFiltered = - briefToolNames.length > 0 && !isTranscriptMode - ? isBriefOnly - ? filterForBriefTool(messagesToShowNotTruncated as Parameters[0], briefToolNames) - : dropTextToolNames.length > 0 - ? dropTextInBriefTurns( - messagesToShowNotTruncated as Parameters[0], - dropTextToolNames, - ) - : messagesToShowNotTruncated - : messagesToShowNotTruncated - - const messagesToShow = shouldTruncate - ? briefFiltered.slice(-MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE) - : briefFiltered - - const hasTruncatedMessages = - shouldTruncate && - briefFiltered.length > MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE - - const { messages: groupedMessages } = applyGrouping( - messagesToShow as MessageType[], - tools, - verbose, - ) - - const collapsed = collapseBackgroundBashNotifications( - collapseHookSummaries( - collapseTeammateShutdowns( - collapseReadSearchGroups(groupedMessages, tools), - ), - ), - verbose, - ) - - const lookups = buildMessageLookups(normalizedMessages, messagesToShow as MessageType[]) - - const hiddenMessageCount = - messagesToShowNotTruncated.length - - MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE - - return { - collapsed, - lookups, - hasTruncatedMessages, - hiddenMessageCount, - } - }, [ - verbose, - normalizedMessages, - isTranscriptMode, + const messagesToShowNotTruncated = reorderMessagesInUI( + compactAwareMessages + .filter((msg): msg is Exclude => msg.type !== 'progress') + // CC-724: drop attachment messages that AttachmentMessage renders as + // null (hook_success, hook_additional_context, hook_cancelled, etc.) + // BEFORE counting/slicing so they don't inflate the "N messages" + // count in ctrl-o or consume slots in the 200-message render cap. + .filter(msg => !isNullRenderingAttachment(msg)) + .filter(_ => shouldShowUserMessage(_, isTranscriptMode)) as Parameters[0], syntheticStreamingToolUseMessages, - shouldTruncate, - tools, - isBriefOnly, - ]) + ); + // Three-tier filtering. Transcript mode (ctrl+o screen) is truly unfiltered. + // Brief-only: SendUserMessage + user input only. Default: drop redundant + // assistant text in turns where SendUserMessage was called (the model's + // text is working-notes that duplicate the SendUserMessage content). + const briefToolNames = [BRIEF_TOOL_NAME, SEND_USER_FILE_TOOL_NAME].filter((n): n is string => n !== null); + // dropTextInBriefTurns should only trigger on SendUserMessage turns — + // SendUserFile delivers a file without replacement text, so dropping + // assistant text for file-only turns would leave the user with no context. + const dropTextToolNames = [BRIEF_TOOL_NAME].filter((n): n is string => n !== null); + const briefFiltered = + briefToolNames.length > 0 && !isTranscriptMode + ? isBriefOnly + ? filterForBriefTool(messagesToShowNotTruncated as Parameters[0], briefToolNames) + : dropTextToolNames.length > 0 + ? dropTextInBriefTurns( + messagesToShowNotTruncated as Parameters[0], + dropTextToolNames, + ) + : messagesToShowNotTruncated + : messagesToShowNotTruncated; + + const messagesToShow = shouldTruncate + ? briefFiltered.slice(-MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE) + : briefFiltered; + + const hasTruncatedMessages = shouldTruncate && briefFiltered.length > MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE; + + const { messages: groupedMessages } = applyGrouping(messagesToShow as MessageType[], tools, verbose); + + const collapsed = collapseBackgroundBashNotifications( + collapseHookSummaries(collapseTeammateShutdowns(collapseReadSearchGroups(groupedMessages, tools))), + verbose, + ); + + const lookups = buildMessageLookups(normalizedMessages, messagesToShow as MessageType[]); + + const hiddenMessageCount = messagesToShowNotTruncated.length - MAX_MESSAGES_TO_SHOW_IN_TRANSCRIPT_MODE; + + return { + collapsed, + lookups, + hasTruncatedMessages, + hiddenMessageCount, + }; + }, [ + verbose, + normalizedMessages, + isTranscriptMode, + syntheticStreamingToolUseMessages, + shouldTruncate, + tools, + isBriefOnly, + ]); // Cheap slice — only runs when scroll range or slice config changes. const renderableMessages = useMemo(() => { @@ -677,57 +609,52 @@ const MessagesImpl = ({ // component's lifetime (scrollRef is either always passed or never). // renderRange is first: the chunked export path slices the // post-grouping array so each chunk gets correct tool-call grouping. - const capApplies = !virtualScrollRuntimeGate && !disableRenderCap - const sliceStart = capApplies - ? computeSliceStart(collapsed, sliceAnchorRef) - : 0 + const capApplies = !virtualScrollRuntimeGate && !disableRenderCap; + const sliceStart = capApplies ? computeSliceStart(collapsed, sliceAnchorRef) : 0; return renderRange ? collapsed.slice(renderRange[0], renderRange[1]) : sliceStart > 0 ? collapsed.slice(sliceStart) - : collapsed - }, [collapsed, renderRange, virtualScrollRuntimeGate, disableRenderCap]) + : collapsed; + }, [collapsed, renderRange, virtualScrollRuntimeGate, disableRenderCap]); const streamingToolUseIDs = useMemo( () => new Set(streamingToolUses.map(_ => _.contentBlock.id)), [streamingToolUses], - ) + ); // Divider insertion point: first renderableMessage whose uuid shares the // 24-char prefix with firstUnseenUuid (deriveUUID keeps the first 24 // chars of the source message uuid, so this matches any block from it). const dividerBeforeIndex = useMemo(() => { - if (!unseenDivider) return -1 - const prefix = unseenDivider.firstUnseenUuid.slice(0, 24) - return renderableMessages.findIndex(m => m.uuid.slice(0, 24) === prefix) - }, [unseenDivider, renderableMessages]) + if (!unseenDivider) return -1; + const prefix = unseenDivider.firstUnseenUuid.slice(0, 24); + return renderableMessages.findIndex(m => m.uuid.slice(0, 24) === prefix); + }, [unseenDivider, renderableMessages]); const selectedIdx = useMemo(() => { - if (!cursor) return -1 - return renderableMessages.findIndex(m => m.uuid === cursor.uuid) - }, [cursor, renderableMessages]) + if (!cursor) return -1; + return renderableMessages.findIndex(m => m.uuid === cursor.uuid); + }, [cursor, renderableMessages]); // Fullscreen: click a message to toggle verbose rendering for it. Keyed by // tool_use_id where available so a tool_use and its tool_result (separate // rows) expand together; falls back to uuid for groups/thinking. Stale keys // are harmless — they never match anything in renderableMessages. - const [expandedKeys, setExpandedKeys] = useState>( - () => new Set(), - ) + const [expandedKeys, setExpandedKeys] = useState>(() => new Set()); const onItemClick = useCallback((msg: RenderableMessage) => { - const k = expandKey(msg) + const k = expandKey(msg); setExpandedKeys(prev => { - const next = new Set(prev) - if (next.has(k)) next.delete(k) - else next.add(k) - return next - }) - }, []) + const next = new Set(prev); + if (next.has(k)) next.delete(k); + else next.add(k); + return next; + }); + }, []); const isItemExpanded = useCallback( - (msg: RenderableMessage) => - expandedKeys.size > 0 && expandedKeys.has(expandKey(msg)), + (msg: RenderableMessage) => expandedKeys.size > 0 && expandedKeys.has(expandKey(msg)), [expandedKeys], - ) + ); // Only hover/click messages where the verbose toggle reveals more: // collapsed read/search groups, or tool results that self-report truncation // via isResultTruncated. Callback must be stable across message updates: if @@ -735,70 +662,62 @@ const MessagesImpl = ({ // attaches after the mouse is already inside → hover never fires. tools is // session-stable; lookups is read via ref so the callback doesn't churn on // every new message. - const lookupsRef = useRef(lookups) - lookupsRef.current = lookups + const lookupsRef = useRef(lookups); + lookupsRef.current = lookups; const isItemClickable = useCallback( (msg: RenderableMessage): boolean => { - if (msg.type === 'collapsed_read_search') return true + if (msg.type === 'collapsed_read_search') return true; if (msg.type === 'assistant') { - const content = msg.message!.content - const b = (Array.isArray(content) ? content[0] : undefined) as unknown as AdvisorBlock | undefined + const content = msg.message!.content; + const b = (Array.isArray(content) ? content[0] : undefined) as unknown as AdvisorBlock | undefined; return ( - b != null && - isAdvisorBlock(b) && - b.type === 'advisor_tool_result' && - b.content.type === 'advisor_result' - ) + b != null && isAdvisorBlock(b) && b.type === 'advisor_tool_result' && b.content.type === 'advisor_result' + ); } - if (msg.type !== 'user') return false - const b = (msg.message!.content as Array<{ type: string; tool_use_id?: string; is_error?: boolean; [key: string]: unknown }>)[0] - if (b?.type !== 'tool_result' || b.is_error || !msg.toolUseResult) - return false - const name = lookupsRef.current.toolUseByToolUseID.get( - b.tool_use_id ?? '', - )?.name - const tool = name ? findToolByName(tools, name) : undefined - return tool?.isResultTruncated?.(msg.toolUseResult as never) ?? false + if (msg.type !== 'user') return false; + const b = ( + msg.message!.content as Array<{ + type: string; + tool_use_id?: string; + is_error?: boolean; + [key: string]: unknown; + }> + )[0]; + if (b?.type !== 'tool_result' || b.is_error || !msg.toolUseResult) return false; + const name = lookupsRef.current.toolUseByToolUseID.get(b.tool_use_id ?? '')?.name; + const tool = name ? findToolByName(tools, name) : undefined; + return tool?.isResultTruncated?.(msg.toolUseResult as never) ?? false; }, [tools], - ) + ); const canAnimate = - (!toolJSX || !!toolJSX.shouldContinueAnimation) && - !toolUseConfirmQueue.length && - !isMessageSelectorVisible + (!toolJSX || !!toolJSX.shouldContinueAnimation) && !toolUseConfirmQueue.length && !isMessageSelectorVisible; - const hasToolsInProgress = inProgressToolUseIDs.size > 0 + const hasToolsInProgress = inProgressToolUseIDs.size > 0; // Report progress to terminal (for terminals that support OSC 9;4) - const { progress } = useTerminalNotification() - const prevProgressState = useRef(null) + const { progress } = useTerminalNotification(); + const prevProgressState = useRef(null); const progressEnabled = getGlobalConfig().terminalProgressBarEnabled && !getIsRemoteMode() && - !(proactiveModule?.isProactiveActive() ?? false) + !(proactiveModule?.isProactiveActive() ?? false); useEffect(() => { - const state = progressEnabled - ? hasToolsInProgress - ? 'indeterminate' - : 'completed' - : null - if (prevProgressState.current === state) return - prevProgressState.current = state - progress(state) - }, [progress, progressEnabled, hasToolsInProgress]) + const state = progressEnabled ? (hasToolsInProgress ? 'indeterminate' : 'completed') : null; + if (prevProgressState.current === state) return; + prevProgressState.current = state; + progress(state); + }, [progress, progressEnabled, hasToolsInProgress]); useEffect(() => { - return () => progress(null) - }, [progress]) + return () => progress(null); + }, [progress]); - const messageKey = useCallback( - (msg: RenderableMessage) => `${msg.uuid}-${conversationId}`, - [conversationId], - ) + const messageKey = useCallback((msg: RenderableMessage) => `${msg.uuid}-${conversationId}`, [conversationId]); const renderMessageRow = (msg: RenderableMessage, index: number) => { - const prevType = index > 0 ? renderableMessages[index - 1]?.type : undefined - const isUserContinuation = msg.type === 'user' && prevType === 'user' + const prevType = index > 0 ? renderableMessages[index - 1]?.type : undefined; + const isUserContinuation = msg.type === 'user' && prevType === 'user'; // hasContentAfter is only consumed for collapsed_read_search groups; // skip the scan for everything else. streamingText is rendered as a // sibling after this map, so it's never in renderableMessages — OR it @@ -806,15 +725,9 @@ const MessagesImpl = ({ // streaming instead of waiting for the block to finalize. const hasContentAfter = msg.type === 'collapsed_read_search' && - (!!streamingText || - hasContentAfterIndex( - renderableMessages, - index, - tools, - streamingToolUseIDs, - )) + (!!streamingText || hasContentAfterIndex(renderableMessages, index, tools, streamingToolUseIDs)); - const k = messageKey(msg) + const k = messageKey(msg); const row = ( - ) + ); // Per-row Provider — only 2 rows re-render on selection change. // Wrapped BEFORE divider branch so both return paths get it. const wrapped = ( - + {row} - ) + ); if (unseenDivider && index === dividerBeforeIndex) { return [ @@ -862,10 +768,10 @@ const MessagesImpl = ({ /> , wrapped, - ] + ]; } - return wrapped - } + return wrapped; + }; // Search indexing: for tool_result messages, look up the Tool and use // its extractSearchText — tool-owned, precise, matches what @@ -877,30 +783,24 @@ const MessagesImpl = ({ // A second-React-root reconcile approach was tried and ruled out // (measured 3.1ms/msg, growing — flushSyncWork processes all roots; // component hooks mutate shared state → main root accumulates updates). - const searchTextCache = useRef(new WeakMap()) + const searchTextCache = useRef(new WeakMap()); const extractSearchText = useCallback( (msg: RenderableMessage): string => { - const cached = searchTextCache.current.get(msg) - if (cached !== undefined) return cached - let text = renderableSearchText(msg) + const cached = searchTextCache.current.get(msg); + if (cached !== undefined) return cached; + let text = renderableSearchText(msg); // If this is a tool_result message and the tool implements // extractSearchText, prefer that — it's precise (tool-owned) // vs renderableSearchText's field-name heuristic. - if ( - msg.type === 'user' && - msg.toolUseResult && - Array.isArray(msg.message.content) - ) { - const tr = msg.message.content.find(b => b.type === 'tool_result') + if (msg.type === 'user' && msg.toolUseResult && Array.isArray(msg.message.content)) { + const tr = msg.message.content.find(b => b.type === 'tool_result'); if (tr && 'tool_use_id' in tr) { - const tu = lookups.toolUseByToolUseID.get(tr.tool_use_id) - const tool = tu && findToolByName(tools, tu.name) - const extracted = tool?.extractSearchText?.( - msg.toolUseResult as never, - ) + const tu = lookups.toolUseByToolUseID.get(tr.tool_use_id); + const tool = tu && findToolByName(tools, tu.name); + const extracted = tool?.extractSearchText?.(msg.toolUseResult as never); // undefined = tool didn't implement → keep heuristic. Empty // string = tool says "nothing to index" → respect that. - if (extracted !== undefined) text = extracted + if (extracted !== undefined) text = extracted; } } // Cache LOWERED: setSearchQuery's hot loop indexOfs per keystroke. @@ -908,19 +808,17 @@ const MessagesImpl = ({ // ~same steady-state memory for zero per-keystroke alloc. Cache // GC's with messages on transcript exit. Tool methods return raw; // renderableSearchText already lowercases (redundant but cheap). - const lowered = text.toLowerCase() - searchTextCache.current.set(msg, lowered) - return lowered + const lowered = text.toLowerCase(); + searchTextCache.current.set(msg, lowered); + return lowered; }, [tools, lookups], - ) + ); return ( <> {/* Logo */} - {!hideLogo && !(renderRange && renderRange[0] > 0) && ( - - )} + {!hideLogo && !(renderRange && renderRange[0] > 0) && } {/* Truncation indicator */} {hasTruncatedMessages && ( @@ -979,12 +877,7 @@ const MessagesImpl = ({ )} {streamingText && !isBriefOnly && ( - + {BLACK_CIRCLE} @@ -1011,17 +904,13 @@ const MessagesImpl = ({ )} - ) -} + ); +}; /** Key for click-to-expand: tool_use_id where available (so tool_use + its * tool_result expand together), else uuid for groups/thinking. */ function expandKey(msg: RenderableMessage): string { - return ( - (msg.type === 'assistant' || msg.type === 'user' - ? getToolUseID(msg) - : null) ?? msg.uuid - ) + return (msg.type === 'assistant' || msg.type === 'user' ? getToolUseID(msg) : null) ?? msg.uuid; } // Custom comparator to prevent unnecessary re-renders during streaming. @@ -1030,15 +919,15 @@ function expandKey(msg: RenderableMessage): string { // 2. streamingToolUses array is recreated on every delta, but only contentBlock matters for rendering // 3. streamingThinking changes on every delta - we DO want to re-render for this function setsEqual(a: Set, b: Set): boolean { - if (a.size !== b.size) return false + if (a.size !== b.size) return false; for (const item of a) { - if (!b.has(item)) return false + if (!b.has(item)) return false; } - return true + return true; } export const Messages = React.memo(MessagesImpl, (prev, next) => { - const keys = Object.keys(prev) as (keyof typeof prev)[] + const keys = Object.keys(prev) as (keyof typeof prev)[]; for (const key of keys) { if ( key === 'onOpenRateLimitOptions' || @@ -1051,50 +940,41 @@ export const Messages = React.memo(MessagesImpl, (prev, next) => { key === 'scanElement' || key === 'setPositions' ) - continue + continue; if (prev[key] !== next[key]) { if (key === 'streamingToolUses') { - const p = prev.streamingToolUses - const n = next.streamingToolUses - if ( - p.length === n.length && - p.every((item, i) => item.contentBlock === n[i]?.contentBlock) - ) { - continue + const p = prev.streamingToolUses; + const n = next.streamingToolUses; + if (p.length === n.length && p.every((item, i) => item.contentBlock === n[i]?.contentBlock)) { + continue; } } if (key === 'inProgressToolUseIDs') { if (setsEqual(prev.inProgressToolUseIDs, next.inProgressToolUseIDs)) { - continue + continue; } } if (key === 'unseenDivider') { - const p = prev.unseenDivider - const n = next.unseenDivider - if ( - p?.firstUnseenUuid === n?.firstUnseenUuid && - p?.count === n?.count - ) { - continue + const p = prev.unseenDivider; + const n = next.unseenDivider; + if (p?.firstUnseenUuid === n?.firstUnseenUuid && p?.count === n?.count) { + continue; } } if (key === 'tools') { - const p = prev.tools - const n = next.tools - if ( - p.length === n.length && - p.every((tool, i) => tool.name === n[i]?.name) - ) { - continue + const p = prev.tools; + const n = next.tools; + if (p.length === n.length && p.every((tool, i) => tool.name === n[i]?.name)) { + continue; } } // streamingThinking changes frequently - always re-render when it changes // (no special handling needed, default behavior is correct) - return false + return false; } } - return true -}) + return true; +}); export function shouldRenderStatically( message: RenderableMessage, @@ -1105,58 +985,55 @@ export function shouldRenderStatically( lookups: ReturnType, ): boolean { if (screen === 'transcript') { - return true + return true; } switch (message.type) { case 'attachment': case 'user': case 'assistant': { if (message.type === 'assistant') { - const block = (message.message!.content as Array<{ type: string; id?: string }>)[0] + const block = (message.message!.content as Array<{ type: string; id?: string }>)[0]; if (block?.type === 'server_tool_use') { - return lookups.resolvedToolUseIDs.has(block.id!) + return lookups.resolvedToolUseIDs.has(block.id!); } } - const toolUseID = getToolUseID(message) + const toolUseID = getToolUseID(message); if (!toolUseID) { - return true + return true; } if (streamingToolUseIDs.has(toolUseID)) { - return false + return false; } if (inProgressToolUseIDs.has(toolUseID)) { - return false + return false; } // Check if there are any unresolved PostToolUse hooks for this tool use // If so, keep the message transient so the HookProgressMessage can update if (hasUnresolvedHooksFromLookup(toolUseID, 'PostToolUse', lookups)) { - return false + return false; } - return every(siblingToolUseIDs, lookups.resolvedToolUseIDs) + return every(siblingToolUseIDs, lookups.resolvedToolUseIDs); } case 'system': { // api errors always render dynamically, since we hide // them as soon as we see another non-error message. - return message.subtype !== 'api_error' + return message.subtype !== 'api_error'; } case 'grouped_tool_use': { const allResolved = message.messages.every(msg => { - const content = (msg.message!.content as Array<{ type: string; id?: string }>)[0] - return ( - content?.type === 'tool_use' && - lookups.resolvedToolUseIDs.has(content.id!) - ) - }) - return allResolved + const content = (msg.message!.content as Array<{ type: string; id?: string }>)[0]; + return content?.type === 'tool_use' && lookups.resolvedToolUseIDs.has(content.id!); + }); + return allResolved; } case 'collapsed_read_search': { // In prompt mode, never mark as static to prevent flicker between API turns // (In transcript mode, we already returned true at the top of this function) - return false + return false; } default: - return true + return true; } } diff --git a/src/components/ModelPicker.tsx b/src/components/ModelPicker.tsx index b9f5155bc..a38708266 100644 --- a/src/components/ModelPicker.tsx +++ b/src/components/ModelPicker.tsx @@ -1,21 +1,21 @@ -import capitalize from 'lodash-es/capitalize.js' -import * as React from 'react' -import { useCallback, useMemo, useState } from 'react' -import { has1mContext } from '../utils/context.js' -import { useExitOnCtrlCDWithKeybindings } from 'src/hooks/useExitOnCtrlCDWithKeybindings.js' +import capitalize from 'lodash-es/capitalize.js'; +import * as React from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import { has1mContext } from '../utils/context.js'; +import { useExitOnCtrlCDWithKeybindings } from 'src/hooks/useExitOnCtrlCDWithKeybindings.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from 'src/services/analytics/index.js' +} from 'src/services/analytics/index.js'; import { FAST_MODE_MODEL_DISPLAY, isFastModeAvailable, isFastModeCooldown, isFastModeEnabled, -} from 'src/utils/fastMode.js' -import { Box, Text } from '@anthropic/ink' -import { useKeybindings } from '../keybindings/useKeybinding.js' -import { useAppState, useSetAppState } from '../state/AppState.js' +} from 'src/utils/fastMode.js'; +import { Box, Text } from '@anthropic/ink'; +import { useKeybindings } from '../keybindings/useKeybinding.js'; +import { useAppState, useSetAppState } from '../state/AppState.js'; import { convertEffortValueToLevel, type EffortLevel, @@ -24,42 +24,39 @@ import { modelSupportsMaxEffort, resolvePickerEffortPersistence, toPersistableEffort, -} from '../utils/effort.js' +} from '../utils/effort.js'; import { getDefaultMainLoopModel, type ModelSetting, modelDisplayString, parseUserSpecifiedModel, -} from '../utils/model/model.js' -import { getModelOptions } from '../utils/model/modelOptions.js' -import { - getSettingsForSource, - updateSettingsForSource, -} from '../utils/settings/settings.js' -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' -import { Select } from './CustomSelect/index.js' -import { Byline, KeyboardShortcutHint, Pane } from '@anthropic/ink' -import { effortLevelToSymbol } from './EffortIndicator.js' +} from '../utils/model/model.js'; +import { getModelOptions } from '../utils/model/modelOptions.js'; +import { getSettingsForSource, updateSettingsForSource } from '../utils/settings/settings.js'; +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; +import { Select } from './CustomSelect/index.js'; +import { Byline, KeyboardShortcutHint, Pane } from '@anthropic/ink'; +import { effortLevelToSymbol } from './EffortIndicator.js'; export type Props = { - initial: string | null - sessionModel?: ModelSetting - onSelect: (model: string | null, effort: EffortLevel | undefined) => void - onCancel?: () => void - isStandaloneCommand?: boolean - showFastModeNotice?: boolean + initial: string | null; + sessionModel?: ModelSetting; + onSelect: (model: string | null, effort: EffortLevel | undefined) => void; + onCancel?: () => void; + isStandaloneCommand?: boolean; + showFastModeNotice?: boolean; /** Overrides the dim header line below "Select model". */ - headerText?: string + headerText?: string; /** * When true, skip writing effortLevel to userSettings on selection. * Used by the assistant installer wizard where the model choice is * project-scoped (written to the assistant's .claude/settings.json via * install.ts) and should not leak to the user's global ~/.claude/settings. */ - skipSettingsWrite?: boolean -} + skipSettingsWrite?: boolean; +}; -const NO_PREFERENCE = '__NO_PREFERENCE__' +const NO_PREFERENCE = '__NO_PREFERENCE__'; export function ModelPicker({ initial, @@ -71,49 +68,40 @@ export function ModelPicker({ headerText, skipSettingsWrite, }: Props): React.ReactNode { - const setAppState = useSetAppState() - const exitState = useExitOnCtrlCDWithKeybindings() - const maxVisible = 10 + const setAppState = useSetAppState(); + const exitState = useExitOnCtrlCDWithKeybindings(); + const maxVisible = 10; - const initialValue = initial === null ? NO_PREFERENCE : initial - const [focusedValue, setFocusedValue] = useState( - initialValue, - ) + const initialValue = initial === null ? NO_PREFERENCE : initial; + const [focusedValue, setFocusedValue] = useState(initialValue); - const isFastMode = useAppState(s => - isFastModeEnabled() ? s.fastMode : false, - ) + const isFastMode = useAppState(s => (isFastModeEnabled() ? s.fastMode : false)); const [marked1MValues, setMarked1MValues] = useState>( - () => new Set(has1mContext(initialValue) ? [initialValue.replace(/\[1m\]/i, '')] : []) - ) + () => new Set(has1mContext(initialValue) ? [initialValue.replace(/\[1m\]/i, '')] : []), + ); const handleToggle1M = useCallback(() => { - if (!focusedValue || focusedValue === NO_PREFERENCE) return + if (!focusedValue || focusedValue === NO_PREFERENCE) return; setMarked1MValues(prev => { - const next = new Set(prev) + const next = new Set(prev); if (next.has(focusedValue)) { - next.delete(focusedValue) + next.delete(focusedValue); } else { - next.add(focusedValue) + next.add(focusedValue); } - return next - }) - }, [focusedValue]) + return next; + }); + }, [focusedValue]); - const [hasToggledEffort, setHasToggledEffort] = useState(false) - const effortValue = useAppState(s => s.effortValue) + const [hasToggledEffort, setHasToggledEffort] = useState(false); + const effortValue = useAppState(s => s.effortValue); const [effort, setEffort] = useState( - effortValue !== undefined - ? convertEffortValueToLevel(effortValue) - : undefined, - ) + effortValue !== undefined ? convertEffortValueToLevel(effortValue) : undefined, + ); // Memoize all derived values to prevent re-renders - const modelOptions = useMemo( - () => getModelOptions(isFastMode ?? false), - [isFastMode], - ) + const modelOptions = useMemo(() => getModelOptions(isFastMode ?? false), [isFastMode]); // Ensure the initial value is in the options list // This handles edge cases where the user's current model (e.g., 'haiku' for 3P users) @@ -127,10 +115,10 @@ export function ModelPicker({ label: modelDisplayString(initial), description: 'Current model', }, - ] + ]; } - return modelOptions - }, [modelOptions, initial]) + return modelOptions; + }, [modelOptions, initial]); const selectOptions = useMemo( () => @@ -139,59 +127,43 @@ export function ModelPicker({ value: opt.value === null ? NO_PREFERENCE : opt.value, })), [optionsWithInitial], - ) + ); const initialFocusValue = useMemo( - () => - selectOptions.some(_ => _.value === initialValue) - ? initialValue - : (selectOptions[0]?.value ?? undefined), + () => (selectOptions.some(_ => _.value === initialValue) ? initialValue : (selectOptions[0]?.value ?? undefined)), [selectOptions, initialValue], - ) - const visibleCount = Math.min(maxVisible, selectOptions.length) - const hiddenCount = Math.max(0, selectOptions.length - visibleCount) + ); + const visibleCount = Math.min(maxVisible, selectOptions.length); + const hiddenCount = Math.max(0, selectOptions.length - visibleCount); - const focusedModelName = selectOptions.find( - opt => opt.value === focusedValue, - )?.label - const focusedModel = resolveOptionModel(focusedValue) - const is1MMarked = focusedValue !== undefined && focusedValue !== NO_PREFERENCE && marked1MValues.has(focusedValue) - const focusedSupportsEffort = focusedModel - ? modelSupportsEffort(focusedModel) - : false - const focusedSupportsMax = focusedModel - ? modelSupportsMaxEffort(focusedModel) - : false - const focusedDefaultEffort = getDefaultEffortLevelForOption(focusedValue) + const focusedModelName = selectOptions.find(opt => opt.value === focusedValue)?.label; + const focusedModel = resolveOptionModel(focusedValue); + const is1MMarked = focusedValue !== undefined && focusedValue !== NO_PREFERENCE && marked1MValues.has(focusedValue); + const focusedSupportsEffort = focusedModel ? modelSupportsEffort(focusedModel) : false; + const focusedSupportsMax = focusedModel ? modelSupportsMaxEffort(focusedModel) : false; + const focusedDefaultEffort = getDefaultEffortLevelForOption(focusedValue); // Clamp display when 'max' is selected but the focused model doesn't support it. // resolveAppliedEffort() does the same downgrade at API-send time. - const displayEffort = - effort === 'max' && !focusedSupportsMax ? 'high' : effort + const displayEffort = effort === 'max' && !focusedSupportsMax ? 'high' : effort; const handleFocus = useCallback( (value: string) => { - setFocusedValue(value) + setFocusedValue(value); if (!hasToggledEffort && effortValue === undefined) { - setEffort(getDefaultEffortLevelForOption(value)) + setEffort(getDefaultEffortLevelForOption(value)); } }, [hasToggledEffort, effortValue], - ) + ); // Effort level cycling keybindings const handleCycleEffort = useCallback( (direction: 'left' | 'right') => { - if (!focusedSupportsEffort) return - setEffort(prev => - cycleEffortLevel( - prev ?? focusedDefaultEffort, - direction, - focusedSupportsMax, - ), - ) - setHasToggledEffort(true) + if (!focusedSupportsEffort) return; + setEffort(prev => cycleEffortLevel(prev ?? focusedDefaultEffort, direction, focusedSupportsMax)); + setHasToggledEffort(true); }, [focusedSupportsEffort, focusedSupportsMax, focusedDefaultEffort], - ) + ); useKeybindings( { @@ -200,13 +172,12 @@ export function ModelPicker({ 'modelPicker:toggle1M': () => handleToggle1M(), }, { context: 'ModelPicker' }, - ) + ); function handleSelect(value: string): void { logEvent('tengu_model_command_menu_effort', { - effort: - effort as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + effort: effort as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); if (!skipSettingsWrite) { // Prior comes from userSettings on disk — NOT merged settings (which // includes project/policy layers that must not leak into the user's @@ -218,28 +189,25 @@ export function ModelPicker({ getDefaultEffortLevelForOption(value), getSettingsForSource('userSettings')?.effortLevel, hasToggledEffort, - ) - const persistable = toPersistableEffort(effortLevel) + ); + const persistable = toPersistableEffort(effortLevel); if (persistable !== undefined) { - updateSettingsForSource('userSettings', { effortLevel: persistable }) + updateSettingsForSource('userSettings', { effortLevel: persistable }); } - setAppState(prev => ({ ...prev, effortValue: effortLevel })) + setAppState(prev => ({ ...prev, effortValue: effortLevel })); } - const selectedModel = resolveOptionModel(value) - const selectedEffort = - hasToggledEffort && selectedModel && modelSupportsEffort(selectedModel) - ? effort - : undefined + const selectedModel = resolveOptionModel(value); + const selectedEffort = hasToggledEffort && selectedModel && modelSupportsEffort(selectedModel) ? effort : undefined; if (value === NO_PREFERENCE) { - onSelect(null, selectedEffort) - return + onSelect(null, selectedEffort); + return; } // Apply or strip [1m] suffix based on user toggle - const wants1M = marked1MValues.has(value) - const baseValue = value.replace(/\[1m\]/i, '') - const finalValue = wants1M ? `${baseValue}[1m]` : baseValue - onSelect(finalValue, selectedEffort) + const wants1M = marked1MValues.has(value); + const baseValue = value.replace(/\[1m\]/i, ''); + const finalValue = wants1M ? `${baseValue}[1m]` : baseValue; + onSelect(finalValue, selectedEffort); } const content = ( @@ -255,8 +223,8 @@ export function ModelPicker({ {sessionModel && ( - Currently using {modelDisplayString(sessionModel)} for this - session (set by plan mode). Selecting a model will undo this. + Currently using {modelDisplayString(sessionModel)} for this session (set by plan mode). Selecting a model + will undo this. )} @@ -283,10 +251,8 @@ export function ModelPicker({ {focusedSupportsEffort ? ( - {' '} - {capitalize(displayEffort)} effort - {displayEffort === focusedDefaultEffort ? ` (default)` : ``}{' '} - ← → to adjust + {capitalize(displayEffort)} effort + {displayEffort === focusedDefaultEffort ? ` (default)` : ``} ← → to adjust ) : ( @@ -311,16 +277,14 @@ export function ModelPicker({ showFastModeNotice ? ( - Fast mode is ON and available with{' '} - {FAST_MODE_MODEL_DISPLAY} only (/fast). Switching to other - models turn off fast mode. + Fast mode is ON and available with {FAST_MODE_MODEL_DISPLAY} only (/fast). Switching + to other models turn off fast mode. ) : isFastModeAvailable() && !isFastModeCooldown() ? ( - Use /fast to turn on Fast mode ( - {FAST_MODE_MODEL_DISPLAY} only). + Use /fast to turn on Fast mode ({FAST_MODE_MODEL_DISPLAY} only). ) : null @@ -334,68 +298,45 @@ export function ModelPicker({ ) : ( - + )} )} - ) + ); if (!isStandaloneCommand) { - return content + return content; } - return {content} + return {content}; } function resolveOptionModel(value?: string): string | undefined { - if (!value) return undefined - return value === NO_PREFERENCE - ? getDefaultMainLoopModel() - : parseUserSpecifiedModel(value) + if (!value) return undefined; + return value === NO_PREFERENCE ? getDefaultMainLoopModel() : parseUserSpecifiedModel(value); } -function EffortLevelIndicator({ - effort, -}: { - effort?: EffortLevel -}): React.ReactNode { - return ( - - {effortLevelToSymbol(effort ?? 'low')} - - ) +function EffortLevelIndicator({ effort }: { effort?: EffortLevel }): React.ReactNode { + return {effortLevelToSymbol(effort ?? 'low')}; } -function cycleEffortLevel( - current: EffortLevel, - direction: 'left' | 'right', - includeMax: boolean, -): EffortLevel { - const levels: EffortLevel[] = includeMax - ? ['low', 'medium', 'high', 'max'] - : ['low', 'medium', 'high'] +function cycleEffortLevel(current: EffortLevel, direction: 'left' | 'right', includeMax: boolean): EffortLevel { + const levels: EffortLevel[] = includeMax ? ['low', 'medium', 'high', 'max'] : ['low', 'medium', 'high']; // If the current level isn't in the cycle (e.g. 'max' after switching to a // non-Opus model), clamp to 'high'. - const idx = levels.indexOf(current) - const currentIndex = idx !== -1 ? idx : levels.indexOf('high') + const idx = levels.indexOf(current); + const currentIndex = idx !== -1 ? idx : levels.indexOf('high'); if (direction === 'right') { - return levels[(currentIndex + 1) % levels.length]! + return levels[(currentIndex + 1) % levels.length]!; } else { - return levels[(currentIndex - 1 + levels.length) % levels.length]! + return levels[(currentIndex - 1 + levels.length) % levels.length]!; } } function getDefaultEffortLevelForOption(value?: string): EffortLevel { - const resolved = resolveOptionModel(value) ?? getDefaultMainLoopModel() - const defaultValue = getDefaultEffortForModel(resolved) - return defaultValue !== undefined - ? convertEffortValueToLevel(defaultValue) - : 'high' + const resolved = resolveOptionModel(value) ?? getDefaultMainLoopModel(); + const defaultValue = getDefaultEffortForModel(resolved); + return defaultValue !== undefined ? convertEffortValueToLevel(defaultValue) : 'high'; } diff --git a/src/components/NativeAutoUpdater.tsx b/src/components/NativeAutoUpdater.tsx index 77d84e968..f090f0f85 100644 --- a/src/components/NativeAutoUpdater.tsx +++ b/src/components/NativeAutoUpdater.tsx @@ -1,58 +1,54 @@ -import * as React from 'react' -import { useEffect, useRef, useState } from 'react' -import { logEvent } from 'src/services/analytics/index.js' -import { logForDebugging } from 'src/utils/debug.js' -import { logError } from 'src/utils/log.js' -import { useInterval } from 'usehooks-ts' -import { useUpdateNotification } from '../hooks/useUpdateNotification.js' -import { Box, Text } from '@anthropic/ink' -import type { AutoUpdaterResult } from '../utils/autoUpdater.js' -import { getMaxVersion, getMaxVersionMessage } from '../utils/autoUpdater.js' -import { isAutoUpdaterDisabled } from '../utils/config.js' -import { installLatest } from '../utils/nativeInstaller/index.js' -import { gt } from '../utils/semver.js' -import { getInitialSettings } from '../utils/settings/settings.js' +import * as React from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { logEvent } from 'src/services/analytics/index.js'; +import { logForDebugging } from 'src/utils/debug.js'; +import { logError } from 'src/utils/log.js'; +import { useInterval } from 'usehooks-ts'; +import { useUpdateNotification } from '../hooks/useUpdateNotification.js'; +import { Box, Text } from '@anthropic/ink'; +import type { AutoUpdaterResult } from '../utils/autoUpdater.js'; +import { getMaxVersion, getMaxVersionMessage } from '../utils/autoUpdater.js'; +import { isAutoUpdaterDisabled } from '../utils/config.js'; +import { installLatest } from '../utils/nativeInstaller/index.js'; +import { gt } from '../utils/semver.js'; +import { getInitialSettings } from '../utils/settings/settings.js'; /** * Categorize error messages for analytics */ function getErrorType(errorMessage: string): string { if (errorMessage.includes('timeout')) { - return 'timeout' + return 'timeout'; } if (errorMessage.includes('Checksum mismatch')) { - return 'checksum_mismatch' + return 'checksum_mismatch'; } if (errorMessage.includes('ENOENT') || errorMessage.includes('not found')) { - return 'not_found' + return 'not_found'; } if (errorMessage.includes('EACCES') || errorMessage.includes('permission')) { - return 'permission_denied' + return 'permission_denied'; } if (errorMessage.includes('ENOSPC')) { - return 'disk_full' + return 'disk_full'; } if (errorMessage.includes('npm')) { - return 'npm_error' + return 'npm_error'; } - if ( - errorMessage.includes('network') || - errorMessage.includes('ECONNREFUSED') || - errorMessage.includes('ENOTFOUND') - ) { - return 'network_error' + if (errorMessage.includes('network') || errorMessage.includes('ECONNREFUSED') || errorMessage.includes('ENOTFOUND')) { + return 'network_error'; } - return 'unknown' + return 'unknown'; } type Props = { - isUpdating: boolean - onChangeIsUpdating: (isUpdating: boolean) => void - onAutoUpdaterResult: (autoUpdaterResult: AutoUpdaterResult) => void - autoUpdaterResult: AutoUpdaterResult | null - showSuccessMessage: boolean - verbose: boolean -} + isUpdating: boolean; + onChangeIsUpdating: (isUpdating: boolean) => void; + onAutoUpdaterResult: (autoUpdaterResult: AutoUpdaterResult) => void; + autoUpdaterResult: AutoUpdaterResult | null; + showSuccessMessage: boolean; + verbose: boolean; +}; export function NativeAutoUpdater({ isUpdating, @@ -63,90 +59,84 @@ export function NativeAutoUpdater({ verbose, }: Props): React.ReactNode { const [versions, setVersions] = useState<{ - current?: string | null - latest?: string | null - }>({}) - const [maxVersionIssue, setMaxVersionIssue] = useState(null) - const updateSemver = useUpdateNotification(autoUpdaterResult?.version) - const channel = getInitialSettings()?.autoUpdatesChannel ?? 'latest' + current?: string | null; + latest?: string | null; + }>({}); + const [maxVersionIssue, setMaxVersionIssue] = useState(null); + const updateSemver = useUpdateNotification(autoUpdaterResult?.version); + const channel = getInitialSettings()?.autoUpdatesChannel ?? 'latest'; // Track latest isUpdating value in a ref so the memoized checkForUpdates // callback always sees the current value without changing callback identity // (which would re-trigger the initial-check useEffect below and cause // repeated downloads on remount — the upstream trigger for #22413). - const isUpdatingRef = useRef(isUpdating) - isUpdatingRef.current = isUpdating + const isUpdatingRef = useRef(isUpdating); + isUpdatingRef.current = isUpdating; const checkForUpdates = React.useCallback(async () => { if (isUpdatingRef.current) { - return + return; } - if ( - process.env.NODE_ENV === 'test' || - process.env.NODE_ENV === 'development' - ) { - logForDebugging( - 'NativeAutoUpdater: Skipping update check in test/dev environment', - ) - return + if (process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'development') { + logForDebugging('NativeAutoUpdater: Skipping update check in test/dev environment'); + return; } if (isAutoUpdaterDisabled()) { - return + return; } - onChangeIsUpdating(true) - const startTime = Date.now() + onChangeIsUpdating(true); + const startTime = Date.now(); // Log the start of an auto-update check for funnel analysis - logEvent('tengu_native_auto_updater_start', {}) + logEvent('tengu_native_auto_updater_start', {}); try { // Check if current version is above the max allowed version - const maxVersion = await getMaxVersion() + const maxVersion = await getMaxVersion(); if (maxVersion && gt(MACRO.VERSION, maxVersion)) { - const msg = await getMaxVersionMessage() - setMaxVersionIssue(msg ?? 'affects your version') + const msg = await getMaxVersionMessage(); + setMaxVersionIssue(msg ?? 'affects your version'); } - const result = await installLatest(channel) - const currentVersion = MACRO.VERSION - const latencyMs = Date.now() - startTime + const result = await installLatest(channel); + const currentVersion = MACRO.VERSION; + const latencyMs = Date.now() - startTime; // Handle lock contention gracefully - just return without treating as error if (result.lockFailed) { logEvent('tengu_native_auto_updater_lock_contention', { latency_ms: latencyMs, - }) - return // Silently skip this update check, will try again later + }); + return; // Silently skip this update check, will try again later } // Update versions for display - setVersions({ current: currentVersion, latest: result.latestVersion }) + setVersions({ current: currentVersion, latest: result.latestVersion }); if (result.wasUpdated) { logEvent('tengu_native_auto_updater_success', { latency_ms: latencyMs, - }) + }); onAutoUpdaterResult({ version: result.latestVersion, status: 'success', - }) + }); } else { // Already up to date logEvent('tengu_native_auto_updater_up_to_date', { latency_ms: latencyMs, - }) + }); } } catch (error) { - const latencyMs = Date.now() - startTime - const errorMessage = - error instanceof Error ? error.message : String(error) - logError(error) + const latencyMs = Date.now() - startTime; + const errorMessage = error instanceof Error ? error.message : String(error); + logError(error); - const errorType = getErrorType(errorMessage) + const errorType = getErrorType(errorMessage); logEvent('tengu_native_auto_updater_fail', { latency_ms: latencyMs, error_timeout: errorType === 'timeout', @@ -156,41 +146,39 @@ export function NativeAutoUpdater({ error_disk_full: errorType === 'disk_full', error_npm: errorType === 'npm_error', error_network: errorType === 'network_error', - }) + }); onAutoUpdaterResult({ version: null, status: 'install_failed', - }) + }); } finally { - onChangeIsUpdating(false) + onChangeIsUpdating(false); } // isUpdating intentionally omitted from deps; we read isUpdatingRef // instead so the guard is always current without changing callback // identity (which would re-trigger the initial-check useEffect below). // eslint-disable-next-line react-hooks/exhaustive-deps - // biome-ignore lint/correctness/useExhaustiveDependencies: isUpdating read via ref - }, [onAutoUpdaterResult, channel]) + }, [onAutoUpdaterResult, channel]); // Initial check useEffect(() => { - void checkForUpdates() - }, [checkForUpdates]) + void checkForUpdates(); + }, [checkForUpdates]); // Check every 30 minutes - useInterval(checkForUpdates, 30 * 60 * 1000) + useInterval(checkForUpdates, 30 * 60 * 1000); - const hasUpdateResult = !!autoUpdaterResult?.version - const hasVersionInfo = !!versions.current && !!versions.latest + const hasUpdateResult = !!autoUpdaterResult?.version; + const hasVersionInfo = !!versions.current && !!versions.latest; // Show the component when: // - warning banner needed (above max version), or // - there's an update result to display (success/error), or // - actively checking and we have version info to show - const shouldRender = - !!maxVersionIssue || hasUpdateResult || (isUpdating && hasVersionInfo) + const shouldRender = !!maxVersionIssue || hasUpdateResult || (isUpdating && hasVersionInfo); if (!shouldRender) { - return null + return null; } return ( @@ -222,10 +210,9 @@ export function NativeAutoUpdater({ )} {maxVersionIssue && process.env.USER_TYPE === 'ant' && ( - ⚠ Known issue: {maxVersionIssue} · Run{' '} - claude rollback --safe to downgrade + ⚠ Known issue: {maxVersionIssue} · Run claude rollback --safe to downgrade )} - ) + ); } diff --git a/src/components/NotebookEditToolUseRejectedMessage.tsx b/src/components/NotebookEditToolUseRejectedMessage.tsx index fc4e4f317..014bc545e 100644 --- a/src/components/NotebookEditToolUseRejectedMessage.tsx +++ b/src/components/NotebookEditToolUseRejectedMessage.tsx @@ -1,18 +1,18 @@ -import { relative } from 'path' -import * as React from 'react' -import { getCwd } from 'src/utils/cwd.js' -import { Box, Text } from '@anthropic/ink' -import { HighlightedCode } from './HighlightedCode.js' -import { MessageResponse } from './MessageResponse.js' +import { relative } from 'path'; +import * as React from 'react'; +import { getCwd } from 'src/utils/cwd.js'; +import { Box, Text } from '@anthropic/ink'; +import { HighlightedCode } from './HighlightedCode.js'; +import { MessageResponse } from './MessageResponse.js'; type Props = { - notebook_path: string - cell_id: string | undefined - new_source: string - cell_type?: 'code' | 'markdown' - edit_mode?: 'replace' | 'insert' | 'delete' - verbose: boolean -} + notebook_path: string; + cell_id: string | undefined; + new_source: string; + cell_type?: 'code' | 'markdown'; + edit_mode?: 'replace' | 'insert' | 'delete'; + verbose: boolean; +}; export function NotebookEditToolUseRejectedMessage({ notebook_path, @@ -22,7 +22,7 @@ export function NotebookEditToolUseRejectedMessage({ edit_mode = 'replace', verbose, }: Props): React.ReactNode { - const operation = edit_mode === 'delete' ? 'delete' : `${edit_mode} cell in` + const operation = edit_mode === 'delete' ? 'delete' : `${edit_mode} cell in`; return ( @@ -36,14 +36,10 @@ export function NotebookEditToolUseRejectedMessage({ {edit_mode !== 'delete' && ( - + )} - ) + ); } diff --git a/src/components/OffscreenFreeze.tsx b/src/components/OffscreenFreeze.tsx index 8a94b5bd9..a374ea397 100644 --- a/src/components/OffscreenFreeze.tsx +++ b/src/components/OffscreenFreeze.tsx @@ -1,10 +1,10 @@ -import React, { useContext, useRef } from 'react' -import { useTerminalViewport, Box } from '@anthropic/ink' -import { InVirtualListContext } from './messageActions.js' +import React, { useContext, useRef } from 'react'; +import { useTerminalViewport, Box } from '@anthropic/ink'; +import { InVirtualListContext } from './messageActions.js'; type Props = { - children: React.ReactNode -} + children: React.ReactNode; +}; /** * Freezes children when they scroll above the terminal viewport (into scrollback). @@ -23,16 +23,16 @@ type Props = { export function OffscreenFreeze({ children }: Props): React.ReactNode { // React Compiler: reading cached.current in the return is the entire // freeze mechanism — memoizing this component would defeat it. Opt out. - 'use no memo' - const inVirtualList = useContext(InVirtualListContext) - const [ref, { isVisible }] = useTerminalViewport() - const cached = useRef(children) + 'use no memo'; + const inVirtualList = useContext(InVirtualListContext); + const [ref, { isVisible }] = useTerminalViewport(); + const cached = useRef(children); // Virtual list has no terminal scrollback — the ScrollBox clips inside the // viewport, so there's nothing to freeze. Freezing there also blocks // click-to-expand since useTerminalViewport's visibility calc can disagree // with the ScrollBox's virtual scroll position. if (isVisible || inVirtualList) { - cached.current = children + cached.current = children; } - return {cached.current} + return {cached.current}; } diff --git a/src/components/Onboarding.tsx b/src/components/Onboarding.tsx index 8391cf886..6b7b12c1c 100644 --- a/src/components/Onboarding.tsx +++ b/src/components/Onboarding.tsx @@ -1,80 +1,70 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react' +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from 'src/services/analytics/index.js' -import { - setupTerminal, - shouldOfferTerminalSetup, -} from '../commands/terminalSetup/terminalSetup.js' -import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js' -import { Box, Link, Newline, Text, useTheme } from '@anthropic/ink' -import { useKeybindings } from '../keybindings/useKeybinding.js' -import { isAnthropicAuthEnabled } from '../utils/auth.js' -import { normalizeApiKeyForConfig } from '../utils/authPortable.js' -import { getCustomApiKeyStatus } from '../utils/config.js' -import { env } from '../utils/env.js' -import { isRunningOnHomespace } from '../utils/envUtils.js' -import { PreflightStep } from '../utils/preflightChecks.js' -import type { ThemeSetting } from '../utils/theme.js' -import { ApproveApiKey } from './ApproveApiKey.js' -import { ConsoleOAuthFlow } from './ConsoleOAuthFlow.js' -import { Select } from './CustomSelect/select.js' -import { WelcomeV2 } from './LogoV2/WelcomeV2.js' -import { PressEnterToContinue } from './PressEnterToContinue.js' -import { ThemePicker } from './ThemePicker.js' -import { OrderedList } from './ui/OrderedList.js' +} from 'src/services/analytics/index.js'; +import { setupTerminal, shouldOfferTerminalSetup } from '../commands/terminalSetup/terminalSetup.js'; +import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js'; +import { Box, Link, Newline, Text, useTheme } from '@anthropic/ink'; +import { useKeybindings } from '../keybindings/useKeybinding.js'; +import { isAnthropicAuthEnabled } from '../utils/auth.js'; +import { normalizeApiKeyForConfig } from '../utils/authPortable.js'; +import { getCustomApiKeyStatus } from '../utils/config.js'; +import { env } from '../utils/env.js'; +import { isRunningOnHomespace } from '../utils/envUtils.js'; +import { PreflightStep } from '../utils/preflightChecks.js'; +import type { ThemeSetting } from '../utils/theme.js'; +import { ApproveApiKey } from './ApproveApiKey.js'; +import { ConsoleOAuthFlow } from './ConsoleOAuthFlow.js'; +import { Select } from './CustomSelect/select.js'; +import { WelcomeV2 } from './LogoV2/WelcomeV2.js'; +import { PressEnterToContinue } from './PressEnterToContinue.js'; +import { ThemePicker } from './ThemePicker.js'; +import { OrderedList } from './ui/OrderedList.js'; -type StepId = - | 'preflight' - | 'theme' - | 'oauth' - | 'api-key' - | 'security' - | 'terminal-setup' +type StepId = 'preflight' | 'theme' | 'oauth' | 'api-key' | 'security' | 'terminal-setup'; interface OnboardingStep { - id: StepId - component: React.ReactNode + id: StepId; + component: React.ReactNode; } type Props = { - onDone(): void -} + onDone(): void; +}; export function Onboarding({ onDone }: Props): React.ReactNode { - const [currentStepIndex, setCurrentStepIndex] = useState(0) - const [skipOAuth, setSkipOAuth] = useState(false) - const [oauthEnabled] = useState(() => isAnthropicAuthEnabled()) - const [theme, setTheme] = useTheme() + const [currentStepIndex, setCurrentStepIndex] = useState(0); + const [skipOAuth, setSkipOAuth] = useState(false); + const [oauthEnabled] = useState(() => isAnthropicAuthEnabled()); + const [theme, setTheme] = useTheme(); useEffect(() => { logEvent('tengu_began_setup', { oauthEnabled, - }) - }, [oauthEnabled]) + }); + }, [oauthEnabled]); function goToNextStep() { if (currentStepIndex < steps.length - 1) { - const nextIndex = currentStepIndex + 1 - setCurrentStepIndex(nextIndex) + const nextIndex = currentStepIndex + 1; + setCurrentStepIndex(nextIndex); logEvent('tengu_onboarding_step', { oauthEnabled, - stepId: steps[nextIndex] - ?.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + stepId: steps[nextIndex]?.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); } else { - onDone() + onDone(); } } function handleThemeSelection(newTheme: ThemeSetting) { - setTheme(newTheme) - goToNextStep() + setTheme(newTheme); + goToNextStep(); } - const exitState = useExitOnCtrlCDWithKeybindings() + const exitState = useExitOnCtrlCDWithKeybindings(); // Define all onboarding steps const themeStep = ( @@ -87,7 +77,7 @@ export function Onboarding({ onDone }: Props): React.ReactNode { skipExitHandling={true} // Skip exit handling as Onboarding already handles it /> - ) + ); const securityStep = ( @@ -108,9 +98,7 @@ export function Onboarding({ onDone }: Props): React.ReactNode { - - Due to prompt injection risks, only use it with code you trust - + Due to prompt injection risks, only use it with code you trust For more details see: @@ -121,49 +109,42 @@ export function Onboarding({ onDone }: Props): React.ReactNode { - ) + ); - const preflightStep = + const preflightStep = ; // Create the steps array - determine which steps to include based on reAuth and oauthEnabled const apiKeyNeedingApproval = useMemo(() => { // Add API key step if needed // 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()) { - return '' + return ''; } - const customApiKeyTruncated = normalizeApiKeyForConfig( - process.env.ANTHROPIC_API_KEY, - ) + const customApiKeyTruncated = normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY); if (getCustomApiKeyStatus(customApiKeyTruncated) === 'new') { - return customApiKeyTruncated + return customApiKeyTruncated; } - }, []) + }, []); function handleApiKeyDone(approved: boolean) { if (approved) { - setSkipOAuth(true) + setSkipOAuth(true); } - goToNextStep() + goToNextStep(); } - const steps: OnboardingStep[] = [] + const steps: OnboardingStep[] = []; // Preflight check disabled — users may use third-party API providers // if (oauthEnabled) { // steps.push({ id: 'preflight', component: preflightStep }) // } - steps.push({ id: 'theme', component: themeStep }) + steps.push({ id: 'theme', component: themeStep }); if (apiKeyNeedingApproval) { steps.push({ id: 'api-key', - component: ( - - ), - }) + component: , + }); } if (oauthEnabled) { @@ -174,10 +155,10 @@ export function Onboarding({ onDone }: Props): React.ReactNode { ), - }) + }); } - steps.push({ id: 'security', component: securityStep }) + steps.push({ id: 'security', component: securityStep }); if (shouldOfferTerminalSetup()) { steps.push({ @@ -210,41 +191,37 @@ export function Onboarding({ onDone }: Props): React.ReactNode { // Errors already logged in setupTerminal, just swallow and proceed void setupTerminal(theme) .catch(() => {}) - .finally(goToNextStep) + .finally(goToNextStep); } else { - goToNextStep() + goToNextStep(); } }} onCancel={() => goToNextStep()} /> - {exitState.pending ? ( - <>Press {exitState.keyName} again to exit - ) : ( - <>Enter to confirm · Esc to skip - )} + {exitState.pending ? <>Press {exitState.keyName} again to exit : <>Enter to confirm · Esc to skip} ), - }) + }); } - const currentStep = steps[currentStepIndex] + const currentStep = steps[currentStepIndex]; // Handle Enter on security step and Escape on terminal-setup step // Dependencies match what goToNextStep uses internally const handleSecurityContinue = useCallback(() => { if (currentStepIndex === steps.length - 1) { - onDone() + onDone(); } else { - goToNextStep() + goToNextStep(); } - }, [currentStepIndex, steps.length, oauthEnabled, onDone]) + }, [currentStepIndex, steps.length, oauthEnabled, onDone]); const handleTerminalSetupSkip = useCallback(() => { - goToNextStep() - }, [currentStepIndex, steps.length, oauthEnabled, onDone]) + goToNextStep(); + }, [currentStepIndex, steps.length, oauthEnabled, onDone]); useKeybindings( { @@ -254,7 +231,7 @@ export function Onboarding({ onDone }: Props): React.ReactNode { context: 'Confirmation', isActive: currentStep?.id === 'security', }, - ) + ); useKeybindings( { @@ -264,7 +241,7 @@ export function Onboarding({ onDone }: Props): React.ReactNode { context: 'Confirmation', isActive: currentStep?.id === 'terminal-setup', }, - ) + ); return ( @@ -278,7 +255,7 @@ export function Onboarding({ onDone }: Props): React.ReactNode { )} - ) + ); } export function SkippableStep({ @@ -286,17 +263,17 @@ export function SkippableStep({ onSkip, children, }: { - skip: boolean - onSkip(): void - children: React.ReactNode + skip: boolean; + onSkip(): void; + children: React.ReactNode; }): React.ReactNode { useEffect(() => { if (skip) { - onSkip() + onSkip(); } - }, [skip, onSkip]) + }, [skip, onSkip]); if (skip) { - return null + return null; } - return children + return children; } diff --git a/src/components/OutputStylePicker.tsx b/src/components/OutputStylePicker.tsx index 5717a26e6..aa12855e3 100644 --- a/src/components/OutputStylePicker.tsx +++ b/src/components/OutputStylePicker.tsx @@ -1,36 +1,29 @@ -import * as React from 'react' -import { useCallback, useEffect, useState } from 'react' -import { - getAllOutputStyles, - OUTPUT_STYLE_CONFIG, - type OutputStyleConfig, -} from '../constants/outputStyles.js' -import { Box, Text, Dialog } from '@anthropic/ink' -import type { OutputStyle } from '../utils/config.js' -import { getCwd } from '../utils/cwd.js' -import type { OptionWithDescription } from './CustomSelect/select.js' -import { Select } from './CustomSelect/select.js' +import * as React from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { getAllOutputStyles, OUTPUT_STYLE_CONFIG, type OutputStyleConfig } from '../constants/outputStyles.js'; +import { Box, Text, Dialog } from '@anthropic/ink'; +import type { OutputStyle } from '../utils/config.js'; +import { getCwd } from '../utils/cwd.js'; +import type { OptionWithDescription } from './CustomSelect/select.js'; +import { Select } from './CustomSelect/select.js'; -const DEFAULT_OUTPUT_STYLE_LABEL = 'Default' -const DEFAULT_OUTPUT_STYLE_DESCRIPTION = - 'Claude completes coding tasks efficiently and provides concise responses' +const DEFAULT_OUTPUT_STYLE_LABEL = 'Default'; +const DEFAULT_OUTPUT_STYLE_DESCRIPTION = 'Claude completes coding tasks efficiently and provides concise responses'; -function mapConfigsToOptions(styles: { - [styleName: string]: OutputStyleConfig | null -}): OptionWithDescription[] { +function mapConfigsToOptions(styles: { [styleName: string]: OutputStyleConfig | null }): OptionWithDescription[] { return Object.entries(styles).map(([style, config]) => ({ label: config?.name ?? DEFAULT_OUTPUT_STYLE_LABEL, value: style, description: config?.description ?? DEFAULT_OUTPUT_STYLE_DESCRIPTION, - })) + })); } export type OutputStylePickerProps = { - initialStyle: OutputStyle - onComplete: (style: OutputStyle) => void - onCancel: () => void - isStandaloneCommand?: boolean -} + initialStyle: OutputStyle; + onComplete: (style: OutputStyle) => void; + onCancel: () => void; + isStandaloneCommand?: boolean; +}; export function OutputStylePicker({ initialStyle, @@ -38,32 +31,32 @@ export function OutputStylePicker({ onCancel, isStandaloneCommand, }: OutputStylePickerProps): React.ReactNode { - const [styleOptions, setStyleOptions] = useState([]) - const [isLoading, setIsLoading] = useState(true) + const [styleOptions, setStyleOptions] = useState([]); + const [isLoading, setIsLoading] = useState(true); useEffect(() => { // Load all output styles including custom ones getAllOutputStyles(getCwd()) .then(allStyles => { - const options = mapConfigsToOptions(allStyles) - setStyleOptions(options) - setIsLoading(false) + const options = mapConfigsToOptions(allStyles); + setStyleOptions(options); + setIsLoading(false); }) .catch(() => { // On error, fall back to built-in styles only - const builtInOptions = mapConfigsToOptions(OUTPUT_STYLE_CONFIG) - setStyleOptions(builtInOptions) - setIsLoading(false) - }) - }, []) + const builtInOptions = mapConfigsToOptions(OUTPUT_STYLE_CONFIG); + setStyleOptions(builtInOptions); + setIsLoading(false); + }); + }, []); const handleStyleSelect = useCallback( (style: string) => { - const outputStyle = style as OutputStyle - onComplete(outputStyle) + const outputStyle = style as OutputStyle; + onComplete(outputStyle); }, [onComplete], - ) + ); return ( - - This changes how Claude Code communicates with you - + This changes how Claude Code communicates with you {isLoading ? ( Loading output styles… @@ -90,5 +81,5 @@ export function OutputStylePicker({ )} - ) + ); } diff --git a/src/components/PackageManagerAutoUpdater.tsx b/src/components/PackageManagerAutoUpdater.tsx index eeef217e2..7fd586ae6 100644 --- a/src/components/PackageManagerAutoUpdater.tsx +++ b/src/components/PackageManagerAutoUpdater.tsx @@ -1,95 +1,85 @@ -import * as React from 'react' -import { useState } from 'react' -import { useInterval } from 'usehooks-ts' -import { Text } from '@anthropic/ink' +import * as React from 'react'; +import { useState } from 'react'; +import { useInterval } from 'usehooks-ts'; +import { Text } from '@anthropic/ink'; import { type AutoUpdaterResult, getLatestVersionFromGcs, getMaxVersion, shouldSkipVersion, -} from '../utils/autoUpdater.js' -import { isAutoUpdaterDisabled } from '../utils/config.js' -import { logForDebugging } from '../utils/debug.js' -import { - getPackageManager, - type PackageManager, -} from '../utils/nativeInstaller/packageManagers.js' -import { gt, gte } from '../utils/semver.js' -import { getInitialSettings } from '../utils/settings/settings.js' +} from '../utils/autoUpdater.js'; +import { isAutoUpdaterDisabled } from '../utils/config.js'; +import { logForDebugging } from '../utils/debug.js'; +import { getPackageManager, type PackageManager } from '../utils/nativeInstaller/packageManagers.js'; +import { gt, gte } from '../utils/semver.js'; +import { getInitialSettings } from '../utils/settings/settings.js'; type Props = { - isUpdating: boolean - onChangeIsUpdating: (isUpdating: boolean) => void - onAutoUpdaterResult: (autoUpdaterResult: AutoUpdaterResult) => void - autoUpdaterResult: AutoUpdaterResult | null - showSuccessMessage: boolean - verbose: boolean -} + isUpdating: boolean; + onChangeIsUpdating: (isUpdating: boolean) => void; + onAutoUpdaterResult: (autoUpdaterResult: AutoUpdaterResult) => void; + autoUpdaterResult: AutoUpdaterResult | null; + showSuccessMessage: boolean; + verbose: boolean; +}; export function PackageManagerAutoUpdater({ verbose }: Props): React.ReactNode { - const [updateAvailable, setUpdateAvailable] = useState(false) - const [packageManager, setPackageManager] = - useState('unknown') + const [updateAvailable, setUpdateAvailable] = useState(false); + const [packageManager, setPackageManager] = useState('unknown'); const checkForUpdates = React.useCallback(async () => { - if ( - process.env.NODE_ENV === 'test' || - process.env.NODE_ENV === 'development' - ) { - return + if (process.env.NODE_ENV === 'test' || process.env.NODE_ENV === 'development') { + return; } if (isAutoUpdaterDisabled()) { - return + return; } const [channel, pm] = await Promise.all([ Promise.resolve(getInitialSettings()?.autoUpdatesChannel ?? 'latest'), getPackageManager(), - ]) - setPackageManager(pm) + ]); + setPackageManager(pm); - let latest = await getLatestVersionFromGcs(channel) + let latest = await getLatestVersionFromGcs(channel); // Check if max version is set (server-side kill switch for auto-updates) - const maxVersion = await getMaxVersion() + const maxVersion = await getMaxVersion(); if (maxVersion && latest && gt(latest, maxVersion)) { logForDebugging( `PackageManagerAutoUpdater: maxVersion ${maxVersion} is set, capping update from ${latest} to ${maxVersion}`, - ) + ); if (gte(MACRO.VERSION, maxVersion)) { logForDebugging( `PackageManagerAutoUpdater: current version ${MACRO.VERSION} is already at or above maxVersion ${maxVersion}, skipping update`, - ) - setUpdateAvailable(false) - return + ); + setUpdateAvailable(false); + return; } - latest = maxVersion + latest = maxVersion; } - const hasUpdate = - latest && !gte(MACRO.VERSION, latest) && !shouldSkipVersion(latest) + const hasUpdate = latest && !gte(MACRO.VERSION, latest) && !shouldSkipVersion(latest); - setUpdateAvailable(!!hasUpdate) + setUpdateAvailable(!!hasUpdate); if (hasUpdate) { - logForDebugging( - `PackageManagerAutoUpdater: Update available ${MACRO.VERSION} -> ${latest}`, - ) + logForDebugging(`PackageManagerAutoUpdater: Update available ${MACRO.VERSION} -> ${latest}`); } - }, []) + }, []); // Initial check React.useEffect(() => { - void checkForUpdates() - }, [checkForUpdates]) + void checkForUpdates(); + }, [checkForUpdates]); // Check every 30 minutes - useInterval(checkForUpdates, 30 * 60 * 1000) + useInterval(checkForUpdates, 30 * 60 * 1000); if (!updateAvailable) { - return null + return null; } // pacman, deb, and rpm don't get specific commands because they each have @@ -102,7 +92,7 @@ export function PackageManagerAutoUpdater({ verbose }: Props): React.ReactNode { ? 'winget upgrade Anthropic.ClaudeCode' : packageManager === 'apk' ? 'apk upgrade claude-code' - : 'your package manager update command' + : 'your package manager update command'; return ( <> @@ -115,5 +105,5 @@ export function PackageManagerAutoUpdater({ verbose }: Props): React.ReactNode { Update available! Run: {updateCommand} - ) + ); } diff --git a/src/components/Passes/Passes.tsx b/src/components/Passes/Passes.tsx index 94b6b2d52..cb9432d46 100644 --- a/src/components/Passes/Passes.tsx +++ b/src/components/Passes/Passes.tsx @@ -1,130 +1,120 @@ -import * as React from 'react' -import { useCallback, useEffect, useState } from 'react' -import type { CommandResultDisplay } from '../../commands.js' -import { TEARDROP_ASTERISK } from '../../constants/figures.js' -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' -import { setClipboard } from '@anthropic/ink' +import * as React from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import type { CommandResultDisplay } from '../../commands.js'; +import { TEARDROP_ASTERISK } from '../../constants/figures.js'; +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; +import { setClipboard } from '@anthropic/ink'; // eslint-disable-next-line custom-rules/prefer-use-keybindings -- enter to copy link -import { Box, Link, Text, useInput } from '@anthropic/ink' -import { useKeybinding } from '../../keybindings/useKeybinding.js' -import { logEvent } from '../../services/analytics/index.js' +import { Box, Link, Text, useInput } from '@anthropic/ink'; +import { useKeybinding } from '../../keybindings/useKeybinding.js'; +import { logEvent } from '../../services/analytics/index.js'; import { fetchReferralRedemptions, formatCreditAmount, getCachedOrFetchPassesEligibility, -} from '../../services/api/referral.js' -import type { - ReferralRedemptionsResponse, - ReferrerRewardInfo, -} from '../../services/oauth/types.js' -import { count } from '../../utils/array.js' -import { logError } from '../../utils/log.js' -import { Pane } from '@anthropic/ink' +} from '../../services/api/referral.js'; +import type { ReferralRedemptionsResponse, ReferrerRewardInfo } from '../../services/oauth/types.js'; +import { count } from '../../utils/array.js'; +import { logError } from '../../utils/log.js'; +import { Pane } from '@anthropic/ink'; type PassStatus = { - passNumber: number - isAvailable: boolean -} + passNumber: number; + isAvailable: boolean; +}; type Props = { - onDone: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void -} + onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void; +}; export function Passes({ onDone }: Props): React.ReactNode { - const [loading, setLoading] = useState(true) - const [passStatuses, setPassStatuses] = useState([]) - const [isAvailable, setIsAvailable] = useState(false) - const [referralLink, setReferralLink] = useState(null) - const [referrerReward, setReferrerReward] = useState< - ReferrerRewardInfo | null | undefined - >(undefined) + const [loading, setLoading] = useState(true); + const [passStatuses, setPassStatuses] = useState([]); + const [isAvailable, setIsAvailable] = useState(false); + const [referralLink, setReferralLink] = useState(null); + const [referrerReward, setReferrerReward] = useState(undefined); const exitState = useExitOnCtrlCDWithKeybindings(() => onDone('Guest passes dialog dismissed', { display: 'system' }), - ) + ); const handleCancel = useCallback(() => { - onDone('Guest passes dialog dismissed', { display: 'system' }) - }, [onDone]) + onDone('Guest passes dialog dismissed', { display: 'system' }); + }, [onDone]); - useKeybinding('confirm:no', handleCancel, { context: 'Confirmation' }) + useKeybinding('confirm:no', handleCancel, { context: 'Confirmation' }); useInput((_input, key) => { if (key.return && referralLink) { void setClipboard(referralLink).then(raw => { - if (raw) process.stdout.write(raw) - logEvent('tengu_guest_passes_link_copied', {}) - onDone(`Referral link copied to clipboard!`) - }) + if (raw) process.stdout.write(raw); + logEvent('tengu_guest_passes_link_copied', {}); + onDone(`Referral link copied to clipboard!`); + }); } - }) + }); useEffect(() => { async function loadPassesData() { try { // Check eligibility first (uses cache if available) - const eligibilityData = await getCachedOrFetchPassesEligibility() + const eligibilityData = await getCachedOrFetchPassesEligibility(); if (!eligibilityData || !eligibilityData.eligible) { - setIsAvailable(false) - setLoading(false) - return + setIsAvailable(false); + setLoading(false); + return; } - setIsAvailable(true) + setIsAvailable(true); // Store the referral link if available if (eligibilityData.referral_code_details?.referral_link) { - setReferralLink(eligibilityData.referral_code_details.referral_link) + setReferralLink(eligibilityData.referral_code_details.referral_link); } // Store referrer reward info for v1 campaign messaging - setReferrerReward(eligibilityData.referrer_reward) + setReferrerReward(eligibilityData.referrer_reward); // Use the campaign returned from eligibility for redemptions - const campaign = - eligibilityData.referral_code_details?.campaign ?? - 'claude_code_guest_pass' + const campaign = eligibilityData.referral_code_details?.campaign ?? 'claude_code_guest_pass'; // Fetch redemptions data - let redemptionsData: ReferralRedemptionsResponse + let redemptionsData: ReferralRedemptionsResponse; try { - redemptionsData = await fetchReferralRedemptions(campaign) + redemptionsData = await fetchReferralRedemptions(campaign); } catch (err) { - logError(err as Error) - setIsAvailable(false) - setLoading(false) - return + logError(err as Error); + setIsAvailable(false); + setLoading(false); + return; } // Build pass statuses array - const redemptions = redemptionsData.redemptions || [] - const maxRedemptions = redemptionsData.limit || 3 - const statuses: PassStatus[] = [] + const redemptions = redemptionsData.redemptions || []; + const maxRedemptions = redemptionsData.limit || 3; + const statuses: PassStatus[] = []; for (let i = 0; i < maxRedemptions; i++) { - const redemption = redemptions[i] + const redemption = redemptions[i]; statuses.push({ passNumber: i + 1, isAvailable: !redemption, - }) + }); } - setPassStatuses(statuses) - setLoading(false) + setPassStatuses(statuses); + setLoading(false); } catch (err) { // For any error, just show passes as not available - logError(err as Error) - setIsAvailable(false) - setLoading(false) + logError(err as Error); + setIsAvailable(false); + setLoading(false); } } - void loadPassesData() - }, []) + void loadPassesData(); + }, []); if (loading) { return ( @@ -132,15 +122,11 @@ export function Passes({ onDone }: Props): React.ReactNode { Loading guest pass information… - {exitState.pending ? ( - <>Press {exitState.keyName} again to exit - ) : ( - <>Esc to cancel - )} + {exitState.pending ? <>Press {exitState.keyName} again to exit : <>Esc to cancel} - ) + ); } if (!isAvailable) { @@ -149,27 +135,21 @@ export function Passes({ onDone }: Props): React.ReactNode { Guest passes are not currently available. - {exitState.pending ? ( - <>Press {exitState.keyName} again to exit - ) : ( - <>Esc to cancel - )} + {exitState.pending ? <>Press {exitState.keyName} again to exit : <>Esc to cancel} - ) + ); } - const availableCount = count(passStatuses, p => p.isAvailable) + const availableCount = count(passStatuses, p => p.isAvailable); // Sort passes: available first, then redeemed - const sortedPasses = [...passStatuses].sort( - (a, b) => +b.isAvailable - +a.isAvailable, - ) + const sortedPasses = [...passStatuses].sort((a, b) => +b.isAvailable - +a.isAvailable); // ASCII art for tickets const renderTicket = (pass: PassStatus) => { - const isRedeemed = !pass.isAvailable + const isRedeemed = !pass.isAvailable; if (isRedeemed) { // Grayed out redeemed ticket with slashes @@ -179,7 +159,7 @@ export function Passes({ onDone }: Props): React.ReactNode { {` ) CC ${TEARDROP_ASTERISK} ┊╱`} {'└───────╱'} - ) + ); } return ( @@ -192,8 +172,8 @@ export function Passes({ onDone }: Props): React.ReactNode { {'└──────────┘'} - ) - } + ); + }; return ( @@ -229,14 +209,10 @@ export function Passes({ onDone }: Props): React.ReactNode { - {exitState.pending ? ( - <>Press {exitState.keyName} again to exit - ) : ( - <>Enter to copy link · Esc to cancel - )} + {exitState.pending ? <>Press {exitState.keyName} again to exit : <>Enter to copy link · Esc to cancel} - ) + ); } diff --git a/src/components/PrBadge.tsx b/src/components/PrBadge.tsx index e2f7a2a7b..172c4474a 100644 --- a/src/components/PrBadge.tsx +++ b/src/components/PrBadge.tsx @@ -1,56 +1,44 @@ -import React from 'react' -import { Link, Text } from '@anthropic/ink' -import type { PrReviewState } from '../utils/ghPrStatus.js' +import React from 'react'; +import { Link, Text } from '@anthropic/ink'; +import type { PrReviewState } from '../utils/ghPrStatus.js'; type Props = { - number: number - url: string - reviewState?: PrReviewState - bold?: boolean -} + number: number; + url: string; + reviewState?: PrReviewState; + bold?: boolean; +}; -export function PrBadge({ - number, - url, - reviewState, - bold, -}: Props): React.ReactNode { - const statusColor = getPrStatusColor(reviewState) +export function PrBadge({ number, url, reviewState, bold }: Props): React.ReactNode { + const statusColor = getPrStatusColor(reviewState); const label = ( #{number} - ) + ); return ( PR{' '} - + #{number} - ) + ); } -function getPrStatusColor( - state?: PrReviewState, -): 'success' | 'error' | 'warning' | 'merged' | undefined { +function getPrStatusColor(state?: PrReviewState): 'success' | 'error' | 'warning' | 'merged' | undefined { switch (state) { case 'approved': - return 'success' + return 'success'; case 'changes_requested': - return 'error' + return 'error'; case 'pending': - return 'warning' + return 'warning'; case 'merged': - return 'merged' + return 'merged'; default: - return undefined + return undefined; } } diff --git a/src/components/PressEnterToContinue.tsx b/src/components/PressEnterToContinue.tsx index 49b398b12..20eb2fea6 100644 --- a/src/components/PressEnterToContinue.tsx +++ b/src/components/PressEnterToContinue.tsx @@ -1,10 +1,10 @@ -import * as React from 'react' -import { Text } from '@anthropic/ink' +import * as React from 'react'; +import { Text } from '@anthropic/ink'; export function PressEnterToContinue(): React.ReactNode { return ( Press Enter to continue… - ) + ); } diff --git a/src/components/PromptInput/HistorySearchInput.tsx b/src/components/PromptInput/HistorySearchInput.tsx index b8eaf8714..820417183 100644 --- a/src/components/PromptInput/HistorySearchInput.tsx +++ b/src/components/PromptInput/HistorySearchInput.tsx @@ -1,23 +1,17 @@ -import * as React from 'react' -import { Box, Text, stringWidth } from '@anthropic/ink' -import TextInput from '../TextInput.js' +import * as React from 'react'; +import { Box, Text, stringWidth } from '@anthropic/ink'; +import TextInput from '../TextInput.js'; type Props = { - value: string - onChange: (value: string) => void - historyFailedMatch: boolean -} + value: string; + onChange: (value: string) => void; + historyFailedMatch: boolean; +}; -function HistorySearchInput({ - value, - onChange, - historyFailedMatch, -}: Props): React.ReactNode { +function HistorySearchInput({ value, onChange, historyFailedMatch }: Props): React.ReactNode { return ( - - {historyFailedMatch ? 'no matching prompt:' : 'search prompts:'} - + {historyFailedMatch ? 'no matching prompt:' : 'search prompts:'} - ) + ); } -export default HistorySearchInput +export default HistorySearchInput; diff --git a/src/components/PromptInput/IssueFlagBanner.tsx b/src/components/PromptInput/IssueFlagBanner.tsx index 39cb491b9..6d957e796 100644 --- a/src/components/PromptInput/IssueFlagBanner.tsx +++ b/src/components/PromptInput/IssueFlagBanner.tsx @@ -1,6 +1,6 @@ -import * as React from 'react' -import { FLAG_ICON } from '../../constants/figures.js' -import { Box, Text } from '@anthropic/ink' +import * as React from 'react'; +import { FLAG_ICON } from '../../constants/figures.js'; +import { Box, Text } from '@anthropic/ink'; /** * ANT-ONLY: Banner shown in the transcript that prompts users to report @@ -8,7 +8,7 @@ import { Box, Text } from '@anthropic/ink' */ export function IssueFlagBanner(): React.ReactNode { if (process.env.USER_TYPE !== 'ant') { - return null + return null; } return ( @@ -24,5 +24,5 @@ export function IssueFlagBanner(): React.ReactNode { /issue to report it - ) + ); } diff --git a/src/components/PromptInput/Notifications.tsx b/src/components/PromptInput/Notifications.tsx index 6ddccb3cc..a2388657d 100644 --- a/src/components/PromptInput/Notifications.tsx +++ b/src/components/PromptInput/Notifications.tsx @@ -1,67 +1,59 @@ -import { feature } from 'bun:bundle' -import * as React from 'react' -import { type ReactNode, useEffect, useMemo, useState } from 'react' -import { - type Notification, - useNotifications, -} from 'src/context/notifications.js' -import { logEvent } from 'src/services/analytics/index.js' -import { useAppState } from 'src/state/AppState.js' -import { useVoiceState } from '../../context/voice.js' -import type { VerificationStatus } from '../../hooks/useApiKeyVerification.js' -import { useIdeConnectionStatus } from '../../hooks/useIdeConnectionStatus.js' -import type { IDESelection } from '../../hooks/useIdeSelection.js' -import { useMainLoopModel } from '../../hooks/useMainLoopModel.js' -import { useVoiceEnabled } from '../../hooks/useVoiceEnabled.js' -import { Box, Text } from '@anthropic/ink' -import { useClaudeAiLimits } from '../../services/claudeAiLimitsHook.js' -import { calculateTokenWarningState } from '../../services/compact/autoCompact.js' -import type { MCPServerConnection } from '../../services/mcp/types.js' -import type { Message } from '../../types/message.js' -import { - getApiKeyHelperElapsedMs, - getConfiguredApiKeyHelper, - getSubscriptionType, -} from '../../utils/auth.js' -import type { AutoUpdaterResult } from '../../utils/autoUpdater.js' -import { getExternalEditor } from '../../utils/editor.js' -import { isEnvTruthy } from '../../utils/envUtils.js' -import { formatDuration } from '../../utils/format.js' -import { setEnvHookNotifier } from '../../utils/hooks/fileChangedWatcher.js' -import { toIDEDisplayName } from '../../utils/ide.js' -import { getMessagesAfterCompactBoundary } from '../../utils/messages.js' -import { tokenCountFromLastAPIResponse } from '../../utils/tokens.js' -import { AutoUpdaterWrapper } from '../AutoUpdaterWrapper.js' -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' -import { IdeStatusIndicator } from '../IdeStatusIndicator.js' -import { MemoryUsageIndicator } from '../MemoryUsageIndicator.js' -import { SentryErrorBoundary } from '../SentryErrorBoundary.js' -import { TokenWarning } from '../TokenWarning.js' -import { SandboxPromptFooterHint } from './SandboxPromptFooterHint.js' +import { feature } from 'bun:bundle'; +import * as React from 'react'; +import { type ReactNode, useEffect, useMemo, useState } from 'react'; +import { type Notification, useNotifications } from 'src/context/notifications.js'; +import { logEvent } from 'src/services/analytics/index.js'; +import { useAppState } from 'src/state/AppState.js'; +import { useVoiceState } from '../../context/voice.js'; +import type { VerificationStatus } from '../../hooks/useApiKeyVerification.js'; +import { useIdeConnectionStatus } from '../../hooks/useIdeConnectionStatus.js'; +import type { IDESelection } from '../../hooks/useIdeSelection.js'; +import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'; +import { useVoiceEnabled } from '../../hooks/useVoiceEnabled.js'; +import { Box, Text } from '@anthropic/ink'; +import { useClaudeAiLimits } from '../../services/claudeAiLimitsHook.js'; +import { calculateTokenWarningState } from '../../services/compact/autoCompact.js'; +import type { MCPServerConnection } from '../../services/mcp/types.js'; +import type { Message } from '../../types/message.js'; +import { getApiKeyHelperElapsedMs, getConfiguredApiKeyHelper, getSubscriptionType } from '../../utils/auth.js'; +import type { AutoUpdaterResult } from '../../utils/autoUpdater.js'; +import { getExternalEditor } from '../../utils/editor.js'; +import { isEnvTruthy } from '../../utils/envUtils.js'; +import { formatDuration } from '../../utils/format.js'; +import { setEnvHookNotifier } from '../../utils/hooks/fileChangedWatcher.js'; +import { toIDEDisplayName } from '../../utils/ide.js'; +import { getMessagesAfterCompactBoundary } from '../../utils/messages.js'; +import { tokenCountFromLastAPIResponse } from '../../utils/tokens.js'; +import { AutoUpdaterWrapper } from '../AutoUpdaterWrapper.js'; +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; +import { IdeStatusIndicator } from '../IdeStatusIndicator.js'; +import { MemoryUsageIndicator } from '../MemoryUsageIndicator.js'; +import { SentryErrorBoundary } from '../SentryErrorBoundary.js'; +import { TokenWarning } from '../TokenWarning.js'; +import { SandboxPromptFooterHint } from './SandboxPromptFooterHint.js'; /* eslint-disable @typescript-eslint/no-require-imports */ -const VoiceIndicator: typeof import('./VoiceIndicator.js').VoiceIndicator = - feature('VOICE_MODE') - ? require('./VoiceIndicator.js').VoiceIndicator - : () => null +const VoiceIndicator: typeof import('./VoiceIndicator.js').VoiceIndicator = feature('VOICE_MODE') + ? require('./VoiceIndicator.js').VoiceIndicator + : () => null; /* eslint-enable @typescript-eslint/no-require-imports */ -export const FOOTER_TEMPORARY_STATUS_TIMEOUT = 5000 +export const FOOTER_TEMPORARY_STATUS_TIMEOUT = 5000; type Props = { - apiKeyStatus: VerificationStatus - autoUpdaterResult: AutoUpdaterResult | null - isAutoUpdating: boolean - debug: boolean - verbose: boolean - messages: Message[] - onAutoUpdaterResult: (result: AutoUpdaterResult) => void - onChangeIsUpdating: (isUpdating: boolean) => void - ideSelection: IDESelection | undefined - mcpClients?: MCPServerConnection[] - isInputWrapped?: boolean - isNarrow?: boolean -} + apiKeyStatus: VerificationStatus; + autoUpdaterResult: AutoUpdaterResult | null; + isAutoUpdating: boolean; + debug: boolean; + verbose: boolean; + messages: Message[]; + onAutoUpdaterResult: (result: AutoUpdaterResult) => void; + onChangeIsUpdating: (isUpdating: boolean) => void; + ideSelection: IDESelection | undefined; + mcpClients?: MCPServerConnection[]; + isInputWrapped?: boolean; + isNarrow?: boolean; +}; export function Notifications({ apiKeyStatus, @@ -78,22 +70,19 @@ export function Notifications({ isNarrow = false, }: Props): ReactNode { const tokenUsage = useMemo(() => { - const messagesForTokenCount = getMessagesAfterCompactBoundary(messages) - return tokenCountFromLastAPIResponse(messagesForTokenCount) - }, [messages]) + const messagesForTokenCount = getMessagesAfterCompactBoundary(messages); + return tokenCountFromLastAPIResponse(messagesForTokenCount); + }, [messages]); // AppState-sourced model — same source as API requests. getMainLoopModel() // re-reads settings.json on every call, so another session's /model write // would leak into this session's display (anthropics/claude-code#37596). - const mainLoopModel = useMainLoopModel() - const isShowingCompactMessage = calculateTokenWarningState( - tokenUsage, - mainLoopModel, - ).isAboveWarningThreshold - const { status: ideStatus } = useIdeConnectionStatus(mcpClients) - const notifications = useAppState(s => s.notifications) - const { addNotification, removeNotification } = useNotifications() - const claudeAiLimits = useClaudeAiLimits() + const mainLoopModel = useMainLoopModel(); + const isShowingCompactMessage = calculateTokenWarningState(tokenUsage, mainLoopModel).isAboveWarningThreshold; + const { status: ideStatus } = useIdeConnectionStatus(mcpClients); + const notifications = useAppState(s => s.notifications); + const { addNotification, removeNotification } = useNotifications(); + const claudeAiLimits = useClaudeAiLimits(); // Register env hook notifier for CwdChanged/FileChanged feedback useEffect(() => { @@ -104,42 +93,36 @@ export function Notifications({ color: isError ? 'error' : undefined, priority: isError ? 'medium' : 'low', timeoutMs: isError ? 8000 : 5000, - }) - }) - return () => setEnvHookNotifier(null) - }, [addNotification]) + }); + }); + return () => setEnvHookNotifier(null); + }, [addNotification]); // Check if we should show the IDE selection indicator const shouldShowIdeSelection = - ideStatus === 'connected' && - (ideSelection?.filePath || - (ideSelection?.text && ideSelection.lineCount > 0)) + ideStatus === 'connected' && (ideSelection?.filePath || (ideSelection?.text && ideSelection.lineCount > 0)); // Hide update installed message when showing IDE selection - const shouldShowAutoUpdater = - !shouldShowIdeSelection || - isAutoUpdating || - autoUpdaterResult?.status !== 'success' + const shouldShowAutoUpdater = !shouldShowIdeSelection || isAutoUpdating || autoUpdaterResult?.status !== 'success'; // Check if we're in overage mode for UI indicators - const isInOverageMode = claudeAiLimits.isUsingOverage - const subscriptionType = getSubscriptionType() - const isTeamOrEnterprise = - subscriptionType === 'team' || subscriptionType === 'enterprise' + const isInOverageMode = claudeAiLimits.isUsingOverage; + const subscriptionType = getSubscriptionType(); + const isTeamOrEnterprise = subscriptionType === 'team' || subscriptionType === 'enterprise'; // Check if the external editor hint should be shown - const editor = getExternalEditor() + const editor = getExternalEditor(); const shouldShowExternalEditorHint = isInputWrapped && !isShowingCompactMessage && apiKeyStatus !== 'invalid' && apiKeyStatus !== 'missing' && - editor !== undefined + editor !== undefined; // Show external editor hint as notification when input is wrapped useEffect(() => { if (shouldShowExternalEditorHint && editor) { - logEvent('tengu_external_editor_hint_shown', {}) + logEvent('tengu_external_editor_hint_shown', {}); addNotification({ key: 'external-editor-hint', jsx: ( @@ -154,25 +137,15 @@ export function Notifications({ ), priority: 'immediate', timeoutMs: 5000, - }) + }); } else { - removeNotification('external-editor-hint') + removeNotification('external-editor-hint'); } - }, [ - shouldShowExternalEditorHint, - editor, - addNotification, - removeNotification, - ]) + }, [shouldShowExternalEditorHint, editor, addNotification, removeNotification]); return ( - + - ) + ); } function NotificationContent({ @@ -214,69 +187,61 @@ function NotificationContent({ onAutoUpdaterResult, onChangeIsUpdating, }: { - ideSelection: IDESelection | undefined - mcpClients?: MCPServerConnection[] + ideSelection: IDESelection | undefined; + mcpClients?: MCPServerConnection[]; notifications: { - current: Notification | null - queue: Notification[] - } - isInOverageMode: boolean - isTeamOrEnterprise: boolean - apiKeyStatus: VerificationStatus - debug: boolean - verbose: boolean - tokenUsage: number - mainLoopModel: string - shouldShowAutoUpdater: boolean - autoUpdaterResult: AutoUpdaterResult | null - isAutoUpdating: boolean - isShowingCompactMessage: boolean - onAutoUpdaterResult: (result: AutoUpdaterResult) => void - onChangeIsUpdating: (isUpdating: boolean) => void + current: Notification | null; + queue: Notification[]; + }; + isInOverageMode: boolean; + isTeamOrEnterprise: boolean; + apiKeyStatus: VerificationStatus; + debug: boolean; + verbose: boolean; + tokenUsage: number; + mainLoopModel: string; + shouldShowAutoUpdater: boolean; + autoUpdaterResult: AutoUpdaterResult | null; + isAutoUpdating: boolean; + isShowingCompactMessage: boolean; + onAutoUpdaterResult: (result: AutoUpdaterResult) => void; + onChangeIsUpdating: (isUpdating: boolean) => void; }): ReactNode { // Poll apiKeyHelper inflight state to show slow-helper notice. // Gated on configuration — most users never set apiKeyHelper, so the // effect is a no-op for them (no interval allocated). - const [apiKeyHelperSlow, setApiKeyHelperSlow] = useState(null) + const [apiKeyHelperSlow, setApiKeyHelperSlow] = useState(null); useEffect(() => { - if (!getConfiguredApiKeyHelper()) return + if (!getConfiguredApiKeyHelper()) return; const interval = setInterval( (setSlow: React.Dispatch>) => { - const ms = getApiKeyHelperElapsedMs() - const next = ms >= 10_000 ? formatDuration(ms) : null - setSlow(prev => (next === prev ? prev : next)) + const ms = getApiKeyHelperElapsedMs(); + const next = ms >= 10_000 ? formatDuration(ms) : null; + setSlow(prev => (next === prev ? prev : next)); }, 1000, setApiKeyHelperSlow, - ) - return () => clearInterval(interval) - }, []) + ); + return () => clearInterval(interval); + }, []); // Voice state (VOICE_MODE builds only, runtime-gated by GrowthBook) const voiceState = feature('VOICE_MODE') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useVoiceState(s => s.voiceState) - : ('idle' as const) - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false + ? useVoiceState(s => s.voiceState) + : ('idle' as const); + const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false; const voiceError = feature('VOICE_MODE') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useVoiceState(s => s.voiceError) - : null + ? useVoiceState(s => s.voiceError) + : null; const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s => s.isBriefOnly) - : false + ? useAppState(s => s.isBriefOnly) + : false; // When voice is actively recording or processing, replace all // notifications with just the voice indicator. - if ( - feature('VOICE_MODE') && - voiceEnabled && - (voiceState === 'recording' || voiceState === 'processing') - ) { - return + if (feature('VOICE_MODE') && voiceEnabled && (voiceState === 'recording' || voiceState === 'processing')) { + return ; } return ( @@ -288,11 +253,7 @@ function NotificationContent({ {notifications.current.jsx} ) : ( - + {notifications.current.text} ))} @@ -336,9 +297,7 @@ function NotificationContent({ )} - {!isBriefOnly && ( - - )} + {!isBriefOnly && } {feature('VOICE_MODE') ? voiceEnabled && voiceError && ( @@ -352,5 +311,5 @@ function NotificationContent({ - ) + ); } diff --git a/src/components/PromptInput/PromptInput.tsx b/src/components/PromptInput/PromptInput.tsx index 3d43a1fae..1bceeedd7 100644 --- a/src/components/PromptInput/PromptInput.tsx +++ b/src/components/PromptInput/PromptInput.tsx @@ -1,314 +1,228 @@ -import { feature } from 'bun:bundle' -import chalk from 'chalk' -import * as path from 'path' -import * as React from 'react' -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - useSyncExternalStore, -} from 'react' -import { useNotifications } from 'src/context/notifications.js' -import { useCommandQueue } from 'src/hooks/useCommandQueue.js' -import { - type IDEAtMentioned, - useIdeAtMentioned, -} from 'src/hooks/useIdeAtMentioned.js' +import { feature } from 'bun:bundle'; +import chalk from 'chalk'; +import * as path from 'path'; +import * as React from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import { useNotifications } from 'src/context/notifications.js'; +import { useCommandQueue } from 'src/hooks/useCommandQueue.js'; +import { type IDEAtMentioned, useIdeAtMentioned } from 'src/hooks/useIdeAtMentioned.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from 'src/services/analytics/index.js' -import { - type AppState, - useAppState, - useAppStateStore, - useSetAppState, -} from 'src/state/AppState.js' -import type { FooterItem } from 'src/state/AppStateStore.js' -import { getCwd } from 'src/utils/cwd.js' -import { - isQueuedCommandEditable, - popAllEditable, -} from 'src/utils/messageQueueManager.js' -import stripAnsi from 'strip-ansi' -import { companionReservedColumns } from '../../buddy/CompanionSprite.js' -import { - findBuddyTriggerPositions, - useBuddyNotification, -} from '../../buddy/useBuddyNotification.js' -import { FastModePicker } from '../../commands/fast/fast.js' -import { isUltrareviewEnabled } from '../../commands/review/ultrareviewEnabled.js' -import { getNativeCSIuTerminalDisplayName } from '../../commands/terminalSetup/terminalSetup.js' -import { type Command, hasCommand } from '../../commands.js' -import { useIsModalOverlayActive } from '../../context/overlayContext.js' -import { useSetPromptOverlayDialog } from '../../context/promptOverlayContext.js' -import { - formatImageRef, - formatPastedTextRef, - getPastedTextRefNumLines, - parseReferences, -} from '../../history.js' -import type { VerificationStatus } from '../../hooks/useApiKeyVerification.js' -import { - type HistoryMode, - useArrowKeyHistory, -} from '../../hooks/useArrowKeyHistory.js' -import { useDoublePress } from '../../hooks/useDoublePress.js' -import { useHistorySearch } from '../../hooks/useHistorySearch.js' -import type { IDESelection } from '../../hooks/useIdeSelection.js' -import { useInputBuffer } from '../../hooks/useInputBuffer.js' -import { useMainLoopModel } from '../../hooks/useMainLoopModel.js' -import { usePromptSuggestion } from '../../hooks/usePromptSuggestion.js' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' -import { useTypeahead } from '../../hooks/useTypeahead.js' -import { Box, type BorderTextOptions, type ClickEvent, type Key, stringWidth, Text, useInput } from '@anthropic/ink' -import { useOptionalKeybindingContext } from '../../keybindings/KeybindingContext.js' -import { getShortcutDisplay } from '../../keybindings/shortcutFormat.js' -import { - useKeybinding, - useKeybindings, -} from '../../keybindings/useKeybinding.js' -import type { MCPServerConnection } from '../../services/mcp/types.js' -import { - abortPromptSuggestion, - logSuggestionSuppressed, -} from '../../services/PromptSuggestion/promptSuggestion.js' -import { - type ActiveSpeculationState, - abortSpeculation, -} from '../../services/PromptSuggestion/speculation.js' -import { - getActiveAgentForInput, - getViewedTeammateTask, -} from '../../state/selectors.js' -import { - enterTeammateView, - exitTeammateView, - stopOrDismissAgent, -} from '../../state/teammateViewHelpers.js' -import type { ToolPermissionContext } from '../../Tool.js' -import { getRunningTeammatesSorted } from '../../tasks/InProcessTeammateTask/InProcessTeammateTask.js' -import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js' -import { - isPanelAgentTask, - type LocalAgentTaskState, -} from '../../tasks/LocalAgentTask/LocalAgentTask.js' -import { isBackgroundTask } from '../../tasks/types.js' +} from 'src/services/analytics/index.js'; +import { type AppState, useAppState, useAppStateStore, useSetAppState } from 'src/state/AppState.js'; +import type { FooterItem } from 'src/state/AppStateStore.js'; +import { getCwd } from 'src/utils/cwd.js'; +import { isQueuedCommandEditable, popAllEditable } from 'src/utils/messageQueueManager.js'; +import stripAnsi from 'strip-ansi'; +import { companionReservedColumns } from '../../buddy/CompanionSprite.js'; +import { findBuddyTriggerPositions, useBuddyNotification } from '../../buddy/useBuddyNotification.js'; +import { FastModePicker } from '../../commands/fast/fast.js'; +import { isUltrareviewEnabled } from '../../commands/review/ultrareviewEnabled.js'; +import { getNativeCSIuTerminalDisplayName } from '../../commands/terminalSetup/terminalSetup.js'; +import { type Command, hasCommand } from '../../commands.js'; +import { useIsModalOverlayActive } from '../../context/overlayContext.js'; +import { useSetPromptOverlayDialog } from '../../context/promptOverlayContext.js'; +import { formatImageRef, formatPastedTextRef, getPastedTextRefNumLines, parseReferences } from '../../history.js'; +import type { VerificationStatus } from '../../hooks/useApiKeyVerification.js'; +import { type HistoryMode, useArrowKeyHistory } from '../../hooks/useArrowKeyHistory.js'; +import { useDoublePress } from '../../hooks/useDoublePress.js'; +import { useHistorySearch } from '../../hooks/useHistorySearch.js'; +import type { IDESelection } from '../../hooks/useIdeSelection.js'; +import { useInputBuffer } from '../../hooks/useInputBuffer.js'; +import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'; +import { usePromptSuggestion } from '../../hooks/usePromptSuggestion.js'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import { useTypeahead } from '../../hooks/useTypeahead.js'; +import { Box, type BorderTextOptions, type ClickEvent, type Key, stringWidth, Text, useInput } from '@anthropic/ink'; +import { useOptionalKeybindingContext } from '../../keybindings/KeybindingContext.js'; +import { getShortcutDisplay } from '../../keybindings/shortcutFormat.js'; +import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js'; +import type { MCPServerConnection } from '../../services/mcp/types.js'; +import { abortPromptSuggestion, logSuggestionSuppressed } from '../../services/PromptSuggestion/promptSuggestion.js'; +import { type ActiveSpeculationState, abortSpeculation } from '../../services/PromptSuggestion/speculation.js'; +import { getActiveAgentForInput, getViewedTeammateTask } from '../../state/selectors.js'; +import { enterTeammateView, exitTeammateView, stopOrDismissAgent } from '../../state/teammateViewHelpers.js'; +import type { ToolPermissionContext } from '../../Tool.js'; +import { getRunningTeammatesSorted } from '../../tasks/InProcessTeammateTask/InProcessTeammateTask.js'; +import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js'; +import { isPanelAgentTask, type LocalAgentTaskState } from '../../tasks/LocalAgentTask/LocalAgentTask.js'; +import { isBackgroundTask } from '../../tasks/types.js'; import { AGENT_COLOR_TO_THEME_COLOR, AGENT_COLORS, type AgentColorName, -} from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js' -import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js' -import type { Message } from '../../types/message.js' -import type { PermissionMode } from '../../types/permissions.js' -import type { - BaseTextInputProps, - PromptInputMode, - VimMode, -} from '../../types/textInputTypes.js' -import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' -import { count } from '../../utils/array.js' -import type { AutoUpdaterResult } from '../../utils/autoUpdater.js' -import { Cursor } from '../../utils/Cursor.js' -import { - getGlobalConfig, - type PastedContent, - saveGlobalConfig, -} from '../../utils/config.js' -import { logForDebugging } from '../../utils/debug.js' -import { - parseDirectMemberMessage, - sendDirectMemberMessage, -} from '../../utils/directMemberMessage.js' -import type { EffortLevel } from '../../utils/effort.js' -import { env } from '../../utils/env.js' -import { errorMessage } from '../../utils/errors.js' -import { isBilledAsExtraUsage } from '../../utils/extraUsage.js' +} from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js'; +import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'; +import type { Message } from '../../types/message.js'; +import type { PermissionMode } from '../../types/permissions.js'; +import type { BaseTextInputProps, PromptInputMode, VimMode } from '../../types/textInputTypes.js'; +import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'; +import { count } from '../../utils/array.js'; +import type { AutoUpdaterResult } from '../../utils/autoUpdater.js'; +import { Cursor } from '../../utils/Cursor.js'; +import { getGlobalConfig, type PastedContent, saveGlobalConfig } from '../../utils/config.js'; +import { logForDebugging } from '../../utils/debug.js'; +import { parseDirectMemberMessage, sendDirectMemberMessage } from '../../utils/directMemberMessage.js'; +import type { EffortLevel } from '../../utils/effort.js'; +import { env } from '../../utils/env.js'; +import { errorMessage } from '../../utils/errors.js'; +import { isBilledAsExtraUsage } from '../../utils/extraUsage.js'; import { getFastModeUnavailableReason, isFastModeAvailable, isFastModeCooldown, isFastModeEnabled, isFastModeSupportedByModel, -} from '../../utils/fastMode.js' -import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js' -import type { PromptInputHelpers } from '../../utils/handlePromptSubmit.js' -import { - getImageFromClipboard, - PASTE_THRESHOLD, -} from '../../utils/imagePaste.js' -import type { ImageDimensions } from '../../utils/imageResizer.js' -import { cacheImagePath, storeImage } from '../../utils/imageStore.js' -import { - isMacosOptionChar, - MACOS_OPTION_SPECIAL_CHARS, -} from '../../utils/keyboardShortcuts.js' -import { logError } from '../../utils/log.js' -import { - isOpus1mMergeEnabled, - modelDisplayString, -} from '../../utils/model/model.js' -import { setAutoModeActive } from '../../utils/permissions/autoModeState.js' -import { - cyclePermissionMode, - getNextPermissionMode, -} from '../../utils/permissions/getNextPermissionMode.js' -import { transitionPermissionMode } from '../../utils/permissions/permissionSetup.js' -import { getPlatform } from '../../utils/platform.js' -import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js' -import { editPromptInEditor } from '../../utils/promptEditor.js' -import { hasAutoModeOptIn } from '../../utils/settings/settings.js' -import { findBtwTriggerPositions } from '../../utils/sideQuestion.js' -import { findSlashCommandPositions } from '../../utils/suggestions/commandSuggestions.js' +} from '../../utils/fastMode.js'; +import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'; +import type { PromptInputHelpers } from '../../utils/handlePromptSubmit.js'; +import { getImageFromClipboard, PASTE_THRESHOLD } from '../../utils/imagePaste.js'; +import type { ImageDimensions } from '../../utils/imageResizer.js'; +import { cacheImagePath, storeImage } from '../../utils/imageStore.js'; +import { isMacosOptionChar, MACOS_OPTION_SPECIAL_CHARS } from '../../utils/keyboardShortcuts.js'; +import { logError } from '../../utils/log.js'; +import { isOpus1mMergeEnabled, modelDisplayString } from '../../utils/model/model.js'; +import { setAutoModeActive } from '../../utils/permissions/autoModeState.js'; +import { cyclePermissionMode, getNextPermissionMode } from '../../utils/permissions/getNextPermissionMode.js'; +import { transitionPermissionMode } from '../../utils/permissions/permissionSetup.js'; +import { getPlatform } from '../../utils/platform.js'; +import type { ProcessUserInputContext } from '../../utils/processUserInput/processUserInput.js'; +import { editPromptInEditor } from '../../utils/promptEditor.js'; +import { hasAutoModeOptIn } from '../../utils/settings/settings.js'; +import { findBtwTriggerPositions } from '../../utils/sideQuestion.js'; +import { findSlashCommandPositions } from '../../utils/suggestions/commandSuggestions.js'; import { findSlackChannelPositions, getKnownChannelsVersion, hasSlackMcpServer, subscribeKnownChannels, -} from '../../utils/suggestions/slackChannelSuggestions.js' -import { isInProcessEnabled } from '../../utils/swarm/backends/registry.js' -import { syncTeammateMode } from '../../utils/swarm/teamHelpers.js' -import type { TeamSummary } from '../../utils/teamDiscovery.js' -import { getTeammateColor } from '../../utils/teammate.js' -import { isInProcessTeammate } from '../../utils/teammateContext.js' -import { writeToMailbox } from '../../utils/teammateMailbox.js' -import type { TextHighlight } from '../../utils/textHighlighting.js' -import type { Theme } from '../../utils/theme.js' -import { - findThinkingTriggerPositions, - getRainbowColor, - isUltrathinkEnabled, -} from '../../utils/thinking.js' -import { findTokenBudgetPositions } from '../../utils/tokenBudget.js' -import { - findUltraplanTriggerPositions, - findUltrareviewTriggerPositions, -} from '../../utils/ultraplan/keyword.js' -import { AutoModeOptInDialog } from '../AutoModeOptInDialog.js' -import { BridgeDialog } from '../BridgeDialog.js' -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' -import { - getVisibleAgentTasks, - useCoordinatorTaskCount, -} from '../CoordinatorAgentStatus.js' -import { getEffortNotificationText } from '../EffortIndicator.js' -import { getFastIconString } from '../FastIcon.js' -import { GlobalSearchDialog } from '../GlobalSearchDialog.js' -import { HistorySearchDialog } from '../HistorySearchDialog.js' -import { ModelPicker } from '../ModelPicker.js' -import { QuickOpenDialog } from '../QuickOpenDialog.js' -import TextInput from '../TextInput.js' -import { ThinkingToggle } from '../ThinkingToggle.js' -import { BackgroundTasksDialog } from '../tasks/BackgroundTasksDialog.js' -import { shouldHideTasksFooter } from '../tasks/taskStatusUtils.js' -import { TeamsDialog } from '../teams/TeamsDialog.js' -import VimTextInput from '../VimTextInput.js' -import { getModeFromInput, getValueFromInput } from './inputModes.js' -import { - FOOTER_TEMPORARY_STATUS_TIMEOUT, - Notifications, -} from './Notifications.js' -import PromptInputFooter from './PromptInputFooter.js' -import type { SuggestionItem } from './PromptInputFooterSuggestions.js' -import { PromptInputModeIndicator } from './PromptInputModeIndicator.js' -import { PromptInputQueuedCommands } from './PromptInputQueuedCommands.js' -import { PromptInputStashNotice } from './PromptInputStashNotice.js' -import { useMaybeTruncateInput } from './useMaybeTruncateInput.js' -import { usePromptInputPlaceholder } from './usePromptInputPlaceholder.js' -import { useShowFastIconHint } from './useShowFastIconHint.js' -import { useSwarmBanner } from './useSwarmBanner.js' -import { isNonSpacePrintable, isVimModeEnabled } from './utils.js' +} from '../../utils/suggestions/slackChannelSuggestions.js'; +import { isInProcessEnabled } from '../../utils/swarm/backends/registry.js'; +import { syncTeammateMode } from '../../utils/swarm/teamHelpers.js'; +import type { TeamSummary } from '../../utils/teamDiscovery.js'; +import { getTeammateColor } from '../../utils/teammate.js'; +import { isInProcessTeammate } from '../../utils/teammateContext.js'; +import { writeToMailbox } from '../../utils/teammateMailbox.js'; +import type { TextHighlight } from '../../utils/textHighlighting.js'; +import type { Theme } from '../../utils/theme.js'; +import { findThinkingTriggerPositions, getRainbowColor, isUltrathinkEnabled } from '../../utils/thinking.js'; +import { findTokenBudgetPositions } from '../../utils/tokenBudget.js'; +import { findUltraplanTriggerPositions, findUltrareviewTriggerPositions } from '../../utils/ultraplan/keyword.js'; +import { AutoModeOptInDialog } from '../AutoModeOptInDialog.js'; +import { BridgeDialog } from '../BridgeDialog.js'; +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; +import { getVisibleAgentTasks, useCoordinatorTaskCount } from '../CoordinatorAgentStatus.js'; +import { getEffortNotificationText } from '../EffortIndicator.js'; +import { getFastIconString } from '../FastIcon.js'; +import { GlobalSearchDialog } from '../GlobalSearchDialog.js'; +import { HistorySearchDialog } from '../HistorySearchDialog.js'; +import { ModelPicker } from '../ModelPicker.js'; +import { QuickOpenDialog } from '../QuickOpenDialog.js'; +import TextInput from '../TextInput.js'; +import { ThinkingToggle } from '../ThinkingToggle.js'; +import { BackgroundTasksDialog } from '../tasks/BackgroundTasksDialog.js'; +import { shouldHideTasksFooter } from '../tasks/taskStatusUtils.js'; +import { TeamsDialog } from '../teams/TeamsDialog.js'; +import VimTextInput from '../VimTextInput.js'; +import { getModeFromInput, getValueFromInput } from './inputModes.js'; +import { FOOTER_TEMPORARY_STATUS_TIMEOUT, Notifications } from './Notifications.js'; +import PromptInputFooter from './PromptInputFooter.js'; +import type { SuggestionItem } from './PromptInputFooterSuggestions.js'; +import { PromptInputModeIndicator } from './PromptInputModeIndicator.js'; +import { PromptInputQueuedCommands } from './PromptInputQueuedCommands.js'; +import { PromptInputStashNotice } from './PromptInputStashNotice.js'; +import { useMaybeTruncateInput } from './useMaybeTruncateInput.js'; +import { usePromptInputPlaceholder } from './usePromptInputPlaceholder.js'; +import { useShowFastIconHint } from './useShowFastIconHint.js'; +import { useSwarmBanner } from './useSwarmBanner.js'; +import { isNonSpacePrintable, isVimModeEnabled } from './utils.js'; type Props = { - debug: boolean - ideSelection: IDESelection | undefined - toolPermissionContext: ToolPermissionContext - setToolPermissionContext: (ctx: ToolPermissionContext) => void - apiKeyStatus: VerificationStatus - commands: Command[] - agents: AgentDefinition[] - isLoading: boolean - verbose: boolean - messages: Message[] - onAutoUpdaterResult: (result: AutoUpdaterResult) => void - autoUpdaterResult: AutoUpdaterResult | null - input: string - onInputChange: (value: string) => void - mode: PromptInputMode - onModeChange: (mode: PromptInputMode) => void + debug: boolean; + ideSelection: IDESelection | undefined; + toolPermissionContext: ToolPermissionContext; + setToolPermissionContext: (ctx: ToolPermissionContext) => void; + apiKeyStatus: VerificationStatus; + commands: Command[]; + agents: AgentDefinition[]; + isLoading: boolean; + verbose: boolean; + messages: Message[]; + onAutoUpdaterResult: (result: AutoUpdaterResult) => void; + autoUpdaterResult: AutoUpdaterResult | null; + input: string; + onInputChange: (value: string) => void; + mode: PromptInputMode; + onModeChange: (mode: PromptInputMode) => void; stashedPrompt: | { - text: string - cursorOffset: number - pastedContents: Record + text: string; + cursorOffset: number; + pastedContents: Record; } - | undefined + | undefined; setStashedPrompt: ( value: | { - text: string - cursorOffset: number - pastedContents: Record + text: string; + cursorOffset: number; + pastedContents: Record; } | undefined, - ) => void - submitCount: number - onShowMessageSelector: () => void + ) => void; + submitCount: number; + onShowMessageSelector: () => void; /** Fullscreen message actions: shift+↑ enters cursor. */ - onMessageActionsEnter?: () => void - mcpClients: MCPServerConnection[] - pastedContents: Record - setPastedContents: React.Dispatch< - React.SetStateAction> - > - vimMode: VimMode - setVimMode: (mode: VimMode) => void - showBashesDialog: string | boolean - setShowBashesDialog: (show: string | boolean) => void - onExit: () => void + onMessageActionsEnter?: () => void; + mcpClients: MCPServerConnection[]; + pastedContents: Record; + setPastedContents: React.Dispatch>>; + vimMode: VimMode; + setVimMode: (mode: VimMode) => void; + showBashesDialog: string | boolean; + setShowBashesDialog: (show: string | boolean) => void; + onExit: () => void; getToolUseContext: ( messages: Message[], newMessages: Message[], abortController: AbortController, mainLoopModel: string, - ) => ProcessUserInputContext + ) => ProcessUserInputContext; onSubmit: ( input: string, helpers: PromptInputHelpers, speculationAccept?: { - state: ActiveSpeculationState - speculationSessionTimeSavedMs: number - setAppState: (f: (prev: AppState) => AppState) => void + state: ActiveSpeculationState; + speculationSessionTimeSavedMs: number; + setAppState: (f: (prev: AppState) => AppState) => void; }, options?: { fromKeybinding?: boolean }, - ) => Promise + ) => Promise; onAgentSubmit?: ( input: string, task: InProcessTeammateTaskState | LocalAgentTaskState, helpers: PromptInputHelpers, - ) => Promise - isSearchingHistory: boolean - setIsSearchingHistory: (isSearching: boolean) => void - onDismissSideQuestion?: () => void - isSideQuestionVisible?: boolean - helpOpen: boolean - setHelpOpen: React.Dispatch> - hasSuppressedDialogs?: boolean - isLocalJSXCommandActive?: boolean + ) => Promise; + isSearchingHistory: boolean; + setIsSearchingHistory: (isSearching: boolean) => void; + onDismissSideQuestion?: () => void; + isSideQuestionVisible?: boolean; + helpOpen: boolean; + setHelpOpen: React.Dispatch>; + hasSuppressedDialogs?: boolean; + isLocalJSXCommandActive?: boolean; insertTextRef?: React.MutableRefObject<{ - insert: (text: string) => void - setInputWithCursor: (value: string, cursor: number) => void - cursorOffset: number - } | null> - voiceInterimRange?: { start: number; end: number } | null -} + insert: (text: string) => void; + setInputWithCursor: (value: string, cursor: number) => void; + cursorOffset: number; + } | null>; + voiceInterimRange?: { start: number; end: number } | null; +}; // Bottom slot has maxHeight="50%"; reserve lines for footer, border, status. -const PROMPT_FOOTER_LINES = 5 -const MIN_INPUT_VIEWPORT_LINES = 3 +const PROMPT_FOOTER_LINES = 5; +const MIN_INPUT_VIEWPORT_LINES = 3; function PromptInput({ debug, @@ -354,96 +268,82 @@ function PromptInput({ insertTextRef, voiceInterimRange, }: Props): React.ReactNode { - const mainLoopModel = useMainLoopModel() + const mainLoopModel = useMainLoopModel(); // A local-jsx command (e.g., /mcp while agent is running) renders a full- // screen dialog on top of PromptInput via the immediate-command path with // shouldHidePromptInput: false. Those dialogs don't register in the overlay // system, so treat them as a modal overlay here to stop navigation keys from // leaking into TextInput/footer handlers and stacking a second dialog. - const isModalOverlayActive = - useIsModalOverlayActive() || isLocalJSXCommandActive - const [isAutoUpdating, setIsAutoUpdating] = useState(false) + const isModalOverlayActive = useIsModalOverlayActive() || isLocalJSXCommandActive; + const [isAutoUpdating, setIsAutoUpdating] = useState(false); const [exitMessage, setExitMessage] = useState<{ - show: boolean - key?: string - }>({ show: false }) - const [cursorOffset, setCursorOffset] = useState(input.length) + show: boolean; + key?: string; + }>({ show: false }); + const [cursorOffset, setCursorOffset] = useState(input.length); // Track the last input value set via internal handlers so we can detect // external input changes (e.g. speech-to-text injection) and move cursor to end. - const lastInternalInputRef = React.useRef(input) + const lastInternalInputRef = React.useRef(input); if (input !== lastInternalInputRef.current) { // Input changed externally (not through any internal handler) — move cursor to end - setCursorOffset(input.length) - lastInternalInputRef.current = input + setCursorOffset(input.length); + lastInternalInputRef.current = input; } // Wrap onInputChange to track internal changes before they trigger re-render const trackAndSetInput = React.useCallback( (value: string) => { - lastInternalInputRef.current = value - onInputChange(value) + lastInternalInputRef.current = value; + onInputChange(value); }, [onInputChange], - ) + ); // Expose an insertText function so callers (e.g. STT) can splice text at the // current cursor position instead of replacing the entire input. if (insertTextRef) { insertTextRef.current = { cursorOffset, insert: (text: string) => { - const needsSpace = - cursorOffset === input.length && - input.length > 0 && - !/\s$/.test(input) - const insertText = needsSpace ? ' ' + text : text - const newValue = - input.slice(0, cursorOffset) + insertText + input.slice(cursorOffset) - lastInternalInputRef.current = newValue - onInputChange(newValue) - setCursorOffset(cursorOffset + insertText.length) + const needsSpace = cursorOffset === input.length && input.length > 0 && !/\s$/.test(input); + const insertText = needsSpace ? ' ' + text : text; + const newValue = input.slice(0, cursorOffset) + insertText + input.slice(cursorOffset); + lastInternalInputRef.current = newValue; + onInputChange(newValue); + setCursorOffset(cursorOffset + insertText.length); }, setInputWithCursor: (value: string, cursor: number) => { - lastInternalInputRef.current = value - onInputChange(value) - setCursorOffset(cursor) + lastInternalInputRef.current = value; + onInputChange(value); + setCursorOffset(cursor); }, - } + }; } - const store = useAppStateStore() - const setAppState = useSetAppState() - const tasks = useAppState(s => s.tasks) - const replBridgeConnected = useAppState(s => s.replBridgeConnected) - const replBridgeExplicit = useAppState(s => s.replBridgeExplicit) - const replBridgeReconnecting = useAppState(s => s.replBridgeReconnecting) + const store = useAppStateStore(); + const setAppState = useSetAppState(); + const tasks = useAppState(s => s.tasks); + const replBridgeConnected = useAppState(s => s.replBridgeConnected); + const replBridgeExplicit = useAppState(s => s.replBridgeExplicit); + const replBridgeReconnecting = useAppState(s => s.replBridgeReconnecting); // Must match BridgeStatusIndicator's render condition (PromptInputFooter.tsx) — // the pill returns null for implicit-and-not-reconnecting, so nav must too, // otherwise bridge becomes an invisible selection stop. - const bridgeFooterVisible = - replBridgeConnected && (replBridgeExplicit || replBridgeReconnecting) + const bridgeFooterVisible = replBridgeConnected && (replBridgeExplicit || replBridgeReconnecting); // Tmux pill (ant-only) — visible when there's an active tungsten session - const hasTungstenSession = useAppState( - s => - process.env.USER_TYPE === 'ant' && s.tungstenActiveSession !== undefined, - ) - const tmuxFooterVisible = - process.env.USER_TYPE === 'ant' && hasTungstenSession + const hasTungstenSession = useAppState(s => process.env.USER_TYPE === 'ant' && s.tungstenActiveSession !== undefined); + const tmuxFooterVisible = process.env.USER_TYPE === 'ant' && hasTungstenSession; // WebBrowser pill — visible when a browser is open - const bagelFooterVisible = useAppState(s => - false, - ) - const teamContext = useAppState(s => s.teamContext) - const queuedCommands = useCommandQueue() - const promptSuggestionState = useAppState(s => s.promptSuggestion) - const speculation = useAppState(s => s.speculation) - const speculationSessionTimeSavedMs = useAppState( - s => s.speculationSessionTimeSavedMs, - ) - const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId) - const viewSelectionMode = useAppState(s => s.viewSelectionMode) - const showSpinnerTree = useAppState(s => s.expandedView) === 'teammates' + const bagelFooterVisible = useAppState(s => false); + const teamContext = useAppState(s => s.teamContext); + const queuedCommands = useCommandQueue(); + const promptSuggestionState = useAppState(s => s.promptSuggestion); + const speculation = useAppState(s => s.speculation); + const speculationSessionTimeSavedMs = useAppState(s => s.speculationSessionTimeSavedMs); + const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId); + const viewSelectionMode = useAppState(s => s.viewSelectionMode); + const showSpinnerTree = useAppState(s => s.expandedView) === 'teammates'; const { companion: _companion, companionMuted } = feature('BUDDY') ? getGlobalConfig() - : { companion: undefined, companionMuted: undefined } - const companionFooterVisible = !!_companion && !companionMuted + : { companion: undefined, companionMuted: undefined }; + const companionFooterVisible = !!_companion && !companionMuted; // Brief mode: BriefSpinner/BriefIdleStatus own the 2-row footprint above // the input. Dropping marginTop here lets the spinner sit flush against // the input bar. viewingAgentTaskId mirrors the gate on both (Spinner.tsx, @@ -451,35 +351,27 @@ function PromptInput({ // its own marginTop, so the gap stays even without ours. const briefOwnsGap = feature('KAIROS') || feature('KAIROS_BRIEF') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s => s.isBriefOnly) && !viewingAgentTaskId - : false - const mainLoopModel_ = useAppState(s => s.mainLoopModel) - const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession) - const thinkingEnabled = useAppState(s => s.thinkingEnabled) - const isFastMode = useAppState(s => - isFastModeEnabled() ? s.fastMode : false, - ) - const effortValue = useAppState(s => s.effortValue) - const viewedTeammate = getViewedTeammateTask(store.getState()) - const viewingAgentName = viewedTeammate?.identity.agentName + ? useAppState(s => s.isBriefOnly) && !viewingAgentTaskId + : false; + const mainLoopModel_ = useAppState(s => s.mainLoopModel); + const mainLoopModelForSession = useAppState(s => s.mainLoopModelForSession); + const thinkingEnabled = useAppState(s => s.thinkingEnabled); + const isFastMode = useAppState(s => (isFastModeEnabled() ? s.fastMode : false)); + const effortValue = useAppState(s => s.effortValue); + const viewedTeammate = getViewedTeammateTask(store.getState()); + const viewingAgentName = viewedTeammate?.identity.agentName; // identity.color is typed as `string | undefined` (not AgentColorName) because // teammate identity comes from file-based config. Validate before casting to // ensure we only use valid color names (falls back to cyan if invalid). const viewingAgentColor = - viewedTeammate?.identity.color && - AGENT_COLORS.includes(viewedTeammate.identity.color as AgentColorName) + viewedTeammate?.identity.color && AGENT_COLORS.includes(viewedTeammate.identity.color as AgentColorName) ? (viewedTeammate.identity.color as AgentColorName) - : undefined + : undefined; // In-process teammates sorted alphabetically for footer team selector - const inProcessTeammates = useMemo( - () => getRunningTeammatesSorted(tasks), - [tasks], - ) + const inProcessTeammates = useMemo(() => getRunningTeammatesSorted(tasks), [tasks]); // Team mode: all background tasks are in-process teammates - const isTeammateMode = - inProcessTeammates.length > 0 || viewedTeammate !== undefined + const isTeammateMode = inProcessTeammates.length > 0 || viewedTeammate !== undefined; // When viewing a teammate, show their permission mode in the footer instead of the leader's const effectiveToolPermissionContext = useMemo((): ToolPermissionContext => { @@ -487,125 +379,114 @@ function PromptInput({ return { ...toolPermissionContext, mode: viewedTeammate.permissionMode, - } + }; } - return toolPermissionContext - }, [viewedTeammate, toolPermissionContext]) - const { historyQuery, setHistoryQuery, historyMatch, historyFailedMatch } = - useHistorySearch( - entry => { - setPastedContents(entry.pastedContents) - void onSubmit(entry.display) - }, - input, - trackAndSetInput, - setCursorOffset, - cursorOffset, - onModeChange, - mode, - isSearchingHistory, - setIsSearchingHistory, - setPastedContents, - pastedContents, - ) + return toolPermissionContext; + }, [viewedTeammate, toolPermissionContext]); + const { historyQuery, setHistoryQuery, historyMatch, historyFailedMatch } = useHistorySearch( + entry => { + setPastedContents(entry.pastedContents); + void onSubmit(entry.display); + }, + input, + trackAndSetInput, + setCursorOffset, + cursorOffset, + onModeChange, + mode, + isSearchingHistory, + setIsSearchingHistory, + setPastedContents, + pastedContents, + ); // Counter for paste IDs (shared between images and text). // Compute initial value once from existing messages (for --continue/--resume). // useRef(fn()) evaluates fn() on every render and discards the result after // mount — getInitialPasteId walks all messages + regex-scans text blocks, // so guard with a lazy-init pattern to run it exactly once. - const nextPasteIdRef = useRef(-1) + const nextPasteIdRef = useRef(-1); if (nextPasteIdRef.current === -1) { - nextPasteIdRef.current = getInitialPasteId(messages) + nextPasteIdRef.current = getInitialPasteId(messages); } // Armed by onImagePaste; if the very next keystroke is a non-space // printable, inputFilter prepends a space before it. Any other input // (arrow, escape, backspace, paste, space) disarms without inserting. - const pendingSpaceAfterPillRef = useRef(false) + const pendingSpaceAfterPillRef = useRef(false); - const [showTeamsDialog, setShowTeamsDialog] = useState(false) - const [showBridgeDialog, setShowBridgeDialog] = useState(false) - const [teammateFooterIndex, setTeammateFooterIndex] = useState(0) + const [showTeamsDialog, setShowTeamsDialog] = useState(false); + const [showBridgeDialog, setShowBridgeDialog] = useState(false); + const [teammateFooterIndex, setTeammateFooterIndex] = useState(0); // -1 sentinel: tasks pill is selected but no specific agent row is selected yet. // First ↓ selects the pill, second ↓ moves to row 0. Prevents double-select // of pill + row when both bg tasks (pill) and forked agents (rows) are visible. - const coordinatorTaskIndex = useAppState(s => s.coordinatorTaskIndex) + const coordinatorTaskIndex = useAppState(s => s.coordinatorTaskIndex); const setCoordinatorTaskIndex = useCallback( (v: number | ((prev: number) => number)) => setAppState(prev => { - const next = typeof v === 'function' ? v(prev.coordinatorTaskIndex) : v - if (next === prev.coordinatorTaskIndex) return prev - return { ...prev, coordinatorTaskIndex: next } + const next = typeof v === 'function' ? v(prev.coordinatorTaskIndex) : v; + if (next === prev.coordinatorTaskIndex) return prev; + return { ...prev, coordinatorTaskIndex: next }; }), [setAppState], - ) - const coordinatorTaskCount = useCoordinatorTaskCount() + ); + const coordinatorTaskCount = useCoordinatorTaskCount(); // The pill (BackgroundTaskStatus) only renders when non-local_agent bg tasks // exist. When only local_agent tasks are running (coordinator/fork mode), the // pill is absent, so the -1 sentinel would leave nothing visually selected. // In that case, skip -1 and treat 0 as the minimum selectable index. const hasBgTaskPill = useMemo( () => - Object.values(tasks).some( - t => - isBackgroundTask(t) && - !(process.env.USER_TYPE === 'ant' && isPanelAgentTask(t)), - ), + Object.values(tasks).some(t => isBackgroundTask(t) && !(process.env.USER_TYPE === 'ant' && isPanelAgentTask(t))), [tasks], - ) - const minCoordinatorIndex = hasBgTaskPill ? -1 : 0 + ); + const minCoordinatorIndex = hasBgTaskPill ? -1 : 0; // Clamp index when tasks complete and the list shrinks beneath the cursor useEffect(() => { if (coordinatorTaskIndex >= coordinatorTaskCount) { - setCoordinatorTaskIndex( - Math.max(minCoordinatorIndex, coordinatorTaskCount - 1), - ) + setCoordinatorTaskIndex(Math.max(minCoordinatorIndex, coordinatorTaskCount - 1)); } else if (coordinatorTaskIndex < minCoordinatorIndex) { - setCoordinatorTaskIndex(minCoordinatorIndex) + setCoordinatorTaskIndex(minCoordinatorIndex); } - }, [coordinatorTaskCount, coordinatorTaskIndex, minCoordinatorIndex]) - const [isPasting, setIsPasting] = useState(false) - const [isExternalEditorActive, setIsExternalEditorActive] = useState(false) - const [showModelPicker, setShowModelPicker] = useState(false) - const [showQuickOpen, setShowQuickOpen] = useState(false) - const [showGlobalSearch, setShowGlobalSearch] = useState(false) - const [showHistoryPicker, setShowHistoryPicker] = useState(false) - const [showFastModePicker, setShowFastModePicker] = useState(false) - const [showThinkingToggle, setShowThinkingToggle] = useState(false) - const [showAutoModeOptIn, setShowAutoModeOptIn] = useState(false) - const [previousModeBeforeAuto, setPreviousModeBeforeAuto] = - useState(null) - const autoModeOptInTimeoutRef = useRef(null) + }, [coordinatorTaskCount, coordinatorTaskIndex, minCoordinatorIndex]); + const [isPasting, setIsPasting] = useState(false); + const [isExternalEditorActive, setIsExternalEditorActive] = useState(false); + const [showModelPicker, setShowModelPicker] = useState(false); + const [showQuickOpen, setShowQuickOpen] = useState(false); + const [showGlobalSearch, setShowGlobalSearch] = useState(false); + const [showHistoryPicker, setShowHistoryPicker] = useState(false); + const [showFastModePicker, setShowFastModePicker] = useState(false); + const [showThinkingToggle, setShowThinkingToggle] = useState(false); + const [showAutoModeOptIn, setShowAutoModeOptIn] = useState(false); + const [previousModeBeforeAuto, setPreviousModeBeforeAuto] = useState(null); + const autoModeOptInTimeoutRef = useRef(null); // Check if cursor is on the first line of input const isCursorOnFirstLine = useMemo(() => { - const firstNewlineIndex = input.indexOf('\n') + const firstNewlineIndex = input.indexOf('\n'); if (firstNewlineIndex === -1) { - return true // No newlines, cursor is always on first line + return true; // No newlines, cursor is always on first line } - return cursorOffset <= firstNewlineIndex - }, [input, cursorOffset]) + return cursorOffset <= firstNewlineIndex; + }, [input, cursorOffset]); const isCursorOnLastLine = useMemo(() => { - const lastNewlineIndex = input.lastIndexOf('\n') + const lastNewlineIndex = input.lastIndexOf('\n'); if (lastNewlineIndex === -1) { - return true // No newlines, cursor is always on last line + return true; // No newlines, cursor is always on last line } - return cursorOffset > lastNewlineIndex - }, [input, cursorOffset]) + return cursorOffset > lastNewlineIndex; + }, [input, cursorOffset]); // Derive team info from teamContext (no filesystem I/O needed) // A session can only lead one team at a time const cachedTeams: TeamSummary[] = useMemo(() => { - if (!isAgentSwarmsEnabled()) return [] + if (!isAgentSwarmsEnabled()) return []; // In-process mode uses Shift+Down/Up navigation instead of footer menu - if (isInProcessEnabled()) return [] + if (isInProcessEnabled()) return []; if (!teamContext) { - return [] + return []; } - const teammateCount = count( - Object.values(teamContext.teammates), - t => t.name !== 'team-lead', - ) + const teammateCount = count(Object.values(teamContext.teammates), t => t.name !== 'team-lead'); return [ { name: teamContext.teamName, @@ -613,25 +494,21 @@ function PromptInput({ runningCount: 0, idleCount: 0, }, - ] - }, [teamContext]) + ]; + }, [teamContext]); // ─── Footer pill navigation ───────────────────────────────────────────── // Which pills render below the input box. Order here IS the nav order // (down/right = forward, up/left = back). Selection lives in AppState so // pills rendered outside PromptInput (CompanionSprite) can read focus. - const runningTaskCount = useMemo( - () => count(Object.values(tasks), t => t.status === 'running'), - [tasks], - ) + const runningTaskCount = useMemo(() => count(Object.values(tasks), t => t.status === 'running'), [tasks]); // Panel shows retained-completed agents too (getVisibleAgentTasks), so the // pill must stay navigable whenever the panel has rows — not just when // something is running. const tasksFooterVisible = - (runningTaskCount > 0 || - (process.env.USER_TYPE === 'ant' && coordinatorTaskCount > 0)) && - !shouldHideTasksFooter(tasks, showSpinnerTree) - const teamsFooterVisible = cachedTeams.length > 0 + (runningTaskCount > 0 || (process.env.USER_TYPE === 'ant' && coordinatorTaskCount > 0)) && + !shouldHideTasksFooter(tasks, showSpinnerTree); + const teamsFooterVisible = cachedTeams.length > 0; const footerItems = useMemo( () => @@ -651,60 +528,49 @@ function PromptInput({ bridgeFooterVisible, companionFooterVisible, ], - ) + ); // Effective selection: null if the selected pill stopped rendering (bridge // disconnected, task finished). The derivation makes the UI correct // immediately; the useEffect below clears the raw state so it doesn't // resurrect when the same pill reappears (new task starts → focus stolen). - const rawFooterSelection = useAppState(s => s.footerSelection) - const footerItemSelected = - rawFooterSelection && footerItems.includes(rawFooterSelection) - ? rawFooterSelection - : null + const rawFooterSelection = useAppState(s => s.footerSelection); + const footerItemSelected = rawFooterSelection && footerItems.includes(rawFooterSelection) ? rawFooterSelection : null; useEffect(() => { if (rawFooterSelection && !footerItemSelected) { - setAppState(prev => - prev.footerSelection === null - ? prev - : { ...prev, footerSelection: null }, - ) + setAppState(prev => (prev.footerSelection === null ? prev : { ...prev, footerSelection: null })); } - }, [rawFooterSelection, footerItemSelected, setAppState]) + }, [rawFooterSelection, footerItemSelected, setAppState]); - const tasksSelected = footerItemSelected === 'tasks' - const tmuxSelected = footerItemSelected === 'tmux' - const bagelSelected = footerItemSelected === 'bagel' - const teamsSelected = footerItemSelected === 'teams' - const bridgeSelected = footerItemSelected === 'bridge' + const tasksSelected = footerItemSelected === 'tasks'; + const tmuxSelected = footerItemSelected === 'tmux'; + const bagelSelected = footerItemSelected === 'bagel'; + const teamsSelected = footerItemSelected === 'teams'; + const bridgeSelected = footerItemSelected === 'bridge'; function selectFooterItem(item: FooterItem | null): void { - setAppState(prev => - prev.footerSelection === item ? prev : { ...prev, footerSelection: item }, - ) + setAppState(prev => (prev.footerSelection === item ? prev : { ...prev, footerSelection: item })); if (item === 'tasks') { - setTeammateFooterIndex(0) - setCoordinatorTaskIndex(minCoordinatorIndex) + setTeammateFooterIndex(0); + setCoordinatorTaskIndex(minCoordinatorIndex); } } // delta: +1 = down/right, -1 = up/left. Returns true if nav happened // (including deselecting at the start), false if at a boundary. function navigateFooter(delta: 1 | -1, exitAtStart = false): boolean { - const idx = footerItemSelected - ? footerItems.indexOf(footerItemSelected) - : -1 - const next = footerItems[idx + delta] + const idx = footerItemSelected ? footerItems.indexOf(footerItemSelected) : -1; + const next = footerItems[idx + delta]; if (next) { - selectFooterItem(next) - return true + selectFooterItem(next); + return true; } if (delta < 0 && exitAtStart) { - selectFooterItem(null) - return true + selectFooterItem(null); + return true; } - return false + return false; } // Prompt suggestion hook - reads suggestions generated by forked agent in query loop @@ -716,124 +582,100 @@ function PromptInput({ } = usePromptSuggestion({ inputValue: input, isAssistantResponding: isLoading, - }) + }); const displayedValue = useMemo( () => isSearchingHistory && historyMatch - ? getValueFromInput( - typeof historyMatch === 'string' - ? historyMatch - : historyMatch.display, - ) + ? getValueFromInput(typeof historyMatch === 'string' ? historyMatch : historyMatch.display) : input, [isSearchingHistory, historyMatch, input], - ) + ); - const thinkTriggers = useMemo( - () => findThinkingTriggerPositions(displayedValue), - [displayedValue], - ) + const thinkTriggers = useMemo(() => findThinkingTriggerPositions(displayedValue), [displayedValue]); - const ultraplanSessionUrl = useAppState(s => s.ultraplanSessionUrl) - const ultraplanLaunching = useAppState(s => s.ultraplanLaunching) + const ultraplanSessionUrl = useAppState(s => s.ultraplanSessionUrl); + const ultraplanLaunching = useAppState(s => s.ultraplanLaunching); const ultraplanTriggers = useMemo( () => feature('ULTRAPLAN') && !ultraplanSessionUrl && !ultraplanLaunching ? findUltraplanTriggerPositions(displayedValue) : [], [displayedValue, ultraplanSessionUrl, ultraplanLaunching], - ) + ); const ultrareviewTriggers = useMemo( - () => - isUltrareviewEnabled() - ? findUltrareviewTriggerPositions(displayedValue) - : [], + () => (isUltrareviewEnabled() ? findUltrareviewTriggerPositions(displayedValue) : []), [displayedValue], - ) + ); - const btwTriggers = useMemo( - () => findBtwTriggerPositions(displayedValue), - [displayedValue], - ) + const btwTriggers = useMemo(() => findBtwTriggerPositions(displayedValue), [displayedValue]); - const buddyTriggers = useMemo( - () => findBuddyTriggerPositions(displayedValue), - [displayedValue], - ) + const buddyTriggers = useMemo(() => findBuddyTriggerPositions(displayedValue), [displayedValue]); const slashCommandTriggers = useMemo(() => { - const positions = findSlashCommandPositions(displayedValue) + const positions = findSlashCommandPositions(displayedValue); // Only highlight valid commands return positions.filter(pos => { - const commandName = displayedValue.slice(pos.start + 1, pos.end) // +1 to skip "/" - return hasCommand(commandName, commands) - }) - }, [displayedValue, commands]) + const commandName = displayedValue.slice(pos.start + 1, pos.end); // +1 to skip "/" + return hasCommand(commandName, commands); + }); + }, [displayedValue, commands]); const tokenBudgetTriggers = useMemo( - () => - feature('TOKEN_BUDGET') ? findTokenBudgetPositions(displayedValue) : [], + () => (feature('TOKEN_BUDGET') ? findTokenBudgetPositions(displayedValue) : []), [displayedValue], - ) + ); - const knownChannelsVersion = useSyncExternalStore( - subscribeKnownChannels, - getKnownChannelsVersion, - ) + const knownChannelsVersion = useSyncExternalStore(subscribeKnownChannels, getKnownChannelsVersion); const slackChannelTriggers = useMemo( - () => - hasSlackMcpServer(store.getState().mcp.clients) - ? findSlackChannelPositions(displayedValue) - : [], + () => (hasSlackMcpServer(store.getState().mcp.clients) ? findSlackChannelPositions(displayedValue) : []), // eslint-disable-next-line react-hooks/exhaustive-deps -- store is a stable ref [displayedValue, knownChannelsVersion], - ) + ); // Find @name mentions and highlight with team member's color const memberMentionHighlights = useMemo((): Array<{ - start: number - end: number - themeColor: keyof Theme + start: number; + end: number; + themeColor: keyof Theme; }> => { - if (!isAgentSwarmsEnabled()) return [] - if (!teamContext?.teammates) return [] + if (!isAgentSwarmsEnabled()) return []; + if (!teamContext?.teammates) return []; const highlights: Array<{ - start: number - end: number - themeColor: keyof Theme - }> = [] - const members = teamContext.teammates - if (!members) return highlights + start: number; + end: number; + themeColor: keyof Theme; + }> = []; + const members = teamContext.teammates; + if (!members) return highlights; // Find all @name patterns in the input - const regex = /(^|\s)@([\w-]+)/g - const memberValues = Object.values(members) - let match + const regex = /(^|\s)@([\w-]+)/g; + const memberValues = Object.values(members); + let match; while ((match = regex.exec(displayedValue)) !== null) { - const leadingSpace = match[1] ?? '' - const nameStart = match.index + leadingSpace.length - const fullMatch = match[0].trimStart() - const name = match[2] + const leadingSpace = match[1] ?? ''; + const nameStart = match.index + leadingSpace.length; + const fullMatch = match[0].trimStart(); + const name = match[2]; // Check if this name matches a team member - const member = memberValues.find(t => t.name === name) + const member = memberValues.find(t => t.name === name); if (member?.color) { - const themeColor = - AGENT_COLOR_TO_THEME_COLOR[member.color as AgentColorName] + const themeColor = AGENT_COLOR_TO_THEME_COLOR[member.color as AgentColorName]; if (themeColor) { highlights.push({ start: nameStart, end: nameStart + fullMatch.length, themeColor, - }) + }); } } } - return highlights - }, [displayedValue, teamContext]) + return highlights; + }, [displayedValue, teamContext]); const imageRefPositions = useMemo( () => @@ -841,30 +683,26 @@ function PromptInput({ .filter(r => r.match.startsWith('[Image')) .map(r => ({ start: r.index, end: r.index + r.match.length })), [displayedValue], - ) + ); // chip.start is the "selected" state: the inverted chip IS the cursor. // chip.end stays a normal position so you can park the cursor right after // `]` like any other character. - const cursorAtImageChip = imageRefPositions.some( - r => r.start === cursorOffset, - ) + const cursorAtImageChip = imageRefPositions.some(r => r.start === cursorOffset); // up/down movement or a fullscreen click can land the cursor strictly // inside a chip; snap to the nearer boundary so it's never editable // char-by-char. useEffect(() => { - const inside = imageRefPositions.find( - r => cursorOffset > r.start && cursorOffset < r.end, - ) + const inside = imageRefPositions.find(r => cursorOffset > r.start && cursorOffset < r.end); if (inside) { - const mid = (inside.start + inside.end) / 2 - setCursorOffset(cursorOffset < mid ? inside.start : inside.end) + const mid = (inside.start + inside.end) / 2; + setCursorOffset(cursorOffset < mid ? inside.start : inside.end); } - }, [cursorOffset, imageRefPositions, setCursorOffset]) + }, [cursorOffset, imageRefPositions, setCursorOffset]); const combinedHighlights = useMemo((): TextHighlight[] => { - const highlights: TextHighlight[] = [] + const highlights: TextHighlight[] = []; // Invert the [Image #N] chip when the cursor is at chip.start (the // "selected" state) so backspace-to-delete is visually obvious. @@ -876,7 +714,7 @@ function PromptInput({ color: undefined, inverse: true, priority: 8, - }) + }); } } @@ -886,7 +724,7 @@ function PromptInput({ end: cursorOffset + historyQuery.length, color: 'warning', priority: 20, - }) + }); } // Add "btw" highlighting (solid yellow) @@ -896,7 +734,7 @@ function PromptInput({ end: trigger.end, color: 'warning', priority: 15, - }) + }); } // Add /command highlighting (blue) @@ -906,7 +744,7 @@ function PromptInput({ end: trigger.end, color: 'suggestion', priority: 5, - }) + }); } // Add token budget highlighting (blue) @@ -916,7 +754,7 @@ function PromptInput({ end: trigger.end, color: 'suggestion', priority: 5, - }) + }); } for (const trigger of slackChannelTriggers) { @@ -925,7 +763,7 @@ function PromptInput({ end: trigger.end, color: 'suggestion', priority: 5, - }) + }); } // Add @name highlighting with team member's color @@ -935,7 +773,7 @@ function PromptInput({ end: mention.end, color: mention.themeColor, priority: 5, - }) + }); } // Dim interim voice dictation text @@ -946,7 +784,7 @@ function PromptInput({ color: undefined, dimColor: true, priority: 1, - }) + }); } // Rainbow highlighting for ultrathink keyword (per-character cycling colors) @@ -959,7 +797,7 @@ function PromptInput({ color: getRainbowColor(i - trigger.start), shimmerColor: getRainbowColor(i - trigger.start, true), priority: 10, - }) + }); } } } @@ -974,7 +812,7 @@ function PromptInput({ color: getRainbowColor(i - trigger.start), shimmerColor: getRainbowColor(i - trigger.start, true), priority: 10, - }) + }); } } } @@ -988,7 +826,7 @@ function PromptInput({ color: getRainbowColor(i - trigger.start), shimmerColor: getRainbowColor(i - trigger.start, true), priority: 10, - }) + }); } } @@ -1001,11 +839,11 @@ function PromptInput({ color: getRainbowColor(i - trigger.start), shimmerColor: getRainbowColor(i - trigger.start, true), priority: 10, - }) + }); } } - return highlights + return highlights; }, [ isSearchingHistory, historyQuery, @@ -1024,9 +862,9 @@ function PromptInput({ ultraplanTriggers, ultrareviewTriggers, buddyTriggers, - ]) + ]); - const { addNotification, removeNotification } = useNotifications() + const { addNotification, removeNotification } = useNotifications(); // Show ultrathink notification useEffect(() => { @@ -1036,11 +874,11 @@ function PromptInput({ text: 'Effort set to high for this turn', priority: 'immediate', timeoutMs: 5000, - }) + }); } else { - removeNotification('ultrathink-active') + removeNotification('ultrathink-active'); } - }, [addNotification, removeNotification, thinkTriggers.length]) + }, [addNotification, removeNotification, thinkTriggers.length]); useEffect(() => { if (feature('ULTRAPLAN') && ultraplanTriggers.length) { @@ -1049,11 +887,11 @@ function PromptInput({ text: 'This prompt will launch an ultraplan session in Claude Code on the web', priority: 'immediate', timeoutMs: 5000, - }) + }); } else { - removeNotification('ultraplan-active') + removeNotification('ultraplan-active'); } - }, [addNotification, removeNotification, ultraplanTriggers.length]) + }, [addNotification, removeNotification, ultraplanTriggers.length]); useEffect(() => { if (isUltrareviewEnabled() && ultrareviewTriggers.length) { @@ -1062,72 +900,66 @@ function PromptInput({ text: 'Run /ultrareview after Claude finishes to review these changes in the cloud', priority: 'immediate', timeoutMs: 5000, - }) + }); } - }, [addNotification, ultrareviewTriggers.length]) + }, [addNotification, ultrareviewTriggers.length]); // Track input length for stash hint - const prevInputLengthRef = useRef(input.length) - const peakInputLengthRef = useRef(input.length) + const prevInputLengthRef = useRef(input.length); + const peakInputLengthRef = useRef(input.length); // Dismiss stash hint when user makes any input change const dismissStashHint = useCallback(() => { - removeNotification('stash-hint') - }, [removeNotification]) + removeNotification('stash-hint'); + }, [removeNotification]); // Show stash hint when user gradually clears substantial input useEffect(() => { - const prevLength = prevInputLengthRef.current - const peakLength = peakInputLengthRef.current - const currentLength = input.length - prevInputLengthRef.current = currentLength + const prevLength = prevInputLengthRef.current; + const peakLength = peakInputLengthRef.current; + const currentLength = input.length; + prevInputLengthRef.current = currentLength; // Update peak when input grows if (currentLength > peakLength) { - peakInputLengthRef.current = currentLength - return + peakInputLengthRef.current = currentLength; + return; } // Reset state when input is empty if (currentLength === 0) { - peakInputLengthRef.current = 0 - return + peakInputLengthRef.current = 0; + return; } // Detect gradual clear: peak was high, current is low, but this wasn't a single big jump // (rapid clears like esc-esc go from 20+ to 0 in one step) - const clearedSubstantialInput = peakLength >= 20 && currentLength <= 5 - const wasRapidClear = prevLength >= 20 && currentLength <= 5 + const clearedSubstantialInput = peakLength >= 20 && currentLength <= 5; + const wasRapidClear = prevLength >= 20 && currentLength <= 5; if (clearedSubstantialInput && !wasRapidClear) { - const config = getGlobalConfig() + const config = getGlobalConfig(); if (!config.hasUsedStash) { addNotification({ key: 'stash-hint', jsx: ( - Tip:{' '} - + Tip: ), priority: 'immediate', timeoutMs: FOOTER_TEMPORARY_STATUS_TIMEOUT, - }) + }); } - peakInputLengthRef.current = currentLength + peakInputLengthRef.current = currentLength; } - }, [input.length, addNotification]) + }, [input.length, addNotification]); // Initialize input buffer for undo functionality const { pushToBuffer, undo, canUndo, clearBuffer } = useInputBuffer({ maxBufferSize: 50, debounceMs: 1000, - }) + }); useMaybeTruncateInput({ input, @@ -1135,232 +967,187 @@ function PromptInput({ onInputChange: trackAndSetInput, setCursorOffset, setPastedContents, - }) + }); const defaultPlaceholder = usePromptInputPlaceholder({ input, submitCount, viewingAgentName, - }) + }); const onChange = useCallback( (value: string) => { if (value === '?') { - logEvent('tengu_help_toggled', {}) - setHelpOpen(v => !v) - return + logEvent('tengu_help_toggled', {}); + setHelpOpen(v => !v); + return; } - setHelpOpen(false) + setHelpOpen(false); // Dismiss stash hint when user makes any input change - dismissStashHint() + dismissStashHint(); // Cancel any pending prompt suggestion and speculation when user types - abortPromptSuggestion() - abortSpeculation(setAppState) + abortPromptSuggestion(); + abortSpeculation(setAppState); // Check if this is a single character insertion at the start - const isSingleCharInsertion = value.length === input.length + 1 - const insertedAtStart = cursorOffset === 0 - const mode = getModeFromInput(value) + const isSingleCharInsertion = value.length === input.length + 1; + const insertedAtStart = cursorOffset === 0; + const mode = getModeFromInput(value); if (insertedAtStart && mode !== 'prompt') { if (isSingleCharInsertion) { - onModeChange(mode) - return + onModeChange(mode); + return; } // Multi-char insertion into empty input (e.g. tab-accepting "! gcloud auth login") if (input.length === 0) { - onModeChange(mode) - const valueWithoutMode = getValueFromInput(value).replaceAll( - '\t', - ' ', - ) - pushToBuffer(input, cursorOffset, pastedContents) - trackAndSetInput(valueWithoutMode) - setCursorOffset(valueWithoutMode.length) - return + onModeChange(mode); + const valueWithoutMode = getValueFromInput(value).replaceAll('\t', ' '); + pushToBuffer(input, cursorOffset, pastedContents); + trackAndSetInput(valueWithoutMode); + setCursorOffset(valueWithoutMode.length); + return; } } - const processedValue = value.replaceAll('\t', ' ') + const processedValue = value.replaceAll('\t', ' '); // Push current state to buffer before making changes if (input !== processedValue) { - pushToBuffer(input, cursorOffset, pastedContents) + pushToBuffer(input, cursorOffset, pastedContents); } // Deselect footer items when user types - setAppState(prev => - prev.footerSelection === null - ? prev - : { ...prev, footerSelection: null }, - ) + setAppState(prev => (prev.footerSelection === null ? prev : { ...prev, footerSelection: null })); - trackAndSetInput(processedValue) + trackAndSetInput(processedValue); }, - [ - trackAndSetInput, - onModeChange, - input, - cursorOffset, - pushToBuffer, - pastedContents, - dismissStashHint, - setAppState, - ], - ) + [trackAndSetInput, onModeChange, input, cursorOffset, pushToBuffer, pastedContents, dismissStashHint, setAppState], + ); - const { - resetHistory, - onHistoryUp, - onHistoryDown, - dismissSearchHint, - historyIndex, - } = useArrowKeyHistory( - ( - value: string, - historyMode: HistoryMode, - pastedContents: Record, - ) => { - onChange(value) - onModeChange(historyMode) - setPastedContents(pastedContents) + const { resetHistory, onHistoryUp, onHistoryDown, dismissSearchHint, historyIndex } = useArrowKeyHistory( + (value: string, historyMode: HistoryMode, pastedContents: Record) => { + onChange(value); + onModeChange(historyMode); + setPastedContents(pastedContents); }, input, pastedContents, setCursorOffset, mode, - ) + ); // Dismiss search hint when user starts searching useEffect(() => { if (isSearchingHistory) { - dismissSearchHint() + dismissSearchHint(); } - }, [isSearchingHistory, dismissSearchHint]) + }, [isSearchingHistory, dismissSearchHint]); // Only use history navigation when there are 0 or 1 slash command suggestions. // Footer nav is NOT here — when a pill is selected, TextInput focus=false so // these never fire. The Footer keybinding context handles ↑/↓ instead. function handleHistoryUp() { if (suggestions.length > 1) { - return + return; } // Only navigate history when cursor is on the first line. // In multiline inputs, up arrow should move the cursor (handled by TextInput) // and only trigger history when at the top of the input. if (!isCursorOnFirstLine) { - return + return; } // If there's an editable queued command, move it to the input for editing when UP is pressed - const hasEditableCommand = queuedCommands.some(isQueuedCommandEditable) + const hasEditableCommand = queuedCommands.some(isQueuedCommandEditable); if (hasEditableCommand) { - void popAllCommandsFromQueue() - return + void popAllCommandsFromQueue(); + return; } - onHistoryUp() + onHistoryUp(); } function handleHistoryDown() { if (suggestions.length > 1) { - return + return; } // Only navigate history/footer when cursor is on the last line. // In multiline inputs, down arrow should move the cursor (handled by TextInput) // and only trigger navigation when at the bottom of the input. if (!isCursorOnLastLine) { - return + return; } // At bottom of history → enter footer at first visible pill if (onHistoryDown() && footerItems.length > 0) { - const first = footerItems[0]! - selectFooterItem(first) + const first = footerItems[0]!; + selectFooterItem(first); if (first === 'tasks' && !getGlobalConfig().hasSeenTasksHint) { - saveGlobalConfig(c => - c.hasSeenTasksHint ? c : { ...c, hasSeenTasksHint: true }, - ) + saveGlobalConfig(c => (c.hasSeenTasksHint ? c : { ...c, hasSeenTasksHint: true })); } } } // Create a suggestions state directly - we'll sync it with useTypeahead later const [suggestionsState, setSuggestionsStateRaw] = useState<{ - suggestions: SuggestionItem[] - selectedSuggestion: number - commandArgumentHint?: string + suggestions: SuggestionItem[]; + selectedSuggestion: number; + commandArgumentHint?: string; }>({ suggestions: [], selectedSuggestion: -1, commandArgumentHint: undefined, - }) + }); // Setter for suggestions state const setSuggestionsState = useCallback( - ( - updater: - | typeof suggestionsState - | ((prev: typeof suggestionsState) => typeof suggestionsState), - ) => { - setSuggestionsStateRaw(prev => - typeof updater === 'function' ? updater(prev) : updater, - ) + (updater: typeof suggestionsState | ((prev: typeof suggestionsState) => typeof suggestionsState)) => { + setSuggestionsStateRaw(prev => (typeof updater === 'function' ? updater(prev) : updater)); }, [], - ) + ); const onSubmit = useCallback( async (inputParam: string, isSubmittingSlashCommand = false) => { - inputParam = inputParam.trimEnd() + inputParam = inputParam.trimEnd(); // Don't submit if a footer indicator is being opened. Read fresh from // store — footer:openSelected calls selectFooterItem(null) then onSubmit // in the same tick, and the closure value hasn't updated yet. Apply the // same "still visible?" derivation as footerItemSelected so a stale // selection (pill disappeared) doesn't swallow Enter. - const state = store.getState() - if ( - state.footerSelection && - footerItems.includes(state.footerSelection) - ) { - return + const state = store.getState(); + if (state.footerSelection && footerItems.includes(state.footerSelection)) { + return; } // Enter in selection modes confirms selection (useBackgroundTaskNavigation). // BaseTextInput's useInput registers before that hook (child effects fire first), // so without this guard Enter would double-fire and auto-submit the suggestion. if (state.viewSelectionMode === 'selecting-agent') { - return + return; } // Check for images early - we need this for suggestion logic below - const hasImages = Object.values(pastedContents).some( - c => c.type === 'image', - ) + const hasImages = Object.values(pastedContents).some(c => c.type === 'image'); // If input is empty OR matches the suggestion, submit it // But if there are images attached, don't auto-accept the suggestion - // the user wants to submit just the image(s). // Only in leader view — promptSuggestion is leader-context, not teammate. - const suggestionText = promptSuggestionState.text - const inputMatchesSuggestion = - inputParam.trim() === '' || inputParam === suggestionText - if ( - inputMatchesSuggestion && - suggestionText && - !hasImages && - !state.viewingAgentTaskId - ) { + const suggestionText = promptSuggestionState.text; + const inputMatchesSuggestion = inputParam.trim() === '' || inputParam === suggestionText; + if (inputMatchesSuggestion && suggestionText && !hasImages && !state.viewingAgentTaskId) { // If speculation is active, inject messages immediately as they stream if (speculation.status === 'active') { - markAccepted() + markAccepted(); // skipReset: resetSuggestion would abort the speculation before we accept it - logOutcomeAtSubmission(suggestionText, { skipReset: true }) + logOutcomeAtSubmission(suggestionText, { skipReset: true }); void onSubmitProp( suggestionText, @@ -1374,27 +1161,27 @@ function PromptInput({ speculationSessionTimeSavedMs: speculationSessionTimeSavedMs, setAppState, }, - ) - return // Skip normal query - speculation handled it + ); + return; // Skip normal query - speculation handled it } // Regular suggestion acceptance (requires shownAt > 0) if (promptSuggestionState.shownAt > 0) { - markAccepted() - inputParam = suggestionText + markAccepted(); + inputParam = suggestionText; } } // Handle @name direct message if (isAgentSwarmsEnabled()) { - const directMessage = parseDirectMemberMessage(inputParam) + const directMessage = parseDirectMemberMessage(inputParam); if (directMessage) { const result = await sendDirectMemberMessage( directMessage.recipientName, directMessage.message, teamContext, writeToMailbox, - ) + ); if (result.success) { addNotification({ @@ -1402,12 +1189,12 @@ function PromptInput({ text: `Sent to @${result.recipientName}`, priority: 'immediate', timeoutMs: 3000, - }) - trackAndSetInput('') - setCursorOffset(0) - clearBuffer() - resetHistory() - return + }); + trackAndSetInput(''); + setCursorOffset(0); + clearBuffer(); + resetHistory(); + return; } else if (!result.success && (result as { error: string }).error === 'no_team_context') { // No team context - fall through to normal prompt submission } else { @@ -1419,44 +1206,38 @@ function PromptInput({ // Allow submission if there are images attached, even without text if (inputParam.trim() === '' && !hasImages) { - return + return; } // PromptInput UX: Check if suggestions dropdown is showing // For directory suggestions, allow submission (Tab is used for completion) const hasDirectorySuggestions = suggestionsState.suggestions.length > 0 && - suggestionsState.suggestions.every(s => s.description === 'directory') + suggestionsState.suggestions.every(s => s.description === 'directory'); - if ( - suggestionsState.suggestions.length > 0 && - !isSubmittingSlashCommand && - !hasDirectorySuggestions - ) { - logForDebugging( - `[onSubmit] early return: suggestions showing (count=${suggestionsState.suggestions.length})`, - ) - return // Don't submit, user needs to clear suggestions first + if (suggestionsState.suggestions.length > 0 && !isSubmittingSlashCommand && !hasDirectorySuggestions) { + logForDebugging(`[onSubmit] early return: suggestions showing (count=${suggestionsState.suggestions.length})`); + return; // Don't submit, user needs to clear suggestions first } // Log suggestion outcome if one exists if (promptSuggestionState.text && promptSuggestionState.shownAt > 0) { - logOutcomeAtSubmission(inputParam) + logOutcomeAtSubmission(inputParam); } // Clear stash hint notification on submit - removeNotification('stash-hint') + removeNotification('stash-hint'); // Route input to viewed agent (in-process teammate or named local_agent). - const activeAgent = getActiveAgentForInput(store.getState()) + const activeAgent = getActiveAgentForInput(store.getState()); if (activeAgent.type !== 'leader' && onAgentSubmit) { - logEvent('tengu_transcript_input_to_teammate', {}) + logEvent('tengu_transcript_input_to_teammate', {}); await onAgentSubmit(inputParam, activeAgent.task, { setCursorOffset, clearBuffer, resetHistory, - }) - return + }); + return; } // Normal leader submission @@ -1464,7 +1245,7 @@ function PromptInput({ setCursorOffset, clearBuffer, resetHistory, - }) + }); }, [ promptSuggestionState, @@ -1484,15 +1265,9 @@ function PromptInput({ pastedContents, removeNotification, ], - ) + ); - const { - suggestions, - selectedSuggestion, - commandArgumentHint, - inlineGhostText, - maxColumnWidth, - } = useTypeahead({ + const { suggestions, selectedSuggestion, commandArgumentHint, inlineGhostText, maxColumnWidth } = useTypeahead({ commands, onInputChange: trackAndSetInput, onSubmit, @@ -1506,29 +1281,20 @@ function PromptInput({ suppressSuggestions: isSearchingHistory || historyIndex > 0, markAccepted, onModeChange, - }) + }); // Track if prompt suggestion should be shown (computed later with terminal width). // Hidden in teammate view — suggestion is leader-context only. - const showPromptSuggestion = - mode === 'prompt' && - suggestions.length === 0 && - promptSuggestion && - !viewingAgentTaskId + const showPromptSuggestion = mode === 'prompt' && suggestions.length === 0 && promptSuggestion && !viewingAgentTaskId; if (showPromptSuggestion) { - markShown() + markShown(); } // If suggestion was generated but can't be shown due to timing, log suppression. // Exclude teammate view: markShown() is gated above, so shownAt stays 0 there — // but that's not a timing failure, the suggestion is valid when returning to leader. - if ( - promptSuggestionState.text && - !promptSuggestion && - promptSuggestionState.shownAt === 0 && - !viewingAgentTaskId - ) { - logSuggestionSuppressed('timing', promptSuggestionState.text) + if (promptSuggestionState.text && !promptSuggestion && promptSuggestionState.shownAt === 0 && !viewingAgentTaskId) { + logSuggestionSuppressed('timing', promptSuggestionState.text); setAppState(prev => ({ ...prev, promptSuggestion: { @@ -1538,7 +1304,7 @@ function PromptInput({ acceptedAt: 0, generationRequestId: null, }, - })) + })); } function onImagePaste( @@ -1548,10 +1314,10 @@ function PromptInput({ dimensions?: ImageDimensions, sourcePath?: string, ) { - logEvent('tengu_paste_image', {}) - onModeChange('prompt') + logEvent('tengu_paste_image', {}); + onModeChange('prompt'); - const pasteId = nextPasteIdRef.current++ + const pasteId = nextPasteIdRef.current++; const newContent: PastedContent = { id: pasteId, @@ -1561,22 +1327,22 @@ function PromptInput({ filename: filename || 'Pasted image', dimensions, sourcePath, - } + }; // Cache path immediately (fast) so links work on render - cacheImagePath(newContent) + cacheImagePath(newContent); // Store image to disk in background - void storeImage(newContent) + void storeImage(newContent); // Update UI - setPastedContents(prev => ({ ...prev, [pasteId]: newContent })) + setPastedContents(prev => ({ ...prev, [pasteId]: newContent })); // Multi-image paste calls onImagePaste in a loop. If the ref is already // armed, the previous pill's lazy space fires now (before this pill) // rather than being lost. - const prefix = pendingSpaceAfterPillRef.current ? ' ' : '' - insertTextAtCursor(prefix + formatImageRef(pasteId)) - pendingSpaceAfterPillRef.current = true + const prefix = pendingSpaceAfterPillRef.current ? ' ' : ''; + insertTextAtCursor(prefix + formatImageRef(pasteId)); + pendingSpaceAfterPillRef.current = true; } // Prune images whose [Image #N] placeholder is no longer in the input text. @@ -1584,168 +1350,154 @@ function PromptInput({ // the ref. onImagePaste batches setPastedContents + insertTextAtCursor in the // same event, so this effect sees the placeholder already present. useEffect(() => { - const referencedIds = new Set(parseReferences(input).map(r => r.id)) + const referencedIds = new Set(parseReferences(input).map(r => r.id)); setPastedContents(prev => { - const orphaned = Object.values(prev).filter( - c => c.type === 'image' && !referencedIds.has(c.id), - ) - if (orphaned.length === 0) return prev - const next = { ...prev } - for (const img of orphaned) delete next[img.id] - return next - }) - }, [input, setPastedContents]) + const orphaned = Object.values(prev).filter(c => c.type === 'image' && !referencedIds.has(c.id)); + if (orphaned.length === 0) return prev; + const next = { ...prev }; + for (const img of orphaned) delete next[img.id]; + return next; + }); + }, [input, setPastedContents]); function onTextPaste(rawText: string) { - pendingSpaceAfterPillRef.current = false + pendingSpaceAfterPillRef.current = false; // Clean up pasted text - strip ANSI escape codes and normalize line endings and tabs - let text = stripAnsi(rawText).replace(/\r/g, '\n').replaceAll('\t', ' ') + let text = stripAnsi(rawText).replace(/\r/g, '\n').replaceAll('\t', ' '); // Match typed/auto-suggest: `!cmd` pasted into empty input enters bash mode. if (input.length === 0) { - const pastedMode = getModeFromInput(text) + const pastedMode = getModeFromInput(text); if (pastedMode !== 'prompt') { - onModeChange(pastedMode) - text = getValueFromInput(text) + onModeChange(pastedMode); + text = getValueFromInput(text); } } - const numLines = getPastedTextRefNumLines(text) + const numLines = getPastedTextRefNumLines(text); // Limit the number of lines to show in the input // If the overall layout is too high then Ink will repaint // the entire terminal. // The actual required height is dependent on the content, this // is just an estimate. - const maxLines = Math.min(rows - 10, 2) + const maxLines = Math.min(rows - 10, 2); // Use special handling for long pasted text (>PASTE_THRESHOLD chars) // or if it exceeds the number of lines we want to show if (text.length > PASTE_THRESHOLD || numLines > maxLines) { - const pasteId = nextPasteIdRef.current++ + const pasteId = nextPasteIdRef.current++; const newContent: PastedContent = { id: pasteId, type: 'text', content: text, - } + }; - setPastedContents(prev => ({ ...prev, [pasteId]: newContent })) + setPastedContents(prev => ({ ...prev, [pasteId]: newContent })); - insertTextAtCursor(formatPastedTextRef(pasteId, numLines)) + insertTextAtCursor(formatPastedTextRef(pasteId, numLines)); } else { // For shorter pastes, just insert the text normally - insertTextAtCursor(text) + insertTextAtCursor(text); } } - const lazySpaceInputFilter = useCallback( - (input: string, key: Key): string => { - if (!pendingSpaceAfterPillRef.current) return input - pendingSpaceAfterPillRef.current = false - if (isNonSpacePrintable(input, key)) return ' ' + input - return input - }, - [], - ) + const lazySpaceInputFilter = useCallback((input: string, key: Key): string => { + if (!pendingSpaceAfterPillRef.current) return input; + pendingSpaceAfterPillRef.current = false; + if (isNonSpacePrintable(input, key)) return ' ' + input; + return input; + }, []); function insertTextAtCursor(text: string) { // Push current state to buffer before inserting - pushToBuffer(input, cursorOffset, pastedContents) + pushToBuffer(input, cursorOffset, pastedContents); - const newInput = - input.slice(0, cursorOffset) + text + input.slice(cursorOffset) - trackAndSetInput(newInput) - setCursorOffset(cursorOffset + text.length) + const newInput = input.slice(0, cursorOffset) + text + input.slice(cursorOffset); + trackAndSetInput(newInput); + setCursorOffset(cursorOffset + text.length); } const doublePressEscFromEmpty = useDoublePress( () => {}, () => onShowMessageSelector(), - ) + ); // Function to get the queued command for editing. Returns true if commands were popped. const popAllCommandsFromQueue = useCallback((): boolean => { - const result = popAllEditable(input, cursorOffset) + const result = popAllEditable(input, cursorOffset); if (!result) { - return false + return false; } - trackAndSetInput(result.text) - onModeChange('prompt') // Always prompt mode for queued commands - setCursorOffset(result.cursorOffset) + trackAndSetInput(result.text); + onModeChange('prompt'); // Always prompt mode for queued commands + setCursorOffset(result.cursorOffset); // Restore images from queued commands to pastedContents if (result.images.length > 0) { setPastedContents(prev => { - const newContents = { ...prev } + const newContents = { ...prev }; for (const image of result.images) { - newContents[image.id] = image + newContents[image.id] = image; } - return newContents - }) + return newContents; + }); } - return true - }, [trackAndSetInput, onModeChange, input, cursorOffset, setPastedContents]) + return true; + }, [trackAndSetInput, onModeChange, input, cursorOffset, setPastedContents]); // Insert the at-mentioned reference (the file and, optionally, a line range) when // we receive an at-mentioned notification the IDE. const onIdeAtMentioned = function (atMentioned: IDEAtMentioned) { - logEvent('tengu_ext_at_mentioned', {}) - let atMentionedText: string - const relativePath = path.relative(getCwd(), atMentioned.filePath) + logEvent('tengu_ext_at_mentioned', {}); + let atMentionedText: string; + const relativePath = path.relative(getCwd(), atMentioned.filePath); if (atMentioned.lineStart && atMentioned.lineEnd) { atMentionedText = atMentioned.lineStart === atMentioned.lineEnd ? `@${relativePath}#L${atMentioned.lineStart} ` - : `@${relativePath}#L${atMentioned.lineStart}-${atMentioned.lineEnd} ` + : `@${relativePath}#L${atMentioned.lineStart}-${atMentioned.lineEnd} `; } else { - atMentionedText = `@${relativePath} ` + atMentionedText = `@${relativePath} `; } - const cursorChar = input[cursorOffset - 1] ?? ' ' + const cursorChar = input[cursorOffset - 1] ?? ' '; if (!/\s/.test(cursorChar)) { - atMentionedText = ` ${atMentionedText}` + atMentionedText = ` ${atMentionedText}`; } - insertTextAtCursor(atMentionedText) - } - useIdeAtMentioned(mcpClients, onIdeAtMentioned) + insertTextAtCursor(atMentionedText); + }; + useIdeAtMentioned(mcpClients, onIdeAtMentioned); // Handler for chat:undo - undo last edit const handleUndo = useCallback(() => { if (canUndo) { - const previousState = undo() + const previousState = undo(); if (previousState) { - trackAndSetInput(previousState.text) - setCursorOffset(previousState.cursorOffset) - setPastedContents(previousState.pastedContents) + trackAndSetInput(previousState.text); + setCursorOffset(previousState.cursorOffset); + setPastedContents(previousState.pastedContents); } } - }, [canUndo, undo, trackAndSetInput, setPastedContents]) + }, [canUndo, undo, trackAndSetInput, setPastedContents]); // Handler for chat:newline - insert a newline at the cursor position const handleNewline = useCallback(() => { - pushToBuffer(input, cursorOffset, pastedContents) - const newInput = - input.slice(0, cursorOffset) + '\n' + input.slice(cursorOffset) - trackAndSetInput(newInput) - setCursorOffset(cursorOffset + 1) - }, [ - input, - cursorOffset, - trackAndSetInput, - setCursorOffset, - pushToBuffer, - pastedContents, - ]) + pushToBuffer(input, cursorOffset, pastedContents); + const newInput = input.slice(0, cursorOffset) + '\n' + input.slice(cursorOffset); + trackAndSetInput(newInput); + setCursorOffset(cursorOffset + 1); + }, [input, cursorOffset, trackAndSetInput, setCursorOffset, pushToBuffer, pastedContents]); // Handler for chat:externalEditor - edit in $EDITOR const handleExternalEditor = useCallback(async () => { - logEvent('tengu_external_editor_used', {}) - setIsExternalEditorActive(true) + logEvent('tengu_external_editor_used', {}); + setIsExternalEditorActive(true); try { // Pass pastedContents to expand collapsed text references - const result = await editPromptInEditor(input, pastedContents) + const result = await editPromptInEditor(input, pastedContents); if (result.error) { addNotification({ @@ -1753,91 +1505,76 @@ function PromptInput({ text: result.error, color: 'warning', priority: 'high', - }) + }); } if (result.content !== null && result.content !== input) { // Push current state to buffer before making changes - pushToBuffer(input, cursorOffset, pastedContents) + pushToBuffer(input, cursorOffset, pastedContents); - trackAndSetInput(result.content) - setCursorOffset(result.content.length) + trackAndSetInput(result.content); + setCursorOffset(result.content.length); } } catch (err) { if (err instanceof Error) { - logError(err) + logError(err); } addNotification({ key: 'external-editor-error', text: `External editor failed: ${errorMessage(err)}`, color: 'warning', priority: 'high', - }) + }); } finally { - setIsExternalEditorActive(false) + setIsExternalEditorActive(false); } - }, [ - input, - cursorOffset, - pastedContents, - pushToBuffer, - trackAndSetInput, - addNotification, - ]) + }, [input, cursorOffset, pastedContents, pushToBuffer, trackAndSetInput, addNotification]); // Handler for chat:stash - stash/unstash prompt const handleStash = useCallback(() => { if (input.trim() === '' && stashedPrompt !== undefined) { // Pop stash when input is empty - trackAndSetInput(stashedPrompt.text) - setCursorOffset(stashedPrompt.cursorOffset) - setPastedContents(stashedPrompt.pastedContents) - setStashedPrompt(undefined) + trackAndSetInput(stashedPrompt.text); + setCursorOffset(stashedPrompt.cursorOffset); + setPastedContents(stashedPrompt.pastedContents); + setStashedPrompt(undefined); } else if (input.trim() !== '') { // Push to stash (save text, cursor position, and pasted contents) - setStashedPrompt({ text: input, cursorOffset, pastedContents }) - trackAndSetInput('') - setCursorOffset(0) - setPastedContents({}) + setStashedPrompt({ text: input, cursorOffset, pastedContents }); + trackAndSetInput(''); + setCursorOffset(0); + setPastedContents({}); // Track usage for /discover and stop showing hint saveGlobalConfig(c => { - if (c.hasUsedStash) return c - return { ...c, hasUsedStash: true } - }) + if (c.hasUsedStash) return c; + return { ...c, hasUsedStash: true }; + }); } - }, [ - input, - cursorOffset, - stashedPrompt, - trackAndSetInput, - setStashedPrompt, - pastedContents, - setPastedContents, - ]) + }, [input, cursorOffset, stashedPrompt, trackAndSetInput, setStashedPrompt, pastedContents, setPastedContents]); // Handler for chat:modelPicker - toggle model picker const handleModelPicker = useCallback(() => { - setShowModelPicker(prev => !prev) + setShowModelPicker(prev => !prev); if (helpOpen) { - setHelpOpen(false) + setHelpOpen(false); } - }, [helpOpen]) + }, [helpOpen]); // Handler for chat:fastMode - toggle fast mode picker const handleFastModePicker = useCallback(() => { - setShowFastModePicker(prev => !prev) + setShowFastModePicker(prev => !prev); if (helpOpen) { - setHelpOpen(false) + setHelpOpen(false); } - }, [helpOpen]) + }, [helpOpen]); // Handler for chat:thinkingToggle - toggle thinking mode const handleThinkingToggle = useCallback(() => { - setShowThinkingToggle(prev => !prev) + setShowThinkingToggle(prev => !prev); if (helpOpen) { - setHelpOpen(false) + setHelpOpen(false); } - }, [helpOpen]) + }, [helpOpen]); // Handler for chat:cycleMode - cycle through permission modes const handleCycleMode = useCallback(() => { @@ -1846,22 +1583,22 @@ function PromptInput({ const teammateContext: ToolPermissionContext = { ...toolPermissionContext, mode: viewedTeammate.permissionMode, - } + }; // Pass undefined for teamContext (unused but kept for API compatibility) - const nextMode = getNextPermissionMode(teammateContext, undefined) + const nextMode = getNextPermissionMode(teammateContext, undefined); logEvent('tengu_mode_cycle', { to: nextMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + }); - const teammateTaskId = viewingAgentTaskId + const teammateTaskId = viewingAgentTaskId; setAppState(prev => { - const task = prev.tasks[teammateTaskId] + const task = prev.tasks[teammateTaskId]; if (!task || task.type !== 'in_process_teammate') { - return prev + return prev; } if (task.permissionMode === nextMode) { - return prev + return prev; } return { ...prev, @@ -1872,39 +1609,36 @@ function PromptInput({ permissionMode: nextMode, }, }, - } - }) + }; + }); if (helpOpen) { - setHelpOpen(false) + setHelpOpen(false); } - return + return; } // Compute the next mode without triggering side effects first logForDebugging( `[auto-mode] handleCycleMode: currentMode=${toolPermissionContext.mode} isAutoModeAvailable=${toolPermissionContext.isAutoModeAvailable} showAutoModeOptIn=${showAutoModeOptIn} timeoutPending=${!!autoModeOptInTimeoutRef.current}`, - ) - const nextMode = getNextPermissionMode(toolPermissionContext, teamContext) + ); + const nextMode = getNextPermissionMode(toolPermissionContext, teamContext); // Check if user is entering auto mode for the first time. Gated on the // persistent settings flag (hasAutoModeOptIn) rather than the broader // hasAutoModeOptInAnySource so that --enable-auto-mode users still see // the warning dialog once — the CLI flag should grant carousel access, // not bypass the safety text. - let isEnteringAutoModeFirstTime = false + let isEnteringAutoModeFirstTime = false; if (feature('TRANSCRIPT_CLASSIFIER')) { isEnteringAutoModeFirstTime = - nextMode === 'auto' && - toolPermissionContext.mode !== 'auto' && - !hasAutoModeOptIn() && - !viewingAgentTaskId // Only show for primary agent, not subagents + nextMode === 'auto' && toolPermissionContext.mode !== 'auto' && !hasAutoModeOptIn() && !viewingAgentTaskId; // Only show for primary agent, not subagents } if (feature('TRANSCRIPT_CLASSIFIER')) { if (isEnteringAutoModeFirstTime) { // Store previous mode so we can revert if user declines - setPreviousModeBeforeAuto(toolPermissionContext.mode) + setPreviousModeBeforeAuto(toolPermissionContext.mode); // Only update the UI mode label — do NOT call transitionPermissionMode // or cyclePermissionMode yet; we haven't confirmed with the user. @@ -1914,30 +1648,30 @@ function PromptInput({ ...prev.toolPermissionContext, mode: 'auto', }, - })) + })); setToolPermissionContext({ ...toolPermissionContext, mode: 'auto', - }) + }); // Show opt-in dialog after 400ms debounce if (autoModeOptInTimeoutRef.current) { - clearTimeout(autoModeOptInTimeoutRef.current) + clearTimeout(autoModeOptInTimeoutRef.current); } autoModeOptInTimeoutRef.current = setTimeout( (setShowAutoModeOptIn, autoModeOptInTimeoutRef) => { - setShowAutoModeOptIn(true) - autoModeOptInTimeoutRef.current = null + setShowAutoModeOptIn(true); + autoModeOptInTimeoutRef.current = null; }, 400, setShowAutoModeOptIn, autoModeOptInTimeoutRef, - ) + ); if (helpOpen) { - setHelpOpen(false) + setHelpOpen(false); } - return + return; } } @@ -1949,14 +1683,14 @@ function PromptInput({ if (feature('TRANSCRIPT_CLASSIFIER')) { if (showAutoModeOptIn || autoModeOptInTimeoutRef.current) { if (showAutoModeOptIn) { - logEvent('tengu_auto_mode_opt_in_dialog_decline', {}) + logEvent('tengu_auto_mode_opt_in_dialog_decline', {}); } - setShowAutoModeOptIn(false) + setShowAutoModeOptIn(false); if (autoModeOptInTimeoutRef.current) { - clearTimeout(autoModeOptInTimeoutRef.current) - autoModeOptInTimeoutRef.current = null + clearTimeout(autoModeOptInTimeoutRef.current); + autoModeOptInTimeoutRef.current = null; } - setPreviousModeBeforeAuto(null) + setPreviousModeBeforeAuto(null); // Fall through — mode is 'auto', cyclePermissionMode below goes to 'default'. } } @@ -1964,21 +1698,18 @@ function PromptInput({ // Now that we know this is NOT the first-time auto mode path, // call cyclePermissionMode to apply side effects (e.g. strip // dangerous permissions, activate classifier) - const { context: preparedContext } = cyclePermissionMode( - toolPermissionContext, - teamContext, - ) + const { context: preparedContext } = cyclePermissionMode(toolPermissionContext, teamContext); logEvent('tengu_mode_cycle', { to: nextMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + }); // Track when user enters plan mode if (nextMode === 'plan') { saveGlobalConfig(current => ({ ...current, lastPlanModeUse: Date.now(), - })) + })); } // Set the mode via setAppState directly because setToolPermissionContext @@ -1991,18 +1722,18 @@ function PromptInput({ ...preparedContext, mode: nextMode, }, - })) + })); setToolPermissionContext({ ...preparedContext, mode: nextMode, - }) + }); // If this is a teammate, update config.json so team lead sees the change - syncTeammateMode(nextMode, teamContext?.teamName) + syncTeammateMode(nextMode, teamContext?.teamName); // Close help tips if they're open when mode is cycled if (helpOpen) { - setHelpOpen(false) + setHelpOpen(false); } }, [ toolPermissionContext, @@ -2013,13 +1744,13 @@ function PromptInput({ setToolPermissionContext, helpOpen, showAutoModeOptIn, - ]) + ]); // Handler for auto mode opt-in dialog acceptance const handleAutoModeOptInAccept = useCallback(() => { if (feature('TRANSCRIPT_CLASSIFIER')) { - setShowAutoModeOptIn(false) - setPreviousModeBeforeAuto(null) + setShowAutoModeOptIn(false); + setPreviousModeBeforeAuto(null); // Now that the user accepted, apply the full transition: activate the // auto mode backend (classifier, beta headers) and strip dangerous @@ -2028,49 +1759,42 @@ function PromptInput({ previousModeBeforeAuto ?? toolPermissionContext.mode, 'auto', toolPermissionContext, - ) + ); setAppState(prev => ({ ...prev, toolPermissionContext: { ...strippedContext, mode: 'auto', }, - })) + })); setToolPermissionContext({ ...strippedContext, mode: 'auto', - }) + }); // Close help tips if they're open when auto mode is enabled if (helpOpen) { - setHelpOpen(false) + setHelpOpen(false); } } - }, [ - helpOpen, - setHelpOpen, - previousModeBeforeAuto, - toolPermissionContext, - setAppState, - setToolPermissionContext, - ]) + }, [helpOpen, setHelpOpen, previousModeBeforeAuto, toolPermissionContext, setAppState, setToolPermissionContext]); // Handler for auto mode opt-in dialog decline const handleAutoModeOptInDecline = useCallback(() => { if (feature('TRANSCRIPT_CLASSIFIER')) { logForDebugging( `[auto-mode] handleAutoModeOptInDecline: reverting to ${previousModeBeforeAuto}, setting isAutoModeAvailable=false`, - ) - setShowAutoModeOptIn(false) + ); + setShowAutoModeOptIn(false); if (autoModeOptInTimeoutRef.current) { - clearTimeout(autoModeOptInTimeoutRef.current) - autoModeOptInTimeoutRef.current = null + clearTimeout(autoModeOptInTimeoutRef.current); + autoModeOptInTimeoutRef.current = null; } // Revert to previous mode and remove auto from the carousel // for the rest of this session if (previousModeBeforeAuto) { - setAutoModeActive(false) + setAutoModeActive(false); setAppState(prev => ({ ...prev, toolPermissionContext: { @@ -2078,45 +1802,36 @@ function PromptInput({ mode: previousModeBeforeAuto, isAutoModeAvailable: false, }, - })) + })); setToolPermissionContext({ ...toolPermissionContext, mode: previousModeBeforeAuto, isAutoModeAvailable: false, - }) - setPreviousModeBeforeAuto(null) + }); + setPreviousModeBeforeAuto(null); } } - }, [ - previousModeBeforeAuto, - toolPermissionContext, - setAppState, - setToolPermissionContext, - ]) + }, [previousModeBeforeAuto, toolPermissionContext, setAppState, setToolPermissionContext]); // Handler for chat:imagePaste - paste image from clipboard const handleImagePaste = useCallback(() => { void getImageFromClipboard().then(imageData => { if (imageData) { - onImagePaste(imageData.base64, imageData.mediaType) + onImagePaste(imageData.base64, imageData.mediaType); } else { - const shortcutDisplay = getShortcutDisplay( - 'chat:imagePaste', - 'Chat', - 'ctrl+v', - ) + const shortcutDisplay = getShortcutDisplay('chat:imagePaste', 'Chat', 'ctrl+v'); const message = env.isSSH() ? "No image found in clipboard. You're SSH'd; try scp?" - : `No image found in clipboard. Use ${shortcutDisplay} to paste images.` + : `No image found in clipboard. Use ${shortcutDisplay} to paste images.`; addNotification({ key: 'no-image-in-clipboard', text: message, priority: 'immediate', timeoutMs: 1000, - }) + }); } - }) - }, [addNotification, onImagePaste]) + }); + }, [addNotification, onImagePaste]); // Register chat:submit handler directly in the handler registry (not via // useKeybindings) so that only the ChordInterceptor can invoke it for chord @@ -2124,17 +1839,17 @@ function PromptInput({ // handled by TextInput directly (via onSubmit prop) and useTypeahead (for // autocomplete acceptance). Using useKeybindings would cause // stopImmediatePropagation on Enter, blocking autocomplete from seeing the key. - const keybindingContext = useOptionalKeybindingContext() + const keybindingContext = useOptionalKeybindingContext(); useEffect(() => { - if (!keybindingContext || isModalOverlayActive) return + if (!keybindingContext || isModalOverlayActive) return; return keybindingContext.registerHandler({ action: 'chat:submit', context: 'Chat', handler: () => { - void onSubmit(input) + void onSubmit(input); }, - }) - }, [keybindingContext, isModalOverlayActive, onSubmit, input]) + }); + }, [keybindingContext, isModalOverlayActive, onSubmit, input]); // Chat context keybindings for editing shortcuts // Note: history:previous/history:next are NOT handled here. They are passed as @@ -2162,26 +1877,25 @@ function PromptInput({ handleCycleMode, handleImagePaste, ], - ) + ); useKeybindings(chatHandlers, { context: 'Chat', isActive: !isModalOverlayActive, - }) + }); // Shift+↑ enters message-actions cursor. Separate isActive so ctrl+r search // doesn't leave stale isSearchingHistory on cursor-exit remount. useKeybinding('chat:messageActions', () => onMessageActionsEnter?.(), { context: 'Chat', isActive: !isModalOverlayActive && !isSearchingHistory, - }) + }); // Fast mode keybinding is only active when fast mode is enabled and available useKeybinding('chat:fastMode', handleFastModePicker, { context: 'Chat', - isActive: - !isModalOverlayActive && isFastModeEnabled() && isFastModeAvailable(), - }) + isActive: !isModalOverlayActive && isFastModeEnabled() && isFastModeAvailable(), + }); // Handle help:dismiss keybinding (ESC closes help menu) // This is registered separately from Chat context so it has priority over @@ -2189,64 +1903,62 @@ function PromptInput({ useKeybinding( 'help:dismiss', () => { - setHelpOpen(false) + setHelpOpen(false); }, { context: 'Help', isActive: helpOpen }, - ) + ); // Quick Open / Global Search. Hook calls are unconditional (Rules of Hooks); // the handler body is feature()-gated so the setState calls and component // references get tree-shaken in external builds. - const quickSearchActive = feature('QUICK_SEARCH') - ? !isModalOverlayActive - : false + const quickSearchActive = feature('QUICK_SEARCH') ? !isModalOverlayActive : false; useKeybinding( 'app:quickOpen', () => { if (feature('QUICK_SEARCH')) { - setShowQuickOpen(true) - setHelpOpen(false) + setShowQuickOpen(true); + setHelpOpen(false); } }, { context: 'Global', isActive: quickSearchActive }, - ) + ); useKeybinding( 'app:globalSearch', () => { if (feature('QUICK_SEARCH')) { - setShowGlobalSearch(true) - setHelpOpen(false) + setShowGlobalSearch(true); + setHelpOpen(false); } }, { context: 'Global', isActive: quickSearchActive }, - ) + ); useKeybinding( 'history:search', () => { if (feature('HISTORY_PICKER')) { - setShowHistoryPicker(true) - setHelpOpen(false) + setShowHistoryPicker(true); + setHelpOpen(false); } }, { context: 'Global', isActive: feature('HISTORY_PICKER') ? !isModalOverlayActive : false, }, - ) + ); // Handle Ctrl+C to abort speculation when idle (not loading) // CancelRequestHandler only handles Ctrl+C during active tasks useKeybinding( 'app:interrupt', () => { - abortSpeculation(setAppState) + abortSpeculation(setAppState); }, { context: 'Global', isActive: !isLoading && speculation.status === 'active', }, - ) + ); // Footer indicator navigation keybindings. ↑/↓ live here (not in // handleHistoryUp/Down) because TextInput focus=false when a pill is @@ -2261,80 +1973,75 @@ function PromptInput({ coordinatorTaskCount > 0 && coordinatorTaskIndex > minCoordinatorIndex ) { - setCoordinatorTaskIndex(prev => prev - 1) - return + setCoordinatorTaskIndex(prev => prev - 1); + return; } - navigateFooter(-1, true) + navigateFooter(-1, true); }, 'footer:down': () => { // ↓ scrolls within the coordinator task list, never leaves the pill - if ( - tasksSelected && - process.env.USER_TYPE === 'ant' && - coordinatorTaskCount > 0 - ) { + if (tasksSelected && process.env.USER_TYPE === 'ant' && coordinatorTaskCount > 0) { if (coordinatorTaskIndex < coordinatorTaskCount - 1) { - setCoordinatorTaskIndex(prev => prev + 1) + setCoordinatorTaskIndex(prev => prev + 1); } - return + return; } if (tasksSelected && !isTeammateMode) { - setShowBashesDialog(true) - selectFooterItem(null) - return + setShowBashesDialog(true); + selectFooterItem(null); + return; } - navigateFooter(1) + navigateFooter(1); }, 'footer:next': () => { // Teammate mode: ←/→ cycles within the team member list if (tasksSelected && isTeammateMode) { - const totalAgents = 1 + inProcessTeammates.length - setTeammateFooterIndex(prev => (prev + 1) % totalAgents) - return + const totalAgents = 1 + inProcessTeammates.length; + setTeammateFooterIndex(prev => (prev + 1) % totalAgents); + return; } - navigateFooter(1) + navigateFooter(1); }, 'footer:previous': () => { if (tasksSelected && isTeammateMode) { - const totalAgents = 1 + inProcessTeammates.length - setTeammateFooterIndex(prev => (prev - 1 + totalAgents) % totalAgents) - return + const totalAgents = 1 + inProcessTeammates.length; + setTeammateFooterIndex(prev => (prev - 1 + totalAgents) % totalAgents); + return; } - navigateFooter(-1) + navigateFooter(-1); }, 'footer:openSelected': () => { if (viewSelectionMode === 'selecting-agent') { - return + return; } switch (footerItemSelected) { case 'companion': if (feature('BUDDY')) { - selectFooterItem(null) - void onSubmit('/buddy') + selectFooterItem(null); + void onSubmit('/buddy'); } - break + break; case 'tasks': if (isTeammateMode) { // Enter switches to the selected agent's view if (teammateFooterIndex === 0) { - exitTeammateView(setAppState) + exitTeammateView(setAppState); } else { - const teammate = inProcessTeammates[teammateFooterIndex - 1] - if (teammate) enterTeammateView(teammate.id, setAppState) + const teammate = inProcessTeammates[teammateFooterIndex - 1]; + if (teammate) enterTeammateView(teammate.id, setAppState); } } else if (coordinatorTaskIndex === 0 && coordinatorTaskCount > 0) { - exitTeammateView(setAppState) + exitTeammateView(setAppState); } else { - const selectedTaskId = - getVisibleAgentTasks(tasks)[coordinatorTaskIndex - 1]?.id + const selectedTaskId = getVisibleAgentTasks(tasks)[coordinatorTaskIndex - 1]?.id; if (selectedTaskId) { - enterTeammateView(selectedTaskId, setAppState) + enterTeammateView(selectedTaskId, setAppState); } else { - setShowBashesDialog(true) - selectFooterItem(null) + setShowBashesDialog(true); + selectFooterItem(null); } } - break + break; case 'tmux': if (process.env.USER_TYPE === 'ant') { setAppState(prev => @@ -2342,91 +2049,78 @@ function PromptInput({ ? { ...prev, tungstenPanelAutoHidden: false } : { ...prev, - tungstenPanelVisible: !( - prev.tungstenPanelVisible ?? true - ), + tungstenPanelVisible: !(prev.tungstenPanelVisible ?? true), }, - ) + ); } - break + break; case 'bagel': - break + break; case 'teams': - setShowTeamsDialog(true) - selectFooterItem(null) - break + setShowTeamsDialog(true); + selectFooterItem(null); + break; case 'bridge': - setShowBridgeDialog(true) - selectFooterItem(null) - break + setShowBridgeDialog(true); + selectFooterItem(null); + break; } }, 'footer:clearSelection': () => { - selectFooterItem(null) + selectFooterItem(null); }, 'footer:close': () => { if (tasksSelected && coordinatorTaskIndex >= 1) { - const task = getVisibleAgentTasks(tasks)[coordinatorTaskIndex - 1] - if (!task) return false + const task = getVisibleAgentTasks(tasks)[coordinatorTaskIndex - 1]; + if (!task) return false; // When the selected row IS the viewed agent, 'x' types into the // steering input. Any other row — dismiss it. - if ( - viewSelectionMode === 'viewing-agent' && - task.id === viewingAgentTaskId - ) { - onChange( - input.slice(0, cursorOffset) + 'x' + input.slice(cursorOffset), - ) - setCursorOffset(cursorOffset + 1) - return + if (viewSelectionMode === 'viewing-agent' && task.id === viewingAgentTaskId) { + onChange(input.slice(0, cursorOffset) + 'x' + input.slice(cursorOffset)); + setCursorOffset(cursorOffset + 1); + return; } - stopOrDismissAgent(task.id, setAppState) + stopOrDismissAgent(task.id, setAppState); if (task.status !== 'running') { - setCoordinatorTaskIndex(i => Math.max(minCoordinatorIndex, i - 1)) + setCoordinatorTaskIndex(i => Math.max(minCoordinatorIndex, i - 1)); } - return + return; } // Not handled — let 'x' fall through to type-to-exit - return false + return false; }, }, { context: 'Footer', isActive: !!footerItemSelected && !isModalOverlayActive, }, - ) + ); useInput((char, key) => { // Skip all input handling when a full-screen dialog is open. These dialogs // render via early return, but hooks run unconditionally — so without this // guard, Escape inside a dialog leaks to the double-press message-selector. - if ( - showTeamsDialog || - showQuickOpen || - showGlobalSearch || - showHistoryPicker - ) { - return + if (showTeamsDialog || showQuickOpen || showGlobalSearch || showHistoryPicker) { + return; } // Detect failed Alt shortcuts on macOS (Option key produces special characters) if (getPlatform() === 'macos' && isMacosOptionChar(char)) { - const shortcut = MACOS_OPTION_SPECIAL_CHARS[char] - const terminalName = getNativeCSIuTerminalDisplayName() + const shortcut = MACOS_OPTION_SPECIAL_CHARS[char]; + const terminalName = getNativeCSIuTerminalDisplayName(); const jsx = terminalName ? ( - To enable {shortcut}, set Option as Meta in{' '} - {terminalName} preferences (⌘,) + To enable {shortcut}, set Option as Meta in {terminalName} preferences (⌘,) ) : ( To enable {shortcut}, run /terminal-setup - ) + ); addNotification({ key: 'option-meta-hint', jsx, priority: 'immediate', timeoutMs: 5000, - }) + }); // Don't return - let the character be typed so user sees the issue } @@ -2438,31 +2132,21 @@ function PromptInput({ // the input and type the char. Nav keys are captured by useKeybindings // above, so anything reaching here is genuinely not a footer action. // onChange clears footerSelection, so no explicit deselect. - if ( - footerItemSelected && - char && - !key.ctrl && - !key.meta && - !key.escape && - !key.return - ) { - onChange(input.slice(0, cursorOffset) + char + input.slice(cursorOffset)) - setCursorOffset(cursorOffset + char.length) - return + if (footerItemSelected && char && !key.ctrl && !key.meta && !key.escape && !key.return) { + onChange(input.slice(0, cursorOffset) + char + input.slice(cursorOffset)); + setCursorOffset(cursorOffset + char.length); + return; } // Exit special modes when backspace/escape/delete/ctrl+u is pressed at cursor position 0 - if ( - cursorOffset === 0 && - (key.escape || key.backspace || key.delete || (key.ctrl && char === 'u')) - ) { - onModeChange('prompt') - setHelpOpen(false) + if (cursorOffset === 0 && (key.escape || key.backspace || key.delete || (key.ctrl && char === 'u'))) { + onModeChange('prompt'); + setHelpOpen(false); } // Exit help mode when backspace is pressed and input is empty if (helpOpen && input === '' && (key.backspace || key.delete)) { - setHelpOpen(false) + setHelpOpen(false); } // esc is a little overloaded: @@ -2475,83 +2159,77 @@ function PromptInput({ if (key.escape) { // Abort active speculation if (speculation.status === 'active') { - abortSpeculation(setAppState) - return + abortSpeculation(setAppState); + return; } // Dismiss side question response if visible if (isSideQuestionVisible && onDismissSideQuestion) { - onDismissSideQuestion() - return + onDismissSideQuestion(); + return; } // Close help menu if open if (helpOpen) { - setHelpOpen(false) - return + setHelpOpen(false); + return; } // Footer selection clearing is now handled via Footer context keybindings // (footer:clearSelection action bound to escape) // If a footer item is selected, let the Footer keybinding handle it if (footerItemSelected) { - return + return; } // If there's an editable queued command, move it to the input for editing when ESC is pressed - const hasEditableCommand = queuedCommands.some(isQueuedCommandEditable) + const hasEditableCommand = queuedCommands.some(isQueuedCommandEditable); if (hasEditableCommand) { - void popAllCommandsFromQueue() - return + void popAllCommandsFromQueue(); + return; } if (messages.length > 0 && !input && !isLoading) { - doublePressEscFromEmpty() + doublePressEscFromEmpty(); } } if (key.return && helpOpen) { - setHelpOpen(false) + setHelpOpen(false); } - }) + }); - const swarmBanner = useSwarmBanner() + const swarmBanner = useSwarmBanner(); - const fastModeCooldown = isFastModeEnabled() ? isFastModeCooldown() : false - const showFastIcon = isFastModeEnabled() - ? isFastMode && (isFastModeAvailable() || fastModeCooldown) - : false + const fastModeCooldown = isFastModeEnabled() ? isFastModeCooldown() : false; + const showFastIcon = isFastModeEnabled() ? isFastMode && (isFastModeAvailable() || fastModeCooldown) : false; - const showFastIconHint = useShowFastIconHint(showFastIcon ?? false) + const showFastIconHint = useShowFastIconHint(showFastIcon ?? false); // Show effort notification on startup and when effort changes. // Suppressed in brief/assistant mode — the value reflects the local // client's effort, not the connected agent's. - const effortNotificationText = briefOwnsGap - ? undefined - : getEffortNotificationText(effortValue, mainLoopModel) + const effortNotificationText = briefOwnsGap ? undefined : getEffortNotificationText(effortValue, mainLoopModel); useEffect(() => { if (!effortNotificationText) { - removeNotification('effort-level') - return + removeNotification('effort-level'); + return; } addNotification({ key: 'effort-level', text: effortNotificationText, priority: 'high', timeoutMs: 12_000, - }) - }, [effortNotificationText, addNotification, removeNotification]) + }); + }, [effortNotificationText, addNotification, removeNotification]); - useBuddyNotification() + useBuddyNotification(); const companionSpeaking = feature('BUDDY') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s => s.companionReaction !== undefined) - : false - const { columns, rows } = useTerminalSize() - const textInputColumns = - columns - 3 - companionReservedColumns(columns, companionSpeaking) + ? useAppState(s => s.companionReaction !== undefined) + : false; + const { columns, rows } = useTerminalSize(); + const textInputColumns = columns - 3 - companionReservedColumns(columns, companionSpeaking); // POC: click-to-position-cursor. Mouse tracking is only enabled inside // , so this is dormant in the normal main-screen REPL. @@ -2560,100 +2238,82 @@ function PromptInput({ // in the Cursor wrap model. MeasuredText.getOffsetFromPosition handles // wide chars, wrapped lines, and clamps past-end clicks to line end. const maxVisibleLines = isFullscreenEnvEnabled() - ? Math.max( - MIN_INPUT_VIEWPORT_LINES, - Math.floor(rows / 2) - PROMPT_FOOTER_LINES, - ) - : undefined + ? Math.max(MIN_INPUT_VIEWPORT_LINES, Math.floor(rows / 2) - PROMPT_FOOTER_LINES) + : undefined; const handleInputClick = useCallback( (e: ClickEvent) => { // During history search the displayed text is historyMatch, not // input, and showCursor is false anyway — skip rather than // compute an offset against the wrong string. - if (!input || isSearchingHistory) return - const c = Cursor.fromText(input, textInputColumns, cursorOffset) - const viewportStart = c.getViewportStartLine(maxVisibleLines) + if (!input || isSearchingHistory) return; + const c = Cursor.fromText(input, textInputColumns, cursorOffset); + const viewportStart = c.getViewportStartLine(maxVisibleLines); const offset = c.measuredText.getOffsetFromPosition({ line: e.localRow + viewportStart, column: e.localCol, - }) - setCursorOffset(offset) + }); + setCursorOffset(offset); }, - [ - input, - textInputColumns, - isSearchingHistory, - cursorOffset, - maxVisibleLines, - ], - ) + [input, textInputColumns, isSearchingHistory, cursorOffset, maxVisibleLines], + ); const handleOpenTasksDialog = useCallback( (taskId?: string) => setShowBashesDialog(taskId ?? true), [setShowBashesDialog], - ) + ); - const placeholder = - showPromptSuggestion && promptSuggestion - ? promptSuggestion - : defaultPlaceholder + const placeholder = showPromptSuggestion && promptSuggestion ? promptSuggestion : defaultPlaceholder; // Calculate if input has multiple lines - const isInputWrapped = useMemo(() => input.includes('\n'), [input]) + const isInputWrapped = useMemo(() => input.includes('\n'), [input]); // Memoized callbacks for model picker to prevent re-renders when unrelated // state (like notifications) changes. This prevents the inline model picker // from visually "jumping" when notifications arrive. const handleModelSelect = useCallback( (model: string | null, _effort: EffortLevel | undefined) => { - let wasFastModeDisabled = false + let wasFastModeDisabled = false; setAppState(prev => { - wasFastModeDisabled = - isFastModeEnabled() && - !isFastModeSupportedByModel(model) && - !!prev.fastMode + wasFastModeDisabled = isFastModeEnabled() && !isFastModeSupportedByModel(model) && !!prev.fastMode; return { ...prev, mainLoopModel: model, mainLoopModelForSession: null, // Turn off fast mode if switching to a model that doesn't support it ...(wasFastModeDisabled && { fastMode: false }), - } - }) - setShowModelPicker(false) - const effectiveFastMode = (isFastMode ?? false) && !wasFastModeDisabled - let message = `Model set to ${modelDisplayString(model)}` - if ( - isBilledAsExtraUsage(model, effectiveFastMode, isOpus1mMergeEnabled()) - ) { - message += ' · Billed as extra usage' + }; + }); + setShowModelPicker(false); + const effectiveFastMode = (isFastMode ?? false) && !wasFastModeDisabled; + let message = `Model set to ${modelDisplayString(model)}`; + if (isBilledAsExtraUsage(model, effectiveFastMode, isOpus1mMergeEnabled())) { + message += ' · Billed as extra usage'; } if (wasFastModeDisabled) { - message += ' · Fast mode OFF' + message += ' · Fast mode OFF'; } addNotification({ key: 'model-switched', jsx: {message}, priority: 'immediate', timeoutMs: 3000, - }) + }); logEvent('tengu_model_picker_hotkey', { - model: - model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + model: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); }, [setAppState, addNotification, isFastMode], - ) + ); const handleModelCancel = useCallback(() => { - setShowModelPicker(false) - }, []) + setShowModelPicker(false); + }, []); // Memoize the model picker element to prevent unnecessary re-renders // when AppState changes for unrelated reasons (e.g., notifications arriving) const modelPickerElement = useMemo(() => { - if (!showModelPicker) return null + if (!showModelPicker) return null; return ( - ) - }, [ - showModelPicker, - mainLoopModel_, - mainLoopModelForSession, - handleModelSelect, - handleModelCancel, - ]) + ); + }, [showModelPicker, mainLoopModel_, mainLoopModelForSession, handleModelSelect, handleModelCancel]); const handleFastModeSelect = useCallback( (result?: string) => { - setShowFastModePicker(false) + setShowFastModePicker(false); if (result) { addNotification({ key: 'fast-mode-toggled', jsx: {result}, priority: 'immediate', timeoutMs: 3000, - }) + }); } }, [addNotification], - ) + ); // Memoize the fast mode picker element const fastModePickerElement = useMemo(() => { - if (!showFastModePicker) return null + if (!showFastModePicker) return null; return ( - + - ) - }, [showFastModePicker, handleFastModeSelect]) + ); + }, [showFastModePicker, handleFastModeSelect]); // Memoized callbacks for thinking toggle const handleThinkingSelect = useCallback( @@ -2713,9 +2361,9 @@ function PromptInput({ setAppState(prev => ({ ...prev, thinkingEnabled: enabled, - })) - setShowThinkingToggle(false) - logEvent('tengu_thinking_toggled_hotkey', { enabled }) + })); + setShowThinkingToggle(false); + logEvent('tengu_thinking_toggled_hotkey', { enabled }); addNotification({ key: 'thinking-toggled-hotkey', jsx: ( @@ -2725,18 +2373,18 @@ function PromptInput({ ), priority: 'immediate', timeoutMs: 3000, - }) + }); }, [setAppState, addNotification], - ) + ); const handleThinkingCancel = useCallback(() => { - setShowThinkingToggle(false) - }, []) + setShowThinkingToggle(false); + }, []); // Memoize the thinking toggle element const thinkingToggleElement = useMemo(() => { - if (!showThinkingToggle) return null + if (!showThinkingToggle) return null; return ( m.type === 'assistant')} /> - ) - }, [ - showThinkingToggle, - thinkingEnabled, - handleThinkingSelect, - handleThinkingCancel, - messages.length, - ]) + ); + }, [showThinkingToggle, thinkingEnabled, handleThinkingSelect, handleThinkingCancel, messages.length]); // Portal dialog to DialogOverlay in fullscreen so it escapes the bottom // slot's overflowY:hidden clip (same pattern as SuggestionsOverlay). @@ -2762,32 +2404,20 @@ function PromptInput({ const autoModeOptInDialog = useMemo( () => feature('TRANSCRIPT_CLASSIFIER') && showAutoModeOptIn ? ( - + ) : null, [showAutoModeOptIn, handleAutoModeOptInAccept, handleAutoModeOptInDecline], - ) - useSetPromptOverlayDialog( - isFullscreenEnvEnabled() ? autoModeOptInDialog : null, - ) + ); + useSetPromptOverlayDialog(isFullscreenEnvEnabled() ? autoModeOptInDialog : null); if (showBashesDialog) { return ( setShowBashesDialog(false)} - toolUseContext={getToolUseContext( - messages, - [], - new AbortController(), - mainLoopModel, - )} - initialDetailTaskId={ - typeof showBashesDialog === 'string' ? showBashesDialog : undefined - } + toolUseContext={getToolUseContext(messages, [], new AbortController(), mainLoopModel)} + initialDetailTaskId={typeof showBashesDialog === 'string' ? showBashesDialog : undefined} /> - ) + ); } if (isAgentSwarmsEnabled() && showTeamsDialog) { @@ -2795,32 +2425,22 @@ function PromptInput({ { - setShowTeamsDialog(false) + setShowTeamsDialog(false); }} /> - ) + ); } if (feature('QUICK_SEARCH')) { const insertWithSpacing = (text: string) => { - const cursorChar = input[cursorOffset - 1] ?? ' ' - insertTextAtCursor(/\s/.test(cursorChar) ? text : ` ${text}`) - } + const cursorChar = input[cursorOffset - 1] ?? ' '; + insertTextAtCursor(/\s/.test(cursorChar) ? text : ` ${text}`); + }; if (showQuickOpen) { - return ( - setShowQuickOpen(false)} - onInsert={insertWithSpacing} - /> - ) + return setShowQuickOpen(false)} onInsert={insertWithSpacing} />; } if (showGlobalSearch) { - return ( - setShowGlobalSearch(false)} - onInsert={insertWithSpacing} - /> - ) + return setShowGlobalSearch(false)} onInsert={insertWithSpacing} />; } } @@ -2829,41 +2449,41 @@ function PromptInput({ { - const entryMode = getModeFromInput(entry.display) - const value = getValueFromInput(entry.display) - onModeChange(entryMode) - trackAndSetInput(value) - setPastedContents(entry.pastedContents) - setCursorOffset(value.length) - setShowHistoryPicker(false) + const entryMode = getModeFromInput(entry.display); + const value = getValueFromInput(entry.display); + onModeChange(entryMode); + trackAndSetInput(value); + setPastedContents(entry.pastedContents); + setCursorOffset(value.length); + setShowHistoryPicker(false); }} onCancel={() => setShowHistoryPicker(false)} /> - ) + ); } // Show loop mode menu when requested (ant-only, eliminated from external builds) if (modelPickerElement) { - return modelPickerElement + return modelPickerElement; } if (fastModePickerElement) { - return fastModePickerElement + return fastModePickerElement; } if (thinkingToggleElement) { - return thinkingToggleElement + return thinkingToggleElement; } if (showBridgeDialog) { return ( { - setShowBridgeDialog(false) - selectFooterItem(null) + setShowBridgeDialog(false); + selectFooterItem(null); }} /> - ) + ); } const baseProps: BaseTextInputProps = { @@ -2871,11 +2491,7 @@ function PromptInput({ onSubmit, onChange, value: historyMatch - ? getValueFromInput( - typeof historyMatch === 'string' - ? historyMatch - : historyMatch.display, - ) + ? getValueFromInput(typeof historyMatch === 'string' ? historyMatch : historyMatch.display) : input, // History navigation is handled via TextInput props (onHistoryUp/onHistoryDown), // NOT via useKeybindings. This allows useTextInput's upOrHistoryUp/downOrHistoryDown @@ -2890,58 +2506,53 @@ function PromptInput({ onImagePaste, columns: textInputColumns, maxVisibleLines, - disableCursorMovementForUpDownKeys: - suggestions.length > 0 || !!footerItemSelected, + disableCursorMovementForUpDownKeys: suggestions.length > 0 || !!footerItemSelected, disableEscapeDoublePress: suggestions.length > 0, cursorOffset, onChangeCursorOffset: setCursorOffset, onPaste: onTextPaste, onIsPastingChange: setIsPasting, focus: !isSearchingHistory && !isModalOverlayActive && !footerItemSelected, - showCursor: - !footerItemSelected && !isSearchingHistory && !cursorAtImageChip, + showCursor: !footerItemSelected && !isSearchingHistory && !cursorAtImageChip, argumentHint: commandArgumentHint, onUndo: canUndo ? () => { - const previousState = undo() + const previousState = undo(); if (previousState) { - trackAndSetInput(previousState.text) - setCursorOffset(previousState.cursorOffset) - setPastedContents(previousState.pastedContents) + trackAndSetInput(previousState.text); + setCursorOffset(previousState.cursorOffset); + setPastedContents(previousState.pastedContents); } } : undefined, highlights: combinedHighlights, inlineGhostText, inputFilter: lazySpaceInputFilter, - } + }; const getBorderColor = (): keyof Theme => { const modeColors: Record = { bash: 'bashBorder', - } + }; // Mode colors take priority, then teammate color, then default if (modeColors[mode]) { - return modeColors[mode] + return modeColors[mode]; } // In-process teammates run headless - don't apply teammate colors to leader UI if (isInProcessTeammate()) { - return 'promptBorder' + return 'promptBorder'; } // Check for teammate color from environment - const teammateColorName = getTeammateColor() - if ( - teammateColorName && - AGENT_COLORS.includes(teammateColorName as AgentColorName) - ) { - return AGENT_COLOR_TO_THEME_COLOR[teammateColorName as AgentColorName] + const teammateColorName = getTeammateColor(); + if (teammateColorName && AGENT_COLORS.includes(teammateColorName as AgentColorName)) { + return AGENT_COLOR_TO_THEME_COLOR[teammateColorName as AgentColorName]; } - return 'promptBorder' - } + return 'promptBorder'; + }; if (isExternalEditorActive) { return ( @@ -2960,18 +2571,14 @@ function PromptInput({ Save and close editor to continue... - ) + ); } const textInputElement = isVimModeEnabled() ? ( - + ) : ( - ) + ); return ( @@ -2987,9 +2594,7 @@ function PromptInput({ {swarmBanner.text ? ( <> - {'─'.repeat( - Math.max(0, columns - stringWidth(swarmBanner.text) - 4), - )} + {'─'.repeat(Math.max(0, columns - stringWidth(swarmBanner.text) - 4))} {' '} {swarmBanner.text}{' '} @@ -3024,11 +2629,7 @@ function PromptInput({ borderRight={false} borderBottom width="100%" - borderText={buildBorderText( - showFastIcon ?? false, - showFastIconHint, - fastModeCooldown, - )} + borderText={buildBorderText(showFastIcon ?? false, showFastIconHint, fastModeCooldown)} > {isFullscreenEnvEnabled() ? null : autoModeOptInDialog} {isFullscreenEnvEnabled() ? ( @@ -3122,7 +2721,7 @@ function PromptInput({ ) : null} - ) + ); } /** @@ -3130,29 +2729,29 @@ function PromptInput({ * This handles --continue/--resume scenarios where we need to avoid ID collisions. */ function getInitialPasteId(messages: Message[]): number { - let maxId = 0 + let maxId = 0; for (const message of messages) { if (message.type === 'user') { // Check image paste IDs if (message.imagePasteIds) { for (const id of message.imagePasteIds as number[]) { - if (id > maxId) maxId = id + if (id > maxId) maxId = id; } } // Check text paste references in message content if (Array.isArray(message.message!.content)) { for (const block of message.message!.content) { if (block.type === 'text') { - const refs = parseReferences(block.text) + const refs = parseReferences(block.text); for (const ref of refs) { - if (ref.id > maxId) maxId = ref.id + if (ref.id > maxId) maxId = ref.id; } } } } } } - return maxId + 1 + return maxId + 1; } function buildBorderText( @@ -3160,16 +2759,16 @@ function buildBorderText( showFastIconHint: boolean, fastModeCooldown: boolean, ): BorderTextOptions | undefined { - if (!showFastIcon) return undefined + if (!showFastIcon) return undefined; const fastSeg = showFastIconHint ? `${getFastIconString(true, fastModeCooldown)} ${chalk.dim('/fast')}` - : getFastIconString(true, fastModeCooldown) + : getFastIconString(true, fastModeCooldown); return { content: ` ${fastSeg} `, position: 'top', align: 'end', offset: 0, - } + }; } -export default React.memo(PromptInput) +export default React.memo(PromptInput); diff --git a/src/components/PromptInput/PromptInputFooter.tsx b/src/components/PromptInput/PromptInputFooter.tsx index 2261e56b0..f1193ba9f 100644 --- a/src/components/PromptInput/PromptInputFooter.tsx +++ b/src/components/PromptInput/PromptInputFooter.tsx @@ -1,39 +1,32 @@ -import { feature } from 'bun:bundle' -import * as React from 'react' -import { memo, type ReactNode, useCallback, useMemo, useRef, useState } from 'react' -import { isBridgeEnabled } from '../../bridge/bridgeEnabled.js' -import { getBridgeStatus } from '../../bridge/bridgeStatusUtil.js' -import { useSetPromptOverlay } from '../../context/promptOverlayContext.js' -import type { VerificationStatus } from '../../hooks/useApiKeyVerification.js' -import type { IDESelection } from '../../hooks/useIdeSelection.js' -import { useSettings } from '../../hooks/useSettings.js' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' -import { Box, Text, useInput } from '@anthropic/ink' -import type { MCPServerConnection } from '../../services/mcp/types.js' -import { useRegisterOverlay } from '../../context/overlayContext.js' -import { useAppState, useSetAppState } from '../../state/AppState.js' -import type { ToolPermissionContext } from '../../Tool.js' -import type { Message } from '../../types/message.js' -import type { PromptInputMode, VimMode } from '../../types/textInputTypes.js' -import type { AutoUpdaterResult } from '../../utils/autoUpdater.js' -import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js' -import { getPipeDisplayRole, isPipeControlled } from '../../utils/pipeTransport.js' -import { isUndercover } from '../../utils/undercover.js' -import { - CoordinatorTaskPanel, - useCoordinatorTaskCount, -} from '../CoordinatorAgentStatus.js' -import { - getLastAssistantMessageId, - StatusLine, - statusLineShouldDisplay, -} from '../StatusLine.js' -import { Notifications } from './Notifications.js' -import { PromptInputFooterLeftSide } from './PromptInputFooterLeftSide.js' +import { feature } from 'bun:bundle'; +import * as React from 'react'; +import { memo, type ReactNode, useCallback, useMemo, useRef, useState } from 'react'; +import { isBridgeEnabled } from '../../bridge/bridgeEnabled.js'; +import { getBridgeStatus } from '../../bridge/bridgeStatusUtil.js'; +import { useSetPromptOverlay } from '../../context/promptOverlayContext.js'; +import type { VerificationStatus } from '../../hooks/useApiKeyVerification.js'; +import type { IDESelection } from '../../hooks/useIdeSelection.js'; +import { useSettings } from '../../hooks/useSettings.js'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import { Box, Text, useInput } from '@anthropic/ink'; +import type { MCPServerConnection } from '../../services/mcp/types.js'; +import { useRegisterOverlay } from '../../context/overlayContext.js'; +import { useAppState, useSetAppState } from '../../state/AppState.js'; +import type { ToolPermissionContext } from '../../Tool.js'; +import type { Message } from '../../types/message.js'; +import type { PromptInputMode, VimMode } from '../../types/textInputTypes.js'; +import type { AutoUpdaterResult } from '../../utils/autoUpdater.js'; +import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'; +import { getPipeDisplayRole, isPipeControlled } from '../../utils/pipeTransport.js'; +import { isUndercover } from '../../utils/undercover.js'; +import { CoordinatorTaskPanel, useCoordinatorTaskCount } from '../CoordinatorAgentStatus.js'; +import { getLastAssistantMessageId, StatusLine, statusLineShouldDisplay } from '../StatusLine.js'; +import { Notifications } from './Notifications.js'; +import { PromptInputFooterLeftSide } from './PromptInputFooterLeftSide.js'; // Inline pipe status is shown only after /pipes sets pipeIpc.statusVisible. -import { PromptInputFooterSuggestions, type SuggestionItem } from './PromptInputFooterSuggestions.js' -import { PromptInputHelpMenu } from './PromptInputHelpMenu.js' +import { PromptInputFooterSuggestions, type SuggestionItem } from './PromptInputFooterSuggestions.js'; +import { PromptInputHelpMenu } from './PromptInputHelpMenu.js'; type Props = { apiKeyStatus: VerificationStatus; diff --git a/src/components/PromptInput/PromptInputFooterLeftSide.tsx b/src/components/PromptInput/PromptInputFooterLeftSide.tsx index 8130c8ef1..892c182d5 100644 --- a/src/components/PromptInput/PromptInputFooterLeftSide.tsx +++ b/src/components/PromptInput/PromptInputFooterLeftSide.tsx @@ -1,127 +1,110 @@ // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered -import { feature } from 'bun:bundle' +import { feature } from 'bun:bundle'; // Dead code elimination: conditional import for COORDINATOR_MODE /* eslint-disable @typescript-eslint/no-require-imports */ const coordinatorModule = feature('COORDINATOR_MODE') ? (require('../../coordinator/coordinatorMode.js') as typeof import('../../coordinator/coordinatorMode.js')) - : undefined + : undefined; /* eslint-enable @typescript-eslint/no-require-imports */ -import { Box, Text, Link } from '@anthropic/ink' -import * as React from 'react' -import figures from 'figures' -import { - useEffect, - useMemo, - useRef, - useState, - useSyncExternalStore, -} from 'react' -import type { VimMode, PromptInputMode } from '../../types/textInputTypes.js' -import type { ToolPermissionContext } from '../../Tool.js' -import { isVimModeEnabled } from './utils.js' -import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js' +import { Box, Text, Link } from '@anthropic/ink'; +import * as React from 'react'; +import figures from 'figures'; +import { useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import type { VimMode, PromptInputMode } from '../../types/textInputTypes.js'; +import type { ToolPermissionContext } from '../../Tool.js'; +import { isVimModeEnabled } from './utils.js'; +import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js'; import { isDefaultMode, permissionModeSymbol, permissionModeTitle, getModeColor, -} from '../../utils/permissions/PermissionMode.js' -import { BackgroundTaskStatus } from '../tasks/BackgroundTaskStatus.js' -import { isBackgroundTask } from '../../tasks/types.js' -import { isPanelAgentTask } from '../../tasks/LocalAgentTask/LocalAgentTask.js' -import { getVisibleAgentTasks } from '../CoordinatorAgentStatus.js' -import { count } from '../../utils/array.js' -import { shouldHideTasksFooter } from '../tasks/taskStatusUtils.js' -import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' -import { TeamStatus } from '../teams/TeamStatus.js' -import { isInProcessEnabled } from '../../utils/swarm/backends/registry.js' -import { useAppState, useAppStateStore } from 'src/state/AppState.js' -import { getIsRemoteMode } from '../../bootstrap/state.js' -import HistorySearchInput from './HistorySearchInput.js' -import { usePrStatus } from '../../hooks/usePrStatus.js' -import { Byline, KeyboardShortcutHint } from '@anthropic/ink' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' -import { useTasksV2 } from '../../hooks/useTasksV2.js' -import { formatDuration } from '../../utils/format.js' -import { VoiceWarmupHint } from './VoiceIndicator.js' -import { useVoiceEnabled } from '../../hooks/useVoiceEnabled.js' -import { useVoiceState } from '../../context/voice.js' -import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js' -import { isXtermJs, useHasSelection, useSelection } from '@anthropic/ink' -import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js' -import { getPlatform } from '../../utils/platform.js' -import { PrBadge } from '../PrBadge.js' +} from '../../utils/permissions/PermissionMode.js'; +import { BackgroundTaskStatus } from '../tasks/BackgroundTaskStatus.js'; +import { isBackgroundTask } from '../../tasks/types.js'; +import { isPanelAgentTask } from '../../tasks/LocalAgentTask/LocalAgentTask.js'; +import { getVisibleAgentTasks } from '../CoordinatorAgentStatus.js'; +import { count } from '../../utils/array.js'; +import { shouldHideTasksFooter } from '../tasks/taskStatusUtils.js'; +import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'; +import { TeamStatus } from '../teams/TeamStatus.js'; +import { isInProcessEnabled } from '../../utils/swarm/backends/registry.js'; +import { useAppState, useAppStateStore } from 'src/state/AppState.js'; +import { getIsRemoteMode } from '../../bootstrap/state.js'; +import HistorySearchInput from './HistorySearchInput.js'; +import { usePrStatus } from '../../hooks/usePrStatus.js'; +import { Byline, KeyboardShortcutHint } from '@anthropic/ink'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import { useTasksV2 } from '../../hooks/useTasksV2.js'; +import { formatDuration } from '../../utils/format.js'; +import { VoiceWarmupHint } from './VoiceIndicator.js'; +import { useVoiceEnabled } from '../../hooks/useVoiceEnabled.js'; +import { useVoiceState } from '../../context/voice.js'; +import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'; +import { isXtermJs, useHasSelection, useSelection } from '@anthropic/ink'; +import { getGlobalConfig, saveGlobalConfig } from '../../utils/config.js'; +import { getPlatform } from '../../utils/platform.js'; +import { PrBadge } from '../PrBadge.js'; // Dead code elimination: conditional import for proactive mode /* eslint-disable @typescript-eslint/no-require-imports */ -const proactiveModule = - feature('PROACTIVE') || feature('KAIROS') - ? require('../../proactive/index.js') - : null +const proactiveModule = feature('PROACTIVE') || feature('KAIROS') ? require('../../proactive/index.js') : null; /* eslint-enable @typescript-eslint/no-require-imports */ -const NO_OP_SUBSCRIBE = (_cb: () => void) => () => {} -const NULL = () => null -const MAX_VOICE_HINT_SHOWS = 3 +const NO_OP_SUBSCRIBE = (_cb: () => void) => () => {}; +const NULL = () => null; +const MAX_VOICE_HINT_SHOWS = 3; type Props = { exitMessage: { - show: boolean - key?: string - } - vimMode: VimMode | undefined - mode: PromptInputMode - toolPermissionContext: ToolPermissionContext - suppressHint: boolean - isLoading: boolean - showMemoryTypeSelector?: boolean - tasksSelected: boolean - teamsSelected: boolean - tmuxSelected: boolean - teammateFooterIndex?: number - isPasting?: boolean - isSearching: boolean - historyQuery: string - setHistoryQuery: (query: string) => void - historyFailedMatch: boolean - onOpenTasksDialog?: (taskId?: string) => void -} + show: boolean; + key?: string; + }; + vimMode: VimMode | undefined; + mode: PromptInputMode; + toolPermissionContext: ToolPermissionContext; + suppressHint: boolean; + isLoading: boolean; + showMemoryTypeSelector?: boolean; + tasksSelected: boolean; + teamsSelected: boolean; + tmuxSelected: boolean; + teammateFooterIndex?: number; + isPasting?: boolean; + isSearching: boolean; + historyQuery: string; + setHistoryQuery: (query: string) => void; + historyFailedMatch: boolean; + onOpenTasksDialog?: (taskId?: string) => void; +}; function ProactiveCountdown(): React.ReactNode { const nextTickAt = useSyncExternalStore( proactiveModule?.subscribeToProactiveChanges ?? NO_OP_SUBSCRIBE, proactiveModule?.getNextTickAt ?? NULL, NULL, - ) + ); - const [remainingSeconds, setRemainingSeconds] = useState(null) + const [remainingSeconds, setRemainingSeconds] = useState(null); useEffect(() => { if (nextTickAt === null) { - setRemainingSeconds(null) - return + setRemainingSeconds(null); + return; } function update(): void { - const remaining = Math.max( - 0, - Math.ceil((nextTickAt! - Date.now()) / 1000), - ) - setRemainingSeconds(remaining) + const remaining = Math.max(0, Math.ceil((nextTickAt! - Date.now()) / 1000)); + setRemainingSeconds(remaining); } - update() - const interval = setInterval(update, 1000) - return () => clearInterval(interval) - }, [nextTickAt]) + update(); + const interval = setInterval(update, 1000); + return () => clearInterval(interval); + }, [nextTickAt]); - if (remainingSeconds === null) return null + if (remainingSeconds === null) return null; - return ( - - waiting{' '} - {formatDuration(remainingSeconds * 1000, { mostSignificantOnly: true })} - - ) + return waiting {formatDuration(remainingSeconds * 1000, { mostSignificantOnly: true })}; } export function PromptInputFooterLeftSide({ @@ -147,26 +130,22 @@ export function PromptInputFooterLeftSide({ Press {exitMessage.key} again to exit - ) + ); } if (isPasting) { return ( Pasting text… - ) + ); } - const showVim = isVimModeEnabled() && vimMode === 'INSERT' && !isSearching + const showVim = isVimModeEnabled() && vimMode === 'INSERT' && !isSearching; return ( {isSearching && ( - + )} {showVim ? ( @@ -185,20 +164,20 @@ export function PromptInputFooterLeftSide({ onOpenTasksDialog={onOpenTasksDialog} /> - ) + ); } type ModeIndicatorProps = { - mode: PromptInputMode - toolPermissionContext: ToolPermissionContext - showHint: boolean - isLoading: boolean - tasksSelected: boolean - teamsSelected: boolean - tmuxSelected: boolean - teammateFooterIndex?: number - onOpenTasksDialog?: (taskId?: string) => void -} + mode: PromptInputMode; + toolPermissionContext: ToolPermissionContext; + showHint: boolean; + isLoading: boolean; + tasksSelected: boolean; + teamsSelected: boolean; + tmuxSelected: boolean; + teammateFooterIndex?: number; + onOpenTasksDialog?: (taskId?: string) => void; +}; function ModeIndicator({ mode, @@ -211,110 +190,75 @@ function ModeIndicator({ teammateFooterIndex, onOpenTasksDialog, }: ModeIndicatorProps): React.ReactNode { - const { columns } = useTerminalSize() - const modeCycleShortcut = useShortcutDisplay( - 'chat:cycleMode', - 'Chat', - 'shift+tab', - ) - const tasks = useAppState(s => s.tasks) - const teamContext = useAppState(s => s.teamContext) + const { columns } = useTerminalSize(); + const modeCycleShortcut = useShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab'); + const tasks = useAppState(s => s.tasks); + const teamContext = useAppState(s => s.teamContext); // Set once in initialState (main.tsx --remote mode) and never mutated — lazy // init captures the immutable value without a subscription. - const store = useAppStateStore() - const [remoteSessionUrl] = useState(() => store.getState().remoteSessionUrl) - const viewSelectionMode = useAppState(s => s.viewSelectionMode) - const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId) - const expandedView = useAppState(s => s.expandedView) - const showSpinnerTree = expandedView === 'teammates' - const prStatus = usePrStatus(isLoading, isPrStatusEnabled()) - const hasTmuxSession = useAppState( - s => - process.env.USER_TYPE === 'ant' && s.tungstenActiveSession !== undefined, - ) + const store = useAppStateStore(); + const [remoteSessionUrl] = useState(() => store.getState().remoteSessionUrl); + const viewSelectionMode = useAppState(s => s.viewSelectionMode); + const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId); + const expandedView = useAppState(s => s.expandedView); + const showSpinnerTree = expandedView === 'teammates'; + const prStatus = usePrStatus(isLoading, isPrStatusEnabled()); + const hasTmuxSession = useAppState(s => process.env.USER_TYPE === 'ant' && s.tungstenActiveSession !== undefined); const nextTickAt = useSyncExternalStore( proactiveModule?.subscribeToProactiveChanges ?? NO_OP_SUBSCRIBE, proactiveModule?.getNextTickAt ?? NULL, NULL, - ) - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false + ); + const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false; const voiceState = feature('VOICE_MODE') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useVoiceState(s => s.voiceState) - : ('idle' as const) + ? useVoiceState(s => s.voiceState) + : ('idle' as const); const voiceWarmingUp = feature('VOICE_MODE') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useVoiceState(s => s.voiceWarmingUp) - : false - const hasSelection = useHasSelection() - const selGetState = useSelection().getState - const hasNextTick = nextTickAt !== null - const isCoordinator = feature('COORDINATOR_MODE') - ? coordinatorModule?.isCoordinatorMode() === true - : false + ? useVoiceState(s => s.voiceWarmingUp) + : false; + const hasSelection = useHasSelection(); + const selGetState = useSelection().getState; + const hasNextTick = nextTickAt !== null; + const isCoordinator = feature('COORDINATOR_MODE') ? coordinatorModule?.isCoordinatorMode() === true : false; const runningTaskCount = useMemo( () => count( Object.values(tasks), - t => - isBackgroundTask(t) && - !(process.env.USER_TYPE === 'ant' && isPanelAgentTask(t)), + t => isBackgroundTask(t) && !(process.env.USER_TYPE === 'ant' && isPanelAgentTask(t)), ), [tasks], - ) - const tasksV2 = useTasksV2() - const hasTaskItems = tasksV2 !== undefined && tasksV2.length > 0 - const escShortcut = useShortcutDisplay( - 'chat:cancel', - 'Chat', - 'esc', - ).toLowerCase() - const todosShortcut = useShortcutDisplay( - 'app:toggleTodos', - 'Global', - 'ctrl+t', - ) - const killAgentsShortcut = useShortcutDisplay( - 'chat:killAgents', - 'Chat', - 'ctrl+x ctrl+k', - ) + ); + const tasksV2 = useTasksV2(); + const hasTaskItems = tasksV2 !== undefined && tasksV2.length > 0; + const escShortcut = useShortcutDisplay('chat:cancel', 'Chat', 'esc').toLowerCase(); + const todosShortcut = useShortcutDisplay('app:toggleTodos', 'Global', 'ctrl+t'); + const killAgentsShortcut = useShortcutDisplay('chat:killAgents', 'Chat', 'ctrl+x ctrl+k'); const voiceKeyShortcut = feature('VOICE_MODE') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useShortcutDisplay('voice:pushToTalk', 'Chat', 'Space') - : '' + ? useShortcutDisplay('voice:pushToTalk', 'Chat', 'Space') + : ''; // Captured at mount so the hint doesn't flicker mid-session if another // CC instance increments the counter. Incremented once via useEffect the // first time voice is enabled in this session — approximates "hint was // shown" without tracking the exact render-time condition (which depends // on parts/hintParts computed after the early-return hooks boundary). const [voiceHintUnderCap] = feature('VOICE_MODE') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useState( - () => - (getGlobalConfig().voiceFooterHintSeenCount ?? 0) < - MAX_VOICE_HINT_SHOWS, - ) - : [false] - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const voiceHintIncrementedRef = feature('VOICE_MODE') ? useRef(false) : null + ? useState(() => (getGlobalConfig().voiceFooterHintSeenCount ?? 0) < MAX_VOICE_HINT_SHOWS) + : [false]; + const voiceHintIncrementedRef = feature('VOICE_MODE') ? useRef(false) : null; useEffect(() => { if (feature('VOICE_MODE')) { - if (!voiceEnabled || !voiceHintUnderCap) return - if (voiceHintIncrementedRef?.current) return - if (voiceHintIncrementedRef) voiceHintIncrementedRef.current = true - const newCount = (getGlobalConfig().voiceFooterHintSeenCount ?? 0) + 1 + if (!voiceEnabled || !voiceHintUnderCap) return; + if (voiceHintIncrementedRef?.current) return; + if (voiceHintIncrementedRef) voiceHintIncrementedRef.current = true; + const newCount = (getGlobalConfig().voiceFooterHintSeenCount ?? 0) + 1; saveGlobalConfig(prev => { - if ((prev.voiceFooterHintSeenCount ?? 0) >= newCount) return prev - return { ...prev, voiceFooterHintSeenCount: newCount } - }) + if ((prev.voiceFooterHintSeenCount ?? 0) >= newCount) return prev; + return { ...prev, voiceFooterHintSeenCount: newCount }; + }); } - }, [voiceEnabled, voiceHintUnderCap]) - const isKillAgentsConfirmShowing = useAppState( - s => s.notifications.current?.key === 'kill-agents-confirm', - ) + }, [voiceEnabled, voiceHintUnderCap]); + const isKillAgentsConfirmShowing = useAppState(s => s.notifications.current?.key === 'kill-agents-confirm'); // Derive team info from teamContext (no filesystem I/O needed) // Match the same logic as TeamStatus to avoid trailing separator @@ -323,27 +267,21 @@ function ModeIndicator({ isAgentSwarmsEnabled() && !isInProcessEnabled() && teamContext !== undefined && - count(Object.values(teamContext.teammates), t => t.name !== 'team-lead') > 0 + count(Object.values(teamContext.teammates), t => t.name !== 'team-lead') > 0; if (mode === 'bash') { - return ! for bash mode + return ! for bash mode; } - const currentMode = toolPermissionContext?.mode - const hasActiveMode = !isDefaultMode(currentMode) - const viewedTask = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined - const isViewingTeammate = - viewSelectionMode === 'viewing-agent' && - viewedTask?.type === 'in_process_teammate' - const isViewingCompletedTeammate = - isViewingTeammate && viewedTask != null && viewedTask.status !== 'running' - const hasBackgroundTasks = runningTaskCount > 0 || isViewingTeammate + const currentMode = toolPermissionContext?.mode; + const hasActiveMode = !isDefaultMode(currentMode); + const viewedTask = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined; + const isViewingTeammate = viewSelectionMode === 'viewing-agent' && viewedTask?.type === 'in_process_teammate'; + const isViewingCompletedTeammate = isViewingTeammate && viewedTask != null && viewedTask.status !== 'running'; + const hasBackgroundTasks = runningTaskCount > 0 || isViewingTeammate; // Count primary items (permission mode or coordinator mode, background tasks, and teams) - const primaryItemCount = - (isCoordinator || hasActiveMode ? 1 : 0) + - (hasBackgroundTasks ? 1 : 0) + - (hasTeams ? 1 : 0) + const primaryItemCount = (isCoordinator || hasActiveMode ? 1 : 0) + (hasBackgroundTasks ? 1 : 0) + (hasTeams ? 1 : 0); // PR indicator is short (~10 chars) — unlike the old diff indicator the // >=100 threshold was tuned for. Now that auto mode is effectively the @@ -355,19 +293,16 @@ function ModeIndicator({ prStatus.reviewState !== null && prStatus.url !== null && primaryItemCount < 2 && - (primaryItemCount === 0 || columns >= 80) + (primaryItemCount === 0 || columns >= 80); // Hide the shift+tab hint when there are 2 primary items - const shouldShowModeHint = primaryItemCount < 2 + const shouldShowModeHint = primaryItemCount < 2; // Check if we have in-process teammates (showing pills) // In spinner-tree mode, pills are disabled - teammates appear in the spinner tree instead const hasInProcessTeammates = - !showSpinnerTree && - hasBackgroundTasks && - Object.values(tasks).some(t => t.type === 'in_process_teammate') - const hasTeammatePills = - hasInProcessTeammates || (!showSpinnerTree && isViewingTeammate) + !showSpinnerTree && hasBackgroundTasks && Object.values(tasks).some(t => t.type === 'in_process_teammate'); + const hasTeammatePills = hasInProcessTeammates || (!showSpinnerTree && isViewingTeammate); // In remote mode (`claude assistant`, --teleport) the agent runs elsewhere; // the local permission mode shown here doesn't reflect the agent's state. @@ -376,20 +311,15 @@ function ModeIndicator({ const modePart = currentMode && hasActiveMode && !getIsRemoteMode() ? ( - {permissionModeSymbol(currentMode)}{' '} - {permissionModeTitle(currentMode).toLowerCase()} on + {permissionModeSymbol(currentMode)} {permissionModeTitle(currentMode).toLowerCase()} on {shouldShowModeHint && ( {' '} - + )} - ) : null + ) : null; // Build parts array - exclude BackgroundTaskStatus when we have teammate pills // (teammate pills get their own row) @@ -406,37 +336,20 @@ function ModeIndicator({ // its click-target Box isn't nested inside the // wrapper (reconciler throws on Box-in-Text). // Tmux pill (ant-only) — appears right after tasks in nav order - ...(process.env.USER_TYPE === 'ant' && hasTmuxSession - ? [] - : []), + ...(process.env.USER_TYPE === 'ant' && hasTmuxSession ? [] : []), ...(isAgentSwarmsEnabled() && hasTeams - ? [ - , - ] + ? [] : []), ...(shouldShowPrStatus - ? [ - , - ] + ? [] : []), - ] + ]; // Check if any in-process teammates exist (for hint text cycling) const hasAnyInProcessTeammates = Object.values(tasks).some( t => t.type === 'in_process_teammate' && t.status === 'running', - ) - const hasRunningAgentTasks = Object.values(tasks).some( - t => t.type === 'local_agent' && t.status === 'running', - ) + ); + const hasRunningAgentTasks = Object.values(tasks).some(t => t.type === 'local_agent' && t.status === 'running'); // Get hint parts separately for potential second-line rendering const hintParts = showHint @@ -451,32 +364,25 @@ function ModeIndicator({ hasRunningAgentTasks, isKillAgentsConfirmShowing, ) - : [] + : []; if (isViewingCompletedTeammate) { parts.push( - + , - ) + ); } else if ((feature('PROACTIVE') || feature('KAIROS')) && hasNextTick) { - parts.push() + parts.push(); } else if (!hasTeammatePills && showHint) { - parts.push(...hintParts) + parts.push(...hintParts); } // When we have teammate pills, always render them on their own line above other parts if (hasTeammatePills) { // Don't append spinner hints when viewing a completed teammate — // the "esc to return to team lead" hint already replaces "esc to interrupt" - const otherParts = [ - ...(modePart ? [modePart] : []), - ...parts, - ...(isViewingCompletedTeammate ? [] : hintParts), - ] + const otherParts = [...(modePart ? [modePart] : []), ...parts, ...(isViewingCompletedTeammate ? [] : hintParts)]; return ( @@ -494,21 +400,18 @@ function ModeIndicator({ )} - ) + ); } // Add "↓ to manage tasks" hint when panel has visible rows - const hasCoordinatorTasks = - process.env.USER_TYPE === 'ant' && getVisibleAgentTasks(tasks).length > 0 + const hasCoordinatorTasks = process.env.USER_TYPE === 'ant' && getVisibleAgentTasks(tasks).length > 0; // Tasks pill renders as a Box sibling (not a parts entry) so its // click-target Box isn't nested inside — the // reconciler throws on Box-in-Text. Computed here so the empty-checks // below still treat "pill present" as non-empty. const tasksPart = - hasBackgroundTasks && - !hasTeammatePills && - !shouldHideTasksFooter(tasks, showSpinnerTree) ? ( + hasBackgroundTasks && !hasTeammatePills && !shouldHideTasksFooter(tasks, showSpinnerTree) ? ( - ) : null + ) : null; if (parts.length === 0 && !tasksPart && !modePart && showHint) { parts.push( ? for shortcuts , - ) + ); } // Only replace the idle voice hint when there's something to say — otherwise // fall through instead of showing an empty Byline. "esc to clear" was removed // (looked like "esc to interrupt" when idle; esc-clears-selection is standard // UX) leaving only ctrl+c (copyOnSelect off) and the xterm.js native-select hint. - const copyOnSelect = getGlobalConfig().copyOnSelect ?? true - const selectionHintHasContent = hasSelection && (!copyOnSelect || isXtermJs()) + const copyOnSelect = getGlobalConfig().copyOnSelect ?? true; + const selectionHintHasContent = hasSelection && (!copyOnSelect || isXtermJs()); // Warmup hint takes priority — when the user is actively holding // the activation key, show feedback regardless of other hints. if (feature('VOICE_MODE') && voiceEnabled && voiceWarmingUp) { - parts.push() + parts.push(); } else if (isFullscreenEnvEnabled() && selectionHintHasContent) { // xterm.js (VS Code/Cursor/Windsurf) force-selection modifier is // platform-specific and gated on macOS (SelectionService.shouldForceSelection): @@ -548,26 +451,21 @@ function ModeIndicator({ // option+click hint they just tried. // Non-reactive getState() read is safe: lastPressHadAlt is immutable // while hasSelection is true (set pre-drag, cleared with selection). - const isMac = getPlatform() === 'macos' - const altClickFailed = isMac && (selGetState()?.lastPressHadAlt ?? false) + const isMac = getPlatform() === 'macos'; + const altClickFailed = isMac && (selGetState()?.lastPressHadAlt ?? false); parts.push( - {!copyOnSelect && ( - - )} + {!copyOnSelect && } {isXtermJs() && (altClickFailed ? ( set macOptionClickForcesSelection in VS Code settings ) : ( - + ))} , - ) + ); } else if ( feature('VOICE_MODE') && parts.length > 0 && @@ -581,7 +479,7 @@ function ModeIndicator({ hold {voiceKeyShortcut} to speak , - ) + ); } if ((tasksPart || hasCoordinatorTasks) && showHint && !hasTeams) { @@ -593,7 +491,7 @@ function ModeIndicator({ )} , - ) + ); } // In fullscreen the bottom section is flexShrink:0 — every row here @@ -605,7 +503,7 @@ function ModeIndicator({ // from 0→1 row. Always render 1 row in fullscreen; return a space when // empty so Yoga reserves the row without painting anything visible. if (parts.length === 0 && !tasksPart && !modePart) { - return isFullscreenEnvEnabled() ? : null + return isFullscreenEnvEnabled() ? : null; } // flexShrink=0 keeps mode + pill at natural width; the remaining parts @@ -630,7 +528,7 @@ function ModeIndicator({ )} - ) + ); } function getSpinnerHintParts( @@ -644,27 +542,27 @@ function getSpinnerHintParts( hasRunningAgentTasks: boolean, isKillAgentsConfirmShowing: boolean, ): React.ReactElement[] { - let toggleAction: string + let toggleAction: string; if (hasTeammates) { // Cycling: none → tasks → teammates → none switch (expandedView) { case 'none': - toggleAction = 'show tasks' - break + toggleAction = 'show tasks'; + break; case 'tasks': - toggleAction = 'show teammates' - break + toggleAction = 'show teammates'; + break; case 'teammates': - toggleAction = 'hide' - break + toggleAction = 'hide'; + break; } } else { - toggleAction = expandedView === 'tasks' ? 'hide tasks' : 'show tasks' + toggleAction = expandedView === 'tasks' ? 'hide tasks' : 'show tasks'; } // Show the toggle hint only when there are task items to display or // teammates to cycle to - const showToggleHint = hasTaskItems || hasTeammates + const showToggleHint = hasTaskItems || hasTeammates; return [ ...(isLoading @@ -677,26 +575,20 @@ function getSpinnerHintParts( ...(!isLoading && hasRunningAgentTasks && !isKillAgentsConfirmShowing ? [ - + , ] : []), ...(showToggleHint ? [ - + , ] : []), - ] + ]; } function isPrStatusEnabled(): boolean { - return getGlobalConfig().prStatusFooterEnabled ?? true + return getGlobalConfig().prStatusFooterEnabled ?? true; } diff --git a/src/components/PromptInput/PromptInputFooterSuggestions.tsx b/src/components/PromptInput/PromptInputFooterSuggestions.tsx index e6b2065b6..e45167a8c 100644 --- a/src/components/PromptInput/PromptInputFooterSuggestions.tsx +++ b/src/components/PromptInput/PromptInputFooterSuggestions.tsx @@ -1,18 +1,18 @@ -import * as React from 'react' -import { memo, type ReactNode } from 'react' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' -import { Box, Text, stringWidth } from '@anthropic/ink' -import { truncatePathMiddle, truncateToWidth } from '../../utils/format.js' -import type { Theme } from '../../utils/theme.js' +import * as React from 'react'; +import { memo, type ReactNode } from 'react'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import { Box, Text, stringWidth } from '@anthropic/ink'; +import { truncatePathMiddle, truncateToWidth } from '../../utils/format.js'; +import type { Theme } from '../../utils/theme.js'; export type SuggestionItem = { - id: string - displayText: string - tag?: string - description?: string - metadata?: unknown - color?: keyof Theme -} + id: string; + displayText: string; + tag?: string; + description?: string; + metadata?: unknown; + color?: keyof Theme; +}; export type SuggestionType = | 'command' @@ -22,30 +22,26 @@ export type SuggestionType = | 'shell' | 'custom-title' | 'slack-channel' - | 'none' + | 'none'; -export const OVERLAY_MAX_ITEMS = 5 +export const OVERLAY_MAX_ITEMS = 5; /** * Get the icon for a suggestion based on its type * Icons: + for files, ◇ for MCP resources, * for agents */ function getIcon(itemId: string): string { - if (itemId.startsWith('file-')) return '+' - if (itemId.startsWith('mcp-resource-')) return '◇' - if (itemId.startsWith('agent-')) return '*' - return '+' + if (itemId.startsWith('file-')) return '+'; + if (itemId.startsWith('mcp-resource-')) return '◇'; + if (itemId.startsWith('agent-')) return '*'; + return '+'; } /** * Check if an item is a unified suggestion type (file, mcp-resource, or agent) */ function isUnifiedSuggestion(itemId: string): boolean { - return ( - itemId.startsWith('file-') || - itemId.startsWith('mcp-resource-') || - itemId.startsWith('agent-') - ) + return itemId.startsWith('file-') || itemId.startsWith('mcp-resource-') || itemId.startsWith('agent-'); } const SuggestionItemRow = memo(function SuggestionItemRow({ @@ -53,109 +49,88 @@ const SuggestionItemRow = memo(function SuggestionItemRow({ maxColumnWidth, isSelected, }: { - item: SuggestionItem - maxColumnWidth?: number - isSelected: boolean + item: SuggestionItem; + maxColumnWidth?: number; + isSelected: boolean; }): ReactNode { - const columns = useTerminalSize().columns - const isUnified = isUnifiedSuggestion(item.id) + const columns = useTerminalSize().columns; + const isUnified = isUnifiedSuggestion(item.id); // For unified suggestions (file, mcp-resource, agent), use single-line layout with icon if (isUnified) { - const icon = getIcon(item.id) - const textColor: keyof Theme | undefined = isSelected - ? 'suggestion' - : undefined - const dimColor = !isSelected + const icon = getIcon(item.id); + const textColor: keyof Theme | undefined = isSelected ? 'suggestion' : undefined; + const dimColor = !isSelected; - const isFile = item.id.startsWith('file-') - const isMcpResource = item.id.startsWith('mcp-resource-') + const isFile = item.id.startsWith('file-'); + const isMcpResource = item.id.startsWith('mcp-resource-'); // Calculate layout widths // Layout: "X " (2) + displayText + " – " (3) + description + padding (4) - const iconWidth = 2 // icon + space (fixed) - const paddingWidth = 4 - const separatorWidth = item.description ? 3 : 0 // ' – ' separator + const iconWidth = 2; // icon + space (fixed) + const paddingWidth = 4; + const separatorWidth = item.description ? 3 : 0; // ' – ' separator // For files, truncate middle of path to show both directory context and filename // For MCP resources, limit displayText to 30 chars (truncate from end) // For agents, no truncation - let displayText: string + let displayText: string; if (isFile) { // Reserve space for description if present, otherwise use all available space - const descReserve = item.description - ? Math.min(20, stringWidth(item.description)) - : 0 - const maxPathLength = - columns - iconWidth - paddingWidth - separatorWidth - descReserve - displayText = truncatePathMiddle(item.displayText, maxPathLength) + const descReserve = item.description ? Math.min(20, stringWidth(item.description)) : 0; + const maxPathLength = columns - iconWidth - paddingWidth - separatorWidth - descReserve; + displayText = truncatePathMiddle(item.displayText, maxPathLength); } else if (isMcpResource) { - const maxDisplayTextLength = 30 - displayText = truncateToWidth(item.displayText, maxDisplayTextLength) + const maxDisplayTextLength = 30; + displayText = truncateToWidth(item.displayText, maxDisplayTextLength); } else { - displayText = item.displayText + displayText = item.displayText; } - const availableWidth = - columns - - iconWidth - - stringWidth(displayText) - - separatorWidth - - paddingWidth + const availableWidth = columns - iconWidth - stringWidth(displayText) - separatorWidth - paddingWidth; // Build the full line as a single string to prevent wrapping - let lineContent: string + let lineContent: string; if (item.description) { - const maxDescLength = Math.max(0, availableWidth) - const truncatedDesc = truncateToWidth( - item.description.replace(/\s+/g, ' '), - maxDescLength, - ) - lineContent = `${icon} ${displayText} – ${truncatedDesc}` + const maxDescLength = Math.max(0, availableWidth); + const truncatedDesc = truncateToWidth(item.description.replace(/\s+/g, ' '), maxDescLength); + lineContent = `${icon} ${displayText} – ${truncatedDesc}`; } else { - lineContent = `${icon} ${displayText}` + lineContent = `${icon} ${displayText}`; } return ( {lineContent} - ) + ); } // For non-unified suggestions (commands, shell, etc.), use improved layout from main // Cap the command name column at 40% of terminal width to ensure description has space - const maxNameWidth = Math.floor(columns * 0.4) - const displayTextWidth = Math.min( - maxColumnWidth ?? stringWidth(item.displayText) + 5, - maxNameWidth, - ) + const maxNameWidth = Math.floor(columns * 0.4); + const displayTextWidth = Math.min(maxColumnWidth ?? stringWidth(item.displayText) + 5, maxNameWidth); - const textColor = item.color || (isSelected ? 'suggestion' : undefined) - const shouldDim = !isSelected + const textColor = item.color || (isSelected ? 'suggestion' : undefined); + const shouldDim = !isSelected; // Truncate and pad the display text to fixed width - let displayText = item.displayText + let displayText = item.displayText; if (stringWidth(displayText) > displayTextWidth - 2) { - displayText = truncateToWidth(displayText, displayTextWidth - 2) + displayText = truncateToWidth(displayText, displayTextWidth - 2); } - const paddedDisplayText = - displayText + - ' '.repeat(Math.max(0, displayTextWidth - stringWidth(displayText))) + const paddedDisplayText = displayText + ' '.repeat(Math.max(0, displayTextWidth - stringWidth(displayText))); - const tagText = item.tag ? `[${item.tag}] ` : '' - const tagWidth = stringWidth(tagText) - const descriptionWidth = Math.max( - 0, - columns - displayTextWidth - tagWidth - 4, - ) + const tagText = item.tag ? `[${item.tag}] ` : ''; + const tagWidth = stringWidth(tagText); + const descriptionWidth = Math.max(0, columns - displayTextWidth - tagWidth - 4); // Skill descriptions can contain newlines (e.g. /claude-api's "TRIGGER // when:" block). A multi-line row grows the overlay past minHeight; when // the filter narrows past that skill, the overlay shrinks and leaves // ghost rows. Flatten to one line before truncating. const truncatedDescription = item.description ? truncateToWidth(item.description.replace(/\s+/g, ' '), descriptionWidth) - : '' + : ''; return ( @@ -167,27 +142,24 @@ const SuggestionItemRow = memo(function SuggestionItemRow({ {tagText} ) : null} - + {truncatedDescription} - ) -}) + ); +}); type Props = { - suggestions: SuggestionItem[] - selectedSuggestion: number - maxColumnWidth?: number + suggestions: SuggestionItem[]; + selectedSuggestion: number; + maxColumnWidth?: number; /** * When true, the suggestions are rendered inside a position=absolute * overlay. We omit minHeight and flex-end so the y-clamp in the * renderer doesn't push fewer items down into the prompt area. */ - overlay?: boolean -} + overlay?: boolean; +}; export function PromptInputFooterSuggestions({ suggestions, @@ -195,34 +167,27 @@ export function PromptInputFooterSuggestions({ maxColumnWidth: maxColumnWidthProp, overlay, }: Props): ReactNode { - const { rows } = useTerminalSize() + const { rows } = useTerminalSize(); // Maximum number of suggestions to show at once (leaving space for prompt). // Overlay mode (fullscreen) uses a fixed 5 — the floating box sits over // the ScrollBox, so terminal height isn't the constraint. - const maxVisibleItems = overlay - ? OVERLAY_MAX_ITEMS - : Math.min(6, Math.max(1, rows - 3)) + const maxVisibleItems = overlay ? OVERLAY_MAX_ITEMS : Math.min(6, Math.max(1, rows - 3)); // No suggestions to display if (suggestions.length === 0) { - return null + return null; } // Use prop if provided (stable width from all commands), otherwise calculate from visible - const maxColumnWidth = - maxColumnWidthProp ?? - Math.max(...suggestions.map(item => stringWidth(item.displayText))) + 5 + const maxColumnWidth = maxColumnWidthProp ?? Math.max(...suggestions.map(item => stringWidth(item.displayText))) + 5; // Calculate visible items range based on selected index const startIndex = Math.max( 0, - Math.min( - selectedSuggestion - Math.floor(maxVisibleItems / 2), - suggestions.length - maxVisibleItems, - ), - ) - const endIndex = Math.min(startIndex + maxVisibleItems, suggestions.length) - const visibleItems = suggestions.slice(startIndex, endIndex) + Math.min(selectedSuggestion - Math.floor(maxVisibleItems / 2), suggestions.length - maxVisibleItems), + ); + const endIndex = Math.min(startIndex + maxVisibleItems, suggestions.length); + const visibleItems = suggestions.slice(startIndex, endIndex); // In non-overlay (inline) mode, justifyContent keeps suggestions // anchored to the bottom (near the prompt). In overlay mode we omit @@ -232,10 +197,7 @@ export function PromptInputFooterSuggestions({ // padding rows that shift the visible items down into the prompt area // when the list has fewer items than maxVisibleItems. return ( - + {visibleItems.map(item => ( ))} - ) + ); } -export default memo(PromptInputFooterSuggestions) +export default memo(PromptInputFooterSuggestions); diff --git a/src/components/PromptInput/PromptInputHelpMenu.tsx b/src/components/PromptInput/PromptInputHelpMenu.tsx index 88aa5dfc2..97d2a1b0e 100644 --- a/src/components/PromptInput/PromptInputHelpMenu.tsx +++ b/src/components/PromptInput/PromptInputHelpMenu.tsx @@ -1,59 +1,39 @@ -import { feature } from 'bun:bundle' -import * as React from 'react' -import { Box, Text } from '@anthropic/ink' -import { getPlatform } from 'src/utils/platform.js' -import { isKeybindingCustomizationEnabled } from '../../keybindings/loadUserBindings.js' -import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js' -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' -import { isFastModeAvailable, isFastModeEnabled } from '../../utils/fastMode.js' -import { getNewlineInstructions } from './utils.js' +import { feature } from 'bun:bundle'; +import * as React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { getPlatform } from 'src/utils/platform.js'; +import { isKeybindingCustomizationEnabled } from '../../keybindings/loadUserBindings.js'; +import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js'; +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'; +import { isFastModeAvailable, isFastModeEnabled } from '../../utils/fastMode.js'; +import { getNewlineInstructions } from './utils.js'; /** Format a shortcut for display in the help menu (e.g., "ctrl+o" → "ctrl + o") */ function formatShortcut(shortcut: string): string { - return shortcut.replace(/\+/g, ' + ') + return shortcut.replace(/\+/g, ' + '); } type Props = { - dimColor?: boolean - fixedWidth?: boolean - gap?: number - paddingX?: number -} + dimColor?: boolean; + fixedWidth?: boolean; + gap?: number; + paddingX?: number; +}; export function PromptInputHelpMenu(props: Props): React.ReactNode { - const { dimColor, fixedWidth, gap, paddingX } = props + const { dimColor, fixedWidth, gap, paddingX } = props; // Get configured shortcuts from keybinding system - const transcriptShortcut = formatShortcut( - useShortcutDisplay('app:toggleTranscript', 'Global', 'ctrl+o'), - ) - const todosShortcut = formatShortcut( - useShortcutDisplay('app:toggleTodos', 'Global', 'ctrl+t'), - ) - const undoShortcut = formatShortcut( - useShortcutDisplay('chat:undo', 'Chat', 'ctrl+_'), - ) - const stashShortcut = formatShortcut( - useShortcutDisplay('chat:stash', 'Chat', 'ctrl+s'), - ) - const cycleModeShortcut = formatShortcut( - useShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab'), - ) - const modelPickerShortcut = formatShortcut( - useShortcutDisplay('chat:modelPicker', 'Chat', 'alt+p'), - ) - const fastModeShortcut = formatShortcut( - useShortcutDisplay('chat:fastMode', 'Chat', 'alt+o'), - ) - const externalEditorShortcut = formatShortcut( - useShortcutDisplay('chat:externalEditor', 'Chat', 'ctrl+g'), - ) - const terminalShortcut = formatShortcut( - useShortcutDisplay('app:toggleTerminal', 'Global', 'meta+j'), - ) - const imagePasteShortcut = formatShortcut( - useShortcutDisplay('chat:imagePaste', 'Chat', 'ctrl+v'), - ) + const transcriptShortcut = formatShortcut(useShortcutDisplay('app:toggleTranscript', 'Global', 'ctrl+o')); + const todosShortcut = formatShortcut(useShortcutDisplay('app:toggleTodos', 'Global', 'ctrl+t')); + const undoShortcut = formatShortcut(useShortcutDisplay('chat:undo', 'Chat', 'ctrl+_')); + const stashShortcut = formatShortcut(useShortcutDisplay('chat:stash', 'Chat', 'ctrl+s')); + const cycleModeShortcut = formatShortcut(useShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab')); + const modelPickerShortcut = formatShortcut(useShortcutDisplay('chat:modelPicker', 'Chat', 'alt+p')); + const fastModeShortcut = formatShortcut(useShortcutDisplay('chat:fastMode', 'Chat', 'alt+o')); + const externalEditorShortcut = formatShortcut(useShortcutDisplay('chat:externalEditor', 'Chat', 'ctrl+g')); + const terminalShortcut = formatShortcut(useShortcutDisplay('app:toggleTerminal', 'Global', 'meta+j')); + const imagePasteShortcut = formatShortcut(useShortcutDisplay('chat:imagePaste', 'Chat', 'ctrl+v')); // Compute terminal shortcut element outside JSX to satisfy feature() constraint const terminalShortcutElement = feature('TERMINAL_PANEL') ? ( @@ -62,7 +42,7 @@ export function PromptInputHelpMenu(props: Props): React.ReactNode { {terminalShortcut} for terminal ) : null - ) : null + ) : null; return ( @@ -89,16 +69,11 @@ export function PromptInputHelpMenu(props: Props): React.ReactNode { - {cycleModeShortcut}{' '} - {process.env.USER_TYPE === 'ant' - ? 'to cycle modes' - : 'to auto-accept edits'} + {cycleModeShortcut} {process.env.USER_TYPE === 'ant' ? 'to cycle modes' : 'to auto-accept edits'} - - {transcriptShortcut} for verbose output - + {transcriptShortcut} for verbose output {todosShortcut} to toggle tasks @@ -125,18 +100,14 @@ export function PromptInputHelpMenu(props: Props): React.ReactNode { {isFastModeEnabled() && isFastModeAvailable() && ( - - {fastModeShortcut} to toggle fast mode - + {fastModeShortcut} to toggle fast mode )} {stashShortcut} to stash prompt - - {externalEditorShortcut} to edit in $EDITOR - + {externalEditorShortcut} to edit in $EDITOR {isKeybindingCustomizationEnabled() && ( @@ -145,5 +116,5 @@ export function PromptInputHelpMenu(props: Props): React.ReactNode { )} - ) + ); } diff --git a/src/components/PromptInput/PromptInputModeIndicator.tsx b/src/components/PromptInput/PromptInputModeIndicator.tsx index b52828ba6..5b4d00167 100644 --- a/src/components/PromptInput/PromptInputModeIndicator.tsx +++ b/src/components/PromptInput/PromptInputModeIndicator.tsx @@ -1,22 +1,22 @@ -import figures from 'figures' -import * as React from 'react' -import { Box, Text } from '@anthropic/ink' +import figures from 'figures'; +import * as React from 'react'; +import { Box, Text } from '@anthropic/ink'; import { AGENT_COLOR_TO_THEME_COLOR, AGENT_COLORS, type AgentColorName, -} from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js' -import type { PromptInputMode } from 'src/types/textInputTypes.js' -import { getTeammateColor } from 'src/utils/teammate.js' -import type { Theme } from 'src/utils/theme.js' -import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' +} from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js'; +import type { PromptInputMode } from 'src/types/textInputTypes.js'; +import { getTeammateColor } from 'src/utils/teammate.js'; +import type { Theme } from 'src/utils/theme.js'; +import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'; type Props = { - mode: PromptInputMode - isLoading: boolean - viewingAgentName?: string - viewingAgentColor?: AgentColorName -} + mode: PromptInputMode; + isLoading: boolean; + viewingAgentName?: string; + viewingAgentColor?: AgentColorName; +}; /** * Gets the theme color key for the teammate's assigned color. @@ -24,42 +24,39 @@ type Props = { */ function getTeammateThemeColor(): keyof Theme | undefined { if (!isAgentSwarmsEnabled()) { - return undefined + return undefined; } - const colorName = getTeammateColor() + const colorName = getTeammateColor(); if (!colorName) { - return undefined + return undefined; } if (AGENT_COLORS.includes(colorName as AgentColorName)) { - return AGENT_COLOR_TO_THEME_COLOR[colorName as AgentColorName] + return AGENT_COLOR_TO_THEME_COLOR[colorName as AgentColorName]; } - return undefined + return undefined; } type PromptCharProps = { - isLoading: boolean + isLoading: boolean; // Dead code elimination: parameter named themeColor to avoid "teammate" string in external builds - themeColor?: keyof Theme -} + themeColor?: keyof Theme; +}; /** * Renders the prompt character (❯). * Teammate color overrides the default color when set. */ -function PromptChar({ - isLoading, - themeColor, -}: PromptCharProps): React.ReactNode { +function PromptChar({ isLoading, themeColor }: PromptCharProps): React.ReactNode { // Assign to original name for clarity within the function - const teammateColor = themeColor - const isAnt = process.env.USER_TYPE === 'ant' - const color = teammateColor ?? (isAnt ? 'subtle' : undefined) + const teammateColor = themeColor; + const isAnt = process.env.USER_TYPE === 'ant'; + const color = teammateColor ?? (isAnt ? 'subtle' : undefined); return ( {figures.pointer}  - ) + ); } export function PromptInputModeIndicator({ @@ -68,37 +65,24 @@ export function PromptInputModeIndicator({ viewingAgentName, viewingAgentColor, }: Props): React.ReactNode { - const teammateColor = getTeammateThemeColor() + const teammateColor = getTeammateThemeColor(); // Convert viewed teammate's color to theme color // Falls back to PromptChar's default (subtle for ants, undefined for external) - const viewedTeammateThemeColor = viewingAgentColor - ? AGENT_COLOR_TO_THEME_COLOR[viewingAgentColor] - : undefined + const viewedTeammateThemeColor = viewingAgentColor ? AGENT_COLOR_TO_THEME_COLOR[viewingAgentColor] : undefined; return ( - + {viewingAgentName ? ( // Use teammate's color on the standard prompt character, matching established style - + ) : mode === 'bash' ? ( ) : ( - + )} - ) + ); } diff --git a/src/components/PromptInput/PromptInputQueuedCommands.tsx b/src/components/PromptInput/PromptInputQueuedCommands.tsx index e96ba3532..be2a9c58a 100644 --- a/src/components/PromptInput/PromptInputQueuedCommands.tsx +++ b/src/components/PromptInput/PromptInputQueuedCommands.tsx @@ -1,26 +1,18 @@ -import { feature } from 'bun:bundle' -import * as React from 'react' -import { useMemo } from 'react' -import { Box } from '@anthropic/ink' -import { useAppState } from 'src/state/AppState.js' -import { - STATUS_TAG, - SUMMARY_TAG, - TASK_NOTIFICATION_TAG, -} from '../../constants/xml.js' -import { QueuedMessageProvider } from '../../context/QueuedMessageContext.js' -import { useCommandQueue } from '../../hooks/useCommandQueue.js' -import type { QueuedCommand } from '../../types/textInputTypes.js' -import { isQueuedCommandVisible } from '../../utils/messageQueueManager.js' -import { - createUserMessage, - EMPTY_LOOKUPS, - normalizeMessages, -} from '../../utils/messages.js' -import { jsonParse } from '../../utils/slowOperations.js' -import { Message } from '../Message.js' +import { feature } from 'bun:bundle'; +import * as React from 'react'; +import { useMemo } from 'react'; +import { Box } from '@anthropic/ink'; +import { useAppState } from 'src/state/AppState.js'; +import { STATUS_TAG, SUMMARY_TAG, TASK_NOTIFICATION_TAG } from '../../constants/xml.js'; +import { QueuedMessageProvider } from '../../context/QueuedMessageContext.js'; +import { useCommandQueue } from '../../hooks/useCommandQueue.js'; +import type { QueuedCommand } from '../../types/textInputTypes.js'; +import { isQueuedCommandVisible } from '../../utils/messageQueueManager.js'; +import { createUserMessage, EMPTY_LOOKUPS, normalizeMessages } from '../../utils/messages.js'; +import { jsonParse } from '../../utils/slowOperations.js'; +import { Message } from '../Message.js'; -const EMPTY_SET = new Set() +const EMPTY_SET = new Set(); /** * Check if a command value is an idle notification that should be hidden. @@ -28,15 +20,15 @@ const EMPTY_SET = new Set() */ function isIdleNotification(value: string): boolean { try { - const parsed = jsonParse(value) - return parsed?.type === 'idle_notification' + const parsed = jsonParse(value); + return parsed?.type === 'idle_notification'; } catch { - return false + return false; } } // Maximum number of task notification lines to show -const MAX_VISIBLE_NOTIFICATIONS = 3 +const MAX_VISIBLE_NOTIFICATIONS = 3; /** * Create a synthetic overflow notification message for capped task notifications. @@ -45,7 +37,7 @@ function createOverflowNotificationMessage(count: number): string { return `<${TASK_NOTIFICATION_TAG}> <${SUMMARY_TAG}>+${count} more tasks completed <${STATUS_TAG}>completed -` +`; } /** @@ -53,94 +45,79 @@ function createOverflowNotificationMessage(count: number): string { * Other command types are always shown in full. * Idle notifications are filtered out entirely. */ -function processQueuedCommands( - queuedCommands: QueuedCommand[], -): QueuedCommand[] { +function processQueuedCommands(queuedCommands: QueuedCommand[]): QueuedCommand[] { // Filter out idle notifications - they are processed silently const filteredCommands = queuedCommands.filter( cmd => typeof cmd.value !== 'string' || !isIdleNotification(cmd.value), - ) + ); // Separate task notifications from other commands - const taskNotifications = filteredCommands.filter( - cmd => cmd.mode === 'task-notification', - ) - const otherCommands = filteredCommands.filter( - cmd => cmd.mode !== 'task-notification', - ) + const taskNotifications = filteredCommands.filter(cmd => cmd.mode === 'task-notification'); + const otherCommands = filteredCommands.filter(cmd => cmd.mode !== 'task-notification'); // If notifications fit within limit, return all commands as-is if (taskNotifications.length <= MAX_VISIBLE_NOTIFICATIONS) { - return [...otherCommands, ...taskNotifications] + return [...otherCommands, ...taskNotifications]; } // Show first (MAX_VISIBLE_NOTIFICATIONS - 1) notifications, then a summary - const visibleNotifications = taskNotifications.slice( - 0, - MAX_VISIBLE_NOTIFICATIONS - 1, - ) - const overflowCount = - taskNotifications.length - (MAX_VISIBLE_NOTIFICATIONS - 1) + const visibleNotifications = taskNotifications.slice(0, MAX_VISIBLE_NOTIFICATIONS - 1); + const overflowCount = taskNotifications.length - (MAX_VISIBLE_NOTIFICATIONS - 1); // Create synthetic overflow message const overflowCommand: QueuedCommand = { value: createOverflowNotificationMessage(overflowCount), mode: 'task-notification', - } + }; - return [...otherCommands, ...visibleNotifications, overflowCommand] + return [...otherCommands, ...visibleNotifications, overflowCommand]; } function PromptInputQueuedCommandsImpl(): React.ReactNode { - const queuedCommands = useCommandQueue() - const viewingAgent = useAppState(s => !!s.viewingAgentTaskId) + const queuedCommands = useCommandQueue(); + const viewingAgent = useAppState(s => !!s.viewingAgentTaskId); // Brief layout: dim queue items + skip the paddingX (brief messages // already indent themselves). Gate mirrors the brief-spinner/message // check elsewhere — no teammate-view override needed since this // component early-returns when viewing a teammate. const useBriefLayout = feature('KAIROS') || feature('KAIROS_BRIEF') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s => s.isBriefOnly) - : false + ? useAppState(s => s.isBriefOnly) + : false; // createUserMessage mints a fresh UUID per call; without memoization, streaming // re-renders defeat Message's areMessagePropsEqual (compares uuid) → flicker. const messages = useMemo(() => { - if (queuedCommands.length === 0) return null + if (queuedCommands.length === 0) return null; // task-notification is shown via useInboxNotification; most isMeta commands // (scheduled tasks, proactive ticks) are system-generated and hidden. // Channel messages are the exception — isMeta but shown so the keyboard // user sees what arrived. - const visibleCommands = queuedCommands.filter(isQueuedCommandVisible) - if (visibleCommands.length === 0) return null - const processedCommands = processQueuedCommands(visibleCommands) + const visibleCommands = queuedCommands.filter(isQueuedCommandVisible); + if (visibleCommands.length === 0) return null; + const processedCommands = processQueuedCommands(visibleCommands); return normalizeMessages( processedCommands.map(cmd => { - let content = cmd.value + let content = cmd.value; if (cmd.mode === 'bash' && typeof content === 'string') { - content = `${content}` + content = `${content}`; } // [Image #N] placeholders are inline in the text value (inserted at // paste time), so the queue preview shows them without stub blocks. - return createUserMessage({ content }) + return createUserMessage({ content }); }), - ) - }, [queuedCommands]) + ); + }, [queuedCommands]); // Don't show leader's queued commands when viewing any agent's transcript if (viewingAgent || messages === null) { - return null + return null; } return ( {messages.map((message, i) => ( - + ))} - ) + ); } -export const PromptInputQueuedCommands = React.memo( - PromptInputQueuedCommandsImpl, -) +export const PromptInputQueuedCommands = React.memo(PromptInputQueuedCommandsImpl); diff --git a/src/components/PromptInput/PromptInputStashNotice.tsx b/src/components/PromptInput/PromptInputStashNotice.tsx index c33f43190..866426249 100644 --- a/src/components/PromptInput/PromptInputStashNotice.tsx +++ b/src/components/PromptInput/PromptInputStashNotice.tsx @@ -1,21 +1,19 @@ -import figures from 'figures' -import * as React from 'react' -import { Box, Text } from '@anthropic/ink' +import figures from 'figures'; +import * as React from 'react'; +import { Box, Text } from '@anthropic/ink'; type Props = { - hasStash: boolean -} + hasStash: boolean; +}; export function PromptInputStashNotice({ hasStash }: Props): React.ReactNode { if (!hasStash) { - return null + return null; } return ( - - {figures.pointerSmall} Stashed (auto-restores after submit) - + {figures.pointerSmall} Stashed (auto-restores after submit) - ) + ); } diff --git a/src/components/PromptInput/SandboxPromptFooterHint.tsx b/src/components/PromptInput/SandboxPromptFooterHint.tsx index e470509d4..dc831e4c1 100644 --- a/src/components/PromptInput/SandboxPromptFooterHint.tsx +++ b/src/components/PromptInput/SandboxPromptFooterHint.tsx @@ -1,61 +1,56 @@ -import * as React from 'react' -import { type ReactNode, useEffect, useRef, useState } from 'react' -import { Box, Text } from '@anthropic/ink' -import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js' -import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' +import * as React from 'react'; +import { type ReactNode, useEffect, useRef, useState } from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js'; +import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'; export function SandboxPromptFooterHint(): ReactNode { - const [recentViolationCount, setRecentViolationCount] = useState(0) - const timerRef = useRef(null) - const detailsShortcut = useShortcutDisplay( - 'app:toggleTranscript', - 'Global', - 'ctrl+o', - ) + const [recentViolationCount, setRecentViolationCount] = useState(0); + const timerRef = useRef(null); + const detailsShortcut = useShortcutDisplay('app:toggleTranscript', 'Global', 'ctrl+o'); useEffect(() => { if (!SandboxManager.isSandboxingEnabled()) { - return + return; } - const store = SandboxManager.getSandboxViolationStore() - let lastCount = store.getTotalCount() + const store = SandboxManager.getSandboxViolationStore(); + let lastCount = store.getTotalCount(); const unsubscribe = store.subscribe(() => { - const currentCount = store.getTotalCount() - const newViolations = currentCount - lastCount + const currentCount = store.getTotalCount(); + const newViolations = currentCount - lastCount; if (newViolations > 0) { - setRecentViolationCount(newViolations) - lastCount = currentCount + setRecentViolationCount(newViolations); + lastCount = currentCount; if (timerRef.current) { - clearTimeout(timerRef.current) + clearTimeout(timerRef.current); } - timerRef.current = setTimeout(setRecentViolationCount, 5000, 0) + timerRef.current = setTimeout(setRecentViolationCount, 5000, 0); } - }) + }); return () => { - unsubscribe() + unsubscribe(); if (timerRef.current) { - clearTimeout(timerRef.current) + clearTimeout(timerRef.current); } - } - }, []) + }; + }, []); if (!SandboxManager.isSandboxingEnabled() || recentViolationCount === 0) { - return null + return null; } return ( - ⧈ Sandbox blocked {recentViolationCount}{' '} - {recentViolationCount === 1 ? 'operation' : 'operations'} ·{' '} + ⧈ Sandbox blocked {recentViolationCount} {recentViolationCount === 1 ? 'operation' : 'operations'} ·{' '} {detailsShortcut} for details · /sandbox to disable - ) + ); } diff --git a/src/components/PromptInput/ShimmeredInput.tsx b/src/components/PromptInput/ShimmeredInput.tsx index a14afbc9b..c98a83c43 100644 --- a/src/components/PromptInput/ShimmeredInput.tsx +++ b/src/components/PromptInput/ShimmeredInput.tsx @@ -1,21 +1,18 @@ -import * as React from 'react' -import { Ansi, Box, Text, useAnimationFrame } from '@anthropic/ink' -import { - segmentTextByHighlights, - type TextHighlight, -} from '../../utils/textHighlighting.js' -import { ShimmerChar } from '../Spinner/ShimmerChar.js' +import * as React from 'react'; +import { Ansi, Box, Text, useAnimationFrame } from '@anthropic/ink'; +import { segmentTextByHighlights, type TextHighlight } from '../../utils/textHighlighting.js'; +import { ShimmerChar } from '../Spinner/ShimmerChar.js'; type Props = { - text: string - highlights: TextHighlight[] -} + text: string; + highlights: TextHighlight[]; +}; type LinePart = { - text: string - highlight: TextHighlight | undefined - start: number -} + text: string; + highlight: TextHighlight | undefined; + start: number; +}; export function HighlightedInput({ text, highlights }: Props): React.ReactNode { // The shimmer animation (below) re-renders this component at 20fps while the @@ -24,59 +21,57 @@ export function HighlightedInput({ text, highlights }: Props): React.ReactNode { // that derives from them: segmentTextByHighlights alone is ~85µs/call // (tokenize + sort + O(n²) overlap), which adds up fast at 20fps. const { lines, hasShimmer, sweepStart, cycleLength } = React.useMemo(() => { - const segments = segmentTextByHighlights(text, highlights) + const segments = segmentTextByHighlights(text, highlights); // Split segments by newlines into per-line groups. Ink's row-direction Box // indents continuation lines of a multi-line child to that child's X offset. // By splitting at newlines, each line renders as its own row, avoiding the // incorrect indentation when highlighted text is followed by wrapped content. - const lines: LinePart[][] = [[]] - let pos = 0 + const lines: LinePart[][] = [[]]; + let pos = 0; for (const segment of segments) { - const parts = segment.text.split('\n') + const parts = segment.text.split('\n'); for (let i = 0; i < parts.length; i++) { if (i > 0) { - lines.push([]) - pos += 1 + lines.push([]); + pos += 1; } - const part = parts[i]! + const part = parts[i]!; if (part.length > 0) { lines[lines.length - 1]!.push({ text: part, highlight: segment.highlight, start: pos, - }) + }); } - pos += part.length + pos += part.length; } } // Scope the sweep to shimmer-highlighted ranges so cycle time doesn't grow // with input length. Padding creates an offscreen pause between sweeps. - const hasShimmer = highlights.some(h => h.shimmerColor) - let sweepStart = 0 - let cycleLength = 1 + const hasShimmer = highlights.some(h => h.shimmerColor); + let sweepStart = 0; + let cycleLength = 1; if (hasShimmer) { - const padding = 10 - let lo = Infinity - let hi = -Infinity + const padding = 10; + let lo = Infinity; + let hi = -Infinity; for (const h of highlights) { if (h.shimmerColor) { - lo = Math.min(lo, h.start) - hi = Math.max(hi, h.end) + lo = Math.min(lo, h.start); + hi = Math.max(hi, h.end); } } - sweepStart = lo - padding - cycleLength = hi - lo + padding * 2 + sweepStart = lo - padding; + cycleLength = hi - lo + padding * 2; } - return { lines, hasShimmer, sweepStart, cycleLength } - }, [text, highlights]) + return { lines, hasShimmer, sweepStart, cycleLength }; + }, [text, highlights]); - const [ref, time] = useAnimationFrame(hasShimmer ? 50 : null) - const glimmerIndex = hasShimmer - ? sweepStart + (Math.floor(time / 50) % cycleLength) - : -100 + const [ref, time] = useAnimationFrame(hasShimmer ? 50 : null); + const glimmerIndex = hasShimmer ? sweepStart + (Math.floor(time / 50) % cycleLength) : -100; return ( @@ -100,7 +95,7 @@ export function HighlightedInput({ text, highlights }: Props): React.ReactNode { /> ))} - ) + ); } return ( {part.text} - ) + ); }) )} ))} - ) + ); } diff --git a/src/components/PromptInput/VoiceIndicator.tsx b/src/components/PromptInput/VoiceIndicator.tsx index c6fe0b07f..a2c36ac07 100644 --- a/src/components/PromptInput/VoiceIndicator.tsx +++ b/src/components/PromptInput/VoiceIndicator.tsx @@ -1,32 +1,32 @@ -import { feature } from 'bun:bundle' -import * as React from 'react' -import { useSettings } from '../../hooks/useSettings.js' -import { Box, Text, useAnimationFrame } from '@anthropic/ink' -import { interpolateColor, toRGBColor } from '../Spinner/utils.js' +import { feature } from 'bun:bundle'; +import * as React from 'react'; +import { useSettings } from '../../hooks/useSettings.js'; +import { Box, Text, useAnimationFrame } from '@anthropic/ink'; +import { interpolateColor, toRGBColor } from '../Spinner/utils.js'; type Props = { - voiceState: 'idle' | 'recording' | 'processing' -} + voiceState: 'idle' | 'recording' | 'processing'; +}; // Processing shimmer colors: dim gray to lighter gray (matches ThinkingShimmerText) -const PROCESSING_DIM = { r: 153, g: 153, b: 153 } -const PROCESSING_BRIGHT = { r: 185, g: 185, b: 185 } +const PROCESSING_DIM = { r: 153, g: 153, b: 153 }; +const PROCESSING_BRIGHT = { r: 185, g: 185, b: 185 }; -const PULSE_PERIOD_S = 2 // 2 second period for all pulsing animations +const PULSE_PERIOD_S = 2; // 2 second period for all pulsing animations export function VoiceIndicator(props: Props): React.ReactNode { - if (!feature('VOICE_MODE')) return null - return + if (!feature('VOICE_MODE')) return null; + return ; } function VoiceIndicatorImpl({ voiceState }: Props): React.ReactNode { switch (voiceState) { case 'recording': - return listening… + return listening…; case 'processing': - return + return ; case 'idle': - return null + return null; } } @@ -35,29 +35,26 @@ function VoiceIndicatorImpl({ voiceState }: Props): React.ReactNode { // timer here runs concurrently with auto-repeat spaces arriving every // 30-80ms, compounding re-renders during an already-busy window. export function VoiceWarmupHint(): React.ReactNode { - if (!feature('VOICE_MODE')) return null - return keep holding… + if (!feature('VOICE_MODE')) return null; + return keep holding…; } function ProcessingShimmer(): React.ReactNode { - const settings = useSettings() - const reducedMotion = settings.prefersReducedMotion ?? false - const [ref, time] = useAnimationFrame(reducedMotion ? null : 50) + const settings = useSettings(); + const reducedMotion = settings.prefersReducedMotion ?? false; + const [ref, time] = useAnimationFrame(reducedMotion ? null : 50); if (reducedMotion) { - return Voice: processing… + return Voice: processing…; } - const elapsedSec = time / 1000 - const opacity = - (Math.sin((elapsedSec * Math.PI * 2) / PULSE_PERIOD_S) + 1) / 2 - const color = toRGBColor( - interpolateColor(PROCESSING_DIM, PROCESSING_BRIGHT, opacity), - ) + const elapsedSec = time / 1000; + const opacity = (Math.sin((elapsedSec * Math.PI * 2) / PULSE_PERIOD_S) + 1) / 2; + const color = toRGBColor(interpolateColor(PROCESSING_DIM, PROCESSING_BRIGHT, opacity)); return ( Voice: processing… - ) + ); } diff --git a/src/components/PromptInput/src/context/notifications.ts b/src/components/PromptInput/src/context/notifications.ts index 8db063f70..38a1d4cec 100644 --- a/src/components/PromptInput/src/context/notifications.ts +++ b/src/components/PromptInput/src/context/notifications.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type Notification = any; -export type useNotifications = any; +export type Notification = any +export type useNotifications = any diff --git a/src/components/PromptInput/src/history.ts b/src/components/PromptInput/src/history.ts index 626e5c17d..b0bc41129 100644 --- a/src/components/PromptInput/src/history.ts +++ b/src/components/PromptInput/src/history.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getPastedTextRefNumLines = any; +export type getPastedTextRefNumLines = any diff --git a/src/components/PromptInput/src/hooks/useArrowKeyHistory.ts b/src/components/PromptInput/src/hooks/useArrowKeyHistory.ts index 943c7404f..a2b9fecc7 100644 --- a/src/components/PromptInput/src/hooks/useArrowKeyHistory.ts +++ b/src/components/PromptInput/src/hooks/useArrowKeyHistory.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type HistoryMode = any; +export type HistoryMode = any diff --git a/src/components/PromptInput/src/hooks/useCommandQueue.ts b/src/components/PromptInput/src/hooks/useCommandQueue.ts index ef6e2c885..54537f8c4 100644 --- a/src/components/PromptInput/src/hooks/useCommandQueue.ts +++ b/src/components/PromptInput/src/hooks/useCommandQueue.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useCommandQueue = any; +export type useCommandQueue = any diff --git a/src/components/PromptInput/src/hooks/useIdeAtMentioned.ts b/src/components/PromptInput/src/hooks/useIdeAtMentioned.ts index c0d005f12..974c85c5d 100644 --- a/src/components/PromptInput/src/hooks/useIdeAtMentioned.ts +++ b/src/components/PromptInput/src/hooks/useIdeAtMentioned.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type IDEAtMentioned = any; -export type useIdeAtMentioned = any; +export type IDEAtMentioned = any +export type useIdeAtMentioned = any diff --git a/src/components/PromptInput/src/ink.ts b/src/components/PromptInput/src/ink.ts index 51d6eb4b7..7371bcca6 100644 --- a/src/components/PromptInput/src/ink.ts +++ b/src/components/PromptInput/src/ink.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type Box = any; -export type Text = any; +export type Box = any +export type Text = any diff --git a/src/components/PromptInput/src/services/analytics/index.ts b/src/components/PromptInput/src/services/analytics/index.ts index ce0a9a827..eca4493cf 100644 --- a/src/components/PromptInput/src/services/analytics/index.ts +++ b/src/components/PromptInput/src/services/analytics/index.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type logEvent = any; -export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any; +export type logEvent = any +export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any diff --git a/src/components/PromptInput/src/state/AppState.ts b/src/components/PromptInput/src/state/AppState.ts index 46a69cf69..1323f0469 100644 --- a/src/components/PromptInput/src/state/AppState.ts +++ b/src/components/PromptInput/src/state/AppState.ts @@ -1,5 +1,5 @@ // Auto-generated type stub — replace with real implementation -export type useAppState = any; -export type useAppStateStore = any; -export type AppState = any; -export type useSetAppState = any; +export type useAppState = any +export type useAppStateStore = any +export type AppState = any +export type useSetAppState = any diff --git a/src/components/PromptInput/src/state/AppStateStore.ts b/src/components/PromptInput/src/state/AppStateStore.ts index f27111408..21e6be93f 100644 --- a/src/components/PromptInput/src/state/AppStateStore.ts +++ b/src/components/PromptInput/src/state/AppStateStore.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type FooterItem = any; +export type FooterItem = any diff --git a/src/components/PromptInput/src/tools/AgentTool/agentColorManager.ts b/src/components/PromptInput/src/tools/AgentTool/agentColorManager.ts index e2c6f578c..59806be0f 100644 --- a/src/components/PromptInput/src/tools/AgentTool/agentColorManager.ts +++ b/src/components/PromptInput/src/tools/AgentTool/agentColorManager.ts @@ -1,4 +1,4 @@ // Auto-generated type stub — replace with real implementation -export type AGENT_COLOR_TO_THEME_COLOR = any; -export type AGENT_COLORS = any; -export type AgentColorName = any; +export type AGENT_COLOR_TO_THEME_COLOR = any +export type AGENT_COLORS = any +export type AgentColorName = any diff --git a/src/components/PromptInput/src/types/textInputTypes.ts b/src/components/PromptInput/src/types/textInputTypes.ts index a289d0789..b7c48289d 100644 --- a/src/components/PromptInput/src/types/textInputTypes.ts +++ b/src/components/PromptInput/src/types/textInputTypes.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type PromptInputMode = any; +export type PromptInputMode = any diff --git a/src/components/PromptInput/src/utils/config.ts b/src/components/PromptInput/src/utils/config.ts index 7e541740c..24e95e8c6 100644 --- a/src/components/PromptInput/src/utils/config.ts +++ b/src/components/PromptInput/src/utils/config.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type getGlobalConfig = any; -export type PastedContent = any; +export type getGlobalConfig = any +export type PastedContent = any diff --git a/src/components/PromptInput/src/utils/cwd.ts b/src/components/PromptInput/src/utils/cwd.ts index 76c192ed8..4bd56a824 100644 --- a/src/components/PromptInput/src/utils/cwd.ts +++ b/src/components/PromptInput/src/utils/cwd.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getCwd = any; +export type getCwd = any diff --git a/src/components/PromptInput/src/utils/exampleCommands.ts b/src/components/PromptInput/src/utils/exampleCommands.ts index c6ce8c5e9..c33c4a503 100644 --- a/src/components/PromptInput/src/utils/exampleCommands.ts +++ b/src/components/PromptInput/src/utils/exampleCommands.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getExampleCommandFromCache = any; +export type getExampleCommandFromCache = any diff --git a/src/components/PromptInput/src/utils/messageQueueManager.ts b/src/components/PromptInput/src/utils/messageQueueManager.ts index a08a46829..c9dd61aa0 100644 --- a/src/components/PromptInput/src/utils/messageQueueManager.ts +++ b/src/components/PromptInput/src/utils/messageQueueManager.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type isQueuedCommandEditable = any; -export type popAllEditable = any; +export type isQueuedCommandEditable = any +export type popAllEditable = any diff --git a/src/components/PromptInput/src/utils/platform.ts b/src/components/PromptInput/src/utils/platform.ts index b6686f812..c7486cc77 100644 --- a/src/components/PromptInput/src/utils/platform.ts +++ b/src/components/PromptInput/src/utils/platform.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getPlatform = any; +export type getPlatform = any diff --git a/src/components/PromptInput/src/utils/teammate.ts b/src/components/PromptInput/src/utils/teammate.ts index fb30f2184..89c60ff40 100644 --- a/src/components/PromptInput/src/utils/teammate.ts +++ b/src/components/PromptInput/src/utils/teammate.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getTeammateColor = any; +export type getTeammateColor = any diff --git a/src/components/PromptInput/src/utils/theme.ts b/src/components/PromptInput/src/utils/theme.ts index c6999a678..833b24799 100644 --- a/src/components/PromptInput/src/utils/theme.ts +++ b/src/components/PromptInput/src/utils/theme.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type Theme = any; +export type Theme = any diff --git a/src/components/QuickOpenDialog.tsx b/src/components/QuickOpenDialog.tsx index 336feed0d..eea834599 100644 --- a/src/components/QuickOpenDialog.tsx +++ b/src/components/QuickOpenDialog.tsx @@ -1,68 +1,66 @@ -import * as path from 'path' -import * as React from 'react' -import { useEffect, useRef, useState } from 'react' -import { useRegisterOverlay } from '../context/overlayContext.js' -import { generateFileSuggestions } from '../hooks/fileSuggestions.js' -import { useTerminalSize } from '../hooks/useTerminalSize.js' -import { Text } from '@anthropic/ink' -import { logEvent } from '../services/analytics/index.js' -import { getCwd } from '../utils/cwd.js' -import { openFileInExternalEditor } from '../utils/editor.js' -import { truncatePathMiddle, truncateToWidth } from '../utils/format.js' -import { highlightMatch } from '../utils/highlightMatch.js' -import { readFileInRange } from '../utils/readFileInRange.js' -import { FuzzyPicker, LoadingState } from '@anthropic/ink' +import * as path from 'path'; +import * as React from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { useRegisterOverlay } from '../context/overlayContext.js'; +import { generateFileSuggestions } from '../hooks/fileSuggestions.js'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { Text } from '@anthropic/ink'; +import { logEvent } from '../services/analytics/index.js'; +import { getCwd } from '../utils/cwd.js'; +import { openFileInExternalEditor } from '../utils/editor.js'; +import { truncatePathMiddle, truncateToWidth } from '../utils/format.js'; +import { highlightMatch } from '../utils/highlightMatch.js'; +import { readFileInRange } from '../utils/readFileInRange.js'; +import { FuzzyPicker, LoadingState } from '@anthropic/ink'; type Props = { - onDone: () => void - onInsert: (text: string) => void -} + onDone: () => void; + onInsert: (text: string) => void; +}; -const VISIBLE_RESULTS = 8 -const PREVIEW_LINES = 20 +const VISIBLE_RESULTS = 8; +const PREVIEW_LINES = 20; /** * Quick Open dialog (ctrl+shift+p / cmd+shift+p). * Fuzzy file finder with a syntax-highlighted preview of the focused file. */ export function QuickOpenDialog({ onDone, onInsert }: Props): React.ReactNode { - useRegisterOverlay('quick-open') - const { columns, rows } = useTerminalSize() + useRegisterOverlay('quick-open'); + const { columns, rows } = useTerminalSize(); // Chrome (title + search + hints + pane border + gaps) eats ~14 rows. // Shrink the list on short terminals so the dialog doesn't clip. - const visibleResults = Math.min(VISIBLE_RESULTS, Math.max(4, rows - 14)) + const visibleResults = Math.min(VISIBLE_RESULTS, Math.max(4, rows - 14)); - const [results, setResults] = useState([]) - const [query, setQuery] = useState('') - const [focusedPath, setFocusedPath] = useState(undefined) + const [results, setResults] = useState([]); + const [query, setQuery] = useState(''); + const [focusedPath, setFocusedPath] = useState(undefined); const [preview, setPreview] = useState<{ - path: string - content: string - } | null>(null) - const queryGenRef = useRef(0) - useEffect(() => () => void queryGenRef.current++, []) + path: string; + content: string; + } | null>(null); + const queryGenRef = useRef(0); + useEffect(() => () => void queryGenRef.current++, []); - const previewOnRight = columns >= 120 + const previewOnRight = columns >= 120; // Side preview sits in a fixed-height row alongside the list (visibleCount // rows), so overflowing that height garbles the layout — cap to fit, minus // one for the path header line. - const effectivePreviewLines = previewOnRight - ? VISIBLE_RESULTS - 1 - : PREVIEW_LINES + const effectivePreviewLines = previewOnRight ? VISIBLE_RESULTS - 1 : PREVIEW_LINES; // A generation counter invalidates stale results if the user types faster // than the index can respond. const handleQueryChange = (q: string) => { - setQuery(q) - const gen = ++queryGenRef.current + setQuery(q); + const gen = ++queryGenRef.current; if (!q.trim()) { // generateFileSuggestions('') returns raw readdir() of cwd (designed for // @-mentions). For Quick Open that's just noise — show the empty state. - setResults([]) - return + setResults([]); + return; } void generateFileSuggestions(q, true).then(items => { - if (gen !== queryGenRef.current) return + if (gen !== queryGenRef.current) return; // Filter out directory entries — they come back with a trailing path.sep // from getTopLevelPaths() and would cause readFileInRange to throw EISDIR, // leaving the preview pane stuck on "Loading preview…". @@ -72,10 +70,10 @@ export function QuickOpenDialog({ onDone, onInsert }: Props): React.ReactNode { .filter(i => i.id.startsWith('file-')) .map(i => i.displayText) .filter(p => !p.endsWith(path.sep)) - .map(p => p.split(path.sep).join('/')) - setResults(paths) - }) - } + .map(p => p.split(path.sep).join('/')); + setResults(paths); + }); + }; // Load a short preview of the focused file. Each navigation aborts the // previous read so holding ↓ doesn't pile up whole-file reads and so a @@ -86,53 +84,43 @@ export function QuickOpenDialog({ onDone, onInsert }: Props): React.ReactNode { if (!focusedPath) { // No results — clear so the empty-state renders instead of a stale // preview from a previous query. - setPreview(null) - return + setPreview(null); + return; } - const controller = new AbortController() - const absolute = path.resolve(getCwd(), focusedPath) - void readFileInRange( - absolute, - 0, - effectivePreviewLines, - undefined, - controller.signal, - ) + const controller = new AbortController(); + const absolute = path.resolve(getCwd(), focusedPath); + void readFileInRange(absolute, 0, effectivePreviewLines, undefined, controller.signal) .then(r => { - if (controller.signal.aborted) return - setPreview({ path: focusedPath, content: r.content }) + if (controller.signal.aborted) return; + setPreview({ path: focusedPath, content: r.content }); }) .catch(() => { - if (controller.signal.aborted) return - setPreview({ path: focusedPath, content: '(preview unavailable)' }) - }) - return () => controller.abort() - }, [focusedPath, effectivePreviewLines]) + if (controller.signal.aborted) return; + setPreview({ path: focusedPath, content: '(preview unavailable)' }); + }); + return () => controller.abort(); + }, [focusedPath, effectivePreviewLines]); - const maxPathWidth = previewOnRight - ? Math.max(20, Math.floor((columns - 10) * 0.4)) - : Math.max(20, columns - 8) - const previewWidth = previewOnRight - ? Math.max(40, columns - maxPathWidth - 14) - : columns - 6 + const maxPathWidth = previewOnRight ? Math.max(20, Math.floor((columns - 10) * 0.4)) : Math.max(20, columns - 8); + const previewWidth = previewOnRight ? Math.max(40, columns - maxPathWidth - 14) : columns - 6; const handleOpen = (p: string) => { - const opened = openFileInExternalEditor(path.resolve(getCwd(), p)) + const opened = openFileInExternalEditor(path.resolve(getCwd(), p)); logEvent('tengu_quick_open_select', { result_count: results.length, opened_editor: opened, - }) - onDone() - } + }); + onDone(); + }; const handleInsert = (p: string, mention: boolean) => { - onInsert(mention ? `@${p} ` : `${p} `) + onInsert(mention ? `@${p} ` : `${p} `); logEvent('tengu_quick_open_insert', { result_count: results.length, mention, - }) - onDone() - } + }); + onDone(); + }; return ( (q ? 'No matching files' : 'Start typing to search…')} selectAction="open in editor" renderItem={(p, isFocused) => ( - - {truncatePathMiddle(p, maxPathWidth)} - + {truncatePathMiddle(p, maxPathWidth)} )} renderPreview={p => preview ? ( @@ -167,9 +153,7 @@ export function QuickOpenDialog({ onDone, onInsert }: Props): React.ReactNode { {preview.path !== p ? ' · loading…' : ''} {preview.content.split('\n').map((line, i) => ( - - {highlightMatch(truncateToWidth(line, previewWidth), query)} - + {highlightMatch(truncateToWidth(line, previewWidth), query)} ))} ) : ( @@ -177,5 +161,5 @@ export function QuickOpenDialog({ onDone, onInsert }: Props): React.ReactNode { ) } /> - ) + ); } diff --git a/src/components/RemoteCallout.tsx b/src/components/RemoteCallout.tsx index 7d9dff991..5bd157d8e 100644 --- a/src/components/RemoteCallout.tsx +++ b/src/components/RemoteCallout.tsx @@ -1,37 +1,37 @@ -import React, { useCallback, useEffect, useRef } from 'react' -import { isBridgeEnabled } from '../bridge/bridgeEnabled.js' -import { Box, Text } from '@anthropic/ink' -import { getClaudeAIOAuthTokens } from '../utils/auth.js' -import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' -import type { OptionWithDescription } from './CustomSelect/select.js' -import { Select } from './CustomSelect/select.js' -import { PermissionDialog } from './permissions/PermissionDialog.js' +import React, { useCallback, useEffect, useRef } from 'react'; +import { isBridgeEnabled } from '../bridge/bridgeEnabled.js'; +import { Box, Text } from '@anthropic/ink'; +import { getClaudeAIOAuthTokens } from '../utils/auth.js'; +import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'; +import type { OptionWithDescription } from './CustomSelect/select.js'; +import { Select } from './CustomSelect/select.js'; +import { PermissionDialog } from './permissions/PermissionDialog.js'; -type RemoteCalloutSelection = 'enable' | 'dismiss' +type RemoteCalloutSelection = 'enable' | 'dismiss'; type Props = { - onDone: (selection: RemoteCalloutSelection) => void -} + onDone: (selection: RemoteCalloutSelection) => void; +}; export function RemoteCallout({ onDone }: Props): React.ReactNode { - const onDoneRef = useRef(onDone) - onDoneRef.current = onDone + const onDoneRef = useRef(onDone); + onDoneRef.current = onDone; const handleCancel = useCallback((): void => { - onDoneRef.current('dismiss') - }, []) + onDoneRef.current('dismiss'); + }, []); // Permanently mark as seen on mount so it only shows once useEffect(() => { saveGlobalConfig(current => { - if (current.remoteDialogSeen) return current - return { ...current, remoteDialogSeen: true } - }) - }, []) + if (current.remoteDialogSeen) return current; + return { ...current, remoteDialogSeen: true }; + }); + }, []); const handleSelect = useCallback((value: RemoteCalloutSelection): void => { - onDoneRef.current(value) - }, []) + onDoneRef.current(value); + }, []); const options: OptionWithDescription[] = [ { @@ -44,43 +44,35 @@ export function RemoteCallout({ onDone }: Props): React.ReactNode { description: 'You can always enable it later with /remote-control.', value: 'dismiss', }, - ] + ]; return ( - Remote Control lets you access this CLI session from the web - (claude.ai/code) or the Claude app, so you can pick up where you - left off on any device. + Remote Control lets you access this CLI session from the web (claude.ai/code) or the Claude app, so you can + pick up where you left off on any device. - - You can disconnect remote access anytime by running /remote-control - again. - + You can disconnect remote access anytime by running /remote-control again. - - ) + ); } /** * Check whether to show the remote callout (first-time dialog). */ export function shouldShowRemoteCallout(): boolean { - const config = getGlobalConfig() - if (config.remoteDialogSeen) return false - if (!isBridgeEnabled()) return false - const tokens = getClaudeAIOAuthTokens() - if (!tokens?.accessToken) return false - return true + const config = getGlobalConfig(); + if (config.remoteDialogSeen) return false; + if (!isBridgeEnabled()) return false; + const tokens = getClaudeAIOAuthTokens(); + if (!tokens?.accessToken) return false; + return true; } diff --git a/src/components/RemoteEnvironmentDialog.tsx b/src/components/RemoteEnvironmentDialog.tsx index 436b59583..15b486274 100644 --- a/src/components/RemoteEnvironmentDialog.tsx +++ b/src/components/RemoteEnvironmentDialog.tsx @@ -1,88 +1,81 @@ -import chalk from 'chalk' -import figures from 'figures' -import * as React from 'react' -import { useEffect, useState } from 'react' -import { Text } from '@anthropic/ink' -import { useKeybinding } from '../keybindings/useKeybinding.js' -import { toError } from '../utils/errors.js' -import { logError } from '../utils/log.js' -import { - getSettingSourceName, - type SettingSource, -} from '../utils/settings/constants.js' -import { updateSettingsForSource } from '../utils/settings/settings.js' -import { getEnvironmentSelectionInfo } from '../utils/teleport/environmentSelection.js' -import type { EnvironmentResource } from '../utils/teleport/environments.js' -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' -import { Select } from './CustomSelect/select.js' -import { Byline, Dialog, KeyboardShortcutHint, LoadingState } from '@anthropic/ink' +import chalk from 'chalk'; +import figures from 'figures'; +import * as React from 'react'; +import { useEffect, useState } from 'react'; +import { Text } from '@anthropic/ink'; +import { useKeybinding } from '../keybindings/useKeybinding.js'; +import { toError } from '../utils/errors.js'; +import { logError } from '../utils/log.js'; +import { getSettingSourceName, type SettingSource } from '../utils/settings/constants.js'; +import { updateSettingsForSource } from '../utils/settings/settings.js'; +import { getEnvironmentSelectionInfo } from '../utils/teleport/environmentSelection.js'; +import type { EnvironmentResource } from '../utils/teleport/environments.js'; +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; +import { Select } from './CustomSelect/select.js'; +import { Byline, Dialog, KeyboardShortcutHint, LoadingState } from '@anthropic/ink'; -const DIALOG_TITLE = 'Select Remote Environment' -const SETUP_HINT = `Configure environments at: https://claude.ai/code` +const DIALOG_TITLE = 'Select Remote Environment'; +const SETUP_HINT = `Configure environments at: https://claude.ai/code`; type Props = { - onDone: (message?: string) => void -} + onDone: (message?: string) => void; +}; -type LoadingState = 'loading' | 'updating' | null +type LoadingState = 'loading' | 'updating' | null; export function RemoteEnvironmentDialog({ onDone }: Props): React.ReactNode { - const [loadingState, setLoadingState] = useState('loading') - const [environments, setEnvironments] = useState([]) - const [selectedEnvironment, setSelectedEnvironment] = - useState(null) - const [selectedEnvironmentSource, setSelectedEnvironmentSource] = - useState(null) - const [error, setError] = useState(null) + const [loadingState, setLoadingState] = useState('loading'); + const [environments, setEnvironments] = useState([]); + const [selectedEnvironment, setSelectedEnvironment] = useState(null); + const [selectedEnvironmentSource, setSelectedEnvironmentSource] = useState(null); + const [error, setError] = useState(null); useEffect(() => { - let cancelled = false + let cancelled = false; async function fetchInfo(): Promise { try { - const result = await getEnvironmentSelectionInfo() - if (cancelled) return - setEnvironments(result.availableEnvironments) - setSelectedEnvironment(result.selectedEnvironment) - setSelectedEnvironmentSource(result.selectedEnvironmentSource) - setLoadingState(null) + const result = await getEnvironmentSelectionInfo(); + if (cancelled) return; + setEnvironments(result.availableEnvironments); + setSelectedEnvironment(result.selectedEnvironment); + setSelectedEnvironmentSource(result.selectedEnvironmentSource); + setLoadingState(null); } catch (err) { - if (cancelled) return - const fetchError = toError(err) - logError(fetchError) - setError(fetchError.message) - setLoadingState(null) + if (cancelled) return; + const fetchError = toError(err); + logError(fetchError); + setError(fetchError.message); + setLoadingState(null); } } - void fetchInfo() + void fetchInfo(); return () => { - cancelled = true - } - }, []) + cancelled = true; + }; + }, []); function handleSelect(value: string): void { if (value === 'cancel') { - onDone() - return + onDone(); + return; } - setLoadingState('updating') + setLoadingState('updating'); - const selectedEnv = environments.find(env => env.environment_id === value) + const selectedEnv = environments.find(env => env.environment_id === value); if (!selectedEnv) { - onDone('Error: Selected environment not found') - return + onDone('Error: Selected environment not found'); + return; } updateSettingsForSource('localSettings', { remote: { defaultEnvironmentId: selectedEnv.environment_id, }, - }) + }); - onDone( - `Set default remote environment to ${chalk.bold(selectedEnv.name)} (${selectedEnv.environment_id})`, - ) + onDone(`Set default remote environment to ${chalk.bold(selectedEnv.name)} (${selectedEnv.environment_id})`); } // Loading state @@ -91,7 +84,7 @@ export function RemoteEnvironmentDialog({ onDone }: Props): React.ReactNode { - ) + ); } // Error state @@ -100,7 +93,7 @@ export function RemoteEnvironmentDialog({ onDone }: Props): React.ReactNode { Error: {error} - ) + ); } // No environments available @@ -109,17 +102,12 @@ export function RemoteEnvironmentDialog({ onDone }: Props): React.ReactNode { No remote environments available. - ) + ); } // Single environment - just show info if (environments.length === 1) { - return ( - - ) + return ; } // Multiple environments - show selection UI @@ -132,37 +120,32 @@ export function RemoteEnvironmentDialog({ onDone }: Props): React.ReactNode { onSelect={handleSelect} onCancel={onDone} /> - ) + ); } -function EnvironmentLabel({ - environment, -}: { - environment: EnvironmentResource -}): React.ReactNode { +function EnvironmentLabel({ environment }: { environment: EnvironmentResource }): React.ReactNode { return ( - {figures.tick} Using {environment.name}{' '} - ({environment.environment_id}) + {figures.tick} Using {environment.name} ({environment.environment_id}) - ) + ); } function SingleEnvironmentContent({ environment, onDone, }: { - environment: EnvironmentResource - onDone: () => void + environment: EnvironmentResource; + onDone: () => void; }): React.ReactNode { // Handle Enter to continue - useKeybinding('confirm:yes', onDone, { context: 'Confirmation' }) + useKeybinding('confirm:yes', onDone, { context: 'Confirmation' }); return ( - ) + ); } function MultipleEnvironmentsContent({ @@ -173,32 +156,27 @@ function MultipleEnvironmentsContent({ onSelect, onCancel, }: { - environments: EnvironmentResource[] - selectedEnvironment: EnvironmentResource - selectedEnvironmentSource: SettingSource | null - loadingState: LoadingState - onSelect: (value: string) => void - onCancel: () => void + environments: EnvironmentResource[]; + selectedEnvironment: EnvironmentResource; + selectedEnvironmentSource: SettingSource | null; + loadingState: LoadingState; + onSelect: (value: string) => void; + onCancel: () => void; }): React.ReactNode { const sourceSuffix = selectedEnvironmentSource && selectedEnvironmentSource !== 'localSettings' ? ` (from ${getSettingSourceName(selectedEnvironmentSource)} settings)` - : '' + : ''; const subtitle = ( Currently using: {selectedEnvironment.name} {sourceSuffix} - ) + ); return ( - + {SETUP_HINT} {loadingState === 'updating' ? ( @@ -221,14 +199,9 @@ function MultipleEnvironmentsContent({ - + - ) + ); } diff --git a/src/components/ResumeTask.tsx b/src/components/ResumeTask.tsx index c81ed0291..4822ac13b 100644 --- a/src/components/ResumeTask.tsx +++ b/src/components/ResumeTask.tsx @@ -1,133 +1,125 @@ -import React, { useCallback, useState } from 'react' -import { useTerminalSize } from 'src/hooks/useTerminalSize.js' -import { - type CodeSession, - fetchCodeSessionsFromSessionsAPI, -} from 'src/utils/teleport/api.js' +import React, { useCallback, useState } from 'react'; +import { useTerminalSize } from 'src/hooks/useTerminalSize.js'; +import { type CodeSession, fetchCodeSessionsFromSessionsAPI } from 'src/utils/teleport/api.js'; // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw j/k/arrow list navigation -import { Box, Text, useInput } from '@anthropic/ink' -import { useKeybinding } from '../keybindings/useKeybinding.js' -import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js' -import { logForDebugging } from '../utils/debug.js' -import { detectCurrentRepository } from '../utils/detectRepository.js' -import { formatRelativeTime } from '../utils/format.js' -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' -import { Select } from './CustomSelect/index.js' -import { Byline, KeyboardShortcutHint } from '@anthropic/ink' -import { Spinner } from './Spinner.js' -import { TeleportError } from './TeleportError.js' +import { Box, Text, useInput } from '@anthropic/ink'; +import { useKeybinding } from '../keybindings/useKeybinding.js'; +import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'; +import { logForDebugging } from '../utils/debug.js'; +import { detectCurrentRepository } from '../utils/detectRepository.js'; +import { formatRelativeTime } from '../utils/format.js'; +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; +import { Select } from './CustomSelect/index.js'; +import { Byline, KeyboardShortcutHint } from '@anthropic/ink'; +import { Spinner } from './Spinner.js'; +import { TeleportError } from './TeleportError.js'; type Props = { - onSelect: (session: CodeSession) => void - onCancel: () => void - isEmbedded?: boolean -} + onSelect: (session: CodeSession) => void; + onCancel: () => void; + isEmbedded?: boolean; +}; -type LoadErrorType = 'network' | 'auth' | 'api' | 'other' +type LoadErrorType = 'network' | 'auth' | 'api' | 'other'; -const UPDATED_STRING = 'Updated' -const SPACE_BETWEEN_TABLE_COLUMNS = ' ' +const UPDATED_STRING = 'Updated'; +const SPACE_BETWEEN_TABLE_COLUMNS = ' '; -export function ResumeTask({ - onSelect, - onCancel, - isEmbedded = false, -}: Props): React.ReactNode { - const { rows } = useTerminalSize() - const [sessions, setSessions] = useState([]) - const [currentRepo, setCurrentRepo] = useState(null) +export function ResumeTask({ onSelect, onCancel, isEmbedded = false }: Props): React.ReactNode { + const { rows } = useTerminalSize(); + const [sessions, setSessions] = useState([]); + const [currentRepo, setCurrentRepo] = useState(null); - const [loading, setLoading] = useState(true) - const [loadErrorType, setLoadErrorType] = useState(null) - const [retrying, setRetrying] = useState(false) + const [loading, setLoading] = useState(true); + const [loadErrorType, setLoadErrorType] = useState(null); + const [retrying, setRetrying] = useState(false); - const [hasCompletedTeleportErrorFlow, setHasCompletedTeleportErrorFlow] = - useState(false) + const [hasCompletedTeleportErrorFlow, setHasCompletedTeleportErrorFlow] = useState(false); // Track focused index for scroll position display in title - const [focusedIndex, setFocusedIndex] = useState(1) + const [focusedIndex, setFocusedIndex] = useState(1); - const escKey = useShortcutDisplay('confirm:no', 'Confirmation', 'Esc') + const escKey = useShortcutDisplay('confirm:no', 'Confirmation', 'Esc'); const loadSessions = useCallback(async () => { try { - setLoading(true) - setLoadErrorType(null) + setLoading(true); + setLoadErrorType(null); // Detect current repository - const detectedRepo = await detectCurrentRepository() - setCurrentRepo(detectedRepo) - logForDebugging(`Current repository: ${detectedRepo || 'not detected'}`) + const detectedRepo = await detectCurrentRepository(); + setCurrentRepo(detectedRepo); + logForDebugging(`Current repository: ${detectedRepo || 'not detected'}`); - const codeSessions = await fetchCodeSessionsFromSessionsAPI() + const codeSessions = await fetchCodeSessionsFromSessionsAPI(); // Filter sessions by current repository if detected - let filteredSessions = codeSessions + let filteredSessions = codeSessions; if (detectedRepo) { filteredSessions = codeSessions.filter(session => { - if (!session.repo) return false - const sessionRepo = `${session.repo.owner.login}/${session.repo.name}` - return sessionRepo === detectedRepo - }) + if (!session.repo) return false; + const sessionRepo = `${session.repo.owner.login}/${session.repo.name}`; + return sessionRepo === detectedRepo; + }); logForDebugging( `Filtered ${filteredSessions.length} sessions for repo ${detectedRepo} from ${codeSessions.length} total`, - ) + ); } // Sort by updated_at (newest first) const sortedSessions = [...filteredSessions].sort((a, b) => { - const dateA = new Date(a.updated_at) - const dateB = new Date(b.updated_at) - return dateB.getTime() - dateA.getTime() - }) + const dateA = new Date(a.updated_at); + const dateB = new Date(b.updated_at); + return dateB.getTime() - dateA.getTime(); + }); - setSessions(sortedSessions) + setSessions(sortedSessions); } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err) - logForDebugging(`Error loading code sessions: ${errorMessage}`) - setLoadErrorType(determineErrorType(errorMessage)) + const errorMessage = err instanceof Error ? err.message : String(err); + logForDebugging(`Error loading code sessions: ${errorMessage}`); + setLoadErrorType(determineErrorType(errorMessage)); } finally { - setLoading(false) - setRetrying(false) + setLoading(false); + setRetrying(false); } - }, []) + }, []); const handleRetry = () => { - setRetrying(true) - void loadSessions() - } + setRetrying(true); + void loadSessions(); + }; // Handle escape via keybinding - useKeybinding('confirm:no', onCancel, { context: 'Confirmation' }) + useKeybinding('confirm:no', onCancel, { context: 'Confirmation' }); useInput((input, key) => { // We need to handle ctrl+c in case we don't render a { - const selected = options.find(opt => opt.value === value) + const selected = options.find(opt => opt.value === value); if (selected) { // For reject option if (selected.option.type === 'reject') { - const trimmedFeedback = rejectFeedback.trim() - onChange(selected.option, input, trimmedFeedback || undefined) - return + const trimmedFeedback = rejectFeedback.trim(); + onChange(selected.option, input, trimmedFeedback || undefined); + return; } // For accept-once option, pass accept feedback if present if (selected.option.type === 'accept-once') { - const trimmedFeedback = acceptFeedback.trim() - onChange(selected.option, input, trimmedFeedback || undefined) - return + const trimmedFeedback = acceptFeedback.trim(); + onChange(selected.option, input, trimmedFeedback || undefined); + return; } - onChange(selected.option, input) + onChange(selected.option, input); } }} onCancel={() => onChange({ type: 'reject' }, input)} @@ -90,12 +87,11 @@ export function ShowInIDEPrompt({ Esc to cancel - {((focusedOption === 'yes' && !yesInputMode) || - (focusedOption === 'no' && !noInputMode)) && + {((focusedOption === 'yes' && !yesInputMode) || (focusedOption === 'no' && !noInputMode)) && ' · Tab to amend'} - ) + ); } diff --git a/src/components/SkillImprovementSurvey.tsx b/src/components/SkillImprovementSurvey.tsx index ad7b09147..7d9e7ae52 100644 --- a/src/components/SkillImprovementSurvey.tsx +++ b/src/components/SkillImprovementSurvey.tsx @@ -1,19 +1,19 @@ -import React, { useEffect, useRef } from 'react' -import { BLACK_CIRCLE, BULLET_OPERATOR } from '../constants/figures.js' -import { Box, Text } from '@anthropic/ink' -import type { SkillUpdate } from '../utils/hooks/skillImprovement.js' -import { normalizeFullWidthDigits } from '../utils/stringUtils.js' -import { isValidResponseInput } from './FeedbackSurvey/FeedbackSurveyView.js' -import type { FeedbackSurveyResponse } from './FeedbackSurvey/utils.js' +import React, { useEffect, useRef } from 'react'; +import { BLACK_CIRCLE, BULLET_OPERATOR } from '../constants/figures.js'; +import { Box, Text } from '@anthropic/ink'; +import type { SkillUpdate } from '../utils/hooks/skillImprovement.js'; +import { normalizeFullWidthDigits } from '../utils/stringUtils.js'; +import { isValidResponseInput } from './FeedbackSurvey/FeedbackSurveyView.js'; +import type { FeedbackSurveyResponse } from './FeedbackSurvey/utils.js'; type Props = { - isOpen: boolean - skillName: string - updates: SkillUpdate[] - handleSelect: (selected: FeedbackSurveyResponse) => void - inputValue: string - setInputValue: (value: string) => void -} + isOpen: boolean; + skillName: string; + updates: SkillUpdate[]; + handleSelect: (selected: FeedbackSurveyResponse) => void; + inputValue: string; + setInputValue: (value: string) => void; +}; export function SkillImprovementSurvey({ isOpen, @@ -24,12 +24,12 @@ export function SkillImprovementSurvey({ setInputValue, }: Props): React.ReactNode { if (!isOpen) { - return null + return null; } // Hide the survey if the user is typing anything other than a survey response if (inputValue && !isValidResponseInput(inputValue)) { - return null + return null; } return ( @@ -40,22 +40,22 @@ export function SkillImprovementSurvey({ inputValue={inputValue} setInputValue={setInputValue} /> - ) + ); } type ViewProps = { - skillName: string - updates: SkillUpdate[] - onSelect: (option: FeedbackSurveyResponse) => void - inputValue: string - setInputValue: (value: string) => void -} + skillName: string; + updates: SkillUpdate[]; + onSelect: (option: FeedbackSurveyResponse) => void; + inputValue: string; + setInputValue: (value: string) => void; +}; // Only 1 (apply) and 0 (dismiss) are valid for this survey -const VALID_INPUTS = ['0', '1'] as const +const VALID_INPUTS = ['0', '1'] as const; function isValidInput(input: string): boolean { - return (VALID_INPUTS as readonly string[]).includes(input) + return (VALID_INPUTS as readonly string[]).includes(input); } function SkillImprovementSurveyView({ @@ -65,26 +65,24 @@ function SkillImprovementSurveyView({ inputValue, setInputValue, }: ViewProps): React.ReactNode { - const initialInputValue = useRef(inputValue) + const initialInputValue = useRef(inputValue); useEffect(() => { if (inputValue !== initialInputValue.current) { - const lastChar = normalizeFullWidthDigits(inputValue.slice(-1)) + const lastChar = normalizeFullWidthDigits(inputValue.slice(-1)); if (isValidInput(lastChar)) { - setInputValue(inputValue.slice(0, -1)) + setInputValue(inputValue.slice(0, -1)); // Map: 1 = "good" (apply), 0 = "dismissed" - onSelect(lastChar === '1' ? 'good' : 'dismissed') + onSelect(lastChar === '1' ? 'good' : 'dismissed'); } } - }, [inputValue, onSelect, setInputValue]) + }, [inputValue, onSelect, setInputValue]); return ( {BLACK_CIRCLE} - - Skill improvement suggested for "{skillName}" - + Skill improvement suggested for "{skillName}" @@ -108,5 +106,5 @@ function SkillImprovementSurveyView({ - ) + ); } diff --git a/src/components/Spinner.tsx b/src/components/Spinner.tsx index c2ebc9b67..fd3ce07bc 100644 --- a/src/components/Spinner.tsx +++ b/src/components/Spinner.tsx @@ -1,68 +1,53 @@ // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered -import { Box, Text, stringWidth } from '@anthropic/ink' -import * as React from 'react' -import { useEffect, useMemo, useRef, useState } from 'react' -import { - computeGlimmerIndex, - computeShimmerSegments, - SHIMMER_INTERVAL_MS, -} from '../bridge/bridgeStatusUtil.js' -import { feature } from 'bun:bundle' -import { getKairosActive, getUserMsgOptIn } from '../bootstrap/state.js' -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' -import { isEnvTruthy } from '../utils/envUtils.js' -import { count } from '../utils/array.js' -import sample from 'lodash-es/sample.js' -import { - formatDuration, - formatNumber, - formatSecondsShort, -} from '../utils/format.js' -import type { Theme } from 'src/utils/theme.js' -import { activityManager } from '../utils/activityManager.js' -import { getSpinnerVerbs } from '../constants/spinnerVerbs.js' -import { MessageResponse } from './MessageResponse.js' -import { TaskListV2 } from './TaskListV2.js' -import { useTasksV2 } from '../hooks/useTasksV2.js' -import type { Task } from '../utils/tasks.js' -import { useAppState } from '../state/AppState.js' -import { useTerminalSize } from '../hooks/useTerminalSize.js' -import { getDefaultCharacters, type SpinnerMode } from './Spinner/index.js' -import { SpinnerAnimationRow } from './Spinner/SpinnerAnimationRow.js' -import { useSettings } from '../hooks/useSettings.js' -import { isInProcessTeammateTask } from '../tasks/InProcessTeammateTask/types.js' -import { isBackgroundTask } from '../tasks/types.js' -import { getAllInProcessTeammateTasks } from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js' -import { getEffortSuffix } from '../utils/effort.js' -import { getMainLoopModel } from '../utils/model/model.js' -import { getViewedTeammateTask } from '../state/selectors.js' -import { TEARDROP_ASTERISK } from '../constants/figures.js' -import figures from 'figures' -import { - getCurrentTurnTokenBudget, - getTurnOutputTokens, -} from '../bootstrap/state.js' +import { Box, Text, stringWidth } from '@anthropic/ink'; +import * as React from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { computeGlimmerIndex, computeShimmerSegments, SHIMMER_INTERVAL_MS } from '../bridge/bridgeStatusUtil.js'; +import { feature } from 'bun:bundle'; +import { getKairosActive, getUserMsgOptIn } from '../bootstrap/state.js'; +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'; +import { isEnvTruthy } from '../utils/envUtils.js'; +import { count } from '../utils/array.js'; +import sample from 'lodash-es/sample.js'; +import { formatDuration, formatNumber, formatSecondsShort } from '../utils/format.js'; +import type { Theme } from 'src/utils/theme.js'; +import { activityManager } from '../utils/activityManager.js'; +import { getSpinnerVerbs } from '../constants/spinnerVerbs.js'; +import { MessageResponse } from './MessageResponse.js'; +import { TaskListV2 } from './TaskListV2.js'; +import { useTasksV2 } from '../hooks/useTasksV2.js'; +import type { Task } from '../utils/tasks.js'; +import { useAppState } from '../state/AppState.js'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { getDefaultCharacters, type SpinnerMode } from './Spinner/index.js'; +import { SpinnerAnimationRow } from './Spinner/SpinnerAnimationRow.js'; +import { useSettings } from '../hooks/useSettings.js'; +import { isInProcessTeammateTask } from '../tasks/InProcessTeammateTask/types.js'; +import { isBackgroundTask } from '../tasks/types.js'; +import { getAllInProcessTeammateTasks } from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js'; +import { getEffortSuffix } from '../utils/effort.js'; +import { getMainLoopModel } from '../utils/model/model.js'; +import { getViewedTeammateTask } from '../state/selectors.js'; +import { TEARDROP_ASTERISK } from '../constants/figures.js'; +import figures from 'figures'; +import { getCurrentTurnTokenBudget, getTurnOutputTokens } from '../bootstrap/state.js'; -import { TeammateSpinnerTree } from './Spinner/TeammateSpinnerTree.js' -import { useAnimationFrame } from '@anthropic/ink' -import { getGlobalConfig } from '../utils/config.js' -export type { SpinnerMode } from './Spinner/index.js' +import { TeammateSpinnerTree } from './Spinner/TeammateSpinnerTree.js'; +import { useAnimationFrame } from '@anthropic/ink'; +import { getGlobalConfig } from '../utils/config.js'; +export type { SpinnerMode } from './Spinner/index.js'; -const DEFAULT_CHARACTERS = getDefaultCharacters() - -const SPINNER_FRAMES = [ - ...DEFAULT_CHARACTERS, - ...[...DEFAULT_CHARACTERS].reverse(), -] +const DEFAULT_CHARACTERS = getDefaultCharacters(); +const SPINNER_FRAMES = [...DEFAULT_CHARACTERS, ...[...DEFAULT_CHARACTERS].reverse()]; type Props = { - mode: SpinnerMode - loadingStartTimeRef: React.RefObject - totalPausedMsRef: React.RefObject - pauseStartTimeRef: React.RefObject - spinnerTip?: string - responseLengthRef: React.RefObject + mode: SpinnerMode; + loadingStartTimeRef: React.RefObject; + totalPausedMsRef: React.RefObject; + pauseStartTimeRef: React.RefObject; + spinnerTip?: string; + responseLengthRef: React.RefObject; apiMetricsRef?: React.RefObject< Array<{ ttftMs: number; @@ -71,33 +56,32 @@ type Props = { responseLengthBaseline: number; endResponseLength: number; }> - > - overrideColor?: keyof Theme | null - overrideShimmerColor?: keyof Theme | null - overrideMessage?: string | null - spinnerSuffix?: string | null - verbose: boolean - hasActiveTools?: boolean + >; + overrideColor?: keyof Theme | null; + overrideShimmerColor?: keyof Theme | null; + overrideMessage?: string | null; + spinnerSuffix?: string | null; + verbose: boolean; + hasActiveTools?: boolean; /** Leader's turn has completed (no active query). Used to suppress stall-red spinner when only teammates are running. */ - leaderIsIdle?: boolean -} + leaderIsIdle?: boolean; +}; // Thin wrapper: branches on isBriefOnly so the two variants have independent // hook call chains. Without this split, toggling /brief mid-render would // violate Rules of Hooks (the inner variant calls ~10 more hooks). export function SpinnerWithVerb(props: Props): React.ReactNode { - const isBriefOnly = useAppState(s => s.isBriefOnly) + const isBriefOnly = useAppState(s => s.isBriefOnly); // REPL overrides isBriefOnly→false when viewing a teammate transcript // (see isBriefOnly={viewedTeammateTask ? false : isBriefOnly}). That // prop isn't threaded here, so replicate the gate from the store — // teammate view needs the real spinner (which shows teammate status). - const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId) + const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId); // Hoisted to mount-time — this component re-renders at animation framerate. const briefEnvEnabled = feature('KAIROS') || feature('KAIROS_BRIEF') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), []) - : false + ? useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_BRIEF), []) + : false; // Runtime gate mirrors isBriefEnabled() but inlined — importing from // BriefTool.ts would leak tool-name strings into external builds. Single @@ -105,18 +89,14 @@ export function SpinnerWithVerb(props: Props): React.ReactNode { if ( (feature('KAIROS') || feature('KAIROS_BRIEF')) && (getKairosActive() || - (getUserMsgOptIn() && - (briefEnvEnabled || - getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_brief', false)))) && + (getUserMsgOptIn() && (briefEnvEnabled || getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_brief', false)))) && isBriefOnly && !viewingAgentTaskId ) { - return ( - - ) + return ; } - return + return ; } function SpinnerWithVerbInner({ @@ -134,8 +114,8 @@ function SpinnerWithVerbInner({ hasActiveTools = false, leaderIsIdle = false, }: Props): React.ReactNode { - const settings = useSettings() - const reducedMotion = settings.prefersReducedMotion ?? false + const settings = useSettings(); + const reducedMotion = settings.prefersReducedMotion ?? false; // NOTE: useAnimationFrame(50) lives in SpinnerAnimationRow, not here. // This component only re-renders when props or app state change — @@ -143,114 +123,102 @@ function SpinnerWithVerbInner({ // (frame, glimmer, stalled intensity, token counter, thinking shimmer, // elapsed-time timer) are computed inside the child. - const tasks = useAppState(s => s.tasks) - const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId) - const expandedView = useAppState(s => s.expandedView) - const showExpandedTodos = expandedView === 'tasks' - const showSpinnerTree = expandedView === 'teammates' - const selectedIPAgentIndex = useAppState(s => s.selectedIPAgentIndex) - const viewSelectionMode = useAppState(s => s.viewSelectionMode) + const tasks = useAppState(s => s.tasks); + const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId); + const expandedView = useAppState(s => s.expandedView); + const showExpandedTodos = expandedView === 'tasks'; + const showSpinnerTree = expandedView === 'teammates'; + const selectedIPAgentIndex = useAppState(s => s.selectedIPAgentIndex); + const viewSelectionMode = useAppState(s => s.viewSelectionMode); // Get foregrounded teammate (if viewing a teammate's transcript) - const foregroundedTeammate = viewingAgentTaskId - ? getViewedTeammateTask({ viewingAgentTaskId, tasks }) - : undefined - const { columns } = useTerminalSize() - const tasksV2 = useTasksV2() + const foregroundedTeammate = viewingAgentTaskId ? getViewedTeammateTask({ viewingAgentTaskId, tasks }) : undefined; + const { columns } = useTerminalSize(); + const tasksV2 = useTasksV2(); // Track thinking status: 'thinking' | number (duration in ms) | null // Shows each state for minimum 2s to avoid UI jank - const [thinkingStatus, setThinkingStatus] = useState< - 'thinking' | number | null - >(null) - const thinkingStartRef = useRef(null) + const [thinkingStatus, setThinkingStatus] = useState<'thinking' | number | null>(null); + const thinkingStartRef = useRef(null); useEffect(() => { - let showDurationTimer: ReturnType | null = null - let clearStatusTimer: ReturnType | null = null + let showDurationTimer: ReturnType | null = null; + let clearStatusTimer: ReturnType | null = null; if (mode === 'thinking') { // Started thinking if (thinkingStartRef.current === null) { - thinkingStartRef.current = Date.now() - setThinkingStatus('thinking') + thinkingStartRef.current = Date.now(); + setThinkingStatus('thinking'); } } else if (thinkingStartRef.current !== null) { // Stopped thinking - calculate duration and ensure 2s minimum display - const duration = Date.now() - thinkingStartRef.current - const elapsed = Date.now() - thinkingStartRef.current - const remainingThinkingTime = Math.max(0, 2000 - elapsed) + const duration = Date.now() - thinkingStartRef.current; + const elapsed = Date.now() - thinkingStartRef.current; + const remainingThinkingTime = Math.max(0, 2000 - elapsed); - thinkingStartRef.current = null + thinkingStartRef.current = null; // Show "thinking..." for remaining time if < 2s elapsed, then show duration const showDuration = (): void => { - setThinkingStatus(duration) + setThinkingStatus(duration); // Clear after 2s - clearStatusTimer = setTimeout(setThinkingStatus, 2000, null) - } + clearStatusTimer = setTimeout(setThinkingStatus, 2000, null); + }; if (remainingThinkingTime > 0) { - showDurationTimer = setTimeout(showDuration, remainingThinkingTime) + showDurationTimer = setTimeout(showDuration, remainingThinkingTime); } else { - showDuration() + showDuration(); } } return () => { - if (showDurationTimer) clearTimeout(showDurationTimer) - if (clearStatusTimer) clearTimeout(clearStatusTimer) - } - }, [mode]) + if (showDurationTimer) clearTimeout(showDurationTimer); + if (clearStatusTimer) clearTimeout(clearStatusTimer); + }; + }, [mode]); // Find the current in-progress task and next pending task - const currentTodo = tasksV2?.find( - task => task.status !== 'pending' && task.status !== 'completed', - ) - const nextTask = findNextPendingTask(tasksV2) + const currentTodo = tasksV2?.find(task => task.status !== 'pending' && task.status !== 'completed'); + const nextTask = findNextPendingTask(tasksV2); // Use useState with initializer to pick a random verb once on mount - const [randomVerb] = useState(() => sample(getSpinnerVerbs())) + const [randomVerb] = useState(() => sample(getSpinnerVerbs())); // Leader's own verb (always the leader's, regardless of who is foregrounded) - const leaderVerb = - overrideMessage ?? - currentTodo?.activeForm ?? - currentTodo?.subject ?? - randomVerb + const leaderVerb = overrideMessage ?? currentTodo?.activeForm ?? currentTodo?.subject ?? randomVerb; const effectiveVerb = foregroundedTeammate && !foregroundedTeammate.isIdle ? (foregroundedTeammate.spinnerVerb ?? randomVerb) - : leaderVerb - const message = effectiveVerb + '…' + : leaderVerb; + const message = effectiveVerb + '…'; // Track CLI activity when spinner is active useEffect(() => { - const operationId = 'spinner-' + mode - activityManager.startCLIActivity(operationId) + const operationId = 'spinner-' + mode; + activityManager.startCLIActivity(operationId); return () => { - activityManager.endCLIActivity(operationId) - } - }, [mode]) + activityManager.endCLIActivity(operationId); + }; + }, [mode]); - const effortValue = useAppState(s => s.effortValue) - const effortSuffix = getEffortSuffix(getMainLoopModel(), effortValue) + const effortValue = useAppState(s => s.effortValue); + const effortSuffix = getEffortSuffix(getMainLoopModel(), effortValue); // Check if any running in-process teammates exist (needed for both modes) - const runningTeammates = getAllInProcessTeammateTasks(tasks).filter( - t => t.status === 'running', - ) - const hasRunningTeammates = runningTeammates.length > 0 - const allIdle = hasRunningTeammates && runningTeammates.every(t => t.isIdle) + const runningTeammates = getAllInProcessTeammateTasks(tasks).filter(t => t.status === 'running'); + const hasRunningTeammates = runningTeammates.length > 0; + const allIdle = hasRunningTeammates && runningTeammates.every(t => t.isIdle); // Gather aggregate token stats from all running swarm teammates // In spinner-tree mode, skip aggregation (teammates have their own lines in the tree) - let teammateTokens = 0 + let teammateTokens = 0; if (!showSpinnerTree) { for (const task of Object.values(tasks)) { if (isInProcessTeammateTask(task) && task.status === 'running') { if (task.progress?.tokenCount) { - teammateTokens += task.progress.tokenCount + teammateTokens += task.progress.tokenCount; } } } @@ -261,24 +229,22 @@ function SpinnerWithVerbInner({ // a coarse 30s threshold. const elapsedSnapshot = pauseStartTimeRef.current !== null - ? pauseStartTimeRef.current - - loadingStartTimeRef.current - - totalPausedMsRef.current - : Date.now() - loadingStartTimeRef.current - totalPausedMsRef.current + ? pauseStartTimeRef.current - loadingStartTimeRef.current - totalPausedMsRef.current + : Date.now() - loadingStartTimeRef.current - totalPausedMsRef.current; // Leader token count for TeammateSpinnerTree — read raw (non-animated) from // the ref. The tree is only shown when teammates are running; teammate // progress updates to s.tasks trigger re-renders that keep this fresh. - const leaderTokenCount = Math.round(responseLengthRef.current / 4) + const leaderTokenCount = Math.round(responseLengthRef.current / 4); - const defaultColor: keyof Theme = 'claude' - const defaultShimmerColor = 'claudeShimmer' - const messageColor = overrideColor ?? defaultColor - const shimmerColor = overrideShimmerColor ?? defaultShimmerColor + const defaultColor: keyof Theme = 'claude'; + const defaultShimmerColor = 'claudeShimmer'; + const messageColor = overrideColor ?? defaultColor; + const shimmerColor = overrideShimmerColor ?? defaultShimmerColor; // TTFT display is gated to internal builds — apiMetricsRef was removed from // props during a refactor, so skip this until it's re-threaded. - let ttftText: string | null = null + let ttftText: string | null = null; // When leader is idle but teammates are running (and we're viewing the leader), // show a static dim idle display instead of the animated spinner — otherwise @@ -302,14 +268,14 @@ function SpinnerWithVerbInner({ /> )} - ) + ); } // When viewing an idle teammate, show static idle display instead of animated spinner if (foregroundedTeammate?.isIdle) { const idleText = allIdle ? `${TEARDROP_ASTERISK} Worked for ${formatDuration(Date.now() - foregroundedTeammate.startTime)}` - : `${TEARDROP_ASTERISK} Idle` + : `${TEARDROP_ASTERISK} Idle`; return ( @@ -326,17 +292,16 @@ function SpinnerWithVerbInner({ /> )} - ) + ); } // Time-based tip overrides: coarse thresholds so a stale ref read (we're // off the 50ms clock) is fine. Other triggers (mode change, setMessages) // cause re-renders that refresh this in practice. - let contextTipsActive = false - const tipsEnabled = settings.spinnerTipsEnabled !== false - const showClearTip = tipsEnabled && elapsedSnapshot > 1_800_000 - const showBtwTip = - tipsEnabled && elapsedSnapshot > 30_000 && !getGlobalConfig().btwUseCount + let contextTipsActive = false; + const tipsEnabled = settings.spinnerTipsEnabled !== false; + const showClearTip = tipsEnabled && elapsedSnapshot > 1_800_000; + const showBtwTip = tipsEnabled && elapsedSnapshot > 30_000 && !getGlobalConfig().btwUseCount; const effectiveTip = contextTipsActive ? undefined @@ -344,28 +309,22 @@ function SpinnerWithVerbInner({ ? 'Use /clear to start fresh when switching topics and free up context' : showBtwTip && !nextTask ? "Use /btw to ask a quick side question without interrupting Claude's current work" - : spinnerTip + : spinnerTip; // Budget text (ant-only) — shown above the tip line - let budgetText: string | null = null + let budgetText: string | null = null; if (feature('TOKEN_BUDGET')) { - const budget = getCurrentTurnTokenBudget() + const budget = getCurrentTurnTokenBudget(); if (budget !== null && budget > 0) { - const tokens = getTurnOutputTokens() + const tokens = getTurnOutputTokens(); if (tokens >= budget) { - budgetText = `Target: ${formatNumber(tokens)} used (${formatNumber(budget)} min ${figures.tick})` + budgetText = `Target: ${formatNumber(tokens)} used (${formatNumber(budget)} min ${figures.tick})`; } else { - const pct = Math.round((tokens / budget) * 100) - const remaining = budget - tokens - const rate = - elapsedSnapshot > 5000 && tokens >= 2000 - ? tokens / elapsedSnapshot - : 0 - const eta = - rate > 0 - ? ` \u00B7 ~${formatDuration(remaining / rate, { mostSignificantOnly: true })}` - : '' - budgetText = `Target: ${formatNumber(tokens)} / ${formatNumber(budget)} (${pct}%)${eta}` + const pct = Math.round((tokens / budget) * 100); + const remaining = budget - tokens; + const rate = elapsedSnapshot > 5000 && tokens >= 2000 ? tokens / elapsedSnapshot : 0; + const eta = rate > 0 ? ` \u00B7 ~${formatDuration(remaining / rate, { mostSignificantOnly: true })}` : ''; + budgetText = `Target: ${formatNumber(tokens)} / ${formatNumber(budget)} (${pct}%)${eta}`; } } } @@ -421,17 +380,13 @@ function SpinnerWithVerbInner({ )} {(nextTask || effectiveTip) && ( - - {nextTask - ? `Next: ${nextTask.subject}` - : `Tip: ${effectiveTip}`} - + {nextTask ? `Next: ${nextTask.subject}` : `Tip: ${effectiveTip}`} )} ) : null} - ) + ); } // Brief/assistant mode spinner: single status line. PromptInput drops its @@ -444,71 +399,60 @@ function SpinnerWithVerbInner({ // spinner, not over the spinner content. Paired with BriefIdleStatus which // keeps the same footprint when idle. type BriefSpinnerProps = { - mode: SpinnerMode - overrideMessage?: string | null -} + mode: SpinnerMode; + overrideMessage?: string | null; +}; -function BriefSpinner({ - mode, - overrideMessage, -}: BriefSpinnerProps): React.ReactNode { - const settings = useSettings() - const reducedMotion = settings.prefersReducedMotion ?? false - const [randomVerb] = useState(() => sample(getSpinnerVerbs()) ?? 'Working') - const verb = overrideMessage ?? randomVerb - const connStatus = useAppState(s => s.remoteConnectionStatus) +function BriefSpinner({ mode, overrideMessage }: BriefSpinnerProps): React.ReactNode { + const settings = useSettings(); + const reducedMotion = settings.prefersReducedMotion ?? false; + const [randomVerb] = useState(() => sample(getSpinnerVerbs()) ?? 'Working'); + const verb = overrideMessage ?? randomVerb; + const connStatus = useAppState(s => s.remoteConnectionStatus); // Track CLI activity so OS/IDE "busy" indicators fire in brief mode too useEffect(() => { - const operationId = 'spinner-' + mode - activityManager.startCLIActivity(operationId) + const operationId = 'spinner-' + mode; + activityManager.startCLIActivity(operationId); return () => { - activityManager.endCLIActivity(operationId) - } - }, [mode]) + activityManager.endCLIActivity(operationId); + }; + }, [mode]); // Drive both dot cycle and shimmer from the shared clock. The viewport // ref is unused — the spinner unmounts on turn end so viewport-based // pausing isn't needed. - const [, time] = useAnimationFrame(reducedMotion ? null : 120) + const [, time] = useAnimationFrame(reducedMotion ? null : 120); // Local tasks + remote tasks are mutually exclusive (viewer mode has an // empty local AppState.tasks; local mode has remoteBackgroundTaskCount=0). // Summing avoids a mode branch. - const runningCount = useAppState( - s => - count(Object.values(s.tasks), isBackgroundTask) + - s.remoteBackgroundTaskCount, - ) + const runningCount = useAppState(s => count(Object.values(s.tasks), isBackgroundTask) + s.remoteBackgroundTaskCount); // Connection trouble overrides the verb — `claude assistant` is a pure viewer, // nothing useful is happening while the WS is down. - const showConnWarning = - connStatus === 'reconnecting' || connStatus === 'disconnected' - const connText = - connStatus === 'reconnecting' ? 'Reconnecting' : 'Disconnected' + const showConnWarning = connStatus === 'reconnecting' || connStatus === 'disconnected'; + const connText = connStatus === 'reconnecting' ? 'Reconnecting' : 'Disconnected'; // Dots padded to a fixed 3 columns so the right-aligned count doesn't // jitter as the cycle advances. - const dotFrame = Math.floor(time / 300) % 3 - const dots = reducedMotion ? '… ' : '.'.repeat(dotFrame + 1).padEnd(3) + const dotFrame = Math.floor(time / 300) % 3; + const dots = reducedMotion ? '… ' : '.'.repeat(dotFrame + 1).padEnd(3); // Shimmer: reverse-sweep highlight across the verb. Skip for connection // warnings (shimmer reads as "working"; Reconnecting/Disconnected is not). - const verbWidth = useMemo(() => stringWidth(verb), [verb]) + const verbWidth = useMemo(() => stringWidth(verb), [verb]); const glimmerIndex = - reducedMotion || showConnWarning - ? -100 - : computeGlimmerIndex(Math.floor(time / SHIMMER_INTERVAL_MS), verbWidth) - const { before, shimmer, after } = computeShimmerSegments(verb, glimmerIndex) + reducedMotion || showConnWarning ? -100 : computeGlimmerIndex(Math.floor(time / SHIMMER_INTERVAL_MS), verbWidth); + const { before, shimmer, after } = computeShimmerSegments(verb, glimmerIndex); - const { columns } = useTerminalSize() - const rightText = runningCount > 0 ? `${runningCount} in background` : '' + const { columns } = useTerminalSize(); + const rightText = runningCount > 0 ? `${runningCount} in background` : ''; // Manual right-align via space padding — flexGrow spacers inside // FullscreenLayout's `main` slot don't resolve a width and caused the // diff engine to miss dot-frame updates. - const leftWidth = (showConnWarning ? stringWidth(connText) : verbWidth) + 3 - const pad = Math.max(1, columns - 2 - leftWidth - stringWidth(rightText)) + const leftWidth = (showConnWarning ? stringWidth(connText) : verbWidth) + 3; + const pad = Math.max(1, columns - 2 - leftWidth - stringWidth(rightText)); return ( @@ -529,7 +473,7 @@ function BriefSpinner({ ) : null} - ) + ); } // Idle placeholder for brief mode. Same 2-row [blank, content] footprint @@ -537,27 +481,18 @@ function BriefSpinner({ // working/idle/disconnected. See BriefSpinner's comment for the // Notifications overlay coupling. export function BriefIdleStatus(): React.ReactNode { - const connStatus = useAppState(s => s.remoteConnectionStatus) - const runningCount = useAppState( - s => - count(Object.values(s.tasks), isBackgroundTask) + - s.remoteBackgroundTaskCount, - ) - const { columns } = useTerminalSize() + const connStatus = useAppState(s => s.remoteConnectionStatus); + const runningCount = useAppState(s => count(Object.values(s.tasks), isBackgroundTask) + s.remoteBackgroundTaskCount); + const { columns } = useTerminalSize(); - const showConnWarning = - connStatus === 'reconnecting' || connStatus === 'disconnected' - const connText = - connStatus === 'reconnecting' ? 'Reconnecting…' : 'Disconnected' - const leftText = showConnWarning ? connText : '' - const rightText = runningCount > 0 ? `${runningCount} in background` : '' + const showConnWarning = connStatus === 'reconnecting' || connStatus === 'disconnected'; + const connText = connStatus === 'reconnecting' ? 'Reconnecting…' : 'Disconnected'; + const leftText = showConnWarning ? connText : ''; + const rightText = runningCount > 0 ? `${runningCount} in background` : ''; - if (!leftText && !rightText) return + if (!leftText && !rightText) return ; - const pad = Math.max( - 1, - columns - 2 - stringWidth(leftText) - stringWidth(rightText), - ) + const pad = Math.max(1, columns - 2 - stringWidth(leftText) - stringWidth(rightText)); return ( @@ -570,13 +505,13 @@ export function BriefIdleStatus(): React.ReactNode { ) : null} - ) + ); } export function Spinner(): React.ReactNode { - const settings = useSettings() - const reducedMotion = settings.prefersReducedMotion ?? false - const [ref, time] = useAnimationFrame(reducedMotion ? null : 120) + const settings = useSettings(); + const reducedMotion = settings.prefersReducedMotion ?? false; + const [ref, time] = useAnimationFrame(reducedMotion ? null : 120); // Reduced motion: static dot instead of animated spinner if (reducedMotion) { @@ -584,33 +519,27 @@ export function Spinner(): React.ReactNode { - ) + ); } // Derive frame from synced time - all spinners animate together - const frame = Math.floor(time / 120) % SPINNER_FRAMES.length + const frame = Math.floor(time / 120) % SPINNER_FRAMES.length; return ( {SPINNER_FRAMES[frame]} - ) + ); } - function findNextPendingTask(tasks: Task[] | undefined): Task | undefined { if (!tasks) { - return undefined + return undefined; } - const pendingTasks = tasks.filter(t => t.status === 'pending') + const pendingTasks = tasks.filter(t => t.status === 'pending'); if (pendingTasks.length === 0) { - return undefined + return undefined; } - const unresolvedIds = new Set( - tasks.filter(t => t.status !== 'completed').map(t => t.id), - ) - return ( - pendingTasks.find(t => !t.blockedBy.some(id => unresolvedIds.has(id))) ?? - pendingTasks[0] - ) + const unresolvedIds = new Set(tasks.filter(t => t.status !== 'completed').map(t => t.id)); + return pendingTasks.find(t => !t.blockedBy.some(id => unresolvedIds.has(id))) ?? pendingTasks[0]; } diff --git a/src/components/Spinner/FlashingChar.tsx b/src/components/Spinner/FlashingChar.tsx index bfb190999..407cd7d85 100644 --- a/src/components/Spinner/FlashingChar.tsx +++ b/src/components/Spinner/FlashingChar.tsx @@ -1,39 +1,32 @@ -import * as React from 'react' -import { Text, useTheme } from '@anthropic/ink' -import { getTheme, type Theme } from '../../utils/theme.js' -import { interpolateColor, parseRGB, toRGBColor } from './utils.js' +import * as React from 'react'; +import { Text, useTheme } from '@anthropic/ink'; +import { getTheme, type Theme } from '../../utils/theme.js'; +import { interpolateColor, parseRGB, toRGBColor } from './utils.js'; type Props = { - char: string - flashOpacity: number - messageColor: keyof Theme - shimmerColor: keyof Theme -} + char: string; + flashOpacity: number; + messageColor: keyof Theme; + shimmerColor: keyof Theme; +}; -export function FlashingChar({ - char, - flashOpacity, - messageColor, - shimmerColor, -}: Props): React.ReactNode { - const [themeName] = useTheme() - const theme = getTheme(themeName) +export function FlashingChar({ char, flashOpacity, messageColor, shimmerColor }: Props): React.ReactNode { + const [themeName] = useTheme(); + const theme = getTheme(themeName); - const baseColorStr = theme[messageColor] - const shimmerColorStr = theme[shimmerColor] + const baseColorStr = theme[messageColor]; + const shimmerColorStr = theme[shimmerColor]; - const baseRGB = baseColorStr ? parseRGB(baseColorStr) : null - const shimmerRGB = shimmerColorStr ? parseRGB(shimmerColorStr) : null + const baseRGB = baseColorStr ? parseRGB(baseColorStr) : null; + const shimmerRGB = shimmerColorStr ? parseRGB(shimmerColorStr) : null; if (baseRGB && shimmerRGB) { // Smooth interpolation between colors - const interpolated = interpolateColor(baseRGB, shimmerRGB, flashOpacity) - return {char} + const interpolated = interpolateColor(baseRGB, shimmerRGB, flashOpacity); + return {char}; } // Fallback for ANSI themes: binary switch - const shouldUseShimmer = flashOpacity > 0.5 - return ( - {char} - ) + const shouldUseShimmer = flashOpacity > 0.5; + return {char}; } diff --git a/src/components/Spinner/GlimmerMessage.tsx b/src/components/Spinner/GlimmerMessage.tsx index 9f8ee9265..b6528fe28 100644 --- a/src/components/Spinner/GlimmerMessage.tsx +++ b/src/components/Spinner/GlimmerMessage.tsx @@ -1,21 +1,21 @@ -import * as React from 'react' -import { Text, stringWidth, useTheme } from '@anthropic/ink' -import { getGraphemeSegmenter } from '../../utils/intl.js' -import { getTheme, type Theme } from '../../utils/theme.js' -import type { SpinnerMode } from './types.js' -import { interpolateColor, parseRGB, toRGBColor } from './utils.js' +import * as React from 'react'; +import { Text, stringWidth, useTheme } from '@anthropic/ink'; +import { getGraphemeSegmenter } from '../../utils/intl.js'; +import { getTheme, type Theme } from '../../utils/theme.js'; +import type { SpinnerMode } from './types.js'; +import { interpolateColor, parseRGB, toRGBColor } from './utils.js'; type Props = { - message: string - mode: SpinnerMode - messageColor: keyof Theme - glimmerIndex: number - flashOpacity: number - shimmerColor: keyof Theme - stalledIntensity?: number -} + message: string; + mode: SpinnerMode; + messageColor: keyof Theme; + glimmerIndex: number; + flashOpacity: number; + shimmerColor: keyof Theme; + stalledIntensity?: number; +}; -const ERROR_RED = { r: 171, g: 43, b: 63 } +const ERROR_RED = { r: 171, g: 43, b: 63 }; export function GlimmerMessage({ message, @@ -26,83 +26,79 @@ export function GlimmerMessage({ shimmerColor, stalledIntensity = 0, }: Props): React.ReactNode { - const [themeName] = useTheme() - const theme = getTheme(themeName) + const [themeName] = useTheme(); + const theme = getTheme(themeName); // This component re-renders at 20fps (glimmerIndex changes every 50ms) but // message is stable within a turn. Precompute grapheme segmentation + widths // once per message instead of per frame. Measured -81% on the shimmer path. const { segments, messageWidth } = React.useMemo(() => { - const segs: { segment: string; width: number }[] = [] + const segs: { segment: string; width: number }[] = []; for (const { segment } of getGraphemeSegmenter().segment(message)) { - segs.push({ segment, width: stringWidth(segment) }) + segs.push({ segment, width: stringWidth(segment) }); } - return { segments: segs, messageWidth: stringWidth(message) } - }, [message]) + return { segments: segs, messageWidth: stringWidth(message) }; + }, [message]); - if (!message) return null + if (!message) return null; // When stalled, show text that smoothly transitions to red if (stalledIntensity > 0) { - const baseColorStr = theme[messageColor] - const baseRGB = baseColorStr ? parseRGB(baseColorStr) : null + const baseColorStr = theme[messageColor]; + const baseRGB = baseColorStr ? parseRGB(baseColorStr) : null; if (baseRGB) { - const interpolated = interpolateColor( - baseRGB, - ERROR_RED, - stalledIntensity, - ) - const color = toRGBColor(interpolated) + const interpolated = interpolateColor(baseRGB, ERROR_RED, stalledIntensity); + const color = toRGBColor(interpolated); return ( <> {message} - ) + ); } // Fallback for ANSI themes: use messageColor until fully stalled, then error - const color = stalledIntensity > 0.5 ? 'error' : messageColor + const color = stalledIntensity > 0.5 ? 'error' : messageColor; return ( <> {message} - ) + ); } // tool-use mode: all chars flash with the same opacity, so render as a // single instead of N individual FlashingChar components. if (mode === 'tool-use') { - const baseColorStr = theme[messageColor] - const shimmerColorStr = theme[shimmerColor] - const baseRGB = baseColorStr ? parseRGB(baseColorStr) : null - const shimmerRGB = shimmerColorStr ? parseRGB(shimmerColorStr) : null + const baseColorStr = theme[messageColor]; + const shimmerColorStr = theme[shimmerColor]; + const baseRGB = baseColorStr ? parseRGB(baseColorStr) : null; + const shimmerRGB = shimmerColorStr ? parseRGB(shimmerColorStr) : null; if (baseRGB && shimmerRGB) { - const interpolated = interpolateColor(baseRGB, shimmerRGB, flashOpacity) + const interpolated = interpolateColor(baseRGB, shimmerRGB, flashOpacity); return ( <> {message} - ) + ); } - const color = flashOpacity > 0.5 ? shimmerColor : messageColor + const color = flashOpacity > 0.5 ? shimmerColor : messageColor; return ( <> {message} - ) + ); } // Shimmer mode: only chars within ±1 of glimmerIndex need the shimmer // color. When glimmer is offscreen, render as a single . - const shimmerStart = glimmerIndex - 1 - const shimmerEnd = glimmerIndex + 1 + const shimmerStart = glimmerIndex - 1; + const shimmerEnd = glimmerIndex + 1; if (shimmerStart >= messageWidth || shimmerEnd < 0) { return ( @@ -110,24 +106,24 @@ export function GlimmerMessage({ {message} - ) + ); } // Split into at most 3 segments by visual column position - const clampedStart = Math.max(0, shimmerStart) - let colPos = 0 - let before = '' - let shim = '' - let after = '' + const clampedStart = Math.max(0, shimmerStart); + let colPos = 0; + let before = ''; + let shim = ''; + let after = ''; for (const { segment, width } of segments) { if (colPos + width <= clampedStart) { - before += segment + before += segment; } else if (colPos > shimmerEnd) { - after += segment + after += segment; } else { - shim += segment + shim += segment; } - colPos += width + colPos += width; } return ( @@ -137,5 +133,5 @@ export function GlimmerMessage({ {after && {after}} - ) + ); } diff --git a/src/components/Spinner/ShimmerChar.tsx b/src/components/Spinner/ShimmerChar.tsx index 0daa44c67..5e4ed5706 100644 --- a/src/components/Spinner/ShimmerChar.tsx +++ b/src/components/Spinner/ShimmerChar.tsx @@ -1,27 +1,19 @@ -import * as React from 'react' -import { Text } from '@anthropic/ink' -import type { Theme } from '../../utils/theme.js' +import * as React from 'react'; +import { Text } from '@anthropic/ink'; +import type { Theme } from '../../utils/theme.js'; type Props = { - char: string - index: number - glimmerIndex: number - messageColor: keyof Theme - shimmerColor: keyof Theme -} + char: string; + index: number; + glimmerIndex: number; + messageColor: keyof Theme; + shimmerColor: keyof Theme; +}; -export function ShimmerChar({ - char, - index, - glimmerIndex, - messageColor, - shimmerColor, -}: Props): React.ReactNode { - const isHighlighted = index === glimmerIndex - const isNearHighlight = Math.abs(index - glimmerIndex) === 1 - const shouldUseShimmer = isHighlighted || isNearHighlight +export function ShimmerChar({ char, index, glimmerIndex, messageColor, shimmerColor }: Props): React.ReactNode { + const isHighlighted = index === glimmerIndex; + const isNearHighlight = Math.abs(index - glimmerIndex) === 1; + const shouldUseShimmer = isHighlighted || isNearHighlight; - return ( - {char} - ) + return {char}; } diff --git a/src/components/Spinner/SpinnerAnimationRow.tsx b/src/components/Spinner/SpinnerAnimationRow.tsx index 57b8358f2..c2ad3dd76 100644 --- a/src/components/Spinner/SpinnerAnimationRow.tsx +++ b/src/components/Spinner/SpinnerAnimationRow.tsx @@ -1,66 +1,65 @@ -import figures from 'figures' -import * as React from 'react' -import { useMemo, useRef } from 'react' -import { Box, Text, useAnimationFrame, stringWidth, Byline } from '@anthropic/ink' -import { toInkColor } from '../../utils/ink.js' -import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js' -import { formatDuration, formatNumber } from '../../utils/format.js' +import figures from 'figures'; +import * as React from 'react'; +import { useMemo, useRef } from 'react'; +import { Box, Text, useAnimationFrame, stringWidth, Byline } from '@anthropic/ink'; +import { toInkColor } from '../../utils/ink.js'; +import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js'; +import { formatDuration, formatNumber } from '../../utils/format.js'; -import type { Theme } from '../../utils/theme.js' +import type { Theme } from '../../utils/theme.js'; -import { GlimmerMessage } from './GlimmerMessage.js' -import { SpinnerGlyph } from './SpinnerGlyph.js' -import type { SpinnerMode } from './types.js' -import { useStalledAnimation } from './useStalledAnimation.js' -import { interpolateColor, toRGBColor } from './utils.js' +import { GlimmerMessage } from './GlimmerMessage.js'; +import { SpinnerGlyph } from './SpinnerGlyph.js'; +import type { SpinnerMode } from './types.js'; +import { useStalledAnimation } from './useStalledAnimation.js'; +import { interpolateColor, toRGBColor } from './utils.js'; -const SEP_WIDTH = stringWidth(' · ') -const THINKING_BARE_WIDTH = stringWidth('thinking') -const SHOW_TOKENS_AFTER_MS = 30_000 +const SEP_WIDTH = stringWidth(' · '); +const THINKING_BARE_WIDTH = stringWidth('thinking'); +const SHOW_TOKENS_AFTER_MS = 30_000; // Thinking shimmer constants. Previously lived in a separate ThinkingShimmerText // component with its own useAnimationFrame(50) — inlined here to reuse our // existing 50ms clock and eliminate the redundant subscriber. -const THINKING_INACTIVE = { r: 153, g: 153, b: 153 } -const THINKING_INACTIVE_SHIMMER = { r: 185, g: 185, b: 185 } -const THINKING_DELAY_MS = 3000 -const THINKING_GLOW_PERIOD_S = 2 +const THINKING_INACTIVE = { r: 153, g: 153, b: 153 }; +const THINKING_INACTIVE_SHIMMER = { r: 185, g: 185, b: 185 }; +const THINKING_DELAY_MS = 3000; +const THINKING_GLOW_PERIOD_S = 2; export type SpinnerAnimationRowProps = { // Animation inputs - mode: SpinnerMode - reducedMotion: boolean - hasActiveTools: boolean - responseLengthRef: React.RefObject + mode: SpinnerMode; + reducedMotion: boolean; + hasActiveTools: boolean; + responseLengthRef: React.RefObject; // Message (stable within a turn) - message: string - messageColor: keyof Theme - shimmerColor: keyof Theme - overrideColor?: keyof Theme | null + message: string; + messageColor: keyof Theme; + shimmerColor: keyof Theme; + overrideColor?: keyof Theme | null; // Timer refs (stable references) - loadingStartTimeRef: React.RefObject - totalPausedMsRef: React.RefObject - pauseStartTimeRef: React.RefObject + loadingStartTimeRef: React.RefObject; + totalPausedMsRef: React.RefObject; + pauseStartTimeRef: React.RefObject; // Display flags - spinnerSuffix?: string | null - verbose: boolean - columns: number + spinnerSuffix?: string | null; + verbose: boolean; + columns: number; // Teammate-derived (computed by parent from tasks) - hasRunningTeammates: boolean - teammateTokens: number - foregroundedTeammate: InProcessTeammateTaskState | undefined + hasRunningTeammates: boolean; + teammateTokens: number; + foregroundedTeammate: InProcessTeammateTaskState | undefined; /** Leader's turn has completed. Suppresses stall-red since responseLengthRef/hasActiveTools track leader state only. */ - leaderIsIdle?: boolean + leaderIsIdle?: boolean; // Thinking (state owned by parent, mode-dependent) - thinkingStatus: 'thinking' | number | null - effortSuffix: string - -} + thinkingStatus: 'thinking' | number | null; + effortSuffix: string; +}; /** * The 50ms-animated portion of SpinnerWithVerb. Owns useAnimationFrame(50) @@ -94,30 +93,28 @@ export function SpinnerAnimationRow({ thinkingStatus, effortSuffix, }: SpinnerAnimationRowProps): React.ReactNode { - const [viewportRef, time] = useAnimationFrame(reducedMotion ? null : 50) + const [viewportRef, time] = useAnimationFrame(reducedMotion ? null : 50); // === Elapsed time (wall-clock, derived from refs each frame) === - const now = Date.now() + const now = Date.now(); const elapsedTimeMs = pauseStartTimeRef.current !== null - ? pauseStartTimeRef.current - - loadingStartTimeRef.current - - totalPausedMsRef.current - : now - loadingStartTimeRef.current - totalPausedMsRef.current + ? pauseStartTimeRef.current - loadingStartTimeRef.current - totalPausedMsRef.current + : now - loadingStartTimeRef.current - totalPausedMsRef.current; // Track wall-clock turn start for teammates. While a swarm is running the // leader's elapsedTimeMs may jump around (new API calls reset // loadingStartTimeRef; pauses freeze it), so we anchor to the earliest // derived start seen so far. When no teammates are running this just tracks // derivedStart every frame, effectively resetting for the next swarm. - const derivedStart = now - elapsedTimeMs - const turnStartRef = useRef(derivedStart) + const derivedStart = now - elapsedTimeMs; + const turnStartRef = useRef(derivedStart); if (!hasRunningTeammates || derivedStart < turnStartRef.current) { - turnStartRef.current = derivedStart + turnStartRef.current = derivedStart; } // === Animation derivations from `time` === - const currentResponseLength = responseLengthRef.current + const currentResponseLength = responseLengthRef.current; // Suppress stall detection when leader is idle — responseLengthRef and // hasActiveTools both track leader state. When viewing an active teammate @@ -128,70 +125,59 @@ export function SpinnerAnimationRow({ currentResponseLength, hasActiveTools || leaderIsIdle, reducedMotion, - ) + ); - const frame = reducedMotion ? 0 : Math.floor(time / 120) + const frame = reducedMotion ? 0 : Math.floor(time / 120); - const glimmerSpeed = mode === 'requesting' ? 50 : 200 + const glimmerSpeed = mode === 'requesting' ? 50 : 200; // message is stable within a turn; stringWidth is expensive enough (Bun native // call per code point) to memoize explicitly across the 50ms loop. - const glimmerMessageWidth = useMemo(() => stringWidth(message), [message]) - const cycleLength = glimmerMessageWidth + 20 - const cyclePosition = Math.floor(time / glimmerSpeed) + const glimmerMessageWidth = useMemo(() => stringWidth(message), [message]); + const cycleLength = glimmerMessageWidth + 20; + const cyclePosition = Math.floor(time / glimmerSpeed); const glimmerIndex = reducedMotion ? -100 : isStalled ? -100 : mode === 'requesting' ? (cyclePosition % cycleLength) - 10 - : glimmerMessageWidth + 10 - (cyclePosition % cycleLength) + : glimmerMessageWidth + 10 - (cyclePosition % cycleLength); - const flashOpacity = reducedMotion - ? 0 - : mode === 'tool-use' - ? (Math.sin((time / 1000) * Math.PI) + 1) / 2 - : 0 + const flashOpacity = reducedMotion ? 0 : mode === 'tool-use' ? (Math.sin((time / 1000) * Math.PI) + 1) / 2 : 0; // === Token counter animation (smooth increment, driven by 50ms clock) === - const tokenCounterRef = useRef(currentResponseLength) + const tokenCounterRef = useRef(currentResponseLength); if (reducedMotion) { - tokenCounterRef.current = currentResponseLength + tokenCounterRef.current = currentResponseLength; } else { - const gap = currentResponseLength - tokenCounterRef.current + const gap = currentResponseLength - tokenCounterRef.current; if (gap > 0) { - let increment + let increment; if (gap < 70) { - increment = 3 + increment = 3; } else if (gap < 200) { - increment = Math.max(8, Math.ceil(gap * 0.15)) + increment = Math.max(8, Math.ceil(gap * 0.15)); } else { - increment = 50 + increment = 50; } - tokenCounterRef.current = Math.min( - tokenCounterRef.current + increment, - currentResponseLength, - ) + tokenCounterRef.current = Math.min(tokenCounterRef.current + increment, currentResponseLength); } } - const displayedResponseLength = tokenCounterRef.current - const leaderTokens = Math.round(displayedResponseLength / 4) + const displayedResponseLength = tokenCounterRef.current; + const leaderTokens = Math.round(displayedResponseLength / 4); - const effectiveElapsedMs = hasRunningTeammates - ? Math.max(elapsedTimeMs, now - turnStartRef.current) - : elapsedTimeMs - const timerText = formatDuration(effectiveElapsedMs) - const timerWidth = stringWidth(timerText) + const effectiveElapsedMs = hasRunningTeammates ? Math.max(elapsedTimeMs, now - turnStartRef.current) : elapsedTimeMs; + const timerText = formatDuration(effectiveElapsedMs); + const timerWidth = stringWidth(timerText); // === Token count (leader + teammates, or foregrounded teammate) === const totalTokens = foregroundedTeammate && !foregroundedTeammate.isIdle ? (foregroundedTeammate.progress?.tokenCount ?? 0) - : leaderTokens + teammateTokens - const tokenCount = formatNumber(totalTokens) - const tokensText = hasRunningTeammates - ? `${tokenCount} tokens` - : `${figures.arrowDown} ${tokenCount} tokens` - const tokensWidth = stringWidth(tokensText) + : leaderTokens + teammateTokens; + const tokenCount = formatNumber(totalTokens); + const tokensText = hasRunningTeammates ? `${tokenCount} tokens` : `${figures.arrowDown} ${tokenCount} tokens`; + const tokensWidth = stringWidth(tokensText); // === Thinking text (may shrink to fit) === let thinkingText = @@ -199,68 +185,45 @@ export function SpinnerAnimationRow({ ? `thinking${effortSuffix}` : typeof thinkingStatus === 'number' ? `thought for ${Math.max(1, Math.round(thinkingStatus / 1000))}s` - : null - let thinkingWidthValue = thinkingText ? stringWidth(thinkingText) : 0 + : null; + let thinkingWidthValue = thinkingText ? stringWidth(thinkingText) : 0; // === Progressive width gating === - const messageWidth = glimmerMessageWidth + 2 - const sep = SEP_WIDTH + const messageWidth = glimmerMessageWidth + 2; + const sep = SEP_WIDTH; - const wantsThinking = thinkingStatus !== null - const wantsTimerAndTokens = - verbose || hasRunningTeammates || effectiveElapsedMs > SHOW_TOKENS_AFTER_MS + const wantsThinking = thinkingStatus !== null; + const wantsTimerAndTokens = verbose || hasRunningTeammates || effectiveElapsedMs > SHOW_TOKENS_AFTER_MS; - const availableSpace = columns - messageWidth - 5 + const availableSpace = columns - messageWidth - 5; - let showThinking = wantsThinking && availableSpace > thinkingWidthValue - if ( - !showThinking && - wantsThinking && - thinkingStatus === 'thinking' && - effortSuffix - ) { + let showThinking = wantsThinking && availableSpace > thinkingWidthValue; + if (!showThinking && wantsThinking && thinkingStatus === 'thinking' && effortSuffix) { if (availableSpace > THINKING_BARE_WIDTH) { - thinkingText = 'thinking' - thinkingWidthValue = THINKING_BARE_WIDTH - showThinking = true + thinkingText = 'thinking'; + thinkingWidthValue = THINKING_BARE_WIDTH; + showThinking = true; } } - const usedAfterThinking = showThinking ? thinkingWidthValue + sep : 0 + const usedAfterThinking = showThinking ? thinkingWidthValue + sep : 0; - const showTimer = - wantsTimerAndTokens && availableSpace > usedAfterThinking + timerWidth - const usedAfterTimer = usedAfterThinking + (showTimer ? timerWidth + sep : 0) + const showTimer = wantsTimerAndTokens && availableSpace > usedAfterThinking + timerWidth; + const usedAfterTimer = usedAfterThinking + (showTimer ? timerWidth + sep : 0); - const showTokens = - wantsTimerAndTokens && - totalTokens > 0 && - availableSpace > usedAfterTimer + tokensWidth + const showTokens = wantsTimerAndTokens && totalTokens > 0 && availableSpace > usedAfterTimer + tokensWidth; const thinkingOnly = - showThinking && - thinkingStatus === 'thinking' && - !spinnerSuffix && - !showTimer && - !showTokens && - true + showThinking && thinkingStatus === 'thinking' && !spinnerSuffix && !showTimer && !showTokens && true; // === Thinking shimmer color (formerly ThinkingShimmerText's own timer) === // Same sine-wave opacity, but derived from our shared `time` instead of a // second useAnimationFrame(50) subscription. - const thinkingElapsedSec = (time - THINKING_DELAY_MS) / 1000 + const thinkingElapsedSec = (time - THINKING_DELAY_MS) / 1000; const thinkingOpacity = - time < THINKING_DELAY_MS - ? 0 - : (Math.sin((thinkingElapsedSec * Math.PI * 2) / THINKING_GLOW_PERIOD_S) + - 1) / - 2 + time < THINKING_DELAY_MS ? 0 : (Math.sin((thinkingElapsedSec * Math.PI * 2) / THINKING_GLOW_PERIOD_S) + 1) / 2; const thinkingShimmerColor = toRGBColor( - interpolateColor( - THINKING_INACTIVE, - THINKING_INACTIVE_SHIMMER, - thinkingOpacity, - ), - ) + interpolateColor(THINKING_INACTIVE, THINKING_INACTIVE_SHIMMER, thinkingOpacity), + ); // === Build status parts === const parts = [ @@ -299,15 +262,13 @@ export function SpinnerAnimationRow({ ), ] : []), - ] + ]; const status = foregroundedTeammate && !foregroundedTeammate.isIdle ? ( <> (esc to interrupt - - {foregroundedTeammate.identity.agentName} - + {foregroundedTeammate.identity.agentName} ) ) : !foregroundedTeammate && parts.length > 0 ? ( @@ -320,16 +281,10 @@ export function SpinnerAnimationRow({ ) ) - ) : null + ) : null; return ( - + {status} - ) + ); } function SpinnerModeGlyph({ mode }: { mode: SpinnerMode }): React.ReactNode { @@ -361,12 +316,12 @@ function SpinnerModeGlyph({ mode }: { mode: SpinnerMode }): React.ReactNode { {figures.arrowDown} - ) + ); case 'requesting': return ( {figures.arrowUp} - ) + ); } } diff --git a/src/components/Spinner/SpinnerGlyph.tsx b/src/components/Spinner/SpinnerGlyph.tsx index d7db456db..fc7f75f60 100644 --- a/src/components/Spinner/SpinnerGlyph.tsx +++ b/src/components/Spinner/SpinnerGlyph.tsx @@ -1,31 +1,23 @@ -import * as React from 'react' -import { Box, Text, useTheme } from '@anthropic/ink' -import { getTheme, type Theme } from '../../utils/theme.js' -import { - getDefaultCharacters, - interpolateColor, - parseRGB, - toRGBColor, -} from './utils.js' +import * as React from 'react'; +import { Box, Text, useTheme } from '@anthropic/ink'; +import { getTheme, type Theme } from '../../utils/theme.js'; +import { getDefaultCharacters, interpolateColor, parseRGB, toRGBColor } from './utils.js'; -const DEFAULT_CHARACTERS = getDefaultCharacters() +const DEFAULT_CHARACTERS = getDefaultCharacters(); -const SPINNER_FRAMES = [ - ...DEFAULT_CHARACTERS, - ...[...DEFAULT_CHARACTERS].reverse(), -] +const SPINNER_FRAMES = [...DEFAULT_CHARACTERS, ...[...DEFAULT_CHARACTERS].reverse()]; -const REDUCED_MOTION_DOT = '●' -const REDUCED_MOTION_CYCLE_MS = 2000 // 2-second cycle: 1s visible, 1s dim -const ERROR_RED = { r: 171, g: 43, b: 63 } +const REDUCED_MOTION_DOT = '●'; +const REDUCED_MOTION_CYCLE_MS = 2000; // 2-second cycle: 1s visible, 1s dim +const ERROR_RED = { r: 171, g: 43, b: 63 }; type Props = { - frame: number - messageColor: keyof Theme - stalledIntensity?: number - reducedMotion?: boolean - time?: number -} + frame: number; + messageColor: keyof Theme; + stalledIntensity?: number; + reducedMotion?: boolean; + time?: number; +}; export function SpinnerGlyph({ frame, @@ -34,53 +26,49 @@ export function SpinnerGlyph({ reducedMotion = false, time = 0, }: Props): React.ReactNode { - const [themeName] = useTheme() - const theme = getTheme(themeName) + const [themeName] = useTheme(); + const theme = getTheme(themeName); // Reduced motion: slowly flashing orange dot if (reducedMotion) { - const isDim = Math.floor(time / (REDUCED_MOTION_CYCLE_MS / 2)) % 2 === 1 + const isDim = Math.floor(time / (REDUCED_MOTION_CYCLE_MS / 2)) % 2 === 1; return ( {REDUCED_MOTION_DOT} - ) + ); } - const spinnerChar = SPINNER_FRAMES[frame % SPINNER_FRAMES.length] + const spinnerChar = SPINNER_FRAMES[frame % SPINNER_FRAMES.length]; // Smoothly interpolate from current color to red when stalled if (stalledIntensity > 0) { - const baseColorStr = theme[messageColor] - const baseRGB = baseColorStr ? parseRGB(baseColorStr) : null + const baseColorStr = theme[messageColor]; + const baseRGB = baseColorStr ? parseRGB(baseColorStr) : null; if (baseRGB) { - const interpolated = interpolateColor( - baseRGB, - ERROR_RED, - stalledIntensity, - ) + const interpolated = interpolateColor(baseRGB, ERROR_RED, stalledIntensity); return ( {spinnerChar} - ) + ); } // Fallback for ANSI themes - const color = stalledIntensity > 0.5 ? 'error' : messageColor + const color = stalledIntensity > 0.5 ? 'error' : messageColor; return ( {spinnerChar} - ) + ); } return ( {spinnerChar} - ) + ); } diff --git a/src/components/Spinner/TeammateSpinnerLine.tsx b/src/components/Spinner/TeammateSpinnerLine.tsx index 736623ce4..e7e73db21 100644 --- a/src/components/Spinner/TeammateSpinnerLine.tsx +++ b/src/components/Spinner/TeammateSpinnerLine.tsx @@ -1,66 +1,55 @@ -import figures from 'figures' -import sample from 'lodash-es/sample.js' -import * as React from 'react' -import { useRef, useState } from 'react' -import { getSpinnerVerbs } from '../../constants/spinnerVerbs.js' -import { TURN_COMPLETION_VERBS } from '../../constants/turnCompletionVerbs.js' -import { useElapsedTime } from '../../hooks/useElapsedTime.js' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' -import { Box, Text, stringWidth } from '@anthropic/ink' -import { toInkColor } from '../../utils/ink.js' -import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js' -import { summarizeRecentActivities } from '../../utils/collapseReadSearch.js' -import { - formatDuration, - formatNumber, - truncateToWidth, -} from '../../utils/format.js' +import figures from 'figures'; +import sample from 'lodash-es/sample.js'; +import * as React from 'react'; +import { useRef, useState } from 'react'; +import { getSpinnerVerbs } from '../../constants/spinnerVerbs.js'; +import { TURN_COMPLETION_VERBS } from '../../constants/turnCompletionVerbs.js'; +import { useElapsedTime } from '../../hooks/useElapsedTime.js'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import { Box, Text, stringWidth } from '@anthropic/ink'; +import { toInkColor } from '../../utils/ink.js'; +import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js'; +import { summarizeRecentActivities } from '../../utils/collapseReadSearch.js'; +import { formatDuration, formatNumber, truncateToWidth } from '../../utils/format.js'; -import { TEAMMATE_SELECT_HINT } from './teammateSelectHint.js' +import { TEAMMATE_SELECT_HINT } from './teammateSelectHint.js'; type Props = { - teammate: InProcessTeammateTaskState - isLast: boolean - isSelected?: boolean - isForegrounded?: boolean - allIdle?: boolean - showPreview?: boolean -} + teammate: InProcessTeammateTaskState; + isLast: boolean; + isSelected?: boolean; + isForegrounded?: boolean; + allIdle?: boolean; + showPreview?: boolean; +}; /** * Extract the last 3 lines of content from a teammate's conversation. * Shows recent activity from any message type (user or assistant). */ -function getMessagePreview( - messages: InProcessTeammateTaskState['messages'], -): string[] { - if (!messages?.length) return [] +function getMessagePreview(messages: InProcessTeammateTaskState['messages']): string[] { + if (!messages?.length) return []; - const allLines: string[] = [] - const maxLineLength = 80 + const allLines: string[] = []; + const maxLineLength = 80; // Collect lines from recent messages (newest first) for (let i = messages.length - 1; i >= 0 && allLines.length < 3; i--) { - const msg = messages[i] + const msg = messages[i]; // Only process messages that have content (user/assistant messages) - if ( - !msg || - (msg.type !== 'user' && msg.type !== 'assistant') || - !msg.message?.content?.length - ) { - continue + if (!msg || (msg.type !== 'user' && msg.type !== 'assistant') || !msg.message?.content?.length) { + continue; } - const content = msg.message.content + const content = msg.message.content; for (const block of content) { - if (allLines.length >= 3) break - if (!block || typeof block !== 'object') continue + if (allLines.length >= 3) break; + if (!block || typeof block !== 'object') continue; if ('type' in block && block.type === 'tool_use' && 'name' in block) { // Try to show meaningful info from tool input - const input = - 'input' in block ? (block.input as Record) : null - let toolLine = `Using ${block.name}…` + const input = 'input' in block ? (block.input as Record) : null; + let toolLine = `Using ${block.name}…`; if (input) { // Look for common descriptive fields const desc = @@ -68,28 +57,26 @@ function getMessagePreview( (input.prompt as string | undefined) || (input.command as string | undefined) || (input.query as string | undefined) || - (input.pattern as string | undefined) + (input.pattern as string | undefined); if (desc) { - toolLine = desc.split('\n')[0] ?? toolLine + toolLine = desc.split('\n')[0] ?? toolLine; } } - allLines.push(truncateToWidth(toolLine, maxLineLength)) + allLines.push(truncateToWidth(toolLine, maxLineLength)); } else if ('type' in block && block.type === 'text' && 'text' in block) { - const textLines = (block.text as string) - .split('\n') - .filter(l => l.trim()) + const textLines = (block.text as string).split('\n').filter(l => l.trim()); // Take from end of text (most recent lines) for (let j = textLines.length - 1; j >= 0 && allLines.length < 3; j--) { - const line = textLines[j] - if (!line) continue - allLines.push(truncateToWidth(line, maxLineLength)) + const line = textLines[j]; + if (!line) continue; + allLines.push(truncateToWidth(line, maxLineLength)); } } } } // Reverse so oldest of the 3 is first (reading order) - return allLines.reverse() + return allLines.reverse(); } export function TeammateSpinnerLine({ @@ -100,135 +87,111 @@ export function TeammateSpinnerLine({ allIdle, showPreview, }: Props): React.ReactNode { - const [randomVerb] = useState( - () => teammate.spinnerVerb ?? sample(getSpinnerVerbs()), - ) - const [pastTenseVerb] = useState( - () => teammate.pastTenseVerb ?? sample(TURN_COMPLETION_VERBS), - ) - const isHighlighted = isSelected || isForegrounded - const treeChar = isHighlighted ? (isLast ? '╘═' : '╞═') : isLast ? '└─' : '├─' - const nameColor = toInkColor(teammate.identity.color) - const { columns } = useTerminalSize() + const [randomVerb] = useState(() => teammate.spinnerVerb ?? sample(getSpinnerVerbs())); + const [pastTenseVerb] = useState(() => teammate.pastTenseVerb ?? sample(TURN_COMPLETION_VERBS)); + const isHighlighted = isSelected || isForegrounded; + const treeChar = isHighlighted ? (isLast ? '╘═' : '╞═') : isLast ? '└─' : '├─'; + const nameColor = toInkColor(teammate.identity.color); + const { columns } = useTerminalSize(); // Track when teammate became idle (for "Idle for X..." display) - const idleStartRef = useRef(null) + const idleStartRef = useRef(null); // Freeze elapsed time when entering all-idle state - const frozenDurationRef = useRef(null) + const frozenDurationRef = useRef(null); // Track idle start time if (teammate.isIdle && idleStartRef.current === null) { - idleStartRef.current = Date.now() + idleStartRef.current = Date.now(); } else if (!teammate.isIdle) { - idleStartRef.current = null + idleStartRef.current = null; } // Reset frozen duration when leaving all-idle state if (!allIdle && frozenDurationRef.current !== null) { - frozenDurationRef.current = null + frozenDurationRef.current = null; } // Get elapsed idle time (how long they've been idle) - for "Idle for X..." display - const idleElapsedTime = useElapsedTime( - idleStartRef.current ?? Date.now(), - teammate.isIdle && !allIdle, - ) + const idleElapsedTime = useElapsedTime(idleStartRef.current ?? Date.now(), teammate.isIdle && !allIdle); // Freeze the duration when we first detect all idle // Use the teammate's actual work time (since task started) for the past-tense display if (allIdle && frozenDurationRef.current === null) { frozenDurationRef.current = formatDuration( - Math.max( - 0, - Date.now() - teammate.startTime - (teammate.totalPausedMs ?? 0), - ), - ) + Math.max(0, Date.now() - teammate.startTime - (teammate.totalPausedMs ?? 0)), + ); } // Use frozen work duration when all idle, otherwise use idle elapsed time const displayTime = allIdle ? (frozenDurationRef.current ?? (() => { - throw new Error( - `frozenDurationRef is null for idle teammate ${teammate.identity.agentName}`, - ) + throw new Error(`frozenDurationRef is null for idle teammate ${teammate.identity.agentName}`); })()) - : idleElapsedTime + : idleElapsedTime; // Layout: paddingLeft(3) + pointer(1) + space(1) + treeChar(2) + space(1) = 8 fixed chars // Then optionally: @name + ": " OR just ": " // Then: activity text + optional extras (stats, hints) - const basePrefix = 8 - const fullAgentName = `@${teammate.identity.agentName}` - const fullNameWidth = stringWidth(fullAgentName) + const basePrefix = 8; + const fullAgentName = `@${teammate.identity.agentName}`; + const fullNameWidth = stringWidth(fullAgentName); // Get stats from progress - const toolUseCount = teammate.progress?.toolUseCount ?? 0 - const tokenCount = teammate.progress?.tokenCount ?? 0 - const statsText = ` · ${toolUseCount} tool ${toolUseCount === 1 ? 'use' : 'uses'} · ${formatNumber(tokenCount)} tokens` - const statsWidth = stringWidth(statsText) - const selectHintText = ` · ${TEAMMATE_SELECT_HINT}` - const selectHintWidth = stringWidth(selectHintText) - const viewHintText = ' · enter to view' - const viewHintWidth = stringWidth(viewHintText) + const toolUseCount = teammate.progress?.toolUseCount ?? 0; + const tokenCount = teammate.progress?.tokenCount ?? 0; + const statsText = ` · ${toolUseCount} tool ${toolUseCount === 1 ? 'use' : 'uses'} · ${formatNumber(tokenCount)} tokens`; + const statsWidth = stringWidth(statsText); + const selectHintText = ` · ${TEAMMATE_SELECT_HINT}`; + const selectHintWidth = stringWidth(selectHintText); + const viewHintText = ' · enter to view'; + const viewHintWidth = stringWidth(viewHintText); // Progressive responsive layout: // Wide (80+): full name + activity + stats + hint // Medium (60-80): full name + activity // Narrow (<60): hide name, just show activity - const minActivityWidth = 25 + const minActivityWidth = 25; // Hide name on narrow terminals (< 60 cols) or if there's not enough room - const spaceWithFullName = columns - basePrefix - fullNameWidth - 2 - const showName = columns >= 60 && spaceWithFullName >= minActivityWidth - const nameWidth = showName ? fullNameWidth + 2 : 0 // +2 for ": " when name shown - const availableForActivity = columns - basePrefix - nameWidth + const spaceWithFullName = columns - basePrefix - fullNameWidth - 2; + const showName = columns >= 60 && spaceWithFullName >= minActivityWidth; + const nameWidth = showName ? fullNameWidth + 2 : 0; // +2 for ": " when name shown + const availableForActivity = columns - basePrefix - nameWidth; // Progressive hiding: view hint → select hint → stats // Stats always visible (dimmed when not selected); hints only when highlighted/selected const showViewHint = - isSelected && - !isForegrounded && - availableForActivity > viewHintWidth + statsWidth + minActivityWidth + 5 + isSelected && !isForegrounded && availableForActivity > viewHintWidth + statsWidth + minActivityWidth + 5; const showSelectHint = isHighlighted && - availableForActivity > - selectHintWidth + - (showViewHint ? viewHintWidth : 0) + - statsWidth + - minActivityWidth + - 5 - const showStats = availableForActivity > statsWidth + minActivityWidth + 5 + availableForActivity > selectHintWidth + (showViewHint ? viewHintWidth : 0) + statsWidth + minActivityWidth + 5; + const showStats = availableForActivity > statsWidth + minActivityWidth + 5; // Activity text gets remaining space const extrasCost = - (showStats ? statsWidth : 0) + - (showSelectHint ? selectHintWidth : 0) + - (showViewHint ? viewHintWidth : 0) - const activityMaxWidth = Math.max( - minActivityWidth, - availableForActivity - extrasCost - 1, - ) + (showStats ? statsWidth : 0) + (showSelectHint ? selectHintWidth : 0) + (showViewHint ? viewHintWidth : 0); + const activityMaxWidth = Math.max(minActivityWidth, availableForActivity - extrasCost - 1); // Format the activity text for active teammates, rolling up search/read ops const activityText = (() => { - const activities = teammate.progress?.recentActivities + const activities = teammate.progress?.recentActivities; if (activities && activities.length > 0) { - const summary = summarizeRecentActivities(activities) - if (summary) return truncateToWidth(summary, activityMaxWidth) + const summary = summarizeRecentActivities(activities); + if (summary) return truncateToWidth(summary, activityMaxWidth); } - const desc = teammate.progress?.lastActivity?.activityDescription - if (desc) return truncateToWidth(desc, activityMaxWidth) - return randomVerb - })() + const desc = teammate.progress?.lastActivity?.activityDescription; + if (desc) return truncateToWidth(desc, activityMaxWidth); + return randomVerb; + })(); // Status rendering logic const renderStatus = (): React.ReactNode => { if (teammate.shutdownRequested) { - return [stopping] + return [stopping]; } if (teammate.awaitingPlanApproval) { - return [awaiting approval] + return [awaiting approval]; } if (teammate.isIdle) { if (allIdle) { @@ -236,27 +199,23 @@ export function TeammateSpinnerLine({ {pastTenseVerb} for {displayTime} - ) + ); } - return Idle for {idleElapsedTime} + return Idle for {idleElapsedTime}; } // Active - show spinner glyph + activity description (only when not highlighted; // when highlighted, the main spinner above already shows the verb) if (isHighlighted) { - return null + return null; } - return ( - - {activityText?.endsWith('…') ? activityText : `${activityText}…`} - - ) - } + return {activityText?.endsWith('…') ? activityText : `${activityText}…`}; + }; // Get preview lines if enabled - const previewLines = showPreview ? getMessagePreview(teammate.messages) : [] + const previewLines = showPreview ? getMessagePreview(teammate.messages) : []; // Tree continuation character for preview lines - const previewTreeChar = isLast ? ' ' : '│ ' + const previewTreeChar = isLast ? ' ' : '│ '; return ( @@ -267,19 +226,14 @@ export function TeammateSpinnerLine({ {treeChar} {/* Agent name: hidden on very narrow screens */} - {showName && ( - - @{teammate.identity.agentName} - - )} + {showName && @{teammate.identity.agentName}} {showName && : } {renderStatus()} {/* Stats: only shown when selected and terminal is wide enough */} {showStats && ( {' '} - · {toolUseCount} tool {toolUseCount === 1 ? 'use' : 'uses'} ·{' '} - {formatNumber(tokenCount)} tokens + · {toolUseCount} tool {toolUseCount === 1 ? 'use' : 'uses'} · {formatNumber(tokenCount)} tokens )} {/* Hints: select hint when highlighted, view hint when selected but not foregrounded */} @@ -295,5 +249,5 @@ export function TeammateSpinnerLine({ ))} - ) + ); } diff --git a/src/components/Spinner/TeammateSpinnerTree.tsx b/src/components/Spinner/TeammateSpinnerTree.tsx index 8c297ed57..419bc96b9 100644 --- a/src/components/Spinner/TeammateSpinnerTree.tsx +++ b/src/components/Spinner/TeammateSpinnerTree.tsx @@ -1,23 +1,23 @@ -import figures from 'figures' -import * as React from 'react' -import { Box, Text, type TextProps } from '@anthropic/ink' -import { useAppState } from '../../state/AppState.js' -import { getRunningTeammatesSorted } from '../../tasks/InProcessTeammateTask/InProcessTeammateTask.js' -import { formatNumber } from '../../utils/format.js' -import { TeammateSpinnerLine } from './TeammateSpinnerLine.js' -import { TEAMMATE_SELECT_HINT } from './teammateSelectHint.js' +import figures from 'figures'; +import * as React from 'react'; +import { Box, Text, type TextProps } from '@anthropic/ink'; +import { useAppState } from '../../state/AppState.js'; +import { getRunningTeammatesSorted } from '../../tasks/InProcessTeammateTask/InProcessTeammateTask.js'; +import { formatNumber } from '../../utils/format.js'; +import { TeammateSpinnerLine } from './TeammateSpinnerLine.js'; +import { TEAMMATE_SELECT_HINT } from './teammateSelectHint.js'; type Props = { - selectedIndex?: number - isInSelectionMode?: boolean - allIdle?: boolean + selectedIndex?: number; + isInSelectionMode?: boolean; + allIdle?: boolean; /** Leader's active verb (when leader is actively processing) */ - leaderVerb?: string + leaderVerb?: string; /** Leader's token count (when leader is actively processing) */ - leaderTokenCount?: number + leaderTokenCount?: number; /** Leader's idle status text (when leader is idle, e.g. "✻ Idle for 3s") */ - leaderIdleText?: string -} + leaderIdleText?: string; +}; export function TeammateSpinnerTree({ selectedIndex, @@ -27,72 +27,52 @@ export function TeammateSpinnerTree({ leaderTokenCount, leaderIdleText, }: Props): React.ReactNode { - const tasks = useAppState(s => s.tasks) - const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId) - const showTeammateMessagePreview = useAppState( - s => s.showTeammateMessagePreview, - ) + const tasks = useAppState(s => s.tasks); + const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId); + const showTeammateMessagePreview = useAppState(s => s.showTeammateMessagePreview); - const teammateTasks = getRunningTeammatesSorted(tasks) + const teammateTasks = getRunningTeammatesSorted(tasks); // Don't render if no running teammates if (teammateTasks.length === 0) { - return null + return null; } // Leader highlighting follows same pattern as teammates: // isHighlighted = isForegrounded || isSelected - const isLeaderForegrounded = viewingAgentTaskId === undefined - const isLeaderSelected = isInSelectionMode && selectedIndex === -1 - const isLeaderHighlighted = isLeaderForegrounded || isLeaderSelected - const leaderColor: TextProps['color'] = 'cyan_FOR_SUBAGENTS_ONLY' + const isLeaderForegrounded = viewingAgentTaskId === undefined; + const isLeaderSelected = isInSelectionMode && selectedIndex === -1; + const isLeaderHighlighted = isLeaderForegrounded || isLeaderSelected; + const leaderColor: TextProps['color'] = 'cyan_FOR_SUBAGENTS_ONLY'; // Is the "hide" row selected? (index === teammateCount in selection mode) - const isHideSelected = - isInSelectionMode === true && selectedIndex === teammateTasks.length + const isHideSelected = isInSelectionMode === true && selectedIndex === teammateTasks.length; return ( {/* Leader row - always visible, uses ┌─ to enclose the tree */} { - + {isLeaderSelected ? figures.pointer : ' '} {isLeaderHighlighted ? '╒═' : '┌─'}{' '} - + team-lead {/* When backgrounded and active: show spinner + verb */} - {!isLeaderForegrounded && leaderVerb && ( - : {leaderVerb}… - )} + {!isLeaderForegrounded && leaderVerb && : {leaderVerb}…} {/* When backgrounded and idle: show idle text */} - {!isLeaderForegrounded && !leaderVerb && leaderIdleText && ( - : {leaderIdleText} - )} + {!isLeaderForegrounded && !leaderVerb && leaderIdleText && : {leaderIdleText}} {/* Stats (tokens) - same dimColor logic as teammates */} {leaderTokenCount !== undefined && leaderTokenCount > 0 && ( - - {' '} - · {formatNumber(leaderTokenCount)} tokens - + · {formatNumber(leaderTokenCount)} tokens )} {/* Hints - select hint when highlighted, view hint when selected but not foregrounded */} - {isLeaderHighlighted && ( - · {TEAMMATE_SELECT_HINT} - )} - {isLeaderSelected && !isLeaderForegrounded && ( - · enter to view - )} + {isLeaderHighlighted && · {TEAMMATE_SELECT_HINT}} + {isLeaderSelected && !isLeaderForegrounded && · enter to view} } {teammateTasks.map((teammate, index) => ( @@ -109,7 +89,7 @@ export function TeammateSpinnerTree({ {/* Hide row - only visible during selection mode */} {isInSelectionMode && } - ) + ); } function HideRow({ isSelected }: { isSelected: boolean }): React.ReactNode { @@ -126,5 +106,5 @@ function HideRow({ isSelected }: { isSelected: boolean }): React.ReactNode { {isSelected && · enter to collapse} - ) + ); } diff --git a/src/components/Spinner/types.ts b/src/components/Spinner/types.ts index 78a11f342..35409ec9a 100644 --- a/src/components/Spinner/types.ts +++ b/src/components/Spinner/types.ts @@ -1,3 +1,3 @@ // Auto-generated stub — replace with real implementation -export type SpinnerMode = any; -export type RGBColor = any; +export type SpinnerMode = any +export type RGBColor = any diff --git a/src/components/Stats.tsx b/src/components/Stats.tsx index 1d20359f1..9ef8e0728 100644 --- a/src/components/Stats.tsx +++ b/src/components/Stats.tsx @@ -1,67 +1,66 @@ -import { feature } from 'bun:bundle' -import { plot as asciichart } from 'asciichart' -import chalk from 'chalk' -import figures from 'figures' -import React, { - Suspense, - use, - useCallback, - useEffect, - useMemo, - useState, -} from 'react' -import stripAnsi from 'strip-ansi' -import type { CommandResultDisplay } from '../commands.js' -import { useTerminalSize } from '../hooks/useTerminalSize.js' +import { feature } from 'bun:bundle'; +import { plot as asciichart } from 'asciichart'; +import chalk from 'chalk'; +import figures from 'figures'; +import React, { Suspense, use, useCallback, useEffect, useMemo, useState } from 'react'; +import stripAnsi from 'strip-ansi'; +import type { CommandResultDisplay } from '../commands.js'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw j/k/arrow stats navigation -import { Ansi, applyColor, Box, Text, useInput, stringWidth as getStringWidth, type Color, Pane, Tab, Tabs, useTabHeaderFocus } from '@anthropic/ink' -import { useKeybinding } from '../keybindings/useKeybinding.js' -import { getGlobalConfig } from '../utils/config.js' -import { formatDuration, formatNumber } from '../utils/format.js' -import { generateHeatmap } from '../utils/heatmap.js' -import { renderModelName } from '../utils/model/model.js' -import { copyAnsiToClipboard } from '../utils/screenshotClipboard.js' +import { + Ansi, + applyColor, + Box, + Text, + useInput, + stringWidth as getStringWidth, + type Color, + Pane, + Tab, + Tabs, + useTabHeaderFocus, +} from '@anthropic/ink'; +import { useKeybinding } from '../keybindings/useKeybinding.js'; +import { getGlobalConfig } from '../utils/config.js'; +import { formatDuration, formatNumber } from '../utils/format.js'; +import { generateHeatmap } from '../utils/heatmap.js'; +import { renderModelName } from '../utils/model/model.js'; +import { copyAnsiToClipboard } from '../utils/screenshotClipboard.js'; import { aggregateClaudeCodeStatsForRange, type ClaudeCodeStats, type DailyModelTokens, type StatsDateRange, -} from '../utils/stats.js' -import { resolveThemeSetting } from '../utils/systemTheme.js' -import { getTheme, themeColorToAnsi } from '../utils/theme.js' -import { Spinner } from './Spinner.js' +} from '../utils/stats.js'; +import { resolveThemeSetting } from '../utils/systemTheme.js'; +import { getTheme, themeColorToAnsi } from '../utils/theme.js'; +import { Spinner } from './Spinner.js'; function formatPeakDay(dateStr: string): string { - const date = new Date(dateStr) + const date = new Date(dateStr); return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', - }) + }); } type Props = { - onClose: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void -} + onClose: (result?: string, options?: { display?: CommandResultDisplay }) => void; +}; -type StatsResult = - | { type: 'success'; data: ClaudeCodeStats } - | { type: 'error'; message: string } - | { type: 'empty' } +type StatsResult = { type: 'success'; data: ClaudeCodeStats } | { type: 'error'; message: string } | { type: 'empty' }; const DATE_RANGE_LABELS: Record = { '7d': 'Last 7 days', '30d': 'Last 30 days', all: 'All time', -} +}; -const DATE_RANGE_ORDER: StatsDateRange[] = ['all', '7d', '30d'] +const DATE_RANGE_ORDER: StatsDateRange[] = ['all', '7d', '30d']; function getNextDateRange(current: StatsDateRange): StatsDateRange { - const currentIndex = DATE_RANGE_ORDER.indexOf(current) - return DATE_RANGE_ORDER[(currentIndex + 1) % DATE_RANGE_ORDER.length]! + const currentIndex = DATE_RANGE_ORDER.indexOf(current); + return DATE_RANGE_ORDER[(currentIndex + 1) % DATE_RANGE_ORDER.length]!; } /** @@ -72,20 +71,19 @@ function createAllTimeStatsPromise(): Promise { return aggregateClaudeCodeStatsForRange('all') .then((data): StatsResult => { if (!data || data.totalSessions === 0) { - return { type: 'empty' } + return { type: 'empty' }; } - return { type: 'success', data } + return { type: 'success', data }; }) .catch((err): StatsResult => { - const message = - err instanceof Error ? err.message : 'Failed to load stats' - return { type: 'error', message } - }) + const message = err instanceof Error ? err.message : 'Failed to load stats'; + return { type: 'error', message }; + }); } export function Stats({ onClose }: Props): React.ReactNode { // Always load all-time stats first (for heatmap) - const allTimePromise = useMemo(() => createAllTimeStatsPromise(), []) + const allTimePromise = useMemo(() => createAllTimeStatsPromise(), []); return ( - ) + ); } type StatsContentProps = { - allTimePromise: Promise - onClose: Props['onClose'] -} + allTimePromise: Promise; + onClose: Props['onClose']; +}; /** * Inner component that uses React 19's use() to read the stats promise. * Suspends while loading all-time stats, then handles date range changes without suspending. */ -function StatsContent({ - allTimePromise, - onClose, -}: StatsContentProps): React.ReactNode { - const allTimeResult = use(allTimePromise) - const [dateRange, setDateRange] = useState('all') - const [statsCache, setStatsCache] = useState< - Partial> - >({}) - const [isLoadingFiltered, setIsLoadingFiltered] = useState(false) - const [activeTab, setActiveTab] = useState<'Overview' | 'Models'>('Overview') - const [copyStatus, setCopyStatus] = useState(null) +function StatsContent({ allTimePromise, onClose }: StatsContentProps): React.ReactNode { + const allTimeResult = use(allTimePromise); + const [dateRange, setDateRange] = useState('all'); + const [statsCache, setStatsCache] = useState>>({}); + const [isLoadingFiltered, setIsLoadingFiltered] = useState(false); + const [activeTab, setActiveTab] = useState<'Overview' | 'Models'>('Overview'); + const [copyStatus, setCopyStatus] = useState(null); // Load filtered stats when date range changes (with caching) useEffect(() => { if (dateRange === 'all') { - return + return; } // Already cached if (statsCache[dateRange]) { - return + return; } - let cancelled = false - setIsLoadingFiltered(true) + let cancelled = false; + setIsLoadingFiltered(true); aggregateClaudeCodeStatsForRange(dateRange) .then(data => { if (!cancelled) { - setStatsCache(prev => ({ ...prev, [dateRange]: data })) - setIsLoadingFiltered(false) + setStatsCache(prev => ({ ...prev, [dateRange]: data })); + setIsLoadingFiltered(false); } }) .catch(() => { if (!cancelled) { - setIsLoadingFiltered(false) + setIsLoadingFiltered(false); } - }) + }); return () => { - cancelled = true - } - }, [dateRange, statsCache]) + cancelled = true; + }; + }, [dateRange, statsCache]); // Use cached stats for current range const displayStats = @@ -161,54 +154,50 @@ function StatsContent({ ? allTimeResult.type === 'success' ? allTimeResult.data : null - : (statsCache[dateRange] ?? - (allTimeResult.type === 'success' ? allTimeResult.data : null)) + : (statsCache[dateRange] ?? (allTimeResult.type === 'success' ? allTimeResult.data : null)); // All-time stats for the heatmap (always use all-time) - const allTimeStats = - allTimeResult.type === 'success' ? allTimeResult.data : null + const allTimeStats = allTimeResult.type === 'success' ? allTimeResult.data : null; const handleClose = useCallback(() => { - onClose('Stats dialog dismissed', { display: 'system' }) - }, [onClose]) + onClose('Stats dialog dismissed', { display: 'system' }); + }, [onClose]); - useKeybinding('confirm:no', handleClose, { context: 'Confirmation' }) + useKeybinding('confirm:no', handleClose, { context: 'Confirmation' }); useInput((input, key) => { // Handle ctrl+c and ctrl+d for closing if (key.ctrl && (input === 'c' || input === 'd')) { - onClose('Stats dialog dismissed', { display: 'system' }) + onClose('Stats dialog dismissed', { display: 'system' }); } // Track tab changes if (key.tab) { - setActiveTab(prev => (prev === 'Overview' ? 'Models' : 'Overview')) + setActiveTab(prev => (prev === 'Overview' ? 'Models' : 'Overview')); } // r to cycle date range if (input === 'r' && !key.ctrl && !key.meta) { - setDateRange(getNextDateRange(dateRange)) + setDateRange(getNextDateRange(dateRange)); } // Ctrl+S to copy screenshot to clipboard if (key.ctrl && input === 's' && displayStats) { - void handleScreenshot(displayStats, activeTab, setCopyStatus) + void handleScreenshot(displayStats, activeTab, setCopyStatus); } - }) + }); if (allTimeResult.type === 'error') { return ( Failed to load stats: {allTimeResult.message} - ) + ); } if (allTimeResult.type === 'empty') { return ( - - No stats available yet. Start using Claude Code! - + No stats available yet. Start using Claude Code! - ) + ); } if (!displayStats || !allTimeStats) { @@ -217,7 +206,7 @@ function StatsContent({ Loading stats… - ) + ); } return ( @@ -233,11 +222,7 @@ function StatsContent({ /> - + @@ -248,15 +233,15 @@ function StatsContent({ - ) + ); } function DateRangeSelector({ dateRange, isLoading, }: { - dateRange: StatsDateRange - isLoading: boolean + dateRange: StatsDateRange; + isLoading: boolean; }): React.ReactNode { return ( @@ -276,7 +261,7 @@ function DateRangeSelector({ {isLoading && } - ) + ); } function OverviewTab({ @@ -285,59 +270,48 @@ function OverviewTab({ dateRange, isLoading, }: { - stats: ClaudeCodeStats - allTimeStats: ClaudeCodeStats - dateRange: StatsDateRange - isLoading: boolean + stats: ClaudeCodeStats; + allTimeStats: ClaudeCodeStats; + dateRange: StatsDateRange; + isLoading: boolean; }): React.ReactNode { - const { columns: terminalWidth } = useTerminalSize() + const { columns: terminalWidth } = useTerminalSize(); // Calculate favorite model and total tokens const modelEntries = Object.entries(stats.modelUsage).sort( - ([, a], [, b]) => - b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens), - ) - const favoriteModel = modelEntries[0] - const totalTokens = modelEntries.reduce( - (sum, [, usage]) => sum + usage.inputTokens + usage.outputTokens, - 0, - ) + ([, a], [, b]) => b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens), + ); + const favoriteModel = modelEntries[0]; + const totalTokens = modelEntries.reduce((sum, [, usage]) => sum + usage.inputTokens + usage.outputTokens, 0); // Memoize the factoid so it doesn't change when switching tabs - const factoid = useMemo( - () => generateFunFactoid(stats, totalTokens), - [stats, totalTokens], - ) + const factoid = useMemo(() => generateFunFactoid(stats, totalTokens), [stats, totalTokens]); // Calculate range days based on selected date range - const rangeDays = - dateRange === '7d' ? 7 : dateRange === '30d' ? 30 : stats.totalDays + const rangeDays = dateRange === '7d' ? 7 : dateRange === '30d' ? 30 : stats.totalDays; // Compute shot stats data (ant-only, gated by feature flag) let shotStatsData: { - avgShots: string - buckets: { label: string; count: number; pct: number }[] - } | null = null + avgShots: string; + buckets: { label: string; count: number; pct: number }[]; + } | null = null; if (feature('SHOT_STATS') && stats.shotDistribution) { - const dist = stats.shotDistribution - const total = Object.values(dist).reduce((s, n) => s + n, 0) + const dist = stats.shotDistribution; + const total = Object.values(dist).reduce((s, n) => s + n, 0); if (total > 0) { - const totalShots = Object.entries(dist).reduce( - (s, [count, sessions]) => s + parseInt(count, 10) * sessions, - 0, - ) + const totalShots = Object.entries(dist).reduce((s, [count, sessions]) => s + parseInt(count, 10) * sessions, 0); const bucket = (min: number, max?: number) => Object.entries(dist) .filter(([k]) => { - const n = parseInt(k, 10) - return n >= min && (max === undefined || n <= max) + const n = parseInt(k, 10); + return n >= min && (max === undefined || n <= max); }) - .reduce((s, [, v]) => s + v, 0) - const pct = (n: number) => Math.round((n / total) * 100) - const b1 = bucket(1, 1) - const b2_5 = bucket(2, 5) - const b6_10 = bucket(6, 10) - const b11 = bucket(11) + .reduce((s, [, v]) => s + v, 0); + const pct = (n: number) => Math.round((n / total) * 100); + const b1 = bucket(1, 1); + const b2_5 = bucket(2, 5); + const b6_10 = bucket(6, 10); + const b11 = bucket(11); shotStatsData = { avgShots: (totalShots / total).toFixed(1), buckets: [ @@ -346,7 +320,7 @@ function OverviewTab({ { label: '6\u201310 shot', count: b6_10, pct: pct(b6_10) }, { label: '11+ shot', count: b11, pct: pct(b11) }, ], - } + }; } } @@ -355,9 +329,7 @@ function OverviewTab({ {/* Activity Heatmap - always shows all-time data */} {allTimeStats.dailyActivity.length > 0 && ( - - {generateHeatmap(allTimeStats.dailyActivity, { terminalWidth })} - + {generateHeatmap(allTimeStats.dailyActivity, { terminalWidth })} )} @@ -378,8 +350,7 @@ function OverviewTab({ - Total tokens:{' '} - {formatNumber(totalTokens)} + Total tokens: {formatNumber(totalTokens)} @@ -388,17 +359,13 @@ function OverviewTab({ - Sessions:{' '} - {formatNumber(stats.totalSessions)} + Sessions: {formatNumber(stats.totalSessions)} {stats.longestSession && ( - Longest session:{' '} - - {formatDuration(stats.longestSession.duration)} - + Longest session: {formatDuration(stats.longestSession.duration)} )} @@ -428,8 +395,7 @@ function OverviewTab({ {stats.peakActivityDay && ( - Most active day:{' '} - {formatPeakDay(stats.peakActivityDay)} + Most active day: {formatPeakDay(stats.peakActivityDay)} )} @@ -445,19 +411,15 @@ function OverviewTab({ {/* Speculation time saved (ant-only) */} - {process.env.USER_TYPE === 'ant' && - stats.totalSpeculationTimeSavedMs > 0 && ( - - - - Speculation saved:{' '} - - {formatDuration(stats.totalSpeculationTimeSavedMs)} - - - + {process.env.USER_TYPE === 'ant' && stats.totalSpeculationTimeSavedMs > 0 && ( + + + + Speculation saved: {formatDuration(stats.totalSpeculationTimeSavedMs)} + - )} + + )} {/* Shot stats (ant-only) */} {shotStatsData && ( @@ -468,15 +430,13 @@ function OverviewTab({ - {shotStatsData.buckets[0]!.label}:{' '} - {shotStatsData.buckets[0]!.count} + {shotStatsData.buckets[0]!.label}: {shotStatsData.buckets[0]!.count} ({shotStatsData.buckets[0]!.pct}%) - {shotStatsData.buckets[1]!.label}:{' '} - {shotStatsData.buckets[1]!.count} + {shotStatsData.buckets[1]!.label}: {shotStatsData.buckets[1]!.count} ({shotStatsData.buckets[1]!.pct}%) @@ -484,15 +444,13 @@ function OverviewTab({ - {shotStatsData.buckets[2]!.label}:{' '} - {shotStatsData.buckets[2]!.count} + {shotStatsData.buckets[2]!.label}: {shotStatsData.buckets[2]!.count} ({shotStatsData.buckets[2]!.pct}%) - {shotStatsData.buckets[3]!.label}:{' '} - {shotStatsData.buckets[3]!.count} + {shotStatsData.buckets[3]!.label}: {shotStatsData.buckets[3]!.count} ({shotStatsData.buckets[3]!.pct}%) @@ -500,8 +458,7 @@ function OverviewTab({ - Avg/session:{' '} - {shotStatsData.avgShots} + Avg/session: {shotStatsData.avgShots} @@ -515,7 +472,7 @@ function OverviewTab({ )} - ) + ); } // Famous books and their approximate token counts (words * ~1.3) @@ -545,7 +502,7 @@ const BOOK_COMPARISONS = [ { name: 'The Count of Monte Cristo', tokens: 603000 }, { name: 'Les Misérables', tokens: 689000 }, { name: 'War and Peace', tokens: 730000 }, -] +]; // Time equivalents for session durations const TIME_COMPARISONS = [ @@ -559,48 +516,39 @@ const TIME_COMPARISONS = [ { name: 'watching Titanic', minutes: 195 }, { name: 'a transatlantic flight', minutes: 420 }, { name: 'a full night of sleep', minutes: 480 }, -] +]; -function generateFunFactoid( - stats: ClaudeCodeStats, - totalTokens: number, -): string { - const factoids: string[] = [] +function generateFunFactoid(stats: ClaudeCodeStats, totalTokens: number): string { + const factoids: string[] = []; if (totalTokens > 0) { - const matchingBooks = BOOK_COMPARISONS.filter( - book => totalTokens >= book.tokens, - ) + const matchingBooks = BOOK_COMPARISONS.filter(book => totalTokens >= book.tokens); for (const book of matchingBooks) { - const times = totalTokens / book.tokens + const times = totalTokens / book.tokens; if (times >= 2) { - factoids.push( - `You've used ~${Math.floor(times)}x more tokens than ${book.name}`, - ) + factoids.push(`You've used ~${Math.floor(times)}x more tokens than ${book.name}`); } else { - factoids.push(`You've used the same number of tokens as ${book.name}`) + factoids.push(`You've used the same number of tokens as ${book.name}`); } } } if (stats.longestSession) { - const sessionMinutes = stats.longestSession.duration / (1000 * 60) + const sessionMinutes = stats.longestSession.duration / (1000 * 60); for (const comparison of TIME_COMPARISONS) { - const ratio = sessionMinutes / comparison.minutes + const ratio = sessionMinutes / comparison.minutes; if (ratio >= 2) { - factoids.push( - `Your longest session is ~${Math.floor(ratio)}x longer than ${comparison.name}`, - ) + factoids.push(`Your longest session is ~${Math.floor(ratio)}x longer than ${comparison.name}`); } } } if (factoids.length === 0) { - return '' + return ''; } - const randomIndex = Math.floor(Math.random() * factoids.length) - return factoids[randomIndex]! + const randomIndex = Math.floor(Math.random() * factoids.length); + return factoids[randomIndex]!; } function ModelsTab({ @@ -608,74 +556,62 @@ function ModelsTab({ dateRange, isLoading, }: { - stats: ClaudeCodeStats - dateRange: StatsDateRange - isLoading: boolean + stats: ClaudeCodeStats; + dateRange: StatsDateRange; + isLoading: boolean; }): React.ReactNode { - const { headerFocused, focusHeader } = useTabHeaderFocus() - const [scrollOffset, setScrollOffset] = useState(0) - const { columns: terminalWidth } = useTerminalSize() - const VISIBLE_MODELS = 4 // Show 4 models at a time (2 per column) + const { headerFocused, focusHeader } = useTabHeaderFocus(); + const [scrollOffset, setScrollOffset] = useState(0); + const { columns: terminalWidth } = useTerminalSize(); + const VISIBLE_MODELS = 4; // Show 4 models at a time (2 per column) const modelEntries = Object.entries(stats.modelUsage).sort( - ([, a], [, b]) => - b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens), - ) + ([, a], [, b]) => b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens), + ); // Handle scrolling with arrow keys useInput( (_input, key) => { - if ( - key.downArrow && - scrollOffset < modelEntries.length - VISIBLE_MODELS - ) { - setScrollOffset(prev => - Math.min(prev + 2, modelEntries.length - VISIBLE_MODELS), - ) + if (key.downArrow && scrollOffset < modelEntries.length - VISIBLE_MODELS) { + setScrollOffset(prev => Math.min(prev + 2, modelEntries.length - VISIBLE_MODELS)); } if (key.upArrow) { if (scrollOffset > 0) { - setScrollOffset(prev => Math.max(prev - 2, 0)) + setScrollOffset(prev => Math.max(prev - 2, 0)); } else { - focusHeader() + focusHeader(); } } }, { isActive: !headerFocused }, - ) + ); if (modelEntries.length === 0) { return ( No model usage data available - ) + ); } - const totalTokens = modelEntries.reduce( - (sum, [, usage]) => sum + usage.inputTokens + usage.outputTokens, - 0, - ) + const totalTokens = modelEntries.reduce((sum, [, usage]) => sum + usage.inputTokens + usage.outputTokens, 0); // Generate token usage chart - use terminal width for responsive sizing const chartOutput = generateTokenChart( stats.dailyModelTokens, modelEntries.map(([model]) => model), terminalWidth, - ) + ); // Get visible models and split into two columns - const visibleModels = modelEntries.slice( - scrollOffset, - scrollOffset + VISIBLE_MODELS, - ) - const midpoint = Math.ceil(visibleModels.length / 2) - const leftModels = visibleModels.slice(0, midpoint) - const rightModels = visibleModels.slice(midpoint) + const visibleModels = modelEntries.slice(scrollOffset, scrollOffset + VISIBLE_MODELS); + const midpoint = Math.ceil(visibleModels.length / 2); + const leftModels = visibleModels.slice(0, midpoint); + const rightModels = visibleModels.slice(midpoint); - const canScrollUp = scrollOffset > 0 - const canScrollDown = scrollOffset < modelEntries.length - VISIBLE_MODELS - const showScrollHint = modelEntries.length > VISIBLE_MODELS + const canScrollUp = scrollOffset > 0; + const canScrollDown = scrollOffset < modelEntries.length - VISIBLE_MODELS; + const showScrollHint = modelEntries.length > VISIBLE_MODELS; return ( @@ -703,22 +639,12 @@ function ModelsTab({ {leftModels.map(([model, usage]) => ( - + ))} {rightModels.map(([model, usage]) => ( - + ))} @@ -727,59 +653,52 @@ function ModelsTab({ {showScrollHint && ( - {canScrollUp ? figures.arrowUp : ' '}{' '} - {canScrollDown ? figures.arrowDown : ' '} {scrollOffset + 1}- - {Math.min(scrollOffset + VISIBLE_MODELS, modelEntries.length)} of{' '} - {modelEntries.length} models (↑↓ to scroll) + {canScrollUp ? figures.arrowUp : ' '} {canScrollDown ? figures.arrowDown : ' '} {scrollOffset + 1}- + {Math.min(scrollOffset + VISIBLE_MODELS, modelEntries.length)} of {modelEntries.length} models (↑↓ to + scroll) )} - ) + ); } type ModelEntryProps = { - model: string + model: string; usage: { - inputTokens: number - outputTokens: number - cacheReadInputTokens: number - } - totalTokens: number -} + inputTokens: number; + outputTokens: number; + cacheReadInputTokens: number; + }; + totalTokens: number; +}; -function ModelEntry({ - model, - usage, - totalTokens, -}: ModelEntryProps): React.ReactNode { - const modelTokens = usage.inputTokens + usage.outputTokens - const percentage = ((modelTokens / totalTokens) * 100).toFixed(1) +function ModelEntry({ model, usage, totalTokens }: ModelEntryProps): React.ReactNode { + const modelTokens = usage.inputTokens + usage.outputTokens; + const percentage = ((modelTokens / totalTokens) * 100).toFixed(1); return ( - {figures.bullet} {renderModelName(model)}{' '} - ({percentage}%) + {figures.bullet} {renderModelName(model)} ({percentage}%) - {' '}In: {formatNumber(usage.inputTokens)} · Out:{' '} - {formatNumber(usage.outputTokens)} + {' '}In: {formatNumber(usage.inputTokens)} · Out: {formatNumber(usage.outputTokens)} - ) + ); } type ChartLegend = { - model: string - coloredBullet: string // Pre-colored bullet using chalk -} + model: string; + coloredBullet: string; // Pre-colored bullet using chalk +}; type ChartOutput = { - chart: string - legend: ChartLegend[] - xAxisLabels: string -} + chart: string; + legend: ChartLegend[]; + xAxisLabels: string; +}; function generateTokenChart( dailyTokens: DailyModelTokens[], @@ -787,131 +706,116 @@ function generateTokenChart( terminalWidth: number, ): ChartOutput | null { if (dailyTokens.length < 2 || models.length === 0) { - return null + return null; } // Y-axis labels take about 6 characters, plus some padding // Cap at ~52 to align with heatmap width (1 year of data) - const yAxisWidth = 7 - const availableWidth = terminalWidth - yAxisWidth - const chartWidth = Math.min(52, Math.max(20, availableWidth)) + const yAxisWidth = 7; + const availableWidth = terminalWidth - yAxisWidth; + const chartWidth = Math.min(52, Math.max(20, availableWidth)); // Distribute data across the available chart width - let recentData: DailyModelTokens[] + let recentData: DailyModelTokens[]; if (dailyTokens.length >= chartWidth) { // More data than space: take most recent N days - recentData = dailyTokens.slice(-chartWidth) + recentData = dailyTokens.slice(-chartWidth); } else { // Less data than space: expand by repeating each point - const repeatCount = Math.floor(chartWidth / dailyTokens.length) - recentData = [] + const repeatCount = Math.floor(chartWidth / dailyTokens.length); + recentData = []; for (const day of dailyTokens) { for (let i = 0; i < repeatCount; i++) { - recentData.push(day) + recentData.push(day); } } } // Color palette for different models - use theme colors - const theme = getTheme(resolveThemeSetting(getGlobalConfig().theme)) - const colors = [ - themeColorToAnsi(theme.suggestion), - themeColorToAnsi(theme.success), - themeColorToAnsi(theme.warning), - ] + const theme = getTheme(resolveThemeSetting(getGlobalConfig().theme)); + const colors = [themeColorToAnsi(theme.suggestion), themeColorToAnsi(theme.success), themeColorToAnsi(theme.warning)]; // Prepare series data for each model - const series: number[][] = [] - const legend: ChartLegend[] = [] + const series: number[][] = []; + const legend: ChartLegend[] = []; // Only show top 3 models to keep chart readable - const topModels = models.slice(0, 3) + const topModels = models.slice(0, 3); for (let i = 0; i < topModels.length; i++) { - const model = topModels[i]! - const data = recentData.map(day => day.tokensByModel[model] || 0) + const model = topModels[i]!; + const data = recentData.map(day => day.tokensByModel[model] || 0); // Only include if there's actual data if (data.some(v => v > 0)) { - series.push(data) + series.push(data); // Use theme colors that match the chart - const bulletColors = [theme.suggestion, theme.success, theme.warning] + const bulletColors = [theme.suggestion, theme.success, theme.warning]; legend.push({ model: renderModelName(model), - coloredBullet: applyColor( - figures.bullet, - bulletColors[i % bulletColors.length] as Color, - ), - }) + coloredBullet: applyColor(figures.bullet, bulletColors[i % bulletColors.length] as Color), + }); } } if (series.length === 0) { - return null + return null; } const chart = asciichart(series, { height: 8, colors: colors.slice(0, series.length), format: (x: number) => { - let label: string + let label: string; if (x >= 1_000_000) { - label = (x / 1_000_000).toFixed(1) + 'M' + label = (x / 1_000_000).toFixed(1) + 'M'; } else if (x >= 1_000) { - label = (x / 1_000).toFixed(0) + 'k' + label = (x / 1_000).toFixed(0) + 'k'; } else { - label = x.toFixed(0) + label = x.toFixed(0); } - return label.padStart(6) + return label.padStart(6); }, - }) + }); // Generate x-axis labels with dates - const xAxisLabels = generateXAxisLabels( - recentData, - recentData.length, - yAxisWidth, - ) + const xAxisLabels = generateXAxisLabels(recentData, recentData.length, yAxisWidth); - return { chart, legend, xAxisLabels } + return { chart, legend, xAxisLabels }; } -function generateXAxisLabels( - data: DailyModelTokens[], - _chartWidth: number, - yAxisOffset: number, -): string { - if (data.length === 0) return '' +function generateXAxisLabels(data: DailyModelTokens[], _chartWidth: number, yAxisOffset: number): string { + if (data.length === 0) return ''; // Show 3-4 date labels evenly spaced, but leave room for last label - const numLabels = Math.min(4, Math.max(2, Math.floor(data.length / 8))) + const numLabels = Math.min(4, Math.max(2, Math.floor(data.length / 8))); // Don't use the very last position - leave room for the label text - const usableLength = data.length - 6 // Reserve ~6 chars for last label (e.g., "Dec 7") - const step = Math.floor(usableLength / (numLabels - 1)) || 1 + const usableLength = data.length - 6; // Reserve ~6 chars for last label (e.g., "Dec 7") + const step = Math.floor(usableLength / (numLabels - 1)) || 1; - const labelPositions: { pos: number; label: string }[] = [] + const labelPositions: { pos: number; label: string }[] = []; for (let i = 0; i < numLabels; i++) { - const idx = Math.min(i * step, data.length - 1) - const date = new Date(data[idx]!.date) + const idx = Math.min(i * step, data.length - 1); + const date = new Date(data[idx]!.date); const label = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', - }) - labelPositions.push({ pos: idx, label }) + }); + labelPositions.push({ pos: idx, label }); } // Build the label string with proper spacing - let result = ' '.repeat(yAxisOffset) - let currentPos = 0 + let result = ' '.repeat(yAxisOffset); + let currentPos = 0; for (const { pos, label } of labelPositions) { - const spaces = Math.max(1, pos - currentPos) - result += ' '.repeat(spaces) + label - currentPos = pos + label.length + const spaces = Math.max(1, pos - currentPos); + result += ' '.repeat(spaces) + label; + currentPos = pos + label.length; } - return result + return result; } // Screenshot functionality @@ -920,110 +824,92 @@ async function handleScreenshot( activeTab: 'Overview' | 'Models', setStatus: (status: string | null) => void, ): Promise { - setStatus('copying…') + setStatus('copying…'); - const ansiText = renderStatsToAnsi(stats, activeTab) - const result = await copyAnsiToClipboard(ansiText) + const ansiText = renderStatsToAnsi(stats, activeTab); + const result = await copyAnsiToClipboard(ansiText); - setStatus(result.success ? 'copied!' : 'copy failed') + setStatus(result.success ? 'copied!' : 'copy failed'); // Clear status after 2 seconds - setTimeout(setStatus, 2000, null) + setTimeout(setStatus, 2000, null); } -function renderStatsToAnsi( - stats: ClaudeCodeStats, - activeTab: 'Overview' | 'Models', -): string { - const lines: string[] = [] +function renderStatsToAnsi(stats: ClaudeCodeStats, activeTab: 'Overview' | 'Models'): string { + const lines: string[] = []; if (activeTab === 'Overview') { - lines.push(...renderOverviewToAnsi(stats)) + lines.push(...renderOverviewToAnsi(stats)); } else { - lines.push(...renderModelsToAnsi(stats)) + lines.push(...renderModelsToAnsi(stats)); } // Trim trailing empty lines - while ( - lines.length > 0 && - stripAnsi(lines[lines.length - 1]!).trim() === '' - ) { - lines.pop() + while (lines.length > 0 && stripAnsi(lines[lines.length - 1]!).trim() === '') { + lines.pop(); } // Add "/stats" right-aligned on the last line if (lines.length > 0) { - const lastLine = lines[lines.length - 1]! - const lastLineLen = getStringWidth(lastLine) + const lastLine = lines[lines.length - 1]!; + const lastLineLen = getStringWidth(lastLine); // Use known content widths based on layout: // Overview: two-column stats = COL2_START(40) + COL2_LABEL_WIDTH(18) + max_value(~12) = 70 // Models: chart width = 80 - const contentWidth = activeTab === 'Overview' ? 70 : 80 - const statsLabel = '/stats' - const padding = Math.max(2, contentWidth - lastLineLen - statsLabel.length) - lines[lines.length - 1] = - lastLine + ' '.repeat(padding) + chalk.gray(statsLabel) + const contentWidth = activeTab === 'Overview' ? 70 : 80; + const statsLabel = '/stats'; + const padding = Math.max(2, contentWidth - lastLineLen - statsLabel.length); + lines[lines.length - 1] = lastLine + ' '.repeat(padding) + chalk.gray(statsLabel); } - return lines.join('\n') + return lines.join('\n'); } function renderOverviewToAnsi(stats: ClaudeCodeStats): string[] { - const lines: string[] = [] - const theme = getTheme(resolveThemeSetting(getGlobalConfig().theme)) - const h = (text: string) => applyColor(text, theme.claude as Color) + const lines: string[] = []; + const theme = getTheme(resolveThemeSetting(getGlobalConfig().theme)); + const h = (text: string) => applyColor(text, theme.claude as Color); // Two-column helper with fixed spacing // Column 1: label (18 chars) + value + padding to reach col 2 // Column 2 starts at character position 40 - const COL1_LABEL_WIDTH = 18 - const COL2_START = 40 - const COL2_LABEL_WIDTH = 18 + const COL1_LABEL_WIDTH = 18; + const COL2_START = 40; + const COL2_LABEL_WIDTH = 18; const row = (l1: string, v1: string, l2: string, v2: string): string => { // Build column 1: label + value - const label1 = (l1 + ':').padEnd(COL1_LABEL_WIDTH) - const col1PlainLen = label1.length + v1.length + const label1 = (l1 + ':').padEnd(COL1_LABEL_WIDTH); + const col1PlainLen = label1.length + v1.length; // Calculate spaces needed between col1 value and col2 label - const spaceBetween = Math.max(2, COL2_START - col1PlainLen) + const spaceBetween = Math.max(2, COL2_START - col1PlainLen); // Build column 2: label + value - const label2 = (l2 + ':').padEnd(COL2_LABEL_WIDTH) + const label2 = (l2 + ':').padEnd(COL2_LABEL_WIDTH); // Assemble with colors applied to values only - return label1 + h(v1) + ' '.repeat(spaceBetween) + label2 + h(v2) - } + return label1 + h(v1) + ' '.repeat(spaceBetween) + label2 + h(v2); + }; // Heatmap - use fixed width for screenshot (56 = 52 weeks + 4 for day labels) if (stats.dailyActivity.length > 0) { - lines.push(generateHeatmap(stats.dailyActivity, { terminalWidth: 56 })) - lines.push('') + lines.push(generateHeatmap(stats.dailyActivity, { terminalWidth: 56 })); + lines.push(''); } // Calculate values const modelEntries = Object.entries(stats.modelUsage).sort( - ([, a], [, b]) => - b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens), - ) - const favoriteModel = modelEntries[0] - const totalTokens = modelEntries.reduce( - (sum, [, usage]) => sum + usage.inputTokens + usage.outputTokens, - 0, - ) + ([, a], [, b]) => b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens), + ); + const favoriteModel = modelEntries[0]; + const totalTokens = modelEntries.reduce((sum, [, usage]) => sum + usage.inputTokens + usage.outputTokens, 0); // Row 1: Favorite model | Total tokens if (favoriteModel) { - lines.push( - row( - 'Favorite model', - renderModelName(favoriteModel[0]), - 'Total tokens', - formatNumber(totalTokens), - ), - ) + lines.push(row('Favorite model', renderModelName(favoriteModel[0]), 'Total tokens', formatNumber(totalTokens))); } - lines.push('') + lines.push(''); // Row 2: Sessions | Longest session lines.push( @@ -1031,149 +917,111 @@ function renderOverviewToAnsi(stats: ClaudeCodeStats): string[] { 'Sessions', formatNumber(stats.totalSessions), 'Longest session', - stats.longestSession - ? formatDuration(stats.longestSession.duration) - : 'N/A', + stats.longestSession ? formatDuration(stats.longestSession.duration) : 'N/A', ), - ) + ); // Row 3: Current streak | Longest streak - const currentStreakVal = `${stats.streaks.currentStreak} ${stats.streaks.currentStreak === 1 ? 'day' : 'days'}` - const longestStreakVal = `${stats.streaks.longestStreak} ${stats.streaks.longestStreak === 1 ? 'day' : 'days'}` - lines.push( - row('Current streak', currentStreakVal, 'Longest streak', longestStreakVal), - ) + const currentStreakVal = `${stats.streaks.currentStreak} ${stats.streaks.currentStreak === 1 ? 'day' : 'days'}`; + const longestStreakVal = `${stats.streaks.longestStreak} ${stats.streaks.longestStreak === 1 ? 'day' : 'days'}`; + lines.push(row('Current streak', currentStreakVal, 'Longest streak', longestStreakVal)); // Row 4: Active days | Peak hour - const activeDaysVal = `${stats.activeDays}/${stats.totalDays}` + const activeDaysVal = `${stats.activeDays}/${stats.totalDays}`; const peakHourVal = - stats.peakActivityHour !== null - ? `${stats.peakActivityHour}:00-${stats.peakActivityHour + 1}:00` - : 'N/A' - lines.push(row('Active days', activeDaysVal, 'Peak hour', peakHourVal)) + stats.peakActivityHour !== null ? `${stats.peakActivityHour}:00-${stats.peakActivityHour + 1}:00` : 'N/A'; + lines.push(row('Active days', activeDaysVal, 'Peak hour', peakHourVal)); // Speculation time saved (ant-only) - if ( - process.env.USER_TYPE === 'ant' && - stats.totalSpeculationTimeSavedMs > 0 - ) { - const label = 'Speculation saved:'.padEnd(COL1_LABEL_WIDTH) - lines.push(label + h(formatDuration(stats.totalSpeculationTimeSavedMs))) + if (process.env.USER_TYPE === 'ant' && stats.totalSpeculationTimeSavedMs > 0) { + const label = 'Speculation saved:'.padEnd(COL1_LABEL_WIDTH); + lines.push(label + h(formatDuration(stats.totalSpeculationTimeSavedMs))); } // Shot stats (ant-only) if (feature('SHOT_STATS') && stats.shotDistribution) { - const dist = stats.shotDistribution - const totalWithShots = Object.values(dist).reduce((s, n) => s + n, 0) + const dist = stats.shotDistribution; + const totalWithShots = Object.values(dist).reduce((s, n) => s + n, 0); if (totalWithShots > 0) { - const totalShots = Object.entries(dist).reduce( - (s, [count, sessions]) => s + parseInt(count, 10) * sessions, - 0, - ) - const avgShots = (totalShots / totalWithShots).toFixed(1) + const totalShots = Object.entries(dist).reduce((s, [count, sessions]) => s + parseInt(count, 10) * sessions, 0); + const avgShots = (totalShots / totalWithShots).toFixed(1); const bucket = (min: number, max?: number) => Object.entries(dist) .filter(([k]) => { - const n = parseInt(k, 10) - return n >= min && (max === undefined || n <= max) + const n = parseInt(k, 10); + return n >= min && (max === undefined || n <= max); }) - .reduce((s, [, v]) => s + v, 0) - const pct = (n: number) => Math.round((n / totalWithShots) * 100) - const fmtBucket = (count: number, p: number) => `${count} (${p}%)` - const b1 = bucket(1, 1) - const b2_5 = bucket(2, 5) - const b6_10 = bucket(6, 10) - const b11 = bucket(11) - lines.push('') - lines.push('Shot distribution') - lines.push( - row( - '1-shot', - fmtBucket(b1, pct(b1)), - '2\u20135 shot', - fmtBucket(b2_5, pct(b2_5)), - ), - ) - lines.push( - row( - '6\u201310 shot', - fmtBucket(b6_10, pct(b6_10)), - '11+ shot', - fmtBucket(b11, pct(b11)), - ), - ) - lines.push(`${'Avg/session:'.padEnd(COL1_LABEL_WIDTH)}${h(avgShots)}`) + .reduce((s, [, v]) => s + v, 0); + const pct = (n: number) => Math.round((n / totalWithShots) * 100); + const fmtBucket = (count: number, p: number) => `${count} (${p}%)`; + const b1 = bucket(1, 1); + const b2_5 = bucket(2, 5); + const b6_10 = bucket(6, 10); + const b11 = bucket(11); + lines.push(''); + lines.push('Shot distribution'); + lines.push(row('1-shot', fmtBucket(b1, pct(b1)), '2\u20135 shot', fmtBucket(b2_5, pct(b2_5)))); + lines.push(row('6\u201310 shot', fmtBucket(b6_10, pct(b6_10)), '11+ shot', fmtBucket(b11, pct(b11)))); + lines.push(`${'Avg/session:'.padEnd(COL1_LABEL_WIDTH)}${h(avgShots)}`); } } - lines.push('') + lines.push(''); // Fun factoid - const factoid = generateFunFactoid(stats, totalTokens) - lines.push(h(factoid)) - lines.push(chalk.gray(`Stats from the last ${stats.totalDays} days`)) + const factoid = generateFunFactoid(stats, totalTokens); + lines.push(h(factoid)); + lines.push(chalk.gray(`Stats from the last ${stats.totalDays} days`)); - return lines + return lines; } function renderModelsToAnsi(stats: ClaudeCodeStats): string[] { - const lines: string[] = [] + const lines: string[] = []; const modelEntries = Object.entries(stats.modelUsage).sort( - ([, a], [, b]) => - b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens), - ) + ([, a], [, b]) => b.inputTokens + b.outputTokens - (a.inputTokens + a.outputTokens), + ); if (modelEntries.length === 0) { - lines.push(chalk.gray('No model usage data available')) - return lines + lines.push(chalk.gray('No model usage data available')); + return lines; } - const favoriteModel = modelEntries[0] - const totalTokens = modelEntries.reduce( - (sum, [, usage]) => sum + usage.inputTokens + usage.outputTokens, - 0, - ) + const favoriteModel = modelEntries[0]; + const totalTokens = modelEntries.reduce((sum, [, usage]) => sum + usage.inputTokens + usage.outputTokens, 0); // Generate chart if we have data - use fixed width for screenshot const chartOutput = generateTokenChart( stats.dailyModelTokens, modelEntries.map(([model]) => model), 80, // Fixed width for screenshot - ) + ); if (chartOutput) { - lines.push(chalk.bold('Tokens per Day')) - lines.push(chartOutput.chart) - lines.push(chalk.gray(chartOutput.xAxisLabels)) + lines.push(chalk.bold('Tokens per Day')); + lines.push(chartOutput.chart); + lines.push(chalk.gray(chartOutput.xAxisLabels)); // Legend - use pre-colored bullets from chart output - const legendLine = chartOutput.legend - .map(item => `${item.coloredBullet} ${item.model}`) - .join(' · ') - lines.push(legendLine) - lines.push('') + const legendLine = chartOutput.legend.map(item => `${item.coloredBullet} ${item.model}`).join(' · '); + lines.push(legendLine); + lines.push(''); } // Summary lines.push( `${figures.star} Favorite: ${chalk.magenta.bold(renderModelName(favoriteModel?.[0] || ''))} · ${figures.circle} Total: ${chalk.magenta(formatNumber(totalTokens))} tokens`, - ) - lines.push('') + ); + lines.push(''); // Model breakdown - only show top 3 for screenshot - const topModels = modelEntries.slice(0, 3) + const topModels = modelEntries.slice(0, 3); for (const [model, usage] of topModels) { - const modelTokens = usage.inputTokens + usage.outputTokens - const percentage = ((modelTokens / totalTokens) * 100).toFixed(1) - lines.push( - `${figures.bullet} ${chalk.bold(renderModelName(model))} ${chalk.gray(`(${percentage}%)`)}`, - ) - lines.push( - chalk.dim( - ` In: ${formatNumber(usage.inputTokens)} · Out: ${formatNumber(usage.outputTokens)}`, - ), - ) + const modelTokens = usage.inputTokens + usage.outputTokens; + const percentage = ((modelTokens / totalTokens) * 100).toFixed(1); + lines.push(`${figures.bullet} ${chalk.bold(renderModelName(model))} ${chalk.gray(`(${percentage}%)`)}`); + lines.push(chalk.dim(` In: ${formatNumber(usage.inputTokens)} · Out: ${formatNumber(usage.outputTokens)}`)); } - return lines + return lines; } diff --git a/src/components/StatusLine.tsx b/src/components/StatusLine.tsx index b2f2c7c91..8ef1fa359 100644 --- a/src/components/StatusLine.tsx +++ b/src/components/StatusLine.tsx @@ -1,9 +1,9 @@ -import { feature } from 'bun:bundle' -import * as React from 'react' -import { memo, useCallback, useEffect, useRef } from 'react' -import { logEvent } from 'src/services/analytics/index.js' -import { useAppState, useSetAppState } from 'src/state/AppState.js' -import type { PermissionMode } from 'src/utils/permissions/PermissionMode.js' +import { feature } from 'bun:bundle'; +import * as React from 'react'; +import { memo, useCallback, useEffect, useRef } from 'react'; +import { logEvent } from 'src/services/analytics/index.js'; +import { useAppState, useSetAppState } from 'src/state/AppState.js'; +import type { PermissionMode } from 'src/utils/permissions/PermissionMode.js'; import { getIsRemoteMode, getKairosActive, @@ -11,9 +11,9 @@ import { getOriginalCwd, getSdkBetas, getSessionId, -} from '../bootstrap/state.js' -import { DEFAULT_OUTPUT_STYLE_NAME } from '../constants/outputStyles.js' -import { useNotifications } from '../context/notifications.js' +} from '../bootstrap/state.js'; +import { DEFAULT_OUTPUT_STYLE_NAME } from '../constants/outputStyles.js'; +import { useNotifications } from '../context/notifications.js'; import { getTotalAPIDuration, getTotalCost, @@ -22,45 +22,32 @@ import { getTotalLinesAdded, getTotalLinesRemoved, getTotalOutputTokens, -} from '../cost-tracker.js' -import { useMainLoopModel } from '../hooks/useMainLoopModel.js' -import { type ReadonlySettings, useSettings } from '../hooks/useSettings.js' -import { Ansi, Box, Text } from '@anthropic/ink' -import { getRawUtilization } from '../services/claudeAiLimits.js' -import type { Message } from '../types/message.js' -import type { StatusLineCommandInput } from '../types/statusLine.js' -import type { VimMode } from '../types/textInputTypes.js' -import { checkHasTrustDialogAccepted } from '../utils/config.js' -import { - calculateContextPercentages, - getContextWindowForModel, -} from '../utils/context.js' -import { getCwd } from '../utils/cwd.js' -import { logForDebugging } from '../utils/debug.js' -import { isFullscreenEnvEnabled } from '../utils/fullscreen.js' -import { - createBaseHookInput, - executeStatusLineCommand, -} from '../utils/hooks.js' -import { getLastAssistantMessage } from '../utils/messages.js' -import { - getRuntimeMainLoopModel, - type ModelName, - renderModelName, -} from '../utils/model/model.js' -import { getCurrentSessionTitle } from '../utils/sessionStorage.js' -import { - doesMostRecentAssistantMessageExceed200k, - getCurrentUsage, -} from '../utils/tokens.js' -import { getCurrentWorktreeSession } from '../utils/worktree.js' -import { isVimModeEnabled } from './PromptInput/utils.js' +} from '../cost-tracker.js'; +import { useMainLoopModel } from '../hooks/useMainLoopModel.js'; +import { type ReadonlySettings, useSettings } from '../hooks/useSettings.js'; +import { Ansi, Box, Text } from '@anthropic/ink'; +import { getRawUtilization } from '../services/claudeAiLimits.js'; +import type { Message } from '../types/message.js'; +import type { StatusLineCommandInput } from '../types/statusLine.js'; +import type { VimMode } from '../types/textInputTypes.js'; +import { checkHasTrustDialogAccepted } from '../utils/config.js'; +import { calculateContextPercentages, getContextWindowForModel } from '../utils/context.js'; +import { getCwd } from '../utils/cwd.js'; +import { logForDebugging } from '../utils/debug.js'; +import { isFullscreenEnvEnabled } from '../utils/fullscreen.js'; +import { createBaseHookInput, executeStatusLineCommand } from '../utils/hooks.js'; +import { getLastAssistantMessage } from '../utils/messages.js'; +import { getRuntimeMainLoopModel, type ModelName, renderModelName } from '../utils/model/model.js'; +import { getCurrentSessionTitle } from '../utils/sessionStorage.js'; +import { doesMostRecentAssistantMessageExceed200k, getCurrentUsage } from '../utils/tokens.js'; +import { getCurrentWorktreeSession } from '../utils/worktree.js'; +import { isVimModeEnabled } from './PromptInput/utils.js'; export function statusLineShouldDisplay(settings: ReadonlySettings): boolean { // Assistant mode: statusline fields (model, permission mode, cwd) reflect the // REPL/daemon process, not what the agent child is actually running. Hide it. - if (feature('KAIROS') && getKairosActive()) return false - return settings?.statusLine !== undefined + if (feature('KAIROS') && getKairosActive()) return false; + return settings?.statusLine !== undefined; } function buildStatusLineCommandInput( @@ -72,28 +59,22 @@ function buildStatusLineCommandInput( mainLoopModel: ModelName, vimMode?: VimMode, ): StatusLineCommandInput { - const agentType = getMainThreadAgentType() - const worktreeSession = getCurrentWorktreeSession() + const agentType = getMainThreadAgentType(); + const worktreeSession = getCurrentWorktreeSession(); const runtimeModel = getRuntimeMainLoopModel({ permissionMode, mainLoopModel, exceeds200kTokens, - }) - const outputStyleName = settings?.outputStyle || DEFAULT_OUTPUT_STYLE_NAME + }); + const outputStyleName = settings?.outputStyle || DEFAULT_OUTPUT_STYLE_NAME; - const currentUsage = getCurrentUsage(messages) - const contextWindowSize = getContextWindowForModel( - runtimeModel, - getSdkBetas(), - ) - const contextPercentages = calculateContextPercentages( - currentUsage, - contextWindowSize, - ) + const currentUsage = getCurrentUsage(messages); + const contextWindowSize = getContextWindowForModel(runtimeModel, getSdkBetas()); + const contextPercentages = calculateContextPercentages(currentUsage, contextWindowSize); - const sessionId = getSessionId() - const sessionName = getCurrentSessionTitle(sessionId) - const rawUtil = getRawUtilization() + const sessionId = getSessionId(); + const sessionName = getCurrentSessionTitle(sessionId); + const rawUtil = getRawUtilization(); const rateLimits: StatusLineCommandInput['rate_limits'] = { ...(rawUtil.five_hour && { five_hour: { @@ -107,7 +88,7 @@ function buildStatusLineCommandInput( resets_at: rawUtil.seven_day.resets_at, }, }), - } + }; return { ...createBaseHookInput(), ...(sessionName && { session_name: sessionName }), @@ -167,97 +148,89 @@ function buildStatusLineCommandInput( original_branch: worktreeSession.originalBranch, }, }), - } + }; } type Props = { // messages stays behind a ref (read only in the debounced callback); // lastAssistantMessageId is the actual re-render trigger. - messagesRef: React.RefObject - lastAssistantMessageId: string | null - vimMode?: VimMode -} + messagesRef: React.RefObject; + lastAssistantMessageId: string | null; + vimMode?: VimMode; +}; export function getLastAssistantMessageId(messages: Message[]): string | null { - return getLastAssistantMessage(messages)?.uuid ?? null + return getLastAssistantMessage(messages)?.uuid ?? null; } -function StatusLineInner({ - messagesRef, - lastAssistantMessageId, - vimMode, -}: Props): React.ReactNode { - const abortControllerRef = useRef(undefined) - const permissionMode = useAppState(s => s.toolPermissionContext.mode) - const additionalWorkingDirectories = useAppState( - s => s.toolPermissionContext.additionalWorkingDirectories, - ) - const statusLineText = useAppState(s => s.statusLineText) - const setAppState = useSetAppState() - const settings = useSettings() - const { addNotification } = useNotifications() +function StatusLineInner({ messagesRef, lastAssistantMessageId, vimMode }: Props): React.ReactNode { + const abortControllerRef = useRef(undefined); + const permissionMode = useAppState(s => s.toolPermissionContext.mode); + const additionalWorkingDirectories = useAppState(s => s.toolPermissionContext.additionalWorkingDirectories); + const statusLineText = useAppState(s => s.statusLineText); + const setAppState = useSetAppState(); + const settings = useSettings(); + const { addNotification } = useNotifications(); // AppState-sourced model — same source as API requests. getMainLoopModel() // re-reads settings.json on every call, so another session's /model write // would leak into this session's statusline (anthropics/claude-code#37596). - const mainLoopModel = useMainLoopModel() + const mainLoopModel = useMainLoopModel(); // Keep latest values in refs for stable callback access - const settingsRef = useRef(settings) - settingsRef.current = settings - const vimModeRef = useRef(vimMode) - vimModeRef.current = vimMode - const permissionModeRef = useRef(permissionMode) - permissionModeRef.current = permissionMode - const addedDirsRef = useRef(additionalWorkingDirectories) - addedDirsRef.current = additionalWorkingDirectories - const mainLoopModelRef = useRef(mainLoopModel) - mainLoopModelRef.current = mainLoopModel + const settingsRef = useRef(settings); + settingsRef.current = settings; + const vimModeRef = useRef(vimMode); + vimModeRef.current = vimMode; + const permissionModeRef = useRef(permissionMode); + permissionModeRef.current = permissionMode; + const addedDirsRef = useRef(additionalWorkingDirectories); + addedDirsRef.current = additionalWorkingDirectories; + const mainLoopModelRef = useRef(mainLoopModel); + mainLoopModelRef.current = mainLoopModel; // Track previous state to detect changes and cache expensive calculations const previousStateRef = useRef<{ - messageId: string | null - exceeds200kTokens: boolean - permissionMode: PermissionMode - vimMode: VimMode | undefined - mainLoopModel: ModelName + messageId: string | null; + exceeds200kTokens: boolean; + permissionMode: PermissionMode; + vimMode: VimMode | undefined; + mainLoopModel: ModelName; }>({ messageId: null, exceeds200kTokens: false, permissionMode, vimMode, mainLoopModel, - }) + }); // Debounce timer ref - const debounceTimerRef = useRef | undefined>( - undefined, - ) + const debounceTimerRef = useRef | undefined>(undefined); // True when the next invocation should log its result (first run or after settings reload) - const logNextResultRef = useRef(true) + const logNextResultRef = useRef(true); // Stable update function — reads latest values from refs const doUpdate = useCallback(async () => { // Cancel any in-flight requests - abortControllerRef.current?.abort() + abortControllerRef.current?.abort(); - const controller = new AbortController() - abortControllerRef.current = controller + const controller = new AbortController(); + abortControllerRef.current = controller; - const msgs = messagesRef.current + const msgs = messagesRef.current; - const logResult = logNextResultRef.current - logNextResultRef.current = false + const logResult = logNextResultRef.current; + logNextResultRef.current = false; try { - let exceeds200kTokens = previousStateRef.current.exceeds200kTokens + let exceeds200kTokens = previousStateRef.current.exceeds200kTokens; // Only recalculate 200k check if messages changed - const currentMessageId = getLastAssistantMessageId(msgs) + const currentMessageId = getLastAssistantMessageId(msgs); if (currentMessageId !== previousStateRef.current.messageId) { - exceeds200kTokens = doesMostRecentAssistantMessageExceed200k(msgs) - previousStateRef.current.messageId = currentMessageId - previousStateRef.current.exceeds200kTokens = exceeds200kTokens + exceeds200kTokens = doesMostRecentAssistantMessageExceed200k(msgs); + previousStateRef.current.messageId = currentMessageId; + previousStateRef.current.exceeds200kTokens = exceeds200kTokens; } const statusInput = buildStatusLineCommandInput( @@ -268,40 +241,35 @@ function StatusLineInner({ Array.from(addedDirsRef.current.keys()), mainLoopModelRef.current, vimModeRef.current, - ) + ); - const text = await executeStatusLineCommand( - statusInput, - controller.signal, - undefined, - logResult, - ) + const text = await executeStatusLineCommand(statusInput, controller.signal, undefined, logResult); if (!controller.signal.aborted) { setAppState(prev => { - if (prev.statusLineText === text) return prev - return { ...prev, statusLineText: text } - }) + if (prev.statusLineText === text) return prev; + return { ...prev, statusLineText: text }; + }); } } catch { // Silently ignore errors in status line updates } - }, [messagesRef, setAppState]) + }, [messagesRef, setAppState]); // Stable debounced schedule function — no deps, uses refs const scheduleUpdate = useCallback(() => { if (debounceTimerRef.current !== undefined) { - clearTimeout(debounceTimerRef.current) + clearTimeout(debounceTimerRef.current); } debounceTimerRef.current = setTimeout( (ref, doUpdate) => { - ref.current = undefined - void doUpdate() + ref.current = undefined; + void doUpdate(); }, 300, debounceTimerRef, doUpdate, - ) - }, [doUpdate]) + ); + }, [doUpdate]); // Only trigger update when assistant message, permission mode, vim mode, or model actually changes useEffect(() => { @@ -313,45 +281,36 @@ function StatusLineInner({ ) { // Don't update messageId here — let doUpdate handle it so // exceeds200kTokens is recalculated with the latest messages - previousStateRef.current.permissionMode = permissionMode - previousStateRef.current.vimMode = vimMode - previousStateRef.current.mainLoopModel = mainLoopModel - scheduleUpdate() + previousStateRef.current.permissionMode = permissionMode; + previousStateRef.current.vimMode = vimMode; + previousStateRef.current.mainLoopModel = mainLoopModel; + scheduleUpdate(); } - }, [ - lastAssistantMessageId, - permissionMode, - vimMode, - mainLoopModel, - scheduleUpdate, - ]) + }, [lastAssistantMessageId, permissionMode, vimMode, mainLoopModel, scheduleUpdate]); // When the statusLine command changes (hot reload), log the next result - const statusLineCommand = settings?.statusLine?.command - const isFirstSettingsRender = useRef(true) + const statusLineCommand = settings?.statusLine?.command; + const isFirstSettingsRender = useRef(true); useEffect(() => { if (isFirstSettingsRender.current) { - isFirstSettingsRender.current = false - return + isFirstSettingsRender.current = false; + return; } - logNextResultRef.current = true - void doUpdate() - }, [statusLineCommand, doUpdate]) + logNextResultRef.current = true; + void doUpdate(); + }, [statusLineCommand, doUpdate]); // Separate effect for logging on mount useEffect(() => { - const statusLine = settings?.statusLine + const statusLine = settings?.statusLine; if (statusLine) { logEvent('tengu_status_line_mount', { command_length: statusLine.command.length, padding: statusLine.padding, - }) + }); // Log if status line is configured but disabled by disableAllHooks if (settings.disableAllHooks === true) { - logForDebugging( - 'Status line is configured but disableAllHooks is true', - { level: 'warn' }, - ) + logForDebugging('Status line is configured but disableAllHooks is true', { level: 'warn' }); } // executeStatusLineCommand (hooks.ts) returns undefined when trust is // blocked — statusLineText stays undefined forever, user sees nothing, @@ -362,33 +321,28 @@ function StatusLineInner({ text: 'statusline skipped · restart to fix', color: 'warning', priority: 'low', - }) - logForDebugging( - 'Status line command skipped: workspace trust not accepted', - { level: 'warn' }, - ) + }); + logForDebugging('Status line command skipped: workspace trust not accepted', { level: 'warn' }); } } // eslint-disable-next-line react-hooks/exhaustive-deps - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional - }, []) // Only run once on mount - settings stable for initial logging + }, []); // Only run once on mount - settings stable for initial logging // Initial update on mount + cleanup on unmount useEffect(() => { - void doUpdate() + void doUpdate(); return () => { - abortControllerRef.current?.abort() + abortControllerRef.current?.abort(); if (debounceTimerRef.current !== undefined) { - clearTimeout(debounceTimerRef.current) + clearTimeout(debounceTimerRef.current); } - } + }; // eslint-disable-next-line react-hooks/exhaustive-deps - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional - }, []) // Only run once on mount, not when doUpdate changes + }, []); // Only run once on mount, not when doUpdate changes // Get padding from settings or default to 0 - const paddingX = settings?.statusLine?.padding ?? 0 + const paddingX = settings?.statusLine?.padding ?? 0; // StatusLine must have stable height in fullscreen — the footer is // flexShrink:0 so a 0→1 row change when the command finishes steals @@ -404,10 +358,10 @@ function StatusLineInner({ ) : null} - ) + ); } // Parent (PromptInputFooter) re-renders on every setMessages, but StatusLine's // own props now only change when lastAssistantMessageId flips — memo keeps it // from being dragged along (previously ~18 no-prop-change renders per session). -export const StatusLine = memo(StatusLineInner) +export const StatusLine = memo(StatusLineInner); diff --git a/src/components/StatusNotices.tsx b/src/components/StatusNotices.tsx index c8e629885..b22a22559 100644 --- a/src/components/StatusNotices.tsx +++ b/src/components/StatusNotices.tsx @@ -1,43 +1,36 @@ -import * as React from 'react' -import { use } from 'react' -import { Box } from '@anthropic/ink' -import type { AgentDefinitionsResult } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js' -import { getMemoryFiles } from '../utils/claudemd.js' -import { getGlobalConfig } from '../utils/config.js' -import { - getActiveNotices, - type StatusNoticeContext, -} from '../utils/statusNoticeDefinitions.js' +import * as React from 'react'; +import { use } from 'react'; +import { Box } from '@anthropic/ink'; +import type { AgentDefinitionsResult } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'; +import { getMemoryFiles } from '../utils/claudemd.js'; +import { getGlobalConfig } from '../utils/config.js'; +import { getActiveNotices, type StatusNoticeContext } from '../utils/statusNoticeDefinitions.js'; type Props = { - agentDefinitions?: AgentDefinitionsResult -} + agentDefinitions?: AgentDefinitionsResult; +}; /** * StatusNotices contains the information displayed to users at startup. We have * moved neutral or positive status to src/components/Status.tsx instead, which * users can access through /status. */ -export function StatusNotices({ - agentDefinitions, -}: Props = {}): React.ReactNode { +export function StatusNotices({ agentDefinitions }: Props = {}): React.ReactNode { const context: StatusNoticeContext = { config: getGlobalConfig(), agentDefinitions, memoryFiles: use(getMemoryFiles()), - } - const activeNotices = getActiveNotices(context) + }; + const activeNotices = getActiveNotices(context); if (activeNotices.length === 0) { - return null + return null; } return ( {activeNotices.map(notice => ( - - {notice.render(context)} - + {notice.render(context)} ))} - ) + ); } diff --git a/src/components/StructuredDiff.tsx b/src/components/StructuredDiff.tsx index 890977701..d559cf797 100644 --- a/src/components/StructuredDiff.tsx +++ b/src/components/StructuredDiff.tsx @@ -1,22 +1,22 @@ -import type { StructuredPatchHunk } from 'diff' -import * as React from 'react' -import { memo } from 'react' -import { useSettings } from '../hooks/useSettings.js' -import { Box, NoSelect, RawAnsi, useTheme } from '@anthropic/ink' -import { isFullscreenEnvEnabled } from '../utils/fullscreen.js' -import sliceAnsi from '../utils/sliceAnsi.js' -import { expectColorDiff } from './StructuredDiff/colorDiff.js' -import { StructuredDiffFallback } from './StructuredDiff/Fallback.js' +import type { StructuredPatchHunk } from 'diff'; +import * as React from 'react'; +import { memo } from 'react'; +import { useSettings } from '../hooks/useSettings.js'; +import { Box, NoSelect, RawAnsi, useTheme } from '@anthropic/ink'; +import { isFullscreenEnvEnabled } from '../utils/fullscreen.js'; +import sliceAnsi from '../utils/sliceAnsi.js'; +import { expectColorDiff } from './StructuredDiff/colorDiff.js'; +import { StructuredDiffFallback } from './StructuredDiff/Fallback.js'; type Props = { - patch: StructuredPatchHunk - dim: boolean - filePath: string // File path for language detection - firstLine: string | null // First line of file for shebang detection - fileContent?: string // Full file content for syntax context (multiline strings, etc.) - width: number - skipHighlighting?: boolean // Skip syntax highlighting -} + patch: StructuredPatchHunk; + dim: boolean; + filePath: string; // File path for language detection + firstLine: string | null; // First line of file for shebang detection + fileContent?: string; // Full file content for syntax context (multiline strings, etc.) + width: number; + skipHighlighting?: boolean; // Skip syntax highlighting +}; // REPL.tsx renders at two disjoint tree positions (transcript // early-return vs prompt-mode nested in FullscreenLayout), so ctrl+o @@ -30,29 +30,22 @@ type Props = { // reactivating the per-line branch that PR #20378 had bypassed. // Caching the split here restores the O(1)-leaves-per-diff invariant. type CachedRender = { - lines: string[] + lines: string[]; // Two RawAnsi columns replace what was N DiffLine rows. sliceAnsi work // moves from per-remount to cold-cache-only; parseToSpans is eliminated // entirely (RawAnsi bypasses Ansi parsing). - gutterWidth: number - gutters: string[] | null - contents: string[] | null -} -const RENDER_CACHE = new WeakMap< - StructuredPatchHunk, - Map ->() + gutterWidth: number; + gutters: string[] | null; + contents: string[] | null; +}; +const RENDER_CACHE = new WeakMap>(); // Gutter width matches the Rust module's layout: marker (1) + space + // right-aligned line number (max_digits) + space. Depends only on patch // identity (the WeakMap key), so it's cacheable alongside the NAPI output. function computeGutterWidth(patch: StructuredPatchHunk): number { - const maxLineNumber = Math.max( - patch.oldStart + patch.oldLines - 1, - patch.newStart + patch.newLines - 1, - 1, - ) - return maxLineNumber.toString().length + 3 // marker + 2 padding spaces + const maxLineNumber = Math.max(patch.oldStart + patch.oldLines - 1, patch.newStart + patch.newLines - 1, 1); + return maxLineNumber.toString().length + 3; // marker + 2 padding spaces } function renderColorDiff( @@ -65,54 +58,49 @@ function renderColorDiff( dim: boolean, splitGutter: boolean, ): CachedRender | null { - const ColorDiff = expectColorDiff() - if (!ColorDiff) return null + const ColorDiff = expectColorDiff(); + if (!ColorDiff) return null; // Defensive: if the gutter would eat the whole render width (narrow // terminal), skip the split. Rust already wraps to `width` so the // single-column output stays correct; we just lose noSelect. Without // this, sliceAnsi(line, gutterWidth) would return empty content and // RawAnsi(width<=0) is untested. - const rawGutterWidth = splitGutter ? computeGutterWidth(patch) : 0 - const gutterWidth = - rawGutterWidth > 0 && rawGutterWidth < width ? rawGutterWidth : 0 + const rawGutterWidth = splitGutter ? computeGutterWidth(patch) : 0; + const gutterWidth = rawGutterWidth > 0 && rawGutterWidth < width ? rawGutterWidth : 0; - const key = `${theme}|${width}|${dim ? 1 : 0}|${gutterWidth}|${firstLine ?? ''}|${filePath}` + const key = `${theme}|${width}|${dim ? 1 : 0}|${gutterWidth}|${firstLine ?? ''}|${filePath}`; - let perHunk = RENDER_CACHE.get(patch) - const hit = perHunk?.get(key) - if (hit) return hit + let perHunk = RENDER_CACHE.get(patch); + const hit = perHunk?.get(key); + if (hit) return hit; - const lines = new ColorDiff(patch, firstLine, filePath, fileContent).render( - theme, - width, - dim, - ) - if (lines === null) return null + const lines = new ColorDiff(patch, firstLine, filePath, fileContent).render(theme, width, dim); + if (lines === null) return null; // Pre-split the gutter column once (cold-cache). sliceAnsi preserves // styles across the cut; the Rust module already pads the gutter to // gutterWidth so the narrow RawAnsi column's width matches its cells. - let gutters: string[] | null = null - let contents: string[] | null = null + let gutters: string[] | null = null; + let contents: string[] | null = null; if (gutterWidth > 0) { - gutters = lines.map(l => sliceAnsi(l, 0, gutterWidth)) - contents = lines.map(l => sliceAnsi(l, gutterWidth)) + gutters = lines.map(l => sliceAnsi(l, 0, gutterWidth)); + contents = lines.map(l => sliceAnsi(l, gutterWidth)); } - const entry: CachedRender = { lines, gutterWidth, gutters, contents } + const entry: CachedRender = { lines, gutterWidth, gutters, contents }; if (!perHunk) { - perHunk = new Map() - RENDER_CACHE.set(patch, perHunk) + perHunk = new Map(); + RENDER_CACHE.set(patch, perHunk); } // Cap the inner map: width is part of the key, so terminal resize while a // diff is visible accumulates a full render copy per distinct width. Four // variants (two widths × dim on/off) covers the steady state; beyond that // the user is actively resizing and old widths are stale. - if (perHunk.size >= 4) perHunk.clear() - perHunk.set(key, entry) - return entry + if (perHunk.size >= 4) perHunk.clear(); + perHunk.set(key, entry); + return entry; } export const StructuredDiff = memo(function StructuredDiff({ @@ -124,44 +112,34 @@ export const StructuredDiff = memo(function StructuredDiff({ width, skipHighlighting = false, }: Props): React.ReactNode { - const [theme] = useTheme() - const settings = useSettings() - const syntaxHighlightingDisabled = - settings.syntaxHighlightingDisabled ?? false + const [theme] = useTheme(); + const settings = useSettings(); + const syntaxHighlightingDisabled = settings.syntaxHighlightingDisabled ?? false; // Ensure width is at least 1 to prevent crashes in the Rust NAPI module // which expects u32 (can't handle negative numbers) - const safeWidth = Math.max(1, Math.floor(width)) + const safeWidth = Math.max(1, Math.floor(width)); // Only split out a noSelect gutter in fullscreen mode — terminal native // selection is used otherwise and noSelect is meaningless. Both branches // are now O(1) Yoga leaves per diff on remount (2 vs 1), so this gate // only saves cold-cache sliceAnsi work when fullscreen is off. - const splitGutter = isFullscreenEnvEnabled() + const splitGutter = isFullscreenEnvEnabled(); const cached = skipHighlighting || syntaxHighlightingDisabled ? null - : renderColorDiff( - patch, - firstLine, - filePath, - fileContent ?? null, - theme, - safeWidth, - dim, - splitGutter, - ) + : renderColorDiff(patch, firstLine, filePath, fileContent ?? null, theme, safeWidth, dim, splitGutter); if (!cached) { return ( - ) + ); } - const { lines, gutterWidth, gutters, contents } = cached + const { lines, gutterWidth, gutters, contents } = cached; // Two-column layout: gutter (noSelect) + content. NoSelect marks the // Box's computed bounds non-selectable; RawAnsi's measure func sets @@ -175,12 +153,12 @@ export const StructuredDiff = memo(function StructuredDiff({ - ) + ); } return ( - ) -}) + ); +}); diff --git a/src/components/StructuredDiff/Fallback.tsx b/src/components/StructuredDiff/Fallback.tsx index 0331a0ed5..bbe4de695 100644 --- a/src/components/StructuredDiff/Fallback.tsx +++ b/src/components/StructuredDiff/Fallback.tsx @@ -1,8 +1,8 @@ -import { diffWordsWithSpace, type StructuredPatchHunk } from 'diff' -import * as React from 'react' -import { useMemo } from 'react' -import type { ThemeName } from 'src/utils/theme.js' -import { Box, NoSelect, Text, stringWidth, useTheme, wrapText } from '@anthropic/ink' +import { diffWordsWithSpace, type StructuredPatchHunk } from 'diff'; +import * as React from 'react'; +import { useMemo } from 'react'; +import type { ThemeName } from 'src/utils/theme.js'; +import { Box, NoSelect, Text, stringWidth, useTheme, wrapText } from '@anthropic/ink'; /* * StructuredDiffFallback Component: Word-Level Diff Highlighting Example @@ -44,50 +44,46 @@ import { Box, NoSelect, Text, stringWidth, useTheme, wrapText } from '@anthropic // Define DiffLine interface to be used throughout the file interface DiffLine { - code: string - type: 'add' | 'remove' | 'nochange' - i: number - originalCode: string - wordDiff?: boolean // Flag for word-level diffing - matchedLine?: DiffLine + code: string; + type: 'add' | 'remove' | 'nochange'; + i: number; + originalCode: string; + wordDiff?: boolean; // Flag for word-level diffing + matchedLine?: DiffLine; } // Line object type for internal functions export interface LineObject { - code: string - i: number - type: 'add' | 'remove' | 'nochange' - originalCode: string - wordDiff?: boolean - matchedLine?: LineObject + code: string; + i: number; + type: 'add' | 'remove' | 'nochange'; + originalCode: string; + wordDiff?: boolean; + matchedLine?: LineObject; } // Type for word-level diff parts interface DiffPart { - added?: boolean - removed?: boolean - value: string + added?: boolean; + removed?: boolean; + value: string; } type Props = { - patch: StructuredPatchHunk - dim: boolean - width: number -} + patch: StructuredPatchHunk; + dim: boolean; + width: number; +}; // Threshold for when we show a full-line diff instead of word-level diffing -const CHANGE_THRESHOLD = 0.4 +const CHANGE_THRESHOLD = 0.4; -export function StructuredDiffFallback({ - patch, - dim, - width, -}: Props): React.ReactNode { - const [theme] = useTheme() +export function StructuredDiffFallback({ patch, dim, width }: Props): React.ReactNode { + const [theme] = useTheme(); const diff = useMemo( () => formatDiff(patch.lines, patch.oldStart, width, dim, theme), [patch.lines, patch.oldStart, width, dim, theme], - ) + ); return ( @@ -95,7 +91,7 @@ export function StructuredDiffFallback({ {node} ))} - ) + ); } // Transform lines to line objects with type information @@ -107,7 +103,7 @@ export function transformLinesToObjects(lines: string[]): LineObject[] { i: 0, type: 'add', originalCode: code.slice(1), - } + }; } if (code.startsWith('-')) { return { @@ -115,105 +111,102 @@ export function transformLinesToObjects(lines: string[]): LineObject[] { i: 0, type: 'remove', originalCode: code.slice(1), - } + }; } return { code: code.slice(1), i: 0, type: 'nochange', originalCode: code.slice(1), - } - }) + }; + }); } // Group adjacent add/remove lines for word-level diffing export function processAdjacentLines(lineObjects: LineObject[]): LineObject[] { - const processedLines: LineObject[] = [] - let i = 0 + const processedLines: LineObject[] = []; + let i = 0; while (i < lineObjects.length) { - const current = lineObjects[i] + const current = lineObjects[i]; if (!current) { - i++ - continue + i++; + continue; } // Find a sequence of remove followed by add (possible word-level diff candidates) if (current.type === 'remove') { - const removeLines: LineObject[] = [current] - let j = i + 1 + const removeLines: LineObject[] = [current]; + let j = i + 1; // Collect consecutive remove lines while (j < lineObjects.length && lineObjects[j]?.type === 'remove') { - const line = lineObjects[j] + const line = lineObjects[j]; if (line) { - removeLines.push(line) + removeLines.push(line); } - j++ + j++; } // Check if there are add lines following the remove lines - const addLines: LineObject[] = [] + const addLines: LineObject[] = []; while (j < lineObjects.length && lineObjects[j]?.type === 'add') { - const line = lineObjects[j] + const line = lineObjects[j]; if (line) { - addLines.push(line) + addLines.push(line); } - j++ + j++; } // If we have both remove and add lines, perform word-level diffing if (removeLines.length > 0 && addLines.length > 0) { // For word diffing, we'll compare each pair of lines or the closest available match - const pairCount = Math.min(removeLines.length, addLines.length) + const pairCount = Math.min(removeLines.length, addLines.length); // Add paired lines with word diff info for (let k = 0; k < pairCount; k++) { - const removeLine = removeLines[k] - const addLine = addLines[k] + const removeLine = removeLines[k]; + const addLine = addLines[k]; if (removeLine && addLine) { - removeLine.wordDiff = true - addLine.wordDiff = true + removeLine.wordDiff = true; + addLine.wordDiff = true; // Store the matched pair for later word diffing - removeLine.matchedLine = addLine - addLine.matchedLine = removeLine + removeLine.matchedLine = addLine; + addLine.matchedLine = removeLine; } } // Add all remove lines (both paired and unpaired) - processedLines.push(...removeLines.filter(Boolean)) + processedLines.push(...removeLines.filter(Boolean)); // Then add all add lines (both paired and unpaired) - processedLines.push(...addLines.filter(Boolean)) + processedLines.push(...addLines.filter(Boolean)); - i = j // Skip all the lines we've processed + i = j; // Skip all the lines we've processed } else { // No matching add lines, just add the current remove line - processedLines.push(current) - i++ + processedLines.push(current); + i++; } } else { // Not a remove line, just add it - processedLines.push(current) - i++ + processedLines.push(current); + i++; } } - return processedLines + return processedLines; } // Calculate word-level diffs between two text strings -export function calculateWordDiffs( - oldText: string, - newText: string, -): DiffPart[] { +export function calculateWordDiffs(oldText: string, newText: string): DiffPart[] { // Use diffWordsWithSpace instead of diffWords to preserve whitespace // This ensures spaces between tokens like > and { are preserved - const result = diffWordsWithSpace(oldText, newText, { ignoreCase: false }) + const result = diffWordsWithSpace(oldText, newText, { ignoreCase: false }); - return result + return result; } // Process word-level diffs with manual wrapping support @@ -224,149 +217,120 @@ function generateWordDiffElements( dim: boolean, overrideTheme?: ThemeName, ): React.ReactNode[] | null { - const { type, i, wordDiff, matchedLine, originalCode } = item + const { type, i, wordDiff, matchedLine, originalCode } = item; if (!wordDiff || !matchedLine) { - return null // This function only handles word-level diff rendering + return null; // This function only handles word-level diff rendering } - const removedLineText = - type === 'remove' ? originalCode : matchedLine.originalCode - const addedLineText = - type === 'remove' ? matchedLine.originalCode : originalCode + const removedLineText = type === 'remove' ? originalCode : matchedLine.originalCode; + const addedLineText = type === 'remove' ? matchedLine.originalCode : originalCode; - const wordDiffs = calculateWordDiffs(removedLineText, addedLineText) + const wordDiffs = calculateWordDiffs(removedLineText, addedLineText); // Check if we should use word-level diffing - const totalLength = removedLineText.length + addedLineText.length + const totalLength = removedLineText.length + addedLineText.length; const changedLength = wordDiffs .filter(part => part.added || part.removed) - .reduce((sum, part) => sum + part.value.length, 0) - const changeRatio = changedLength / totalLength + .reduce((sum, part) => sum + part.value.length, 0); + const changeRatio = changedLength / totalLength; if (changeRatio > CHANGE_THRESHOLD || dim) { - return null // Fall back to standard rendering for major changes + return null; // Fall back to standard rendering for major changes } // Calculate available width for content - const diffPrefix = type === 'add' ? '+' : '-' - const diffPrefixWidth = diffPrefix.length - const availableContentWidth = Math.max( - 1, - width - maxWidth - 1 - diffPrefixWidth, - ) + const diffPrefix = type === 'add' ? '+' : '-'; + const diffPrefixWidth = diffPrefix.length; + const availableContentWidth = Math.max(1, width - maxWidth - 1 - diffPrefixWidth); // Manually wrap the word diff parts with better space efficiency - const wrappedLines: { content: React.ReactNode[]; contentWidth: number }[] = - [] - let currentLine: React.ReactNode[] = [] - let currentLineWidth = 0 + const wrappedLines: { content: React.ReactNode[]; contentWidth: number }[] = []; + let currentLine: React.ReactNode[] = []; + let currentLineWidth = 0; wordDiffs.forEach((part, partIndex) => { // Determine if this part should be shown for this line type - let shouldShow = false - let partBgColor: 'diffAddedWord' | 'diffRemovedWord' | undefined + let shouldShow = false; + let partBgColor: 'diffAddedWord' | 'diffRemovedWord' | undefined; if (type === 'add') { if (part.added) { - shouldShow = true - partBgColor = 'diffAddedWord' + shouldShow = true; + partBgColor = 'diffAddedWord'; } else if (!part.removed) { - shouldShow = true + shouldShow = true; } } else if (type === 'remove') { if (part.removed) { - shouldShow = true - partBgColor = 'diffRemovedWord' + shouldShow = true; + partBgColor = 'diffRemovedWord'; } else if (!part.added) { - shouldShow = true + shouldShow = true; } } - if (!shouldShow) return + if (!shouldShow) return; // Use wrapText to wrap this individual part if it's long - const partWrapped = wrapText(part.value, availableContentWidth, 'wrap') - const partLines = partWrapped.split('\n') + const partWrapped = wrapText(part.value, availableContentWidth, 'wrap'); + const partLines = partWrapped.split('\n'); partLines.forEach((partLine, lineIdx) => { - if (!partLine) return + if (!partLine) return; // Check if we need to start a new line - if ( - lineIdx > 0 || - currentLineWidth + stringWidth(partLine) > availableContentWidth - ) { + if (lineIdx > 0 || currentLineWidth + stringWidth(partLine) > availableContentWidth) { if (currentLine.length > 0) { wrappedLines.push({ content: [...currentLine], contentWidth: currentLineWidth, - }) - currentLine = [] - currentLineWidth = 0 + }); + currentLine = []; + currentLineWidth = 0; } } currentLine.push( - + {partLine} , - ) + ); - currentLineWidth += stringWidth(partLine) - }) - }) + currentLineWidth += stringWidth(partLine); + }); + }); if (currentLine.length > 0) { - wrappedLines.push({ content: currentLine, contentWidth: currentLineWidth }) + wrappedLines.push({ content: currentLine, contentWidth: currentLineWidth }); } // Render each wrapped line as a separate Text element return wrappedLines.map(({ content, contentWidth }, lineIndex) => { - const key = `${type}-${i}-${lineIndex}` + const key = `${type}-${i}-${lineIndex}`; const lineBgColor = - type === 'add' - ? dim - ? 'diffAddedDimmed' - : 'diffAdded' - : dim - ? 'diffRemovedDimmed' - : 'diffRemoved' - const lineNum = lineIndex === 0 ? i : undefined - const lineNumStr = - (lineNum !== undefined - ? lineNum.toString().padStart(maxWidth) - : ' '.repeat(maxWidth)) + ' ' + type === 'add' ? (dim ? 'diffAddedDimmed' : 'diffAdded') : dim ? 'diffRemovedDimmed' : 'diffRemoved'; + const lineNum = lineIndex === 0 ? i : undefined; + const lineNumStr = (lineNum !== undefined ? lineNum.toString().padStart(maxWidth) : ' '.repeat(maxWidth)) + ' '; // Calculate padding to fill the entire terminal width - const usedWidth = lineNumStr.length + diffPrefixWidth + contentWidth - const padding = Math.max(0, width - usedWidth) + const usedWidth = lineNumStr.length + diffPrefixWidth + contentWidth; + const padding = Math.max(0, width - usedWidth); return ( - + {lineNumStr} {diffPrefix} - + {content} {' '.repeat(padding)} - ) - }) + ); + }); } function formatDiff( @@ -377,63 +341,51 @@ function formatDiff( overrideTheme?: ThemeName, ): React.ReactNode[] { // Ensure width is at least 1 to prevent rendering issues with very narrow terminals - const safeWidth = Math.max(1, Math.floor(width)) + const safeWidth = Math.max(1, Math.floor(width)); // Step 1: Transform lines to line objects with type information - const lineObjects = transformLinesToObjects(lines) + const lineObjects = transformLinesToObjects(lines); // Step 2: Group adjacent add/remove lines for word-level diffing - const processedLines = processAdjacentLines(lineObjects) + const processedLines = processAdjacentLines(lineObjects); // Step 3: Number the diff lines - const ls = numberDiffLines(processedLines, startingLineNumber) + const ls = numberDiffLines(processedLines, startingLineNumber); // Find max line number width for alignment - const maxLineNumber = Math.max(...ls.map(({ i }) => i), 0) - const maxWidth = Math.max(maxLineNumber.toString().length + 1, 0) + const maxLineNumber = Math.max(...ls.map(({ i }) => i), 0); + const maxWidth = Math.max(maxLineNumber.toString().length + 1, 0); // Step 4: Render formatting return ls.flatMap((item): React.ReactNode[] => { - const { type, code, i, wordDiff, matchedLine } = item + const { type, code, i, wordDiff, matchedLine } = item; // Handle word-level diffing for add/remove pairs if (wordDiff && matchedLine) { - const wordDiffElements = generateWordDiffElements( - item, - safeWidth, - maxWidth, - dim, - overrideTheme, - ) + const wordDiffElements = generateWordDiffElements(item, safeWidth, maxWidth, dim, overrideTheme); // word-diff might refuse (e.g. due to lines being substantially different) in which // case we'll fall through to normal renderin gbelow if (wordDiffElements !== null) { - return wordDiffElements + return wordDiffElements; } } // Standard rendering for lines without word diffing or as fallback // Calculate available width accounting for line number + space + diff prefix - const diffPrefixWidth = 2 // " " for unchanged, "+ " or "- " for changes - const availableContentWidth = Math.max( - 1, - safeWidth - maxWidth - 1 - diffPrefixWidth, - ) // -1 for space after line number - const wrappedText = wrapText(code, availableContentWidth, 'wrap') - const wrappedLines = wrappedText.split('\n') + const diffPrefixWidth = 2; // " " for unchanged, "+ " or "- " for changes + const availableContentWidth = Math.max(1, safeWidth - maxWidth - 1 - diffPrefixWidth); // -1 for space after line number + const wrappedText = wrapText(code, availableContentWidth, 'wrap'); + const wrappedLines = wrappedText.split('\n'); return wrappedLines.map((line, lineIndex) => { - const key = `${type}-${i}-${lineIndex}` - const lineNum = lineIndex === 0 ? i : undefined - const lineNumStr = - (lineNum !== undefined - ? lineNum.toString().padStart(maxWidth) - : ' '.repeat(maxWidth)) + ' ' - const sigil = type === 'add' ? '+' : type === 'remove' ? '-' : ' ' + const key = `${type}-${i}-${lineIndex}`; + const lineNum = lineIndex === 0 ? i : undefined; + const lineNumStr = (lineNum !== undefined ? lineNum.toString().padStart(maxWidth) : ' '.repeat(maxWidth)) + ' '; + const sigil = type === 'add' ? '+' : type === 'remove' ? '-' : ' '; // Calculate padding to fill the entire terminal width - const contentWidth = lineNumStr.length + 1 + stringWidth(line) // lineNum + sigil + code - const padding = Math.max(0, safeWidth - contentWidth) + const contentWidth = lineNumStr.length + 1 + stringWidth(line); // lineNum + sigil + code + const padding = Math.max(0, safeWidth - contentWidth); const bgColor = type === 'add' @@ -444,7 +396,7 @@ function formatDiff( ? dim ? 'diffRemovedDimmed' : 'diffRemoved' - : undefined + : undefined; // Gutter (line number + sigil) is wrapped in so fullscreen // text selection yields clean code. bgColor carries across both boxes @@ -461,31 +413,24 @@ function formatDiff( {sigil} - + {line} {' '.repeat(padding)} - ) - }) - }) + ); + }); + }); } -export function numberDiffLines( - diff: LineObject[], - startLine: number, -): DiffLine[] { - let i = startLine - const result: DiffLine[] = [] - const queue = [...diff] +export function numberDiffLines(diff: LineObject[], startLine: number): DiffLine[] { + let i = startLine; + const result: DiffLine[] = []; + const queue = [...diff]; while (queue.length > 0) { - const current = queue.shift()! - const { code, type, originalCode, wordDiff, matchedLine } = current + const current = queue.shift()!; + const { code, type, originalCode, wordDiff, matchedLine } = current; const line = { code, type, @@ -493,25 +438,25 @@ export function numberDiffLines( originalCode, wordDiff, matchedLine, - } + }; // Update counters based on change type switch (type) { case 'nochange': - i++ - result.push(line) - break + i++; + result.push(line); + break; case 'add': - i++ - result.push(line) - break + i++; + result.push(line); + break; case 'remove': { - result.push(line) - let numRemoved = 0 + result.push(line); + let numRemoved = 0; while (queue[0]?.type === 'remove') { - i++ - const current = queue.shift()! - const { code, type, originalCode, wordDiff, matchedLine } = current + i++; + const current = queue.shift()!; + const { code, type, originalCode, wordDiff, matchedLine } = current; const line = { code, type, @@ -519,15 +464,15 @@ export function numberDiffLines( originalCode, wordDiff, matchedLine, - } - result.push(line) - numRemoved++ + }; + result.push(line); + numRemoved++; } - i -= numRemoved - break + i -= numRemoved; + break; } } } - return result + return result; } diff --git a/src/components/StructuredDiff/src/utils/theme.ts b/src/components/StructuredDiff/src/utils/theme.ts index 62c7ea83c..b6a2857b7 100644 --- a/src/components/StructuredDiff/src/utils/theme.ts +++ b/src/components/StructuredDiff/src/utils/theme.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type ThemeName = any; +export type ThemeName = any diff --git a/src/components/StructuredDiffList.tsx b/src/components/StructuredDiffList.tsx index cfbedaecb..c6bf3798f 100644 --- a/src/components/StructuredDiffList.tsx +++ b/src/components/StructuredDiffList.tsx @@ -1,27 +1,20 @@ -import type { StructuredPatchHunk } from 'diff' -import * as React from 'react' -import { Box, NoSelect, Text } from '@anthropic/ink' -import { intersperse } from '../utils/array.js' -import { StructuredDiff } from './StructuredDiff.js' +import type { StructuredPatchHunk } from 'diff'; +import * as React from 'react'; +import { Box, NoSelect, Text } from '@anthropic/ink'; +import { intersperse } from '../utils/array.js'; +import { StructuredDiff } from './StructuredDiff.js'; type Props = { - hunks: StructuredPatchHunk[] - dim: boolean - width: number - filePath: string - firstLine: string | null - fileContent?: string -} + hunks: StructuredPatchHunk[]; + dim: boolean; + width: number; + filePath: string; + firstLine: string | null; + fileContent?: string; +}; /** Renders a list of diff hunks with ellipsis separators between them. */ -export function StructuredDiffList({ - hunks, - dim, - width, - filePath, - firstLine, - fileContent, -}: Props): React.ReactNode { +export function StructuredDiffList({ hunks, dim, width, filePath, firstLine, fileContent }: Props): React.ReactNode { return intersperse( hunks.map(hunk => ( @@ -40,5 +33,5 @@ export function StructuredDiffList({ ... ), - ) + ); } diff --git a/src/components/TagTabs.tsx b/src/components/TagTabs.tsx index f974bdad4..37e395c5a 100644 --- a/src/components/TagTabs.tsx +++ b/src/components/TagTabs.tsx @@ -1,45 +1,41 @@ -import React from 'react' -import { Box, Text, stringWidth } from '@anthropic/ink' -import { truncateToWidth } from '../utils/format.js' +import React from 'react'; +import { Box, Text, stringWidth } from '@anthropic/ink'; +import { truncateToWidth } from '../utils/format.js'; // Constants for width calculations - derived from actual rendered strings -const ALL_TAB_LABEL = 'All' -const TAB_PADDING = 2 // Space before and after tab text: " {tab} " -const HASH_PREFIX_LENGTH = 1 // "#" prefix for non-All tabs -const LEFT_ARROW_PREFIX = '← ' -const RIGHT_HINT_WITH_COUNT_PREFIX = '→' -const RIGHT_HINT_SUFFIX = ' (tab to cycle)' -const RIGHT_HINT_NO_COUNT = '(tab to cycle)' -const MAX_OVERFLOW_DIGITS = 2 // Assume max 99 hidden tabs for width calculation +const ALL_TAB_LABEL = 'All'; +const TAB_PADDING = 2; // Space before and after tab text: " {tab} " +const HASH_PREFIX_LENGTH = 1; // "#" prefix for non-All tabs +const LEFT_ARROW_PREFIX = '← '; +const RIGHT_HINT_WITH_COUNT_PREFIX = '→'; +const RIGHT_HINT_SUFFIX = ' (tab to cycle)'; +const RIGHT_HINT_NO_COUNT = '(tab to cycle)'; +const MAX_OVERFLOW_DIGITS = 2; // Assume max 99 hidden tabs for width calculation // Computed widths -const LEFT_ARROW_WIDTH = LEFT_ARROW_PREFIX.length + MAX_OVERFLOW_DIGITS + 1 // "← NN " with gap +const LEFT_ARROW_WIDTH = LEFT_ARROW_PREFIX.length + MAX_OVERFLOW_DIGITS + 1; // "← NN " with gap const RIGHT_HINT_WIDTH_WITH_COUNT = - RIGHT_HINT_WITH_COUNT_PREFIX.length + - MAX_OVERFLOW_DIGITS + - RIGHT_HINT_SUFFIX.length // "→NN (tab to cycle)" -const RIGHT_HINT_WIDTH_NO_COUNT = RIGHT_HINT_NO_COUNT.length + RIGHT_HINT_WITH_COUNT_PREFIX.length + MAX_OVERFLOW_DIGITS + RIGHT_HINT_SUFFIX.length; // "→NN (tab to cycle)" +const RIGHT_HINT_WIDTH_NO_COUNT = RIGHT_HINT_NO_COUNT.length; type Props = { - tabs: string[] - selectedIndex: number - availableWidth: number - showAllProjects?: boolean -} + tabs: string[]; + selectedIndex: number; + availableWidth: number; + showAllProjects?: boolean; +}; /** * Calculate the display width of a tab */ function getTabWidth(tab: string, maxWidth?: number): number { if (tab === ALL_TAB_LABEL) { - return ALL_TAB_LABEL.length + TAB_PADDING + return ALL_TAB_LABEL.length + TAB_PADDING; } // For non-All tabs: " #{tag} " but truncate tag if needed - const tagWidth = stringWidth(tab) - const effectiveTagWidth = maxWidth - ? Math.min(tagWidth, maxWidth - TAB_PADDING - HASH_PREFIX_LENGTH) - : tagWidth - return Math.max(0, effectiveTagWidth) + TAB_PADDING + HASH_PREFIX_LENGTH + const tagWidth = stringWidth(tab); + const effectiveTagWidth = maxWidth ? Math.min(tagWidth, maxWidth - TAB_PADDING - HASH_PREFIX_LENGTH) : tagWidth; + return Math.max(0, effectiveTagWidth) + TAB_PADDING + HASH_PREFIX_LENGTH; } /** @@ -47,92 +43,78 @@ function getTabWidth(tab: string, maxWidth?: number): number { */ function truncateTag(tag: string, maxWidth: number): string { // Available space for the tag text itself: maxWidth - " #" - " " - const availableForTag = maxWidth - TAB_PADDING - HASH_PREFIX_LENGTH + const availableForTag = maxWidth - TAB_PADDING - HASH_PREFIX_LENGTH; if (stringWidth(tag) <= availableForTag) { - return tag + return tag; } if (availableForTag <= 1) { - return tag.charAt(0) + return tag.charAt(0); } - return truncateToWidth(tag, availableForTag) + return truncateToWidth(tag, availableForTag); } -export function TagTabs({ - tabs, - selectedIndex, - availableWidth, - showAllProjects = false, -}: Props): React.ReactNode { - const resumeLabel = showAllProjects ? 'Resume (All Projects)' : 'Resume' - const resumeLabelWidth = resumeLabel.length + 1 // +1 for gap +export function TagTabs({ tabs, selectedIndex, availableWidth, showAllProjects = false }: Props): React.ReactNode { + const resumeLabel = showAllProjects ? 'Resume (All Projects)' : 'Resume'; + const resumeLabelWidth = resumeLabel.length + 1; // +1 for gap // Calculate how much space we have for tabs (use worst-case hint width) - const rightHintWidth = Math.max( - RIGHT_HINT_WIDTH_WITH_COUNT, - RIGHT_HINT_WIDTH_NO_COUNT, - ) - const maxTabsWidth = availableWidth - resumeLabelWidth - rightHintWidth - 2 // 2 for gaps + const rightHintWidth = Math.max(RIGHT_HINT_WIDTH_WITH_COUNT, RIGHT_HINT_WIDTH_NO_COUNT); + const maxTabsWidth = availableWidth - resumeLabelWidth - rightHintWidth - 2; // 2 for gaps // Clamp selectedIndex to valid range - const safeSelectedIndex = Math.max( - 0, - Math.min(selectedIndex, tabs.length - 1), - ) + const safeSelectedIndex = Math.max(0, Math.min(selectedIndex, tabs.length - 1)); // Calculate width of each tab, with truncation for very long tags - const maxSingleTabWidth = Math.max(20, Math.floor(maxTabsWidth / 2)) // At least show half the space for one tab - const tabWidths = tabs.map(tab => getTabWidth(tab, maxSingleTabWidth)) + const maxSingleTabWidth = Math.max(20, Math.floor(maxTabsWidth / 2)); // At least show half the space for one tab + const tabWidths = tabs.map(tab => getTabWidth(tab, maxSingleTabWidth)); // Find a window of tabs that fits, centered around selectedIndex - let startIndex = 0 - let endIndex = tabs.length + let startIndex = 0; + let endIndex = tabs.length; // Calculate total width of all tabs - const totalTabsWidth = tabWidths.reduce( - (sum, w, i) => sum + w + (i < tabWidths.length - 1 ? 1 : 0), - 0, - ) // +1 for gaps between tabs + const totalTabsWidth = tabWidths.reduce((sum, w, i) => sum + w + (i < tabWidths.length - 1 ? 1 : 0), 0); // +1 for gaps between tabs if (totalTabsWidth > maxTabsWidth) { // Need to show a subset - account for left arrow when not at start - const effectiveMaxWidth = maxTabsWidth - LEFT_ARROW_WIDTH + const effectiveMaxWidth = maxTabsWidth - LEFT_ARROW_WIDTH; // Start with the selected tab - let windowWidth = tabWidths[safeSelectedIndex] ?? 0 - startIndex = safeSelectedIndex - endIndex = safeSelectedIndex + 1 + let windowWidth = tabWidths[safeSelectedIndex] ?? 0; + startIndex = safeSelectedIndex; + endIndex = safeSelectedIndex + 1; // Expand window to include more tabs while (startIndex > 0 || endIndex < tabs.length) { - const canExpandLeft = startIndex > 0 - const canExpandRight = endIndex < tabs.length + const canExpandLeft = startIndex > 0; + const canExpandRight = endIndex < tabs.length; if (canExpandLeft) { - const leftWidth = (tabWidths[startIndex - 1] ?? 0) + 1 // +1 for gap + const leftWidth = (tabWidths[startIndex - 1] ?? 0) + 1; // +1 for gap if (windowWidth + leftWidth <= effectiveMaxWidth) { - startIndex-- - windowWidth += leftWidth - continue + startIndex--; + windowWidth += leftWidth; + continue; } } if (canExpandRight) { - const rightWidth = (tabWidths[endIndex] ?? 0) + 1 // +1 for gap + const rightWidth = (tabWidths[endIndex] ?? 0) + 1; // +1 for gap if (windowWidth + rightWidth <= effectiveMaxWidth) { - endIndex++ - windowWidth += rightWidth - continue + endIndex++; + windowWidth += rightWidth; + continue; } } - break + break; } } - const hiddenLeft = startIndex - const hiddenRight = tabs.length - endIndex - const visibleTabs = tabs.slice(startIndex, endIndex) - const visibleIndices = visibleTabs.map((_, i) => startIndex + i) + const hiddenLeft = startIndex; + const hiddenRight = tabs.length - endIndex; + const visibleTabs = tabs.slice(startIndex, endIndex); + const visibleIndices = visibleTabs.map((_, i) => startIndex + i); return ( @@ -144,12 +126,9 @@ export function TagTabs({ )} {visibleTabs.map((tab, i) => { - const actualIndex = visibleIndices[i]! - const isSelected = actualIndex === safeSelectedIndex - const displayText = - tab === ALL_TAB_LABEL - ? tab - : `#${truncateTag(tab, maxSingleTabWidth - TAB_PADDING)}` + const actualIndex = visibleIndices[i]!; + const isSelected = actualIndex === safeSelectedIndex; + const displayText = tab === ALL_TAB_LABEL ? tab : `#${truncateTag(tab, maxSingleTabWidth - TAB_PADDING)}`; return ( - ) + ); })} {hiddenRight > 0 ? ( @@ -172,5 +151,5 @@ export function TagTabs({ {RIGHT_HINT_NO_COUNT} )} - ) + ); } diff --git a/src/components/TaskListV2.tsx b/src/components/TaskListV2.tsx index d96126dec..9f92b4ff7 100644 --- a/src/components/TaskListV2.tsx +++ b/src/components/TaskListV2.tsx @@ -1,116 +1,108 @@ -import figures from 'figures' -import * as React from 'react' -import { useTerminalSize } from '../hooks/useTerminalSize.js' -import { Box, Text, stringWidth } from '@anthropic/ink' -import { useAppState } from '../state/AppState.js' -import { isInProcessTeammateTask } from '../tasks/InProcessTeammateTask/types.js' +import figures from 'figures'; +import * as React from 'react'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { Box, Text, stringWidth } from '@anthropic/ink'; +import { useAppState } from '../state/AppState.js'; +import { isInProcessTeammateTask } from '../tasks/InProcessTeammateTask/types.js'; import { AGENT_COLOR_TO_THEME_COLOR, type AgentColorName, -} from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js' -import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js' -import { count } from '../utils/array.js' -import { summarizeRecentActivities } from '../utils/collapseReadSearch.js' -import { truncateToWidth } from '../utils/format.js' -import { isTodoV2Enabled, type Task } from '../utils/tasks.js' -import type { Theme } from '../utils/theme.js' -import ThemedText from './design-system/ThemedText.js' +} from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js'; +import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js'; +import { count } from '../utils/array.js'; +import { summarizeRecentActivities } from '../utils/collapseReadSearch.js'; +import { truncateToWidth } from '../utils/format.js'; +import { isTodoV2Enabled, type Task } from '../utils/tasks.js'; +import type { Theme } from '../utils/theme.js'; +import ThemedText from './design-system/ThemedText.js'; type Props = { - tasks: Task[] - isStandalone?: boolean -} + tasks: Task[]; + isStandalone?: boolean; +}; -const RECENT_COMPLETED_TTL_MS = 30_000 +const RECENT_COMPLETED_TTL_MS = 30_000; function byIdAsc(a: Task, b: Task): number { - const aNum = parseInt(a.id, 10) - const bNum = parseInt(b.id, 10) + const aNum = parseInt(a.id, 10); + const bNum = parseInt(b.id, 10); if (!isNaN(aNum) && !isNaN(bNum)) { - return aNum - bNum + return aNum - bNum; } - return a.id.localeCompare(b.id) + return a.id.localeCompare(b.id); } -export function TaskListV2({ - tasks, - isStandalone = false, -}: Props): React.ReactNode { - const teamContext = useAppState(s => s.teamContext) - const appStateTasks = useAppState(s => s.tasks) - const [, forceUpdate] = React.useState(0) - const { rows, columns } = useTerminalSize() +export function TaskListV2({ tasks, isStandalone = false }: Props): React.ReactNode { + const teamContext = useAppState(s => s.teamContext); + const appStateTasks = useAppState(s => s.tasks); + const [, forceUpdate] = React.useState(0); + const { rows, columns } = useTerminalSize(); // Track when each task was last observed transitioning to completed - const completionTimestampsRef = React.useRef(new Map()) - const previousCompletedIdsRef = React.useRef | null>(null) + const completionTimestampsRef = React.useRef(new Map()); + const previousCompletedIdsRef = React.useRef | null>(null); if (previousCompletedIdsRef.current === null) { - previousCompletedIdsRef.current = new Set( - tasks.filter(t => t.status === 'completed').map(t => t.id), - ) + previousCompletedIdsRef.current = new Set(tasks.filter(t => t.status === 'completed').map(t => t.id)); } - const maxDisplay = rows <= 10 ? 0 : Math.min(10, Math.max(3, rows - 14)) + const maxDisplay = rows <= 10 ? 0 : Math.min(10, Math.max(3, rows - 14)); // Update completion timestamps: reset when a task transitions to completed - const currentCompletedIds = new Set( - tasks.filter(t => t.status === 'completed').map(t => t.id), - ) - const now = Date.now() + const currentCompletedIds = new Set(tasks.filter(t => t.status === 'completed').map(t => t.id)); + const now = Date.now(); for (const id of currentCompletedIds) { if (!previousCompletedIdsRef.current.has(id)) { - completionTimestampsRef.current.set(id, now) + completionTimestampsRef.current.set(id, now); } } for (const id of completionTimestampsRef.current.keys()) { if (!currentCompletedIds.has(id)) { - completionTimestampsRef.current.delete(id) + completionTimestampsRef.current.delete(id); } } - previousCompletedIdsRef.current = currentCompletedIds + previousCompletedIdsRef.current = currentCompletedIds; // Schedule re-render when the next recent completion expires. // Depend on `tasks` so the timer is only reset when the task list changes, // not on every render (which was causing unnecessary work). React.useEffect(() => { if (completionTimestampsRef.current.size === 0) { - return + return; } - const currentNow = Date.now() - let earliestExpiry = Infinity + const currentNow = Date.now(); + let earliestExpiry = Infinity; for (const ts of completionTimestampsRef.current.values()) { - const expiry = ts + RECENT_COMPLETED_TTL_MS + const expiry = ts + RECENT_COMPLETED_TTL_MS; if (expiry > currentNow && expiry < earliestExpiry) { - earliestExpiry = expiry + earliestExpiry = expiry; } } if (earliestExpiry === Infinity) { - return + return; } const timer = setTimeout( forceUpdate => forceUpdate((n: number) => n + 1), earliestExpiry - currentNow, forceUpdate, - ) - return () => clearTimeout(timer) - }, [tasks]) + ); + return () => clearTimeout(timer); + }, [tasks]); if (!isTodoV2Enabled()) { - return null + return null; } if (tasks.length === 0) { - return null + return null; } // Build a map of teammate name -> theme color - const teammateColors: Record = {} + const teammateColors: Record = {}; if (isAgentSwarmsEnabled() && teamContext?.teammates) { for (const teammate of Object.values(teamContext.teammates)) { if (teammate.color) { - const themeColor = - AGENT_COLOR_TO_THEME_COLOR[teammate.color as AgentColorName] + const themeColor = AGENT_COLOR_TO_THEME_COLOR[teammate.color as AgentColorName]; if (themeColor) { - teammateColors[teammate.name] = themeColor + teammateColors[teammate.name] = themeColor; } } } @@ -121,98 +113,88 @@ export function TaskListV2({ // task owners match regardless of which format the model used. // Rolls up consecutive search/read tool uses into a compact summary. // Also track which teammates are still running (not shut down). - const teammateActivity: Record = {} - const activeTeammates = new Set() + const teammateActivity: Record = {}; + const activeTeammates = new Set(); if (isAgentSwarmsEnabled()) { for (const bgTask of Object.values(appStateTasks)) { if (isInProcessTeammateTask(bgTask) && bgTask.status === 'running') { - activeTeammates.add(bgTask.identity.agentName) - activeTeammates.add(bgTask.identity.agentId) - const activities = bgTask.progress?.recentActivities + activeTeammates.add(bgTask.identity.agentName); + activeTeammates.add(bgTask.identity.agentId); + const activities = bgTask.progress?.recentActivities; const desc = - (activities && summarizeRecentActivities(activities)) ?? - bgTask.progress?.lastActivity?.activityDescription + (activities && summarizeRecentActivities(activities)) ?? bgTask.progress?.lastActivity?.activityDescription; if (desc) { - teammateActivity[bgTask.identity.agentName] = desc - teammateActivity[bgTask.identity.agentId] = desc + teammateActivity[bgTask.identity.agentName] = desc; + teammateActivity[bgTask.identity.agentId] = desc; } } } } // Get task counts for display - const completedCount = count(tasks, t => t.status === 'completed') - const pendingCount = count(tasks, t => t.status === 'pending') - const inProgressCount = tasks.length - completedCount - pendingCount + const completedCount = count(tasks, t => t.status === 'completed'); + const pendingCount = count(tasks, t => t.status === 'pending'); + const inProgressCount = tasks.length - completedCount - pendingCount; // Unresolved tasks (open or in_progress) block dependent tasks - const unresolvedTaskIds = new Set( - tasks.filter(t => t.status !== 'completed').map(t => t.id), - ) + const unresolvedTaskIds = new Set(tasks.filter(t => t.status !== 'completed').map(t => t.id)); // Check if we need to truncate - const needsTruncation = tasks.length > maxDisplay + const needsTruncation = tasks.length > maxDisplay; - let visibleTasks: Task[] - let hiddenTasks: Task[] + let visibleTasks: Task[]; + let hiddenTasks: Task[]; if (needsTruncation) { // Prioritize: recently completed (within 30s), in-progress, pending, older completed - const recentCompleted: Task[] = [] - const olderCompleted: Task[] = [] + const recentCompleted: Task[] = []; + const olderCompleted: Task[] = []; for (const task of tasks.filter(t => t.status === 'completed')) { - const ts = completionTimestampsRef.current.get(task.id) + const ts = completionTimestampsRef.current.get(task.id); if (ts && now - ts < RECENT_COMPLETED_TTL_MS) { - recentCompleted.push(task) + recentCompleted.push(task); } else { - olderCompleted.push(task) + olderCompleted.push(task); } } - recentCompleted.sort(byIdAsc) - olderCompleted.sort(byIdAsc) - const inProgress = tasks - .filter(t => t.status === 'in_progress') - .sort(byIdAsc) + recentCompleted.sort(byIdAsc); + olderCompleted.sort(byIdAsc); + const inProgress = tasks.filter(t => t.status === 'in_progress').sort(byIdAsc); const pending = tasks .filter(t => t.status === 'pending') .sort((a, b) => { - const aBlocked = a.blockedBy.some(id => unresolvedTaskIds.has(id)) - const bBlocked = b.blockedBy.some(id => unresolvedTaskIds.has(id)) + const aBlocked = a.blockedBy.some(id => unresolvedTaskIds.has(id)); + const bBlocked = b.blockedBy.some(id => unresolvedTaskIds.has(id)); if (aBlocked !== bBlocked) { - return aBlocked ? 1 : -1 + return aBlocked ? 1 : -1; } - return byIdAsc(a, b) - }) + return byIdAsc(a, b); + }); - const prioritized = [ - ...recentCompleted, - ...inProgress, - ...pending, - ...olderCompleted, - ] - visibleTasks = prioritized.slice(0, maxDisplay) - hiddenTasks = prioritized.slice(maxDisplay) + const prioritized = [...recentCompleted, ...inProgress, ...pending, ...olderCompleted]; + visibleTasks = prioritized.slice(0, maxDisplay); + hiddenTasks = prioritized.slice(maxDisplay); } else { // No truncation needed — sort by ID for stable ordering - visibleTasks = [...tasks].sort(byIdAsc) - hiddenTasks = [] + visibleTasks = [...tasks].sort(byIdAsc); + hiddenTasks = []; } - let hiddenSummary = '' + let hiddenSummary = ''; if (hiddenTasks.length > 0) { - const parts: string[] = [] - const hiddenPending = count(hiddenTasks, t => t.status === 'pending') - const hiddenInProgress = count(hiddenTasks, t => t.status === 'in_progress') - const hiddenCompleted = count(hiddenTasks, t => t.status === 'completed') + const parts: string[] = []; + const hiddenPending = count(hiddenTasks, t => t.status === 'pending'); + const hiddenInProgress = count(hiddenTasks, t => t.status === 'in_progress'); + const hiddenCompleted = count(hiddenTasks, t => t.status === 'completed'); if (hiddenInProgress > 0) { - parts.push(`${hiddenInProgress} in progress`) + parts.push(`${hiddenInProgress} in progress`); } if (hiddenPending > 0) { - parts.push(`${hiddenPending} pending`) + parts.push(`${hiddenPending} pending`); } if (hiddenCompleted > 0) { - parts.push(`${hiddenCompleted} completed`) + parts.push(`${hiddenCompleted} completed`); } - hiddenSummary = ` … +${parts.join(', ')}` + hiddenSummary = ` … +${parts.join(', ')}`; } const content = ( @@ -230,7 +212,7 @@ export function TaskListV2({ ))} {maxDisplay > 0 && hiddenSummary && {hiddenSummary}} - ) + ); if (isStandalone) { return ( @@ -253,85 +235,68 @@ export function TaskListV2({ {content} - ) + ); } - return {content} + return {content}; } type TaskItemProps = { - task: Task - ownerColor?: keyof Theme - openBlockers: string[] - activity?: string - ownerActive: boolean - columns: number -} + task: Task; + ownerColor?: keyof Theme; + openBlockers: string[]; + activity?: string; + ownerActive: boolean; + columns: number; +}; function getTaskIcon(status: Task['status']): { - icon: string - color: keyof Theme | undefined + icon: string; + color: keyof Theme | undefined; } { switch (status) { case 'completed': - return { icon: figures.tick, color: 'success' } + return { icon: figures.tick, color: 'success' }; case 'in_progress': - return { icon: figures.squareSmallFilled, color: 'claude' } + return { icon: figures.squareSmallFilled, color: 'claude' }; case 'pending': - return { icon: figures.squareSmall, color: undefined } + return { icon: figures.squareSmall, color: undefined }; } } -function TaskItem({ - task, - ownerColor, - openBlockers, - activity, - ownerActive, - columns, -}: TaskItemProps): React.ReactNode { - const isCompleted = task.status === 'completed' - const isInProgress = task.status === 'in_progress' - const isBlocked = openBlockers.length > 0 +function TaskItem({ task, ownerColor, openBlockers, activity, ownerActive, columns }: TaskItemProps): React.ReactNode { + const isCompleted = task.status === 'completed'; + const isInProgress = task.status === 'in_progress'; + const isBlocked = openBlockers.length > 0; - const { icon, color } = getTaskIcon(task.status) + const { icon, color } = getTaskIcon(task.status); - const showActivity = isInProgress && !isBlocked && activity + const showActivity = isInProgress && !isBlocked && activity; // Responsive layout: hide owner on narrow screens (<60 cols) // Truncate subject based on available space - const showOwner = columns >= 60 && task.owner && ownerActive - const ownerWidth = showOwner ? stringWidth(` (@${task.owner})`) : 0 + const showOwner = columns >= 60 && task.owner && ownerActive; + const ownerWidth = showOwner ? stringWidth(` (@${task.owner})`) : 0; // Account for: icon(2) + indentation(~8 when nested under spinner) + owner + safety // Use columns - 15 as a conservative estimate for nested layouts - const maxSubjectWidth = Math.max(15, columns - 15 - ownerWidth) - const displaySubject = truncateToWidth(task.subject, maxSubjectWidth) + const maxSubjectWidth = Math.max(15, columns - 15 - ownerWidth); + const displaySubject = truncateToWidth(task.subject, maxSubjectWidth); // Truncate activity for narrow screens - const maxActivityWidth = Math.max(15, columns - 15) - const displayActivity = activity - ? truncateToWidth(activity, maxActivityWidth) - : undefined + const maxActivityWidth = Math.max(15, columns - 15); + const displayActivity = activity ? truncateToWidth(activity, maxActivityWidth) : undefined; return ( {icon} - + {displaySubject} {showOwner && ( {' ('} - {ownerColor ? ( - @{task.owner} - ) : ( - `@${task.owner}` - )} + {ownerColor ? @{task.owner} : `@${task.owner}`} {')'} )} @@ -356,5 +321,5 @@ function TaskItem({ )} - ) + ); } diff --git a/src/components/TeammateViewHeader.tsx b/src/components/TeammateViewHeader.tsx index 1613e7aca..5c92d0ba6 100644 --- a/src/components/TeammateViewHeader.tsx +++ b/src/components/TeammateViewHeader.tsx @@ -1,23 +1,23 @@ -import * as React from 'react' -import { Box, Text, KeyboardShortcutHint } from '@anthropic/ink' -import { toInkColor } from '../utils/ink.js' -import { useAppState } from '../state/AppState.js' -import { getViewedTeammateTask } from '../state/selectors.js' +import * as React from 'react'; +import { Box, Text, KeyboardShortcutHint } from '@anthropic/ink'; +import { toInkColor } from '../utils/ink.js'; +import { useAppState } from '../state/AppState.js'; +import { getViewedTeammateTask } from '../state/selectors.js'; -import { OffscreenFreeze } from './OffscreenFreeze.js' +import { OffscreenFreeze } from './OffscreenFreeze.js'; /** * Header shown when viewing a teammate's transcript. * Displays teammate name (colored), task description, and exit hint. */ export function TeammateViewHeader(): React.ReactNode { - const viewedTeammate = useAppState(s => getViewedTeammateTask(s)) + const viewedTeammate = useAppState(s => getViewedTeammateTask(s)); if (!viewedTeammate) { - return null + return null; } - const nameColor = toInkColor(viewedTeammate.identity.color) + const nameColor = toInkColor(viewedTeammate.identity.color); return ( @@ -35,5 +35,5 @@ export function TeammateViewHeader(): React.ReactNode { {viewedTeammate.prompt} - ) + ); } diff --git a/src/components/TeleportError.tsx b/src/components/TeleportError.tsx index 260f488d3..507512150 100644 --- a/src/components/TeleportError.tsx +++ b/src/components/TeleportError.tsx @@ -1,125 +1,106 @@ -import React, { useCallback, useEffect, useState } from 'react' -import { - checkIsGitClean, - checkNeedsClaudeAiLogin, -} from 'src/utils/background/remote/preconditions.js' -import { gracefulShutdownSync } from 'src/utils/gracefulShutdown.js' -import { Box, Text } from '@anthropic/ink' -import { ConsoleOAuthFlow } from './ConsoleOAuthFlow.js' -import { Select } from './CustomSelect/index.js' -import { Dialog } from '@anthropic/ink' -import { TeleportStash } from './TeleportStash.js' +import React, { useCallback, useEffect, useState } from 'react'; +import { checkIsGitClean, checkNeedsClaudeAiLogin } from 'src/utils/background/remote/preconditions.js'; +import { gracefulShutdownSync } from 'src/utils/gracefulShutdown.js'; +import { Box, Text } from '@anthropic/ink'; +import { ConsoleOAuthFlow } from './ConsoleOAuthFlow.js'; +import { Select } from './CustomSelect/index.js'; +import { Dialog } from '@anthropic/ink'; +import { TeleportStash } from './TeleportStash.js'; -export type TeleportLocalErrorType = 'needsLogin' | 'needsGitStash' +export type TeleportLocalErrorType = 'needsLogin' | 'needsGitStash'; type TeleportErrorProps = { - onComplete: () => void - errorsToIgnore?: ReadonlySet -} + onComplete: () => void; + errorsToIgnore?: ReadonlySet; +}; // Module-level sentinel so the default parameter has stable identity. // Previously `= new Set()` created a fresh Set every render, which put // a new object in checkErrors' deps and caused the mount effect to // re-fire on every render. -const EMPTY_ERRORS_TO_IGNORE: ReadonlySet = new Set() +const EMPTY_ERRORS_TO_IGNORE: ReadonlySet = new Set(); export function TeleportError({ onComplete, errorsToIgnore = EMPTY_ERRORS_TO_IGNORE, }: TeleportErrorProps): React.ReactNode { - const [currentError, setCurrentError] = - useState(null) - const [isLoggingIn, setIsLoggingIn] = useState(false) + const [currentError, setCurrentError] = useState(null); + const [isLoggingIn, setIsLoggingIn] = useState(false); // Check for errors on mount and when error resolution occurs const checkErrors = useCallback(async () => { - const currentErrors = await getTeleportErrors() + const currentErrors = await getTeleportErrors(); const filteredErrors = new Set( - Array.from(currentErrors).filter( - (error: TeleportLocalErrorType) => !errorsToIgnore.has(error), - ), - ) + Array.from(currentErrors).filter((error: TeleportLocalErrorType) => !errorsToIgnore.has(error)), + ); // If no errors remain, call onComplete if (filteredErrors.size === 0) { - onComplete() - return + onComplete(); + return; } // Set current error to handle (prioritize login over git) if (filteredErrors.has('needsLogin')) { - setCurrentError('needsLogin') + setCurrentError('needsLogin'); } else if (filteredErrors.has('needsGitStash')) { - setCurrentError('needsGitStash') + setCurrentError('needsGitStash'); } - }, [onComplete, errorsToIgnore]) + }, [onComplete, errorsToIgnore]); // Check errors on mount useEffect(() => { - void checkErrors() - }, [checkErrors]) + void checkErrors(); + }, [checkErrors]); const onCancel = useCallback(() => { - gracefulShutdownSync(0) - }, []) + gracefulShutdownSync(0); + }, []); const handleLoginComplete = useCallback(() => { - setIsLoggingIn(false) - void checkErrors() - }, [checkErrors]) + setIsLoggingIn(false); + void checkErrors(); + }, [checkErrors]); const handleLoginWithClaudeAI = useCallback(() => { - setIsLoggingIn(true) - }, [setIsLoggingIn]) + setIsLoggingIn(true); + }, [setIsLoggingIn]); const handleLoginDialogSelect = useCallback( (value: string) => { if (value === 'login') { - handleLoginWithClaudeAI() + handleLoginWithClaudeAI(); } else { // User selected exit - onCancel() + onCancel(); } }, [handleLoginWithClaudeAI, onCancel], - ) + ); const handleStashComplete = useCallback(() => { - void checkErrors() - }, [checkErrors]) + void checkErrors(); + }, [checkErrors]); // Don't render anything if no current error (onComplete will be called) if (!currentError) { - return null + return null; } switch (currentError) { case 'needsGitStash': - return ( - - ) + return ; case 'needsLogin': { if (isLoggingIn) { - return ( - - ) + return ; } return ( Teleport requires a Claude.ai account. - - Your Claude Pro/Max subscription will be used by Claude Code. - + Your Claude Pro/Max subscription will be used by Claude Code. void handleChange(value)} - /> + { - setPreviewTheme(setting as ThemeSetting) + setPreviewTheme(setting as ThemeSetting); }} onChange={(setting: string) => { - savePreview() - onThemeSelect(setting as ThemeSetting) + savePreview(); + onThemeSelect(setting as ThemeSetting); }} onCancel={ skipExitHandling ? () => { - cancelPreview() - onCancelProp?.() + cancelPreview(); + onCancelProp?.(); } : async () => { - cancelPreview() - await gracefulShutdown(0) + cancelPreview(); + await gracefulShutdown(0); } } visibleOptionCount={themeOptions.length} @@ -186,7 +171,7 @@ export function ThemePicker({ - ) + ); // Only wrap in a box when not in onboarding if (!showIntroText) { @@ -215,8 +200,8 @@ export function ThemePicker({ )} - ) + ); } - return content + return content; } diff --git a/src/components/ThinkingToggle.tsx b/src/components/ThinkingToggle.tsx index da2a78687..a6d3e3802 100644 --- a/src/components/ThinkingToggle.tsx +++ b/src/components/ThinkingToggle.tsx @@ -1,29 +1,22 @@ -import * as React from 'react' -import { useState } from 'react' -import { useExitOnCtrlCDWithKeybindings } from 'src/hooks/useExitOnCtrlCDWithKeybindings.js' -import { Box, Text } from '@anthropic/ink' -import { useKeybinding } from '../keybindings/useKeybinding.js' -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' -import { Select } from './CustomSelect/index.js' -import { Byline, KeyboardShortcutHint, Pane } from '@anthropic/ink' +import * as React from 'react'; +import { useState } from 'react'; +import { useExitOnCtrlCDWithKeybindings } from 'src/hooks/useExitOnCtrlCDWithKeybindings.js'; +import { Box, Text } from '@anthropic/ink'; +import { useKeybinding } from '../keybindings/useKeybinding.js'; +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; +import { Select } from './CustomSelect/index.js'; +import { Byline, KeyboardShortcutHint, Pane } from '@anthropic/ink'; export type Props = { - currentValue: boolean - onSelect: (enabled: boolean) => void - onCancel?: () => void - isMidConversation?: boolean -} + currentValue: boolean; + onSelect: (enabled: boolean) => void; + onCancel?: () => void; + isMidConversation?: boolean; +}; -export function ThinkingToggle({ - currentValue, - onSelect, - onCancel, - isMidConversation, -}: Props): React.ReactNode { - const exitState = useExitOnCtrlCDWithKeybindings() - const [confirmationPending, setConfirmationPending] = useState< - boolean | null - >(null) +export function ThinkingToggle({ currentValue, onSelect, onCancel, isMidConversation }: Props): React.ReactNode { + const exitState = useExitOnCtrlCDWithKeybindings(); + const [confirmationPending, setConfirmationPending] = useState(null); const options = [ { @@ -36,38 +29,38 @@ export function ThinkingToggle({ label: 'Disabled', description: 'Claude will respond without extended thinking', }, - ] + ]; // Use configurable keybinding for ESC to cancel/go back useKeybinding( 'confirm:no', () => { if (confirmationPending !== null) { - setConfirmationPending(null) + setConfirmationPending(null); } else { - onCancel?.() + onCancel?.(); } }, { context: 'Confirmation' }, - ) + ); // Use configurable keybinding for Enter to confirm in confirmation mode useKeybinding( 'confirm:yes', () => { if (confirmationPending !== null) { - onSelect(confirmationPending) + onSelect(confirmationPending); } }, { context: 'Confirmation', isActive: confirmationPending !== null }, - ) + ); function handleSelectChange(value: string): void { - const selected = value === 'true' + const selected = value === 'true'; if (isMidConversation && selected !== currentValue) { - setConfirmationPending(selected) + setConfirmationPending(selected); } else { - onSelect(selected) + onSelect(selected); } } @@ -84,9 +77,8 @@ export function ThinkingToggle({ {confirmationPending !== null ? ( - Changing thinking mode mid-conversation will increase latency and - may reduce quality. For best results, set this at the start of a - session. + Changing thinking mode mid-conversation will increase latency and may reduce quality. For best results, + set this at the start of a session. Do you want to proceed? @@ -109,25 +101,15 @@ export function ThinkingToggle({ ) : confirmationPending !== null ? ( - + ) : ( - + )} - ) + ); } diff --git a/src/components/TokenWarning.tsx b/src/components/TokenWarning.tsx index fe134c7ec..41fe004e6 100644 --- a/src/components/TokenWarning.tsx +++ b/src/components/TokenWarning.tsx @@ -1,20 +1,20 @@ -import { feature } from 'bun:bundle' -import * as React from 'react' -import { useSyncExternalStore } from 'react' -import { Box, Text } from '@anthropic/ink' -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' +import { feature } from 'bun:bundle'; +import * as React from 'react'; +import { useSyncExternalStore } from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'; import { calculateTokenWarningState, getEffectiveContextWindowSize, isAutoCompactEnabled, -} from '../services/compact/autoCompact.js' -import { useCompactWarningSuppression } from '../services/compact/compactWarningHook.js' -import { getUpgradeMessage } from '../utils/model/contextWindowUpgradeCheck.js' +} from '../services/compact/autoCompact.js'; +import { useCompactWarningSuppression } from '../services/compact/compactWarningHook.js'; +import { getUpgradeMessage } from '../utils/model/contextWindowUpgradeCheck.js'; type Props = { - tokenUsage: number - model: string -} + tokenUsage: number; + model: string; +}; /** * Live collapse progress: "x / y summarized". Sub-component so @@ -22,68 +22,62 @@ type Props = { * (hooks-in-conditionals would violate React rules). The parent only * renders this when feature('CONTEXT_COLLAPSE') + isContextCollapseEnabled(). */ -function CollapseLabel({ - upgradeMessage, -}: { - upgradeMessage: string | null -}): React.ReactNode { +function CollapseLabel({ upgradeMessage }: { upgradeMessage: string | null }): React.ReactNode { /* eslint-disable @typescript-eslint/no-require-imports */ const { getStats, subscribe } = - require('../services/contextCollapse/index.js') as typeof import('../services/contextCollapse/index.js') + require('../services/contextCollapse/index.js') as typeof import('../services/contextCollapse/index.js'); /* eslint-enable @typescript-eslint/no-require-imports */ // Snapshot must be referentially stable across calls when the // underlying counts haven't changed — returning a fresh object every // time would infinite-loop useSyncExternalStore. Encode as a string. const snapshot = useSyncExternalStore(subscribe, () => { - const s = getStats() - const idleWarn = s.health.emptySpawnWarningEmitted ? 1 : 0 - return `${s.collapsedSpans}|${s.stagedSpans}|${s.health.totalErrors}|${s.health.totalEmptySpawns}|${idleWarn}` - }) + const s = getStats(); + const idleWarn = s.health.emptySpawnWarningEmitted ? 1 : 0; + return `${s.collapsedSpans}|${s.stagedSpans}|${s.health.totalErrors}|${s.health.totalEmptySpawns}|${idleWarn}`; + }); - const [collapsed, staged, errors, emptySpawns, idleWarn] = snapshot - .split('|') - .map(Number) as [number, number, number, number, number] - const total = collapsed + staged + const [collapsed, staged, errors, emptySpawns, idleWarn] = snapshot.split('|').map(Number) as [ + number, + number, + number, + number, + number, + ]; + const total = collapsed + staged; // Show error indicator when ctx-agent is failing silently if (errors > 0 || idleWarn) { - const problem = - errors > 0 - ? `collapse errors: ${errors}` - : `collapse idle (${emptySpawns} empty runs)` + const problem = errors > 0 ? `collapse errors: ${errors}` : `collapse idle (${emptySpawns} empty runs)`; return ( - {total > 0 - ? `${collapsed} / ${total} summarized \u00b7 ${problem}` - : problem} + {total > 0 ? `${collapsed} / ${total} summarized \u00b7 ${problem}` : problem} - ) + ); } - if (total === 0) return null + if (total === 0) return null; - const label = `${collapsed} / ${total} summarized` + const label = `${collapsed} / ${total} summarized`; return ( {upgradeMessage ? `${label} \u00b7 ${upgradeMessage}` : label} - ) + ); } export function TokenWarning({ tokenUsage, model }: Props): React.ReactNode { - const { percentLeft, isAboveWarningThreshold, isAboveErrorThreshold } = - calculateTokenWarningState(tokenUsage, model) + const { percentLeft, isAboveWarningThreshold, isAboveErrorThreshold } = calculateTokenWarningState(tokenUsage, model); // Use reactive hook to check if warning should be suppressed - const suppressWarning = useCompactWarningSuppression() + const suppressWarning = useCompactWarningSuppression(); if (!isAboveWarningThreshold || suppressWarning) { - return null + return null; } - const showAutoCompactWarning = isAutoCompactEnabled() - const upgradeMessage = getUpgradeMessage('warning') + const showAutoCompactWarning = isAutoCompactEnabled(); + const upgradeMessage = getUpgradeMessage('warning'); // Reactive-only or context-collapse mode: proactive autocompact never // fires, so percentLeft's normal calculation (against the autocompact @@ -92,29 +86,26 @@ export function TokenWarning({ tokenUsage, model }: Props): React.ReactNode { // // Each feature() block stands alone so the flag strings DCE from // external builds independently. - let displayPercentLeft = percentLeft - let reactiveOnlyMode = false - let collapseMode = false + let displayPercentLeft = percentLeft; + let reactiveOnlyMode = false; + let collapseMode = false; if (feature('REACTIVE_COMPACT')) { if (getFeatureValue_CACHED_MAY_BE_STALE('tengu_cobalt_raccoon', false)) { - reactiveOnlyMode = true + reactiveOnlyMode = true; } } if (feature('CONTEXT_COLLAPSE')) { /* eslint-disable @typescript-eslint/no-require-imports */ const { isContextCollapseEnabled } = - require('../services/contextCollapse/index.js') as typeof import('../services/contextCollapse/index.js') + require('../services/contextCollapse/index.js') as typeof import('../services/contextCollapse/index.js'); /* eslint-enable @typescript-eslint/no-require-imports */ if (isContextCollapseEnabled()) { - collapseMode = true + collapseMode = true; } } if (reactiveOnlyMode || collapseMode) { - const effectiveWindow = getEffectiveContextWindowSize(model) - displayPercentLeft = Math.max( - 0, - Math.round(((effectiveWindow - tokenUsage) / effectiveWindow) * 100), - ) + const effectiveWindow = getEffectiveContextWindowSize(model); + displayPercentLeft = Math.max(0, Math.round(((effectiveWindow - tokenUsage) / effectiveWindow) * 100)); } // Collapse mode: delegate to the subscribing sub-component so the @@ -125,31 +116,26 @@ export function TokenWarning({ tokenUsage, model }: Props): React.ReactNode { - ) + ); } const autocompactLabel = reactiveOnlyMode ? `${100 - displayPercentLeft}% context used` - : `${displayPercentLeft}% until auto-compact` + : `${displayPercentLeft}% until auto-compact`; return ( {showAutoCompactWarning ? ( - {upgradeMessage - ? `${autocompactLabel} \u00b7 ${upgradeMessage}` - : autocompactLabel} + {upgradeMessage ? `${autocompactLabel} \u00b7 ${upgradeMessage}` : autocompactLabel} ) : ( - + {upgradeMessage ? `Context low (${percentLeft}% remaining) \u00b7 ${upgradeMessage}` : `Context low (${percentLeft}% remaining) \u00b7 Run /compact to compact & continue`} )} - ) + ); } diff --git a/src/components/ToolUseLoader.tsx b/src/components/ToolUseLoader.tsx index de8fdb3c2..fe202575e 100644 --- a/src/components/ToolUseLoader.tsx +++ b/src/components/ToolUseLoader.tsx @@ -1,23 +1,19 @@ -import React from 'react' -import { BLACK_CIRCLE } from '../constants/figures.js' +import React from 'react'; +import { BLACK_CIRCLE } from '../constants/figures.js'; -import { Box, Text } from '@anthropic/ink' -import { useBlink } from '../hooks/useBlink.js' +import { Box, Text } from '@anthropic/ink'; +import { useBlink } from '../hooks/useBlink.js'; type Props = { - isError: boolean - isUnresolved: boolean - shouldAnimate: boolean -} + isError: boolean; + isUnresolved: boolean; + shouldAnimate: boolean; +}; -export function ToolUseLoader({ - isError, - isUnresolved, - shouldAnimate, -}: Props): React.ReactNode { - const [ref, isBlinking] = useBlink(shouldAnimate) +export function ToolUseLoader({ isError, isUnresolved, shouldAnimate }: Props): React.ReactNode { + const [ref, isBlinking] = useBlink(shouldAnimate); - const color = isUnresolved ? undefined : isError ? 'error' : 'success' + const color = isUnresolved ? undefined : isError ? 'error' : 'success'; // WARNING: The code here and in AssistantToolUseMessage is particularly // sensitive to what *should* just be trivial refactorings. A `x` @@ -30,10 +26,8 @@ export function ToolUseLoader({ return ( - {!shouldAnimate || isBlinking || isError || !isUnresolved - ? BLACK_CIRCLE - : ' '} + {!shouldAnimate || isBlinking || isError || !isUnresolved ? BLACK_CIRCLE : ' '} - ) + ); } diff --git a/src/components/TrustDialog/TrustDialog.tsx b/src/components/TrustDialog/TrustDialog.tsx index e16384b53..4747a85ab 100644 --- a/src/components/TrustDialog/TrustDialog.tsx +++ b/src/components/TrustDialog/TrustDialog.tsx @@ -1,22 +1,19 @@ -import { homedir } from 'os' -import React from 'react' -import { logEvent } from 'src/services/analytics/index.js' -import { setSessionTrustAccepted } from '../../bootstrap/state.js' -import type { Command } from '../../commands.js' -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' -import { Box, Link, Text } from '@anthropic/ink' -import { useKeybinding } from '../../keybindings/useKeybinding.js' -import { getMcpConfigsByScope } from '../../services/mcp/config.js' -import { BASH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/BashTool/toolName.js' -import { - checkHasTrustDialogAccepted, - saveCurrentProjectConfig, -} from '../../utils/config.js' -import { getCwd } from '../../utils/cwd.js' -import { getFsImplementation } from '../../utils/fsOperations.js' -import { gracefulShutdownSync } from '../../utils/gracefulShutdown.js' -import { Select } from '../CustomSelect/index.js' -import { PermissionDialog } from '../permissions/PermissionDialog.js' +import { homedir } from 'os'; +import React from 'react'; +import { logEvent } from 'src/services/analytics/index.js'; +import { setSessionTrustAccepted } from '../../bootstrap/state.js'; +import type { Command } from '../../commands.js'; +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; +import { Box, Link, Text } from '@anthropic/ink'; +import { useKeybinding } from '../../keybindings/useKeybinding.js'; +import { getMcpConfigsByScope } from '../../services/mcp/config.js'; +import { BASH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/BashTool/toolName.js'; +import { checkHasTrustDialogAccepted, saveCurrentProjectConfig } from '../../utils/config.js'; +import { getCwd } from '../../utils/cwd.js'; +import { getFsImplementation } from '../../utils/fsOperations.js'; +import { gracefulShutdownSync } from '../../utils/gracefulShutdown.js'; +import { Select } from '../CustomSelect/index.js'; +import { PermissionDialog } from '../permissions/PermissionDialog.js'; import { getApiKeyHelperSources, getAwsCommandsSources, @@ -25,77 +22,67 @@ import { getGcpCommandsSources, getHooksSources, getOtelHeadersHelperSources, -} from './utils.js' +} from './utils.js'; type Props = { - onDone(): void - commands?: Command[] -} + onDone(): void; + commands?: Command[]; +}; export function TrustDialog({ onDone, commands }: Props): React.ReactNode { - const { servers: projectServers } = getMcpConfigsByScope('project') + const { servers: projectServers } = getMcpConfigsByScope('project'); // In all cases, we generally check only the project-level and // project-local-level settings, which we assume that users do not configure // directly compared to user-level settings. // Check for MCPs - const hasMcpServers = Object.keys(projectServers).length > 0 + const hasMcpServers = Object.keys(projectServers).length > 0; // Check for hooks - const hooksSettingSources = getHooksSources() - const hasHooks = hooksSettingSources.length > 0 + const hooksSettingSources = getHooksSources(); + const hasHooks = hooksSettingSources.length > 0; // Check whether code execution is allowed in permissions and slash commands - const bashSettingSources = getBashPermissionSources() + const bashSettingSources = getBashPermissionSources(); // Check for apiKeyHelper which executes arbitrary commands - const apiKeyHelperSources = getApiKeyHelperSources() - const hasApiKeyHelper = apiKeyHelperSources.length > 0 + const apiKeyHelperSources = getApiKeyHelperSources(); + const hasApiKeyHelper = apiKeyHelperSources.length > 0; // Check for AWS commands which execute arbitrary commands - const awsCommandsSources = getAwsCommandsSources() - const hasAwsCommands = awsCommandsSources.length > 0 + const awsCommandsSources = getAwsCommandsSources(); + const hasAwsCommands = awsCommandsSources.length > 0; // Check for GCP commands which execute arbitrary commands - const gcpCommandsSources = getGcpCommandsSources() - const hasGcpCommands = gcpCommandsSources.length > 0 + const gcpCommandsSources = getGcpCommandsSources(); + const hasGcpCommands = gcpCommandsSources.length > 0; // Check for otelHeadersHelper which executes arbitrary commands - const otelHeadersHelperSources = getOtelHeadersHelperSources() - const hasOtelHeadersHelper = otelHeadersHelperSources.length > 0 + const otelHeadersHelperSources = getOtelHeadersHelperSources(); + const hasOtelHeadersHelper = otelHeadersHelperSources.length > 0; // Check for dangerous environment variables (not in SAFE_ENV_VARS) - const dangerousEnvVarsSources = getDangerousEnvVarsSources() - const hasDangerousEnvVars = dangerousEnvVarsSources.length > 0 + const dangerousEnvVarsSources = getDangerousEnvVarsSources(); + const hasDangerousEnvVars = dangerousEnvVarsSources.length > 0; const hasSlashCommandBash = commands?.some( command => command.type === 'prompt' && command.loadedFrom === 'commands_DEPRECATED' && - (command.source === 'projectSettings' || - command.source === 'localSettings') && - command.allowedTools?.some( - (tool: string) => - tool === BASH_TOOL_NAME || tool.startsWith(BASH_TOOL_NAME + '('), - ), - ) ?? false + (command.source === 'projectSettings' || command.source === 'localSettings') && + command.allowedTools?.some((tool: string) => tool === BASH_TOOL_NAME || tool.startsWith(BASH_TOOL_NAME + '(')), + ) ?? false; const hasSkillsBash = commands?.some( command => command.type === 'prompt' && (command.loadedFrom === 'skills' || command.loadedFrom === 'plugin') && - (command.source === 'projectSettings' || - command.source === 'localSettings' || - command.source === 'plugin') && - command.allowedTools?.some( - (tool: string) => - tool === BASH_TOOL_NAME || tool.startsWith(BASH_TOOL_NAME + '('), - ), - ) ?? false + (command.source === 'projectSettings' || command.source === 'localSettings' || command.source === 'plugin') && + command.allowedTools?.some((tool: string) => tool === BASH_TOOL_NAME || tool.startsWith(BASH_TOOL_NAME + '(')), + ) ?? false; - const hasAnyBashExecution = - bashSettingSources.length > 0 || hasSlashCommandBash || hasSkillsBash + const hasAnyBashExecution = bashSettingSources.length > 0 || hasSlashCommandBash || hasSkillsBash; - const hasTrustDialogAccepted = checkHasTrustDialogAccepted() + const hasTrustDialogAccepted = checkHasTrustDialogAccepted(); React.useEffect(() => { - const isHomeDir = homedir() === getCwd() + const isHomeDir = homedir() === getCwd(); logEvent('tengu_trust_dialog_shown', { isHomeDir, hasMcpServers, @@ -106,7 +93,7 @@ export function TrustDialog({ onDone, commands }: Props): React.ReactNode { hasGcpCommands, hasOtelHeadersHelper, hasDangerousEnvVars, - }) + }); }, [ hasMcpServers, hasHooks, @@ -116,15 +103,15 @@ export function TrustDialog({ onDone, commands }: Props): React.ReactNode { hasGcpCommands, hasOtelHeadersHelper, hasDangerousEnvVars, - ]) + ]); function onChange(value: 'enable_all' | 'exit') { if (value === 'exit') { - gracefulShutdownSync(1) - return + gracefulShutdownSync(1); + return; } - const isHomeDir = homedir() === getCwd() + const isHomeDir = homedir() === getCwd(); logEvent('tengu_trust_dialog_accept', { isHomeDir, @@ -136,18 +123,18 @@ export function TrustDialog({ onDone, commands }: Props): React.ReactNode { hasGcpCommands, hasOtelHeadersHelper, hasDangerousEnvVars, - }) + }); if (isHomeDir) { // For home directory, store trust in session memory only (not persisted to disk) // This allows hooks and other trust-requiring features to work during this session // while preserving the security intent of not permanently trusting home dir - setSessionTrustAccepted(true) + setSessionTrustAccepted(true); } else { saveCurrentProjectConfig(current => ({ ...current, hasTrustDialogAccepted: true, - })) + })); } // Do NOT write MCP server settings here. handleMcpjsonServerApprovals in @@ -155,7 +142,7 @@ export function TrustDialog({ onDone, commands }: Props): React.ReactNode { // UI. Writing enabledMcpjsonServers/enableAllProjectMcpServers here would // mark every server 'approved' and silently skip that dialog. See #15558. - onDone() + onDone(); } // Default onExit is useApp().exit() → Ink.unmount(), which tears down the @@ -164,48 +151,36 @@ export function TrustDialog({ onDone, commands }: Props): React.ReactNode { // so the default would hang the await forever. With keybinding // customization enabled, the chokidar watcher (persistent: true) keeps the // event loop alive and the process freezes. Explicitly exit 1 like "No". - const exitState = useExitOnCtrlCDWithKeybindings(() => - gracefulShutdownSync(1), - ) + const exitState = useExitOnCtrlCDWithKeybindings(() => gracefulShutdownSync(1)); // Use configurable keybinding for ESC to cancel/exit useKeybinding( 'confirm:no', () => { - gracefulShutdownSync(0) + gracefulShutdownSync(0); }, { context: 'Confirmation' }, - ) + ); // Automatically resolve the trust dialog if there is nothing to be shown. if (hasTrustDialogAccepted) { - setTimeout(onDone) - return null + setTimeout(onDone); + return null; } return ( - + {getFsImplementation().cwd()} - Quick safety check: Is this a project you created or one you trust? - (Like your own code, a well-known open source project, or work from - your team). If not, take a moment to review what{"'"}s in this folder - first. - - - Claude Code{"'"}ll be able to read, edit, and execute files here. + Quick safety check: Is this a project you created or one you trust? (Like your own code, a well-known open + source project, or work from your team). If not, take a moment to review what{"'"}s in this folder first. + Claude Code{"'"}ll be able to read, edit, and execute files here. - - Security guide - + Security guide + + (onCancel ? onCancel() : onComplete(undefined))} /> - ) + ); } diff --git a/src/components/agents/SnapshotUpdateDialog.ts b/src/components/agents/SnapshotUpdateDialog.ts index a23511b4d..dbcd2ebff 100644 --- a/src/components/agents/SnapshotUpdateDialog.ts +++ b/src/components/agents/SnapshotUpdateDialog.ts @@ -1,13 +1,16 @@ // Auto-generated stub — replace with real implementation -import type React from 'react'; -import type { AgentMemoryScope } from '@claude-code-best/builtin-tools/tools/AgentTool/agentMemory.js'; +import type React from 'react' +import type { AgentMemoryScope } from '@claude-code-best/builtin-tools/tools/AgentTool/agentMemory.js' -export {}; +export {} export const SnapshotUpdateDialog: React.FC<{ - agentType: string; - scope: AgentMemoryScope; - snapshotTimestamp: string; - onComplete: (choice: 'merge' | 'keep' | 'replace') => void; - onCancel: () => void; -}> = (() => null); -export const buildMergePrompt: (agentType: string, scope: AgentMemoryScope) => string = (() => ''); + agentType: string + scope: AgentMemoryScope + snapshotTimestamp: string + onComplete: (choice: 'merge' | 'keep' | 'replace') => void + onCancel: () => void +}> = () => null +export const buildMergePrompt: ( + agentType: string, + scope: AgentMemoryScope, +) => string = () => '' diff --git a/src/components/agents/ToolSelector.tsx b/src/components/agents/ToolSelector.tsx index 8fc4b4730..33089bbba 100644 --- a/src/components/agents/ToolSelector.tsx +++ b/src/components/agents/ToolSelector.tsx @@ -1,52 +1,52 @@ -import figures from 'figures' -import React, { useCallback, useMemo, useState } from 'react' -import { mcpInfoFromString } from 'src/services/mcp/mcpStringUtils.js' -import { isMcpTool } from 'src/services/mcp/utils.js' -import type { Tool, Tools } from 'src/Tool.js' -import { filterToolsForAgent } from '@claude-code-best/builtin-tools/tools/AgentTool/agentToolUtils.js' -import { AGENT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/AgentTool/constants.js' -import { BashTool } from '@claude-code-best/builtin-tools/tools/BashTool/BashTool.js' -import { ExitPlanModeV2Tool } from '@claude-code-best/builtin-tools/tools/ExitPlanModeTool/ExitPlanModeV2Tool.js' -import { FileEditTool } from '@claude-code-best/builtin-tools/tools/FileEditTool/FileEditTool.js' -import { FileReadTool } from '@claude-code-best/builtin-tools/tools/FileReadTool/FileReadTool.js' -import { FileWriteTool } from '@claude-code-best/builtin-tools/tools/FileWriteTool/FileWriteTool.js' -import { GlobTool } from '@claude-code-best/builtin-tools/tools/GlobTool/GlobTool.js' -import { GrepTool } from '@claude-code-best/builtin-tools/tools/GrepTool/GrepTool.js' -import { ListMcpResourcesTool } from '@claude-code-best/builtin-tools/tools/ListMcpResourcesTool/ListMcpResourcesTool.js' -import { NotebookEditTool } from '@claude-code-best/builtin-tools/tools/NotebookEditTool/NotebookEditTool.js' -import { ReadMcpResourceTool } from '@claude-code-best/builtin-tools/tools/ReadMcpResourceTool/ReadMcpResourceTool.js' -import { TaskOutputTool } from '@claude-code-best/builtin-tools/tools/TaskOutputTool/TaskOutputTool.js' -import { TaskStopTool } from '@claude-code-best/builtin-tools/tools/TaskStopTool/TaskStopTool.js' -import { TodoWriteTool } from '@claude-code-best/builtin-tools/tools/TodoWriteTool/TodoWriteTool.js' -import { TungstenTool } from '@claude-code-best/builtin-tools/tools/TungstenTool/TungstenTool.js' -import { WebFetchTool } from '@claude-code-best/builtin-tools/tools/WebFetchTool/WebFetchTool.js' -import { WebSearchTool } from '@claude-code-best/builtin-tools/tools/WebSearchTool/WebSearchTool.js' -import { type KeyboardEvent, Box, Text } from '@anthropic/ink' -import { useKeybinding } from '../../keybindings/useKeybinding.js' -import { count } from '../../utils/array.js' -import { plural } from '../../utils/stringUtils.js' -import { Divider } from '@anthropic/ink' +import figures from 'figures'; +import React, { useCallback, useMemo, useState } from 'react'; +import { mcpInfoFromString } from 'src/services/mcp/mcpStringUtils.js'; +import { isMcpTool } from 'src/services/mcp/utils.js'; +import type { Tool, Tools } from 'src/Tool.js'; +import { filterToolsForAgent } from '@claude-code-best/builtin-tools/tools/AgentTool/agentToolUtils.js'; +import { AGENT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/AgentTool/constants.js'; +import { BashTool } from '@claude-code-best/builtin-tools/tools/BashTool/BashTool.js'; +import { ExitPlanModeV2Tool } from '@claude-code-best/builtin-tools/tools/ExitPlanModeTool/ExitPlanModeV2Tool.js'; +import { FileEditTool } from '@claude-code-best/builtin-tools/tools/FileEditTool/FileEditTool.js'; +import { FileReadTool } from '@claude-code-best/builtin-tools/tools/FileReadTool/FileReadTool.js'; +import { FileWriteTool } from '@claude-code-best/builtin-tools/tools/FileWriteTool/FileWriteTool.js'; +import { GlobTool } from '@claude-code-best/builtin-tools/tools/GlobTool/GlobTool.js'; +import { GrepTool } from '@claude-code-best/builtin-tools/tools/GrepTool/GrepTool.js'; +import { ListMcpResourcesTool } from '@claude-code-best/builtin-tools/tools/ListMcpResourcesTool/ListMcpResourcesTool.js'; +import { NotebookEditTool } from '@claude-code-best/builtin-tools/tools/NotebookEditTool/NotebookEditTool.js'; +import { ReadMcpResourceTool } from '@claude-code-best/builtin-tools/tools/ReadMcpResourceTool/ReadMcpResourceTool.js'; +import { TaskOutputTool } from '@claude-code-best/builtin-tools/tools/TaskOutputTool/TaskOutputTool.js'; +import { TaskStopTool } from '@claude-code-best/builtin-tools/tools/TaskStopTool/TaskStopTool.js'; +import { TodoWriteTool } from '@claude-code-best/builtin-tools/tools/TodoWriteTool/TodoWriteTool.js'; +import { TungstenTool } from '@claude-code-best/builtin-tools/tools/TungstenTool/TungstenTool.js'; +import { WebFetchTool } from '@claude-code-best/builtin-tools/tools/WebFetchTool/WebFetchTool.js'; +import { WebSearchTool } from '@claude-code-best/builtin-tools/tools/WebSearchTool/WebSearchTool.js'; +import { type KeyboardEvent, Box, Text } from '@anthropic/ink'; +import { useKeybinding } from '../../keybindings/useKeybinding.js'; +import { count } from '../../utils/array.js'; +import { plural } from '../../utils/stringUtils.js'; +import { Divider } from '@anthropic/ink'; type Props = { - tools: Tools - initialTools: string[] | undefined - onComplete: (selectedTools: string[] | undefined) => void - onCancel?: () => void -} + tools: Tools; + initialTools: string[] | undefined; + onComplete: (selectedTools: string[] | undefined) => void; + onCancel?: () => void; +}; type ToolBucket = { - name: string - toolNames: Set - isMcp?: boolean -} + name: string; + toolNames: Set; + isMcp?: boolean; +}; type ToolBuckets = { - READ_ONLY: ToolBucket - EDIT: ToolBucket - EXECUTION: ToolBucket - MCP: ToolBucket - OTHER: ToolBucket -} + READ_ONLY: ToolBucket; + EDIT: ToolBucket; + EXECUTION: ToolBucket; + MCP: ToolBucket; + OTHER: ToolBucket; +}; function getToolBuckets(): ToolBuckets { return { @@ -68,19 +68,12 @@ function getToolBuckets(): ToolBuckets { }, EDIT: { name: 'Edit tools', - toolNames: new Set([ - FileEditTool.name, - FileWriteTool.name, - NotebookEditTool.name, - ]), + toolNames: new Set([FileEditTool.name, FileWriteTool.name, NotebookEditTool.name]), }, EXECUTION: { name: 'Execution tools', toolNames: new Set( - [ - BashTool.name, - process.env.USER_TYPE === 'ant' ? TungstenTool.name : undefined, - ].filter(n => n !== undefined), + [BashTool.name, process.env.USER_TYPE === 'ant' ? TungstenTool.name : undefined].filter(n => n !== undefined), ), }, MCP: { @@ -92,148 +85,133 @@ function getToolBuckets(): ToolBuckets { name: 'Other tools', toolNames: new Set(), // Dynamic - catch-all for uncategorized tools }, - } + }; } // Helper to get MCP server buckets dynamically function getMcpServerBuckets(tools: Tools): Array<{ - serverName: string - tools: Tools + serverName: string; + tools: Tools; }> { - const serverMap = new Map() + const serverMap = new Map(); tools.forEach(tool => { if (isMcpTool(tool)) { - const mcpInfo = mcpInfoFromString(tool.name) + const mcpInfo = mcpInfoFromString(tool.name); if (mcpInfo?.serverName) { - const existing = serverMap.get(mcpInfo.serverName) || [] - existing.push(tool) - serverMap.set(mcpInfo.serverName, existing) + const existing = serverMap.get(mcpInfo.serverName) || []; + existing.push(tool); + serverMap.set(mcpInfo.serverName, existing); } } - }) + }); return Array.from(serverMap.entries()) .map(([serverName, tools]) => ({ serverName, tools })) - .sort((a, b) => a.serverName.localeCompare(b.serverName)) + .sort((a, b) => a.serverName.localeCompare(b.serverName)); } -export function ToolSelector({ - tools, - initialTools, - onComplete, - onCancel, -}: Props): React.ReactNode { +export function ToolSelector({ tools, initialTools, onComplete, onCancel }: Props): React.ReactNode { // Filter tools for custom agents - const customAgentTools = useMemo( - () => filterToolsForAgent({ tools, isBuiltIn: false, isAsync: false }), - [tools], - ) + const customAgentTools = useMemo(() => filterToolsForAgent({ tools, isBuiltIn: false, isAsync: false }), [tools]); // Expand wildcard or undefined to explicit tool list for internal state const expandedInitialTools = - !initialTools || initialTools.includes('*') - ? customAgentTools.map(t => t.name) - : initialTools + !initialTools || initialTools.includes('*') ? customAgentTools.map(t => t.name) : initialTools; - const [selectedTools, setSelectedTools] = - useState(expandedInitialTools) - const [focusIndex, setFocusIndex] = useState(0) - const [showIndividualTools, setShowIndividualTools] = useState(false) + const [selectedTools, setSelectedTools] = useState(expandedInitialTools); + const [focusIndex, setFocusIndex] = useState(0); + const [showIndividualTools, setShowIndividualTools] = useState(false); // Filter selectedTools to only include tools that currently exist // This handles MCP tools that disconnect while selected const validSelectedTools = useMemo(() => { - const toolNames = new Set(customAgentTools.map(t => t.name)) - return selectedTools.filter(name => toolNames.has(name)) - }, [selectedTools, customAgentTools]) + const toolNames = new Set(customAgentTools.map(t => t.name)); + return selectedTools.filter(name => toolNames.has(name)); + }, [selectedTools, customAgentTools]); - const selectedSet = new Set(validSelectedTools) - const isAllSelected = - validSelectedTools.length === customAgentTools.length && - customAgentTools.length > 0 + const selectedSet = new Set(validSelectedTools); + const isAllSelected = validSelectedTools.length === customAgentTools.length && customAgentTools.length > 0; const handleToggleTool = (toolName: string) => { - if (!toolName) return + if (!toolName) return; setSelectedTools(current => - current.includes(toolName) - ? current.filter(t => t !== toolName) - : [...current, toolName], - ) - } + current.includes(toolName) ? current.filter(t => t !== toolName) : [...current, toolName], + ); + }; const handleToggleTools = (toolNames: string[], select: boolean) => { setSelectedTools(current => { if (select) { - const toolsToAdd = toolNames.filter(t => !current.includes(t)) - return [...current, ...toolsToAdd] + const toolsToAdd = toolNames.filter(t => !current.includes(t)); + return [...current, ...toolsToAdd]; } else { - return current.filter(t => !toolNames.includes(t)) + return current.filter(t => !toolNames.includes(t)); } - }) - } + }); + }; const handleConfirm = () => { // Convert to undefined if all tools are selected (for cleaner file format) - const allToolNames = customAgentTools.map(t => t.name) + const allToolNames = customAgentTools.map(t => t.name); const areAllToolsSelected = validSelectedTools.length === allToolNames.length && - allToolNames.every(name => validSelectedTools.includes(name)) - const finalTools = areAllToolsSelected ? undefined : validSelectedTools + allToolNames.every(name => validSelectedTools.includes(name)); + const finalTools = areAllToolsSelected ? undefined : validSelectedTools; - onComplete(finalTools) - } + onComplete(finalTools); + }; // Group tools by bucket const toolsByBucket = useMemo(() => { - const toolBuckets = getToolBuckets() + const toolBuckets = getToolBuckets(); const buckets = { readOnly: [] as Tool[], edit: [] as Tool[], execution: [] as Tool[], mcp: [] as Tool[], other: [] as Tool[], - } + }; customAgentTools.forEach(tool => { // Check if it's an MCP tool first if (isMcpTool(tool)) { - buckets.mcp.push(tool) + buckets.mcp.push(tool); } else if (toolBuckets.READ_ONLY.toolNames.has(tool.name)) { - buckets.readOnly.push(tool) + buckets.readOnly.push(tool); } else if (toolBuckets.EDIT.toolNames.has(tool.name)) { - buckets.edit.push(tool) + buckets.edit.push(tool); } else if (toolBuckets.EXECUTION.toolNames.has(tool.name)) { - buckets.execution.push(tool) + buckets.execution.push(tool); } else if (tool.name !== AGENT_TOOL_NAME) { // Catch-all for uncategorized tools (except Task) - buckets.other.push(tool) + buckets.other.push(tool); } - }) + }); - return buckets - }, [customAgentTools]) + return buckets; + }, [customAgentTools]); const createBucketToggleAction = (bucketTools: Tool[]) => { - const selected = count(bucketTools, t => selectedSet.has(t.name)) - const needsSelection = selected < bucketTools.length + const selected = count(bucketTools, t => selectedSet.has(t.name)); + const needsSelection = selected < bucketTools.length; return () => { - const toolNames = bucketTools.map(t => t.name) - handleToggleTools(toolNames, needsSelection) - } - } + const toolNames = bucketTools.map(t => t.name); + handleToggleTools(toolNames, needsSelection); + }; + }; // Build navigable items (no separators) const navigableItems: Array<{ - id: string - label: string - action: () => void - isContinue?: boolean - isToggle?: boolean - isHeader?: boolean - }> = [] + id: string; + label: string; + action: () => void; + isContinue?: boolean; + isToggle?: boolean; + isHeader?: boolean; + }> = []; // Continue button navigableItems.push({ @@ -241,20 +219,20 @@ export function ToolSelector({ label: 'Continue', action: handleConfirm, isContinue: true, - }) + }); // All tools navigableItems.push({ id: 'bucket-all', label: `${isAllSelected ? figures.checkboxOn : figures.checkboxOff} All tools`, action: () => { - const allToolNames = customAgentTools.map(t => t.name) - handleToggleTools(allToolNames, !isAllSelected) + const allToolNames = customAgentTools.map(t => t.name); + handleToggleTools(allToolNames, !isAllSelected); }, - }) + }); // Create bucket menu items - const toolBuckets = getToolBuckets() + const toolBuckets = getToolBuckets(); const bucketConfigs = [ { id: 'bucket-readonly', @@ -281,43 +259,38 @@ export function ToolSelector({ name: toolBuckets.OTHER.name, tools: toolsByBucket.other, }, - ] + ]; bucketConfigs.forEach(({ id, name, tools: bucketTools }) => { - if (bucketTools.length === 0) return + if (bucketTools.length === 0) return; - const selected = count(bucketTools, t => selectedSet.has(t.name)) - const isFullySelected = selected === bucketTools.length + const selected = count(bucketTools, t => selectedSet.has(t.name)); + const isFullySelected = selected === bucketTools.length; navigableItems.push({ id, label: `${isFullySelected ? figures.checkboxOn : figures.checkboxOff} ${name}`, action: createBucketToggleAction(bucketTools), - }) - }) + }); + }); // Toggle button for individual tools - const toggleButtonIndex = navigableItems.length + const toggleButtonIndex = navigableItems.length; navigableItems.push({ id: 'toggle-individual', - label: showIndividualTools - ? 'Hide advanced options' - : 'Show advanced options', + label: showIndividualTools ? 'Hide advanced options' : 'Show advanced options', action: () => { - setShowIndividualTools(!showIndividualTools) + setShowIndividualTools(!showIndividualTools); // If hiding tools and focus is on an individual tool, move focus to toggle button if (showIndividualTools && focusIndex > toggleButtonIndex) { - setFocusIndex(toggleButtonIndex) + setFocusIndex(toggleButtonIndex); } }, isToggle: true, - }) + }); // Memoize MCP server buckets (must be outside conditional for hooks rules) - const mcpServerBuckets = useMemo( - () => getMcpServerBuckets(customAgentTools), - [customAgentTools], - ) + const mcpServerBuckets = useMemo(() => getMcpServerBuckets(customAgentTools), [customAgentTools]); // Individual tools (only if expanded) if (showIndividualTools) { @@ -328,21 +301,21 @@ export function ToolSelector({ label: 'MCP Servers:', action: () => {}, // No action - just a header isHeader: true, - }) + }); mcpServerBuckets.forEach(({ serverName, tools: serverTools }) => { - const selected = count(serverTools, t => selectedSet.has(t.name)) - const isFullySelected = selected === serverTools.length + const selected = count(serverTools, t => selectedSet.has(t.name)); + const isFullySelected = selected === serverTools.length; navigableItems.push({ id: `mcp-server-${serverName}`, label: `${isFullySelected ? figures.checkboxOn : figures.checkboxOff} ${serverName} (${serverTools.length} ${plural(serverTools.length, 'tool')})`, action: () => { - const toolNames = serverTools.map(t => t.name) - handleToggleTools(toolNames, !isFullySelected) + const toolNames = serverTools.map(t => t.name); + handleToggleTools(toolNames, !isFullySelected); }, - }) - }) + }); + }); // Add separator header before individual tools navigableItems.push({ @@ -350,79 +323,65 @@ export function ToolSelector({ label: 'Individual Tools:', action: () => {}, isHeader: true, - }) + }); } // Add individual tools customAgentTools.forEach(tool => { - let displayName = tool.name + let displayName = tool.name; if (tool.name.startsWith('mcp__')) { - const mcpInfo = mcpInfoFromString(tool.name) - displayName = mcpInfo - ? `${mcpInfo.toolName} (${mcpInfo.serverName})` - : tool.name + const mcpInfo = mcpInfoFromString(tool.name); + displayName = mcpInfo ? `${mcpInfo.toolName} (${mcpInfo.serverName})` : tool.name; } navigableItems.push({ id: `tool-${tool.name}`, label: `${selectedSet.has(tool.name) ? figures.checkboxOn : figures.checkboxOff} ${displayName}`, action: () => handleToggleTool(tool.name), - }) - }) + }); + }); } const handleCancel = useCallback(() => { if (onCancel) { - onCancel() + onCancel(); } else { - onComplete(initialTools) + onComplete(initialTools); } - }, [onCancel, onComplete, initialTools]) + }, [onCancel, onComplete, initialTools]); - useKeybinding('confirm:no', handleCancel, { context: 'Confirmation' }) + useKeybinding('confirm:no', handleCancel, { context: 'Confirmation' }); const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'return') { - e.preventDefault() - const item = navigableItems[focusIndex] + e.preventDefault(); + const item = navigableItems[focusIndex]; if (item && !item.isHeader) { - item.action() + item.action(); } } else if (e.key === 'up') { - e.preventDefault() - let newIndex = focusIndex - 1 + e.preventDefault(); + let newIndex = focusIndex - 1; // Skip headers when navigating up while (newIndex > 0 && navigableItems[newIndex]?.isHeader) { - newIndex-- + newIndex--; } - setFocusIndex(Math.max(0, newIndex)) + setFocusIndex(Math.max(0, newIndex)); } else if (e.key === 'down') { - e.preventDefault() - let newIndex = focusIndex + 1 + e.preventDefault(); + let newIndex = focusIndex + 1; // Skip headers when navigating down - while ( - newIndex < navigableItems.length - 1 && - navigableItems[newIndex]?.isHeader - ) { - newIndex++ + while (newIndex < navigableItems.length - 1 && navigableItems[newIndex]?.isHeader) { + newIndex++; } - setFocusIndex(Math.min(navigableItems.length - 1, newIndex)) + setFocusIndex(Math.min(navigableItems.length - 1, newIndex)); } - } + }; return ( - + {/* Render Continue button */} - + {focusIndex === 0 ? `${figures.pointer} ` : ' '}[ Continue ] @@ -431,9 +390,9 @@ export function ToolSelector({ {/* Render all navigable items except Continue (which is at index 0) */} {navigableItems.slice(1).map((item, index) => { - const isCurrentlyFocused = index + 1 === focusIndex - const isToggleButton = item.isToggle - const isHeader = item.isHeader + const isCurrentlyFocused = index + 1 === focusIndex; + const isToggleButton = item.isToggle; + const isHeader = item.isHeader; return ( @@ -444,34 +403,22 @@ export function ToolSelector({ {isHeader && index > 0 && } - {isHeader - ? '' - : isCurrentlyFocused - ? `${figures.pointer} ` - : ' '} + {isHeader ? '' : isCurrentlyFocused ? `${figures.pointer} ` : ' '} {isToggleButton ? `[ ${item.label} ]` : item.label} - ) + ); })} - {isAllSelected - ? 'All tools selected' - : `${selectedSet.size} of ${customAgentTools.length} tools selected`} + {isAllSelected ? 'All tools selected' : `${selectedSet.size} of ${customAgentTools.length} tools selected`} - ) + ); } diff --git a/src/components/agents/generateAgent.ts b/src/components/agents/generateAgent.ts index 801601d4b..3137967e2 100644 --- a/src/components/agents/generateAgent.ts +++ b/src/components/agents/generateAgent.ts @@ -164,7 +164,9 @@ export async function generateAgent( }, }) - const textBlocks = (Array.isArray(response.message.content) ? response.message.content : []).filter( + const textBlocks = ( + Array.isArray(response.message.content) ? response.message.content : [] + ).filter( (block): block is ContentBlock & { type: 'text' } => block.type === 'text', ) const responseText = textBlocks.map(block => block.text).join('\n') diff --git a/src/components/agents/new-agent-creation/CreateAgentWizard.tsx b/src/components/agents/new-agent-creation/CreateAgentWizard.tsx index fa6ed6816..61e6f19b8 100644 --- a/src/components/agents/new-agent-creation/CreateAgentWizard.tsx +++ b/src/components/agents/new-agent-creation/CreateAgentWizard.tsx @@ -1,35 +1,30 @@ -import React, { type ReactNode } from 'react' -import { isAutoMemoryEnabled } from '../../../memdir/paths.js' -import type { Tools } from '../../../Tool.js' -import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js' -import { WizardProvider } from '../../wizard/index.js' -import type { WizardStepComponent } from '../../wizard/types.js' -import type { AgentWizardData } from './types.js' -import { ColorStep } from './wizard-steps/ColorStep.js' -import { ConfirmStepWrapper } from './wizard-steps/ConfirmStepWrapper.js' -import { DescriptionStep } from './wizard-steps/DescriptionStep.js' -import { GenerateStep } from './wizard-steps/GenerateStep.js' -import { LocationStep } from './wizard-steps/LocationStep.js' -import { MemoryStep } from './wizard-steps/MemoryStep.js' -import { MethodStep } from './wizard-steps/MethodStep.js' -import { ModelStep } from './wizard-steps/ModelStep.js' -import { PromptStep } from './wizard-steps/PromptStep.js' -import { ToolsStep } from './wizard-steps/ToolsStep.js' -import { TypeStep } from './wizard-steps/TypeStep.js' +import React, { type ReactNode } from 'react'; +import { isAutoMemoryEnabled } from '../../../memdir/paths.js'; +import type { Tools } from '../../../Tool.js'; +import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'; +import { WizardProvider } from '../../wizard/index.js'; +import type { WizardStepComponent } from '../../wizard/types.js'; +import type { AgentWizardData } from './types.js'; +import { ColorStep } from './wizard-steps/ColorStep.js'; +import { ConfirmStepWrapper } from './wizard-steps/ConfirmStepWrapper.js'; +import { DescriptionStep } from './wizard-steps/DescriptionStep.js'; +import { GenerateStep } from './wizard-steps/GenerateStep.js'; +import { LocationStep } from './wizard-steps/LocationStep.js'; +import { MemoryStep } from './wizard-steps/MemoryStep.js'; +import { MethodStep } from './wizard-steps/MethodStep.js'; +import { ModelStep } from './wizard-steps/ModelStep.js'; +import { PromptStep } from './wizard-steps/PromptStep.js'; +import { ToolsStep } from './wizard-steps/ToolsStep.js'; +import { TypeStep } from './wizard-steps/TypeStep.js'; type Props = { - tools: Tools - existingAgents: AgentDefinition[] - onComplete: (message: string) => void - onCancel: () => void -} + tools: Tools; + existingAgents: AgentDefinition[]; + onComplete: (message: string) => void; + onCancel: () => void; +}; -export function CreateAgentWizard({ - tools, - existingAgents, - onComplete, - onCancel, -}: Props): ReactNode { +export function CreateAgentWizard({ tools, existingAgents, onComplete, onCancel }: Props): ReactNode { // Create step components with props const steps: WizardStepComponent[] = [ LocationStep, // 0 @@ -43,14 +38,8 @@ export function CreateAgentWizard({ ColorStep, // 8 // MemoryStep is conditionally included based on GrowthBook gate ...(isAutoMemoryEnabled() ? [MemoryStep] : []), - () => ( - - ), - ] + () => , + ]; return ( @@ -64,5 +53,5 @@ export function CreateAgentWizard({ title="Create new agent" showStepCounter={false} /> - ) + ); } diff --git a/src/components/agents/new-agent-creation/types.ts b/src/components/agents/new-agent-creation/types.ts index a592768c1..5dad63c6c 100644 --- a/src/components/agents/new-agent-creation/types.ts +++ b/src/components/agents/new-agent-creation/types.ts @@ -1,2 +1,2 @@ // Auto-generated stub — replace with real implementation -export type AgentWizardData = any; +export type AgentWizardData = any diff --git a/src/components/agents/new-agent-creation/wizard-steps/ColorStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/ColorStep.tsx index e28133a38..67ce1f103 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/ColorStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/ColorStep.tsx @@ -1,19 +1,18 @@ -import React, { type ReactNode } from 'react' -import { Box, Byline, KeyboardShortcutHint } from '@anthropic/ink' -import { useKeybinding } from '../../../../keybindings/useKeybinding.js' -import type { AgentColorName } from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js' -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' -import { useWizard } from '../../../wizard/index.js' -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' -import { ColorPicker } from '../../ColorPicker.js' -import type { AgentWizardData } from '../types.js' +import React, { type ReactNode } from 'react'; +import { Box, Byline, KeyboardShortcutHint } from '@anthropic/ink'; +import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; +import type { AgentColorName } from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js'; +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; +import { useWizard } from '../../../wizard/index.js'; +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; +import { ColorPicker } from '../../ColorPicker.js'; +import type { AgentWizardData } from '../types.js'; export function ColorStep(): ReactNode { - const { goNext, goBack, updateWizardData, wizardData } = - useWizard() + const { goNext, goBack, updateWizardData, wizardData } = useWizard(); // Handle escape key - ColorPicker handles its own escape internally - useKeybinding('confirm:no', goBack, { context: 'Confirmation' }) + useKeybinding('confirm:no', goBack, { context: 'Confirmation' }); const handleConfirm = (color?: string): void => { updateWizardData({ @@ -24,15 +23,13 @@ export function ColorStep(): ReactNode { whenToUse: wizardData.whenToUse!, getSystemPrompt: () => wizardData.systemPrompt!, tools: wizardData.selectedTools, - ...(wizardData.selectedModel - ? { model: wizardData.selectedModel } - : {}), + ...(wizardData.selectedModel ? { model: wizardData.selectedModel } : {}), ...(color ? { color: color as AgentColorName } : {}), source: wizardData.location!, }, - }) - goNext() - } + }); + goNext(); + }; return ( - + } > - + - ) + ); } diff --git a/src/components/agents/new-agent-creation/wizard-steps/ConfirmStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/ConfirmStep.tsx index cbe284991..60d2ff7b3 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/ConfirmStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/ConfirmStep.tsx @@ -1,69 +1,63 @@ -import React, { type ReactNode } from 'react' -import { type KeyboardEvent, Box, Byline, KeyboardShortcutHint, Text } from '@anthropic/ink' -import { useKeybinding } from '../../../../keybindings/useKeybinding.js' -import { isAutoMemoryEnabled } from '../../../../memdir/paths.js' -import type { Tools } from '../../../../Tool.js' -import { getMemoryScopeDisplay } from '@claude-code-best/builtin-tools/tools/AgentTool/agentMemory.js' -import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js' -import { truncateToWidth } from '../../../../utils/format.js' -import { getAgentModelDisplay } from '../../../../utils/model/agent.js' -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' -import { useWizard } from '../../../wizard/index.js' -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' -import { getNewRelativeAgentFilePath } from '../../agentFileUtils.js' -import { validateAgent } from '../../validateAgent.js' -import type { AgentWizardData } from '../types.js' +import React, { type ReactNode } from 'react'; +import { type KeyboardEvent, Box, Byline, KeyboardShortcutHint, Text } from '@anthropic/ink'; +import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; +import { isAutoMemoryEnabled } from '../../../../memdir/paths.js'; +import type { Tools } from '../../../../Tool.js'; +import { getMemoryScopeDisplay } from '@claude-code-best/builtin-tools/tools/AgentTool/agentMemory.js'; +import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'; +import { truncateToWidth } from '../../../../utils/format.js'; +import { getAgentModelDisplay } from '../../../../utils/model/agent.js'; +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; +import { useWizard } from '../../../wizard/index.js'; +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; +import { getNewRelativeAgentFilePath } from '../../agentFileUtils.js'; +import { validateAgent } from '../../validateAgent.js'; +import type { AgentWizardData } from '../types.js'; type Props = { - tools: Tools - existingAgents: AgentDefinition[] - onSave: () => void - onSaveAndEdit: () => void - error?: string | null -} + tools: Tools; + existingAgents: AgentDefinition[]; + onSave: () => void; + onSaveAndEdit: () => void; + error?: string | null; +}; -export function ConfirmStep({ - tools, - existingAgents, - onSave, - onSaveAndEdit, - error, -}: Props): ReactNode { - const { goBack, wizardData } = useWizard() +export function ConfirmStep({ tools, existingAgents, onSave, onSaveAndEdit, error }: Props): ReactNode { + const { goBack, wizardData } = useWizard(); - useKeybinding('confirm:no', goBack, { context: 'Confirmation' }) + useKeybinding('confirm:no', goBack, { context: 'Confirmation' }); const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 's' || e.key === 'return') { - e.preventDefault() - onSave() + e.preventDefault(); + onSave(); } else if (e.key === 'e') { - e.preventDefault() - onSaveAndEdit() + e.preventDefault(); + onSaveAndEdit(); } - } + }; - const agent = wizardData.finalAgent! - const validation = validateAgent(agent, tools, existingAgents) + const agent = wizardData.finalAgent!; + const validation = validateAgent(agent, tools, existingAgents); - const systemPromptPreview = truncateToWidth(agent.getSystemPrompt(), 240) - const whenToUsePreview = truncateToWidth(agent.whenToUse, 240) + const systemPromptPreview = truncateToWidth(agent.getSystemPrompt(), 240); + const whenToUsePreview = truncateToWidth(agent.whenToUse, 240); const getToolsDisplay = (toolNames: string[] | undefined): string => { // undefined means "all tools" per PR semantic - if (toolNames === undefined) return 'All tools' - if (toolNames.length === 0) return 'None' - if (toolNames.length === 1) return toolNames[0] || 'None' - if (toolNames.length === 2) return toolNames.join(' and ') - return `${toolNames.slice(0, -1).join(', ')}, and ${toolNames[toolNames.length - 1]}` - } + if (toolNames === undefined) return 'All tools'; + if (toolNames.length === 0) return 'None'; + if (toolNames.length === 1) return toolNames[0] || 'None'; + if (toolNames.length === 2) return toolNames.join(' and '); + return `${toolNames.slice(0, -1).join(', ')}, and ${toolNames[toolNames.length - 1]}`; + }; // Compute memory display outside JSX const memoryDisplayElement = isAutoMemoryEnabled() ? ( Memory: {getMemoryScopeDisplay(agent.memory)} - ) : null + ) : null; return ( - + } > - + Name: {agent.agentType} @@ -155,11 +139,10 @@ export function ConfirmStep({ - Press s or Enter to save,{' '} - e to save and edit + Press s or Enter to save, e to save and edit - ) + ); } diff --git a/src/components/agents/new-agent-creation/wizard-steps/ConfirmStepWrapper.tsx b/src/components/agents/new-agent-creation/wizard-steps/ConfirmStepWrapper.tsx index b1e391e7f..66a5c711f 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/ConfirmStepWrapper.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/ConfirmStepWrapper.tsx @@ -1,37 +1,33 @@ -import chalk from 'chalk' -import React, { type ReactNode, useCallback, useState } from 'react' +import chalk from 'chalk'; +import React, { type ReactNode, useCallback, useState } from 'react'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from 'src/services/analytics/index.js' -import { useSetAppState } from 'src/state/AppState.js' -import type { Tools } from '../../../../Tool.js' -import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js' -import { getActiveAgentsFromList } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js' -import { editFileInEditor } from '../../../../utils/promptEditor.js' -import { useWizard } from '../../../wizard/index.js' -import { getNewAgentFilePath, saveAgentToFile } from '../../agentFileUtils.js' -import type { AgentWizardData } from '../types.js' -import { ConfirmStep } from './ConfirmStep.js' +} from 'src/services/analytics/index.js'; +import { useSetAppState } from 'src/state/AppState.js'; +import type { Tools } from '../../../../Tool.js'; +import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'; +import { getActiveAgentsFromList } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'; +import { editFileInEditor } from '../../../../utils/promptEditor.js'; +import { useWizard } from '../../../wizard/index.js'; +import { getNewAgentFilePath, saveAgentToFile } from '../../agentFileUtils.js'; +import type { AgentWizardData } from '../types.js'; +import { ConfirmStep } from './ConfirmStep.js'; type Props = { - tools: Tools - existingAgents: AgentDefinition[] - onComplete: (message: string) => void -} + tools: Tools; + existingAgents: AgentDefinition[]; + onComplete: (message: string) => void; +}; -export function ConfirmStepWrapper({ - tools, - existingAgents, - onComplete, -}: Props): ReactNode { - const { wizardData } = useWizard() - const [saveError, setSaveError] = useState(null) - const setAppState = useSetAppState() +export function ConfirmStepWrapper({ tools, existingAgents, onComplete }: Props): ReactNode { + const { wizardData } = useWizard(); + const [saveError, setSaveError] = useState(null); + const setAppState = useSetAppState(); const saveAgent = useCallback( async (openInEditor: boolean): Promise => { - if (!wizardData?.finalAgent) return + if (!wizardData?.finalAgent) return; try { await saveAgentToFile( @@ -44,14 +40,12 @@ export function ConfirmStepWrapper({ wizardData.finalAgent.color, wizardData.finalAgent.model, wizardData.finalAgent.memory, - ) + ); setAppState(state => { - if (!wizardData.finalAgent) return state + if (!wizardData.finalAgent) return state; - const allAgents = state.agentDefinitions.allAgents.concat( - wizardData.finalAgent, - ) + const allAgents = state.agentDefinitions.allAgents.concat(wizardData.finalAgent); return { ...state, agentDefinitions: { @@ -59,15 +53,15 @@ export function ConfirmStepWrapper({ activeAgents: getActiveAgentsFromList(allAgents), allAgents, }, - } - }) + }; + }); if (openInEditor) { const filePath = getNewAgentFilePath({ source: wizardData.location!, agentType: wizardData.finalAgent.agentType, - }) - await editFileInEditor(filePath) + }); + await editFileInEditor(filePath); } logEvent('tengu_agent_created', { @@ -80,25 +74,23 @@ export function ConfirmStepWrapper({ has_memory: !!wizardData.finalAgent.memory, memory_scope: wizardData.finalAgent.memory ?? 'none', ...(openInEditor ? { opened_in_editor: true } : {}), - } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) + } as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS); const message = openInEditor ? `Created agent: ${chalk.bold(wizardData.finalAgent.agentType)} and opened in editor. ` + `If you made edits, restart to load the latest version.` - : `Created agent: ${chalk.bold(wizardData.finalAgent.agentType)}` - onComplete(message) + : `Created agent: ${chalk.bold(wizardData.finalAgent.agentType)}`; + onComplete(message); } catch (err) { - setSaveError( - err instanceof Error ? err.message : 'Failed to save agent', - ) + setSaveError(err instanceof Error ? err.message : 'Failed to save agent'); } }, [wizardData, onComplete, setAppState], - ) + ); - const handleSave = useCallback(() => saveAgent(false), [saveAgent]) + const handleSave = useCallback(() => saveAgent(false), [saveAgent]); - const handleSaveAndEdit = useCallback(() => saveAgent(true), [saveAgent]) + const handleSaveAndEdit = useCallback(() => saveAgent(true), [saveAgent]); return ( - ) + ); } diff --git a/src/components/agents/new-agent-creation/wizard-steps/DescriptionStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/DescriptionStep.tsx index 50a0d0590..bfb838805 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/DescriptionStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/DescriptionStep.tsx @@ -1,46 +1,45 @@ -import React, { type ReactNode, useCallback, useState } from 'react' -import { Box, Byline, KeyboardShortcutHint, Text } from '@anthropic/ink' -import { useKeybinding } from '../../../../keybindings/useKeybinding.js' -import { editPromptInEditor } from '../../../../utils/promptEditor.js' -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' -import TextInput from '../../../TextInput.js' -import { useWizard } from '../../../wizard/index.js' -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' -import type { AgentWizardData } from '../types.js' +import React, { type ReactNode, useCallback, useState } from 'react'; +import { Box, Byline, KeyboardShortcutHint, Text } from '@anthropic/ink'; +import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; +import { editPromptInEditor } from '../../../../utils/promptEditor.js'; +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; +import TextInput from '../../../TextInput.js'; +import { useWizard } from '../../../wizard/index.js'; +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; +import type { AgentWizardData } from '../types.js'; export function DescriptionStep(): ReactNode { - const { goNext, goBack, updateWizardData, wizardData } = - useWizard() - const [whenToUse, setWhenToUse] = useState(wizardData.whenToUse || '') - const [cursorOffset, setCursorOffset] = useState(whenToUse.length) - const [error, setError] = useState(null) + const { goNext, goBack, updateWizardData, wizardData } = useWizard(); + const [whenToUse, setWhenToUse] = useState(wizardData.whenToUse || ''); + const [cursorOffset, setCursorOffset] = useState(whenToUse.length); + const [error, setError] = useState(null); // Handle escape key - use Settings context so 'n' key doesn't cancel (allows typing 'n' in input) - useKeybinding('confirm:no', goBack, { context: 'Settings' }) + useKeybinding('confirm:no', goBack, { context: 'Settings' }); const handleExternalEditor = useCallback(async () => { - const result = await editPromptInEditor(whenToUse) + const result = await editPromptInEditor(whenToUse); if (result.content !== null) { - setWhenToUse(result.content) - setCursorOffset(result.content.length) + setWhenToUse(result.content); + setCursorOffset(result.content.length); } - }, [whenToUse]) + }, [whenToUse]); useKeybinding('chat:externalEditor', handleExternalEditor, { context: 'Chat', - }) + }); const handleSubmit = (value: string): void => { - const trimmedValue = value.trim() + const trimmedValue = value.trim(); if (!trimmedValue) { - setError('Description is required') - return + setError('Description is required'); + return; } - setError(null) - updateWizardData({ whenToUse: trimmedValue }) - goNext() - } + setError(null); + updateWizardData({ whenToUse: trimmedValue }); + goNext(); + }; return ( - + } > @@ -88,5 +82,5 @@ export function DescriptionStep(): ReactNode { )} - ) + ); } diff --git a/src/components/agents/new-agent-creation/wizard-steps/GenerateStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/GenerateStep.tsx index 1e0b88512..9200e239b 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/GenerateStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/GenerateStep.tsx @@ -1,56 +1,55 @@ -import { APIUserAbortError } from '@anthropic-ai/sdk' -import React, { type ReactNode, useCallback, useRef, useState } from 'react' -import { useMainLoopModel } from '../../../../hooks/useMainLoopModel.js' -import { Box, Byline, Text } from '@anthropic/ink' -import { useKeybinding } from '../../../../keybindings/useKeybinding.js' -import { createAbortController } from '../../../../utils/abortController.js' -import { editPromptInEditor } from '../../../../utils/promptEditor.js' -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' -import { Spinner } from '../../../Spinner.js' -import TextInput from '../../../TextInput.js' -import { useWizard } from '../../../wizard/index.js' -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' -import { generateAgent } from '../../generateAgent.js' -import type { AgentWizardData } from '../types.js' +import { APIUserAbortError } from '@anthropic-ai/sdk'; +import React, { type ReactNode, useCallback, useRef, useState } from 'react'; +import { useMainLoopModel } from '../../../../hooks/useMainLoopModel.js'; +import { Box, Byline, Text } from '@anthropic/ink'; +import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; +import { createAbortController } from '../../../../utils/abortController.js'; +import { editPromptInEditor } from '../../../../utils/promptEditor.js'; +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; +import { Spinner } from '../../../Spinner.js'; +import TextInput from '../../../TextInput.js'; +import { useWizard } from '../../../wizard/index.js'; +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; +import { generateAgent } from '../../generateAgent.js'; +import type { AgentWizardData } from '../types.js'; export function GenerateStep(): ReactNode { - const { updateWizardData, goBack, goToStep, wizardData } = - useWizard() - const [prompt, setPrompt] = useState(wizardData.generationPrompt || '') - const [isGenerating, setIsGenerating] = useState(false) - const [error, setError] = useState(null) - const [cursorOffset, setCursorOffset] = useState(prompt.length) - const model = useMainLoopModel() - const abortControllerRef = useRef(null) + const { updateWizardData, goBack, goToStep, wizardData } = useWizard(); + const [prompt, setPrompt] = useState(wizardData.generationPrompt || ''); + const [isGenerating, setIsGenerating] = useState(false); + const [error, setError] = useState(null); + const [cursorOffset, setCursorOffset] = useState(prompt.length); + const model = useMainLoopModel(); + const abortControllerRef = useRef(null); // Cancel generation when escape pressed during generation const handleCancelGeneration = useCallback(() => { if (abortControllerRef.current) { - abortControllerRef.current.abort() - abortControllerRef.current = null - setIsGenerating(false) - setError('Generation cancelled') + abortControllerRef.current.abort(); + abortControllerRef.current = null; + setIsGenerating(false); + setError('Generation cancelled'); } - }, []) + }, []); // Use Settings context so 'n' key doesn't cancel (allows typing 'n' in prompt input) useKeybinding('confirm:no', handleCancelGeneration, { context: 'Settings', isActive: isGenerating, - }) + }); const handleExternalEditor = useCallback(async () => { - const result = await editPromptInEditor(prompt) + const result = await editPromptInEditor(prompt); if (result.content !== null) { - setPrompt(result.content) - setCursorOffset(result.content.length) + setPrompt(result.content); + setCursorOffset(result.content.length); } - }, [prompt]) + }, [prompt]); useKeybinding('chat:externalEditor', handleExternalEditor, { context: 'Chat', isActive: !isGenerating, - }) + }); // Go back when escape pressed while not generating const handleGoBack = useCallback(() => { @@ -61,43 +60,38 @@ export function GenerateStep(): ReactNode { whenToUse: '', generatedAgent: undefined, wasGenerated: false, - }) - setPrompt('') - setError(null) - goBack() - }, [updateWizardData, goBack]) + }); + setPrompt(''); + setError(null); + goBack(); + }, [updateWizardData, goBack]); // Use Settings context so 'n' key doesn't cancel (allows typing 'n' in prompt input) useKeybinding('confirm:no', handleGoBack, { context: 'Settings', isActive: !isGenerating, - }) + }); const handleGenerate = async (): Promise => { - const trimmedPrompt = prompt.trim() + const trimmedPrompt = prompt.trim(); if (!trimmedPrompt) { - setError('Please describe what the agent should do') - return + setError('Please describe what the agent should do'); + return; } - setError(null) - setIsGenerating(true) + setError(null); + setIsGenerating(true); updateWizardData({ generationPrompt: trimmedPrompt, isGenerating: true, - }) + }); // Create abort controller for this generation - const controller = createAbortController() - abortControllerRef.current = controller + const controller = createAbortController(); + abortControllerRef.current = controller; try { - const generated = await generateAgent( - trimmedPrompt, - model, - [], - controller.signal, - ) + const generated = await generateAgent(trimmedPrompt, model, [], controller.signal); updateWizardData({ agentType: generated.identifier, @@ -106,41 +100,32 @@ export function GenerateStep(): ReactNode { generatedAgent: generated, isGenerating: false, wasGenerated: true, - }) + }); // Skip directly to ToolsStep (index 6) - matching original flow - goToStep(6) + goToStep(6); } catch (err) { // Don't show error if it was cancelled (already set in escape handler) if (err instanceof APIUserAbortError) { // User cancelled - no error to show - } else if ( - err instanceof Error && - !err.message.includes('No assistant message found') - ) { - setError(err.message || 'Failed to generate agent') + } else if (err instanceof Error && !err.message.includes('No assistant message found')) { + setError(err.message || 'Failed to generate agent'); } - updateWizardData({ isGenerating: false }) + updateWizardData({ isGenerating: false }); } finally { - setIsGenerating(false) - abortControllerRef.current = null + setIsGenerating(false); + abortControllerRef.current = null; } - } + }; - const subtitle = - 'Describe what this agent should do and when it should be used (be comprehensive for best results)' + const subtitle = 'Describe what this agent should do and when it should be used (be comprehensive for best results)'; if (isGenerating) { return ( + } > @@ -148,7 +133,7 @@ export function GenerateStep(): ReactNode { Generating agent from description... - ) + ); } return ( @@ -156,24 +141,14 @@ export function GenerateStep(): ReactNode { subtitle={subtitle} footerText={ - + - + } > @@ -196,5 +171,5 @@ export function GenerateStep(): ReactNode { /> - ) + ); } diff --git a/src/components/agents/new-agent-creation/wizard-steps/LocationStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/LocationStep.tsx index d846cb39b..88a455586 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/LocationStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/LocationStep.tsx @@ -1,14 +1,14 @@ -import React, { type ReactNode } from 'react' -import { Box, Byline, KeyboardShortcutHint } from '@anthropic/ink' -import type { SettingSource } from '../../../../utils/settings/constants.js' -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' -import { Select } from '../../../CustomSelect/select.js' -import { useWizard } from '../../../wizard/index.js' -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' -import type { AgentWizardData } from '../types.js' +import React, { type ReactNode } from 'react'; +import { Box, Byline, KeyboardShortcutHint } from '@anthropic/ink'; +import type { SettingSource } from '../../../../utils/settings/constants.js'; +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; +import { Select } from '../../../CustomSelect/select.js'; +import { useWizard } from '../../../wizard/index.js'; +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; +import type { AgentWizardData } from '../types.js'; export function LocationStep(): ReactNode { - const { goNext, updateWizardData, cancel } = useWizard() + const { goNext, updateWizardData, cancel } = useWizard(); const locationOptions = [ { @@ -19,7 +19,7 @@ export function LocationStep(): ReactNode { label: 'Personal (~/.claude/agents/)', value: 'userSettings' as SettingSource, }, - ] + ]; return ( - + } > @@ -42,12 +37,12 @@ export function LocationStep(): ReactNode { key="location-select" options={locationOptions} onChange={(value: string) => { - updateWizardData({ location: value as SettingSource }) - goNext() + updateWizardData({ location: value as SettingSource }); + goNext(); }} onCancel={() => cancel()} /> - ) + ); } diff --git a/src/components/agents/new-agent-creation/wizard-steps/MemoryStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/MemoryStep.tsx index 317e304d0..4c0aaa678 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/MemoryStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/MemoryStep.tsx @@ -1,29 +1,28 @@ -import React, { type ReactNode } from 'react' -import { Box, Byline, KeyboardShortcutHint } from '@anthropic/ink' -import { useKeybinding } from '../../../../keybindings/useKeybinding.js' -import { isAutoMemoryEnabled } from '../../../../memdir/paths.js' +import React, { type ReactNode } from 'react'; +import { Box, Byline, KeyboardShortcutHint } from '@anthropic/ink'; +import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; +import { isAutoMemoryEnabled } from '../../../../memdir/paths.js'; import { type AgentMemoryScope, loadAgentMemoryPrompt, -} from '@claude-code-best/builtin-tools/tools/AgentTool/agentMemory.js' -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' -import { Select } from '../../../CustomSelect/select.js' -import { useWizard } from '../../../wizard/index.js' -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' -import type { AgentWizardData } from '../types.js' +} from '@claude-code-best/builtin-tools/tools/AgentTool/agentMemory.js'; +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; +import { Select } from '../../../CustomSelect/select.js'; +import { useWizard } from '../../../wizard/index.js'; +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; +import type { AgentWizardData } from '../types.js'; type MemoryOption = { - label: string - value: AgentMemoryScope | 'none' -} + label: string; + value: AgentMemoryScope | 'none'; +}; export function MemoryStep(): ReactNode { - const { goNext, goBack, updateWizardData, wizardData } = - useWizard() + const { goNext, goBack, updateWizardData, wizardData } = useWizard(); - useKeybinding('confirm:no', goBack, { context: 'Confirmation' }) + useKeybinding('confirm:no', goBack, { context: 'Confirmation' }); - const isUserScope = wizardData.location === 'userSettings' + const isUserScope = wizardData.location === 'userSettings'; // Build options with the recommended default first, then alternatives // The recommended scope matches the agent's location (project agent → project memory, user agent → user memory) @@ -45,11 +44,11 @@ export function MemoryStep(): ReactNode { { label: 'None (no persistent memory)', value: 'none' }, { label: 'User scope (~/.claude/agent-memory/)', value: 'user' }, { label: 'Local scope (.claude/agent-memory-local/)', value: 'local' }, - ] + ]; const handleSelect = (value: string): void => { - const memory = value === 'none' ? undefined : (value as AgentMemoryScope) - const agentType = wizardData.finalAgent?.agentType + const memory = value === 'none' ? undefined : (value as AgentMemoryScope); + const agentType = wizardData.finalAgent?.agentType; updateWizardData({ selectedMemory: memory, // Update finalAgent with memory and rewire getSystemPrompt to include memory loading. @@ -60,16 +59,13 @@ export function MemoryStep(): ReactNode { memory, getSystemPrompt: isAutoMemoryEnabled() && memory && agentType - ? () => - wizardData.systemPrompt! + - '\n\n' + - loadAgentMemoryPrompt(agentType, memory) + ? () => wizardData.systemPrompt! + '\n\n' + loadAgentMemoryPrompt(agentType, memory) : () => wizardData.systemPrompt!, } : undefined, - }) - goNext() - } + }); + goNext(); + }; return ( - + } > - - ) + ); } diff --git a/src/components/agents/new-agent-creation/wizard-steps/MethodStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/MethodStep.tsx index 552c20d1c..9a1277872 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/MethodStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/MethodStep.tsx @@ -1,14 +1,13 @@ -import React, { type ReactNode } from 'react' -import { Box, Byline, KeyboardShortcutHint } from '@anthropic/ink' -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' -import { Select } from '../../../CustomSelect/select.js' -import { useWizard } from '../../../wizard/index.js' -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' -import type { AgentWizardData } from '../types.js' +import React, { type ReactNode } from 'react'; +import { Box, Byline, KeyboardShortcutHint } from '@anthropic/ink'; +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; +import { Select } from '../../../CustomSelect/select.js'; +import { useWizard } from '../../../wizard/index.js'; +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; +import type { AgentWizardData } from '../types.js'; export function MethodStep(): ReactNode { - const { goNext, goBack, updateWizardData, goToStep } = - useWizard() + const { goNext, goBack, updateWizardData, goToStep } = useWizard(); const methodOptions = [ { @@ -19,7 +18,7 @@ export function MethodStep(): ReactNode { label: 'Manual configuration', value: 'manual', }, - ] + ]; return ( - + } > @@ -42,22 +36,22 @@ export function MethodStep(): ReactNode { key="method-select" options={methodOptions} onChange={(value: string) => { - const method = value as 'generate' | 'manual' + const method = value as 'generate' | 'manual'; updateWizardData({ method, wasGenerated: method === 'generate', - }) + }); // Dynamic navigation based on method if (method === 'generate') { - goNext() // Go to GenerateStep (index 2) + goNext(); // Go to GenerateStep (index 2) } else { - goToStep(3) // Skip to TypeStep (index 3) + goToStep(3); // Skip to TypeStep (index 3) } }} onCancel={() => goBack()} /> - ) + ); } diff --git a/src/components/agents/new-agent-creation/wizard-steps/ModelStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/ModelStep.tsx index a92ecde4a..f68028705 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/ModelStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/ModelStep.tsx @@ -1,19 +1,18 @@ -import React, { type ReactNode } from 'react' -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' -import { Byline, KeyboardShortcutHint } from '@anthropic/ink' -import { useWizard } from '../../../wizard/index.js' -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' -import { ModelSelector } from '../../ModelSelector.js' -import type { AgentWizardData } from '../types.js' +import React, { type ReactNode } from 'react'; +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; +import { Byline, KeyboardShortcutHint } from '@anthropic/ink'; +import { useWizard } from '../../../wizard/index.js'; +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; +import { ModelSelector } from '../../ModelSelector.js'; +import type { AgentWizardData } from '../types.js'; export function ModelStep(): ReactNode { - const { goNext, goBack, updateWizardData, wizardData } = - useWizard() + const { goNext, goBack, updateWizardData, wizardData } = useWizard(); const handleComplete = (model?: string): void => { - updateWizardData({ selectedModel: model }) - goNext() - } + updateWizardData({ selectedModel: model }); + goNext(); + }; return ( - + } > - + - ) + ); } diff --git a/src/components/agents/new-agent-creation/wizard-steps/PromptStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/PromptStep.tsx index c1e1f5260..562e75c6f 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/PromptStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/PromptStep.tsx @@ -1,48 +1,45 @@ -import React, { type ReactNode, useCallback, useState } from 'react' -import { Box, Byline, KeyboardShortcutHint, Text } from '@anthropic/ink' -import { useKeybinding } from '../../../../keybindings/useKeybinding.js' -import { editPromptInEditor } from '../../../../utils/promptEditor.js' -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' -import TextInput from '../../../TextInput.js' -import { useWizard } from '../../../wizard/index.js' -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' -import type { AgentWizardData } from '../types.js' +import React, { type ReactNode, useCallback, useState } from 'react'; +import { Box, Byline, KeyboardShortcutHint, Text } from '@anthropic/ink'; +import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; +import { editPromptInEditor } from '../../../../utils/promptEditor.js'; +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; +import TextInput from '../../../TextInput.js'; +import { useWizard } from '../../../wizard/index.js'; +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; +import type { AgentWizardData } from '../types.js'; export function PromptStep(): ReactNode { - const { goNext, goBack, updateWizardData, wizardData } = - useWizard() - const [systemPrompt, setSystemPrompt] = useState( - wizardData.systemPrompt || '', - ) - const [cursorOffset, setCursorOffset] = useState(systemPrompt.length) - const [error, setError] = useState(null) + const { goNext, goBack, updateWizardData, wizardData } = useWizard(); + const [systemPrompt, setSystemPrompt] = useState(wizardData.systemPrompt || ''); + const [cursorOffset, setCursorOffset] = useState(systemPrompt.length); + const [error, setError] = useState(null); // Handle escape key - use Settings context so 'n' key doesn't cancel (allows typing 'n' in input) - useKeybinding('confirm:no', goBack, { context: 'Settings' }) + useKeybinding('confirm:no', goBack, { context: 'Settings' }); const handleExternalEditor = useCallback(async () => { - const result = await editPromptInEditor(systemPrompt) + const result = await editPromptInEditor(systemPrompt); if (result.content !== null) { - setSystemPrompt(result.content) - setCursorOffset(result.content.length) + setSystemPrompt(result.content); + setCursorOffset(result.content.length); } - }, [systemPrompt]) + }, [systemPrompt]); useKeybinding('chat:externalEditor', handleExternalEditor, { context: 'Chat', - }) + }); const handleSubmit = (): void => { - const trimmedPrompt = systemPrompt.trim() + const trimmedPrompt = systemPrompt.trim(); if (!trimmedPrompt) { - setError('System prompt is required') - return + setError('System prompt is required'); + return; } - setError(null) - updateWizardData({ systemPrompt: trimmedPrompt }) - goNext() - } + setError(null); + updateWizardData({ systemPrompt: trimmedPrompt }); + goNext(); + }; return ( - + } > @@ -91,5 +83,5 @@ export function PromptStep(): ReactNode { )} - ) + ); } diff --git a/src/components/agents/new-agent-creation/wizard-steps/ToolsStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/ToolsStep.tsx index 19a8360c4..c885d6ee0 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/ToolsStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/ToolsStep.tsx @@ -1,28 +1,27 @@ -import React, { type ReactNode } from 'react' -import type { Tools } from '../../../../Tool.js' -import { Byline, KeyboardShortcutHint } from '@anthropic/ink' -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' -import { useWizard } from '../../../wizard/index.js' -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' -import { ToolSelector } from '../../ToolSelector.js' -import type { AgentWizardData } from '../types.js' +import React, { type ReactNode } from 'react'; +import type { Tools } from '../../../../Tool.js'; +import { Byline, KeyboardShortcutHint } from '@anthropic/ink'; +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; +import { useWizard } from '../../../wizard/index.js'; +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; +import { ToolSelector } from '../../ToolSelector.js'; +import type { AgentWizardData } from '../types.js'; type Props = { - tools: Tools -} + tools: Tools; +}; export function ToolsStep({ tools }: Props): ReactNode { - const { goNext, goBack, updateWizardData, wizardData } = - useWizard() + const { goNext, goBack, updateWizardData, wizardData } = useWizard(); const handleComplete = (selectedTools: string[] | undefined): void => { - updateWizardData({ selectedTools }) - goNext() - } + updateWizardData({ selectedTools }); + goNext(); + }; // Pass through undefined to preserve "all tools" semantic // ToolSelector will expand it internally for display purposes - const initialTools = wizardData.selectedTools + const initialTools = wizardData.selectedTools; return ( - + } > - + - ) + ); } diff --git a/src/components/agents/new-agent-creation/wizard-steps/TypeStep.tsx b/src/components/agents/new-agent-creation/wizard-steps/TypeStep.tsx index 59f8fb8da..7e203e195 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/TypeStep.tsx +++ b/src/components/agents/new-agent-creation/wizard-steps/TypeStep.tsx @@ -1,42 +1,41 @@ -import React, { type ReactNode, useState } from 'react' -import { Box, Byline, KeyboardShortcutHint, Text } from '@anthropic/ink' -import { useKeybinding } from '../../../../keybindings/useKeybinding.js' -import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js' -import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js' -import TextInput from '../../../TextInput.js' -import { useWizard } from '../../../wizard/index.js' -import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js' -import { validateAgentType } from '../../validateAgent.js' -import type { AgentWizardData } from '../types.js' +import React, { type ReactNode, useState } from 'react'; +import { Box, Byline, KeyboardShortcutHint, Text } from '@anthropic/ink'; +import { useKeybinding } from '../../../../keybindings/useKeybinding.js'; +import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'; +import { ConfigurableShortcutHint } from '../../../ConfigurableShortcutHint.js'; +import TextInput from '../../../TextInput.js'; +import { useWizard } from '../../../wizard/index.js'; +import { WizardDialogLayout } from '../../../wizard/WizardDialogLayout.js'; +import { validateAgentType } from '../../validateAgent.js'; +import type { AgentWizardData } from '../types.js'; type Props = { - existingAgents: AgentDefinition[] -} + existingAgents: AgentDefinition[]; +}; export function TypeStep(_props: Props): ReactNode { - const { goNext, goBack, updateWizardData, wizardData } = - useWizard() - const [agentType, setAgentType] = useState(wizardData.agentType || '') - const [error, setError] = useState(null) - const [cursorOffset, setCursorOffset] = useState(agentType.length) + const { goNext, goBack, updateWizardData, wizardData } = useWizard(); + const [agentType, setAgentType] = useState(wizardData.agentType || ''); + const [error, setError] = useState(null); + const [cursorOffset, setCursorOffset] = useState(agentType.length); // Handle escape key - Go back to MethodStep // Use Settings context so 'n' key doesn't cancel (allows typing 'n' in input) - useKeybinding('confirm:no', goBack, { context: 'Settings' }) + useKeybinding('confirm:no', goBack, { context: 'Settings' }); const handleSubmit = (value: string): void => { - const trimmedValue = value.trim() - const validationError = validateAgentType(trimmedValue) + const trimmedValue = value.trim(); + const validationError = validateAgentType(trimmedValue); if (validationError) { - setError(validationError) - return + setError(validationError); + return; } - setError(null) - updateWizardData({ agentType: trimmedValue }) - goNext() - } + setError(null); + updateWizardData({ agentType: trimmedValue }); + goNext(); + }; return ( - + } > @@ -77,5 +71,5 @@ export function TypeStep(_props: Props): ReactNode { )} - ) + ); } diff --git a/src/components/agents/new-agent-creation/wizard-steps/src/services/analytics/index.ts b/src/components/agents/new-agent-creation/wizard-steps/src/services/analytics/index.ts index 142e7b6f5..2e0f796da 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/src/services/analytics/index.ts +++ b/src/components/agents/new-agent-creation/wizard-steps/src/services/analytics/index.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any; -export type logEvent = any; +export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any +export type logEvent = any diff --git a/src/components/agents/new-agent-creation/wizard-steps/src/state/AppState.ts b/src/components/agents/new-agent-creation/wizard-steps/src/state/AppState.ts index 93788b82c..aed7070f4 100644 --- a/src/components/agents/new-agent-creation/wizard-steps/src/state/AppState.ts +++ b/src/components/agents/new-agent-creation/wizard-steps/src/state/AppState.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useSetAppState = any; +export type useSetAppState = any diff --git a/src/components/agents/src/Tool.ts b/src/components/agents/src/Tool.ts index 03b2b7ee7..c9f919389 100644 --- a/src/components/agents/src/Tool.ts +++ b/src/components/agents/src/Tool.ts @@ -1,4 +1,4 @@ // Auto-generated type stub — replace with real implementation -export type Tool = any; -export type Tools = any; -export type getEmptyToolPermissionContext = any; +export type Tool = any +export type Tools = any +export type getEmptyToolPermissionContext = any diff --git a/src/components/agents/src/context.ts b/src/components/agents/src/context.ts index 3ce849ca0..c6b74d50b 100644 --- a/src/components/agents/src/context.ts +++ b/src/components/agents/src/context.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getUserContext = any; +export type getUserContext = any diff --git a/src/components/agents/src/services/api/claude.ts b/src/components/agents/src/services/api/claude.ts index b02c1424c..d4410ae92 100644 --- a/src/components/agents/src/services/api/claude.ts +++ b/src/components/agents/src/services/api/claude.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type queryModelWithoutStreaming = any; +export type queryModelWithoutStreaming = any diff --git a/src/components/agents/src/services/mcp/mcpStringUtils.ts b/src/components/agents/src/services/mcp/mcpStringUtils.ts index 6813ff192..cb1ed7faa 100644 --- a/src/components/agents/src/services/mcp/mcpStringUtils.ts +++ b/src/components/agents/src/services/mcp/mcpStringUtils.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type mcpInfoFromString = any; +export type mcpInfoFromString = any diff --git a/src/components/agents/src/services/mcp/utils.ts b/src/components/agents/src/services/mcp/utils.ts index 3bd1bb388..e5a1d80cd 100644 --- a/src/components/agents/src/services/mcp/utils.ts +++ b/src/components/agents/src/services/mcp/utils.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type isMcpTool = any; +export type isMcpTool = any diff --git a/src/components/agents/src/state/AppState.ts b/src/components/agents/src/state/AppState.ts index 93788b82c..aed7070f4 100644 --- a/src/components/agents/src/state/AppState.ts +++ b/src/components/agents/src/state/AppState.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useSetAppState = any; +export type useSetAppState = any diff --git a/src/components/agents/src/tools/AgentTool/agentToolUtils.ts b/src/components/agents/src/tools/AgentTool/agentToolUtils.ts index 1a95f5b3f..5f12f5850 100644 --- a/src/components/agents/src/tools/AgentTool/agentToolUtils.ts +++ b/src/components/agents/src/tools/AgentTool/agentToolUtils.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type filterToolsForAgent = any; +export type filterToolsForAgent = any diff --git a/src/components/agents/src/tools/AgentTool/constants.ts b/src/components/agents/src/tools/AgentTool/constants.ts index c5969e5de..a835a0b7e 100644 --- a/src/components/agents/src/tools/AgentTool/constants.ts +++ b/src/components/agents/src/tools/AgentTool/constants.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type AGENT_TOOL_NAME = any; +export type AGENT_TOOL_NAME = any diff --git a/src/components/agents/src/tools/BashTool/BashTool.ts b/src/components/agents/src/tools/BashTool/BashTool.ts index 7a3ea3cc5..0e57d5e17 100644 --- a/src/components/agents/src/tools/BashTool/BashTool.ts +++ b/src/components/agents/src/tools/BashTool/BashTool.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type BashTool = any; +export type BashTool = any diff --git a/src/components/agents/src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts b/src/components/agents/src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts index f9708d2b3..1e7971d36 100644 --- a/src/components/agents/src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts +++ b/src/components/agents/src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type ExitPlanModeV2Tool = any; +export type ExitPlanModeV2Tool = any diff --git a/src/components/agents/src/tools/FileEditTool/FileEditTool.ts b/src/components/agents/src/tools/FileEditTool/FileEditTool.ts index 0d3bc60eb..5493abf9c 100644 --- a/src/components/agents/src/tools/FileEditTool/FileEditTool.ts +++ b/src/components/agents/src/tools/FileEditTool/FileEditTool.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type FileEditTool = any; +export type FileEditTool = any diff --git a/src/components/agents/src/tools/FileReadTool/FileReadTool.ts b/src/components/agents/src/tools/FileReadTool/FileReadTool.ts index b9ca41b17..70baab50b 100644 --- a/src/components/agents/src/tools/FileReadTool/FileReadTool.ts +++ b/src/components/agents/src/tools/FileReadTool/FileReadTool.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type FileReadTool = any; +export type FileReadTool = any diff --git a/src/components/agents/src/tools/FileWriteTool/FileWriteTool.ts b/src/components/agents/src/tools/FileWriteTool/FileWriteTool.ts index 9417a7a94..8e268cd91 100644 --- a/src/components/agents/src/tools/FileWriteTool/FileWriteTool.ts +++ b/src/components/agents/src/tools/FileWriteTool/FileWriteTool.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type FileWriteTool = any; +export type FileWriteTool = any diff --git a/src/components/agents/src/tools/GlobTool/GlobTool.ts b/src/components/agents/src/tools/GlobTool/GlobTool.ts index a411d3dd7..72e6f2921 100644 --- a/src/components/agents/src/tools/GlobTool/GlobTool.ts +++ b/src/components/agents/src/tools/GlobTool/GlobTool.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type GlobTool = any; +export type GlobTool = any diff --git a/src/components/agents/src/tools/GrepTool/GrepTool.ts b/src/components/agents/src/tools/GrepTool/GrepTool.ts index 0ed073688..286c5fba1 100644 --- a/src/components/agents/src/tools/GrepTool/GrepTool.ts +++ b/src/components/agents/src/tools/GrepTool/GrepTool.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type GrepTool = any; +export type GrepTool = any diff --git a/src/components/agents/src/tools/ListMcpResourcesTool/ListMcpResourcesTool.ts b/src/components/agents/src/tools/ListMcpResourcesTool/ListMcpResourcesTool.ts index 17b103d50..3f8f2791c 100644 --- a/src/components/agents/src/tools/ListMcpResourcesTool/ListMcpResourcesTool.ts +++ b/src/components/agents/src/tools/ListMcpResourcesTool/ListMcpResourcesTool.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type ListMcpResourcesTool = any; +export type ListMcpResourcesTool = any diff --git a/src/components/agents/src/tools/NotebookEditTool/NotebookEditTool.ts b/src/components/agents/src/tools/NotebookEditTool/NotebookEditTool.ts index 9ebe8c2b0..8eaf1f324 100644 --- a/src/components/agents/src/tools/NotebookEditTool/NotebookEditTool.ts +++ b/src/components/agents/src/tools/NotebookEditTool/NotebookEditTool.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type NotebookEditTool = any; +export type NotebookEditTool = any diff --git a/src/components/agents/src/tools/ReadMcpResourceTool/ReadMcpResourceTool.ts b/src/components/agents/src/tools/ReadMcpResourceTool/ReadMcpResourceTool.ts index bee0f9bdd..fd8bd82ca 100644 --- a/src/components/agents/src/tools/ReadMcpResourceTool/ReadMcpResourceTool.ts +++ b/src/components/agents/src/tools/ReadMcpResourceTool/ReadMcpResourceTool.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type ReadMcpResourceTool = any; +export type ReadMcpResourceTool = any diff --git a/src/components/agents/src/tools/TaskOutputTool/TaskOutputTool.ts b/src/components/agents/src/tools/TaskOutputTool/TaskOutputTool.ts index f77e9dbfb..2d6a1a350 100644 --- a/src/components/agents/src/tools/TaskOutputTool/TaskOutputTool.ts +++ b/src/components/agents/src/tools/TaskOutputTool/TaskOutputTool.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type TaskOutputTool = any; +export type TaskOutputTool = any diff --git a/src/components/agents/src/tools/TaskStopTool/TaskStopTool.ts b/src/components/agents/src/tools/TaskStopTool/TaskStopTool.ts index 0a069d4d0..685c2f8c2 100644 --- a/src/components/agents/src/tools/TaskStopTool/TaskStopTool.ts +++ b/src/components/agents/src/tools/TaskStopTool/TaskStopTool.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type TaskStopTool = any; +export type TaskStopTool = any diff --git a/src/components/agents/src/tools/TodoWriteTool/TodoWriteTool.ts b/src/components/agents/src/tools/TodoWriteTool/TodoWriteTool.ts index ce5e439e1..6ead1f314 100644 --- a/src/components/agents/src/tools/TodoWriteTool/TodoWriteTool.ts +++ b/src/components/agents/src/tools/TodoWriteTool/TodoWriteTool.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type TodoWriteTool = any; +export type TodoWriteTool = any diff --git a/src/components/agents/src/tools/TungstenTool/TungstenTool.ts b/src/components/agents/src/tools/TungstenTool/TungstenTool.ts index e86bbfbe8..c61a7a267 100644 --- a/src/components/agents/src/tools/TungstenTool/TungstenTool.ts +++ b/src/components/agents/src/tools/TungstenTool/TungstenTool.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type TungstenTool = any; +export type TungstenTool = any diff --git a/src/components/agents/src/tools/WebFetchTool/WebFetchTool.ts b/src/components/agents/src/tools/WebFetchTool/WebFetchTool.ts index 97d4995f7..aa70bb126 100644 --- a/src/components/agents/src/tools/WebFetchTool/WebFetchTool.ts +++ b/src/components/agents/src/tools/WebFetchTool/WebFetchTool.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type WebFetchTool = any; +export type WebFetchTool = any diff --git a/src/components/agents/src/tools/WebSearchTool/WebSearchTool.ts b/src/components/agents/src/tools/WebSearchTool/WebSearchTool.ts index 606345335..c8c2f94ed 100644 --- a/src/components/agents/src/tools/WebSearchTool/WebSearchTool.ts +++ b/src/components/agents/src/tools/WebSearchTool/WebSearchTool.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type WebSearchTool = any; +export type WebSearchTool = any diff --git a/src/components/agents/src/utils/api.ts b/src/components/agents/src/utils/api.ts index d5a1bbc37..4b86d59b0 100644 --- a/src/components/agents/src/utils/api.ts +++ b/src/components/agents/src/utils/api.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type prependUserContext = any; +export type prependUserContext = any diff --git a/src/components/agents/src/utils/messages.ts b/src/components/agents/src/utils/messages.ts index 4c11a77c9..8a50620c6 100644 --- a/src/components/agents/src/utils/messages.ts +++ b/src/components/agents/src/utils/messages.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type createUserMessage = any; -export type normalizeMessagesForAPI = any; +export type createUserMessage = any +export type normalizeMessagesForAPI = any diff --git a/src/components/agents/src/utils/model/model.ts b/src/components/agents/src/utils/model/model.ts index abf3bce15..2221a4237 100644 --- a/src/components/agents/src/utils/model/model.ts +++ b/src/components/agents/src/utils/model/model.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type ModelName = any; +export type ModelName = any diff --git a/src/components/agents/src/utils/settings/constants.ts b/src/components/agents/src/utils/settings/constants.ts index 48b2b5a37..d9bf6f5a5 100644 --- a/src/components/agents/src/utils/settings/constants.ts +++ b/src/components/agents/src/utils/settings/constants.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type SettingSource = any; -export type getSettingSourceName = any; +export type SettingSource = any +export type getSettingSourceName = any diff --git a/src/components/agents/src/utils/settings/managedPath.ts b/src/components/agents/src/utils/settings/managedPath.ts index 1214f262c..f987221c1 100644 --- a/src/components/agents/src/utils/settings/managedPath.ts +++ b/src/components/agents/src/utils/settings/managedPath.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getManagedFilePath = any; +export type getManagedFilePath = any diff --git a/src/components/design-system/Byline.tsx b/src/components/design-system/Byline.tsx index eefab18cf..8e79e66b4 100644 --- a/src/components/design-system/Byline.tsx +++ b/src/components/design-system/Byline.tsx @@ -1 +1 @@ -export { Byline } from '@anthropic/ink' +export { Byline } from '@anthropic/ink'; diff --git a/src/components/design-system/Dialog.tsx b/src/components/design-system/Dialog.tsx index bb531183c..ff05ef396 100644 --- a/src/components/design-system/Dialog.tsx +++ b/src/components/design-system/Dialog.tsx @@ -1 +1 @@ -export { Dialog } from '@anthropic/ink' +export { Dialog } from '@anthropic/ink'; diff --git a/src/components/design-system/Divider.tsx b/src/components/design-system/Divider.tsx index a5b7f0e88..3ec1c0cf0 100644 --- a/src/components/design-system/Divider.tsx +++ b/src/components/design-system/Divider.tsx @@ -1 +1 @@ -export { Divider } from '@anthropic/ink' +export { Divider } from '@anthropic/ink'; diff --git a/src/components/design-system/FuzzyPicker.tsx b/src/components/design-system/FuzzyPicker.tsx index c034335e9..3ca71160c 100644 --- a/src/components/design-system/FuzzyPicker.tsx +++ b/src/components/design-system/FuzzyPicker.tsx @@ -1 +1 @@ -export { FuzzyPicker } from '@anthropic/ink' +export { FuzzyPicker } from '@anthropic/ink'; diff --git a/src/components/design-system/KeyboardShortcutHint.tsx b/src/components/design-system/KeyboardShortcutHint.tsx index ad61f636f..77955b0cb 100644 --- a/src/components/design-system/KeyboardShortcutHint.tsx +++ b/src/components/design-system/KeyboardShortcutHint.tsx @@ -1 +1 @@ -export { KeyboardShortcutHint } from '@anthropic/ink' +export { KeyboardShortcutHint } from '@anthropic/ink'; diff --git a/src/components/design-system/ListItem.tsx b/src/components/design-system/ListItem.tsx index f309c9975..ff051fbb9 100644 --- a/src/components/design-system/ListItem.tsx +++ b/src/components/design-system/ListItem.tsx @@ -1 +1 @@ -export { ListItem } from '@anthropic/ink' +export { ListItem } from '@anthropic/ink'; diff --git a/src/components/design-system/LoadingState.tsx b/src/components/design-system/LoadingState.tsx index 71dbdaa40..c382e7964 100644 --- a/src/components/design-system/LoadingState.tsx +++ b/src/components/design-system/LoadingState.tsx @@ -1 +1 @@ -export { LoadingState } from '@anthropic/ink' +export { LoadingState } from '@anthropic/ink'; diff --git a/src/components/design-system/Pane.tsx b/src/components/design-system/Pane.tsx index 12eb3ed55..3160b1138 100644 --- a/src/components/design-system/Pane.tsx +++ b/src/components/design-system/Pane.tsx @@ -1 +1 @@ -export { Pane } from '@anthropic/ink' +export { Pane } from '@anthropic/ink'; diff --git a/src/components/design-system/ProgressBar.tsx b/src/components/design-system/ProgressBar.tsx index 6aacc6129..31f9a6e5f 100644 --- a/src/components/design-system/ProgressBar.tsx +++ b/src/components/design-system/ProgressBar.tsx @@ -1 +1 @@ -export { ProgressBar } from '@anthropic/ink' +export { ProgressBar } from '@anthropic/ink'; diff --git a/src/components/design-system/Ratchet.tsx b/src/components/design-system/Ratchet.tsx index 5aaab3289..1283ac9e1 100644 --- a/src/components/design-system/Ratchet.tsx +++ b/src/components/design-system/Ratchet.tsx @@ -1 +1 @@ -export { Ratchet } from '@anthropic/ink' +export { Ratchet } from '@anthropic/ink'; diff --git a/src/components/design-system/StatusIcon.tsx b/src/components/design-system/StatusIcon.tsx index 962ea1ba6..05e75b564 100644 --- a/src/components/design-system/StatusIcon.tsx +++ b/src/components/design-system/StatusIcon.tsx @@ -1 +1 @@ -export { StatusIcon } from '@anthropic/ink' +export { StatusIcon } from '@anthropic/ink'; diff --git a/src/components/design-system/Tabs.tsx b/src/components/design-system/Tabs.tsx index e11de0a6b..01433ba1d 100644 --- a/src/components/design-system/Tabs.tsx +++ b/src/components/design-system/Tabs.tsx @@ -1 +1 @@ -export { Tab, Tabs, useTabHeaderFocus, useTabsWidth } from '@anthropic/ink' +export { Tab, Tabs, useTabHeaderFocus, useTabsWidth } from '@anthropic/ink'; diff --git a/src/components/design-system/ThemeProvider.tsx b/src/components/design-system/ThemeProvider.tsx index 9d9bc9f66..db5b2a52f 100644 --- a/src/components/design-system/ThemeProvider.tsx +++ b/src/components/design-system/ThemeProvider.tsx @@ -1 +1 @@ -export { ThemeProvider, usePreviewTheme, useTheme, useThemeSetting } from '@anthropic/ink' +export { ThemeProvider, usePreviewTheme, useTheme, useThemeSetting } from '@anthropic/ink'; diff --git a/src/components/design-system/ThemedBox.tsx b/src/components/design-system/ThemedBox.tsx index 30059f318..f40832612 100644 --- a/src/components/design-system/ThemedBox.tsx +++ b/src/components/design-system/ThemedBox.tsx @@ -1 +1 @@ -export { Box as default } from '@anthropic/ink' +export { Box as default } from '@anthropic/ink'; diff --git a/src/components/design-system/ThemedText.tsx b/src/components/design-system/ThemedText.tsx index 792f0da4b..4d2204f1c 100644 --- a/src/components/design-system/ThemedText.tsx +++ b/src/components/design-system/ThemedText.tsx @@ -1 +1 @@ -export { Text as default, TextHoverColorContext } from '@anthropic/ink' +export { Text as default, TextHoverColorContext } from '@anthropic/ink'; diff --git a/src/components/diff/DiffDetailView.tsx b/src/components/diff/DiffDetailView.tsx index 43fb442fc..5e8e6ce66 100644 --- a/src/components/diff/DiffDetailView.tsx +++ b/src/components/diff/DiffDetailView.tsx @@ -1,21 +1,21 @@ -import type { StructuredPatchHunk } from 'diff' -import { resolve } from 'path' -import React, { useMemo } from 'react' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' -import { Box, Text } from '@anthropic/ink' -import { getCwd } from '../../utils/cwd.js' -import { readFileSafe } from '../../utils/file.js' -import { Divider } from '@anthropic/ink' -import { StructuredDiff } from '../StructuredDiff.js' +import type { StructuredPatchHunk } from 'diff'; +import { resolve } from 'path'; +import React, { useMemo } from 'react'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import { Box, Text } from '@anthropic/ink'; +import { getCwd } from '../../utils/cwd.js'; +import { readFileSafe } from '../../utils/file.js'; +import { Divider } from '@anthropic/ink'; +import { StructuredDiff } from '../StructuredDiff.js'; type Props = { - filePath: string - hunks: StructuredPatchHunk[] - isLargeFile?: boolean - isBinary?: boolean - isTruncated?: boolean - isUntracked?: boolean -} + filePath: string; + hunks: StructuredPatchHunk[]; + isLargeFile?: boolean; + isBinary?: boolean; + isTruncated?: boolean; + isUntracked?: boolean; +}; /** * Displays the diff content for a single file. @@ -30,21 +30,21 @@ export function DiffDetailView({ isTruncated, isUntracked, }: Props): React.ReactNode { - const { columns } = useTerminalSize() + const { columns } = useTerminalSize(); // Read file content for syntax detection and multiline construct handling. // Only computed when this component is rendered (detail view mode). const { firstLine, fileContent } = useMemo(() => { if (!filePath) { - return { firstLine: null, fileContent: undefined } + return { firstLine: null, fileContent: undefined }; } - const fullPath = resolve(getCwd(), filePath) - const content = readFileSafe(fullPath) + const fullPath = resolve(getCwd(), filePath); + const content = readFileSafe(fullPath); return { firstLine: content?.split('\n')[0] ?? null, fileContent: content ?? undefined, - } - }, [filePath]) + }; + }, [filePath]); // Handle untracked files if (isUntracked) { @@ -64,7 +64,7 @@ export function DiffDetailView({ - ) + ); } // Handle binary files @@ -81,7 +81,7 @@ export function DiffDetailView({ - ) + ); } // Handle large files @@ -98,11 +98,11 @@ export function DiffDetailView({ - ) + ); } - const outerPaddingX = 1 - const outerBorderWidth = 1 + const outerPaddingX = 1; + const outerBorderWidth = 1; return ( @@ -136,5 +136,5 @@ export function DiffDetailView({ )} - ) + ); } diff --git a/src/components/diff/DiffDialog.tsx b/src/components/diff/DiffDialog.tsx index 3f6757b66..a0608bba5 100644 --- a/src/components/diff/DiffDialog.tsx +++ b/src/components/diff/DiffDialog.tsx @@ -1,29 +1,26 @@ -import type { StructuredPatchHunk } from 'diff' -import React, { useEffect, useMemo, useRef, useState } from 'react' -import type { CommandResultDisplay } from '../../commands.js' -import { useRegisterOverlay } from '../../context/overlayContext.js' -import { type DiffData, useDiffData } from '../../hooks/useDiffData.js' -import { type TurnDiff, useTurnDiffs } from '../../hooks/useTurnDiffs.js' -import { Box, Text } from '@anthropic/ink' -import { useKeybindings } from '../../keybindings/useKeybinding.js' -import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js' -import type { Message } from '../../types/message.js' -import { plural } from '../../utils/stringUtils.js' -import { Byline, Dialog } from '@anthropic/ink' -import { DiffDetailView } from './DiffDetailView.js' -import { DiffFileList } from './DiffFileList.js' +import type { StructuredPatchHunk } from 'diff'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import type { CommandResultDisplay } from '../../commands.js'; +import { useRegisterOverlay } from '../../context/overlayContext.js'; +import { type DiffData, useDiffData } from '../../hooks/useDiffData.js'; +import { type TurnDiff, useTurnDiffs } from '../../hooks/useTurnDiffs.js'; +import { Box, Text } from '@anthropic/ink'; +import { useKeybindings } from '../../keybindings/useKeybinding.js'; +import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js'; +import type { Message } from '../../types/message.js'; +import { plural } from '../../utils/stringUtils.js'; +import { Byline, Dialog } from '@anthropic/ink'; +import { DiffDetailView } from './DiffDetailView.js'; +import { DiffFileList } from './DiffFileList.js'; type Props = { - messages: Message[] - onDone: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void -} + messages: Message[]; + onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void; +}; -type ViewMode = 'list' | 'detail' +type ViewMode = 'list' | 'detail'; -type DiffSource = { type: 'current' } | { type: 'turn'; turn: TurnDiff } +type DiffSource = { type: 'current' } | { type: 'turn'; turn: TurnDiff }; function turnDiffToDiffData(turn: TurnDiff): DiffData { const files = Array.from(turn.files.values()) @@ -36,11 +33,11 @@ function turnDiffToDiffData(turn: TurnDiff): DiffData { isTruncated: false, isNewFile: f.isNewFile, })) - .sort((a, b) => a.path.localeCompare(b.path)) + .sort((a, b) => a.path.localeCompare(b.path)); - const hunks = new Map() + const hunks = new Map(); for (const f of turn.files.values()) { - hunks.set(f.filePath, f.hunks) + hunks.set(f.filePath, f.hunks); } return { @@ -52,56 +49,53 @@ function turnDiffToDiffData(turn: TurnDiff): DiffData { files, hunks, loading: false, - } + }; } export function DiffDialog({ messages, onDone }: Props): React.ReactNode { - const gitDiffData = useDiffData() - const turnDiffs = useTurnDiffs(messages) + const gitDiffData = useDiffData(); + const turnDiffs = useTurnDiffs(messages); - const [viewMode, setViewMode] = useState('list') - const [selectedIndex, setSelectedIndex] = useState(0) - const [sourceIndex, setSourceIndex] = useState(0) + const [viewMode, setViewMode] = useState('list'); + const [selectedIndex, setSelectedIndex] = useState(0); + const [sourceIndex, setSourceIndex] = useState(0); const sources: DiffSource[] = useMemo( - () => [ - { type: 'current' }, - ...turnDiffs.map((turn): DiffSource => ({ type: 'turn', turn })), - ], + () => [{ type: 'current' }, ...turnDiffs.map((turn): DiffSource => ({ type: 'turn', turn }))], [turnDiffs], - ) + ); - const currentSource = sources[sourceIndex] - const currentTurn = currentSource?.type === 'turn' ? currentSource.turn : null + const currentSource = sources[sourceIndex]; + const currentTurn = currentSource?.type === 'turn' ? currentSource.turn : null; const diffData = useMemo((): DiffData => { - return currentTurn ? turnDiffToDiffData(currentTurn) : gitDiffData - }, [currentTurn, gitDiffData]) + return currentTurn ? turnDiffToDiffData(currentTurn) : gitDiffData; + }, [currentTurn, gitDiffData]); - const selectedFile = diffData.files[selectedIndex] + const selectedFile = diffData.files[selectedIndex]; const selectedHunks = useMemo(() => { - return selectedFile ? diffData.hunks.get(selectedFile.path) || [] : [] - }, [selectedFile, diffData.hunks]) + return selectedFile ? diffData.hunks.get(selectedFile.path) || [] : []; + }, [selectedFile, diffData.hunks]); // Clamp sourceIndex when sources shrink (e.g., conversation rewind) useEffect(() => { if (sourceIndex >= sources.length) { - setSourceIndex(Math.max(0, sources.length - 1)) + setSourceIndex(Math.max(0, sources.length - 1)); } - }, [sources.length, sourceIndex]) + }, [sources.length, sourceIndex]); // Reset file selection when source changes - const prevSourceIndex = useRef(sourceIndex) + const prevSourceIndex = useRef(sourceIndex); useEffect(() => { if (prevSourceIndex.current !== sourceIndex) { - setSelectedIndex(0) - prevSourceIndex.current = sourceIndex + setSelectedIndex(0); + prevSourceIndex.current = sourceIndex; } - }, [sourceIndex]) + }, [sourceIndex]); // Register as modal overlay so Chat keybindings and CancelRequestHandler // are disabled while DiffDialog is showing - useRegisterOverlay('diff-dialog') + useRegisterOverlay('diff-dialog'); // Diff dialog navigation keybindings // View-mode dependent: left/right arrows have different behavior based on mode @@ -118,64 +112,55 @@ export function DiffDialog({ messages, onDone }: Props): React.ReactNode { // Left arrow: in detail mode goes back, in list mode switches source 'diff:previousSource': () => { if (viewMode === 'detail') { - setViewMode('list') + setViewMode('list'); } else if (viewMode === 'list' && sources.length > 1) { - setSourceIndex(prev => Math.max(0, prev - 1)) + setSourceIndex(prev => Math.max(0, prev - 1)); } }, 'diff:nextSource': () => { if (viewMode === 'list' && sources.length > 1) { - setSourceIndex(prev => Math.min(sources.length - 1, prev + 1)) + setSourceIndex(prev => Math.min(sources.length - 1, prev + 1)); } }, 'diff:back': () => { if (viewMode === 'detail') { - setViewMode('list') + setViewMode('list'); } }, 'diff:viewDetails': () => { if (viewMode === 'list' && selectedFile) { - setViewMode('detail') + setViewMode('detail'); } }, 'diff:previousFile': () => { if (viewMode === 'list') { - setSelectedIndex(prev => Math.max(0, prev - 1)) + setSelectedIndex(prev => Math.max(0, prev - 1)); } }, 'diff:nextFile': () => { if (viewMode === 'list') { - setSelectedIndex(prev => - Math.min(diffData.files.length - 1, prev + 1), - ) + setSelectedIndex(prev => Math.min(diffData.files.length - 1, prev + 1)); } }, }, { context: 'DiffDialog' }, - ) + ); const subtitle = diffData.stats ? ( - {diffData.stats.filesCount} {plural(diffData.stats.filesCount, 'file')}{' '} - changed - {diffData.stats.linesAdded > 0 && ( - +{diffData.stats.linesAdded} - )} - {diffData.stats.linesRemoved > 0 && ( - -{diffData.stats.linesRemoved} - )} + {diffData.stats.filesCount} {plural(diffData.stats.filesCount, 'file')} changed + {diffData.stats.linesAdded > 0 && +{diffData.stats.linesAdded}} + {diffData.stats.linesRemoved > 0 && -{diffData.stats.linesRemoved}} - ) : null + ) : null; // Build header based on current source - const headerTitle = currentTurn - ? `Turn ${currentTurn.turnIndex}` - : 'Uncommitted changes' + const headerTitle = currentTurn ? `Turn ${currentTurn.turnIndex}` : 'Uncommitted changes'; const headerSubtitle = currentTurn ? currentTurn.userPromptPreview ? `"${currentTurn.userPromptPreview}"` : '' - : '(git diff HEAD)' + : '(git diff HEAD)'; // Source selector pills const sourceSelector = @@ -183,43 +168,34 @@ export function DiffDialog({ messages, onDone }: Props): React.ReactNode { {sourceIndex > 0 && } {sources.map((source, i) => { - const isSelected = i === sourceIndex - const label = - source.type === 'current' ? 'Current' : `T${source.turn.turnIndex}` + const isSelected = i === sourceIndex; + const label = source.type === 'current' ? 'Current' : `T${source.turn.turnIndex}`; return ( {i > 0 ? ' · ' : ''} {label} - ) + ); })} {sourceIndex < sources.length - 1 && } - ) : null + ) : null; - const dismissShortcut = useShortcutDisplay( - 'diff:dismiss', - 'DiffDialog', - 'esc', - ) + const dismissShortcut = useShortcutDisplay('diff:dismiss', 'DiffDialog', 'esc'); // Determine the appropriate message when no files are shown const emptyMessage = (() => { if (diffData.loading) { - return 'Loading diff…' + return 'Loading diff…'; } if (currentTurn) { - return 'No file changes in this turn' + return 'No file changes in this turn'; } // Check if we have stats but no files (too many files case) - if ( - diffData.stats && - diffData.stats.filesCount > 0 && - diffData.files.length === 0 - ) { - return 'Too many files to display details' + if (diffData.stats && diffData.stats.filesCount > 0 && diffData.files.length === 0) { + return 'Too many files to display details'; } - return 'Working tree is clean' - })() + return 'Working tree is clean'; + })(); // Build title with header subtitle inline const title = ( @@ -227,14 +203,14 @@ export function DiffDialog({ messages, onDone }: Props): React.ReactNode { {headerTitle} {headerSubtitle && {headerSubtitle}} - ) + ); // Handle cancel/dismiss - in detail mode goes back, in list mode dismisses function handleCancel(): void { if (viewMode === 'detail') { - setViewMode('list') + setViewMode('list'); } else { - onDone('Diff dialog dismissed', { display: 'system' }) + onDone('Diff dialog dismissed', { display: 'system' }); } } @@ -284,5 +260,5 @@ export function DiffDialog({ messages, onDone }: Props): React.ReactNode { )} - ) + ); } diff --git a/src/components/diff/DiffFileList.tsx b/src/components/diff/DiffFileList.tsx index ef498925b..15055a6ac 100644 --- a/src/components/diff/DiffFileList.tsx +++ b/src/components/diff/DiffFileList.tsx @@ -1,61 +1,57 @@ -import figures from 'figures' -import React, { useMemo } from 'react' -import type { DiffFile } from '../../hooks/useDiffData.js' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' -import { Box, Text } from '@anthropic/ink' -import { truncateStartToWidth } from '../../utils/format.js' -import { plural } from '../../utils/stringUtils.js' +import figures from 'figures'; +import React, { useMemo } from 'react'; +import type { DiffFile } from '../../hooks/useDiffData.js'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import { Box, Text } from '@anthropic/ink'; +import { truncateStartToWidth } from '../../utils/format.js'; +import { plural } from '../../utils/stringUtils.js'; -const MAX_VISIBLE_FILES = 5 +const MAX_VISIBLE_FILES = 5; type Props = { - files: DiffFile[] - selectedIndex: number -} + files: DiffFile[]; + selectedIndex: number; +}; export function DiffFileList({ files, selectedIndex }: Props): React.ReactNode { - const { columns } = useTerminalSize() + const { columns } = useTerminalSize(); // Calculate scroll window - must be before early return for hooks rules const { startIndex, endIndex } = useMemo(() => { if (files.length === 0 || files.length <= MAX_VISIBLE_FILES) { - return { startIndex: 0, endIndex: files.length } + return { startIndex: 0, endIndex: files.length }; } // Keep selected item roughly in the middle - let start = Math.max(0, selectedIndex - Math.floor(MAX_VISIBLE_FILES / 2)) - let end = start + MAX_VISIBLE_FILES + let start = Math.max(0, selectedIndex - Math.floor(MAX_VISIBLE_FILES / 2)); + let end = start + MAX_VISIBLE_FILES; // Adjust if we're at the end if (end > files.length) { - end = files.length - start = Math.max(0, end - MAX_VISIBLE_FILES) + end = files.length; + start = Math.max(0, end - MAX_VISIBLE_FILES); } - return { startIndex: start, endIndex: end } - }, [files.length, selectedIndex]) + return { startIndex: start, endIndex: end }; + }, [files.length, selectedIndex]); if (files.length === 0) { - return No changed files + return No changed files; } - const visibleFiles = files.slice(startIndex, endIndex) - const hasMoreAbove = startIndex > 0 - const hasMoreBelow = endIndex < files.length - const needsPagination = files.length > MAX_VISIBLE_FILES + const visibleFiles = files.slice(startIndex, endIndex); + const hasMoreAbove = startIndex > 0; + const hasMoreBelow = endIndex < files.length; + const needsPagination = files.length > MAX_VISIBLE_FILES; - const statsWidth = 16 - const pointerWidth = 3 - const maxPathWidth = Math.max(20, columns - statsWidth - pointerWidth - 4) + const statsWidth = 16; + const pointerWidth = 3; + const maxPathWidth = Math.max(20, columns - statsWidth - pointerWidth - 4); return ( {needsPagination && ( - - {hasMoreAbove - ? ` ↑ ${startIndex} more ${plural(startIndex, 'file')}` - : ' '} - + {hasMoreAbove ? ` ↑ ${startIndex} more ${plural(startIndex, 'file')}` : ' '} )} {visibleFiles.map((file, index) => ( - {hasMoreBelow - ? ` ↓ ${files.length - endIndex} more ${plural(files.length - endIndex, 'file')}` - : ' '} + {hasMoreBelow ? ` ↓ ${files.length - endIndex} more ${plural(files.length - endIndex, 'file')}` : ' '} )} - ) + ); } function FileItem({ @@ -81,57 +75,47 @@ function FileItem({ isSelected, maxPathWidth, }: { - file: DiffFile - isSelected: boolean - maxPathWidth: number + file: DiffFile; + isSelected: boolean; + maxPathWidth: number; }): React.ReactNode { - const displayPath = truncateStartToWidth(file.path, maxPathWidth) + const displayPath = truncateStartToWidth(file.path, maxPathWidth); - const pointer = isSelected ? figures.pointer + ' ' : ' ' - const line = `${pointer}${displayPath}` + const pointer = isSelected ? figures.pointer + ' ' : ' '; + const line = `${pointer}${displayPath}`; return ( - + {line} - ) + ); } -function FileStats({ - file, - isSelected, -}: { - file: DiffFile - isSelected: boolean -}): React.ReactNode { +function FileStats({ file, isSelected }: { file: DiffFile; isSelected: boolean }): React.ReactNode { if (file.isUntracked) { return ( untracked - ) + ); } if (file.isBinary) { return ( Binary file - ) + ); } if (file.isLargeFile) { return ( Large file modified - ) + ); } // Normal or truncated file - show line counts return ( @@ -149,5 +133,5 @@ function FileStats({ )} {file.isTruncated && (truncated)} - ) + ); } diff --git a/src/components/grove/Grove.tsx b/src/components/grove/Grove.tsx index 938971b99..8c8d19050 100644 --- a/src/components/grove/Grove.tsx +++ b/src/components/grove/Grove.tsx @@ -1,9 +1,9 @@ -import React, { useEffect, useState } from 'react' +import React, { useEffect, useState } from 'react'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from 'src/services/analytics/index.js' -import { Box, Link, Text, useInput } from '@anthropic/ink' +} from 'src/services/analytics/index.js'; +import { Box, Link, Text, useInput } from '@anthropic/ink'; import { type AccountSettings, calculateShouldShowGrove, @@ -12,22 +12,17 @@ import { getGroveSettings, markGroveNoticeViewed, updateGroveSettings, -} from '../../services/api/grove.js' -import { Select } from '../CustomSelect/index.js' -import { Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink' +} from '../../services/api/grove.js'; +import { Select } from '../CustomSelect/index.js'; +import { Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink'; -export type GroveDecision = - | 'accept_opt_in' - | 'accept_opt_out' - | 'defer' - | 'escape' - | 'skip_rendering' +export type GroveDecision = 'accept_opt_in' | 'accept_opt_out' | 'defer' | 'escape' | 'skip_rendering'; type Props = { - showIfAlreadyViewed: boolean - location: 'settings' | 'policy_update_modal' | 'onboarding' - onDone(decision: GroveDecision): void -} + showIfAlreadyViewed: boolean; + location: 'settings' | 'policy_update_modal' | 'onboarding'; + onDone(decision: GroveDecision): void; +}; const NEW_TERMS_ASCII = ` _____________ | \\ \\ @@ -39,15 +34,14 @@ const NEW_TERMS_ASCII = ` _____________ | ---------- | | ---------- | | | - |______________|` + |______________|`; function GracePeriodContentBody(): React.ReactNode { return ( <> - An update to our Consumer Terms and Privacy Policy will take effect on{' '} - October 8, 2025. You can accept the updated terms - today. + An update to our Consumer Terms and Privacy Policy will take effect on October 8, 2025. You + can accept the updated terms today. @@ -58,12 +52,8 @@ function GracePeriodContentBody(): React.ReactNode { · You can help improve Claude - — Allow the use of your chats and coding sessions to train and - improve Anthropic AI models. Change anytime in your Privacy - Settings ( - + — Allow the use of your chats and coding sessions to train and improve Anthropic AI models. Change anytime + in your Privacy Settings ( ). @@ -73,24 +63,19 @@ function GracePeriodContentBody(): React.ReactNode { · Updates to data retention - — To help us improve our AI models and safety protections, - we're extending data retention to 5 years. + — To help us improve our AI models and safety protections, we're extending data retention to 5 years. - Learn more ( - - ) or read the updated Consumer Terms ( - ) and Privacy - Policy () + Learn more () or read the + updated Consumer Terms () and Privacy Policy ( + ) - ) + ); } function PostGracePeriodContentBody(): React.ReactNode { @@ -104,8 +89,8 @@ function PostGracePeriodContentBody(): React.ReactNode { Help improve Claude - Allow the use of your chats and coding sessions to train and improve - Anthropic AI models. You can change this anytime in Privacy Settings + Allow the use of your chats and coding sessions to train and improve Anthropic AI models. You can change + this anytime in Privacy Settings @@ -113,122 +98,101 @@ function PostGracePeriodContentBody(): React.ReactNode { How this affects data retention - Turning ON the improve Claude setting extends data retention from 30 - days to 5 years. Turning it OFF keeps the default 30-day data - retention. Delete data anytime. + Turning ON the improve Claude setting extends data retention from 30 days to 5 years. Turning it OFF keeps + the default 30-day data retention. Delete data anytime. - Learn more ( - - ) or read the updated Consumer Terms ( - ) and Privacy - Policy () + Learn more () or read the + updated Consumer Terms () and Privacy Policy ( + ) - ) + ); } -export function GroveDialog({ - showIfAlreadyViewed, - location, - onDone, -}: Props): React.ReactNode { - const [shouldShowDialog, setShouldShowDialog] = useState(null) - const [groveConfig, setGroveConfig] = useState(null) +export function GroveDialog({ showIfAlreadyViewed, location, onDone }: Props): React.ReactNode { + const [shouldShowDialog, setShouldShowDialog] = useState(null); + const [groveConfig, setGroveConfig] = useState(null); useEffect(() => { async function checkGroveSettings() { - const [settingsResult, configResult] = await Promise.all([ - getGroveSettings(), - getGroveNoticeConfig(), - ]) + const [settingsResult, configResult] = await Promise.all([getGroveSettings(), getGroveNoticeConfig()]); // Extract config data if successful, otherwise null - const config = configResult.success ? configResult.data : null - setGroveConfig(config) + const config = configResult.success ? configResult.data : null; + setGroveConfig(config); // Determine if we should show the dialog (returns false on API failure) - const shouldShow = calculateShouldShowGrove( - settingsResult, - configResult, - showIfAlreadyViewed, - ) + const shouldShow = calculateShouldShowGrove(settingsResult, configResult, showIfAlreadyViewed); - setShouldShowDialog(shouldShow) + setShouldShowDialog(shouldShow); // If we shouldn't show the dialog, immediately call onDone if (!shouldShow) { - onDone('skip_rendering') - return + onDone('skip_rendering'); + return; } // Mark as viewed every time we show the dialog (for reminder frequency tracking) - void markGroveNoticeViewed() + void markGroveNoticeViewed(); // Log that the Grove policy dialog was shown logEvent('tengu_grove_policy_viewed', { - location: - location as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - dismissable: - config?.notice_is_grace_period as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + location: location as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + dismissable: config?.notice_is_grace_period as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); } - void checkGroveSettings() - }, [showIfAlreadyViewed, location, onDone]) + void checkGroveSettings(); + }, [showIfAlreadyViewed, location, onDone]); // Loading state if (shouldShowDialog === null) { - return null + return null; } // User has already set preferences, don't show dialog if (!shouldShowDialog) { - return null + return null; } - async function onChange( - value: 'accept_opt_in' | 'accept_opt_out' | 'defer' | 'escape', - ) { + async function onChange(value: 'accept_opt_in' | 'accept_opt_out' | 'defer' | 'escape') { switch (value) { case 'accept_opt_in': { - await updateGroveSettings(true) + await updateGroveSettings(true); logEvent('tengu_grove_policy_submitted', { state: true, dismissable: groveConfig?.notice_is_grace_period as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - break + }); + break; } case 'accept_opt_out': { - await updateGroveSettings(false) + await updateGroveSettings(false); logEvent('tengu_grove_policy_submitted', { state: false, dismissable: groveConfig?.notice_is_grace_period as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - break + }); + break; } case 'defer': logEvent('tengu_grove_policy_dismissed', { state: true, - }) - break + }); + break; case 'escape': - logEvent('tengu_grove_policy_escaped', {}) - break + logEvent('tengu_grove_policy_escaped', {}); + break; } - onDone(value) + onDone(value); } const acceptOptions = groveConfig?.domain_excluded ? [ { - label: - 'Accept terms · Help improve Claude: OFF (for emails with your domain)', + label: 'Accept terms · Help improve Claude: OFF (for emails with your domain)', value: 'accept_opt_out', }, ] @@ -241,14 +205,14 @@ export function GroveDialog({ label: 'Accept terms · Help improve Claude: OFF', value: 'accept_opt_out', }, - ] + ]; function handleCancel(): void { if (groveConfig?.notice_is_grace_period) { - void onChange('defer') - return + void onChange('defer'); + return; } - void onChange('escape') + void onChange('escape'); } return ( @@ -269,11 +233,7 @@ export function GroveDialog({ > - {groveConfig?.notice_is_grace_period ? ( - - ) : ( - - )} + {groveConfig?.notice_is_grace_period ? : } {NEW_TERMS_ASCII} @@ -290,53 +250,47 @@ export function GroveDialog({ options={[ ...acceptOptions, // Only show "Not now" if in grace period - ...(groveConfig?.notice_is_grace_period - ? [{ label: 'Not now', value: 'defer' }] - : []), + ...(groveConfig?.notice_is_grace_period ? [{ label: 'Not now', value: 'defer' }] : []), ]} - onChange={value => - onChange(value as 'accept_opt_in' | 'accept_opt_out' | 'defer') - } + onChange={value => onChange(value as 'accept_opt_in' | 'accept_opt_out' | 'defer')} onCancel={handleCancel} /> - ) + ); } type PrivacySettingsDialogProps = { - settings: AccountSettings - domainExcluded?: boolean - onDone(): void -} + settings: AccountSettings; + domainExcluded?: boolean; + onDone(): void; +}; export function PrivacySettingsDialog({ settings, domainExcluded, onDone, }: PrivacySettingsDialogProps): React.ReactNode { - const [groveEnabled, setGroveEnabled] = useState(settings.grove_enabled) + const [groveEnabled, setGroveEnabled] = useState(settings.grove_enabled); React.useEffect(() => { - logEvent('tengu_grove_privacy_settings_viewed', {}) - }, []) + logEvent('tengu_grove_privacy_settings_viewed', {}); + }, []); useInput(async (input, key) => { // Toggle the setting when enter/tab/space is pressed if (!domainExcluded && (key.tab || key.return || input === ' ')) { - const newValue = !groveEnabled - setGroveEnabled(newValue) - await updateGroveSettings(newValue) + const newValue = !groveEnabled; + setGroveEnabled(newValue); + await updateGroveSettings(newValue); } - }) + }); - let valueComponent = false + let valueComponent = false; if (domainExcluded) { - valueComponent = ( - false (for emails with your domain) - ) + valueComponent = false (for emails with your domain); } else if (groveEnabled) { - valueComponent = true + valueComponent = true; } return ( @@ -369,5 +323,5 @@ export function PrivacySettingsDialog({ {valueComponent} - ) + ); } diff --git a/src/components/grove/src/services/analytics/index.ts b/src/components/grove/src/services/analytics/index.ts index 142e7b6f5..2e0f796da 100644 --- a/src/components/grove/src/services/analytics/index.ts +++ b/src/components/grove/src/services/analytics/index.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any; -export type logEvent = any; +export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any +export type logEvent = any diff --git a/src/components/hooks/HooksConfigMenu.tsx b/src/components/hooks/HooksConfigMenu.tsx index f59ced979..27fb524db 100644 --- a/src/components/hooks/HooksConfigMenu.tsx +++ b/src/components/hooks/HooksConfigMenu.tsx @@ -10,153 +10,126 @@ * command-type hooks and duplicating the settings.json editing surface * in-menu for all four types would be a maintenance burden. */ -import * as React from 'react' -import { useCallback, useMemo, useState } from 'react' -import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' -import { useAppState, useAppStateStore } from 'src/state/AppState.js' -import type { CommandResultDisplay } from '../../commands.js' -import { useSettingsChange } from '../../hooks/useSettingsChange.js' -import { Box, Text } from '@anthropic/ink' -import { useKeybinding } from '../../keybindings/useKeybinding.js' +import * as React from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js'; +import { useAppState, useAppStateStore } from 'src/state/AppState.js'; +import type { CommandResultDisplay } from '../../commands.js'; +import { useSettingsChange } from '../../hooks/useSettingsChange.js'; +import { Box, Text } from '@anthropic/ink'; +import { useKeybinding } from '../../keybindings/useKeybinding.js'; import { getHookEventMetadata, getHooksForMatcher, getMatcherMetadata, getSortedMatchersForEvent, groupHooksByEventAndMatcher, -} from '../../utils/hooks/hooksConfigManager.js' -import type { IndividualHookConfig } from '../../utils/hooks/hooksSettings.js' -import { - getSettings_DEPRECATED, - getSettingsForSource, -} from '../../utils/settings/settings.js' -import { plural } from '../../utils/stringUtils.js' -import { Dialog } from '@anthropic/ink' -import { SelectEventMode } from './SelectEventMode.js' -import { SelectHookMode } from './SelectHookMode.js' -import { SelectMatcherMode } from './SelectMatcherMode.js' -import { ViewHookMode } from './ViewHookMode.js' +} from '../../utils/hooks/hooksConfigManager.js'; +import type { IndividualHookConfig } from '../../utils/hooks/hooksSettings.js'; +import { getSettings_DEPRECATED, getSettingsForSource } from '../../utils/settings/settings.js'; +import { plural } from '../../utils/stringUtils.js'; +import { Dialog } from '@anthropic/ink'; +import { SelectEventMode } from './SelectEventMode.js'; +import { SelectHookMode } from './SelectHookMode.js'; +import { SelectMatcherMode } from './SelectMatcherMode.js'; +import { ViewHookMode } from './ViewHookMode.js'; type Props = { - toolNames: string[] - onExit: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void -} + toolNames: string[]; + onExit: (result?: string, options?: { display?: CommandResultDisplay }) => void; +}; type ModeState = | { mode: 'select-event' } | { mode: 'select-matcher'; event: HookEvent } | { mode: 'select-hook'; event: HookEvent; matcher: string } - | { mode: 'view-hook'; event: HookEvent; hook: IndividualHookConfig } + | { mode: 'view-hook'; event: HookEvent; hook: IndividualHookConfig }; export function HooksConfigMenu({ toolNames, onExit }: Props): React.ReactNode { const [modeState, setModeState] = useState({ mode: 'select-event', - }) + }); // Cache whether hooks are disabled by policy settings. // getSettingsForSource() is expensive (file read + JSON parse + validation), // so we compute it once on mount and only re-compute when policy settings change. // Short-circuit evaluation ensures we skip the expensive check when hooks aren't disabled. const [disabledByPolicy, setDisabledByPolicy] = useState(() => { - const settings = getSettings_DEPRECATED() - const hooksDisabled = settings?.disableAllHooks === true - return ( - hooksDisabled && - getSettingsForSource('policySettings')?.disableAllHooks === true - ) - }) + const settings = getSettings_DEPRECATED(); + const hooksDisabled = settings?.disableAllHooks === true; + return hooksDisabled && getSettingsForSource('policySettings')?.disableAllHooks === true; + }); // Check if hooks are restricted to managed-only by policy const [restrictedByPolicy, setRestrictedByPolicy] = useState(() => { - return ( - getSettingsForSource('policySettings')?.allowManagedHooksOnly === true - ) - }) + return getSettingsForSource('policySettings')?.allowManagedHooksOnly === true; + }); // Update cached values when policy settings change useSettingsChange(source => { if (source === 'policySettings') { - const settings = getSettings_DEPRECATED() - const hooksDisabled = settings?.disableAllHooks === true - setDisabledByPolicy( - hooksDisabled && - getSettingsForSource('policySettings')?.disableAllHooks === true, - ) - setRestrictedByPolicy( - getSettingsForSource('policySettings')?.allowManagedHooksOnly === true, - ) + const settings = getSettings_DEPRECATED(); + const hooksDisabled = settings?.disableAllHooks === true; + setDisabledByPolicy(hooksDisabled && getSettingsForSource('policySettings')?.disableAllHooks === true); + setRestrictedByPolicy(getSettingsForSource('policySettings')?.allowManagedHooksOnly === true); } - }) + }); // Extract commonly used values from modeState for convenience - const mode = modeState.mode - const selectedEvent = 'event' in modeState ? modeState.event : 'PreToolUse' - const selectedMatcher = 'matcher' in modeState ? modeState.matcher : null + const mode = modeState.mode; + const selectedEvent = 'event' in modeState ? modeState.event : 'PreToolUse'; + const selectedMatcher = 'matcher' in modeState ? modeState.matcher : null; - const mcp = useAppState(s => s.mcp) - const appStateStore = useAppStateStore() - const combinedToolNames = useMemo( - () => [...toolNames, ...mcp.tools.map(tool => tool.name)], - [toolNames, mcp.tools], - ) + const mcp = useAppState(s => s.mcp); + const appStateStore = useAppStateStore(); + const combinedToolNames = useMemo(() => [...toolNames, ...mcp.tools.map(tool => tool.name)], [toolNames, mcp.tools]); const hooksByEventAndMatcher = useMemo( - () => - groupHooksByEventAndMatcher(appStateStore.getState(), combinedToolNames), + () => groupHooksByEventAndMatcher(appStateStore.getState(), combinedToolNames), [combinedToolNames, appStateStore], - ) + ); const sortedMatchersForSelectedEvent = useMemo( () => getSortedMatchersForEvent(hooksByEventAndMatcher, selectedEvent), [hooksByEventAndMatcher, selectedEvent], - ) + ); const hooksForSelectedMatcher = useMemo( - () => - getHooksForMatcher( - hooksByEventAndMatcher, - selectedEvent, - selectedMatcher, - ), + () => getHooksForMatcher(hooksByEventAndMatcher, selectedEvent, selectedMatcher), [hooksByEventAndMatcher, selectedEvent, selectedMatcher], - ) + ); // Handler for exiting the dialog const handleExit = useCallback(() => { - onExit('Hooks dialog dismissed', { display: 'system' }) - }, [onExit]) + onExit('Hooks dialog dismissed', { display: 'system' }); + }, [onExit]); // Escape handling for select-event mode - exit the menu useKeybinding('confirm:no', handleExit, { context: 'Confirmation', isActive: mode === 'select-event', - }) + }); // Escape handling for select-matcher mode - go to select-event useKeybinding( 'confirm:no', () => { - setModeState({ mode: 'select-event' }) + setModeState({ mode: 'select-event' }); }, { context: 'Confirmation', isActive: mode === 'select-matcher', }, - ) + ); // Escape handling for select-hook mode - go to select-matcher or select-event useKeybinding( 'confirm:no', () => { if ('event' in modeState) { - if ( - getMatcherMetadata(modeState.event, combinedToolNames) !== undefined - ) { - setModeState({ mode: 'select-matcher', event: modeState.event }) + if (getMatcherMetadata(modeState.event, combinedToolNames) !== undefined) { + setModeState({ mode: 'select-matcher', event: modeState.event }); } else { - setModeState({ mode: 'select-event' }) + setModeState({ mode: 'select-event' }); } } }, @@ -164,85 +137,73 @@ export function HooksConfigMenu({ toolNames, onExit }: Props): React.ReactNode { context: 'Confirmation', isActive: mode === 'select-hook', }, - ) + ); // Escape handling for view-hook mode - go to select-hook useKeybinding( 'confirm:no', () => { if (modeState.mode === 'view-hook') { - const { event, hook } = modeState + const { event, hook } = modeState; setModeState({ mode: 'select-hook', event, matcher: hook.matcher || '', - }) + }); } }, { context: 'Confirmation', isActive: mode === 'view-hook', }, - ) + ); - const hookEventMetadata = getHookEventMetadata(combinedToolNames) + const hookEventMetadata = getHookEventMetadata(combinedToolNames); // Check if hooks are disabled - const settings = getSettings_DEPRECATED() - const hooksDisabled = settings?.disableAllHooks === true + const settings = getSettings_DEPRECATED(); + const hooksDisabled = settings?.disableAllHooks === true; // Count hooks per event for the event-selection view, and the total. const { hooksByEvent, totalHooksCount } = useMemo(() => { - const byEvent: Partial> = {} - let total = 0 + const byEvent: Partial> = {}; + let total = 0; for (const [event, matchers] of Object.entries(hooksByEventAndMatcher)) { - const eventCount = Object.values(matchers).reduce( - (sum, hooks) => sum + hooks.length, - 0, - ) - byEvent[event as HookEvent] = eventCount - total += eventCount + const eventCount = Object.values(matchers).reduce((sum, hooks) => sum + hooks.length, 0); + byEvent[event as HookEvent] = eventCount; + total += eventCount; } - return { hooksByEvent: byEvent, totalHooksCount: total } - }, [hooksByEventAndMatcher]) + return { hooksByEvent: byEvent, totalHooksCount: total }; + }, [hooksByEventAndMatcher]); // If hooks are disabled, show an informational screen. // The menu is read-only, so we don't offer a re-enable button — // users can edit settings.json or ask Claude instead. if (hooksDisabled) { return ( - Esc to close} - > + Esc to close}> All hooks are currently disabled - {disabledByPolicy && ' by a managed settings file'}. You have{' '} - {totalHooksCount} configured{' '} - {plural(totalHooksCount, 'hook')} that{' '} - {plural(totalHooksCount, 'is', 'are')} not running. + {disabledByPolicy && ' by a managed settings file'}. You have {totalHooksCount}{' '} + configured {plural(totalHooksCount, 'hook')} that {plural(totalHooksCount, 'is', 'are')} not running. When hooks are disabled: · No hook commands will execute · StatusLine will not be displayed - - · Tool operations will proceed without hook validation - + · Tool operations will proceed without hook validation {!disabledByPolicy && ( - To re-enable hooks, remove "disableAllHooks" from - settings.json or ask Claude. + To re-enable hooks, remove "disableAllHooks" from settings.json or ask Claude. )} - ) + ); } switch (modeState.mode) { @@ -255,14 +216,14 @@ export function HooksConfigMenu({ toolNames, onExit }: Props): React.ReactNode { restrictedByPolicy={restrictedByPolicy} onSelectEvent={event => { if (getMatcherMetadata(event, combinedToolNames) !== undefined) { - setModeState({ mode: 'select-matcher', event }) + setModeState({ mode: 'select-matcher', event }); } else { - setModeState({ mode: 'select-hook', event, matcher: '' }) + setModeState({ mode: 'select-hook', event, matcher: '' }); } }} onCancel={handleExit} /> - ) + ); case 'select-matcher': return ( { - setModeState({ mode: 'select-event' }) + setModeState({ mode: 'select-event' }); }} /> - ) + ); case 'select-hook': return ( { // Go back to matcher selection or event selection - if ( - getMatcherMetadata(modeState.event, combinedToolNames) !== - undefined - ) { + if (getMatcherMetadata(modeState.event, combinedToolNames) !== undefined) { setModeState({ mode: 'select-matcher', event: modeState.event, - }) + }); } else { - setModeState({ mode: 'select-event' }) + setModeState({ mode: 'select-event' }); } }} /> - ) + ); case 'view-hook': return ( { - const { event, hook } = modeState + const { event, hook } = modeState; setModeState({ mode: 'select-hook', event, matcher: hook.matcher || '', - }) + }); }} /> - ) + ); } } diff --git a/src/components/hooks/PromptDialog.tsx b/src/components/hooks/PromptDialog.tsx index 2bca1ceb4..f8869b851 100644 --- a/src/components/hooks/PromptDialog.tsx +++ b/src/components/hooks/PromptDialog.tsx @@ -1,49 +1,41 @@ -import * as React from 'react' -import { Box, Text } from '@anthropic/ink' -import { useKeybinding } from '../../keybindings/useKeybinding.js' -import type { PromptRequest } from '../../types/hooks.js' -import { Select } from '../CustomSelect/select.js' -import { PermissionDialog } from '../permissions/PermissionDialog.js' +import * as React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { useKeybinding } from '../../keybindings/useKeybinding.js'; +import type { PromptRequest } from '../../types/hooks.js'; +import { Select } from '../CustomSelect/select.js'; +import { PermissionDialog } from '../permissions/PermissionDialog.js'; type Props = { - title: string - toolInputSummary?: string | null - request: PromptRequest - onRespond: (key: string) => void - onAbort: () => void -} + title: string; + toolInputSummary?: string | null; + request: PromptRequest; + onRespond: (key: string) => void; + onAbort: () => void; +}; -export function PromptDialog({ - title, - toolInputSummary, - request, - onRespond, - onAbort, -}: Props): React.ReactNode { - useKeybinding('app:interrupt', onAbort, { isActive: true }) +export function PromptDialog({ title, toolInputSummary, request, onRespond, onAbort }: Props): React.ReactNode { + useKeybinding('app:interrupt', onAbort, { isActive: true }); const options = request.options.map(opt => ({ label: opt.label, value: opt.key, description: opt.description, - })) + })); return ( {toolInputSummary} : undefined - } + titleRight={toolInputSummary ? {toolInputSummary} : undefined} > { - onSelectEvent(value as HookEvent) + onSelectEvent(value as HookEvent); }} onCancel={onCancel} - options={Object.entries(hookEventMetadata).map( - ([name, metadata]) => { - const count = hooksByEvent[name as HookEvent] || 0 - return { - label: - count > 0 ? ( - - {name} ({count}) - - ) : ( - name - ), - value: name, - description: metadata.summary, - } - }, - )} + options={Object.entries(hookEventMetadata).map(([name, metadata]) => { + const count = hooksByEvent[name as HookEvent] || 0; + return { + label: + count > 0 ? ( + + {name} ({count}) + + ) : ( + name + ), + value: name, + description: metadata.summary, + }; + })} /> - ) + ); } diff --git a/src/components/hooks/SelectHookMode.tsx b/src/components/hooks/SelectHookMode.tsx index b27195b14..e846dbdb9 100644 --- a/src/components/hooks/SelectHookMode.tsx +++ b/src/components/hooks/SelectHookMode.tsx @@ -5,26 +5,26 @@ * and selecting a hook shows its read-only details instead of a delete * confirmation. */ -import * as React from 'react' -import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' -import type { HookEventMetadata } from 'src/utils/hooks/hooksConfigManager.js' -import { Box, Text } from '@anthropic/ink' +import * as React from 'react'; +import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js'; +import type { HookEventMetadata } from 'src/utils/hooks/hooksConfigManager.js'; +import { Box, Text } from '@anthropic/ink'; import { getHookDisplayText, hookSourceHeaderDisplayString, type IndividualHookConfig, -} from '../../utils/hooks/hooksSettings.js' -import { Select } from '../CustomSelect/select.js' -import { Dialog } from '@anthropic/ink' +} from '../../utils/hooks/hooksSettings.js'; +import { Select } from '../CustomSelect/select.js'; +import { Dialog } from '@anthropic/ink'; type Props = { - selectedEvent: HookEvent - selectedMatcher: string | null - hooksForSelectedMatcher: IndividualHookConfig[] - hookEventMetadata: HookEventMetadata - onSelect: (hook: IndividualHookConfig) => void - onCancel: () => void -} + selectedEvent: HookEvent; + selectedMatcher: string | null; + hooksForSelectedMatcher: IndividualHookConfig[]; + hookEventMetadata: HookEventMetadata; + onSelect: (hook: IndividualHookConfig) => void; + onCancel: () => void; +}; export function SelectHookMode({ selectedEvent, @@ -37,7 +37,7 @@ export function SelectHookMode({ const title = hookEventMetadata.matcherMetadata !== undefined ? `${selectedEvent} - Matcher: ${selectedMatcher || '(all)'}` - : selectedEvent + : selectedEvent; if (hooksForSelectedMatcher.length === 0) { return ( @@ -49,20 +49,14 @@ export function SelectHookMode({ > No hooks configured for this event. - - To add hooks, edit settings.json directly or ask Claude. - + To add hooks, edit settings.json directly or ask Claude. - ) + ); } return ( - + { - const sourceText = item.sources - .map(hookSourceInlineDisplayString) - .join(', ') - const matcherLabel = item.matcher || '(all)' + const sourceText = item.sources.map(hookSourceInlineDisplayString).join(', '); + const matcherLabel = item.matcher || '(all)'; return { label: `[${sourceText}] ${matcherLabel}`, value: item.matcher, description: `${item.hookCount} ${plural(item.hookCount, 'hook')}`, - } + }; })} onChange={value => { - onSelect(value) + onSelect(value); }} onCancel={onCancel} /> - ) + ); } diff --git a/src/components/hooks/ViewHookMode.tsx b/src/components/hooks/ViewHookMode.tsx index b79c09cca..baf0765f8 100644 --- a/src/components/hooks/ViewHookMode.tsx +++ b/src/components/hooks/ViewHookMode.tsx @@ -4,31 +4,20 @@ * The /hooks menu is read-only; this view replaces the former delete-hook * confirmation screen and directs users to settings.json or Claude for edits. */ -import * as React from 'react' -import { Box, Text } from '@anthropic/ink' -import { - hookSourceDescriptionDisplayString, - type IndividualHookConfig, -} from '../../utils/hooks/hooksSettings.js' -import { Dialog } from '@anthropic/ink' +import * as React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { hookSourceDescriptionDisplayString, type IndividualHookConfig } from '../../utils/hooks/hooksSettings.js'; +import { Dialog } from '@anthropic/ink'; type Props = { - selectedHook: IndividualHookConfig - eventSupportsMatcher: boolean - onCancel: () => void -} + selectedHook: IndividualHookConfig; + eventSupportsMatcher: boolean; + onCancel: () => void; +}; -export function ViewHookMode({ - selectedHook, - eventSupportsMatcher, - onCancel, -}: Props): React.ReactNode { +export function ViewHookMode({ selectedHook, eventSupportsMatcher, onCancel }: Props): React.ReactNode { return ( - Esc to go back} - > + Esc to go back}> @@ -43,10 +32,7 @@ export function ViewHookMode({ Type: {selectedHook.config.type} - Source:{' '} - - {hookSourceDescriptionDisplayString(selectedHook.source)} - + Source: {hookSourceDescriptionDisplayString(selectedHook.source)} {selectedHook.pluginName && ( @@ -56,29 +42,19 @@ export function ViewHookMode({ {getContentFieldLabel(selectedHook.config)}: - + {getContentFieldValue(selectedHook.config)} - {'statusMessage' in selectedHook.config && - selectedHook.config.statusMessage && ( - - Status message:{' '} - {selectedHook.config.statusMessage} - - )} - - To modify or remove this hook, edit settings.json directly or ask - Claude to help. - + {'statusMessage' in selectedHook.config && selectedHook.config.statusMessage && ( + + Status message: {selectedHook.config.statusMessage} + + )} + To modify or remove this hook, edit settings.json directly or ask Claude to help. - ) + ); } /** @@ -88,13 +64,13 @@ export function ViewHookMode({ function getContentFieldLabel(config: IndividualHookConfig['config']): string { switch (config.type) { case 'command': - return 'Command' + return 'Command'; case 'prompt': - return 'Prompt' + return 'Prompt'; case 'agent': - return 'Prompt' + return 'Prompt'; case 'http': - return 'URL' + return 'URL'; } } @@ -105,12 +81,12 @@ function getContentFieldLabel(config: IndividualHookConfig['config']): string { function getContentFieldValue(config: IndividualHookConfig['config']): string { switch (config.type) { case 'command': - return config.command + return config.command; case 'prompt': - return config.prompt + return config.prompt; case 'agent': - return config.prompt + return config.prompt; case 'http': - return config.url + return config.url; } } diff --git a/src/components/hooks/src/entrypoints/agentSdkTypes.ts b/src/components/hooks/src/entrypoints/agentSdkTypes.ts index 2ad98a3ad..0d880a5cf 100644 --- a/src/components/hooks/src/entrypoints/agentSdkTypes.ts +++ b/src/components/hooks/src/entrypoints/agentSdkTypes.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type HookEvent = any; +export type HookEvent = any diff --git a/src/components/hooks/src/state/AppState.ts b/src/components/hooks/src/state/AppState.ts index cbb9746b4..9fe15ddad 100644 --- a/src/components/hooks/src/state/AppState.ts +++ b/src/components/hooks/src/state/AppState.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type useAppState = any; -export type useAppStateStore = any; +export type useAppState = any +export type useAppStateStore = any diff --git a/src/components/hooks/src/utils/hooks/hooksConfigManager.ts b/src/components/hooks/src/utils/hooks/hooksConfigManager.ts index 33a2e63cc..3cc873ba8 100644 --- a/src/components/hooks/src/utils/hooks/hooksConfigManager.ts +++ b/src/components/hooks/src/utils/hooks/hooksConfigManager.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type HookEventMetadata = any; +export type HookEventMetadata = any diff --git a/src/components/mcp/CapabilitiesSection.tsx b/src/components/mcp/CapabilitiesSection.tsx index dd0033382..cf44bca95 100644 --- a/src/components/mcp/CapabilitiesSection.tsx +++ b/src/components/mcp/CapabilitiesSection.tsx @@ -1,35 +1,33 @@ -import React from 'react' -import { Box, Text } from '@anthropic/ink' -import { Byline } from '@anthropic/ink' +import React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { Byline } from '@anthropic/ink'; type Props = { - serverToolsCount: number - serverPromptsCount: number - serverResourcesCount: number -} + serverToolsCount: number; + serverPromptsCount: number; + serverResourcesCount: number; +}; export function CapabilitiesSection({ serverToolsCount, serverPromptsCount, serverResourcesCount, }: Props): React.ReactNode { - const capabilities = [] + const capabilities = []; if (serverToolsCount > 0) { - capabilities.push('tools') + capabilities.push('tools'); } if (serverResourcesCount > 0) { - capabilities.push('resources') + capabilities.push('resources'); } if (serverPromptsCount > 0) { - capabilities.push('prompts') + capabilities.push('prompts'); } return ( Capabilities: - - {capabilities.length > 0 ? {capabilities} : 'none'} - + {capabilities.length > 0 ? {capabilities} : 'none'} - ) + ); } diff --git a/src/components/mcp/ElicitationDialog.tsx b/src/components/mcp/ElicitationDialog.tsx index c93003106..8470e494c 100644 --- a/src/components/mcp/ElicitationDialog.tsx +++ b/src/components/mcp/ElicitationDialog.tsx @@ -3,17 +3,17 @@ import type { ElicitRequestURLParams, ElicitResult, PrimitiveSchemaDefinition, -} from '@modelcontextprotocol/sdk/types.js' -import figures from 'figures' -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useRegisterOverlay } from '../../context/overlayContext.js' -import { useNotifyAfterTimeout } from '../../hooks/useNotifyAfterTimeout.js' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' +} from '@modelcontextprotocol/sdk/types.js'; +import figures from 'figures'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useRegisterOverlay } from '../../context/overlayContext.js'; +import { useNotifyAfterTimeout } from '../../hooks/useNotifyAfterTimeout.js'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw text input for elicitation form -import { Box, Text, useInput } from '@anthropic/ink' -import { useKeybinding } from '../../keybindings/useKeybinding.js' -import type { ElicitationRequestEvent } from '../../services/mcp/elicitationHandler.js' -import { openBrowser } from '../../utils/browser.js' +import { Box, Text, useInput } from '@anthropic/ink'; +import { useKeybinding } from '../../keybindings/useKeybinding.js'; +import type { ElicitationRequestEvent } from '../../services/mcp/elicitationHandler.js'; +import { openBrowser } from '../../utils/browser.js'; import { getEnumLabel, getEnumValues, @@ -24,37 +24,28 @@ import { isMultiSelectEnumSchema, validateElicitationInput, validateElicitationInputAsync, -} from '../../utils/mcp/elicitationValidation.js' -import { plural } from '../../utils/stringUtils.js' -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' -import { Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink' -import TextInput from '../TextInput.js' +} from '../../utils/mcp/elicitationValidation.js'; +import { plural } from '../../utils/stringUtils.js'; +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; +import { Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink'; +import TextInput from '../TextInput.js'; type Props = { - event: ElicitationRequestEvent - onResponse: ( - action: ElicitResult['action'], - content?: ElicitResult['content'], - ) => void + event: ElicitationRequestEvent; + onResponse: (action: ElicitResult['action'], content?: ElicitResult['content']) => void; /** Called when the phase 2 waiting state is dismissed (URL elicitations only). */ - onWaitingDismiss?: (action: 'dismiss' | 'retry' | 'cancel') => void -} + onWaitingDismiss?: (action: 'dismiss' | 'retry' | 'cancel') => void; +}; -const isTextField = (s: PrimitiveSchemaDefinition) => - ['string', 'number', 'integer'].includes(s.type) +const isTextField = (s: PrimitiveSchemaDefinition) => ['string', 'number', 'integer'].includes(s.type); -const RESOLVING_SPINNER_CHARS = - '\u280B\u2819\u2839\u2838\u283C\u2834\u2826\u2827\u2807\u280F' -const advanceSpinnerFrame = (f: number) => - (f + 1) % RESOLVING_SPINNER_CHARS.length +const RESOLVING_SPINNER_CHARS = '\u280B\u2819\u2839\u2838\u283C\u2834\u2826\u2827\u2807\u280F'; +const advanceSpinnerFrame = (f: number) => (f + 1) % RESOLVING_SPINNER_CHARS.length; /** Timer callback for enumTypeaheadRef — module-scope to avoid closure capture. */ -function resetTypeahead(ta: { - buffer: string - timer: ReturnType | undefined -}): void { - ta.buffer = '' - ta.timer = undefined +function resetTypeahead(ta: { buffer: string; timer: ReturnType | undefined }): void { + ta.buffer = ''; + ta.timer = undefined; } /** @@ -68,23 +59,20 @@ function resetTypeahead(ta: { * column alignment here (other checkbox states are width-1 glyphs). */ function ResolvingSpinner(): React.ReactNode { - const [frame, setFrame] = useState(0) + const [frame, setFrame] = useState(0); useEffect(() => { - const timer = setInterval(setFrame, 80, advanceSpinnerFrame) - return () => clearInterval(timer) - }, []) - return {RESOLVING_SPINNER_CHARS[frame]} + const timer = setInterval(setFrame, 80, advanceSpinnerFrame); + return () => clearInterval(timer); + }, []); + return {RESOLVING_SPINNER_CHARS[frame]}; } /** Format an ISO date/datetime for display, keeping the ISO value for submission. */ -function formatDateDisplay( - isoValue: string, - schema: PrimitiveSchemaDefinition, -): string { +function formatDateDisplay(isoValue: string, schema: PrimitiveSchemaDefinition): string { try { - const date = new Date(isoValue) - if (Number.isNaN(date.getTime())) return isoValue - const format = 'format' in schema ? schema.format : undefined + const date = new Date(isoValue); + if (Number.isNaN(date.getTime())) return isoValue; + const format = 'format' in schema ? schema.format : undefined; if (format === 'date-time') { return date.toLocaleDateString('en-US', { weekday: 'short', @@ -94,160 +82,122 @@ function formatDateDisplay( hour: 'numeric', minute: '2-digit', timeZoneName: 'short', - }) + }); } // date-only: parse as local date to avoid timezone shift - const parts = isoValue.split('-') + const parts = isoValue.split('-'); if (parts.length === 3) { - const local = new Date( - Number(parts[0]), - Number(parts[1]) - 1, - Number(parts[2]), - ) + const local = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2])); return local.toLocaleDateString('en-US', { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric', - }) + }); } - return isoValue + return isoValue; } catch { - return isoValue + return isoValue; } } -export function ElicitationDialog({ - event, - onResponse, - onWaitingDismiss, -}: Props): React.ReactNode { +export function ElicitationDialog({ event, onResponse, onWaitingDismiss }: Props): React.ReactNode { if (event.params.mode === 'url') { - return ( - - ) + return ; } - return + return ; } function ElicitationFormDialog({ event, onResponse, }: { - event: ElicitationRequestEvent - onResponse: Props['onResponse'] + event: ElicitationRequestEvent; + onResponse: Props['onResponse']; }): React.ReactNode { - const { serverName, signal } = event - const request = event.params as ElicitRequestFormParams - const { message, requestedSchema } = request - const hasFields = Object.keys(requestedSchema.properties).length > 0 - const [focusedButton, setFocusedButton] = useState< - 'accept' | 'decline' | null - >(hasFields ? null : 'accept') - const [formValues, setFormValues] = useState< - Record - >(() => { - const initialValues: Record = - {} + const { serverName, signal } = event; + const request = event.params as ElicitRequestFormParams; + const { message, requestedSchema } = request; + const hasFields = Object.keys(requestedSchema.properties).length > 0; + const [focusedButton, setFocusedButton] = useState<'accept' | 'decline' | null>(hasFields ? null : 'accept'); + const [formValues, setFormValues] = useState>(() => { + const initialValues: Record = {}; if (requestedSchema.properties) { - for (const [propName, propSchema] of Object.entries( - requestedSchema.properties, - )) { + for (const [propName, propSchema] of Object.entries(requestedSchema.properties)) { if (typeof propSchema === 'object' && propSchema !== null) { if (propSchema.default !== undefined) { - initialValues[propName] = propSchema.default + initialValues[propName] = propSchema.default; } } } } - return initialValues - }) + return initialValues; + }); - const [validationErrors, setValidationErrors] = useState< - Record - >(() => { - const initialErrors: Record = {} - for (const [propName, propSchema] of Object.entries( - requestedSchema.properties, - )) { + const [validationErrors, setValidationErrors] = useState>(() => { + const initialErrors: Record = {}; + for (const [propName, propSchema] of Object.entries(requestedSchema.properties)) { if (isTextField(propSchema) && propSchema?.default !== undefined) { - const validation = validateElicitationInput( - String(propSchema.default), - propSchema, - ) + const validation = validateElicitationInput(String(propSchema.default), propSchema); if (!validation.isValid && validation.error) { - initialErrors[propName] = validation.error + initialErrors[propName] = validation.error; } } } - return initialErrors - }) + return initialErrors; + }); useEffect(() => { - if (!signal) return + if (!signal) return; const handleAbort = () => { - onResponse('cancel') - } + onResponse('cancel'); + }; if (signal.aborted) { - handleAbort() - return + handleAbort(); + return; } - signal.addEventListener('abort', handleAbort) + signal.addEventListener('abort', handleAbort); return () => { - signal.removeEventListener('abort', handleAbort) - } - }, [signal, onResponse]) + signal.removeEventListener('abort', handleAbort); + }; + }, [signal, onResponse]); const schemaFields = useMemo(() => { - const requiredFields = requestedSchema.required ?? [] + const requiredFields = requestedSchema.required ?? []; return Object.entries(requestedSchema.properties).map(([name, schema]) => ({ name, schema, isRequired: requiredFields.includes(name), - })) - }, [requestedSchema]) + })); + }, [requestedSchema]); - const [currentFieldIndex, setCurrentFieldIndex] = useState< - number | undefined - >(hasFields ? 0 : undefined) + const [currentFieldIndex, setCurrentFieldIndex] = useState(hasFields ? 0 : undefined); const [textInputValue, setTextInputValue] = useState(() => { // Initialize from the first field's value if it's a text field - const firstField = schemaFields[0] + const firstField = schemaFields[0]; if (firstField && isTextField(firstField.schema)) { - const val = formValues[firstField.name] - if (val === undefined) return '' - return String(val) + const val = formValues[firstField.name]; + if (val === undefined) return ''; + return String(val); } - return '' - }) - const [textInputCursorOffset, setTextInputCursorOffset] = useState( - textInputValue.length, - ) - const [resolvingFields, setResolvingFields] = useState>( - () => new Set(), - ) + return ''; + }); + const [textInputCursorOffset, setTextInputCursorOffset] = useState(textInputValue.length); + const [resolvingFields, setResolvingFields] = useState>(() => new Set()); // Accordion state (shared by multi-select and single-select enum) - const [expandedAccordion, setExpandedAccordion] = useState< - string | undefined - >() - const [accordionOptionIndex, setAccordionOptionIndex] = useState(0) + const [expandedAccordion, setExpandedAccordion] = useState(); + const [accordionOptionIndex, setAccordionOptionIndex] = useState(0); - const dateDebounceRef = useRef | undefined>( - undefined, - ) - const resolveAbortRef = useRef>(new Map()) + const dateDebounceRef = useRef | undefined>(undefined); + const resolveAbortRef = useRef>(new Map()); const enumTypeaheadRef = useRef({ buffer: '', timer: undefined as ReturnType | undefined, - }) + }); // Clear pending debounce/typeahead timers and abort in-flight async // validations on unmount so they don't fire against an unmounted component @@ -255,103 +205,84 @@ function ElicitationFormDialog({ useEffect( () => () => { if (dateDebounceRef.current !== undefined) { - clearTimeout(dateDebounceRef.current) + clearTimeout(dateDebounceRef.current); } - const ta = enumTypeaheadRef.current + const ta = enumTypeaheadRef.current; if (ta.timer !== undefined) { - clearTimeout(ta.timer) + clearTimeout(ta.timer); } for (const controller of resolveAbortRef.current.values()) { - controller.abort() + controller.abort(); } - resolveAbortRef.current.clear() + resolveAbortRef.current.clear(); }, [], - ) + ); - const { columns, rows } = useTerminalSize() + const { columns, rows } = useTerminalSize(); - const currentField = - currentFieldIndex !== undefined - ? schemaFields[currentFieldIndex] - : undefined + const currentField = currentFieldIndex !== undefined ? schemaFields[currentFieldIndex] : undefined; const currentFieldIsText = - currentField !== undefined && - isTextField(currentField.schema) && - !isEnumSchema(currentField.schema) + currentField !== undefined && isTextField(currentField.schema) && !isEnumSchema(currentField.schema); // Text fields are always in edit mode when focused — no Enter-to-edit step. - const isEditingTextField = currentFieldIsText && !focusedButton + const isEditingTextField = currentFieldIsText && !focusedButton; - useRegisterOverlay('elicitation') - useNotifyAfterTimeout('Claude Code needs your input', 'elicitation_dialog') + useRegisterOverlay('elicitation'); + useNotifyAfterTimeout('Claude Code needs your input', 'elicitation_dialog'); // Sync textInputValue when the focused field changes const syncTextInput = useCallback( (fieldIndex: number | undefined) => { if (fieldIndex === undefined) { - setTextInputValue('') - setTextInputCursorOffset(0) - return + setTextInputValue(''); + setTextInputCursorOffset(0); + return; } - const field = schemaFields[fieldIndex] + const field = schemaFields[fieldIndex]; if (field && isTextField(field.schema) && !isEnumSchema(field.schema)) { - const val = formValues[field.name] - const text = val !== undefined ? String(val) : '' - setTextInputValue(text) - setTextInputCursorOffset(text.length) + const val = formValues[field.name]; + const text = val !== undefined ? String(val) : ''; + setTextInputValue(text); + setTextInputCursorOffset(text.length); } }, [schemaFields, formValues], - ) + ); - function validateMultiSelect( - fieldName: string, - schema: PrimitiveSchemaDefinition, - ) { - if (!isMultiSelectEnumSchema(schema)) return - const selected = (formValues[fieldName] as string[] | undefined) ?? [] - const fieldRequired = - schemaFields.find(f => f.name === fieldName)?.isRequired ?? false - const min = schema.minItems - const max = schema.maxItems + function validateMultiSelect(fieldName: string, schema: PrimitiveSchemaDefinition) { + if (!isMultiSelectEnumSchema(schema)) return; + const selected = (formValues[fieldName] as string[] | undefined) ?? []; + const fieldRequired = schemaFields.find(f => f.name === fieldName)?.isRequired ?? false; + const min = schema.minItems; + const max = schema.maxItems; // Skip minItems check when field is optional and unset - if ( - min !== undefined && - selected.length < min && - (selected.length > 0 || fieldRequired) - ) { - updateValidationError( - fieldName, - `Select at least ${min} ${plural(min, 'item')}`, - ) + if (min !== undefined && selected.length < min && (selected.length > 0 || fieldRequired)) { + updateValidationError(fieldName, `Select at least ${min} ${plural(min, 'item')}`); } else if (max !== undefined && selected.length > max) { - updateValidationError( - fieldName, - `Select at most ${max} ${plural(max, 'item')}`, - ) + updateValidationError(fieldName, `Select at most ${max} ${plural(max, 'item')}`); } else { - updateValidationError(fieldName) + updateValidationError(fieldName); } } function handleNavigation(direction: 'up' | 'down'): void { // Collapse accordion and validate on navigate away if (currentField && isMultiSelectEnumSchema(currentField.schema)) { - validateMultiSelect(currentField.name, currentField.schema) - setExpandedAccordion(undefined) + validateMultiSelect(currentField.name, currentField.schema); + setExpandedAccordion(undefined); } else if (currentField && isEnumSchema(currentField.schema)) { - setExpandedAccordion(undefined) + setExpandedAccordion(undefined); } // Commit current text field before navigating away if (isEditingTextField && currentField) { - commitTextField(currentField.name, currentField.schema, textInputValue) + commitTextField(currentField.name, currentField.schema, textInputValue); // Cancel any pending debounce — we're resolving now on navigate-away if (dateDebounceRef.current !== undefined) { - clearTimeout(dateDebounceRef.current) - dateDebounceRef.current = undefined + clearTimeout(dateDebounceRef.current); + dateDebounceRef.current = undefined; } // For date/datetime fields that failed sync validation, try async NL parsing @@ -360,196 +291,160 @@ function ElicitationFormDialog({ textInputValue.trim() !== '' && validationErrors[currentField.name] ) { - resolveFieldAsync( - currentField.name, - currentField.schema, - textInputValue, - ) + resolveFieldAsync(currentField.name, currentField.schema, textInputValue); } } // Fields + accept + decline - const itemCount = schemaFields.length + 2 + const itemCount = schemaFields.length + 2; const index = currentFieldIndex ?? (focusedButton === 'accept' ? schemaFields.length : focusedButton === 'decline' ? schemaFields.length + 1 - : undefined) - const nextIndex = - index !== undefined - ? (index + (direction === 'up' ? itemCount - 1 : 1)) % itemCount - : 0 + : undefined); + const nextIndex = index !== undefined ? (index + (direction === 'up' ? itemCount - 1 : 1)) % itemCount : 0; if (nextIndex < schemaFields.length) { - setCurrentFieldIndex(nextIndex) - setFocusedButton(null) - syncTextInput(nextIndex) + setCurrentFieldIndex(nextIndex); + setFocusedButton(null); + syncTextInput(nextIndex); } else { - setCurrentFieldIndex(undefined) - setFocusedButton(nextIndex === schemaFields.length ? 'accept' : 'decline') - setTextInputValue('') + setCurrentFieldIndex(undefined); + setFocusedButton(nextIndex === schemaFields.length ? 'accept' : 'decline'); + setTextInputValue(''); } } - function setField( - fieldName: string, - value: number | string | boolean | string[] | undefined, - ) { + function setField(fieldName: string, value: number | string | boolean | string[] | undefined) { setFormValues(prev => { - const next = { ...prev } + const next = { ...prev }; if (value === undefined) { - delete next[fieldName] + delete next[fieldName]; } else { - next[fieldName] = value + next[fieldName] = value; } - return next - }) + return next; + }); // Clear "required" error when a value is provided - if ( - value !== undefined && - validationErrors[fieldName] === 'This field is required' - ) { - updateValidationError(fieldName) + if (value !== undefined && validationErrors[fieldName] === 'This field is required') { + updateValidationError(fieldName); } } function updateValidationError(fieldName: string, error?: string) { setValidationErrors(prev => { - const next = { ...prev } + const next = { ...prev }; if (error) { - next[fieldName] = error + next[fieldName] = error; } else { - delete next[fieldName] + delete next[fieldName]; } - return next - }) + return next; + }); } function unsetField(fieldName: string) { - if (!fieldName) return - setField(fieldName, undefined) - updateValidationError(fieldName) - setTextInputValue('') - setTextInputCursorOffset(0) + if (!fieldName) return; + setField(fieldName, undefined); + updateValidationError(fieldName); + setTextInputValue(''); + setTextInputCursorOffset(0); } - function commitTextField( - fieldName: string, - schema: PrimitiveSchemaDefinition, - value: string, - ) { - const trimmedValue = value.trim() + function commitTextField(fieldName: string, schema: PrimitiveSchemaDefinition, value: string) { + const trimmedValue = value.trim(); // Empty input for non-plain-string types means unset - if ( - trimmedValue === '' && - (schema.type !== 'string' || - ('format' in schema && schema.format !== undefined)) - ) { - unsetField(fieldName) - return + if (trimmedValue === '' && (schema.type !== 'string' || ('format' in schema && schema.format !== undefined))) { + unsetField(fieldName); + return; } if (trimmedValue === '') { // Empty plain string — keep or unset depending on whether it was set if (formValues[fieldName] !== undefined) { - setField(fieldName, '') + setField(fieldName, ''); } - return + return; } - const validation = validateElicitationInput(value, schema) - setField(fieldName, validation.isValid ? validation.value : value) - updateValidationError( - fieldName, - validation.isValid ? undefined : validation.error, - ) + const validation = validateElicitationInput(value, schema); + setField(fieldName, validation.isValid ? validation.value : value); + updateValidationError(fieldName, validation.isValid ? undefined : validation.error); } - function resolveFieldAsync( - fieldName: string, - schema: PrimitiveSchemaDefinition, - rawValue: string, - ) { - if (!signal) return + function resolveFieldAsync(fieldName: string, schema: PrimitiveSchemaDefinition, rawValue: string) { + if (!signal) return; // Abort any existing resolution for this field - const existing = resolveAbortRef.current.get(fieldName) + const existing = resolveAbortRef.current.get(fieldName); if (existing) { - existing.abort() + existing.abort(); } - const controller = new AbortController() - resolveAbortRef.current.set(fieldName, controller) + const controller = new AbortController(); + resolveAbortRef.current.set(fieldName, controller); - setResolvingFields(prev => new Set(prev).add(fieldName)) + setResolvingFields(prev => new Set(prev).add(fieldName)); - void validateElicitationInputAsync( - rawValue, - schema, - controller.signal, - ).then( + void validateElicitationInputAsync(rawValue, schema, controller.signal).then( result => { - resolveAbortRef.current.delete(fieldName) + resolveAbortRef.current.delete(fieldName); setResolvingFields(prev => { - const next = new Set(prev) - next.delete(fieldName) - return next - }) - if (controller.signal.aborted) return + const next = new Set(prev); + next.delete(fieldName); + return next; + }); + if (controller.signal.aborted) return; if (result.isValid) { - setField(fieldName, result.value) - updateValidationError(fieldName) + setField(fieldName, result.value); + updateValidationError(fieldName); // Update the text input if we're still on this field - const isoText = String(result.value) + const isoText = String(result.value); setTextInputValue(prev => { // Only replace if the field is still showing the raw input if (prev === rawValue) { - setTextInputCursorOffset(isoText.length) - return isoText + setTextInputCursorOffset(isoText.length); + return isoText; } - return prev - }) + return prev; + }); } else { // Keep raw text, show validation error - updateValidationError(fieldName, result.error) + updateValidationError(fieldName, result.error); } }, () => { - resolveAbortRef.current.delete(fieldName) + resolveAbortRef.current.delete(fieldName); setResolvingFields(prev => { - const next = new Set(prev) - next.delete(fieldName) - return next - }) + const next = new Set(prev); + next.delete(fieldName); + return next; + }); }, - ) + ); } function handleTextInputChange(newValue: string) { - setTextInputValue(newValue) + setTextInputValue(newValue); // Commit immediately on each keystroke (sync validation) if (currentField) { - commitTextField(currentField.name, currentField.schema, newValue) + commitTextField(currentField.name, currentField.schema, newValue); // For date/datetime fields, debounce async NL parsing after 2s of inactivity if (dateDebounceRef.current !== undefined) { - clearTimeout(dateDebounceRef.current) - dateDebounceRef.current = undefined + clearTimeout(dateDebounceRef.current); + dateDebounceRef.current = undefined; } - if ( - isDateTimeSchema(currentField.schema) && - newValue.trim() !== '' && - validationErrors[currentField.name] - ) { - const fieldName = currentField.name - const schema = currentField.schema + if (isDateTimeSchema(currentField.schema) && newValue.trim() !== '' && validationErrors[currentField.name]) { + const fieldName = currentField.name; + const schema = currentField.schema; dateDebounceRef.current = setTimeout( (dateDebounceRef, resolveFieldAsync, fieldName, schema, newValue) => { - dateDebounceRef.current = undefined - resolveFieldAsync(fieldName, schema, newValue) + dateDebounceRef.current = undefined; + resolveFieldAsync(fieldName, schema, newValue); }, 2000, dateDebounceRef, @@ -557,13 +452,13 @@ function ElicitationFormDialog({ fieldName, schema, newValue, - ) + ); } } } function handleTextInputSubmit() { - handleNavigation('down') + handleNavigation('down'); } /** @@ -571,17 +466,13 @@ function ElicitationFormDialog({ * call `onMatch` with the index of the first label that prefix-matches. * Shared by boolean y/n, enum accordion, and multi-select accordion. */ - function runTypeahead( - char: string, - labels: string[], - onMatch: (index: number) => void, - ) { - const ta = enumTypeaheadRef.current - if (ta.timer !== undefined) clearTimeout(ta.timer) - ta.buffer += char.toLowerCase() - ta.timer = setTimeout(resetTypeahead, 2000, ta) - const match = labels.findIndex(l => l.startsWith(ta.buffer)) - if (match !== -1) onMatch(match) + function runTypeahead(char: string, labels: string[], onMatch: (index: number) => void) { + const ta = enumTypeaheadRef.current; + if (ta.timer !== undefined) clearTimeout(ta.timer); + ta.buffer += char.toLowerCase(); + ta.timer = setTimeout(resetTypeahead, 2000, ta); + const match = labels.findIndex(l => l.startsWith(ta.buffer)); + if (match !== -1) onMatch(match); } // Esc while a field is focused: cancel the dialog. @@ -592,316 +483,287 @@ function ElicitationFormDialog({ () => { // For text fields, revert uncommitted changes first if (isEditingTextField && currentField) { - const val = formValues[currentField.name] - setTextInputValue(val !== undefined ? String(val) : '') - setTextInputCursorOffset(0) + const val = formValues[currentField.name]; + setTextInputValue(val !== undefined ? String(val) : ''); + setTextInputCursorOffset(0); } - onResponse('cancel') + onResponse('cancel'); }, { context: 'Settings', isActive: !!currentField && !focusedButton && !expandedAccordion, }, - ) + ); useInput( (_input, key) => { // Text fields handle their own character input; we only intercept // navigation keys and backspace-on-empty here. - if ( - isEditingTextField && - !key.upArrow && - !key.downArrow && - !key.return && - !key.backspace - ) { - return + if (isEditingTextField && !key.upArrow && !key.downArrow && !key.return && !key.backspace) { + return; } // Expanded multi-select accordion - if ( - expandedAccordion && - currentField && - isMultiSelectEnumSchema(currentField.schema) - ) { - const msSchema = currentField.schema - const msValues = getMultiSelectValues(msSchema) - const selected = (formValues[currentField.name] as string[]) ?? [] + if (expandedAccordion && currentField && isMultiSelectEnumSchema(currentField.schema)) { + const msSchema = currentField.schema; + const msValues = getMultiSelectValues(msSchema); + const selected = (formValues[currentField.name] as string[]) ?? []; if (key.leftArrow || key.escape) { - setExpandedAccordion(undefined) - validateMultiSelect(currentField.name, msSchema) - return + setExpandedAccordion(undefined); + validateMultiSelect(currentField.name, msSchema); + return; } if (key.upArrow) { if (accordionOptionIndex === 0) { - setExpandedAccordion(undefined) - validateMultiSelect(currentField.name, msSchema) + setExpandedAccordion(undefined); + validateMultiSelect(currentField.name, msSchema); } else { - setAccordionOptionIndex(accordionOptionIndex - 1) + setAccordionOptionIndex(accordionOptionIndex - 1); } - return + return; } if (key.downArrow) { if (accordionOptionIndex >= msValues.length - 1) { - setExpandedAccordion(undefined) - handleNavigation('down') + setExpandedAccordion(undefined); + handleNavigation('down'); } else { - setAccordionOptionIndex(accordionOptionIndex + 1) + setAccordionOptionIndex(accordionOptionIndex + 1); } - return + return; } if (_input === ' ') { - const optionValue = msValues[accordionOptionIndex] + const optionValue = msValues[accordionOptionIndex]; if (optionValue !== undefined) { const newSelected = selected.includes(optionValue) ? selected.filter(v => v !== optionValue) - : [...selected, optionValue] - const newValue = newSelected.length > 0 ? newSelected : undefined - setField(currentField.name, newValue) - const min = msSchema.minItems - const max = msSchema.maxItems - if ( - min !== undefined && - newSelected.length < min && - (newSelected.length > 0 || currentField.isRequired) - ) { - updateValidationError( - currentField.name, - `Select at least ${min} ${plural(min, 'item')}`, - ) + : [...selected, optionValue]; + const newValue = newSelected.length > 0 ? newSelected : undefined; + setField(currentField.name, newValue); + const min = msSchema.minItems; + const max = msSchema.maxItems; + if (min !== undefined && newSelected.length < min && (newSelected.length > 0 || currentField.isRequired)) { + updateValidationError(currentField.name, `Select at least ${min} ${plural(min, 'item')}`); } else if (max !== undefined && newSelected.length > max) { - updateValidationError( - currentField.name, - `Select at most ${max} ${plural(max, 'item')}`, - ) + updateValidationError(currentField.name, `Select at most ${max} ${plural(max, 'item')}`); } else { - updateValidationError(currentField.name) + updateValidationError(currentField.name); } } - return + return; } if (key.return) { // Check (not toggle) the focused item, then collapse and advance - const optionValue = msValues[accordionOptionIndex] + const optionValue = msValues[accordionOptionIndex]; if (optionValue !== undefined && !selected.includes(optionValue)) { - setField(currentField.name, [...selected, optionValue]) + setField(currentField.name, [...selected, optionValue]); } - setExpandedAccordion(undefined) - handleNavigation('down') - return + setExpandedAccordion(undefined); + handleNavigation('down'); + return; } if (_input) { - const labels = msValues.map(v => - getMultiSelectLabel(msSchema, v).toLowerCase(), - ) - runTypeahead(_input, labels, setAccordionOptionIndex) - return + const labels = msValues.map(v => getMultiSelectLabel(msSchema, v).toLowerCase()); + runTypeahead(_input, labels, setAccordionOptionIndex); + return; } - return + return; } // Expanded single-select enum accordion - if ( - expandedAccordion && - currentField && - isEnumSchema(currentField.schema) - ) { - const enumSchema = currentField.schema - const enumValues = getEnumValues(enumSchema) + if (expandedAccordion && currentField && isEnumSchema(currentField.schema)) { + const enumSchema = currentField.schema; + const enumValues = getEnumValues(enumSchema); if (key.leftArrow || key.escape) { - setExpandedAccordion(undefined) - return + setExpandedAccordion(undefined); + return; } if (key.upArrow) { if (accordionOptionIndex === 0) { - setExpandedAccordion(undefined) + setExpandedAccordion(undefined); } else { - setAccordionOptionIndex(accordionOptionIndex - 1) + setAccordionOptionIndex(accordionOptionIndex - 1); } - return + return; } if (key.downArrow) { if (accordionOptionIndex >= enumValues.length - 1) { - setExpandedAccordion(undefined) - handleNavigation('down') + setExpandedAccordion(undefined); + handleNavigation('down'); } else { - setAccordionOptionIndex(accordionOptionIndex + 1) + setAccordionOptionIndex(accordionOptionIndex + 1); } - return + return; } // Space: select and collapse if (_input === ' ') { - const optionValue = enumValues[accordionOptionIndex] + const optionValue = enumValues[accordionOptionIndex]; if (optionValue !== undefined) { - setField(currentField.name, optionValue) + setField(currentField.name, optionValue); } - setExpandedAccordion(undefined) - return + setExpandedAccordion(undefined); + return; } // Enter: select, collapse, and move to next field if (key.return) { - const optionValue = enumValues[accordionOptionIndex] + const optionValue = enumValues[accordionOptionIndex]; if (optionValue !== undefined) { - setField(currentField.name, optionValue) + setField(currentField.name, optionValue); } - setExpandedAccordion(undefined) - handleNavigation('down') - return + setExpandedAccordion(undefined); + handleNavigation('down'); + return; } if (_input) { - const labels = enumValues.map(v => - getEnumLabel(enumSchema, v).toLowerCase(), - ) - runTypeahead(_input, labels, setAccordionOptionIndex) - return + const labels = enumValues.map(v => getEnumLabel(enumSchema, v).toLowerCase()); + runTypeahead(_input, labels, setAccordionOptionIndex); + return; } - return + return; } // Accept / Decline buttons if (key.return && focusedButton === 'accept') { if (validateRequired() && Object.keys(validationErrors).length === 0) { - onResponse('accept', formValues) + onResponse('accept', formValues); } else { // Show "required" validation errors on missing fields - const requiredFields = requestedSchema.required || [] + const requiredFields = requestedSchema.required || []; for (const fieldName of requiredFields) { if (formValues[fieldName] === undefined) { - updateValidationError(fieldName, 'This field is required') + updateValidationError(fieldName, 'This field is required'); } } const firstBadIndex = schemaFields.findIndex( f => - (requiredFields.includes(f.name) && - formValues[f.name] === undefined) || + (requiredFields.includes(f.name) && formValues[f.name] === undefined) || validationErrors[f.name] !== undefined, - ) + ); if (firstBadIndex !== -1) { - setCurrentFieldIndex(firstBadIndex) - setFocusedButton(null) - syncTextInput(firstBadIndex) + setCurrentFieldIndex(firstBadIndex); + setFocusedButton(null); + syncTextInput(firstBadIndex); } } - return + return; } if (key.return && focusedButton === 'decline') { - onResponse('decline') - return + onResponse('decline'); + return; } // Up/Down navigation if (key.upArrow || key.downArrow) { // Reset enum typeahead when leaving a field - const ta = enumTypeaheadRef.current - ta.buffer = '' + const ta = enumTypeaheadRef.current; + ta.buffer = ''; if (ta.timer !== undefined) { - clearTimeout(ta.timer) - ta.timer = undefined + clearTimeout(ta.timer); + ta.timer = undefined; } - handleNavigation(key.upArrow ? 'up' : 'down') - return + handleNavigation(key.upArrow ? 'up' : 'down'); + return; } // Left/Right to switch between Accept and Decline buttons if (focusedButton && (key.leftArrow || key.rightArrow)) { - setFocusedButton(focusedButton === 'accept' ? 'decline' : 'accept') - return + setFocusedButton(focusedButton === 'accept' ? 'decline' : 'accept'); + return; } - if (!currentField) return - const { schema, name } = currentField - const value = formValues[name] + if (!currentField) return; + const { schema, name } = currentField; + const value = formValues[name]; // Boolean: Space to toggle, Enter to move on if (schema.type === 'boolean') { if (_input === ' ') { - setField(name, value === undefined ? true : !value) - return + setField(name, value === undefined ? true : !value); + return; } if (key.return) { - handleNavigation('down') - return + handleNavigation('down'); + return; } if (key.backspace && value !== undefined) { - unsetField(name) - return + unsetField(name); + return; } // y/n typeahead if (_input && !key.return) { - runTypeahead(_input, ['yes', 'no'], i => setField(name, i === 0)) - return + runTypeahead(_input, ['yes', 'no'], i => setField(name, i === 0)); + return; } - return + return; } // Enum or multi-select (collapsed) — accordion style if (isEnumSchema(schema) || isMultiSelectEnumSchema(schema)) { if (key.return) { - handleNavigation('down') - return + handleNavigation('down'); + return; } if (key.backspace && value !== undefined) { - unsetField(name) - return + unsetField(name); + return; } // Compute option labels + initial focus index for rightArrow expand. // Single-select focuses on the current value; multi-select starts at 0. - let labels: string[] - let startIdx = 0 + let labels: string[]; + let startIdx = 0; if (isEnumSchema(schema)) { - const vals = getEnumValues(schema) - labels = vals.map(v => getEnumLabel(schema, v).toLowerCase()) + const vals = getEnumValues(schema); + labels = vals.map(v => getEnumLabel(schema, v).toLowerCase()); if (value !== undefined) { - startIdx = Math.max(0, vals.indexOf(value as string)) + startIdx = Math.max(0, vals.indexOf(value as string)); } } else { - const vals = getMultiSelectValues(schema) - labels = vals.map(v => getMultiSelectLabel(schema, v).toLowerCase()) + const vals = getMultiSelectValues(schema); + labels = vals.map(v => getMultiSelectLabel(schema, v).toLowerCase()); } if (key.rightArrow) { - setExpandedAccordion(name) - setAccordionOptionIndex(startIdx) - return + setExpandedAccordion(name); + setAccordionOptionIndex(startIdx); + return; } // Typeahead: expand and jump to matching option if (_input && !key.leftArrow) { runTypeahead(_input, labels, i => { - setExpandedAccordion(name) - setAccordionOptionIndex(i) - }) - return + setExpandedAccordion(name); + setAccordionOptionIndex(i); + }); + return; } - return + return; } // Backspace: text fields when empty if (key.backspace) { if (isEditingTextField && textInputValue === '') { - unsetField(name) - return + unsetField(name); + return; } } // Text field Enter is handled by TextInput's onSubmit }, { isActive: true }, - ) + ); function validateRequired(): boolean { - const requiredFields = requestedSchema.required || [] + const requiredFields = requestedSchema.required || []; for (const fieldName of requiredFields) { - const value = formValues[fieldName] + const value = formValues[fieldName]; if (value === undefined || value === null || value === '') { - return false + return false; } if (Array.isArray(value) && value.length === 0) { - return false + return false; } } - return true + return true; } // Scroll windowing: compute visible field range @@ -913,32 +775,29 @@ function ElicitationFormDialog({ // To generalize: track per-field height (3 for collapsed, N+3 for // expanded multi-select) and compute a pixel-budget window instead // of a simple item-count window. - const LINES_PER_FIELD = 3 - const DIALOG_OVERHEAD = 14 - const maxVisibleFields = Math.max( - 2, - Math.floor((rows - DIALOG_OVERHEAD) / LINES_PER_FIELD), - ) + const LINES_PER_FIELD = 3; + const DIALOG_OVERHEAD = 14; + const maxVisibleFields = Math.max(2, Math.floor((rows - DIALOG_OVERHEAD) / LINES_PER_FIELD)); const scrollWindow = useMemo(() => { - const total = schemaFields.length + const total = schemaFields.length; if (total <= maxVisibleFields) { - return { start: 0, end: total } + return { start: 0, end: total }; } // When buttons are focused (currentFieldIndex undefined), pin to end - const focusIdx = currentFieldIndex ?? total - 1 - let start = Math.max(0, focusIdx - Math.floor(maxVisibleFields / 2)) - const end = Math.min(start + maxVisibleFields, total) + const focusIdx = currentFieldIndex ?? total - 1; + let start = Math.max(0, focusIdx - Math.floor(maxVisibleFields / 2)); + const end = Math.min(start + maxVisibleFields, total); // Adjust start if we hit the bottom - start = Math.max(0, end - maxVisibleFields) - return { start, end } - }, [schemaFields.length, maxVisibleFields, currentFieldIndex]) + start = Math.max(0, end - maxVisibleFields); + return { start, end }; + }, [schemaFields.length, maxVisibleFields, currentFieldIndex]); - const hasFieldsAbove = scrollWindow.start > 0 - const hasFieldsBelow = scrollWindow.end < schemaFields.length + const hasFieldsAbove = scrollWindow.start > 0; + const hasFieldsBelow = scrollWindow.end < schemaFields.length; function renderFormFields(): React.ReactNode { - if (!schemaFields.length) return null + if (!schemaFields.length) return null; return ( @@ -949,271 +808,237 @@ function ElicitationFormDialog({ )} - {schemaFields - .slice(scrollWindow.start, scrollWindow.end) - .map((field, visibleIdx) => { - const index = scrollWindow.start + visibleIdx - const { name, schema, isRequired } = field - const isActive = index === currentFieldIndex && !focusedButton - const value = formValues[name] - const hasValue = - value !== undefined && (!Array.isArray(value) || value.length > 0) - const error = validationErrors[name] + {schemaFields.slice(scrollWindow.start, scrollWindow.end).map((field, visibleIdx) => { + const index = scrollWindow.start + visibleIdx; + const { name, schema, isRequired } = field; + const isActive = index === currentFieldIndex && !focusedButton; + const value = formValues[name]; + const hasValue = value !== undefined && (!Array.isArray(value) || value.length > 0); + const error = validationErrors[name]; - // Checkbox: spinner → ⚠ error → ✔ set → * required → space - const isResolving = resolvingFields.has(name) - const checkbox = isResolving ? ( - - ) : error ? ( - {figures.warning} - ) : hasValue ? ( - - {figures.tick} - - ) : isRequired ? ( - * - ) : ( - - ) + // Checkbox: spinner → ⚠ error → ✔ set → * required → space + const isResolving = resolvingFields.has(name); + const checkbox = isResolving ? ( + + ) : error ? ( + {figures.warning} + ) : hasValue ? ( + + {figures.tick} + + ) : isRequired ? ( + * + ) : ( + + ); - // Selection color matches field status - const selectionColor = error - ? 'error' - : hasValue - ? 'success' - : isRequired - ? 'error' - : 'suggestion' + // Selection color matches field status + const selectionColor = error ? 'error' : hasValue ? 'success' : isRequired ? 'error' : 'suggestion'; - const activeColor = isActive ? selectionColor : undefined + const activeColor = isActive ? selectionColor : undefined; - const label = ( - - {schema.title || name} - - ) + const label = ( + + {schema.title || name} + + ); - // Render the value portion based on field type - let valueContent: React.ReactNode - let accordionContent: React.ReactNode = null + // Render the value portion based on field type + let valueContent: React.ReactNode; + let accordionContent: React.ReactNode = null; - if (isMultiSelectEnumSchema(schema)) { - const msValues = getMultiSelectValues(schema) - const selected = (value as string[] | undefined) ?? [] - const isExpanded = expandedAccordion === name && isActive + if (isMultiSelectEnumSchema(schema)) { + const msValues = getMultiSelectValues(schema); + const selected = (value as string[] | undefined) ?? []; + const isExpanded = expandedAccordion === name && isActive; - if (isExpanded) { - valueContent = {figures.triangleDownSmall} - accordionContent = ( - - {msValues.map((optVal, optIdx) => { - const optLabel = getMultiSelectLabel(schema, optVal) - const isChecked = selected.includes(optVal) - const isFocused = optIdx === accordionOptionIndex - return ( - - - {isFocused ? figures.pointer : ' '} - - - {isChecked - ? figures.checkboxOn - : figures.checkboxOff} - - - {optLabel} - - - ) - })} - - ) - } else { - // Collapsed: ▸ arrow then comma-joined selected items - const arrow = isActive ? ( - {figures.triangleRightSmall} - ) : null - if (selected.length > 0) { - const displayLabels = selected.map(v => - getMultiSelectLabel(schema, v), - ) - valueContent = ( - - {arrow} - - {displayLabels.join(', ')} - - - ) - } else { - valueContent = ( - - {arrow} - - not set - - - ) - } - } - } else if (isEnumSchema(schema)) { - const enumValues = getEnumValues(schema) - const isExpanded = expandedAccordion === name && isActive - - if (isExpanded) { - valueContent = {figures.triangleDownSmall} - accordionContent = ( - - {enumValues.map((optVal, optIdx) => { - const optLabel = getEnumLabel(schema, optVal) - const isSelected = value === optVal - const isFocused = optIdx === accordionOptionIndex - return ( - - - {isFocused ? figures.pointer : ' '} - - - {isSelected ? figures.radioOn : figures.radioOff} - - - {optLabel} - - - ) - })} - - ) - } else { - // Collapsed: ▸ arrow then current value - const arrow = isActive ? ( - {figures.triangleRightSmall} - ) : null - if (hasValue) { - valueContent = ( - - {arrow} - - {getEnumLabel(schema, value as string)} - - - ) - } else { - valueContent = ( - - {arrow} - - not set - - - ) - } - } - } else if (schema.type === 'boolean') { - if (isActive) { - valueContent = hasValue ? ( - - {value ? figures.checkboxOn : figures.checkboxOff} - - ) : ( - {figures.checkboxOff} - ) - } else { - valueContent = hasValue ? ( - - {value ? figures.checkboxOn : figures.checkboxOff} - - ) : ( - - not set - - ) - } - } else if (isTextField(schema)) { - if (isActive) { + if (isExpanded) { + valueContent = {figures.triangleDownSmall}; + accordionContent = ( + + {msValues.map((optVal, optIdx) => { + const optLabel = getMultiSelectLabel(schema, optVal); + const isChecked = selected.includes(optVal); + const isFocused = optIdx === accordionOptionIndex; + return ( + + {isFocused ? figures.pointer : ' '} + + {isChecked ? figures.checkboxOn : figures.checkboxOff} + + + {optLabel} + + + ); + })} + + ); + } else { + // Collapsed: ▸ arrow then comma-joined selected items + const arrow = isActive ? {figures.triangleRightSmall} : null; + if (selected.length > 0) { + const displayLabels = selected.map(v => getMultiSelectLabel(schema, v)); valueContent = ( - - ) - } else { - const displayValue = - hasValue && isDateTimeSchema(schema) - ? formatDateDisplay(String(value), schema) - : String(value) - valueContent = hasValue ? ( - {displayValue} - ) : ( - - not set + + {arrow} + + {displayLabels.join(', ')} + - ) + ); + } else { + valueContent = ( + + {arrow} + + not set + + + ); } + } + } else if (isEnumSchema(schema)) { + const enumValues = getEnumValues(schema); + const isExpanded = expandedAccordion === name && isActive; + + if (isExpanded) { + valueContent = {figures.triangleDownSmall}; + accordionContent = ( + + {enumValues.map((optVal, optIdx) => { + const optLabel = getEnumLabel(schema, optVal); + const isSelected = value === optVal; + const isFocused = optIdx === accordionOptionIndex; + return ( + + {isFocused ? figures.pointer : ' '} + + {isSelected ? figures.radioOn : figures.radioOff} + + + {optLabel} + + + ); + })} + + ); + } else { + // Collapsed: ▸ arrow then current value + const arrow = isActive ? {figures.triangleRightSmall} : null; + if (hasValue) { + valueContent = ( + + {arrow} + + {getEnumLabel(schema, value as string)} + + + ); + } else { + valueContent = ( + + {arrow} + + not set + + + ); + } + } + } else if (schema.type === 'boolean') { + if (isActive) { + valueContent = hasValue ? ( + + {value ? figures.checkboxOn : figures.checkboxOff} + + ) : ( + {figures.checkboxOff} + ); } else { valueContent = hasValue ? ( - {String(value)} + {value ? figures.checkboxOn : figures.checkboxOff} ) : ( not set - ) + ); } + } else if (isTextField(schema)) { + if (isActive) { + valueContent = ( + + ); + } else { + const displayValue = + hasValue && isDateTimeSchema(schema) ? formatDateDisplay(String(value), schema) : String(value); + valueContent = hasValue ? ( + {displayValue} + ) : ( + + not set + + ); + } + } else { + valueContent = hasValue ? ( + {String(value)} + ) : ( + + not set + + ); + } - return ( - - - - {isActive ? figures.pointer : ' '} - - {checkbox} - - {label} - : - {valueContent} - - - {accordionContent} - {schema.description && ( - - {schema.description} - - )} - - {error ? ( - - {error} - - ) : ( - - )} + return ( + + + {isActive ? figures.pointer : ' '} + {checkbox} + + {label} + : + {valueContent} - ) - })} + {accordionContent} + {schema.description && ( + + {schema.description} + + )} + + {error ? ( + + {error} + + ) : ( + + )} + + + ); + })} {hasFieldsBelow && ( - {figures.arrowDown} {schemaFields.length - scrollWindow.end} more - below + {figures.arrowDown} {schemaFields.length - scrollWindow.end} more below )} - ) + ); } return ( @@ -1228,16 +1053,9 @@ function ElicitationFormDialog({ Press {exitState.keyName} again to exit ) : ( - + - {currentField && ( - - )} + {currentField && } {currentField && currentField.schema.type === 'boolean' && ( )} @@ -1262,9 +1080,7 @@ function ElicitationFormDialog({ {renderFormFields()} - - {focusedButton === 'accept' ? figures.pointer : ' '} - + {focusedButton === 'accept' ? figures.pointer : ' '} {' Accept '} - - {focusedButton === 'decline' ? figures.pointer : ' '} - + {focusedButton === 'decline' ? figures.pointer : ' '} - ) + ); } function ElicitationURLDialog({ @@ -1293,120 +1107,111 @@ function ElicitationURLDialog({ onResponse, onWaitingDismiss, }: { - event: ElicitationRequestEvent - onResponse: Props['onResponse'] - onWaitingDismiss: Props['onWaitingDismiss'] + event: ElicitationRequestEvent; + onResponse: Props['onResponse']; + onWaitingDismiss: Props['onWaitingDismiss']; }): React.ReactNode { - const { serverName, signal, waitingState } = event - const urlParams = event.params as ElicitRequestURLParams - const { message, url } = urlParams - const [phase, setPhase] = useState<'prompt' | 'waiting'>('prompt') - const phaseRef = useRef<'prompt' | 'waiting'>('prompt') - const [focusedButton, setFocusedButton] = useState< - 'accept' | 'decline' | 'open' | 'action' | 'cancel' - >('accept') - const showCancel = waitingState?.showCancel ?? false + const { serverName, signal, waitingState } = event; + const urlParams = event.params as ElicitRequestURLParams; + const { message, url } = urlParams; + const [phase, setPhase] = useState<'prompt' | 'waiting'>('prompt'); + const phaseRef = useRef<'prompt' | 'waiting'>('prompt'); + const [focusedButton, setFocusedButton] = useState<'accept' | 'decline' | 'open' | 'action' | 'cancel'>('accept'); + const showCancel = waitingState?.showCancel ?? false; - useNotifyAfterTimeout( - 'Claude Code needs your input', - 'elicitation_url_dialog', - ) - useRegisterOverlay('elicitation-url') + useNotifyAfterTimeout('Claude Code needs your input', 'elicitation_url_dialog'); + useRegisterOverlay('elicitation-url'); // Keep refs in sync for use in abort handler (avoids re-registering listener) - phaseRef.current = phase - const onWaitingDismissRef = useRef(onWaitingDismiss) - onWaitingDismissRef.current = onWaitingDismiss + phaseRef.current = phase; + const onWaitingDismissRef = useRef(onWaitingDismiss); + onWaitingDismissRef.current = onWaitingDismiss; useEffect(() => { const handleAbort = () => { if (phaseRef.current === 'waiting') { - onWaitingDismissRef.current?.('cancel') + onWaitingDismissRef.current?.('cancel'); } else { - onResponse('cancel') + onResponse('cancel'); } - } + }; if (signal.aborted) { - handleAbort() - return + handleAbort(); + return; } - signal.addEventListener('abort', handleAbort) - return () => signal.removeEventListener('abort', handleAbort) - }, [signal, onResponse]) + signal.addEventListener('abort', handleAbort); + return () => signal.removeEventListener('abort', handleAbort); + }, [signal, onResponse]); // Parse URL to highlight the domain - let domain = '' - let urlBeforeDomain = '' - let urlAfterDomain = '' + let domain = ''; + let urlBeforeDomain = ''; + let urlAfterDomain = ''; try { - const parsed = new URL(url) - domain = parsed.hostname - const domainStart = url.indexOf(domain) - urlBeforeDomain = url.slice(0, domainStart) - urlAfterDomain = url.slice(domainStart + domain.length) + const parsed = new URL(url); + domain = parsed.hostname; + const domainStart = url.indexOf(domain); + urlBeforeDomain = url.slice(0, domainStart); + urlAfterDomain = url.slice(domainStart + domain.length); } catch { - domain = url + domain = url; } // Auto-dismiss when the server sends a completion notification (sets completed flag) useEffect(() => { if (phase === 'waiting' && event.completed) { - onWaitingDismiss?.(showCancel ? 'retry' : 'dismiss') + onWaitingDismiss?.(showCancel ? 'retry' : 'dismiss'); } - }, [phase, event.completed, onWaitingDismiss, showCancel]) + }, [phase, event.completed, onWaitingDismiss, showCancel]); const handleAccept = useCallback(() => { - void openBrowser(url) - onResponse('accept') - setPhase('waiting') - phaseRef.current = 'waiting' - setFocusedButton('open') - }, [onResponse, url]) + void openBrowser(url); + onResponse('accept'); + setPhase('waiting'); + phaseRef.current = 'waiting'; + setFocusedButton('open'); + }, [onResponse, url]); // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw input for button navigation useInput((_input, key) => { if (phase === 'prompt') { if (key.leftArrow || key.rightArrow) { - setFocusedButton(prev => (prev === 'accept' ? 'decline' : 'accept')) - return + setFocusedButton(prev => (prev === 'accept' ? 'decline' : 'accept')); + return; } if (key.return) { if (focusedButton === 'accept') { - handleAccept() + handleAccept(); } else { - onResponse('decline') + onResponse('decline'); } } } else { // waiting phase — cycle through buttons - type ButtonName = 'accept' | 'decline' | 'open' | 'action' | 'cancel' - const waitingButtons: readonly ButtonName[] = showCancel - ? ['open', 'action', 'cancel'] - : ['open', 'action'] + type ButtonName = 'accept' | 'decline' | 'open' | 'action' | 'cancel'; + const waitingButtons: readonly ButtonName[] = showCancel ? ['open', 'action', 'cancel'] : ['open', 'action']; if (key.leftArrow || key.rightArrow) { setFocusedButton(prev => { - const idx = waitingButtons.indexOf(prev) - const delta = key.rightArrow ? 1 : -1 - return waitingButtons[ - (idx + delta + waitingButtons.length) % waitingButtons.length - ]! - }) - return + const idx = waitingButtons.indexOf(prev); + const delta = key.rightArrow ? 1 : -1; + return waitingButtons[(idx + delta + waitingButtons.length) % waitingButtons.length]!; + }); + return; } if (key.return) { if (focusedButton === 'open') { - void openBrowser(url) + void openBrowser(url); } else if (focusedButton === 'cancel') { - onWaitingDismiss?.('cancel') + onWaitingDismiss?.('cancel'); } else { - onWaitingDismiss?.(showCancel ? 'retry' : 'dismiss') + onWaitingDismiss?.(showCancel ? 'retry' : 'dismiss'); } } } - }) + }); if (phase === 'waiting') { - const actionLabel = waitingState?.actionLabel ?? 'Continue without waiting' + const actionLabel = waitingState?.actionLabel ?? 'Continue without waiting'; return ( - - {focusedButton === 'open' ? figures.pointer : ' '} - + {focusedButton === 'open' ? figures.pointer : ' '} {' Reopen URL '} - - {focusedButton === 'action' ? figures.pointer : ' '} - + {focusedButton === 'action' ? figures.pointer : ' '} - - {focusedButton === 'cancel' ? figures.pointer : ' '} - + {focusedButton === 'cancel' ? figures.pointer : ' '} - ) + ); } return ( @@ -1497,12 +1296,7 @@ function ElicitationURLDialog({ Press {exitState.keyName} again to exit ) : ( - + ) @@ -1517,9 +1311,7 @@ function ElicitationURLDialog({ - - {focusedButton === 'accept' ? figures.pointer : ' '} - + {focusedButton === 'accept' ? figures.pointer : ' '} {' Accept '} - - {focusedButton === 'decline' ? figures.pointer : ' '} - + {focusedButton === 'decline' ? figures.pointer : ' '} - ) + ); } diff --git a/src/components/mcp/MCPAgentServerMenu.tsx b/src/components/mcp/MCPAgentServerMenu.tsx index 913dee9bb..190dbbb85 100644 --- a/src/components/mcp/MCPAgentServerMenu.tsx +++ b/src/components/mcp/MCPAgentServerMenu.tsx @@ -1,106 +1,86 @@ -import figures from 'figures' -import React, { useCallback, useEffect, useRef, useState } from 'react' -import type { CommandResultDisplay } from '../../commands.js' -import { Box, color, Link, Text, useTheme } from '@anthropic/ink' -import { useKeybinding } from '../../keybindings/useKeybinding.js' -import { - AuthenticationCancelledError, - performMCPOAuthFlow, -} from '../../services/mcp/auth.js' -import { capitalize } from '../../utils/stringUtils.js' -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' -import { Select } from '../CustomSelect/index.js' -import { Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink' -import { Spinner } from '../Spinner.js' -import type { AgentMcpServerInfo } from './types.js' +import figures from 'figures'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import type { CommandResultDisplay } from '../../commands.js'; +import { Box, color, Link, Text, useTheme } from '@anthropic/ink'; +import { useKeybinding } from '../../keybindings/useKeybinding.js'; +import { AuthenticationCancelledError, performMCPOAuthFlow } from '../../services/mcp/auth.js'; +import { capitalize } from '../../utils/stringUtils.js'; +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; +import { Select } from '../CustomSelect/index.js'; +import { Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink'; +import { Spinner } from '../Spinner.js'; +import type { AgentMcpServerInfo } from './types.js'; type Props = { - agentServer: AgentMcpServerInfo - onCancel: () => void - onComplete?: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void -} + agentServer: AgentMcpServerInfo; + onCancel: () => void; + onComplete?: (result?: string, options?: { display?: CommandResultDisplay }) => void; +}; /** * Menu for agent-specific MCP servers. * These servers are defined in agent frontmatter and only connect when the agent runs. * For HTTP/SSE servers, this allows pre-authentication before using the agent. */ -export function MCPAgentServerMenu({ - agentServer, - onCancel, - onComplete, -}: Props): React.ReactNode { - const [theme] = useTheme() - const [isAuthenticating, setIsAuthenticating] = useState(false) - const [error, setError] = useState(null) - const [authorizationUrl, setAuthorizationUrl] = useState(null) - const authAbortControllerRef = useRef(null) +export function MCPAgentServerMenu({ agentServer, onCancel, onComplete }: Props): React.ReactNode { + const [theme] = useTheme(); + const [isAuthenticating, setIsAuthenticating] = useState(false); + const [error, setError] = useState(null); + const [authorizationUrl, setAuthorizationUrl] = useState(null); + const authAbortControllerRef = useRef(null); // Abort OAuth flow on unmount so the callback server is closed even if a // parent component's Esc handler navigates away before ours fires. - useEffect(() => () => authAbortControllerRef.current?.abort(), []) + useEffect(() => () => authAbortControllerRef.current?.abort(), []); // Handle ESC to cancel authentication flow const handleEscCancel = useCallback(() => { if (isAuthenticating) { - authAbortControllerRef.current?.abort() - authAbortControllerRef.current = null - setIsAuthenticating(false) - setAuthorizationUrl(null) + authAbortControllerRef.current?.abort(); + authAbortControllerRef.current = null; + setIsAuthenticating(false); + setAuthorizationUrl(null); } - }, [isAuthenticating]) + }, [isAuthenticating]); useKeybinding('confirm:no', handleEscCancel, { context: 'Confirmation', isActive: isAuthenticating, - }) + }); const handleAuthenticate = useCallback(async () => { if (!agentServer.needsAuth || !agentServer.url) { - return + return; } - setIsAuthenticating(true) - setError(null) + setIsAuthenticating(true); + setError(null); - const controller = new AbortController() - authAbortControllerRef.current = controller + const controller = new AbortController(); + authAbortControllerRef.current = controller; try { // Create a temporary config for OAuth const tempConfig = { type: agentServer.transport as 'http' | 'sse', url: agentServer.url, - } + }; - await performMCPOAuthFlow( - agentServer.name, - tempConfig, - setAuthorizationUrl, - controller.signal, - ) + await performMCPOAuthFlow(agentServer.name, tempConfig, setAuthorizationUrl, controller.signal); - onComplete?.( - `Authentication successful for ${agentServer.name}. The server will connect when the agent runs.`, - ) + onComplete?.(`Authentication successful for ${agentServer.name}. The server will connect when the agent runs.`); } catch (err) { // Don't show error if it was a cancellation - if ( - err instanceof Error && - !(err instanceof AuthenticationCancelledError) - ) { - setError(err.message) + if (err instanceof Error && !(err instanceof AuthenticationCancelledError)) { + setError(err.message); } } finally { - setIsAuthenticating(false) - authAbortControllerRef.current = null + setIsAuthenticating(false); + authAbortControllerRef.current = null; } - }, [agentServer, onComplete]) + }, [agentServer, onComplete]); - const capitalizedServerName = capitalize(String(agentServer.name)) + const capitalizedServerName = capitalize(String(agentServer.name)); if (isAuthenticating) { return ( @@ -112,42 +92,34 @@ export function MCPAgentServerMenu({ {authorizationUrl && ( - - If your browser doesn't open automatically, copy this URL - manually: - + If your browser doesn't open automatically, copy this URL manually: )} Return here after authenticating in your browser.{' '} - + - ) + ); } - const menuOptions = [] + const menuOptions = []; // Only show authenticate option for HTTP/SSE servers if (agentServer.needsAuth) { menuOptions.push({ label: agentServer.isAuthenticated ? 'Re-authenticate' : 'Authenticate', value: 'auth', - }) + }); } menuOptions.push({ label: 'Back', value: 'back', - }) + }); return ( - + ) } @@ -198,10 +165,7 @@ export function MCPAgentServerMenu({ Status: - - {color('inactive', theme)(figures.radioOff)} not connected - (agent-only) - + {color('inactive', theme)(figures.radioOff)} not connected (agent-only) {agentServer.needsAuth && ( @@ -210,10 +174,7 @@ export function MCPAgentServerMenu({ {agentServer.isAuthenticated ? ( {color('success', theme)(figures.tick)} authenticated ) : ( - - {color('warning', theme)(figures.triangleUpOutline)} may need - authentication - + {color('warning', theme)(figures.triangleUpOutline)} may need authentication )} )} @@ -235,16 +196,16 @@ export function MCPAgentServerMenu({ onChange={async value => { switch (value) { case 'auth': - await handleAuthenticate() - break + await handleAuthenticate(); + break; case 'back': - onCancel() - break + onCancel(); + break; } }} onCancel={onCancel} /> - ) + ); } diff --git a/src/components/mcp/MCPListPanel.tsx b/src/components/mcp/MCPListPanel.tsx index 365599284..4902e51c7 100644 --- a/src/components/mcp/MCPListPanel.tsx +++ b/src/components/mcp/MCPListPanel.tsx @@ -1,72 +1,67 @@ -import figures from 'figures' -import React, { useCallback, useState } from 'react' -import type { CommandResultDisplay } from '../../commands.js' -import { Box, color, Link, Text, useTheme } from '@anthropic/ink' -import { useKeybindings } from '../../keybindings/useKeybinding.js' -import type { ConfigScope } from '../../services/mcp/types.js' -import { describeMcpConfigFilePath } from '../../services/mcp/utils.js' -import { isDebugMode } from '../../utils/debug.js' -import { plural } from '../../utils/stringUtils.js' -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' -import { Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink' -import { McpParsingWarnings } from './McpParsingWarnings.js' -import type { AgentMcpServerInfo, ServerInfo } from './types.js' +import figures from 'figures'; +import React, { useCallback, useState } from 'react'; +import type { CommandResultDisplay } from '../../commands.js'; +import { Box, color, Link, Text, useTheme } from '@anthropic/ink'; +import { useKeybindings } from '../../keybindings/useKeybinding.js'; +import type { ConfigScope } from '../../services/mcp/types.js'; +import { describeMcpConfigFilePath } from '../../services/mcp/utils.js'; +import { isDebugMode } from '../../utils/debug.js'; +import { plural } from '../../utils/stringUtils.js'; +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; +import { Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink'; +import { McpParsingWarnings } from './McpParsingWarnings.js'; +import type { AgentMcpServerInfo, ServerInfo } from './types.js'; type Props = { - servers: ServerInfo[] - agentServers?: AgentMcpServerInfo[] - onSelectServer: (server: ServerInfo) => void - onSelectAgentServer?: (agentServer: AgentMcpServerInfo) => void - onComplete: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void - defaultTab?: string -} + servers: ServerInfo[]; + agentServers?: AgentMcpServerInfo[]; + onSelectServer: (server: ServerInfo) => void; + onSelectAgentServer?: (agentServer: AgentMcpServerInfo) => void; + onComplete: (result?: string, options?: { display?: CommandResultDisplay }) => void; + defaultTab?: string; +}; type SelectableItem = | { type: 'server'; server: ServerInfo } - | { type: 'agent-server'; agentServer: AgentMcpServerInfo } + | { type: 'agent-server'; agentServer: AgentMcpServerInfo }; // Define scope order for display (constant, outside component) // 'dynamic' (built-in) is rendered separately at the end -const SCOPE_ORDER: ConfigScope[] = ['project', 'local', 'user', 'enterprise'] +const SCOPE_ORDER: ConfigScope[] = ['project', 'local', 'user', 'enterprise']; // Get scope heading parts (label is bold, path is grey) function getScopeHeading(scope: ConfigScope): { label: string; path?: string } { switch (scope) { case 'project': - return { label: 'Project MCPs', path: describeMcpConfigFilePath(scope) } + return { label: 'Project MCPs', path: describeMcpConfigFilePath(scope) }; case 'user': - return { label: 'User MCPs', path: describeMcpConfigFilePath(scope) } + return { label: 'User MCPs', path: describeMcpConfigFilePath(scope) }; case 'local': - return { label: 'Local MCPs', path: describeMcpConfigFilePath(scope) } + return { label: 'Local MCPs', path: describeMcpConfigFilePath(scope) }; case 'enterprise': - return { label: 'Enterprise MCPs' } + return { label: 'Enterprise MCPs' }; case 'dynamic': - return { label: 'Built-in MCPs', path: 'always available' } + return { label: 'Built-in MCPs', path: 'always available' }; default: - return { label: scope } + return { label: scope }; } } // Group servers by scope -function groupServersByScope( - serverList: ServerInfo[], -): Map { - const groups = new Map() +function groupServersByScope(serverList: ServerInfo[]): Map { + const groups = new Map(); for (const server of serverList) { - const scope = server.scope + const scope = server.scope; if (!groups.has(scope)) { - groups.set(scope, []) + groups.set(scope, []); } - groups.get(scope)!.push(server) + groups.get(scope)!.push(server); } // Sort servers within each group alphabetically for (const [, groupServers] of groups) { - groupServers.sort((a, b) => a.name.localeCompare(b.name)) + groupServers.sort((a, b) => a.name.localeCompare(b.name)); } - return groups + return groups; } export function MCPListPanel({ @@ -76,177 +71,151 @@ export function MCPListPanel({ onSelectAgentServer, onComplete, }: Props): React.ReactNode { - const [theme] = useTheme() - const [selectedIndex, setSelectedIndex] = useState(0) + const [theme] = useTheme(); + const [selectedIndex, setSelectedIndex] = useState(0); // Non-claudeai servers grouped by scope const serversByScope = React.useMemo(() => { - const regularServers = servers.filter( - s => s.client.config.type !== 'claudeai-proxy', - ) - return groupServersByScope(regularServers) - }, [servers]) + const regularServers = servers.filter(s => s.client.config.type !== 'claudeai-proxy'); + return groupServersByScope(regularServers); + }, [servers]); const claudeAiServers = React.useMemo( - () => - servers - .filter(s => s.client.config.type === 'claudeai-proxy') - .sort((a, b) => a.name.localeCompare(b.name)), + () => servers.filter(s => s.client.config.type === 'claudeai-proxy').sort((a, b) => a.name.localeCompare(b.name)), [servers], - ) + ); // Built-in (dynamic) servers - rendered last const dynamicServers = React.useMemo( - () => - (serversByScope.get('dynamic') ?? []).sort((a, b) => - a.name.localeCompare(b.name), - ), + () => (serversByScope.get('dynamic') ?? []).sort((a, b) => a.name.localeCompare(b.name)), [serversByScope], - ) + ); // Pre-compute dynamic heading for render - const dynamicHeading = getScopeHeading('dynamic') + const dynamicHeading = getScopeHeading('dynamic'); // Build flat list of selectable items in display order const selectableItems = React.useMemo(() => { - const items: SelectableItem[] = [] + const items: SelectableItem[] = []; for (const scope of SCOPE_ORDER) { - const scopeServers = serversByScope.get(scope) ?? [] + const scopeServers = serversByScope.get(scope) ?? []; for (const server of scopeServers) { - items.push({ type: 'server', server }) + items.push({ type: 'server', server }); } } for (const server of claudeAiServers) { - items.push({ type: 'server', server }) + items.push({ type: 'server', server }); } for (const agentServer of agentServers) { - items.push({ type: 'agent-server', agentServer }) + items.push({ type: 'agent-server', agentServer }); } // Dynamic (built-in) servers come last for (const server of dynamicServers) { - items.push({ type: 'server', server }) + items.push({ type: 'server', server }); } - return items - }, [serversByScope, claudeAiServers, agentServers, dynamicServers]) + return items; + }, [serversByScope, claudeAiServers, agentServers, dynamicServers]); const handleCancel = useCallback((): void => { onComplete('MCP dialog dismissed', { display: 'system', - }) - }, [onComplete]) + }); + }, [onComplete]); const handleSelect = useCallback((): void => { - const item = selectableItems[selectedIndex] - if (!item) return + const item = selectableItems[selectedIndex]; + if (!item) return; if (item.type === 'server') { - onSelectServer(item.server) + onSelectServer(item.server); } else if (item.type === 'agent-server' && onSelectAgentServer) { - onSelectAgentServer(item.agentServer) + onSelectAgentServer(item.agentServer); } - }, [selectableItems, selectedIndex, onSelectServer, onSelectAgentServer]) + }, [selectableItems, selectedIndex, onSelectServer, onSelectAgentServer]); // Use configurable keybindings for navigation and selection useKeybindings( { - 'confirm:previous': () => - setSelectedIndex(prev => - prev === 0 ? selectableItems.length - 1 : prev - 1, - ), - 'confirm:next': () => - setSelectedIndex(prev => - prev === selectableItems.length - 1 ? 0 : prev + 1, - ), + 'confirm:previous': () => setSelectedIndex(prev => (prev === 0 ? selectableItems.length - 1 : prev - 1)), + 'confirm:next': () => setSelectedIndex(prev => (prev === selectableItems.length - 1 ? 0 : prev + 1)), 'confirm:yes': handleSelect, 'confirm:no': handleCancel, }, { context: 'Confirmation' }, - ) + ); // Build index lookup for each server const getServerIndex = (server: ServerInfo): number => { - return selectableItems.findIndex( - item => item.type === 'server' && item.server === server, - ) - } + return selectableItems.findIndex(item => item.type === 'server' && item.server === server); + }; const getAgentServerIndex = (agentServer: AgentMcpServerInfo): number => { - return selectableItems.findIndex( - item => item.type === 'agent-server' && item.agentServer === agentServer, - ) - } + return selectableItems.findIndex(item => item.type === 'agent-server' && item.agentServer === agentServer); + }; - const debugMode = isDebugMode() - const hasFailedClients = servers.some(s => s.client.type === 'failed') + const debugMode = isDebugMode(); + const hasFailedClients = servers.some(s => s.client.type === 'failed'); if (servers.length === 0 && agentServers.length === 0) { - return null + return null; } const renderServerItem = (server: ServerInfo): React.ReactNode => { - const index = getServerIndex(server) - const isSelected = selectedIndex === index - let statusIcon = '' - let statusText = '' + const index = getServerIndex(server); + const isSelected = selectedIndex === index; + let statusIcon = ''; + let statusText = ''; if (server.client.type === 'disabled') { - statusIcon = color('inactive', theme)(figures.radioOff) - statusText = 'disabled' + statusIcon = color('inactive', theme)(figures.radioOff); + statusText = 'disabled'; } else if (server.client.type === 'connected') { - statusIcon = color('success', theme)(figures.tick) - statusText = 'connected' + statusIcon = color('success', theme)(figures.tick); + statusText = 'connected'; } else if (server.client.type === 'pending') { - statusIcon = color('inactive', theme)(figures.radioOff) - const { reconnectAttempt, maxReconnectAttempts } = server.client + statusIcon = color('inactive', theme)(figures.radioOff); + const { reconnectAttempt, maxReconnectAttempts } = server.client; if (reconnectAttempt && maxReconnectAttempts) { - statusText = `reconnecting (${reconnectAttempt}/${maxReconnectAttempts})…` + statusText = `reconnecting (${reconnectAttempt}/${maxReconnectAttempts})…`; } else { - statusText = 'connecting…' + statusText = 'connecting…'; } } else if (server.client.type === 'needs-auth') { - statusIcon = color('warning', theme)(figures.triangleUpOutline) - statusText = 'needs authentication' + statusIcon = color('warning', theme)(figures.triangleUpOutline); + statusText = 'needs authentication'; } else { - statusIcon = color('error', theme)(figures.cross) - statusText = 'failed' + statusIcon = color('error', theme)(figures.cross); + statusText = 'failed'; } return ( - - {isSelected ? `${figures.pointer} ` : ' '} - + {isSelected ? `${figures.pointer} ` : ' '} {server.name} · {statusIcon} {statusText} - ) - } + ); + }; - const renderAgentServerItem = ( - agentServer: AgentMcpServerInfo, - ): React.ReactNode => { - const index = getAgentServerIndex(agentServer) - const isSelected = selectedIndex === index + const renderAgentServerItem = (agentServer: AgentMcpServerInfo): React.ReactNode => { + const index = getAgentServerIndex(agentServer); + const isSelected = selectedIndex === index; const statusIcon = agentServer.needsAuth ? color('warning', theme)(figures.triangleUpOutline) - : color('inactive', theme)(figures.radioOff) - const statusText = agentServer.needsAuth ? 'may need auth' : 'agent-only' + : color('inactive', theme)(figures.radioOff); + const statusText = agentServer.needsAuth ? 'may need auth' : 'agent-only'; return ( - - {isSelected ? `${figures.pointer} ` : ' '} - - - {agentServer.name} - + {isSelected ? `${figures.pointer} ` : ' '} + {agentServer.name} · {statusIcon} {statusText} - ) - } + ); + }; - const totalServers = servers.length + agentServers.length + const totalServers = servers.length + agentServers.length; return ( @@ -261,9 +230,9 @@ export function MCPListPanel({ {/* Regular servers grouped by scope */} {SCOPE_ORDER.map(scope => { - const scopeServers = serversByScope.get(scope) - if (!scopeServers || scopeServers.length === 0) return null - const heading = getScopeHeading(scope) + const scopeServers = serversByScope.get(scope); + if (!scopeServers || scopeServers.length === 0) return null; + const heading = getScopeHeading(scope); return ( @@ -272,7 +241,7 @@ export function MCPListPanel({ {scopeServers.map(server => renderServerItem(server))} - ) + ); })} {/* Claude.ai servers section */} @@ -292,18 +261,16 @@ export function MCPListPanel({ Agent MCPs {/* Group servers by source agent */} - {[...new Set(agentServers.flatMap(s => s.sourceAgents))].map( - agentName => ( - - - @{agentName} - - {agentServers - .filter(s => s.sourceAgents.includes(agentName)) - .map(agentServer => renderAgentServerItem(agentServer))} + {[...new Set(agentServers.flatMap(s => s.sourceAgents))].map(agentName => ( + + + @{agentName} - ), - )} + {agentServers + .filter(s => s.sourceAgents.includes(agentName)) + .map(agentServer => renderAgentServerItem(agentServer))} + + ))} )} @@ -312,9 +279,7 @@ export function MCPListPanel({ {dynamicHeading.label} - {dynamicHeading.path && ( - ({dynamicHeading.path}) - )} + {dynamicHeading.path && ({dynamicHeading.path})} {dynamicServers.map(server => renderServerItem(server))} @@ -324,16 +289,11 @@ export function MCPListPanel({ {hasFailedClients && ( - {debugMode - ? '※ Error logs shown inline with --debug' - : '※ Run claude --debug to see error logs'} + {debugMode ? '※ Error logs shown inline with --debug' : '※ Run claude --debug to see error logs'} )} - - https://code.claude.com/docs/en/mcp - {' '} - for help + https://code.claude.com/docs/en/mcp for help @@ -345,15 +305,10 @@ export function MCPListPanel({ - + - ) + ); } diff --git a/src/components/mcp/MCPReconnect.tsx b/src/components/mcp/MCPReconnect.tsx index 496edaf2f..3dc1c43c9 100644 --- a/src/components/mcp/MCPReconnect.tsx +++ b/src/components/mcp/MCPReconnect.tsx @@ -1,28 +1,22 @@ -import figures from 'figures' -import React, { useEffect, useState } from 'react' -import type { CommandResultDisplay } from '../../commands.js' -import { Box, color, Text, useTheme } from '@anthropic/ink' -import { useMcpReconnect } from '../../services/mcp/MCPConnectionManager.js' -import { useAppStateStore } from '../../state/AppState.js' -import { Spinner } from '../Spinner.js' +import figures from 'figures'; +import React, { useEffect, useState } from 'react'; +import type { CommandResultDisplay } from '../../commands.js'; +import { Box, color, Text, useTheme } from '@anthropic/ink'; +import { useMcpReconnect } from '../../services/mcp/MCPConnectionManager.js'; +import { useAppStateStore } from '../../state/AppState.js'; +import { Spinner } from '../Spinner.js'; type Props = { - serverName: string - onComplete: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void -} + serverName: string; + onComplete: (result?: string, options?: { display?: CommandResultDisplay }) => void; +}; -export function MCPReconnect({ - serverName, - onComplete, -}: Props): React.ReactNode { - const [theme] = useTheme() - const store = useAppStateStore() - const reconnectMcpServer = useMcpReconnect() - const [isReconnecting, setIsReconnecting] = useState(true) - const [error, setError] = useState(null) +export function MCPReconnect({ serverName, onComplete }: Props): React.ReactNode { + const [theme] = useTheme(); + const store = useAppStateStore(); + const reconnectMcpServer = useMcpReconnect(); + const [isReconnecting, setIsReconnecting] = useState(true); + const [error, setError] = useState(null); useEffect(() => { async function attemptReconnect() { @@ -30,50 +24,46 @@ export function MCPReconnect({ // Check if server exists. Read via store.getState() instead of a // reactive selector so this effect does not re-fire when // reconnectMcpServer updates mcp.clients via onConnectionAttempt. - const server = store - .getState() - .mcp.clients.find(c => c.name === serverName) + const server = store.getState().mcp.clients.find(c => c.name === serverName); if (!server) { - setError(`MCP server "${serverName}" not found`) - setIsReconnecting(false) - onComplete(`MCP server "${serverName}" not found`) - return + setError(`MCP server "${serverName}" not found`); + setIsReconnecting(false); + onComplete(`MCP server "${serverName}" not found`); + return; } // Attempt reconnection - const result = await reconnectMcpServer(serverName) + const result = await reconnectMcpServer(serverName); switch (result.client.type) { case 'connected': - setIsReconnecting(false) - onComplete(`Successfully reconnected to ${serverName}`) - break + setIsReconnecting(false); + onComplete(`Successfully reconnected to ${serverName}`); + break; case 'needs-auth': - setError(`${serverName} requires authentication`) - setIsReconnecting(false) - onComplete( - `${serverName} requires authentication. Use /mcp to authenticate.`, - ) - break + setError(`${serverName} requires authentication`); + setIsReconnecting(false); + onComplete(`${serverName} requires authentication. Use /mcp to authenticate.`); + break; case 'pending': case 'failed': case 'disabled': - setError(`Failed to reconnect to ${serverName}`) - setIsReconnecting(false) - onComplete(`Failed to reconnect to ${serverName}`) - break + setError(`Failed to reconnect to ${serverName}`); + setIsReconnecting(false); + onComplete(`Failed to reconnect to ${serverName}`); + break; } } catch (err) { // Only catch actual errors (like server not found) - const errorMessage = err instanceof Error ? err.message : String(err) - setError(errorMessage) - setIsReconnecting(false) - onComplete(`Error: ${errorMessage}`) + const errorMessage = err instanceof Error ? err.message : String(err); + setError(errorMessage); + setIsReconnecting(false); + onComplete(`Error: ${errorMessage}`); } } - void attemptReconnect() - }, [serverName, reconnectMcpServer, store, onComplete]) + void attemptReconnect(); + }, [serverName, reconnectMcpServer, store, onComplete]); if (isReconnecting) { return ( @@ -86,7 +76,7 @@ export function MCPReconnect({ Establishing connection to MCP server - ) + ); } if (error) { @@ -98,8 +88,8 @@ export function MCPReconnect({ Error: {error} - ) + ); } - return null + return null; } diff --git a/src/components/mcp/MCPRemoteServerMenu.tsx b/src/components/mcp/MCPRemoteServerMenu.tsx index 41665860e..090316b73 100644 --- a/src/components/mcp/MCPRemoteServerMenu.tsx +++ b/src/components/mcp/MCPRemoteServerMenu.tsx @@ -1,67 +1,50 @@ -import figures from 'figures' -import React, { useEffect, useRef, useState } from 'react' +import figures from 'figures'; +import React, { useEffect, useRef, useState } from 'react'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from 'src/services/analytics/index.js' -import type { CommandResultDisplay } from '../../commands.js' -import { getOauthConfig } from '../../constants/oauth.js' -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' -import { setClipboard } from '@anthropic/ink' +} from 'src/services/analytics/index.js'; +import type { CommandResultDisplay } from '../../commands.js'; +import { getOauthConfig } from '../../constants/oauth.js'; +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import { setClipboard } from '@anthropic/ink'; // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw j/k/arrow menu navigation -import { Box, color, Link, Text, useInput, useTheme } from '@anthropic/ink' -import { useKeybinding } from '../../keybindings/useKeybinding.js' -import { - AuthenticationCancelledError, - performMCPOAuthFlow, - revokeServerTokens, -} from '../../services/mcp/auth.js' -import { clearServerCache } from '../../services/mcp/client.js' -import { - useMcpReconnect, - useMcpToggleEnabled, -} from '../../services/mcp/MCPConnectionManager.js' +import { Box, color, Link, Text, useInput, useTheme } from '@anthropic/ink'; +import { useKeybinding } from '../../keybindings/useKeybinding.js'; +import { AuthenticationCancelledError, performMCPOAuthFlow, revokeServerTokens } from '../../services/mcp/auth.js'; +import { clearServerCache } from '../../services/mcp/client.js'; +import { useMcpReconnect, useMcpToggleEnabled } from '../../services/mcp/MCPConnectionManager.js'; import { describeMcpConfigFilePath, excludeCommandsByServer, excludeResourcesByServer, excludeToolsByServer, filterMcpPromptsByServer, -} from '../../services/mcp/utils.js' -import { useAppState, useSetAppState } from '../../state/AppState.js' -import { getOauthAccountInfo } from '../../utils/auth.js' -import { openBrowser } from '../../utils/browser.js' -import { errorMessage } from '../../utils/errors.js' -import { logMCPDebug } from '../../utils/log.js' -import { capitalize } from '../../utils/stringUtils.js' -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' -import { Select } from '../CustomSelect/index.js' -import { Byline, KeyboardShortcutHint } from '@anthropic/ink' -import { Spinner } from '../Spinner.js' -import TextInput from '../TextInput.js' -import { CapabilitiesSection } from './CapabilitiesSection.js' -import type { - ClaudeAIServerInfo, - HTTPServerInfo, - SSEServerInfo, -} from './types.js' -import { - handleReconnectError, - handleReconnectResult, -} from './utils/reconnectHelpers.js' +} from '../../services/mcp/utils.js'; +import { useAppState, useSetAppState } from '../../state/AppState.js'; +import { getOauthAccountInfo } from '../../utils/auth.js'; +import { openBrowser } from '../../utils/browser.js'; +import { errorMessage } from '../../utils/errors.js'; +import { logMCPDebug } from '../../utils/log.js'; +import { capitalize } from '../../utils/stringUtils.js'; +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; +import { Select } from '../CustomSelect/index.js'; +import { Byline, KeyboardShortcutHint } from '@anthropic/ink'; +import { Spinner } from '../Spinner.js'; +import TextInput from '../TextInput.js'; +import { CapabilitiesSection } from './CapabilitiesSection.js'; +import type { ClaudeAIServerInfo, HTTPServerInfo, SSEServerInfo } from './types.js'; +import { handleReconnectError, handleReconnectResult } from './utils/reconnectHelpers.js'; type Props = { - server: SSEServerInfo | HTTPServerInfo | ClaudeAIServerInfo - serverToolsCount: number - onViewTools: () => void - onCancel: () => void - onComplete?: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void - borderless?: boolean -} + server: SSEServerInfo | HTTPServerInfo | ClaudeAIServerInfo; + serverToolsCount: number; + onViewTools: () => void; + onCancel: () => void; + onComplete?: (result?: string, options?: { display?: CommandResultDisplay }) => void; + borderless?: boolean; +}; export function MCPRemoteServerMenu({ server, @@ -71,37 +54,27 @@ export function MCPRemoteServerMenu({ onComplete, borderless = false, }: Props): React.ReactNode { - const [theme] = useTheme() - const exitState = useExitOnCtrlCDWithKeybindings() - const { columns: terminalColumns } = useTerminalSize() - const [isAuthenticating, setIsAuthenticating] = React.useState(false) - const [error, setError] = React.useState(null) - const mcp = useAppState(s => s.mcp) - const setAppState = useSetAppState() - const [authorizationUrl, setAuthorizationUrl] = React.useState( - null, - ) - const [isReconnecting, setIsReconnecting] = useState(false) - const authAbortControllerRef = useRef(null) - const [isClaudeAIAuthenticating, setIsClaudeAIAuthenticating] = - useState(false) - const [claudeAIAuthUrl, setClaudeAIAuthUrl] = useState(null) - const [isClaudeAIClearingAuth, setIsClaudeAIClearingAuth] = useState(false) - const [claudeAIClearAuthUrl, setClaudeAIClearAuthUrl] = useState< - string | null - >(null) - const [claudeAIClearAuthBrowserOpened, setClaudeAIClearAuthBrowserOpened] = - useState(false) - const [urlCopied, setUrlCopied] = useState(false) - const copyTimeoutRef = useRef | undefined>( - undefined, - ) - const unmountedRef = useRef(false) - const [callbackUrlInput, setCallbackUrlInput] = useState('') - const [callbackUrlCursorOffset, setCallbackUrlCursorOffset] = useState(0) - const [manualCallbackSubmit, setManualCallbackSubmit] = useState< - ((url: string) => void) | null - >(null) + const [theme] = useTheme(); + const exitState = useExitOnCtrlCDWithKeybindings(); + const { columns: terminalColumns } = useTerminalSize(); + const [isAuthenticating, setIsAuthenticating] = React.useState(false); + const [error, setError] = React.useState(null); + const mcp = useAppState(s => s.mcp); + const setAppState = useSetAppState(); + const [authorizationUrl, setAuthorizationUrl] = React.useState(null); + const [isReconnecting, setIsReconnecting] = useState(false); + const authAbortControllerRef = useRef(null); + const [isClaudeAIAuthenticating, setIsClaudeAIAuthenticating] = useState(false); + const [claudeAIAuthUrl, setClaudeAIAuthUrl] = useState(null); + const [isClaudeAIClearingAuth, setIsClaudeAIClearingAuth] = useState(false); + const [claudeAIClearAuthUrl, setClaudeAIClearAuthUrl] = useState(null); + const [claudeAIClearAuthBrowserOpened, setClaudeAIClearAuthBrowserOpened] = useState(false); + const [urlCopied, setUrlCopied] = useState(false); + const copyTimeoutRef = useRef | undefined>(undefined); + const unmountedRef = useRef(false); + const [callbackUrlInput, setCallbackUrlInput] = useState(''); + const [callbackUrlCursorOffset, setCallbackUrlCursorOffset] = useState(0); + const [manualCallbackSubmit, setManualCallbackSubmit] = useState<((url: string) => void) | null>(null); // If the component unmounts mid-auth (e.g. a parent component's Esc handler // navigates away before ours fires), abort the OAuth flow so the callback @@ -111,70 +84,63 @@ export function MCPRemoteServerMenu({ // schedule a new timer after unmount. useEffect( () => () => { - unmountedRef.current = true - authAbortControllerRef.current?.abort() + unmountedRef.current = true; + authAbortControllerRef.current?.abort(); if (copyTimeoutRef.current !== undefined) { - clearTimeout(copyTimeoutRef.current) + clearTimeout(copyTimeoutRef.current); } }, [], - ) + ); // A server is effectively authenticated if: // 1. It has OAuth tokens (server.isAuthenticated), OR // 2. It's connected and has tools (meaning it's working via some auth mechanism) const isEffectivelyAuthenticated = - server.isAuthenticated || - (server.client.type === 'connected' && serverToolsCount > 0) + server.isAuthenticated || (server.client.type === 'connected' && serverToolsCount > 0); - const reconnectMcpServer = useMcpReconnect() + const reconnectMcpServer = useMcpReconnect(); const handleClaudeAIAuthComplete = React.useCallback(async () => { - setIsClaudeAIAuthenticating(false) - setClaudeAIAuthUrl(null) - setIsReconnecting(true) + setIsClaudeAIAuthenticating(false); + setClaudeAIAuthUrl(null); + setIsReconnecting(true); try { - const result = await reconnectMcpServer(server.name) - const success = result.client.type === 'connected' - logEvent('tengu_claudeai_mcp_auth_completed', { success }) + const result = await reconnectMcpServer(server.name); + const success = result.client.type === 'connected'; + logEvent('tengu_claudeai_mcp_auth_completed', { success }); if (success) { - onComplete?.(`Authentication successful. Connected to ${server.name}.`) + onComplete?.(`Authentication successful. Connected to ${server.name}.`); } else if (result.client.type === 'needs-auth') { onComplete?.( 'Authentication successful, but server still requires authentication. You may need to manually restart Claude Code.', - ) + ); } else { onComplete?.( 'Authentication successful, but server reconnection failed. You may need to manually restart Claude Code for the changes to take effect.', - ) + ); } } catch (err) { - logEvent('tengu_claudeai_mcp_auth_completed', { success: false }) - onComplete?.(handleReconnectError(err, server.name)) + logEvent('tengu_claudeai_mcp_auth_completed', { success: false }); + onComplete?.(handleReconnectError(err, server.name)); } finally { - setIsReconnecting(false) + setIsReconnecting(false); } - }, [reconnectMcpServer, server.name, onComplete]) + }, [reconnectMcpServer, server.name, onComplete]); const handleClaudeAIClearAuthComplete = React.useCallback(async () => { await clearServerCache(server.name, { ...server.config, scope: server.scope, - }) + }); setAppState(prev => { const newClients = prev.mcp.clients.map(c => c.name === server.name ? { ...c, type: 'needs-auth' as const } : c, - ) - const newTools = excludeToolsByServer(prev.mcp.tools, server.name) - const newCommands = excludeCommandsByServer( - prev.mcp.commands, - server.name, - ) - const newResources = excludeResourcesByServer( - prev.mcp.resources, - server.name, - ) + ); + const newTools = excludeToolsByServer(prev.mcp.tools, server.name); + const newCommands = excludeCommandsByServer(prev.mcp.commands, server.name); + const newResources = excludeResourcesByServer(prev.mcp.resources, server.name); return { ...prev, @@ -185,176 +151,155 @@ export function MCPRemoteServerMenu({ commands: newCommands, resources: newResources, }, - } - }) + }; + }); - logEvent('tengu_claudeai_mcp_clear_auth_completed', {}) - onComplete?.(`Disconnected from ${server.name}.`) - setIsClaudeAIClearingAuth(false) - setClaudeAIClearAuthUrl(null) - setClaudeAIClearAuthBrowserOpened(false) - }, [server.name, server.config, server.scope, setAppState, onComplete]) + logEvent('tengu_claudeai_mcp_clear_auth_completed', {}); + onComplete?.(`Disconnected from ${server.name}.`); + setIsClaudeAIClearingAuth(false); + setClaudeAIClearAuthUrl(null); + setClaudeAIClearAuthBrowserOpened(false); + }, [server.name, server.config, server.scope, setAppState, onComplete]); // Escape to cancel authentication flow useKeybinding( 'confirm:no', () => { - authAbortControllerRef.current?.abort() - authAbortControllerRef.current = null - setIsAuthenticating(false) - setAuthorizationUrl(null) + authAbortControllerRef.current?.abort(); + authAbortControllerRef.current = null; + setIsAuthenticating(false); + setAuthorizationUrl(null); }, { context: 'Confirmation', isActive: isAuthenticating, }, - ) + ); // Escape to cancel Claude AI authentication useKeybinding( 'confirm:no', () => { - setIsClaudeAIAuthenticating(false) - setClaudeAIAuthUrl(null) + setIsClaudeAIAuthenticating(false); + setClaudeAIAuthUrl(null); }, { context: 'Confirmation', isActive: isClaudeAIAuthenticating, }, - ) + ); // Escape to cancel Claude AI clear auth useKeybinding( 'confirm:no', () => { - setIsClaudeAIClearingAuth(false) - setClaudeAIClearAuthUrl(null) - setClaudeAIClearAuthBrowserOpened(false) + setIsClaudeAIClearingAuth(false); + setClaudeAIClearAuthUrl(null); + setClaudeAIClearAuthBrowserOpened(false); }, { context: 'Confirmation', isActive: isClaudeAIClearingAuth, }, - ) + ); // Return key handling for authentication flows and 'c' to copy URL useInput((input, key) => { if (key.return && isClaudeAIAuthenticating) { - void handleClaudeAIAuthComplete() + void handleClaudeAIAuthComplete(); } if (key.return && isClaudeAIClearingAuth) { if (claudeAIClearAuthBrowserOpened) { - void handleClaudeAIClearAuthComplete() + void handleClaudeAIClearAuthComplete(); } else { // First Enter: open the browser - const connectorsUrl = `${getOauthConfig().CLAUDE_AI_ORIGIN}/settings/connectors` - setClaudeAIClearAuthUrl(connectorsUrl) - setClaudeAIClearAuthBrowserOpened(true) - void openBrowser(connectorsUrl) + const connectorsUrl = `${getOauthConfig().CLAUDE_AI_ORIGIN}/settings/connectors`; + setClaudeAIClearAuthUrl(connectorsUrl); + setClaudeAIClearAuthBrowserOpened(true); + void openBrowser(connectorsUrl); } } if (input === 'c' && !urlCopied) { - const urlToCopy = - authorizationUrl || claudeAIAuthUrl || claudeAIClearAuthUrl + const urlToCopy = authorizationUrl || claudeAIAuthUrl || claudeAIClearAuthUrl; if (urlToCopy) { void setClipboard(urlToCopy).then(raw => { - if (unmountedRef.current) return - if (raw) process.stdout.write(raw) - setUrlCopied(true) + if (unmountedRef.current) return; + if (raw) process.stdout.write(raw); + setUrlCopied(true); if (copyTimeoutRef.current !== undefined) { - clearTimeout(copyTimeoutRef.current) + clearTimeout(copyTimeoutRef.current); } - copyTimeoutRef.current = setTimeout(setUrlCopied, 2000, false) - }) + copyTimeoutRef.current = setTimeout(setUrlCopied, 2000, false); + }); } } - }) + }); - const capitalizedServerName = capitalize(String(server.name)) + const capitalizedServerName = capitalize(String(server.name)); // Count MCP prompts for this server (skills are shown in /skills, not here) - const serverCommandsCount = filterMcpPromptsByServer( - mcp.commands, - server.name, - ).length + const serverCommandsCount = filterMcpPromptsByServer(mcp.commands, server.name).length; - const toggleMcpServer = useMcpToggleEnabled() + const toggleMcpServer = useMcpToggleEnabled(); const handleClaudeAIAuth = React.useCallback(async () => { - const claudeAiBaseUrl = getOauthConfig().CLAUDE_AI_ORIGIN - const accountInfo = getOauthAccountInfo() - const orgUuid = accountInfo?.organizationUuid + const claudeAiBaseUrl = getOauthConfig().CLAUDE_AI_ORIGIN; + const accountInfo = getOauthAccountInfo(); + const orgUuid = accountInfo?.organizationUuid; - let authUrl: string - if ( - orgUuid && - server.config.type === 'claudeai-proxy' && - server.config.id - ) { + let authUrl: string; + if (orgUuid && server.config.type === 'claudeai-proxy' && server.config.id) { // Use the direct auth URL with org and server IDs // Replace 'mcprs' prefix with 'mcpsrv' if present - const serverId = server.config.id.startsWith('mcprs') - ? 'mcpsrv' + server.config.id.slice(5) - : server.config.id - const productSurface = encodeURIComponent( - process.env.CLAUDE_CODE_ENTRYPOINT || 'cli', - ) - authUrl = `${claudeAiBaseUrl}/api/organizations/${orgUuid}/mcp/start-auth/${serverId}?product_surface=${productSurface}` + const serverId = server.config.id.startsWith('mcprs') ? 'mcpsrv' + server.config.id.slice(5) : server.config.id; + const productSurface = encodeURIComponent(process.env.CLAUDE_CODE_ENTRYPOINT || 'cli'); + authUrl = `${claudeAiBaseUrl}/api/organizations/${orgUuid}/mcp/start-auth/${serverId}?product_surface=${productSurface}`; } else { // Fall back to settings/connectors if we don't have the required IDs - authUrl = `${claudeAiBaseUrl}/settings/connectors` + authUrl = `${claudeAiBaseUrl}/settings/connectors`; } - setClaudeAIAuthUrl(authUrl) - setIsClaudeAIAuthenticating(true) - logEvent('tengu_claudeai_mcp_auth_started', {}) - await openBrowser(authUrl) - }, [server.config]) + setClaudeAIAuthUrl(authUrl); + setIsClaudeAIAuthenticating(true); + logEvent('tengu_claudeai_mcp_auth_started', {}); + await openBrowser(authUrl); + }, [server.config]); const handleClaudeAIClearAuth = React.useCallback(() => { - setIsClaudeAIClearingAuth(true) - logEvent('tengu_claudeai_mcp_clear_auth_started', {}) - }, []) + setIsClaudeAIClearingAuth(true); + logEvent('tengu_claudeai_mcp_clear_auth_started', {}); + }, []); const handleToggleEnabled = React.useCallback(async () => { - const wasEnabled = server.client.type !== 'disabled' + const wasEnabled = server.client.type !== 'disabled'; try { - await toggleMcpServer(server.name) + await toggleMcpServer(server.name); if (server.config.type === 'claudeai-proxy') { logEvent('tengu_claudeai_mcp_toggle', { new_state: (wasEnabled ? 'disabled' : 'enabled') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + }); } // Return to the server list so user can continue managing other servers - onCancel() + onCancel(); } catch (err) { - const action = wasEnabled ? 'disable' : 'enable' - onComplete?.( - `Failed to ${action} MCP server '${server.name}': ${errorMessage(err)}`, - ) + const action = wasEnabled ? 'disable' : 'enable'; + onComplete?.(`Failed to ${action} MCP server '${server.name}': ${errorMessage(err)}`); } - }, [ - server.client.type, - server.config.type, - server.name, - toggleMcpServer, - onCancel, - onComplete, - ]) + }, [server.client.type, server.config.type, server.name, toggleMcpServer, onCancel, onComplete]); const handleAuthenticate = React.useCallback(async () => { - if (server.config.type === 'claudeai-proxy') return + if (server.config.type === 'claudeai-proxy') return; - setIsAuthenticating(true) - setError(null) + setIsAuthenticating(true); + setError(null); - const controller = new AbortController() - authAbortControllerRef.current = controller + const controller = new AbortController(); + authAbortControllerRef.current = controller; try { // Revoke existing tokens if re-authenticating, but preserve step-up @@ -362,97 +307,75 @@ export function MCPRemoteServerMenu({ if (server.isAuthenticated && server.config) { await revokeServerTokens(server.name, server.config, { preserveStepUpState: true, - }) + }); } if (server.config) { - await performMCPOAuthFlow( - server.name, - server.config, - setAuthorizationUrl, - controller.signal, - { - onWaitingForCallback: submit => { - setManualCallbackSubmit(() => submit) - }, + await performMCPOAuthFlow(server.name, server.config, setAuthorizationUrl, controller.signal, { + onWaitingForCallback: submit => { + setManualCallbackSubmit(() => submit); }, - ) + }); logEvent('tengu_mcp_auth_config_authenticate', { wasAuthenticated: server.isAuthenticated, - }) + }); - const result = await reconnectMcpServer(server.name) + const result = await reconnectMcpServer(server.name); if (result.client.type === 'connected') { const message = isEffectivelyAuthenticated ? `Authentication successful. Reconnected to ${server.name}.` - : `Authentication successful. Connected to ${server.name}.` - onComplete?.(message) + : `Authentication successful. Connected to ${server.name}.`; + onComplete?.(message); } else if (result.client.type === 'needs-auth') { onComplete?.( 'Authentication successful, but server still requires authentication. You may need to manually restart Claude Code.', - ) + ); } else { // result.client.type === 'failed' - logMCPDebug(server.name, `Reconnection failed after authentication`) + logMCPDebug(server.name, `Reconnection failed after authentication`); onComplete?.( 'Authentication successful, but server reconnection failed. You may need to manually restart Claude Code for the changes to take effect.', - ) + ); } } } catch (err) { // Don't show error if it was a cancellation - if ( - err instanceof Error && - !(err instanceof AuthenticationCancelledError) - ) { - setError(err.message) + if (err instanceof Error && !(err instanceof AuthenticationCancelledError)) { + setError(err.message); } } finally { - setIsAuthenticating(false) - authAbortControllerRef.current = null - setManualCallbackSubmit(null) - setCallbackUrlInput('') + setIsAuthenticating(false); + authAbortControllerRef.current = null; + setManualCallbackSubmit(null); + setCallbackUrlInput(''); } - }, [ - server.isAuthenticated, - server.config, - server.name, - onComplete, - reconnectMcpServer, - isEffectivelyAuthenticated, - ]) + }, [server.isAuthenticated, server.config, server.name, onComplete, reconnectMcpServer, isEffectivelyAuthenticated]); const handleClearAuth = async () => { - if (server.config.type === 'claudeai-proxy') return + if (server.config.type === 'claudeai-proxy') return; if (server.config) { // First revoke the authentication tokens and clear all auth state - await revokeServerTokens(server.name, server.config) - logEvent('tengu_mcp_auth_config_clear', {}) + await revokeServerTokens(server.name, server.config); + logEvent('tengu_mcp_auth_config_clear', {}); // Disconnect the client and clear the cache await clearServerCache(server.name, { ...server.config, scope: server.scope, - }) + }); // Update app state to remove the disconnected server's tools, commands, and resources setAppState(prev => { const newClients = prev.mcp.clients.map(c => // 'failed' is a misnomer here, but we don't really differentiate between "not connected" and "failed" at the moment c.name === server.name ? { ...c, type: 'failed' as const } : c, - ) - const newTools = excludeToolsByServer(prev.mcp.tools, server.name) - const newCommands = excludeCommandsByServer( - prev.mcp.commands, - server.name, - ) - const newResources = excludeResourcesByServer( - prev.mcp.resources, - server.name, - ) + ); + const newTools = excludeToolsByServer(prev.mcp.tools, server.name); + const newCommands = excludeCommandsByServer(prev.mcp.commands, server.name); + const newResources = excludeResourcesByServer(prev.mcp.resources, server.name); return { ...prev, @@ -463,12 +386,12 @@ export function MCPRemoteServerMenu({ commands: newCommands, resources: newResources, }, - } - }) + }; + }); - onComplete?.(`Authentication cleared for ${server.name}.`) + onComplete?.(`Authentication cleared for ${server.name}.`); } - } + }; if (isAuthenticating) { // XAA: silent exchange (cached id_token → no browser), so don't claim @@ -477,7 +400,7 @@ export function MCPRemoteServerMenu({ const authCopy = server.config.type !== 'claudeai-proxy' && server.config.oauth?.xaa ? ' Authenticating via your identity provider' - : ' A browser window will open for authentication' + : ' A browser window will open for authentication'; return ( Authenticating with {server.name}… @@ -488,10 +411,7 @@ export function MCPRemoteServerMenu({ {authorizationUrl && ( - - If your browser doesn't open automatically, copy this URL - manually{' '} - + If your browser doesn't open automatically, copy this URL manually {urlCopied ? ( (Copied!) ) : ( @@ -506,8 +426,7 @@ export function MCPRemoteServerMenu({ {isAuthenticating && authorizationUrl && manualCallbackSubmit && ( - If the redirect page shows a connection error, paste the URL from - your browser's address bar: + If the redirect page shows a connection error, paste the URL from your browser's address bar: URL {'>'} @@ -515,8 +434,8 @@ export function MCPRemoteServerMenu({ value={callbackUrlInput} onChange={setCallbackUrlInput} onSubmit={(value: string) => { - manualCallbackSubmit(value.trim()) - setCallbackUrlInput('') + manualCallbackSubmit(value.trim()); + setCallbackUrlInput(''); }} cursorOffset={callbackUrlCursorOffset} onChangeCursorOffset={setCallbackUrlCursorOffset} @@ -526,13 +445,10 @@ export function MCPRemoteServerMenu({ )} - - Return here after authenticating in your browser. Press Esc to go - back. - + Return here after authenticating in your browser. Press Esc to go back. - ) + ); } if (isClaudeAIAuthenticating) { @@ -546,10 +462,7 @@ export function MCPRemoteServerMenu({ {claudeAIAuthUrl && ( - - If your browser doesn't open automatically, copy this URL - manually{' '} - + If your browser doesn't open automatically, copy this URL manually {urlCopied ? ( (Copied!) ) : ( @@ -566,16 +479,11 @@ export function MCPRemoteServerMenu({ Press Enter after authenticating in your browser. - + - ) + ); } if (isClaudeAIClearingAuth) { @@ -584,17 +492,11 @@ export function MCPRemoteServerMenu({ Clear authentication for {server.name} {claudeAIClearAuthBrowserOpened ? ( <> - - Find the MCP server in the browser and click - "Disconnect". - + Find the MCP server in the browser and click "Disconnect". {claudeAIClearAuthUrl && ( - - If your browser didn't open automatically, copy this - URL manually{' '} - + If your browser didn't open automatically, copy this URL manually {urlCopied ? ( (Copied!) ) : ( @@ -623,8 +525,7 @@ export function MCPRemoteServerMenu({ ) : ( <> - This will open claude.ai in the browser. Find the MCP server in - the list and click "Disconnect". + This will open claude.ai in the browser. Find the MCP server in the list and click "Disconnect". @@ -642,7 +543,7 @@ export function MCPRemoteServerMenu({ )} - ) + ); } if (isReconnecting) { @@ -657,24 +558,24 @@ export function MCPRemoteServerMenu({ This may take a few moments. - ) + ); } - const menuOptions = [] + const menuOptions = []; // If server is disabled, show Enable first as the primary action if (server.client.type === 'disabled') { menuOptions.push({ label: 'Enable', value: 'toggle-enabled', - }) + }); } if (server.client.type === 'connected' && serverToolsCount > 0) { menuOptions.push({ label: 'View tools', value: 'tools', - }) + }); } if (server.config.type === 'claudeai-proxy') { @@ -682,30 +583,30 @@ export function MCPRemoteServerMenu({ menuOptions.push({ label: 'Clear authentication', value: 'claudeai-clear-auth', - }) + }); } else if (server.client.type !== 'disabled') { menuOptions.push({ label: 'Authenticate', value: 'claudeai-auth', - }) + }); } } else { if (isEffectivelyAuthenticated) { menuOptions.push({ label: 'Re-authenticate', value: 'reauth', - }) + }); menuOptions.push({ label: 'Clear authentication', value: 'clear-auth', - }) + }); } if (!isEffectivelyAuthenticated) { menuOptions.push({ label: 'Authenticate', value: 'auth', - }) + }); } } @@ -714,12 +615,12 @@ export function MCPRemoteServerMenu({ menuOptions.push({ label: 'Reconnect', value: 'reconnectMcpServer', - }) + }); } menuOptions.push({ label: 'Disable', value: 'toggle-enabled', - }) + }); } // If there are no other options, add a back option so Select handles escape @@ -727,16 +628,12 @@ export function MCPRemoteServerMenu({ menuOptions.push({ label: 'Back', value: 'back', - }) + }); } return ( - + {capitalizedServerName} MCP Server @@ -754,10 +651,7 @@ export function MCPRemoteServerMenu({ connecting… ) : server.client.type === 'needs-auth' ? ( - - {color('warning', theme)(figures.triangleUpOutline)} needs - authentication - + {color('warning', theme)(figures.triangleUpOutline)} needs authentication ) : ( {color('error', theme)(figures.cross)} failed )} @@ -767,13 +661,9 @@ export function MCPRemoteServerMenu({ Auth: {isEffectivelyAuthenticated ? ( - - {color('success', theme)(figures.tick)} authenticated - + {color('success', theme)(figures.tick)} authenticated ) : ( - - {color('error', theme)(figures.cross)} not authenticated - + {color('error', theme)(figures.cross)} not authenticated )} )} @@ -817,52 +707,49 @@ export function MCPRemoteServerMenu({ onChange={async value => { switch (value) { case 'tools': - onViewTools() - break + onViewTools(); + break; case 'auth': case 'reauth': - await handleAuthenticate() - break + await handleAuthenticate(); + break; case 'clear-auth': - await handleClearAuth() - break + await handleClearAuth(); + break; case 'claudeai-auth': - await handleClaudeAIAuth() - break + await handleClaudeAIAuth(); + break; case 'claudeai-clear-auth': - handleClaudeAIClearAuth() - break + handleClaudeAIClearAuth(); + break; case 'reconnectMcpServer': - setIsReconnecting(true) + setIsReconnecting(true); try { - const result = await reconnectMcpServer(server.name) + const result = await reconnectMcpServer(server.name); if (server.config.type === 'claudeai-proxy') { logEvent('tengu_claudeai_mcp_reconnect', { success: result.client.type === 'connected', - }) + }); } - const { message } = handleReconnectResult( - result, - server.name, - ) - onComplete?.(message) + const { message } = handleReconnectResult(result, server.name); + onComplete?.(message); } catch (err) { if (server.config.type === 'claudeai-proxy') { logEvent('tengu_claudeai_mcp_reconnect', { success: false, - }) + }); } - onComplete?.(handleReconnectError(err, server.name)) + onComplete?.(handleReconnectError(err, server.name)); } finally { - setIsReconnecting(false) + setIsReconnecting(false); } - break + break; case 'toggle-enabled': - await handleToggleEnabled() - break + await handleToggleEnabled(); + break; case 'back': - onCancel() - break + onCancel(); + break; } }} onCancel={onCancel} @@ -879,16 +766,11 @@ export function MCPRemoteServerMenu({ - + )} - ) + ); } diff --git a/src/components/mcp/MCPSettings.tsx b/src/components/mcp/MCPSettings.tsx index b350bf91e..3203d5b28 100644 --- a/src/components/mcp/MCPSettings.tsx +++ b/src/components/mcp/MCPSettings.tsx @@ -1,92 +1,79 @@ -import React, { useEffect, useMemo } from 'react' -import type { CommandResultDisplay } from '../../commands.js' -import { ClaudeAuthProvider } from '../../services/mcp/auth.js' +import React, { useEffect, useMemo } from 'react'; +import type { CommandResultDisplay } from '../../commands.js'; +import { ClaudeAuthProvider } from '../../services/mcp/auth.js'; import type { McpClaudeAIProxyServerConfig, McpHTTPServerConfig, McpSSEServerConfig, McpStdioServerConfig, -} from '../../services/mcp/types.js' -import { - extractAgentMcpServers, - filterToolsByServer, -} from '../../services/mcp/utils.js' -import { useAppState } from '../../state/AppState.js' -import { getSessionIngressAuthToken } from '../../utils/sessionIngressAuth.js' -import { MCPAgentServerMenu } from './MCPAgentServerMenu.js' -import { MCPListPanel } from './MCPListPanel.js' -import { MCPRemoteServerMenu } from './MCPRemoteServerMenu.js' -import { MCPStdioServerMenu } from './MCPStdioServerMenu.js' -import { MCPToolDetailView } from './MCPToolDetailView.js' -import { MCPToolListView } from './MCPToolListView.js' -import type { AgentMcpServerInfo, MCPViewState, ServerInfo } from './types.js' +} from '../../services/mcp/types.js'; +import { extractAgentMcpServers, filterToolsByServer } from '../../services/mcp/utils.js'; +import { useAppState } from '../../state/AppState.js'; +import { getSessionIngressAuthToken } from '../../utils/sessionIngressAuth.js'; +import { MCPAgentServerMenu } from './MCPAgentServerMenu.js'; +import { MCPListPanel } from './MCPListPanel.js'; +import { MCPRemoteServerMenu } from './MCPRemoteServerMenu.js'; +import { MCPStdioServerMenu } from './MCPStdioServerMenu.js'; +import { MCPToolDetailView } from './MCPToolDetailView.js'; +import { MCPToolListView } from './MCPToolListView.js'; +import type { AgentMcpServerInfo, MCPViewState, ServerInfo } from './types.js'; type Props = { - onComplete: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void -} + onComplete: (result?: string, options?: { display?: CommandResultDisplay }) => void; +}; export function MCPSettings({ onComplete }: Props): React.ReactNode { - const mcp = useAppState(s => s.mcp) - const agentDefinitions = useAppState(s => s.agentDefinitions) - const mcpClients = mcp.clients + const mcp = useAppState(s => s.mcp); + const agentDefinitions = useAppState(s => s.agentDefinitions); + const mcpClients = mcp.clients; const [viewState, setViewState] = React.useState({ type: 'list', - }) - const [servers, setServers] = React.useState([]) + }); + const [servers, setServers] = React.useState([]); // Extract agent-specific MCP servers from agent definitions const agentMcpServers = useMemo( () => extractAgentMcpServers(agentDefinitions.allAgents), [agentDefinitions.allAgents], - ) + ); const filteredClients = React.useMemo( - () => - mcpClients - .filter(client => client.name !== 'ide') - .sort((a, b) => a.name.localeCompare(b.name)), + () => mcpClients.filter(client => client.name !== 'ide').sort((a, b) => a.name.localeCompare(b.name)), [mcpClients], - ) + ); React.useEffect(() => { - let cancelled = false + let cancelled = false; async function prepareServers() { const serverInfos = await Promise.all( filteredClients.map(async client => { - const scope = client.config.scope - const isSSE = client.config.type === 'sse' - const isHTTP = client.config.type === 'http' - const isClaudeAIProxy = client.config.type === 'claudeai-proxy' - let isAuthenticated: boolean | undefined = undefined + const scope = client.config.scope; + const isSSE = client.config.type === 'sse'; + const isHTTP = client.config.type === 'http'; + const isClaudeAIProxy = client.config.type === 'claudeai-proxy'; + let isAuthenticated: boolean | undefined; if (isSSE || isHTTP) { const authProvider = new ClaudeAuthProvider( client.name, client.config as McpSSEServerConfig | McpHTTPServerConfig, - ) - const tokens = await authProvider.tokens() + ); + const tokens = await authProvider.tokens(); // Server is authenticated if: // 1. It has OAuth tokens, OR // 2. It's connected via session auth (has session token and is connected), OR // 3. It's connected and has tools (meaning it's working, regardless of auth method) - const hasSessionAuth = - getSessionIngressAuthToken() !== null && - client.type === 'connected' + const hasSessionAuth = getSessionIngressAuthToken() !== null && client.type === 'connected'; const hasToolsAndConnected = - client.type === 'connected' && - filterToolsByServer(mcp.tools, client.name).length > 0 - isAuthenticated = - Boolean(tokens) || hasSessionAuth || hasToolsAndConnected + client.type === 'connected' && filterToolsByServer(mcp.tools, client.name).length > 0; + isAuthenticated = Boolean(tokens) || hasSessionAuth || hasToolsAndConnected; } const baseInfo = { name: client.name, client, scope, - } + }; if (isClaudeAIProxy) { return { @@ -94,59 +81,54 @@ export function MCPSettings({ onComplete }: Props): React.ReactNode { transport: 'claudeai-proxy' as const, isAuthenticated: false, config: client.config as McpClaudeAIProxyServerConfig, - } + }; } else if (isSSE) { return { ...baseInfo, transport: 'sse' as const, isAuthenticated, config: client.config as McpSSEServerConfig, - } + }; } else if (isHTTP) { return { ...baseInfo, transport: 'http' as const, isAuthenticated, config: client.config as McpHTTPServerConfig, - } + }; } else { return { ...baseInfo, transport: 'stdio' as const, config: client.config as McpStdioServerConfig, - } + }; } }), - ) + ); - if (cancelled) return - setServers(serverInfos) + if (cancelled) return; + setServers(serverInfos); } - void prepareServers() + void prepareServers(); return () => { - cancelled = true - } - }, [filteredClients, mcp.tools]) + cancelled = true; + }; + }, [filteredClients, mcp.tools]); useEffect(() => { if (servers.length === 0 && filteredClients.length > 0) { // Still loading - return + return; } // Only show "no servers" message if no regular servers AND no agent servers if (servers.length === 0 && agentMcpServers.length === 0) { onComplete( 'No MCP servers configured. Please run /doctor if this is unexpected. Otherwise, run `claude mcp --help` or visit https://code.claude.com/docs/en/mcp to learn more.', - ) + ); } - }, [ - servers.length, - filteredClients.length, - agentMcpServers.length, - onComplete, - ]) + }, [servers.length, filteredClients.length, agentMcpServers.length, onComplete]); switch (viewState.type) { case 'list': @@ -154,49 +136,40 @@ export function MCPSettings({ onComplete }: Props): React.ReactNode { - setViewState({ type: 'server-menu', server }) - } + onSelectServer={server => setViewState({ type: 'server-menu', server })} onSelectAgentServer={(agentServer: AgentMcpServerInfo) => setViewState({ type: 'agent-server-menu', agentServer }) } onComplete={onComplete} defaultTab={viewState.defaultTab} /> - ) + ); case 'server-menu': { - const serverTools = filterToolsByServer(mcp.tools, viewState.server.name) + const serverTools = filterToolsByServer(mcp.tools, viewState.server.name); - const defaultTab = - viewState.server.transport === 'claudeai-proxy' - ? 'claude.ai' - : 'Claude Code' + const defaultTab = viewState.server.transport === 'claudeai-proxy' ? 'claude.ai' : 'Claude Code'; if (viewState.server.transport === 'stdio') { return ( - setViewState({ type: 'server-tools', server: viewState.server }) - } + onViewTools={() => setViewState({ type: 'server-tools', server: viewState.server })} onCancel={() => setViewState({ type: 'list', defaultTab })} onComplete={onComplete} /> - ) + ); } else { return ( - setViewState({ type: 'server-tools', server: viewState.server }) - } + onViewTools={() => setViewState({ type: 'server-tools', server: viewState.server })} onCancel={() => setViewState({ type: 'list', defaultTab })} onComplete={onComplete} /> - ) + ); } } @@ -211,28 +184,24 @@ export function MCPSettings({ onComplete }: Props): React.ReactNode { toolIndex: index, }) } - onBack={() => - setViewState({ type: 'server-menu', server: viewState.server }) - } + onBack={() => setViewState({ type: 'server-menu', server: viewState.server })} /> - ) + ); case 'server-tool-detail': { - const serverTools = filterToolsByServer(mcp.tools, viewState.server.name) - const tool = serverTools[viewState.toolIndex] + const serverTools = filterToolsByServer(mcp.tools, viewState.server.name); + const tool = serverTools[viewState.toolIndex]; if (!tool) { - setViewState({ type: 'server-tools', server: viewState.server }) - return null + setViewState({ type: 'server-tools', server: viewState.server }); + return null; } return ( - setViewState({ type: 'server-tools', server: viewState.server }) - } + onBack={() => setViewState({ type: 'server-tools', server: viewState.server })} /> - ) + ); } case 'agent-server-menu': @@ -242,6 +211,6 @@ export function MCPSettings({ onComplete }: Props): React.ReactNode { onCancel={() => setViewState({ type: 'list', defaultTab: 'Agents' })} onComplete={onComplete} /> - ) + ); } } diff --git a/src/components/mcp/MCPStdioServerMenu.tsx b/src/components/mcp/MCPStdioServerMenu.tsx index 4132cdfe7..5f74239ac 100644 --- a/src/components/mcp/MCPStdioServerMenu.tsx +++ b/src/components/mcp/MCPStdioServerMenu.tsx @@ -1,42 +1,30 @@ -import figures from 'figures' -import React, { useState } from 'react' -import type { CommandResultDisplay } from '../../commands.js' -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' -import { Box, color, Text, useTheme } from '@anthropic/ink' -import { getMcpConfigByName } from '../../services/mcp/config.js' -import { - useMcpReconnect, - useMcpToggleEnabled, -} from '../../services/mcp/MCPConnectionManager.js' -import { - describeMcpConfigFilePath, - filterMcpPromptsByServer, -} from '../../services/mcp/utils.js' -import { useAppState } from '../../state/AppState.js' -import { errorMessage } from '../../utils/errors.js' -import { capitalize } from '../../utils/stringUtils.js' -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' -import { Select } from '../CustomSelect/index.js' -import { Byline, KeyboardShortcutHint } from '@anthropic/ink' -import { Spinner } from '../Spinner.js' -import { CapabilitiesSection } from './CapabilitiesSection.js' -import type { StdioServerInfo } from './types.js' -import { - handleReconnectError, - handleReconnectResult, -} from './utils/reconnectHelpers.js' +import figures from 'figures'; +import React, { useState } from 'react'; +import type { CommandResultDisplay } from '../../commands.js'; +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; +import { Box, color, Text, useTheme } from '@anthropic/ink'; +import { getMcpConfigByName } from '../../services/mcp/config.js'; +import { useMcpReconnect, useMcpToggleEnabled } from '../../services/mcp/MCPConnectionManager.js'; +import { describeMcpConfigFilePath, filterMcpPromptsByServer } from '../../services/mcp/utils.js'; +import { useAppState } from '../../state/AppState.js'; +import { errorMessage } from '../../utils/errors.js'; +import { capitalize } from '../../utils/stringUtils.js'; +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; +import { Select } from '../CustomSelect/index.js'; +import { Byline, KeyboardShortcutHint } from '@anthropic/ink'; +import { Spinner } from '../Spinner.js'; +import { CapabilitiesSection } from './CapabilitiesSection.js'; +import type { StdioServerInfo } from './types.js'; +import { handleReconnectError, handleReconnectResult } from './utils/reconnectHelpers.js'; type Props = { - server: StdioServerInfo - serverToolsCount: number - onViewTools: () => void - onCancel: () => void - onComplete: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void - borderless?: boolean -} + server: StdioServerInfo; + serverToolsCount: number; + onViewTools: () => void; + onCancel: () => void; + onComplete: (result?: string, options?: { display?: CommandResultDisplay }) => void; + borderless?: boolean; +}; export function MCPStdioServerMenu({ server, @@ -46,44 +34,39 @@ export function MCPStdioServerMenu({ onComplete, borderless = false, }: Props): React.ReactNode { - const [theme] = useTheme() - const exitState = useExitOnCtrlCDWithKeybindings() - const mcp = useAppState(s => s.mcp) - const reconnectMcpServer = useMcpReconnect() - const toggleMcpServer = useMcpToggleEnabled() - const [isReconnecting, setIsReconnecting] = useState(false) + const [theme] = useTheme(); + const exitState = useExitOnCtrlCDWithKeybindings(); + const mcp = useAppState(s => s.mcp); + const reconnectMcpServer = useMcpReconnect(); + const toggleMcpServer = useMcpToggleEnabled(); + const [isReconnecting, setIsReconnecting] = useState(false); const handleToggleEnabled = React.useCallback(async () => { - const wasEnabled = server.client.type !== 'disabled' + const wasEnabled = server.client.type !== 'disabled'; try { - await toggleMcpServer(server.name) + await toggleMcpServer(server.name); // Return to the server list so user can continue managing other servers - onCancel() + onCancel(); } catch (err) { - const action = wasEnabled ? 'disable' : 'enable' - onComplete( - `Failed to ${action} MCP server '${server.name}': ${errorMessage(err)}`, - ) + const action = wasEnabled ? 'disable' : 'enable'; + onComplete(`Failed to ${action} MCP server '${server.name}': ${errorMessage(err)}`); } - }, [server.client.type, server.name, toggleMcpServer, onCancel, onComplete]) + }, [server.client.type, server.name, toggleMcpServer, onCancel, onComplete]); - const capitalizedServerName = capitalize(String(server.name)) + const capitalizedServerName = capitalize(String(server.name)); // Count MCP prompts for this server (skills are shown in /skills, not here) - const serverCommandsCount = filterMcpPromptsByServer( - mcp.commands, - server.name, - ).length + const serverCommandsCount = filterMcpPromptsByServer(mcp.commands, server.name).length; - const menuOptions = [] + const menuOptions = []; // Only show "View tools" if server is not disabled and has tools if (server.client.type !== 'disabled' && serverToolsCount > 0) { menuOptions.push({ label: 'View tools', value: 'tools', - }) + }); } // Only show reconnect option if the server is not disabled @@ -91,20 +74,20 @@ export function MCPStdioServerMenu({ menuOptions.push({ label: 'Reconnect', value: 'reconnectMcpServer', - }) + }); } menuOptions.push({ label: server.client.type !== 'disabled' ? 'Disable' : 'Enable', value: 'toggle-enabled', - }) + }); // If there are no other options, add a back option so Select handles escape if (menuOptions.length === 0) { menuOptions.push({ label: 'Back', value: 'back', - }) + }); } if (isReconnecting) { @@ -119,16 +102,12 @@ export function MCPStdioServerMenu({ This may take a few moments. - ) + ); } return ( - + {capitalizedServerName} MCP Server @@ -164,11 +143,7 @@ export function MCPStdioServerMenu({ Config location: - - {describeMcpConfigFilePath( - getMcpConfigByName(server.name)?.scope ?? 'dynamic', - )} - + {describeMcpConfigFilePath(getMcpConfigByName(server.name)?.scope ?? 'dynamic')} {server.client.type === 'connected' && ( @@ -193,25 +168,22 @@ export function MCPStdioServerMenu({ options={menuOptions} onChange={async value => { if (value === 'tools') { - onViewTools() + onViewTools(); } else if (value === 'reconnectMcpServer') { - setIsReconnecting(true) + setIsReconnecting(true); try { - const result = await reconnectMcpServer(server.name) - const { message } = handleReconnectResult( - result, - server.name, - ) - onComplete?.(message) + const result = await reconnectMcpServer(server.name); + const { message } = handleReconnectResult(result, server.name); + onComplete?.(message); } catch (err) { - onComplete?.(handleReconnectError(err, server.name)) + onComplete?.(handleReconnectError(err, server.name)); } finally { - setIsReconnecting(false) + setIsReconnecting(false); } } else if (value === 'toggle-enabled') { - await handleToggleEnabled() + await handleToggleEnabled(); } else if (value === 'back') { - onCancel() + onCancel(); } }} onCancel={onCancel} @@ -228,16 +200,11 @@ export function MCPStdioServerMenu({ - + )} - ) + ); } diff --git a/src/components/mcp/MCPToolDetailView.tsx b/src/components/mcp/MCPToolDetailView.tsx index 6b0b60173..99939b352 100644 --- a/src/components/mcp/MCPToolDetailView.tsx +++ b/src/components/mcp/MCPToolDetailView.tsx @@ -1,36 +1,27 @@ -import React from 'react' -import { Box, Text } from '@anthropic/ink' -import { - extractMcpToolDisplayName, - getMcpDisplayName, -} from '../../services/mcp/mcpStringUtils.js' -import type { Tool } from '../../Tool.js' -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' -import { Dialog } from '@anthropic/ink' -import type { ServerInfo } from './types.js' +import React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { extractMcpToolDisplayName, getMcpDisplayName } from '../../services/mcp/mcpStringUtils.js'; +import type { Tool } from '../../Tool.js'; +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; +import { Dialog } from '@anthropic/ink'; +import type { ServerInfo } from './types.js'; type Props = { - tool: Tool - server: ServerInfo - onBack: () => void -} + tool: Tool; + server: ServerInfo; + onBack: () => void; +}; -export function MCPToolDetailView({ - tool, - server, - onBack, -}: Props): React.ReactNode { - const [toolDescription, setToolDescription] = React.useState('') +export function MCPToolDetailView({ tool, server, onBack }: Props): React.ReactNode { + const [toolDescription, setToolDescription] = React.useState(''); - const toolName = getMcpDisplayName(tool.name, server.name) - const fullDisplayName = tool.userFacingName - ? tool.userFacingName({}) - : toolName - const displayName = extractMcpToolDisplayName(fullDisplayName) + const toolName = getMcpDisplayName(tool.name, server.name); + const fullDisplayName = tool.userFacingName ? tool.userFacingName({}) : toolName; + const displayName = extractMcpToolDisplayName(fullDisplayName); - const isReadOnly = tool.isReadOnly?.({}) ?? false - const isDestructive = tool.isDestructive?.({}) ?? false - const isOpenWorld = tool.isOpenWorld?.({}) ?? false + const isReadOnly = tool.isReadOnly?.({}) ?? false; + const isDestructive = tool.isDestructive?.({}) ?? false; + const isOpenWorld = tool.isOpenWorld?.({}) ?? false; React.useEffect(() => { async function loadDescription() { @@ -49,14 +40,14 @@ export function MCPToolDetailView({ }, tools: [], }, - ) - setToolDescription(desc) + ); + setToolDescription(desc); } catch { - setToolDescription('Failed to load description') + setToolDescription('Failed to load description'); } } - void loadDescription() - }, [tool]) + void loadDescription(); + }, [tool]); const titleContent = ( <> @@ -65,7 +56,7 @@ export function MCPToolDetailView({ {isDestructive && [destructive]} {isOpenWorld && [open-world]} - ) + ); return ( Press {exitState.keyName} again to exit ) : ( - + ) } > @@ -109,34 +95,26 @@ export function MCPToolDetailView({ Parameters: - {Object.entries(tool.inputJSONSchema.properties).map( - ([key, value]) => { - const required = tool.inputJSONSchema?.required as - | string[] - | undefined - const isRequired = required?.includes(key) - return ( - - • {key} - {isRequired && (required)}:{' '} - - {typeof value === 'object' && value && 'type' in value - ? String(value.type) - : 'unknown'} - - {typeof value === 'object' && - value && - 'description' in value && ( - - {String(value.description)} - )} + {Object.entries(tool.inputJSONSchema.properties).map(([key, value]) => { + const required = tool.inputJSONSchema?.required as string[] | undefined; + const isRequired = required?.includes(key); + return ( + + • {key} + {isRequired && (required)}:{' '} + + {typeof value === 'object' && value && 'type' in value ? String(value.type) : 'unknown'} - ) - }, - )} + {typeof value === 'object' && value && 'description' in value && ( + - {String(value.description)} + )} + + ); + })} )} - ) + ); } diff --git a/src/components/mcp/MCPToolListView.tsx b/src/components/mcp/MCPToolListView.tsx index ad3eb3695..703af905c 100644 --- a/src/components/mcp/MCPToolListView.tsx +++ b/src/components/mcp/MCPToolListView.tsx @@ -1,64 +1,51 @@ -import React from 'react' -import { Text } from '@anthropic/ink' -import { - extractMcpToolDisplayName, - getMcpDisplayName, -} from '../../services/mcp/mcpStringUtils.js' -import { filterToolsByServer } from '../../services/mcp/utils.js' -import { useAppState } from '../../state/AppState.js' -import type { Tool } from '../../Tool.js' -import { plural } from '../../utils/stringUtils.js' -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' -import { Select } from '../CustomSelect/index.js' -import { Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink' -import type { ServerInfo } from './types.js' +import React from 'react'; +import { Text } from '@anthropic/ink'; +import { extractMcpToolDisplayName, getMcpDisplayName } from '../../services/mcp/mcpStringUtils.js'; +import { filterToolsByServer } from '../../services/mcp/utils.js'; +import { useAppState } from '../../state/AppState.js'; +import type { Tool } from '../../Tool.js'; +import { plural } from '../../utils/stringUtils.js'; +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; +import { Select } from '../CustomSelect/index.js'; +import { Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink'; +import type { ServerInfo } from './types.js'; type Props = { - server: ServerInfo - onSelectTool: (tool: Tool, index: number) => void - onBack: () => void -} + server: ServerInfo; + onSelectTool: (tool: Tool, index: number) => void; + onBack: () => void; +}; -export function MCPToolListView({ - server, - onSelectTool, - onBack, -}: Props): React.ReactNode { - const mcpTools = useAppState(s => s.mcp.tools) +export function MCPToolListView({ server, onSelectTool, onBack }: Props): React.ReactNode { + const mcpTools = useAppState(s => s.mcp.tools); const serverTools = React.useMemo(() => { - if (server.client.type !== 'connected') return [] - return filterToolsByServer(mcpTools, server.name) - }, [server, mcpTools]) + if (server.client.type !== 'connected') return []; + return filterToolsByServer(mcpTools, server.name); + }, [server, mcpTools]); const toolOptions = serverTools.map((tool, index) => { - const toolName = getMcpDisplayName(tool.name, server.name) - const fullDisplayName = tool.userFacingName - ? tool.userFacingName({}) - : toolName + const toolName = getMcpDisplayName(tool.name, server.name); + const fullDisplayName = tool.userFacingName ? tool.userFacingName({}) : toolName; // Extract just the tool display name without server prefix - const displayName = extractMcpToolDisplayName(fullDisplayName) + const displayName = extractMcpToolDisplayName(fullDisplayName); - const isReadOnly = tool.isReadOnly?.({}) ?? false - const isDestructive = tool.isDestructive?.({}) ?? false - const isOpenWorld = tool.isOpenWorld?.({}) ?? false + const isReadOnly = tool.isReadOnly?.({}) ?? false; + const isDestructive = tool.isDestructive?.({}) ?? false; + const isOpenWorld = tool.isOpenWorld?.({}) ?? false; - const annotations = [] - if (isReadOnly) annotations.push('read-only') - if (isDestructive) annotations.push('destructive') - if (isOpenWorld) annotations.push('open-world') + const annotations = []; + if (isReadOnly) annotations.push('read-only'); + if (isDestructive) annotations.push('destructive'); + if (isOpenWorld) annotations.push('open-world'); return { label: displayName, value: index.toString(), description: annotations.length > 0 ? annotations.join(', ') : undefined, - descriptionColor: isDestructive - ? 'error' - : isReadOnly - ? 'success' - : undefined, - } - }) + descriptionColor: isDestructive ? 'error' : isReadOnly ? 'success' : undefined, + }; + }); return ( - + ) } @@ -88,15 +70,15 @@ export function MCPToolListView({ { - onUpdateQuestionState( - questionText, - { selectedValue: value }, - false, - ) - const textInput = - value === '__other__' - ? questionStates[questionText]?.textInputValue - : undefined - onAnswer(questionText, value, textInput) + onUpdateQuestionState(questionText, { selectedValue: value }, false); + const textInput = value === '__other__' ? questionStates[questionText]?.textInputValue : undefined; + onAnswer(questionText, value, textInput); }} onFocus={handleFocus} onCancel={onCancel} @@ -346,13 +291,7 @@ export function QuestionView({ ) : ( )} - + {options.length + 1}. Chat about this @@ -363,13 +302,7 @@ export function QuestionView({ ) : ( )} - + {options.length + 2}. Skip interview and plan immediately @@ -385,14 +318,11 @@ export function QuestionView({ ) : ( 'Tab/Arrow keys to navigate' )} - {isOtherFocused && editorName && ( - <> · ctrl+g to edit in {editorName} - )}{' '} - · Esc to cancel + {isOtherFocused && editorName && <> · ctrl+g to edit in {editorName}} · Esc to cancel - ) + ); } diff --git a/src/components/permissions/AskUserQuestionPermissionRequest/SubmitQuestionsView.tsx b/src/components/permissions/AskUserQuestionPermissionRequest/SubmitQuestionsView.tsx index 8bb6757b8..2864c14d4 100644 --- a/src/components/permissions/AskUserQuestionPermissionRequest/SubmitQuestionsView.tsx +++ b/src/components/permissions/AskUserQuestionPermissionRequest/SubmitQuestionsView.tsx @@ -1,23 +1,23 @@ -import figures from 'figures' -import React from 'react' -import { Box, Text } from '@anthropic/ink' -import type { Question } from '@claude-code-best/builtin-tools/tools/AskUserQuestionTool/AskUserQuestionTool.js' -import type { PermissionDecision } from '../../../utils/permissions/PermissionResult.js' -import { Select } from '../../CustomSelect/index.js' -import { Divider } from '@anthropic/ink' -import { PermissionRequestTitle } from '../PermissionRequestTitle.js' -import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js' -import { QuestionNavigationBar } from './QuestionNavigationBar.js' +import figures from 'figures'; +import React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import type { Question } from '@claude-code-best/builtin-tools/tools/AskUserQuestionTool/AskUserQuestionTool.js'; +import type { PermissionDecision } from '../../../utils/permissions/PermissionResult.js'; +import { Select } from '../../CustomSelect/index.js'; +import { Divider } from '@anthropic/ink'; +import { PermissionRequestTitle } from '../PermissionRequestTitle.js'; +import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js'; +import { QuestionNavigationBar } from './QuestionNavigationBar.js'; type Props = { - questions: Question[] - currentQuestionIndex: number - answers: Record - allQuestionsAnswered: boolean - permissionResult: PermissionDecision - minContentHeight?: number - onFinalResponse: (value: 'submit' | 'cancel') => void -} + questions: Question[]; + currentQuestionIndex: number; + answers: Record; + allQuestionsAnswered: boolean; + permissionResult: PermissionDecision; + minContentHeight?: number; + onFinalResponse: (value: 'submit' | 'cancel') => void; +}; export function SubmitQuestionsView({ questions, @@ -31,24 +31,13 @@ export function SubmitQuestionsView({ return ( - - + + {!allQuestionsAnswered && ( - - {figures.warning} You have not answered all questions - + {figures.warning} You have not answered all questions )} {Object.keys(answers).length > 0 && ( @@ -56,14 +45,10 @@ export function SubmitQuestionsView({ {questions .filter((q: Question) => q?.question && answers[q.question]) .map((q: Question) => { - const answer = answers[q?.question] + const answer = answers[q?.question]; return ( - + {figures.bullet} {q?.question || 'Question'} @@ -73,15 +58,12 @@ export function SubmitQuestionsView({ - ) + ); })} )} - + Ready to submit your answers? handleReject()} @@ -595,18 +515,14 @@ function BashPermissionRequestInner({ Esc to cancel - {((focusedOption === 'yes' && !yesInputMode) || - (focusedOption === 'no' && !noInputMode)) && + {((focusedOption === 'yes' && !yesInputMode) || (focusedOption === 'no' && !noInputMode)) && ' · Tab to amend'} - {explainerState.enabled && - ` · ctrl+e to ${explainerState.visible ? 'hide' : 'explain'}`} + {explainerState.enabled && ` · ctrl+e to ${explainerState.visible ? 'hide' : 'explain'}`} - {toolUseContext.options.debug && ( - Ctrl+d to show debug info - )} + {toolUseContext.options.debug && Ctrl+d to show debug info} )} - ) + ); } diff --git a/src/components/permissions/BashPermissionRequest/bashToolUseOptions.tsx b/src/components/permissions/BashPermissionRequest/bashToolUseOptions.tsx index b6a073ad0..7a485ad9a 100644 --- a/src/components/permissions/BashPermissionRequest/bashToolUseOptions.tsx +++ b/src/components/permissions/BashPermissionRequest/bashToolUseOptions.tsx @@ -1,41 +1,35 @@ -import { BASH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/BashTool/toolName.js' -import { extractOutputRedirections } from '../../../utils/bash/commands.js' -import { isClassifierPermissionsEnabled } from '../../../utils/permissions/bashClassifier.js' -import type { PermissionDecisionReason } from '../../../utils/permissions/PermissionResult.js' -import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js' -import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js' -import type { OptionWithDescription } from '../../CustomSelect/select.js' -import { generateShellSuggestionsLabel } from '../shellPermissionHelpers.js' +import { BASH_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/BashTool/toolName.js'; +import { extractOutputRedirections } from '../../../utils/bash/commands.js'; +import { isClassifierPermissionsEnabled } from '../../../utils/permissions/bashClassifier.js'; +import type { PermissionDecisionReason } from '../../../utils/permissions/PermissionResult.js'; +import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js'; +import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js'; +import type { OptionWithDescription } from '../../CustomSelect/select.js'; +import { generateShellSuggestionsLabel } from '../shellPermissionHelpers.js'; export type BashToolUseOption = | 'yes' | 'yes-apply-suggestions' | 'yes-prefix-edited' | 'yes-classifier-reviewed' - | 'no' + | 'no'; /** * Check if a description already exists in the allow list. * Compares lowercase and trailing-whitespace-trimmed versions. */ -function descriptionAlreadyExists( - description: string, - existingDescriptions: string[], -): boolean { - const normalized = description.toLowerCase().trimEnd() - return existingDescriptions.some( - existing => existing.toLowerCase().trimEnd() === normalized, - ) +function descriptionAlreadyExists(description: string, existingDescriptions: string[]): boolean { + const normalized = description.toLowerCase().trimEnd(); + return existingDescriptions.some(existing => existing.toLowerCase().trimEnd() === normalized); } /** * Strip output redirections so filenames don't show as commands in the label. */ function stripBashRedirections(command: string): string { - const { commandWithoutRedirections, redirections } = - extractOutputRedirections(command) + const { commandWithoutRedirections, redirections } = extractOutputRedirections(command); // Only use stripped version if there were actual redirections - return redirections.length > 0 ? commandWithoutRedirections : command + return redirections.length > 0 ? commandWithoutRedirections : command; } export function bashToolUseOptions({ @@ -52,23 +46,23 @@ export function bashToolUseOptions({ editablePrefix, onEditablePrefixChange, }: { - suggestions?: PermissionUpdate[] - decisionReason?: PermissionDecisionReason - onRejectFeedbackChange: (value: string) => void - onAcceptFeedbackChange: (value: string) => void - onClassifierDescriptionChange?: (value: string) => void - classifierDescription?: string + suggestions?: PermissionUpdate[]; + decisionReason?: PermissionDecisionReason; + onRejectFeedbackChange: (value: string) => void; + onAcceptFeedbackChange: (value: string) => void; + onClassifierDescriptionChange?: (value: string) => void; + classifierDescription?: string; /** Whether the initial classifier description was empty. When true, hides the option. */ - initialClassifierDescriptionEmpty?: boolean - existingAllowDescriptions?: string[] - yesInputMode?: boolean - noInputMode?: boolean + initialClassifierDescriptionEmpty?: boolean; + existingAllowDescriptions?: string[]; + yesInputMode?: boolean; + noInputMode?: boolean; /** Editable prefix rule content (e.g., "npm run:*"). When set, replaces Haiku-based suggestions. */ - editablePrefix?: string + editablePrefix?: string; /** Callback when the user edits the prefix value. */ - onEditablePrefixChange?: (value: string) => void + onEditablePrefixChange?: (value: string) => void; }): OptionWithDescription[] { - const options: OptionWithDescription[] = [] + const options: OptionWithDescription[] = []; if (yesInputMode) { options.push({ @@ -78,12 +72,12 @@ export function bashToolUseOptions({ placeholder: 'and tell Claude what to do next', onChange: onAcceptFeedbackChange, allowEmptySubmitToCancel: true, - }) + }); } else { options.push({ label: 'Yes', value: 'yes', - }) + }); } // Only show "always allow" options when not restricted by allowManagedPermissionRulesOnly @@ -93,17 +87,9 @@ export function bashToolUseOptions({ // don't contain non-Bash items (addDirectories, Read rules) that // the editable prefix can't represent. const hasNonBashSuggestions = suggestions.some( - s => - s.type === 'addDirectories' || - (s.type === 'addRules' && - s.rules?.some(r => r.toolName !== BASH_TOOL_NAME)), - ) - if ( - editablePrefix !== undefined && - onEditablePrefixChange && - !hasNonBashSuggestions && - suggestions.length > 0 - ) { + s => s.type === 'addDirectories' || (s.type === 'addRules' && s.rules?.some(r => r.toolName !== BASH_TOOL_NAME)), + ); + if (editablePrefix !== undefined && onEditablePrefixChange && !hasNonBashSuggestions && suggestions.length > 0) { options.push({ type: 'input', label: 'Yes, and don\u2019t ask again for', @@ -115,19 +101,15 @@ export function bashToolUseOptions({ showLabelWithValue: true, labelValueSeparator: ': ', resetCursorOnUpdate: true, - }) + }); } else if (suggestions.length > 0) { - const label = generateShellSuggestionsLabel( - suggestions, - BASH_TOOL_NAME, - stripBashRedirections, - ) + const label = generateShellSuggestionsLabel(suggestions, BASH_TOOL_NAME, stripBashRedirections); if (label) { options.push({ label, value: 'yes-apply-suggestions', - }) + }); } } @@ -137,19 +119,14 @@ export function bashToolUseOptions({ // (prompt-based rules don't help when the server-side classifier triggers first). // Skip when the editable prefix option is already shown — they serve the // same role and having two identical-looking "don't ask again" inputs is confusing. - const editablePrefixShown = options.some( - o => o.value === 'yes-prefix-edited', - ) + const editablePrefixShown = options.some(o => o.value === 'yes-prefix-edited'); if ( process.env.USER_TYPE === 'ant' && !editablePrefixShown && isClassifierPermissionsEnabled() && onClassifierDescriptionChange && !initialClassifierDescriptionEmpty && - !descriptionAlreadyExists( - classifierDescription ?? '', - existingAllowDescriptions, - ) && + !descriptionAlreadyExists(classifierDescription ?? '', existingAllowDescriptions) && decisionReason?.type !== 'classifier' ) { options.push({ @@ -163,7 +140,7 @@ export function bashToolUseOptions({ showLabelWithValue: true, labelValueSeparator: ': ', resetCursorOnUpdate: true, - }) + }); } } @@ -175,13 +152,13 @@ export function bashToolUseOptions({ placeholder: 'and tell Claude what to do differently', onChange: onRejectFeedbackChange, allowEmptySubmitToCancel: true, - }) + }); } else { options.push({ label: 'No', value: 'no', - }) + }); } - return options + return options; } diff --git a/src/components/permissions/ComputerUseApproval/ComputerUseApproval.tsx b/src/components/permissions/ComputerUseApproval/ComputerUseApproval.tsx index 6eecf1be3..9e554b5a3 100644 --- a/src/components/permissions/ComputerUseApproval/ComputerUseApproval.tsx +++ b/src/components/permissions/ComputerUseApproval/ComputerUseApproval.tsx @@ -1,29 +1,26 @@ -import { getSentinelCategory } from '@ant/computer-use-mcp/sentinelApps' -import type { - CuPermissionRequest, - CuPermissionResponse, -} from '@ant/computer-use-mcp/types' -import { DEFAULT_GRANT_FLAGS } from '@ant/computer-use-mcp/types' -import figures from 'figures' -import * as React from 'react' -import { useMemo, useState } from 'react' -import { Box, Text } from '@anthropic/ink' -import { execFileNoThrow } from '../../../utils/execFileNoThrow.js' -import { plural } from '../../../utils/stringUtils.js' -import type { OptionWithDescription } from '../../CustomSelect/select.js' -import { Select } from '../../CustomSelect/select.js' -import { Dialog } from '@anthropic/ink' +import { getSentinelCategory } from '@ant/computer-use-mcp/sentinelApps'; +import type { CuPermissionRequest, CuPermissionResponse } from '@ant/computer-use-mcp/types'; +import { DEFAULT_GRANT_FLAGS } from '@ant/computer-use-mcp/types'; +import figures from 'figures'; +import * as React from 'react'; +import { useMemo, useState } from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { execFileNoThrow } from '../../../utils/execFileNoThrow.js'; +import { plural } from '../../../utils/stringUtils.js'; +import type { OptionWithDescription } from '../../CustomSelect/select.js'; +import { Select } from '../../CustomSelect/select.js'; +import { Dialog } from '@anthropic/ink'; type ComputerUseApprovalProps = { - request: CuPermissionRequest - onDone: (response: CuPermissionResponse) => void -} + request: CuPermissionRequest; + onDone: (response: CuPermissionResponse) => void; +}; const DENY_ALL_RESPONSE: CuPermissionResponse = { granted: [], denied: [], flags: DEFAULT_GRANT_FLAGS, -} +}; /** * Two-panel dispatcher. When `request.tccState` is present, macOS permissions @@ -31,74 +28,64 @@ const DENY_ALL_RESPONSE: CuPermissionResponse = { * irrelevant — show a TCC panel that opens System Settings. Otherwise show the * app allowlist + grant-flags panel. */ -export function ComputerUseApproval({ - request, - onDone, -}: ComputerUseApprovalProps): React.ReactNode { +export function ComputerUseApproval({ request, onDone }: ComputerUseApprovalProps): React.ReactNode { return request.tccState ? ( - onDone(DENY_ALL_RESPONSE)} - /> + onDone(DENY_ALL_RESPONSE)} /> ) : ( - ) + ); } // ── TCC panel ───────────────────────────────────────────────────────────── -type TccOption = 'open_accessibility' | 'open_screen_recording' | 'retry' +type TccOption = 'open_accessibility' | 'open_screen_recording' | 'retry'; function ComputerUseTccPanel({ tccState, onDone, }: { - tccState: NonNullable - onDone: () => void + tccState: NonNullable; + onDone: () => void; }): React.ReactNode { const options = useMemo[]>(() => { - const opts: OptionWithDescription[] = [] + const opts: OptionWithDescription[] = []; if (!tccState.accessibility) { opts.push({ label: 'Open System Settings → Accessibility', value: 'open_accessibility', - }) + }); } if (!tccState.screenRecording) { opts.push({ label: 'Open System Settings → Screen Recording', value: 'open_screen_recording', - }) + }); } - opts.push({ label: 'Try again', value: 'retry' }) - return opts - }, [tccState.accessibility, tccState.screenRecording]) + opts.push({ label: 'Try again', value: 'retry' }); + return opts; + }, [tccState.accessibility, tccState.screenRecording]); function onChange(value: TccOption): void { switch (value) { case 'open_accessibility': void execFileNoThrow( 'open', - [ - 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility', - ], + ['x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility'], { useCwd: false }, - ) - return + ); + return; case 'open_screen_recording': void execFileNoThrow( 'open', - [ - 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture', - ], + ['x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture'], { useCwd: false }, - ) - return + ); + return; case 'retry': // Resolve with deny-all — the model re-calls request_access, which // re-checks TCC and renders the app list if now granted. - onDone() - return + onDone(); + return; } } @@ -107,69 +94,47 @@ function ComputerUseTccPanel({ - Accessibility:{' '} - {tccState.accessibility - ? `${figures.tick} granted` - : `${figures.cross} not granted`} + Accessibility: {tccState.accessibility ? `${figures.tick} granted` : `${figures.cross} not granted`} - Screen Recording:{' '} - {tccState.screenRecording - ? `${figures.tick} granted` - : `${figures.cross} not granted`} + Screen Recording: {tccState.screenRecording ? `${figures.tick} granted` : `${figures.cross} not granted`} - Grant the missing permissions in System Settings, then select - "Try again". macOS may require you to restart Claude Code - after granting Screen Recording. + Grant the missing permissions in System Settings, then select "Try again". macOS may require you to + restart Claude Code after granting Screen Recording. respond(v === 'allow_all')} - onCancel={() => respond(false)} - /> + {editorName} - {isV2 && planFilePath && ( - · {getDisplayPath(planFilePath)} - )} + {isV2 && planFilePath && · {getDisplayPath(planFilePath)}} {showSaveMessage && ( @@ -927,7 +785,7 @@ export function ExitPlanModePermissionRequest({ )} - ) + ); } /** @internal Exported for testing. */ @@ -939,32 +797,32 @@ export function buildPlanApprovalOptions({ isBypassPermissionsModeAvailable, onFeedbackChange, }: { - showClearContext: boolean - showUltraplan: boolean - usedPercent: number | null - isAutoModeAvailable: boolean | undefined - isBypassPermissionsModeAvailable: boolean | undefined - onFeedbackChange: (v: string) => void + showClearContext: boolean; + showUltraplan: boolean; + usedPercent: number | null; + isAutoModeAvailable: boolean | undefined; + isBypassPermissionsModeAvailable: boolean | undefined; + onFeedbackChange: (v: string) => void; }): OptionWithDescription[] { - const options: OptionWithDescription[] = [] - const usedLabel = usedPercent !== null ? ` (${usedPercent}% used)` : '' + const options: OptionWithDescription[] = []; + const usedLabel = usedPercent !== null ? ` (${usedPercent}% used)` : ''; if (showClearContext) { if (feature('TRANSCRIPT_CLASSIFIER') && isAutoModeAvailable) { options.push({ label: `Yes, clear context${usedLabel} and use auto mode`, value: 'yes-auto-clear-context', - }) + }); } else if (isBypassPermissionsModeAvailable) { options.push({ label: `Yes, clear context${usedLabel} and bypass permissions`, value: 'yes-bypass-permissions', - }) + }); } else { options.push({ label: `Yes, clear context${usedLabel} and auto-accept edits`, value: 'yes-accept-edits', - }) + }); } } @@ -973,29 +831,29 @@ export function buildPlanApprovalOptions({ options.push({ label: 'Yes, and use auto mode', value: 'yes-resume-auto-mode', - }) + }); } else if (isBypassPermissionsModeAvailable) { options.push({ label: 'Yes, and bypass permissions', value: 'yes-accept-edits-keep-context', - }) + }); } else { options.push({ label: 'Yes, auto-accept edits', value: 'yes-accept-edits-keep-context', - }) + }); } options.push({ label: 'Yes, manually approve edits', value: 'yes-default-keep-context', - }) + }); if (showUltraplan) { options.push({ label: 'No, refine with Ultraplan on Claude Code on the web', value: 'ultraplan', - }) + }); } options.push({ @@ -1005,31 +863,28 @@ export function buildPlanApprovalOptions({ placeholder: 'Tell Claude what to change', description: 'shift+tab to approve with this feedback', onChange: onFeedbackChange, - }) + }); - return options + return options; } function getContextUsedPercent( usage: | { - input_tokens: number - cache_creation_input_tokens?: number | null - cache_read_input_tokens?: number | null + input_tokens: number; + cache_creation_input_tokens?: number | null; + cache_read_input_tokens?: number | null; } | undefined, permissionMode: PermissionMode, ): number | null { - if (!usage) return null + if (!usage) return null; const runtimeModel = getRuntimeMainLoopModel({ permissionMode, mainLoopModel: getMainLoopModel(), exceeds200kTokens: false, - }) - const contextWindowSize = getContextWindowForModel( - runtimeModel, - getSdkBetas(), - ) + }); + const contextWindowSize = getContextWindowForModel(runtimeModel, getSdkBetas()); const { used } = calculateContextPercentages( { input_tokens: usage.input_tokens, @@ -1037,6 +892,6 @@ function getContextUsedPercent( cache_read_input_tokens: usage.cache_read_input_tokens ?? 0, }, contextWindowSize, - ) - return used + ); + return used; } diff --git a/src/components/permissions/ExitPlanModePermissionRequest/src/context/notifications.ts b/src/components/permissions/ExitPlanModePermissionRequest/src/context/notifications.ts index 82956e65a..603737bb5 100644 --- a/src/components/permissions/ExitPlanModePermissionRequest/src/context/notifications.ts +++ b/src/components/permissions/ExitPlanModePermissionRequest/src/context/notifications.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useNotifications = any; +export type useNotifications = any diff --git a/src/components/permissions/ExitPlanModePermissionRequest/src/services/analytics/index.ts b/src/components/permissions/ExitPlanModePermissionRequest/src/services/analytics/index.ts index 142e7b6f5..2e0f796da 100644 --- a/src/components/permissions/ExitPlanModePermissionRequest/src/services/analytics/index.ts +++ b/src/components/permissions/ExitPlanModePermissionRequest/src/services/analytics/index.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any; -export type logEvent = any; +export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any +export type logEvent = any diff --git a/src/components/permissions/ExitPlanModePermissionRequest/src/state/AppState.ts b/src/components/permissions/ExitPlanModePermissionRequest/src/state/AppState.ts index f1e2aa661..80730fcb1 100644 --- a/src/components/permissions/ExitPlanModePermissionRequest/src/state/AppState.ts +++ b/src/components/permissions/ExitPlanModePermissionRequest/src/state/AppState.ts @@ -1,4 +1,4 @@ // Auto-generated type stub — replace with real implementation -export type useAppState = any; -export type useAppStateStore = any; -export type useSetAppState = any; +export type useAppState = any +export type useAppStateStore = any +export type useSetAppState = any diff --git a/src/components/permissions/FallbackPermissionRequest.tsx b/src/components/permissions/FallbackPermissionRequest.tsx index 12bb4ef7d..e80af35ec 100644 --- a/src/components/permissions/FallbackPermissionRequest.tsx +++ b/src/components/permissions/FallbackPermissionRequest.tsx @@ -1,22 +1,18 @@ -import React, { useCallback, useMemo } from 'react' -import { getOriginalCwd } from '../../bootstrap/state.js' -import { Box, Text, useTheme } from '@anthropic/ink' -import { sanitizeToolNameForAnalytics } from '../../services/analytics/metadata.js' -import { env } from '../../utils/env.js' -import { shouldShowAlwaysAllowOptions } from '../../utils/permissions/permissionsLoader.js' -import { truncateToLines } from '../../utils/stringUtils.js' -import { logUnaryEvent } from '../../utils/unaryLogging.js' -import { type UnaryEvent, usePermissionRequestLogging } from './hooks.js' -import { PermissionDialog } from './PermissionDialog.js' -import { - PermissionPrompt, - type PermissionPromptOption, - type ToolAnalyticsContext, -} from './PermissionPrompt.js' -import type { PermissionRequestProps } from './PermissionRequest.js' -import { PermissionRuleExplanation } from './PermissionRuleExplanation.js' +import React, { useCallback, useMemo } from 'react'; +import { getOriginalCwd } from '../../bootstrap/state.js'; +import { Box, Text, useTheme } from '@anthropic/ink'; +import { sanitizeToolNameForAnalytics } from '../../services/analytics/metadata.js'; +import { env } from '../../utils/env.js'; +import { shouldShowAlwaysAllowOptions } from '../../utils/permissions/permissionsLoader.js'; +import { truncateToLines } from '../../utils/stringUtils.js'; +import { logUnaryEvent } from '../../utils/unaryLogging.js'; +import { type UnaryEvent, usePermissionRequestLogging } from './hooks.js'; +import { PermissionDialog } from './PermissionDialog.js'; +import { PermissionPrompt, type PermissionPromptOption, type ToolAnalyticsContext } from './PermissionPrompt.js'; +import type { PermissionRequestProps } from './PermissionRequest.js'; +import { PermissionRuleExplanation } from './PermissionRuleExplanation.js'; -type FallbackOptionValue = 'yes' | 'yes-dont-ask-again' | 'no' +type FallbackOptionValue = 'yes' | 'yes-dont-ask-again' | 'no'; export function FallbackPermissionRequest({ toolUseConfirm, @@ -25,14 +21,12 @@ export function FallbackPermissionRequest({ verbose: _verbose, workerBadge, }: PermissionRequestProps): React.ReactNode { - const [theme] = useTheme() + const [theme] = useTheme(); // TODO: Avoid these special cases - const originalUserFacingName = toolUseConfirm.tool.userFacingName( - toolUseConfirm.input as never, - ) + const originalUserFacingName = toolUseConfirm.tool.userFacingName(toolUseConfirm.input as never); const userFacingName = originalUserFacingName.endsWith(' (MCP)') ? originalUserFacingName.slice(0, -6) - : originalUserFacingName + : originalUserFacingName; const unaryEvent = useMemo( () => ({ @@ -40,9 +34,9 @@ export function FallbackPermissionRequest({ language_name: 'none', }), [], - ) + ); - usePermissionRequestLogging(toolUseConfirm, unaryEvent) + usePermissionRequestLogging(toolUseConfirm, unaryEvent); const handleSelect = useCallback( (value: FallbackOptionValue, feedback?: string) => { @@ -56,10 +50,10 @@ export function FallbackPermissionRequest({ message_id: toolUseConfirm.assistantMessage.message.id!, platform: env.platform, }, - }) - toolUseConfirm.onAllow(toolUseConfirm.input, [], feedback) - onDone() - break + }); + toolUseConfirm.onAllow(toolUseConfirm.input, [], feedback); + onDone(); + break; case 'yes-dont-ask-again': { void logUnaryEvent({ completion_type: 'tool_use_single', @@ -69,7 +63,7 @@ export function FallbackPermissionRequest({ message_id: toolUseConfirm.assistantMessage.message.id!, platform: env.platform, }, - }) + }); toolUseConfirm.onAllow(toolUseConfirm.input, [ { @@ -82,9 +76,9 @@ export function FallbackPermissionRequest({ behavior: 'allow', destination: 'localSettings', }, - ]) - onDone() - break + ]); + onDone(); + break; } case 'no': void logUnaryEvent({ @@ -95,15 +89,15 @@ export function FallbackPermissionRequest({ message_id: toolUseConfirm.assistantMessage.message.id!, platform: env.platform, }, - }) - toolUseConfirm.onReject(feedback) - onReject() - onDone() - break + }); + toolUseConfirm.onReject(feedback); + onReject(); + onDone(); + break; } }, [toolUseConfirm, onDone, onReject], - ) + ); const handleCancel = useCallback(() => { void logUnaryEvent({ @@ -114,14 +108,14 @@ export function FallbackPermissionRequest({ message_id: toolUseConfirm.assistantMessage.message.id!, platform: env.platform, }, - }) - toolUseConfirm.onReject() - onReject() - onDone() - }, [toolUseConfirm, onDone, onReject]) + }); + toolUseConfirm.onReject(); + onReject(); + onDone(); + }, [toolUseConfirm, onDone, onReject]); - const originalCwd = getOriginalCwd() - const showAlwaysAllowOptions = shouldShowAlwaysAllowOptions() + const originalCwd = getOriginalCwd(); + const showAlwaysAllowOptions = shouldShowAlwaysAllowOptions(); const options = useMemo((): PermissionPromptOption[] => { const result: PermissionPromptOption[] = [ { @@ -129,28 +123,28 @@ export function FallbackPermissionRequest({ value: 'yes', feedbackConfig: { type: 'accept' }, }, - ] + ]; if (showAlwaysAllowOptions) { result.push({ label: ( - Yes, and don't ask again for {userFacingName}{' '} - commands in {originalCwd} + Yes, and don't ask again for {userFacingName} commands in{' '} + {originalCwd} ), value: 'yes-dont-ask-again', - }) + }); } result.push({ label: 'No', value: 'no', feedbackConfig: { type: 'reject' }, - }) + }); - return result - }, [userFacingName, originalCwd, showAlwaysAllowOptions]) + return result; + }, [userFacingName, originalCwd, showAlwaysAllowOptions]); const toolAnalyticsContext = useMemo( (): ToolAnalyticsContext => ({ @@ -158,32 +152,21 @@ export function FallbackPermissionRequest({ isMcp: toolUseConfirm.tool.isMcp ?? false, }), [toolUseConfirm.tool.name, toolUseConfirm.tool.isMcp], - ) + ); return ( {userFacingName}( - {toolUseConfirm.tool.renderToolUseMessage( - toolUseConfirm.input as never, - { theme, verbose: true }, - )} - ) - {originalUserFacingName.endsWith(' (MCP)') ? ( - (MCP) - ) : ( - '' - )} + {toolUseConfirm.tool.renderToolUseMessage(toolUseConfirm.input as never, { theme, verbose: true })}) + {originalUserFacingName.endsWith(' (MCP)') ? (MCP) : ''} {truncateToLines(toolUseConfirm.description, 3)} - + - ) + ); } diff --git a/src/components/permissions/FileEditPermissionRequest/FileEditPermissionRequest.tsx b/src/components/permissions/FileEditPermissionRequest/FileEditPermissionRequest.tsx index 42d558664..b6544cdeb 100644 --- a/src/components/permissions/FileEditPermissionRequest/FileEditPermissionRequest.tsx +++ b/src/components/permissions/FileEditPermissionRequest/FileEditPermissionRequest.tsx @@ -1,51 +1,44 @@ -import { basename, relative } from 'path' -import React from 'react' -import { FileEditToolDiff } from 'src/components/FileEditToolDiff.js' -import { getCwd } from 'src/utils/cwd.js' -import type { z } from 'zod/v4' -import { Text } from '@anthropic/ink' -import { FileEditTool } from '@claude-code-best/builtin-tools/tools/FileEditTool/FileEditTool.js' -import { FilePermissionDialog } from '../FilePermissionDialog/FilePermissionDialog.js' +import { basename, relative } from 'path'; +import React from 'react'; +import { FileEditToolDiff } from 'src/components/FileEditToolDiff.js'; +import { getCwd } from 'src/utils/cwd.js'; +import type { z } from 'zod/v4'; +import { Text } from '@anthropic/ink'; +import { FileEditTool } from '@claude-code-best/builtin-tools/tools/FileEditTool/FileEditTool.js'; +import { FilePermissionDialog } from '../FilePermissionDialog/FilePermissionDialog.js'; import { createSingleEditDiffConfig, type FileEdit, type IDEDiffSupport, -} from '../FilePermissionDialog/ideDiffConfig.js' -import type { PermissionRequestProps } from '../PermissionRequest.js' +} from '../FilePermissionDialog/ideDiffConfig.js'; +import type { PermissionRequestProps } from '../PermissionRequest.js'; -type FileEditInput = z.infer +type FileEditInput = z.infer; const ideDiffSupport: IDEDiffSupport = { getConfig: (input: FileEditInput) => - createSingleEditDiffConfig( - input.file_path, - input.old_string, - input.new_string, - input.replace_all, - ), + createSingleEditDiffConfig(input.file_path, input.old_string, input.new_string, input.replace_all), applyChanges: (input: FileEditInput, modifiedEdits: FileEdit[]) => { - const firstEdit = modifiedEdits[0] + const firstEdit = modifiedEdits[0]; if (firstEdit) { return { ...input, old_string: firstEdit.old_string, new_string: firstEdit.new_string, replace_all: firstEdit.replace_all, - } + }; } - return input + return input; }, -} +}; -export function FileEditPermissionRequest( - props: PermissionRequestProps, -): React.ReactNode { +export function FileEditPermissionRequest(props: PermissionRequestProps): React.ReactNode { const parseInput = (input: unknown): FileEditInput => { - return FileEditTool.inputSchema.parse(input) - } + return FileEditTool.inputSchema.parse(input); + }; - const parsed = parseInput(props.toolUseConfirm.input) - const { file_path, old_string, new_string, replace_all } = parsed + const parsed = parseInput(props.toolUseConfirm.input); + const { file_path, old_string, new_string, replace_all } = parsed; return ( - Do you want to make this edit to{' '} - {basename(file_path)}? + Do you want to make this edit to {basename(file_path)}? } content={ } path={file_path} @@ -75,5 +65,5 @@ export function FileEditPermissionRequest( parseInput={parseInput} ideDiffSupport={ideDiffSupport} /> - ) + ); } diff --git a/src/components/permissions/FileEditPermissionRequest/src/components/FileEditToolDiff.ts b/src/components/permissions/FileEditPermissionRequest/src/components/FileEditToolDiff.ts index d6d114f60..8b1551f7d 100644 --- a/src/components/permissions/FileEditPermissionRequest/src/components/FileEditToolDiff.ts +++ b/src/components/permissions/FileEditPermissionRequest/src/components/FileEditToolDiff.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type FileEditToolDiff = any; +export type FileEditToolDiff = any diff --git a/src/components/permissions/FileEditPermissionRequest/src/utils/cwd.ts b/src/components/permissions/FileEditPermissionRequest/src/utils/cwd.ts index 76c192ed8..4bd56a824 100644 --- a/src/components/permissions/FileEditPermissionRequest/src/utils/cwd.ts +++ b/src/components/permissions/FileEditPermissionRequest/src/utils/cwd.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getCwd = any; +export type getCwd = any diff --git a/src/components/permissions/FilePermissionDialog/FilePermissionDialog.tsx b/src/components/permissions/FilePermissionDialog/FilePermissionDialog.tsx index 58ac9b118..a9a4c7ffa 100644 --- a/src/components/permissions/FilePermissionDialog/FilePermissionDialog.tsx +++ b/src/components/permissions/FilePermissionDialog/FilePermissionDialog.tsx @@ -1,60 +1,51 @@ -import { relative } from 'path' -import React, { useMemo } from 'react' -import { useDiffInIDE } from '../../../hooks/useDiffInIDE.js' -import { Box, Text } from '@anthropic/ink' -import type { ToolUseContext } from '../../../Tool.js' -import { getLanguageName } from '../../../utils/cliHighlight.js' -import { getCwd } from '../../../utils/cwd.js' -import { - getFsImplementation, - safeResolvePath, -} from '../../../utils/fsOperations.js' -import { expandPath } from '../../../utils/path.js' -import type { CompletionType } from '../../../utils/unaryLogging.js' -import { Select } from '../../CustomSelect/index.js' -import { ShowInIDEPrompt } from '../../ShowInIDEPrompt.js' -import { usePermissionRequestLogging } from '../hooks.js' -import { PermissionDialog } from '../PermissionDialog.js' -import type { ToolUseConfirm } from '../PermissionRequest.js' -import type { WorkerBadgeProps } from '../WorkerBadge.js' -import type { IDEDiffSupport } from './ideDiffConfig.js' -import type { - FileOperationType, - PermissionOption, -} from './permissionOptions.js' -import { - type ToolInput, - useFilePermissionDialog, -} from './useFilePermissionDialog.js' +import { relative } from 'path'; +import React, { useMemo } from 'react'; +import { useDiffInIDE } from '../../../hooks/useDiffInIDE.js'; +import { Box, Text } from '@anthropic/ink'; +import type { ToolUseContext } from '../../../Tool.js'; +import { getLanguageName } from '../../../utils/cliHighlight.js'; +import { getCwd } from '../../../utils/cwd.js'; +import { getFsImplementation, safeResolvePath } from '../../../utils/fsOperations.js'; +import { expandPath } from '../../../utils/path.js'; +import type { CompletionType } from '../../../utils/unaryLogging.js'; +import { Select } from '../../CustomSelect/index.js'; +import { ShowInIDEPrompt } from '../../ShowInIDEPrompt.js'; +import { usePermissionRequestLogging } from '../hooks.js'; +import { PermissionDialog } from '../PermissionDialog.js'; +import type { ToolUseConfirm } from '../PermissionRequest.js'; +import type { WorkerBadgeProps } from '../WorkerBadge.js'; +import type { IDEDiffSupport } from './ideDiffConfig.js'; +import type { FileOperationType, PermissionOption } from './permissionOptions.js'; +import { type ToolInput, useFilePermissionDialog } from './useFilePermissionDialog.js'; export type FilePermissionDialogProps = { // Required props from PermissionRequestProps - toolUseConfirm: ToolUseConfirm - toolUseContext: ToolUseContext - onDone: () => void - onReject: () => void + toolUseConfirm: ToolUseConfirm; + toolUseContext: ToolUseContext; + onDone: () => void; + onReject: () => void; // Dialog customization - title: string - subtitle?: React.ReactNode - question?: string | React.ReactNode - content?: React.ReactNode // Can be general content or diff component + title: string; + subtitle?: React.ReactNode; + question?: string | React.ReactNode; + content?: React.ReactNode; // Can be general content or diff component // Logging - completionType?: CompletionType - languageName?: string // override — derived from path when omitted + completionType?: CompletionType; + languageName?: string; // override — derived from path when omitted // File/directory operations - path: string | null - parseInput: (input: unknown) => T - operationType?: FileOperationType + path: string | null; + parseInput: (input: unknown) => T; + operationType?: FileOperationType; // IDE diff support - ideDiffSupport?: IDEDiffSupport + ideDiffSupport?: IDEDiffSupport; // Worker badge for teammate permission requests - workerBadge: WorkerBadgeProps | undefined -} + workerBadge: WorkerBadgeProps | undefined; +}; export function FilePermissionDialog({ toolUseConfirm, @@ -80,28 +71,28 @@ export function FilePermissionDialog({ const languageName = useMemo( () => languageNameOverride ?? (path ? getLanguageName(path) : 'none'), [languageNameOverride, path], - ) + ); const unaryEvent = useMemo( () => ({ completion_type: completionType, language_name: languageName, }), [completionType, languageName], - ) - usePermissionRequestLogging(toolUseConfirm, unaryEvent) + ); + usePermissionRequestLogging(toolUseConfirm, unaryEvent); const symlinkTarget = useMemo(() => { if (!path || operationType === 'read') { - return null + return null; } - const expandedPath = expandPath(path) - const fs = getFsImplementation() - const { resolvedPath, isSymlink } = safeResolvePath(fs, expandedPath) + const expandedPath = expandPath(path); + const fs = getFsImplementation(); + const { resolvedPath, isSymlink } = safeResolvePath(fs, expandedPath); if (isSymlink) { - return resolvedPath + return resolvedPath; } - return null - }, [path, operationType]) + return null; + }, [path, operationType]); const fileDialogResult = useFilePermissionDialog({ filePath: path || '', @@ -112,7 +103,7 @@ export function FilePermissionDialog({ onReject, parseInput, operationType, - }) + }); // Use file dialog results for options const { @@ -124,22 +115,19 @@ export function FilePermissionDialog({ focusedOption, yesInputMode, noInputMode, - } = fileDialogResult + } = fileDialogResult; // Parse input using the provided parser - const parsedInput = parseInput(toolUseConfirm.input) + const parsedInput = parseInput(toolUseConfirm.input); // Set up IDE diff support if enabled. Memoized: getConfig may do disk I/O // (FileWrite's getConfig calls readFileSync for the old-content diff). // Keyed on the raw input — parseInput is a pure Zod parse whose result // depends only on toolUseConfirm.input. const ideDiffConfig = useMemo( - () => - ideDiffSupport - ? ideDiffSupport.getConfig(parseInput(toolUseConfirm.input)) - : null, + () => (ideDiffSupport ? ideDiffSupport.getConfig(parseInput(toolUseConfirm.input)) : null), [ideDiffSupport, toolUseConfirm.input], - ) + ); // Create diff params based on whether IDE diff is available const diffParams = ideDiffConfig @@ -147,19 +135,16 @@ export function FilePermissionDialog({ onChange: ( option: PermissionOption, input: { - file_path: string + file_path: string; edits: Array<{ - old_string: string - new_string: string - replace_all?: boolean - }> + old_string: string; + new_string: string; + replace_all?: boolean; + }>; }, ) => { - const transformedInput = ideDiffSupport!.applyChanges( - parsedInput, - input.edits, - ) - fileDialogResult.onChange(option, transformedInput) + const transformedInput = ideDiffSupport!.applyChanges(parsedInput, input.edits); + fileDialogResult.onChange(option, transformedInput); }, toolUseContext, filePath: ideDiffConfig.filePath, @@ -176,21 +161,19 @@ export function FilePermissionDialog({ filePath: '', edits: [], editMode: 'single' as const, - } + }; - const { closeTabInIDE, showingDiffInIDE, ideName } = useDiffInIDE(diffParams) + const { closeTabInIDE, showingDiffInIDE, ideName } = useDiffInIDE(diffParams); const onChange = (option: PermissionOption, feedback?: string) => { - closeTabInIDE?.() - fileDialogResult.onChange(option, parsedInput, feedback?.trim()) - } + closeTabInIDE?.(); + fileDialogResult.onChange(option, parsedInput, feedback?.trim()); + }; if (showingDiffInIDE && ideDiffConfig && path) { return ( - onChange(option, feedback) - } + onChange={(option: PermissionOption, _input, feedback?: string) => onChange(option, feedback)} options={options} filePath={path} input={parsedInput} @@ -204,11 +187,10 @@ export function FilePermissionDialog({ yesInputMode={yesInputMode} noInputMode={noInputMode} /> - ) + ); } - const isSymlinkOutsideCwd = - symlinkTarget != null && relative(getCwd(), symlinkTarget).startsWith('..') + const isSymlinkOutsideCwd = symlinkTarget != null && relative(getCwd(), symlinkTarget).startsWith('..'); const symlinkWarning = symlinkTarget ? ( @@ -218,16 +200,11 @@ export function FilePermissionDialog({ : `Symlink target: ${symlinkTarget}`} - ) : null + ) : null; return ( <> - + {symlinkWarning} {content} @@ -236,21 +213,21 @@ export function FilePermissionDialog({ options={options} inlineDescriptions onChange={value => { - const selected = options.find(opt => opt.value === value) + const selected = options.find(opt => opt.value === value); if (selected) { // For reject option if (selected.option.type === 'reject') { - const trimmedFeedback = rejectFeedback.trim() - onChange(selected.option, trimmedFeedback || undefined) - return + const trimmedFeedback = rejectFeedback.trim(); + onChange(selected.option, trimmedFeedback || undefined); + return; } // For accept-once option, pass accept feedback if present if (selected.option.type === 'accept-once') { - const trimmedFeedback = acceptFeedback.trim() - onChange(selected.option, trimmedFeedback || undefined) - return + const trimmedFeedback = acceptFeedback.trim(); + onChange(selected.option, trimmedFeedback || undefined); + return; } - onChange(selected.option) + onChange(selected.option); } }} onCancel={() => onChange({ type: 'reject' })} @@ -262,11 +239,10 @@ export function FilePermissionDialog({ Esc to cancel - {((focusedOption === 'yes' && !yesInputMode) || - (focusedOption === 'no' && !noInputMode)) && + {((focusedOption === 'yes' && !yesInputMode) || (focusedOption === 'no' && !noInputMode)) && ' · Tab to amend'} - ) + ); } diff --git a/src/components/permissions/FilePermissionDialog/permissionOptions.tsx b/src/components/permissions/FilePermissionDialog/permissionOptions.tsx index 3709a6502..e67e64434 100644 --- a/src/components/permissions/FilePermissionDialog/permissionOptions.tsx +++ b/src/components/permissions/FilePermissionDialog/permissionOptions.tsx @@ -1,37 +1,31 @@ -import { homedir } from 'os' -import { basename, join, sep } from 'path' -import React, { type ReactNode } from 'react' -import { getOriginalCwd } from '../../../bootstrap/state.js' -import { Text } from '@anthropic/ink' -import { getShortcutDisplay } from '../../../keybindings/shortcutFormat.js' -import type { ToolPermissionContext } from '../../../Tool.js' -import { expandPath, getDirectoryForPath } from '../../../utils/path.js' -import { - normalizeCaseForComparison, - pathInAllowedWorkingPath, -} from '../../../utils/permissions/filesystem.js' -import type { OptionWithDescription } from '../../CustomSelect/select.js' +import { homedir } from 'os'; +import { basename, join, sep } from 'path'; +import React, { type ReactNode } from 'react'; +import { getOriginalCwd } from '../../../bootstrap/state.js'; +import { Text } from '@anthropic/ink'; +import { getShortcutDisplay } from '../../../keybindings/shortcutFormat.js'; +import type { ToolPermissionContext } from '../../../Tool.js'; +import { expandPath, getDirectoryForPath } from '../../../utils/path.js'; +import { normalizeCaseForComparison, pathInAllowedWorkingPath } from '../../../utils/permissions/filesystem.js'; +import type { OptionWithDescription } from '../../CustomSelect/select.js'; /** * Check if a path is within the project's .claude/ folder. * This is used to determine whether to show the special ".claude folder" permission option. */ export function isInClaudeFolder(filePath: string): boolean { - const absolutePath = expandPath(filePath) - const claudeFolderPath = expandPath(`${getOriginalCwd()}/.claude`) + const absolutePath = expandPath(filePath); + const claudeFolderPath = expandPath(`${getOriginalCwd()}/.claude`); // Check if the path is within the project's .claude folder - const normalizedAbsolutePath = normalizeCaseForComparison(absolutePath) - const normalizedClaudeFolderPath = - normalizeCaseForComparison(claudeFolderPath) + const normalizedAbsolutePath = normalizeCaseForComparison(absolutePath); + const normalizedClaudeFolderPath = normalizeCaseForComparison(claudeFolderPath); // Path must start with the .claude folder path (and be inside it, not just the folder itself) return ( - normalizedAbsolutePath.startsWith( - normalizedClaudeFolderPath + sep.toLowerCase(), - ) || + normalizedAbsolutePath.startsWith(normalizedClaudeFolderPath + sep.toLowerCase()) || // Also match case where sep is / on posix systems normalizedAbsolutePath.startsWith(normalizedClaudeFolderPath + '/') - ) + ); } /** @@ -40,32 +34,28 @@ export function isInClaudeFolder(filePath: string): boolean { * for files in the user's home directory. */ export function isInGlobalClaudeFolder(filePath: string): boolean { - const absolutePath = expandPath(filePath) - const globalClaudeFolderPath = join(homedir(), '.claude') + const absolutePath = expandPath(filePath); + const globalClaudeFolderPath = join(homedir(), '.claude'); - const normalizedAbsolutePath = normalizeCaseForComparison(absolutePath) - const normalizedGlobalClaudeFolderPath = normalizeCaseForComparison( - globalClaudeFolderPath, - ) + const normalizedAbsolutePath = normalizeCaseForComparison(absolutePath); + const normalizedGlobalClaudeFolderPath = normalizeCaseForComparison(globalClaudeFolderPath); return ( - normalizedAbsolutePath.startsWith( - normalizedGlobalClaudeFolderPath + sep.toLowerCase(), - ) || + normalizedAbsolutePath.startsWith(normalizedGlobalClaudeFolderPath + sep.toLowerCase()) || normalizedAbsolutePath.startsWith(normalizedGlobalClaudeFolderPath + '/') - ) + ); } export type PermissionOption = | { type: 'accept-once' } | { type: 'accept-session'; scope?: 'claude-folder' | 'global-claude-folder' } - | { type: 'reject' } + | { type: 'reject' }; export type PermissionOptionWithLabel = OptionWithDescription & { - option: PermissionOption -} + option: PermissionOption; +}; -export type FileOperationType = 'read' | 'write' | 'create' +export type FileOperationType = 'read' | 'write' | 'create'; export function getFilePermissionOptions({ filePath, @@ -76,20 +66,16 @@ export function getFilePermissionOptions({ yesInputMode = false, noInputMode = false, }: { - filePath: string - toolPermissionContext: ToolPermissionContext - operationType?: FileOperationType - onRejectFeedbackChange?: (value: string) => void - onAcceptFeedbackChange?: (value: string) => void - yesInputMode?: boolean - noInputMode?: boolean + filePath: string; + toolPermissionContext: ToolPermissionContext; + operationType?: FileOperationType; + onRejectFeedbackChange?: (value: string) => void; + onAcceptFeedbackChange?: (value: string) => void; + yesInputMode?: boolean; + noInputMode?: boolean; }): PermissionOptionWithLabel[] { - const options: PermissionOptionWithLabel[] = [] - const modeCycleShortcut = getShortcutDisplay( - 'chat:cycleMode', - 'Chat', - 'shift+tab', - ) + const options: PermissionOptionWithLabel[] = []; + const modeCycleShortcut = getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab'); // When in input mode, show input field if (yesInputMode && onAcceptFeedbackChange) { @@ -101,23 +87,20 @@ export function getFilePermissionOptions({ onChange: onAcceptFeedbackChange, allowEmptySubmitToCancel: true, option: { type: 'accept-once' }, - }) + }); } else { options.push({ label: 'Yes', value: 'yes', option: { type: 'accept-once' }, - }) + }); } - const inAllowedPath = pathInAllowedWorkingPath( - filePath, - toolPermissionContext, - ) + const inAllowedPath = pathInAllowedWorkingPath(filePath, toolPermissionContext); // Check if this is a .claude/ folder path (project or global) - const inClaudeFolder = isInClaudeFolder(filePath) - const inGlobalClaudeFolder = isInGlobalClaudeFolder(filePath) + const inClaudeFolder = isInClaudeFolder(filePath); + const inGlobalClaudeFolder = isInGlobalClaudeFolder(filePath); // Option 2: For .claude/ folder, show special option instead of generic session option // Note: Session-level options are always shown since they only affect in-memory state, @@ -131,42 +114,40 @@ export function getFilePermissionOptions({ type: 'accept-session', scope: inGlobalClaudeFolder ? 'global-claude-folder' : 'claude-folder', }, - }) + }); } else { // Option 2: Allow all changes/reads during session - let sessionLabel: ReactNode + let sessionLabel: ReactNode; if (inAllowedPath) { // Inside working directory if (operationType === 'read') { - sessionLabel = 'Yes, during this session' + sessionLabel = 'Yes, during this session'; } else { sessionLabel = ( - Yes, allow all edits during this session{' '} - ({modeCycleShortcut}) + Yes, allow all edits during this session ({modeCycleShortcut}) - ) + ); } } else { // Outside working directory - include directory name - const dirPath = getDirectoryForPath(filePath) - const dirName = basename(dirPath) || 'this directory' + const dirPath = getDirectoryForPath(filePath); + const dirName = basename(dirPath) || 'this directory'; if (operationType === 'read') { sessionLabel = ( - Yes, allow reading from {dirName}/ during this - session + Yes, allow reading from {dirName}/ during this session - ) + ); } else { sessionLabel = ( - Yes, allow all edits in {dirName}/ during this - session ({modeCycleShortcut}) + Yes, allow all edits in {dirName}/ during this session{' '} + ({modeCycleShortcut}) - ) + ); } } @@ -174,7 +155,7 @@ export function getFilePermissionOptions({ label: sessionLabel, value: 'yes-session', option: { type: 'accept-session' }, - }) + }); } // When in input mode, show input field for reject @@ -187,15 +168,15 @@ export function getFilePermissionOptions({ onChange: onRejectFeedbackChange, allowEmptySubmitToCancel: true, option: { type: 'reject' }, - }) + }); } else { // Not in input mode - simple option options.push({ label: 'No', value: 'no', option: { type: 'reject' }, - }) + }); } - return options + return options; } diff --git a/src/components/permissions/FilePermissionDialog/src/state/AppState.ts b/src/components/permissions/FilePermissionDialog/src/state/AppState.ts index 3c93608d8..ff8a9db6a 100644 --- a/src/components/permissions/FilePermissionDialog/src/state/AppState.ts +++ b/src/components/permissions/FilePermissionDialog/src/state/AppState.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useAppState = any; +export type useAppState = any diff --git a/src/components/permissions/FileWritePermissionRequest/FileWritePermissionRequest.tsx b/src/components/permissions/FileWritePermissionRequest/FileWritePermissionRequest.tsx index f6146f705..b4d42a634 100644 --- a/src/components/permissions/FileWritePermissionRequest/FileWritePermissionRequest.tsx +++ b/src/components/permissions/FileWritePermissionRequest/FileWritePermissionRequest.tsx @@ -1,30 +1,30 @@ -import { basename, relative } from 'path' -import React, { useMemo } from 'react' -import type { z } from 'zod/v4' -import { Text } from '@anthropic/ink' -import { FileWriteTool } from '@claude-code-best/builtin-tools/tools/FileWriteTool/FileWriteTool.js' -import { getCwd } from '../../../utils/cwd.js' -import { isENOENT } from '../../../utils/errors.js' -import { readFileSync } from '../../../utils/fileRead.js' -import { FilePermissionDialog } from '../FilePermissionDialog/FilePermissionDialog.js' +import { basename, relative } from 'path'; +import React, { useMemo } from 'react'; +import type { z } from 'zod/v4'; +import { Text } from '@anthropic/ink'; +import { FileWriteTool } from '@claude-code-best/builtin-tools/tools/FileWriteTool/FileWriteTool.js'; +import { getCwd } from '../../../utils/cwd.js'; +import { isENOENT } from '../../../utils/errors.js'; +import { readFileSync } from '../../../utils/fileRead.js'; +import { FilePermissionDialog } from '../FilePermissionDialog/FilePermissionDialog.js'; import { createSingleEditDiffConfig, type FileEdit, type IDEDiffSupport, -} from '../FilePermissionDialog/ideDiffConfig.js' -import type { PermissionRequestProps } from '../PermissionRequest.js' -import { FileWriteToolDiff } from './FileWriteToolDiff.js' +} from '../FilePermissionDialog/ideDiffConfig.js'; +import type { PermissionRequestProps } from '../PermissionRequest.js'; +import { FileWriteToolDiff } from './FileWriteToolDiff.js'; -type FileWriteToolInput = z.infer +type FileWriteToolInput = z.infer; const ideDiffSupport: IDEDiffSupport = { getConfig: (input: FileWriteToolInput) => { - let oldContent: string + let oldContent: string; try { - oldContent = readFileSync(input.file_path) + oldContent = readFileSync(input.file_path); } catch (e) { - if (!isENOENT(e)) throw e - oldContent = '' + if (!isENOENT(e)) throw e; + oldContent = ''; } return createSingleEditDiffConfig( @@ -32,43 +32,41 @@ const ideDiffSupport: IDEDiffSupport = { oldContent, input.content, false, // For file writes, we replace the entire content - ) + ); }, applyChanges: (input: FileWriteToolInput, modifiedEdits: FileEdit[]) => { - const firstEdit = modifiedEdits[0] + const firstEdit = modifiedEdits[0]; if (firstEdit) { return { ...input, content: firstEdit.new_string, - } + }; } - return input + return input; }, -} +}; -export function FileWritePermissionRequest( - props: PermissionRequestProps, -): React.ReactNode { +export function FileWritePermissionRequest(props: PermissionRequestProps): React.ReactNode { const parseInput = (input: unknown): FileWriteToolInput => { - return FileWriteTool.inputSchema.parse(input) - } + return FileWriteTool.inputSchema.parse(input); + }; - const parsed = parseInput(props.toolUseConfirm.input) - const { file_path, content } = parsed + const parsed = parseInput(props.toolUseConfirm.input); + const { file_path, content } = parsed; // Single read drives both UI text ("Create" vs "Overwrite") and the diff // shown by FileWriteToolDiff — avoids a redundant existsSync stat that would // block first-mount commit on slow/networked filesystems. const { fileExists, oldContent } = useMemo(() => { try { - return { fileExists: true, oldContent: readFileSync(file_path) } + return { fileExists: true, oldContent: readFileSync(file_path) }; } catch (e) { - if (!isENOENT(e)) throw e - return { fileExists: false, oldContent: '' } + if (!isENOENT(e)) throw e; + return { fileExists: false, oldContent: '' }; } - }, [file_path]) + }, [file_path]); - const actionText = fileExists ? 'overwrite' : 'create' + const actionText = fileExists ? 'overwrite' : 'create'; return ( } content={ - + } path={file_path} completionType="write_file_single" parseInput={parseInput} ideDiffSupport={ideDiffSupport} /> - ) + ); } diff --git a/src/components/permissions/FileWritePermissionRequest/FileWriteToolDiff.tsx b/src/components/permissions/FileWritePermissionRequest/FileWriteToolDiff.tsx index 38084661f..54b1e1d3f 100644 --- a/src/components/permissions/FileWritePermissionRequest/FileWriteToolDiff.tsx +++ b/src/components/permissions/FileWritePermissionRequest/FileWriteToolDiff.tsx @@ -1,29 +1,24 @@ -import * as React from 'react' -import { useMemo } from 'react' -import { useTerminalSize } from '../../../hooks/useTerminalSize.js' -import { Box, NoSelect, Text } from '@anthropic/ink' -import { intersperse } from '../../../utils/array.js' -import { getPatchForDisplay } from '../../../utils/diff.js' -import { HighlightedCode } from '../../HighlightedCode.js' -import { StructuredDiff } from '../../StructuredDiff.js' +import * as React from 'react'; +import { useMemo } from 'react'; +import { useTerminalSize } from '../../../hooks/useTerminalSize.js'; +import { Box, NoSelect, Text } from '@anthropic/ink'; +import { intersperse } from '../../../utils/array.js'; +import { getPatchForDisplay } from '../../../utils/diff.js'; +import { HighlightedCode } from '../../HighlightedCode.js'; +import { StructuredDiff } from '../../StructuredDiff.js'; type Props = { - file_path: string - content: string - fileExists: boolean - oldContent: string -} + file_path: string; + content: string; + fileExists: boolean; + oldContent: string; +}; -export function FileWriteToolDiff({ - file_path, - content, - fileExists, - oldContent, -}: Props): React.ReactNode { - const { columns } = useTerminalSize() +export function FileWriteToolDiff({ file_path, content, fileExists, oldContent }: Props): React.ReactNode { + const { columns } = useTerminalSize(); const hunks = useMemo(() => { if (!fileExists) { - return null + return null; } return getPatchForDisplay({ filePath: file_path, @@ -35,11 +30,11 @@ export function FileWriteToolDiff({ replace_all: false, }, ], - }) - }, [fileExists, file_path, oldContent, content]) + }); + }, [fileExists, file_path, oldContent, content]); - const firstLine = content.split('\n')[0] ?? null - const paddingX = 1 + const firstLine = content.split('\n')[0] ?? null; + const paddingX = 1; return ( @@ -71,12 +66,9 @@ export function FileWriteToolDiff({ ), ) ) : ( - + )} - ) + ); } diff --git a/src/components/permissions/FilesystemPermissionRequest/FilesystemPermissionRequest.tsx b/src/components/permissions/FilesystemPermissionRequest/FilesystemPermissionRequest.tsx index 67828f563..ec3b6d893 100644 --- a/src/components/permissions/FilesystemPermissionRequest/FilesystemPermissionRequest.tsx +++ b/src/components/permissions/FilesystemPermissionRequest/FilesystemPermissionRequest.tsx @@ -1,23 +1,20 @@ -import React from 'react' -import { Box, Text, useTheme } from '@anthropic/ink' -import { FallbackPermissionRequest } from '../FallbackPermissionRequest.js' -import { FilePermissionDialog } from '../FilePermissionDialog/FilePermissionDialog.js' -import type { ToolInput } from '../FilePermissionDialog/useFilePermissionDialog.js' -import type { - PermissionRequestProps, - ToolUseConfirm, -} from '../PermissionRequest.js' +import React from 'react'; +import { Box, Text, useTheme } from '@anthropic/ink'; +import { FallbackPermissionRequest } from '../FallbackPermissionRequest.js'; +import { FilePermissionDialog } from '../FilePermissionDialog/FilePermissionDialog.js'; +import type { ToolInput } from '../FilePermissionDialog/useFilePermissionDialog.js'; +import type { PermissionRequestProps, ToolUseConfirm } from '../PermissionRequest.js'; function pathFromToolUse(toolUseConfirm: ToolUseConfirm): string | null { - const tool = toolUseConfirm.tool + const tool = toolUseConfirm.tool; if ('getPath' in tool && typeof tool.getPath === 'function') { try { - return tool.getPath(toolUseConfirm.input) + return tool.getPath(toolUseConfirm.input); } catch { - return null + return null; } } - return null + return null; } export function FilesystemPermissionRequest({ @@ -28,20 +25,18 @@ export function FilesystemPermissionRequest({ toolUseContext, workerBadge, }: PermissionRequestProps): React.ReactNode { - const [theme] = useTheme() - const path = pathFromToolUse(toolUseConfirm) - const userFacingName = toolUseConfirm.tool.userFacingName( - toolUseConfirm.input as never, - ) + const [theme] = useTheme(); + const path = pathFromToolUse(toolUseConfirm); + const userFacingName = toolUseConfirm.tool.userFacingName(toolUseConfirm.input as never); - const isReadOnly = toolUseConfirm.tool.isReadOnly(toolUseConfirm.input) - const userFacingReadOrEdit = isReadOnly ? 'Read' : 'Edit' + const isReadOnly = toolUseConfirm.tool.isReadOnly(toolUseConfirm.input); + const userFacingReadOrEdit = isReadOnly ? 'Read' : 'Edit'; // Use simple singular form - the actual operation details are shown in content - const title = `${userFacingReadOrEdit} file` + const title = `${userFacingReadOrEdit} file`; // Simple pass-through parser since we don't need to transform the input - const parseInput = (input: unknown): ToolInput => input as ToolInput + const parseInput = (input: unknown): ToolInput => input as ToolInput; // Fall back to generic permission request if no path is found if (!path) { @@ -54,22 +49,17 @@ export function FilesystemPermissionRequest({ verbose={verbose} workerBadge={workerBadge} /> - ) + ); } // Render tool use message content const content = ( - {userFacingName}( - {toolUseConfirm.tool.renderToolUseMessage( - toolUseConfirm.input as never, - { theme, verbose }, - )} - ) + {userFacingName}({toolUseConfirm.tool.renderToolUseMessage(toolUseConfirm.input as never, { theme, verbose })}) - ) + ); return ( - ) + ); } diff --git a/src/components/permissions/MonitorPermissionRequest/MonitorPermissionRequest.tsx b/src/components/permissions/MonitorPermissionRequest/MonitorPermissionRequest.tsx index 0041f3d6e..61cc7a786 100644 --- a/src/components/permissions/MonitorPermissionRequest/MonitorPermissionRequest.tsx +++ b/src/components/permissions/MonitorPermissionRequest/MonitorPermissionRequest.tsx @@ -1,19 +1,16 @@ -import React, { useCallback, useMemo } from 'react' -import { Box, Text, useTheme } from '@anthropic/ink' -import { getTheme } from '../../../utils/theme.js' -import { env } from '../../../utils/env.js' -import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js' -import { truncateToLines } from '../../../utils/stringUtils.js' -import { logUnaryEvent } from '../../../utils/unaryLogging.js' -import { PermissionDialog } from '../PermissionDialog.js' -import { - PermissionPrompt, - type PermissionPromptOption, -} from '../PermissionPrompt.js' -import type { PermissionRequestProps } from '../PermissionRequest.js' -import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js' +import React, { useCallback, useMemo } from 'react'; +import { Box, Text, useTheme } from '@anthropic/ink'; +import { getTheme } from '../../../utils/theme.js'; +import { env } from '../../../utils/env.js'; +import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js'; +import { truncateToLines } from '../../../utils/stringUtils.js'; +import { logUnaryEvent } from '../../../utils/unaryLogging.js'; +import { PermissionDialog } from '../PermissionDialog.js'; +import { PermissionPrompt, type PermissionPromptOption } from '../PermissionPrompt.js'; +import type { PermissionRequestProps } from '../PermissionRequest.js'; +import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js'; -type OptionValue = 'yes' | 'yes-dont-ask-again' | 'no' +type OptionValue = 'yes' | 'yes-dont-ask-again' | 'no'; /** * Permission request UI for the MonitorTool. Asks the user to confirm @@ -26,18 +23,15 @@ export function MonitorPermissionRequest({ onReject, workerBadge, }: PermissionRequestProps): React.ReactNode { - const [themeName] = useTheme() - const theme = getTheme(themeName) + const [themeName] = useTheme(); + const theme = getTheme(themeName); const input = toolUseConfirm.input as { - command: string - description: string - } + command: string; + description: string; + }; - const showAlwaysAllowOptions = useMemo( - () => shouldShowAlwaysAllowOptions(), - [], - ) + const showAlwaysAllowOptions = useMemo(() => shouldShowAlwaysAllowOptions(), []); const options: PermissionPromptOption[] = useMemo(() => { const opts: PermissionPromptOption[] = [ @@ -46,25 +40,24 @@ export function MonitorPermissionRequest({ value: 'yes', feedbackConfig: { type: 'accept' as const }, }, - ] + ]; if (showAlwaysAllowOptions) { opts.push({ label: ( - Yes, and don{'\u2019'}t ask again for{' '} - {toolUseConfirm.tool.name} commands + Yes, and don{'\u2019'}t ask again for {toolUseConfirm.tool.name} commands ), value: 'yes-dont-ask-again', - }) + }); } opts.push({ label: 'No', value: 'no', feedbackConfig: { type: 'reject' as const }, - }) - return opts - }, [showAlwaysAllowOptions, toolUseConfirm.tool.name]) + }); + return opts; + }, [showAlwaysAllowOptions, toolUseConfirm.tool.name]); const handleSelect = useCallback( (value: OptionValue, feedback?: string) => { @@ -78,10 +71,10 @@ export function MonitorPermissionRequest({ message_id: toolUseConfirm.assistantMessage.message.id ?? '', platform: env.platform, }, - }) - toolUseConfirm.onAllow(toolUseConfirm.input, [], feedback) - onDone() - break + }); + toolUseConfirm.onAllow(toolUseConfirm.input, [], feedback); + onDone(); + break; case 'yes-dont-ask-again': logUnaryEvent({ completion_type: 'tool_use_single', @@ -91,7 +84,7 @@ export function MonitorPermissionRequest({ message_id: toolUseConfirm.assistantMessage.message.id ?? '', platform: env.platform, }, - }) + }); toolUseConfirm.onAllow(toolUseConfirm.input, [ { type: 'addRules', @@ -99,9 +92,9 @@ export function MonitorPermissionRequest({ behavior: 'allow', destination: 'localSettings', }, - ]) - onDone() - break + ]); + onDone(); + break; case 'no': logUnaryEvent({ completion_type: 'tool_use_single', @@ -111,15 +104,15 @@ export function MonitorPermissionRequest({ message_id: toolUseConfirm.assistantMessage.message.id ?? '', platform: env.platform, }, - }) - toolUseConfirm.onReject(feedback) - onReject() - onDone() - break + }); + toolUseConfirm.onReject(feedback); + onReject(); + onDone(); + break; } }, [toolUseConfirm, onDone, onReject], - ) + ); const handleCancel = useCallback(() => { logUnaryEvent({ @@ -130,36 +123,24 @@ export function MonitorPermissionRequest({ message_id: toolUseConfirm.assistantMessage.message.id ?? '', platform: env.platform, }, - }) - toolUseConfirm.onReject() - onReject() - onDone() - }, [toolUseConfirm, onDone, onReject]) + }); + toolUseConfirm.onReject(); + onReject(); + onDone(); + }, [toolUseConfirm, onDone, onReject]); return ( - + {input.description} - - {truncateToLines(input.command, 5)} - + {truncateToLines(input.command, 5)} - - - options={options} - onSelect={handleSelect} - onCancel={handleCancel} - /> + + options={options} onSelect={handleSelect} onCancel={handleCancel} /> - ) + ); } diff --git a/src/components/permissions/NotebookEditPermissionRequest/NotebookEditPermissionRequest.tsx b/src/components/permissions/NotebookEditPermissionRequest/NotebookEditPermissionRequest.tsx index ab2c94393..072ba2c00 100644 --- a/src/components/permissions/NotebookEditPermissionRequest/NotebookEditPermissionRequest.tsx +++ b/src/components/permissions/NotebookEditPermissionRequest/NotebookEditPermissionRequest.tsx @@ -1,47 +1,41 @@ -import { basename } from 'path' -import React from 'react' -import type { z } from 'zod/v4' -import { Text } from '@anthropic/ink' -import { NotebookEditTool } from '@claude-code-best/builtin-tools/tools/NotebookEditTool/NotebookEditTool.js' -import { logError } from '../../../utils/log.js' -import { FilePermissionDialog } from '../FilePermissionDialog/FilePermissionDialog.js' -import type { PermissionRequestProps } from '../PermissionRequest.js' -import { NotebookEditToolDiff } from './NotebookEditToolDiff.js' +import { basename } from 'path'; +import React from 'react'; +import type { z } from 'zod/v4'; +import { Text } from '@anthropic/ink'; +import { NotebookEditTool } from '@claude-code-best/builtin-tools/tools/NotebookEditTool/NotebookEditTool.js'; +import { logError } from '../../../utils/log.js'; +import { FilePermissionDialog } from '../FilePermissionDialog/FilePermissionDialog.js'; +import type { PermissionRequestProps } from '../PermissionRequest.js'; +import { NotebookEditToolDiff } from './NotebookEditToolDiff.js'; -type NotebookEditInput = z.infer +type NotebookEditInput = z.infer; -export function NotebookEditPermissionRequest( - props: PermissionRequestProps, -): React.ReactNode { +export function NotebookEditPermissionRequest(props: PermissionRequestProps): React.ReactNode { const parseInput = (input: unknown): NotebookEditInput => { - const result = NotebookEditTool.inputSchema.safeParse(input) + const result = NotebookEditTool.inputSchema.safeParse(input); if (!result.success) { - logError( - new Error( - `Failed to parse notebook edit input: ${result.error.message}`, - ), - ) + logError(new Error(`Failed to parse notebook edit input: ${result.error.message}`)); // Return a default value to avoid crashing return { notebook_path: '', new_source: '', cell_id: '', - } as NotebookEditInput + } as NotebookEditInput; } - return result.data - } + return result.data; + }; - const parsed = parseInput(props.toolUseConfirm.input) - const { notebook_path, edit_mode, cell_type } = parsed + const parsed = parseInput(props.toolUseConfirm.input); + const { notebook_path, edit_mode, cell_type } = parsed; - const language = cell_type === 'markdown' ? 'markdown' : 'python' + const language = cell_type === 'markdown' ? 'markdown' : 'python'; const editTypeText = edit_mode === 'insert' ? 'insert this cell into' : edit_mode === 'delete' ? 'delete this cell from' - : 'make this edit to' + : 'make this edit to'; return ( - Do you want to {editTypeText}{' '} - {basename(notebook_path)}? + Do you want to {editTypeText} {basename(notebook_path)}? } content={ @@ -73,5 +66,5 @@ export function NotebookEditPermissionRequest( languageName={language} parseInput={parseInput} /> - ) + ); } diff --git a/src/components/permissions/NotebookEditPermissionRequest/NotebookEditToolDiff.tsx b/src/components/permissions/NotebookEditPermissionRequest/NotebookEditToolDiff.tsx index 023f8c6bf..3e554412a 100644 --- a/src/components/permissions/NotebookEditPermissionRequest/NotebookEditToolDiff.tsx +++ b/src/components/permissions/NotebookEditPermissionRequest/NotebookEditToolDiff.tsx @@ -1,41 +1,37 @@ -import { relative } from 'path' -import * as React from 'react' -import { Suspense, use, useMemo } from 'react' -import { Box, NoSelect, Text } from '@anthropic/ink' -import type { - NotebookCell, - NotebookCellType, - NotebookContent, -} from '../../../types/notebook.js' -import { intersperse } from '../../../utils/array.js' -import { getCwd } from '../../../utils/cwd.js' -import { getPatchForDisplay } from '../../../utils/diff.js' -import { getFsImplementation } from '../../../utils/fsOperations.js' -import { safeParseJSON } from '../../../utils/json.js' -import { parseCellId } from '../../../utils/notebook.js' -import { HighlightedCode } from '../../HighlightedCode.js' -import { StructuredDiff } from '../../StructuredDiff.js' +import { relative } from 'path'; +import * as React from 'react'; +import { Suspense, use, useMemo } from 'react'; +import { Box, NoSelect, Text } from '@anthropic/ink'; +import type { NotebookCell, NotebookCellType, NotebookContent } from '../../../types/notebook.js'; +import { intersperse } from '../../../utils/array.js'; +import { getCwd } from '../../../utils/cwd.js'; +import { getPatchForDisplay } from '../../../utils/diff.js'; +import { getFsImplementation } from '../../../utils/fsOperations.js'; +import { safeParseJSON } from '../../../utils/json.js'; +import { parseCellId } from '../../../utils/notebook.js'; +import { HighlightedCode } from '../../HighlightedCode.js'; +import { StructuredDiff } from '../../StructuredDiff.js'; type Props = { - notebook_path: string - cell_id: string | undefined - new_source: string - cell_type?: NotebookCellType - edit_mode?: string - verbose: boolean - width: number -} + notebook_path: string; + cell_id: string | undefined; + new_source: string; + cell_type?: NotebookCellType; + edit_mode?: string; + verbose: boolean; + width: number; +}; type InnerProps = { - notebook_path: string - cell_id: string | undefined - new_source: string - cell_type?: NotebookCellType - edit_mode?: string - verbose: boolean - width: number - promise: Promise -} + notebook_path: string; + cell_id: string | undefined; + new_source: string; + cell_type?: NotebookCellType; + edit_mode?: string; + verbose: boolean; + width: number; + promise: Promise; +}; export function NotebookEditToolDiff(props: Props): React.ReactNode { // Create a promise that never rejects so we can handle errors inline. @@ -47,13 +43,13 @@ export function NotebookEditToolDiff(props: Props): React.ReactNode { .then(content => safeParseJSON(content) as NotebookContent | null) .catch(() => null), [props.notebook_path], - ) + ); return ( - ) + ); } function NotebookEditToolDiffInner({ @@ -66,30 +62,30 @@ function NotebookEditToolDiffInner({ width, promise, }: InnerProps): React.ReactNode { - const notebookData = use(promise) + const notebookData = use(promise); const oldSource = useMemo(() => { if (!notebookData || !cell_id) { - return '' + return ''; } - const cellIndex = parseCellId(cell_id) + const cellIndex = parseCellId(cell_id); if (cellIndex !== undefined) { if (notebookData.cells[cellIndex]) { - const source = notebookData.cells[cellIndex].source - return Array.isArray(source) ? source.join('') : source + const source = notebookData.cells[cellIndex].source; + return Array.isArray(source) ? source.join('') : source; } - return '' + return ''; } - const cell = notebookData.cells.find((cell: NotebookCell) => cell.id === cell_id) + const cell = notebookData.cells.find((cell: NotebookCell) => cell.id === cell_id); if (!cell) { - return '' + return ''; } - return Array.isArray(cell.source) ? cell.source.join('') : cell.source - }, [notebookData, cell_id]) + return Array.isArray(cell.source) ? cell.source.join('') : cell.source; + }, [notebookData, cell_id]); const hunks = useMemo(() => { if (!notebookData || edit_mode === 'insert' || edit_mode === 'delete') { - return null + return null; } // Create a "fake" file content with just the cell source // This allows us to use the regular diff mechanism @@ -104,28 +100,26 @@ function NotebookEditToolDiffInner({ }, ], ignoreWhitespace: false, - }) - }, [notebookData, notebook_path, oldSource, new_source, edit_mode]) + }); + }, [notebookData, notebook_path, oldSource, new_source, edit_mode]); - let editTypeDescription: string + let editTypeDescription: string; switch (edit_mode) { case 'insert': - editTypeDescription = 'Insert new cell' - break + editTypeDescription = 'Insert new cell'; + break; case 'delete': - editTypeDescription = 'Delete cell' - break + editTypeDescription = 'Delete cell'; + break; default: - editTypeDescription = 'Replace cell contents' + editTypeDescription = 'Replace cell contents'; } return ( - - {verbose ? notebook_path : relative(getCwd(), notebook_path)} - + {verbose ? notebook_path : relative(getCwd(), notebook_path)} {editTypeDescription} for cell {cell_id} {cell_type ? ` (${cell_type})` : ''} @@ -137,10 +131,7 @@ function NotebookEditToolDiffInner({ ) : edit_mode === 'insert' ? ( - + ) : hunks ? ( intersperse( @@ -162,12 +153,9 @@ function NotebookEditToolDiffInner({ ), ) ) : ( - + )} - ) + ); } diff --git a/src/components/permissions/PermissionDecisionDebugInfo.tsx b/src/components/permissions/PermissionDecisionDebugInfo.tsx index fbbaf6b18..67716ac55 100644 --- a/src/components/permissions/PermissionDecisionDebugInfo.tsx +++ b/src/components/permissions/PermissionDecisionDebugInfo.tsx @@ -1,111 +1,95 @@ -import { feature } from 'bun:bundle' -import chalk from 'chalk' -import figures from 'figures' -import React, { useMemo } from 'react' -import { Ansi, Box, color, Text, useTheme } from '@anthropic/ink' -import { useAppState } from '../../state/AppState.js' -import type { PermissionMode } from '../../utils/permissions/PermissionMode.js' -import { permissionModeTitle } from '../../utils/permissions/PermissionMode.js' -import type { - PermissionDecision, - PermissionDecisionReason, -} from '../../utils/permissions/PermissionResult.js' -import { extractRules } from '../../utils/permissions/PermissionUpdate.js' -import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js' -import { permissionRuleValueToString } from '../../utils/permissions/permissionRuleParser.js' -import { detectUnreachableRules } from '../../utils/permissions/shadowedRuleDetection.js' -import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' -import { getSettingSourceDisplayNameLowercase } from '../../utils/settings/constants.js' +import { feature } from 'bun:bundle'; +import chalk from 'chalk'; +import figures from 'figures'; +import React, { useMemo } from 'react'; +import { Ansi, Box, color, Text, useTheme } from '@anthropic/ink'; +import { useAppState } from '../../state/AppState.js'; +import type { PermissionMode } from '../../utils/permissions/PermissionMode.js'; +import { permissionModeTitle } from '../../utils/permissions/PermissionMode.js'; +import type { PermissionDecision, PermissionDecisionReason } from '../../utils/permissions/PermissionResult.js'; +import { extractRules } from '../../utils/permissions/PermissionUpdate.js'; +import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js'; +import { permissionRuleValueToString } from '../../utils/permissions/permissionRuleParser.js'; +import { detectUnreachableRules } from '../../utils/permissions/shadowedRuleDetection.js'; +import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'; +import { getSettingSourceDisplayNameLowercase } from '../../utils/settings/constants.js'; type PermissionDecisionInfoItemProps = { - title?: string - decisionReason: PermissionDecisionReason -} + title?: string; + decisionReason: PermissionDecisionReason; +}; function decisionReasonDisplayString( decisionReason: PermissionDecisionReason & { - type: Exclude + type: Exclude; }, ): string { - if ( - (feature('BASH_CLASSIFIER') || feature('TRANSCRIPT_CLASSIFIER')) && - decisionReason.type === 'classifier' - ) { - return `${chalk.bold(decisionReason.classifier)} classifier: ${decisionReason.reason}` + if ((feature('BASH_CLASSIFIER') || feature('TRANSCRIPT_CLASSIFIER')) && decisionReason.type === 'classifier') { + return `${chalk.bold(decisionReason.classifier)} classifier: ${decisionReason.reason}`; } switch (decisionReason.type) { case 'rule': - return `${chalk.bold(permissionRuleValueToString(decisionReason.rule.ruleValue))} rule from ${getSettingSourceDisplayNameLowercase(decisionReason.rule.source)}` + return `${chalk.bold(permissionRuleValueToString(decisionReason.rule.ruleValue))} rule from ${getSettingSourceDisplayNameLowercase(decisionReason.rule.source)}`; case 'mode': - return `${permissionModeTitle(decisionReason.mode)} mode` + return `${permissionModeTitle(decisionReason.mode)} mode`; case 'sandboxOverride': - return 'Requires permission to bypass sandbox' + return 'Requires permission to bypass sandbox'; case 'workingDir': - return decisionReason.reason + return decisionReason.reason; case 'safetyCheck': case 'other': - return decisionReason.reason + return decisionReason.reason; case 'permissionPromptTool': - return `${chalk.bold(decisionReason.permissionPromptToolName)} permission prompt tool` + return `${chalk.bold(decisionReason.permissionPromptToolName)} permission prompt tool`; case 'hook': return decisionReason.reason ? `${chalk.bold(decisionReason.hookName)} hook: ${decisionReason.reason}` - : `${chalk.bold(decisionReason.hookName)} hook` + : `${chalk.bold(decisionReason.hookName)} hook`; case 'asyncAgent': - return decisionReason.reason + return decisionReason.reason; default: - return '' + return ''; } } -function PermissionDecisionInfoItem({ - title, - decisionReason, -}: PermissionDecisionInfoItemProps): React.ReactNode { - const [theme] = useTheme() +function PermissionDecisionInfoItem({ title, decisionReason }: PermissionDecisionInfoItemProps): React.ReactNode { + const [theme] = useTheme(); function formatDecisionReason(): React.ReactNode { switch (decisionReason.type) { case 'subcommandResults': return ( - {Array.from(decisionReason.reasons.entries()).map( - ([subcommand, result]) => { - const icon = - result.behavior === 'allow' - ? color('success', theme)(figures.tick) - : color('error', theme)(figures.cross) - return ( - + {Array.from(decisionReason.reasons.entries()).map(([subcommand, result]) => { + const icon = + result.behavior === 'allow' + ? color('success', theme)(figures.tick) + : color('error', theme)(figures.cross); + return ( + + + {icon} {subcommand} + + {result.decisionReason !== undefined && result.decisionReason.type !== 'subcommandResults' && ( - {icon} {subcommand} + + {' '}⎿{' '} + + {decisionReasonDisplayString(result.decisionReason)} - {result.decisionReason !== undefined && - result.decisionReason.type !== 'subcommandResults' && ( - - - {' '}⎿{' '} - - - {decisionReasonDisplayString(result.decisionReason)} - - - )} - {result.behavior === 'ask' && ( - - )} - - ) - }, - )} + )} + {result.behavior === 'ask' && } + + ); + })} - ) + ); default: return ( {decisionReasonDisplayString(decisionReason)} - ) + ); } } @@ -114,65 +98,54 @@ function PermissionDecisionInfoItem({ {title && {title}} {formatDecisionReason()} - ) + ); } -function SuggestedRules({ - suggestions, -}: { - suggestions: PermissionUpdate[] | undefined -}): React.ReactNode { - const rules = extractRules(suggestions) - if (rules.length === 0) return null +function SuggestedRules({ suggestions }: { suggestions: PermissionUpdate[] | undefined }): React.ReactNode { + const rules = extractRules(suggestions); + if (rules.length === 0) return null; return ( {' '}⎿{' '} - Suggested rules:{' '} - - {rules - .map(rule => chalk.bold(permissionRuleValueToString(rule))) - .join(', ')} - + Suggested rules: {rules.map(rule => chalk.bold(permissionRuleValueToString(rule))).join(', ')} - ) + ); } type Props = { - permissionResult: PermissionDecision - toolName?: string // Filter unreachable rules to this tool -} + permissionResult: PermissionDecision; + toolName?: string; // Filter unreachable rules to this tool +}; // Helper function to extract directories from permission updates function extractDirectories(updates: PermissionUpdate[] | undefined): string[] { - if (!updates) return [] + if (!updates) return []; return updates.flatMap(update => { switch (update.type) { case 'addDirectories': - return update.directories + return update.directories; default: - return [] + return []; } - }) + }); } // Helper function to extract mode from permission updates -function extractMode( - updates: PermissionUpdate[] | undefined, -): PermissionMode | undefined { - if (!updates) return undefined - const update = updates.findLast(u => u.type === 'setMode') - return update?.type === 'setMode' ? update.mode : undefined +function extractMode(updates: PermissionUpdate[] | undefined): PermissionMode | undefined { + if (!updates) return undefined; + const update = updates.findLast(u => u.type === 'setMode'); + return update?.type === 'setMode' ? update.mode : undefined; } function SuggestionDisplay({ suggestions, width, }: { - suggestions: PermissionUpdate[] | undefined - width: number + suggestions: PermissionUpdate[] | undefined; + width: number; }): React.ReactNode { if (!suggestions || suggestions.length === 0) { return ( @@ -182,12 +155,12 @@ function SuggestionDisplay({ None - ) + ); } - const rules = extractRules(suggestions) - const directories = extractDirectories(suggestions) - const mode = extractMode(suggestions) + const rules = extractRules(suggestions); + const directories = extractDirectories(suggestions); + const mode = extractMode(suggestions); // If nothing to display, show None if (rules.length === 0 && directories.length === 0 && !mode) { @@ -198,7 +171,7 @@ function SuggestionDisplay({ None - ) + ); } return ( @@ -252,28 +225,23 @@ function SuggestionDisplay({ )} - ) + ); } -export function PermissionDecisionDebugInfo({ - permissionResult, - toolName, -}: Props): React.ReactNode { - const toolPermissionContext = useAppState(s => s.toolPermissionContext) - const decisionReason = permissionResult.decisionReason - const suggestions = - 'suggestions' in permissionResult ? permissionResult.suggestions : undefined +export function PermissionDecisionDebugInfo({ permissionResult, toolName }: Props): React.ReactNode { + const toolPermissionContext = useAppState(s => s.toolPermissionContext); + const decisionReason = permissionResult.decisionReason; + const suggestions = 'suggestions' in permissionResult ? permissionResult.suggestions : undefined; const unreachableRules = useMemo(() => { const sandboxAutoAllowEnabled = - SandboxManager.isSandboxingEnabled() && - SandboxManager.isAutoAllowBashIfSandboxedEnabled() + SandboxManager.isSandboxingEnabled() && SandboxManager.isAutoAllowBashIfSandboxedEnabled(); const all = detectUnreachableRules(toolPermissionContext, { sandboxAutoAllowEnabled, - }) + }); // Get the suggested rules from the permission result - const suggestedRules = extractRules(suggestions) + const suggestedRules = extractRules(suggestions); // Filter to rules that match any of the suggested rules // A rule matches if it has the same toolName and ruleContent @@ -281,21 +249,20 @@ export function PermissionDecisionDebugInfo({ return all.filter(u => suggestedRules.some( suggested => - suggested.toolName === u.rule.ruleValue.toolName && - suggested.ruleContent === u.rule.ruleValue.ruleContent, + suggested.toolName === u.rule.ruleValue.toolName && suggested.ruleContent === u.rule.ruleValue.ruleContent, ), - ) + ); } // Fallback: filter by tool name if specified if (toolName) { - return all.filter(u => u.rule.ruleValue.toolName === toolName) + return all.filter(u => u.rule.ruleValue.toolName === toolName); } - return all - }, [toolPermissionContext, toolName, suggestions]) + return all; + }, [toolPermissionContext, toolName, suggestions]); - const WIDTH = 10 + const WIDTH = 10; return ( @@ -331,9 +298,7 @@ export function PermissionDecisionDebugInfo({ {unreachableRules.map((u, i) => ( - - {permissionRuleValueToString(u.rule.ruleValue)} - + {permissionRuleValueToString(u.rule.ruleValue)} {' '} {u.reason} @@ -346,5 +311,5 @@ export function PermissionDecisionDebugInfo({ )} - ) + ); } diff --git a/src/components/permissions/PermissionDialog.tsx b/src/components/permissions/PermissionDialog.tsx index 40e99f50f..86877cc6d 100644 --- a/src/components/permissions/PermissionDialog.tsx +++ b/src/components/permissions/PermissionDialog.tsx @@ -1,19 +1,19 @@ -import * as React from 'react' -import { Box } from '@anthropic/ink' -import type { Theme } from '../../utils/theme.js' -import { PermissionRequestTitle } from './PermissionRequestTitle.js' -import type { WorkerBadgeProps } from './WorkerBadge.js' +import * as React from 'react'; +import { Box } from '@anthropic/ink'; +import type { Theme } from '../../utils/theme.js'; +import { PermissionRequestTitle } from './PermissionRequestTitle.js'; +import type { WorkerBadgeProps } from './WorkerBadge.js'; type Props = { - title: string - subtitle?: React.ReactNode - color?: keyof Theme - titleColor?: keyof Theme - innerPaddingX?: number - workerBadge?: WorkerBadgeProps - titleRight?: React.ReactNode - children: React.ReactNode -} + title: string; + subtitle?: React.ReactNode; + color?: keyof Theme; + titleColor?: keyof Theme; + innerPaddingX?: number; + workerBadge?: WorkerBadgeProps; + titleRight?: React.ReactNode; + children: React.ReactNode; +}; export function PermissionDialog({ title, @@ -37,12 +37,7 @@ export function PermissionDialog({ > - + {titleRight} @@ -50,5 +45,5 @@ export function PermissionDialog({ {children} - ) + ); } diff --git a/src/components/permissions/PermissionExplanation.tsx b/src/components/permissions/PermissionExplanation.tsx index 367fc7ccb..d08d31070 100644 --- a/src/components/permissions/PermissionExplanation.tsx +++ b/src/components/permissions/PermissionExplanation.tsx @@ -1,25 +1,21 @@ -import React, { Suspense, use, useState } from 'react' -import { Box, Text } from '@anthropic/ink' -import { useKeybinding } from '../../keybindings/useKeybinding.js' -import { logEvent } from '../../services/analytics/index.js' -import type { Message } from '../../types/message.js' +import React, { Suspense, use, useState } from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { useKeybinding } from '../../keybindings/useKeybinding.js'; +import { logEvent } from '../../services/analytics/index.js'; +import type { Message } from '../../types/message.js'; import { generatePermissionExplanation, isPermissionExplainerEnabled, type PermissionExplanation as PermissionExplanationType, type RiskLevel, -} from '../../utils/permissions/permissionExplainer.js' -import { ShimmerChar } from '../Spinner/ShimmerChar.js' -import { useShimmerAnimation } from '../Spinner/useShimmerAnimation.js' +} from '../../utils/permissions/permissionExplainer.js'; +import { ShimmerChar } from '../Spinner/ShimmerChar.js'; +import { useShimmerAnimation } from '../Spinner/useShimmerAnimation.js'; -const LOADING_MESSAGE = 'Loading explanation…' +const LOADING_MESSAGE = 'Loading explanation…'; function ShimmerLoadingText(): React.ReactNode { - const [ref, glimmerIndex] = useShimmerAnimation( - 'responding', - LOADING_MESSAGE, - false, - ) + const [ref, glimmerIndex] = useShimmerAnimation('responding', LOADING_MESSAGE, false); return ( @@ -36,58 +32,56 @@ function ShimmerLoadingText(): React.ReactNode { ))} - ) + ); } function getRiskColor(riskLevel: RiskLevel): 'success' | 'warning' | 'error' { switch (riskLevel) { case 'LOW': - return 'success' + return 'success'; case 'MEDIUM': - return 'warning' + return 'warning'; case 'HIGH': - return 'error' + return 'error'; } } function getRiskLabel(riskLevel: RiskLevel): string { switch (riskLevel) { case 'LOW': - return 'Low risk' + return 'Low risk'; case 'MEDIUM': - return 'Med risk' + return 'Med risk'; case 'HIGH': - return 'High risk' + return 'High risk'; } } type PermissionExplanationProps = { - toolName: string - toolInput: unknown - toolDescription?: string - messages?: Message[] -} + toolName: string; + toolInput: unknown; + toolDescription?: string; + messages?: Message[]; +}; type ExplainerState = { - visible: boolean - enabled: boolean - promise: Promise | null -} + visible: boolean; + enabled: boolean; + promise: Promise | null; +}; /** * Creates an explanation promise that never rejects. * Errors are caught and returned as null. */ -function createExplanationPromise( - props: PermissionExplanationProps, -): Promise { +function createExplanationPromise(props: PermissionExplanationProps): Promise { return generatePermissionExplanation({ toolName: props.toolName, toolInput: props.toolInput, toolDescription: props.toolDescription, messages: props.messages, signal: new AbortController().signal, // Won't abort - request is fast enough - }).catch(() => null) + }).catch(() => null); } /** @@ -95,50 +89,43 @@ function createExplanationPromise( * Creates the fetch promise lazily (only when user hits Ctrl+E) * to avoid consuming tokens for explanations users never view. */ -export function usePermissionExplainerUI( - props: PermissionExplanationProps, -): ExplainerState { - const enabled = isPermissionExplainerEnabled() - const [visible, setVisible] = useState(false) - const [promise, setPromise] = - useState | null>(null) +export function usePermissionExplainerUI(props: PermissionExplanationProps): ExplainerState { + const enabled = isPermissionExplainerEnabled(); + const [visible, setVisible] = useState(false); + const [promise, setPromise] = useState | null>(null); // Use keybinding for ctrl+e toggle (configurable via keybindings.json) useKeybinding( 'confirm:toggleExplanation', () => { if (!visible) { - logEvent('tengu_permission_explainer_shortcut_used', {}) + logEvent('tengu_permission_explainer_shortcut_used', {}); // Only create the promise on first toggle (lazy loading) if (!promise) { - setPromise(createExplanationPromise(props)) + setPromise(createExplanationPromise(props)); } } - setVisible(v => !v) + setVisible(v => !v); }, { context: 'Confirmation', isActive: enabled }, - ) + ); - return { visible, enabled, promise } + return { visible, enabled, promise }; } /** * Inner component that uses React 19's use() to read the promise. * Suspends while loading, returns null on error. */ -function ExplanationResult({ - promise, -}: { - promise: Promise -}): React.ReactNode { - const explanation = use(promise) +function ExplanationResult({ promise }: { promise: Promise }): React.ReactNode { + const explanation = use(promise); if (!explanation) { return ( Explanation unavailable - ) + ); } return ( @@ -149,14 +136,12 @@ function ExplanationResult({ - - {getRiskLabel(explanation.riskLevel)}: - + {getRiskLabel(explanation.riskLevel)}: {explanation.risk} - ) + ); } /** @@ -166,11 +151,11 @@ export function PermissionExplainerContent({ visible, promise, }: { - visible: boolean - promise: Promise | null + visible: boolean; + promise: Promise | null; }): React.ReactNode { if (!visible || !promise) { - return null + return null; } return ( @@ -183,5 +168,5 @@ export function PermissionExplainerContent({ > - ) + ); } diff --git a/src/components/permissions/PermissionPrompt.tsx b/src/components/permissions/PermissionPrompt.tsx index 0e16fad25..84b7bd1c3 100644 --- a/src/components/permissions/PermissionPrompt.tsx +++ b/src/components/permissions/PermissionPrompt.tsx @@ -1,43 +1,43 @@ -import React, { type ReactNode, useCallback, useMemo, useState } from 'react' -import { Box, Text } from '@anthropic/ink' -import type { KeybindingAction } from '../../keybindings/types.js' -import { useKeybindings } from '../../keybindings/useKeybinding.js' +import React, { type ReactNode, useCallback, useMemo, useState } from 'react'; +import { Box, Text } from '@anthropic/ink'; +import type { KeybindingAction } from '../../keybindings/types.js'; +import { useKeybindings } from '../../keybindings/useKeybinding.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from '../../services/analytics/index.js' -import { useSetAppState } from '../../state/AppState.js' -import { type OptionWithDescription, Select } from '../CustomSelect/select.js' +} from '../../services/analytics/index.js'; +import { useSetAppState } from '../../state/AppState.js'; +import { type OptionWithDescription, Select } from '../CustomSelect/select.js'; -export type FeedbackType = 'accept' | 'reject' +export type FeedbackType = 'accept' | 'reject'; export type PermissionPromptOption = { - value: T - label: ReactNode + value: T; + label: ReactNode; feedbackConfig?: { - type: FeedbackType - placeholder?: string - } - keybinding?: KeybindingAction -} + type: FeedbackType; + placeholder?: string; + }; + keybinding?: KeybindingAction; +}; export type ToolAnalyticsContext = { - toolName: string - isMcp: boolean -} + toolName: string; + isMcp: boolean; +}; export type PermissionPromptProps = { - options: PermissionPromptOption[] - onSelect: (value: T, feedback?: string) => void - onCancel?: () => void - question?: string | ReactNode - toolAnalyticsContext?: ToolAnalyticsContext -} + options: PermissionPromptOption[]; + onSelect: (value: T, feedback?: string) => void; + onCancel?: () => void; + question?: string | ReactNode; + toolAnalyticsContext?: ToolAnalyticsContext; +}; const DEFAULT_PLACEHOLDERS: Record = { accept: 'tell Claude what to do next', reject: 'tell Claude what to do differently', -} +}; /** * Shared component for permission prompts with optional feedback input. @@ -56,44 +56,41 @@ export function PermissionPrompt({ question = 'Do you want to proceed?', toolAnalyticsContext, }: PermissionPromptProps): React.ReactNode { - const setAppState = useSetAppState() - const [acceptFeedback, setAcceptFeedback] = useState('') - const [rejectFeedback, setRejectFeedback] = useState('') - const [acceptInputMode, setAcceptInputMode] = useState(false) - const [rejectInputMode, setRejectInputMode] = useState(false) - const [focusedValue, setFocusedValue] = useState(null) + const setAppState = useSetAppState(); + const [acceptFeedback, setAcceptFeedback] = useState(''); + const [rejectFeedback, setRejectFeedback] = useState(''); + const [acceptInputMode, setAcceptInputMode] = useState(false); + const [rejectInputMode, setRejectInputMode] = useState(false); + const [focusedValue, setFocusedValue] = useState(null); // Track whether user ever entered feedback mode (persists after collapse) - const [acceptFeedbackModeEntered, setAcceptFeedbackModeEntered] = - useState(false) - const [rejectFeedbackModeEntered, setRejectFeedbackModeEntered] = - useState(false) + const [acceptFeedbackModeEntered, setAcceptFeedbackModeEntered] = useState(false); + const [rejectFeedbackModeEntered, setRejectFeedbackModeEntered] = useState(false); // Find which option is focused and whether it has feedback config - const focusedOption = options.find(opt => opt.value === focusedValue) - const focusedFeedbackType = focusedOption?.feedbackConfig?.type + const focusedOption = options.find(opt => opt.value === focusedValue); + const focusedFeedbackType = focusedOption?.feedbackConfig?.type; // Show Tab hint when focused on a feedback-enabled option that's not already in input mode const showTabHint = - (focusedFeedbackType === 'accept' && !acceptInputMode) || - (focusedFeedbackType === 'reject' && !rejectInputMode) + (focusedFeedbackType === 'accept' && !acceptInputMode) || (focusedFeedbackType === 'reject' && !rejectInputMode); // Transform options to Select-compatible format const selectOptions = useMemo((): OptionWithDescription[] => { return options.map(opt => { - const { value, label, feedbackConfig } = opt + const { value, label, feedbackConfig } = opt; // No feedback config = simple option if (!feedbackConfig) { return { label, value, - } + }; } - const { type, placeholder } = feedbackConfig - const isInputMode = type === 'accept' ? acceptInputMode : rejectInputMode - const onChange = type === 'accept' ? setAcceptFeedback : setRejectFeedback - const defaultPlaceholder = DEFAULT_PLACEHOLDERS[type] + const { type, placeholder } = feedbackConfig; + const isInputMode = type === 'accept' ? acceptInputMode : rejectInputMode; + const onChange = type === 'accept' ? setAcceptFeedback : setRejectFeedback; + const defaultPlaceholder = DEFAULT_PLACEHOLDERS[type]; // When in input mode, show input field if (isInputMode) { @@ -104,93 +101,86 @@ export function PermissionPrompt({ placeholder: placeholder ?? defaultPlaceholder, onChange, allowEmptySubmitToCancel: true, - } + }; } // Not in input mode - show simple option return { label, value, - } - }) - }, [options, acceptInputMode, rejectInputMode]) + }; + }); + }, [options, acceptInputMode, rejectInputMode]); // Handle Tab key to toggle input mode const handleInputModeToggle = useCallback( (value: T) => { - const option = options.find(opt => opt.value === value) - if (!option?.feedbackConfig) return + const option = options.find(opt => opt.value === value); + if (!option?.feedbackConfig) return; - const { type } = option.feedbackConfig + const { type } = option.feedbackConfig; const analyticsProps = { - toolName: - toolAnalyticsContext?.toolName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + toolName: toolAnalyticsContext?.toolName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, isMcp: toolAnalyticsContext?.isMcp ?? false, - } + }; if (type === 'accept') { if (acceptInputMode) { - setAcceptInputMode(false) - logEvent('tengu_accept_feedback_mode_collapsed', analyticsProps) + setAcceptInputMode(false); + logEvent('tengu_accept_feedback_mode_collapsed', analyticsProps); } else { - setAcceptInputMode(true) - setAcceptFeedbackModeEntered(true) - logEvent('tengu_accept_feedback_mode_entered', analyticsProps) + setAcceptInputMode(true); + setAcceptFeedbackModeEntered(true); + logEvent('tengu_accept_feedback_mode_entered', analyticsProps); } } else if (type === 'reject') { if (rejectInputMode) { - setRejectInputMode(false) - logEvent('tengu_reject_feedback_mode_collapsed', analyticsProps) + setRejectInputMode(false); + logEvent('tengu_reject_feedback_mode_collapsed', analyticsProps); } else { - setRejectInputMode(true) - setRejectFeedbackModeEntered(true) - logEvent('tengu_reject_feedback_mode_entered', analyticsProps) + setRejectInputMode(true); + setRejectFeedbackModeEntered(true); + logEvent('tengu_reject_feedback_mode_entered', analyticsProps); } } }, [options, acceptInputMode, rejectInputMode, toolAnalyticsContext], - ) + ); // Handle selection const handleSelect = useCallback( (value: T) => { - const option = options.find(opt => opt.value === value) - if (!option) return + const option = options.find(opt => opt.value === value); + if (!option) return; // Get feedback if applicable - let feedback: string | undefined + let feedback: string | undefined; if (option.feedbackConfig) { - const rawFeedback = - option.feedbackConfig.type === 'accept' - ? acceptFeedback - : rejectFeedback - const trimmedFeedback = rawFeedback.trim() + const rawFeedback = option.feedbackConfig.type === 'accept' ? acceptFeedback : rejectFeedback; + const trimmedFeedback = rawFeedback.trim(); if (trimmedFeedback) { - feedback = trimmedFeedback + feedback = trimmedFeedback; } // Log accept/reject submission with feedback context const analyticsProps = { - toolName: - toolAnalyticsContext?.toolName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + toolName: toolAnalyticsContext?.toolName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, isMcp: toolAnalyticsContext?.isMcp ?? false, has_instructions: !!trimmedFeedback, instructions_length: trimmedFeedback?.length ?? 0, entered_feedback_mode: - option.feedbackConfig.type === 'accept' - ? acceptFeedbackModeEntered - : rejectFeedbackModeEntered, - } + option.feedbackConfig.type === 'accept' ? acceptFeedbackModeEntered : rejectFeedbackModeEntered, + }; if (option.feedbackConfig.type === 'accept') { - logEvent('tengu_accept_submitted', analyticsProps) + logEvent('tengu_accept_submitted', analyticsProps); } else if (option.feedbackConfig.type === 'reject') { - logEvent('tengu_reject_submitted', analyticsProps) + logEvent('tengu_reject_submitted', analyticsProps); } } - onSelect(value, feedback) + onSelect(value, feedback); }, [ options, @@ -201,24 +191,24 @@ export function PermissionPrompt({ acceptFeedbackModeEntered, rejectFeedbackModeEntered, ], - ) + ); // Register keybinding handlers for options that have a keybinding set const keybindingHandlers = useMemo(() => { - const handlers: Record void> = {} + const handlers: Record void> = {}; for (const opt of options) { if (opt.keybinding) { - handlers[opt.keybinding] = () => handleSelect(opt.value) + handlers[opt.keybinding] = () => handleSelect(opt.value); } } - return handlers - }, [options, handleSelect]) + return handlers; + }, [options, handleSelect]); - useKeybindings(keybindingHandlers, { context: 'Confirmation' }) + useKeybindings(keybindingHandlers, { context: 'Confirmation' }); // Handle cancel (Esc) const handleCancel = useCallback(() => { - logEvent('tengu_permission_request_escape', {}) + logEvent('tengu_permission_request_escape', {}); // Increment escape count for attribution tracking setAppState(prev => ({ ...prev, @@ -226,9 +216,9 @@ export function PermissionPrompt({ ...prev.attribution, escapeCount: prev.attribution.escapeCount + 1, }, - })) - onCancel?.() - }, [onCancel, setAppState]) + })); + onCancel?.(); + }, [onCancel, setAppState]); return ( @@ -240,22 +230,14 @@ export function PermissionPrompt({ onCancel={handleCancel} onFocus={value => { // Reset input mode when navigating away, but only if no text typed - const newOption = options.find(opt => opt.value === value) - if ( - newOption?.feedbackConfig?.type !== 'accept' && - acceptInputMode && - !acceptFeedback.trim() - ) { - setAcceptInputMode(false) + const newOption = options.find(opt => opt.value === value); + if (newOption?.feedbackConfig?.type !== 'accept' && acceptInputMode && !acceptFeedback.trim()) { + setAcceptInputMode(false); } - if ( - newOption?.feedbackConfig?.type !== 'reject' && - rejectInputMode && - !rejectFeedback.trim() - ) { - setRejectInputMode(false) + if (newOption?.feedbackConfig?.type !== 'reject' && rejectInputMode && !rejectFeedback.trim()) { + setRejectInputMode(false); } - setFocusedValue(value) + setFocusedValue(value); }} onInputModeToggle={handleInputModeToggle} /> @@ -263,5 +245,5 @@ export function PermissionPrompt({ Esc to cancel{showTabHint && ' · Tab to amend'} - ) + ); } diff --git a/src/components/permissions/PermissionRequest.tsx b/src/components/permissions/PermissionRequest.tsx index a08f78519..6182624e8 100644 --- a/src/components/permissions/PermissionRequest.tsx +++ b/src/components/permissions/PermissionRequest.tsx @@ -1,125 +1,123 @@ -import { feature } from 'bun:bundle' -import * as React from 'react' -import { EnterPlanModeTool } from '@claude-code-best/builtin-tools/tools/EnterPlanModeTool/EnterPlanModeTool.js' -import { ExitPlanModeV2Tool } from '@claude-code-best/builtin-tools/tools/ExitPlanModeTool/ExitPlanModeV2Tool.js' -import { useNotifyAfterTimeout } from '../../hooks/useNotifyAfterTimeout.js' -import { useKeybinding } from '../../keybindings/useKeybinding.js' -import type { AnyObject, Tool, ToolUseContext } from '../../Tool.js' -import { AskUserQuestionTool } from '@claude-code-best/builtin-tools/tools/AskUserQuestionTool/AskUserQuestionTool.js' -import { BashTool } from '@claude-code-best/builtin-tools/tools/BashTool/BashTool.js' -import { FileEditTool } from '@claude-code-best/builtin-tools/tools/FileEditTool/FileEditTool.js' -import { FileReadTool } from '@claude-code-best/builtin-tools/tools/FileReadTool/FileReadTool.js' -import { FileWriteTool } from '@claude-code-best/builtin-tools/tools/FileWriteTool/FileWriteTool.js' -import { GlobTool } from '@claude-code-best/builtin-tools/tools/GlobTool/GlobTool.js' -import { GrepTool } from '@claude-code-best/builtin-tools/tools/GrepTool/GrepTool.js' -import { NotebookEditTool } from '@claude-code-best/builtin-tools/tools/NotebookEditTool/NotebookEditTool.js' -import { PowerShellTool } from '@claude-code-best/builtin-tools/tools/PowerShellTool/PowerShellTool.js' -import { SkillTool } from '@claude-code-best/builtin-tools/tools/SkillTool/SkillTool.js' -import { WebFetchTool } from '@claude-code-best/builtin-tools/tools/WebFetchTool/WebFetchTool.js' -import type { AssistantMessage } from '../../types/message.js' -import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js' -import { AskUserQuestionPermissionRequest } from './AskUserQuestionPermissionRequest/AskUserQuestionPermissionRequest.js' -import { BashPermissionRequest } from './BashPermissionRequest/BashPermissionRequest.js' -import { EnterPlanModePermissionRequest } from './EnterPlanModePermissionRequest/EnterPlanModePermissionRequest.js' -import { ExitPlanModePermissionRequest } from './ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.js' -import { FallbackPermissionRequest } from './FallbackPermissionRequest.js' -import { FileEditPermissionRequest } from './FileEditPermissionRequest/FileEditPermissionRequest.js' -import { FilesystemPermissionRequest } from './FilesystemPermissionRequest/FilesystemPermissionRequest.js' -import { FileWritePermissionRequest } from './FileWritePermissionRequest/FileWritePermissionRequest.js' -import { NotebookEditPermissionRequest } from './NotebookEditPermissionRequest/NotebookEditPermissionRequest.js' -import { PowerShellPermissionRequest } from './PowerShellPermissionRequest/PowerShellPermissionRequest.js' -import { SkillPermissionRequest } from './SkillPermissionRequest/SkillPermissionRequest.js' -import { WebFetchPermissionRequest } from './WebFetchPermissionRequest/WebFetchPermissionRequest.js' +import { feature } from 'bun:bundle'; +import * as React from 'react'; +import { EnterPlanModeTool } from '@claude-code-best/builtin-tools/tools/EnterPlanModeTool/EnterPlanModeTool.js'; +import { ExitPlanModeV2Tool } from '@claude-code-best/builtin-tools/tools/ExitPlanModeTool/ExitPlanModeV2Tool.js'; +import { useNotifyAfterTimeout } from '../../hooks/useNotifyAfterTimeout.js'; +import { useKeybinding } from '../../keybindings/useKeybinding.js'; +import type { AnyObject, Tool, ToolUseContext } from '../../Tool.js'; +import { AskUserQuestionTool } from '@claude-code-best/builtin-tools/tools/AskUserQuestionTool/AskUserQuestionTool.js'; +import { BashTool } from '@claude-code-best/builtin-tools/tools/BashTool/BashTool.js'; +import { FileEditTool } from '@claude-code-best/builtin-tools/tools/FileEditTool/FileEditTool.js'; +import { FileReadTool } from '@claude-code-best/builtin-tools/tools/FileReadTool/FileReadTool.js'; +import { FileWriteTool } from '@claude-code-best/builtin-tools/tools/FileWriteTool/FileWriteTool.js'; +import { GlobTool } from '@claude-code-best/builtin-tools/tools/GlobTool/GlobTool.js'; +import { GrepTool } from '@claude-code-best/builtin-tools/tools/GrepTool/GrepTool.js'; +import { NotebookEditTool } from '@claude-code-best/builtin-tools/tools/NotebookEditTool/NotebookEditTool.js'; +import { PowerShellTool } from '@claude-code-best/builtin-tools/tools/PowerShellTool/PowerShellTool.js'; +import { SkillTool } from '@claude-code-best/builtin-tools/tools/SkillTool/SkillTool.js'; +import { WebFetchTool } from '@claude-code-best/builtin-tools/tools/WebFetchTool/WebFetchTool.js'; +import type { AssistantMessage } from '../../types/message.js'; +import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js'; +import { AskUserQuestionPermissionRequest } from './AskUserQuestionPermissionRequest/AskUserQuestionPermissionRequest.js'; +import { BashPermissionRequest } from './BashPermissionRequest/BashPermissionRequest.js'; +import { EnterPlanModePermissionRequest } from './EnterPlanModePermissionRequest/EnterPlanModePermissionRequest.js'; +import { ExitPlanModePermissionRequest } from './ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.js'; +import { FallbackPermissionRequest } from './FallbackPermissionRequest.js'; +import { FileEditPermissionRequest } from './FileEditPermissionRequest/FileEditPermissionRequest.js'; +import { FilesystemPermissionRequest } from './FilesystemPermissionRequest/FilesystemPermissionRequest.js'; +import { FileWritePermissionRequest } from './FileWritePermissionRequest/FileWritePermissionRequest.js'; +import { NotebookEditPermissionRequest } from './NotebookEditPermissionRequest/NotebookEditPermissionRequest.js'; +import { PowerShellPermissionRequest } from './PowerShellPermissionRequest/PowerShellPermissionRequest.js'; +import { SkillPermissionRequest } from './SkillPermissionRequest/SkillPermissionRequest.js'; +import { WebFetchPermissionRequest } from './WebFetchPermissionRequest/WebFetchPermissionRequest.js'; /* eslint-disable @typescript-eslint/no-require-imports */ const ReviewArtifactTool = feature('REVIEW_ARTIFACT') ? ( require('@claude-code-best/builtin-tools/tools/ReviewArtifactTool/ReviewArtifactTool.js') as typeof import('@claude-code-best/builtin-tools/tools/ReviewArtifactTool/ReviewArtifactTool.js') ).ReviewArtifactTool - : null + : null; const ReviewArtifactPermissionRequest = feature('REVIEW_ARTIFACT') ? ( require('./ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.js') as typeof import('./ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.js') ).ReviewArtifactPermissionRequest - : null + : null; const WorkflowTool = feature('WORKFLOW_SCRIPTS') ? ( require('@claude-code-best/builtin-tools/tools/WorkflowTool/WorkflowTool.js') as typeof import('@claude-code-best/builtin-tools/tools/WorkflowTool/WorkflowTool.js') ).WorkflowTool - : null + : null; const WorkflowPermissionRequest = feature('WORKFLOW_SCRIPTS') ? ( require('@claude-code-best/builtin-tools/tools/WorkflowTool/WorkflowPermissionRequest.js') as typeof import('@claude-code-best/builtin-tools/tools/WorkflowTool/WorkflowPermissionRequest.js') ).WorkflowPermissionRequest - : null + : null; const MonitorTool = feature('MONITOR_TOOL') ? ( require('@claude-code-best/builtin-tools/tools/MonitorTool/MonitorTool.js') as typeof import('@claude-code-best/builtin-tools/tools/MonitorTool/MonitorTool.js') ).MonitorTool - : null + : null; const MonitorPermissionRequest = feature('MONITOR_TOOL') ? ( require('./MonitorPermissionRequest/MonitorPermissionRequest.js') as typeof import('./MonitorPermissionRequest/MonitorPermissionRequest.js') ).MonitorPermissionRequest - : null + : null; -import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs' +import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'; /* eslint-enable @typescript-eslint/no-require-imports */ -import type { z } from 'zod/v4' -import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js' -import type { WorkerBadgeProps } from './WorkerBadge.js' +import type { z } from 'zod/v4'; +import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js'; +import type { WorkerBadgeProps } from './WorkerBadge.js'; -function permissionComponentForTool( - tool: Tool, -): React.ComponentType { +function permissionComponentForTool(tool: Tool): React.ComponentType { switch (tool) { case FileEditTool: - return FileEditPermissionRequest + return FileEditPermissionRequest; case FileWriteTool: - return FileWritePermissionRequest + return FileWritePermissionRequest; case BashTool: - return BashPermissionRequest + return BashPermissionRequest; case PowerShellTool: - return PowerShellPermissionRequest + return PowerShellPermissionRequest; case ReviewArtifactTool: - return ReviewArtifactPermissionRequest ?? FallbackPermissionRequest + return ReviewArtifactPermissionRequest ?? FallbackPermissionRequest; case WebFetchTool: - return WebFetchPermissionRequest + return WebFetchPermissionRequest; case NotebookEditTool: - return NotebookEditPermissionRequest + return NotebookEditPermissionRequest; case ExitPlanModeV2Tool: - return ExitPlanModePermissionRequest + return ExitPlanModePermissionRequest; case EnterPlanModeTool: - return EnterPlanModePermissionRequest + return EnterPlanModePermissionRequest; case SkillTool: - return SkillPermissionRequest + return SkillPermissionRequest; case AskUserQuestionTool: - return AskUserQuestionPermissionRequest + return AskUserQuestionPermissionRequest; case WorkflowTool: - return WorkflowPermissionRequest ?? FallbackPermissionRequest + return WorkflowPermissionRequest ?? FallbackPermissionRequest; case MonitorTool: - return MonitorPermissionRequest ?? FallbackPermissionRequest + return MonitorPermissionRequest ?? FallbackPermissionRequest; case GlobTool: case GrepTool: case FileReadTool: - return FilesystemPermissionRequest + return FilesystemPermissionRequest; default: - return FallbackPermissionRequest + return FallbackPermissionRequest; } } export type PermissionRequestProps = { - toolUseConfirm: ToolUseConfirm - toolUseContext: ToolUseContext - onDone(): void - onReject(): void - verbose: boolean - workerBadge: WorkerBadgeProps | undefined + toolUseConfirm: ToolUseConfirm; + toolUseContext: ToolUseContext; + onDone(): void; + onReject(): void; + verbose: boolean; + workerBadge: WorkerBadgeProps | undefined; /** * Register JSX to render in a sticky footer below the scrollable area. * Fullscreen mode only (non-fullscreen has no sticky area — terminal @@ -131,65 +129,60 @@ export type PermissionRequestProps = { * to avoid stale closures (React reconciles the JSX, preserving Select's * internal focus/input state). */ - setStickyFooter?: (jsx: React.ReactNode | null) => void -} + setStickyFooter?: (jsx: React.ReactNode | null) => void; +}; export type ToolUseConfirm = { - assistantMessage: AssistantMessage - tool: Tool - description: string - input: z.infer - toolUseContext: ToolUseContext - toolUseID: string - permissionResult: PermissionDecision - permissionPromptStartTimeMs: number + assistantMessage: AssistantMessage; + tool: Tool; + description: string; + input: z.infer; + toolUseContext: ToolUseContext; + toolUseID: string; + permissionResult: PermissionDecision; + permissionPromptStartTimeMs: number; /** * Called when user interacts with the permission dialog (e.g., arrow keys, tab, typing). * This prevents async auto-approval mechanisms (like the bash classifier) from * dismissing the dialog while the user is actively engaging with it. */ - classifierCheckInProgress?: boolean - classifierAutoApproved?: boolean - classifierMatchedRule?: string - workerBadge?: WorkerBadgeProps - onUserInteraction(): void - onAbort(): void - onDismissCheckmark?(): void + classifierCheckInProgress?: boolean; + classifierAutoApproved?: boolean; + classifierMatchedRule?: string; + workerBadge?: WorkerBadgeProps; + onUserInteraction(): void; + onAbort(): void; + onDismissCheckmark?(): void; onAllow( updatedInput: z.infer, permissionUpdates: PermissionUpdate[], feedback?: string, contentBlocks?: ContentBlockParam[], - ): void - onReject(feedback?: string, contentBlocks?: ContentBlockParam[]): void - recheckPermission(): Promise -} + ): void; + onReject(feedback?: string, contentBlocks?: ContentBlockParam[]): void; + recheckPermission(): Promise; +}; function getNotificationMessage(toolUseConfirm: ToolUseConfirm): string { - const toolName = toolUseConfirm.tool.userFacingName( - toolUseConfirm.input as never, - ) + const toolName = toolUseConfirm.tool.userFacingName(toolUseConfirm.input as never); if (toolUseConfirm.tool === ExitPlanModeV2Tool) { - return 'Claude Code needs your approval for the plan' + return 'Claude Code needs your approval for the plan'; } if (toolUseConfirm.tool === EnterPlanModeTool) { - return 'Claude Code wants to enter plan mode' + return 'Claude Code wants to enter plan mode'; } - if ( - feature('REVIEW_ARTIFACT') && - toolUseConfirm.tool === ReviewArtifactTool - ) { - return 'Claude needs your approval for a review artifact' + if (feature('REVIEW_ARTIFACT') && toolUseConfirm.tool === ReviewArtifactTool) { + return 'Claude needs your approval for a review artifact'; } if (!toolName || toolName.trim() === '') { - return 'Claude Code needs your attention' + return 'Claude Code needs your attention'; } - return `Claude needs your permission to use ${toolName}` + return `Claude needs your permission to use ${toolName}`; } // TODO: Move this to Tool.renderPermissionRequest @@ -206,17 +199,17 @@ export function PermissionRequest({ useKeybinding( 'app:interrupt', () => { - onDone() - onReject() - toolUseConfirm.onReject() + onDone(); + onReject(); + toolUseConfirm.onReject(); }, { context: 'Confirmation' }, - ) + ); - const notificationMessage = getNotificationMessage(toolUseConfirm) - useNotifyAfterTimeout(notificationMessage, 'permission_prompt') + const notificationMessage = getNotificationMessage(toolUseConfirm); + useNotifyAfterTimeout(notificationMessage, 'permission_prompt'); - const PermissionComponent = permissionComponentForTool(toolUseConfirm.tool) + const PermissionComponent = permissionComponentForTool(toolUseConfirm.tool); return ( - ) + ); } diff --git a/src/components/permissions/PermissionRequestTitle.tsx b/src/components/permissions/PermissionRequestTitle.tsx index eafa1f71f..a3ea135ff 100644 --- a/src/components/permissions/PermissionRequestTitle.tsx +++ b/src/components/permissions/PermissionRequestTitle.tsx @@ -1,21 +1,16 @@ -import * as React from 'react' -import { Box, Text } from '@anthropic/ink' -import type { Theme } from '../../utils/theme.js' -import type { WorkerBadgeProps } from './WorkerBadge.js' +import * as React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import type { Theme } from '../../utils/theme.js'; +import type { WorkerBadgeProps } from './WorkerBadge.js'; type Props = { - title: string - subtitle?: React.ReactNode - color?: keyof Theme - workerBadge?: WorkerBadgeProps -} + title: string; + subtitle?: React.ReactNode; + color?: keyof Theme; + workerBadge?: WorkerBadgeProps; +}; -export function PermissionRequestTitle({ - title, - subtitle, - color = 'permission', - workerBadge, -}: Props): React.ReactNode { +export function PermissionRequestTitle({ title, subtitle, color = 'permission', workerBadge }: Props): React.ReactNode { return ( @@ -37,5 +32,5 @@ export function PermissionRequestTitle({ subtitle ))} - ) + ); } diff --git a/src/components/permissions/PermissionRuleExplanation.tsx b/src/components/permissions/PermissionRuleExplanation.tsx index 3ed14a120..d2f4d8696 100644 --- a/src/components/permissions/PermissionRuleExplanation.tsx +++ b/src/components/permissions/PermissionRuleExplanation.tsx @@ -1,50 +1,44 @@ -import { feature } from 'bun:bundle' -import chalk from 'chalk' -import React from 'react' -import { Ansi, Box, Text } from '@anthropic/ink' -import ThemedText from '../design-system/ThemedText.js' -import { useAppState } from '../../state/AppState.js' -import type { - PermissionDecision, - PermissionDecisionReason, -} from '../../utils/permissions/PermissionResult.js' -import { permissionRuleValueToString } from '../../utils/permissions/permissionRuleParser.js' -import type { Theme } from '../../utils/theme.js' +import { feature } from 'bun:bundle'; +import chalk from 'chalk'; +import React from 'react'; +import { Ansi, Box, Text } from '@anthropic/ink'; +import ThemedText from '../design-system/ThemedText.js'; +import { useAppState } from '../../state/AppState.js'; +import type { PermissionDecision, PermissionDecisionReason } from '../../utils/permissions/PermissionResult.js'; +import { permissionRuleValueToString } from '../../utils/permissions/permissionRuleParser.js'; +import type { Theme } from '../../utils/theme.js'; export type PermissionRuleExplanationProps = { - permissionResult: PermissionDecision - toolType: 'tool' | 'command' | 'edit' | 'read' -} + permissionResult: PermissionDecision; + toolType: 'tool' | 'command' | 'edit' | 'read'; +}; type DecisionReasonStrings = { - reasonString: string - configString?: string + reasonString: string; + configString?: string; /** When set, reasonString is plain text rendered with this theme color instead of . */ - themeColor?: keyof Theme -} + themeColor?: keyof Theme; +}; function stringsForDecisionReason( reason: PermissionDecisionReason | undefined, toolType: 'tool' | 'command' | 'edit' | 'read', ): DecisionReasonStrings | null { if (!reason) { - return null + return null; } - if ( - (feature('BASH_CLASSIFIER') || feature('TRANSCRIPT_CLASSIFIER')) && - reason.type === 'classifier' - ) { + if ((feature('BASH_CLASSIFIER') || feature('TRANSCRIPT_CLASSIFIER')) && reason.type === 'classifier') { if (reason.classifier === 'auto-mode') { return { reasonString: `Auto mode classifier requires confirmation for this ${toolType}.\n${reason.reason}`, configString: undefined, themeColor: 'error', - } + }; } return { reasonString: `Classifier ${chalk.bold(reason.classifier)} requires confirmation for this ${toolType}.\n${reason.reason}`, configString: undefined, - } + }; } switch (reason.type) { case 'rule': @@ -52,34 +46,29 @@ function stringsForDecisionReason( reasonString: `Permission rule ${chalk.bold( permissionRuleValueToString(reason.rule.ruleValue), )} requires confirmation for this ${toolType}.`, - configString: - reason.rule.source === 'policySettings' - ? undefined - : '/permissions to update rules', - } + configString: reason.rule.source === 'policySettings' ? undefined : '/permissions to update rules', + }; case 'hook': { - const hookReasonString = reason.reason ? `:\n${reason.reason}` : '.' - const sourceLabel = reason.hookSource - ? ` ${chalk.dim(`[${reason.hookSource}]`)}` - : '' + const hookReasonString = reason.reason ? `:\n${reason.reason}` : '.'; + const sourceLabel = reason.hookSource ? ` ${chalk.dim(`[${reason.hookSource}]`)}` : ''; return { reasonString: `Hook ${chalk.bold(reason.hookName)} requires confirmation for this ${toolType}${hookReasonString}${sourceLabel}`, configString: '/hooks to update', - } + }; } case 'safetyCheck': case 'other': return { reasonString: reason.reason, configString: undefined, - } + }; case 'workingDir': return { reasonString: reason.reason, configString: '/permissions to update rules', - } + }; default: - return null + return null; } } @@ -87,21 +76,15 @@ export function PermissionRuleExplanation({ permissionResult, toolType, }: PermissionRuleExplanationProps): React.ReactNode { - const permissionMode = useAppState(s => s.toolPermissionContext.mode) - const strings = stringsForDecisionReason( - permissionResult?.decisionReason, - toolType, - ) + const permissionMode = useAppState(s => s.toolPermissionContext.mode); + const strings = stringsForDecisionReason(permissionResult?.decisionReason, toolType); if (!strings) { - return null + return null; } const themeColor = strings.themeColor ?? - (permissionResult?.decisionReason?.type === 'hook' && - permissionMode === 'auto' - ? 'warning' - : undefined) + (permissionResult?.decisionReason?.type === 'hook' && permissionMode === 'auto' ? 'warning' : undefined); return ( @@ -114,5 +97,5 @@ export function PermissionRuleExplanation({ )} {strings.configString && {strings.configString}} - ) + ); } diff --git a/src/components/permissions/PowerShellPermissionRequest/PowerShellPermissionRequest.tsx b/src/components/permissions/PowerShellPermissionRequest/PowerShellPermissionRequest.tsx index 3aec521d5..538d75314 100644 --- a/src/components/permissions/PowerShellPermissionRequest/PowerShellPermissionRequest.tsx +++ b/src/components/permissions/PowerShellPermissionRequest/PowerShellPermissionRequest.tsx @@ -1,48 +1,40 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Box, Text, useTheme } from '@anthropic/ink' -import { useKeybinding } from '../../../keybindings/useKeybinding.js' -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../../services/analytics/growthbook.js' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Box, Text, useTheme } from '@anthropic/ink'; +import { useKeybinding } from '../../../keybindings/useKeybinding.js'; +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../../services/analytics/growthbook.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from '../../../services/analytics/index.js' -import { sanitizeToolNameForAnalytics } from '../../../services/analytics/metadata.js' -import { getDestructiveCommandWarning } from '@claude-code-best/builtin-tools/tools/PowerShellTool/destructiveCommandWarning.js' -import { PowerShellTool } from '@claude-code-best/builtin-tools/tools/PowerShellTool/PowerShellTool.js' -import { isAllowlistedCommand } from '@claude-code-best/builtin-tools/tools/PowerShellTool/readOnlyValidation.js' -import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js' -import { getCompoundCommandPrefixesStatic } from '../../../utils/powershell/staticPrefix.js' -import { Select } from '../../CustomSelect/select.js' -import { type UnaryEvent, usePermissionRequestLogging } from '../hooks.js' -import { PermissionDecisionDebugInfo } from '../PermissionDecisionDebugInfo.js' -import { PermissionDialog } from '../PermissionDialog.js' -import { - PermissionExplainerContent, - usePermissionExplainerUI, -} from '../PermissionExplanation.js' -import type { PermissionRequestProps } from '../PermissionRequest.js' -import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js' -import { useShellPermissionFeedback } from '../useShellPermissionFeedback.js' -import { logUnaryPermissionEvent } from '../utils.js' -import { powershellToolUseOptions } from './powershellToolUseOptions.js' +} from '../../../services/analytics/index.js'; +import { sanitizeToolNameForAnalytics } from '../../../services/analytics/metadata.js'; +import { getDestructiveCommandWarning } from '@claude-code-best/builtin-tools/tools/PowerShellTool/destructiveCommandWarning.js'; +import { PowerShellTool } from '@claude-code-best/builtin-tools/tools/PowerShellTool/PowerShellTool.js'; +import { isAllowlistedCommand } from '@claude-code-best/builtin-tools/tools/PowerShellTool/readOnlyValidation.js'; +import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js'; +import { getCompoundCommandPrefixesStatic } from '../../../utils/powershell/staticPrefix.js'; +import { Select } from '../../CustomSelect/select.js'; +import { type UnaryEvent, usePermissionRequestLogging } from '../hooks.js'; +import { PermissionDecisionDebugInfo } from '../PermissionDecisionDebugInfo.js'; +import { PermissionDialog } from '../PermissionDialog.js'; +import { PermissionExplainerContent, usePermissionExplainerUI } from '../PermissionExplanation.js'; +import type { PermissionRequestProps } from '../PermissionRequest.js'; +import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js'; +import { useShellPermissionFeedback } from '../useShellPermissionFeedback.js'; +import { logUnaryPermissionEvent } from '../utils.js'; +import { powershellToolUseOptions } from './powershellToolUseOptions.js'; -export function PowerShellPermissionRequest( - props: PermissionRequestProps, -): React.ReactNode { - const { toolUseConfirm, toolUseContext, onDone, onReject, workerBadge } = - props +export function PowerShellPermissionRequest(props: PermissionRequestProps): React.ReactNode { + const { toolUseConfirm, toolUseContext, onDone, onReject, workerBadge } = props; - const { command, description } = PowerShellTool.inputSchema.parse( - toolUseConfirm.input, - ) + const { command, description } = PowerShellTool.inputSchema.parse(toolUseConfirm.input); - const [theme] = useTheme() + const [theme] = useTheme(); const explainerState = usePermissionExplainerUI({ toolName: toolUseConfirm.tool.name, toolInput: toolUseConfirm.input, toolDescription: toolUseConfirm.description, messages: toolUseContext.messages, - }) + }); const { yesInputMode, noInputMode, @@ -61,15 +53,12 @@ export function PowerShellPermissionRequest( onDone, onReject, explainerVisible: explainerState.visible, - }) - const destructiveWarning = getFeatureValue_CACHED_MAY_BE_STALE( - 'tengu_destructive_command_warning', - false, - ) + }); + const destructiveWarning = getFeatureValue_CACHED_MAY_BE_STALE('tengu_destructive_command_warning', false) ? getDestructiveCommandWarning(command) - : null + : null; - const [showPermissionDebug, setShowPermissionDebug] = useState(false) + const [showPermissionDebug, setShowPermissionDebug] = useState(false); // Editable prefix — compute static prefix locally (no LLM call). // Initialize synchronously to the raw command for single-line commands so @@ -82,49 +71,42 @@ export function PowerShellPermissionRequest( // auto-allowed (read-only). const [editablePrefix, setEditablePrefix] = useState( command.includes('\n') ? undefined : command, - ) - const hasUserEditedPrefix = useRef(false) + ); + const hasUserEditedPrefix = useRef(false); useEffect(() => { - let cancelled = false + let cancelled = false; // Filter receives ParsedCommandElement — isAllowlistedCommand works from // element.name/nameType/args directly. isReadOnlyCommand(text) would need // to reparse (pwsh.exe spawn per subcommand) and returns false without the // full parsed AST, making the filter a no-op. - getCompoundCommandPrefixesStatic(command, element => - isAllowlistedCommand(element, element.text), - ) + getCompoundCommandPrefixesStatic(command, element => isAllowlistedCommand(element, element.text)) .then(prefixes => { - if (cancelled || hasUserEditedPrefix.current) return + if (cancelled || hasUserEditedPrefix.current) return; if (prefixes.length > 0) { - setEditablePrefix(`${prefixes[0]}:*`) + setEditablePrefix(`${prefixes[0]}:*`); } }) - .catch(() => {}) + .catch(() => {}); return () => { - cancelled = true - } + cancelled = true; + }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [command]) + }, [command]); const onEditablePrefixChange = useCallback((value: string) => { - hasUserEditedPrefix.current = true - setEditablePrefix(value) - }, []) + hasUserEditedPrefix.current = true; + setEditablePrefix(value); + }, []); - const unaryEvent = useMemo( - () => ({ completion_type: 'tool_use_single', language_name: 'none' }), - [], - ) + const unaryEvent = useMemo(() => ({ completion_type: 'tool_use_single', language_name: 'none' }), []); - usePermissionRequestLogging(toolUseConfirm, unaryEvent) + usePermissionRequestLogging(toolUseConfirm, unaryEvent); const options = useMemo( () => powershellToolUseOptions({ suggestions: - toolUseConfirm.permissionResult.behavior === 'ask' - ? toolUseConfirm.permissionResult.suggestions - : undefined, + toolUseConfirm.permissionResult.behavior === 'ask' ? toolUseConfirm.permissionResult.suggestions : undefined, onRejectFeedbackChange: setRejectFeedback, onAcceptFeedbackChange: setAcceptFeedback, yesInputMode, @@ -132,22 +114,16 @@ export function PowerShellPermissionRequest( editablePrefix, onEditablePrefixChange, }), - [ - toolUseConfirm, - yesInputMode, - noInputMode, - editablePrefix, - onEditablePrefixChange, - ], - ) + [toolUseConfirm, yesInputMode, noInputMode, editablePrefix, onEditablePrefixChange], + ); // Toggle permission debug info with keybinding const handleToggleDebug = useCallback(() => { - setShowPermissionDebug(prev => !prev) - }, []) + setShowPermissionDebug(prev => !prev); + }, []); useKeybinding('permission:toggleDebug', handleToggleDebug, { context: 'Confirmation', - }) + }); function onSelect(value: string) { // Map options to numeric values for analytics (strings not allowed in logEvent) @@ -156,21 +132,21 @@ export function PowerShellPermissionRequest( 'yes-apply-suggestions': 2, 'yes-prefix-edited': 2, no: 3, - } + }; logEvent('tengu_permission_request_option_selected', { option_index: optionIndex[value], explainer_visible: explainerState.visible, - }) + }); const toolNameForAnalytics = sanitizeToolNameForAnalytics( toolUseConfirm.tool.name, - ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS; if (value === 'yes-prefix-edited') { - const trimmedPrefix = (editablePrefix ?? '').trim() - logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept') + const trimmedPrefix = (editablePrefix ?? '').trim(); + logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept'); if (!trimmedPrefix) { - toolUseConfirm.onAllow(toolUseConfirm.input, []) + toolUseConfirm.onAllow(toolUseConfirm.input, []); } else { const prefixUpdates: PermissionUpdate[] = [ { @@ -184,17 +160,17 @@ export function PowerShellPermissionRequest( behavior: 'allow', destination: 'localSettings', }, - ] - toolUseConfirm.onAllow(toolUseConfirm.input, prefixUpdates) + ]; + toolUseConfirm.onAllow(toolUseConfirm.input, prefixUpdates); } - onDone() - return + onDone(); + return; } switch (value) { case 'yes': { - const trimmedFeedback = acceptFeedback.trim() - logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept') + const trimmedFeedback = acceptFeedback.trim(); + logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept'); // Log accept submission with feedback context logEvent('tengu_accept_submitted', { toolName: toolNameForAnalytics, @@ -202,28 +178,22 @@ export function PowerShellPermissionRequest( has_instructions: !!trimmedFeedback, instructions_length: trimmedFeedback.length, entered_feedback_mode: yesFeedbackModeEntered, - }) - toolUseConfirm.onAllow( - toolUseConfirm.input, - [], - trimmedFeedback || undefined, - ) - onDone() - break + }); + toolUseConfirm.onAllow(toolUseConfirm.input, [], trimmedFeedback || undefined); + onDone(); + break; } case 'yes-apply-suggestions': { - logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept') + logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept'); // Extract suggestions if present (works for both 'ask' and 'passthrough' behaviors) const permissionUpdates = - 'suggestions' in toolUseConfirm.permissionResult - ? toolUseConfirm.permissionResult.suggestions || [] - : [] - toolUseConfirm.onAllow(toolUseConfirm.input, permissionUpdates) - onDone() - break + 'suggestions' in toolUseConfirm.permissionResult ? toolUseConfirm.permissionResult.suggestions || [] : []; + toolUseConfirm.onAllow(toolUseConfirm.input, permissionUpdates); + onDone(); + break; } case 'no': { - const trimmedFeedback = rejectFeedback.trim() + const trimmedFeedback = rejectFeedback.trim(); // Log reject submission with feedback context logEvent('tengu_reject_submitted', { @@ -232,11 +202,11 @@ export function PowerShellPermissionRequest( has_instructions: !!trimmedFeedback, instructions_length: trimmedFeedback.length, entered_feedback_mode: noFeedbackModeEntered, - }) + }); // Process rejection (with or without feedback) - handleReject(trimmedFeedback || undefined) - break + handleReject(trimmedFeedback || undefined); + break; } } } @@ -250,20 +220,12 @@ export function PowerShellPermissionRequest( { theme, verbose: true }, // always show the full command )} - {!explainerState.visible && ( - {toolUseConfirm.description} - )} - + {!explainerState.visible && {toolUseConfirm.description}} + {showPermissionDebug ? ( <> - + {toolUseContext.options.debug && ( Ctrl-D to hide debug info @@ -273,10 +235,7 @@ export function PowerShellPermissionRequest( ) : ( <> - + {destructiveWarning && ( {destructiveWarning} @@ -295,18 +254,14 @@ export function PowerShellPermissionRequest( Esc to cancel - {((focusedOption === 'yes' && !yesInputMode) || - (focusedOption === 'no' && !noInputMode)) && + {((focusedOption === 'yes' && !yesInputMode) || (focusedOption === 'no' && !noInputMode)) && ' · Tab to amend'} - {explainerState.enabled && - ` · ctrl+e to ${explainerState.visible ? 'hide' : 'explain'}`} + {explainerState.enabled && ` · ctrl+e to ${explainerState.visible ? 'hide' : 'explain'}`} - {toolUseContext.options.debug && ( - Ctrl+d to show debug info - )} + {toolUseContext.options.debug && Ctrl+d to show debug info} )} - ) + ); } diff --git a/src/components/permissions/PowerShellPermissionRequest/powershellToolUseOptions.tsx b/src/components/permissions/PowerShellPermissionRequest/powershellToolUseOptions.tsx index f09caf185..136a1b131 100644 --- a/src/components/permissions/PowerShellPermissionRequest/powershellToolUseOptions.tsx +++ b/src/components/permissions/PowerShellPermissionRequest/powershellToolUseOptions.tsx @@ -1,14 +1,10 @@ -import { POWERSHELL_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/PowerShellTool/toolName.js' -import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js' -import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js' -import type { OptionWithDescription } from '../../CustomSelect/select.js' -import { generateShellSuggestionsLabel } from '../shellPermissionHelpers.js' +import { POWERSHELL_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/PowerShellTool/toolName.js'; +import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js'; +import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js'; +import type { OptionWithDescription } from '../../CustomSelect/select.js'; +import { generateShellSuggestionsLabel } from '../shellPermissionHelpers.js'; -export type PowerShellToolUseOption = - | 'yes' - | 'yes-apply-suggestions' - | 'yes-prefix-edited' - | 'no' +export type PowerShellToolUseOption = 'yes' | 'yes-apply-suggestions' | 'yes-prefix-edited' | 'no'; export function powershellToolUseOptions({ suggestions = [], @@ -19,15 +15,15 @@ export function powershellToolUseOptions({ editablePrefix, onEditablePrefixChange, }: { - suggestions?: PermissionUpdate[] - onRejectFeedbackChange: (value: string) => void - onAcceptFeedbackChange: (value: string) => void - yesInputMode?: boolean - noInputMode?: boolean - editablePrefix?: string - onEditablePrefixChange?: (value: string) => void + suggestions?: PermissionUpdate[]; + onRejectFeedbackChange: (value: string) => void; + onAcceptFeedbackChange: (value: string) => void; + yesInputMode?: boolean; + noInputMode?: boolean; + editablePrefix?: string; + onEditablePrefixChange?: (value: string) => void; }): OptionWithDescription[] { - const options: OptionWithDescription[] = [] + const options: OptionWithDescription[] = []; if (yesInputMode) { options.push({ @@ -37,12 +33,12 @@ export function powershellToolUseOptions({ placeholder: 'and tell Claude what to do next', onChange: onAcceptFeedbackChange, allowEmptySubmitToCancel: true, - }) + }); } else { options.push({ label: 'Yes', value: 'yes', - }) + }); } // Note: No sandbox toggle for PowerShell - sandbox is not supported on Windows @@ -57,14 +53,9 @@ export function powershellToolUseOptions({ const hasNonPowerShellSuggestions = suggestions.some( s => s.type === 'addDirectories' || - (s.type === 'addRules' && - s.rules?.some(r => r.toolName !== POWERSHELL_TOOL_NAME)), - ) - if ( - editablePrefix !== undefined && - onEditablePrefixChange && - !hasNonPowerShellSuggestions - ) { + (s.type === 'addRules' && s.rules?.some(r => r.toolName !== POWERSHELL_TOOL_NAME)), + ); + if (editablePrefix !== undefined && onEditablePrefixChange && !hasNonPowerShellSuggestions) { options.push({ type: 'input', label: 'Yes, and don\u2019t ask again for', @@ -76,17 +67,14 @@ export function powershellToolUseOptions({ showLabelWithValue: true, labelValueSeparator: ': ', resetCursorOnUpdate: true, - }) + }); } else { - const label = generateShellSuggestionsLabel( - suggestions, - POWERSHELL_TOOL_NAME, - ) + const label = generateShellSuggestionsLabel(suggestions, POWERSHELL_TOOL_NAME); if (label) { options.push({ label, value: 'yes-apply-suggestions', - }) + }); } } } @@ -99,13 +87,13 @@ export function powershellToolUseOptions({ placeholder: 'and tell Claude what to do differently', onChange: onRejectFeedbackChange, allowEmptySubmitToCancel: true, - }) + }); } else { options.push({ label: 'No', value: 'no', - }) + }); } - return options + return options; } diff --git a/src/components/permissions/ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.tsx b/src/components/permissions/ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.tsx index df24c2ea3..0b1dae4f8 100644 --- a/src/components/permissions/ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.tsx +++ b/src/components/permissions/ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.tsx @@ -1,10 +1,10 @@ -import React from 'react' -import { Box, Text } from '@anthropic/ink' -import { Select } from '../../CustomSelect/select.js' -import { usePermissionRequestLogging } from '../hooks.js' -import { PermissionDialog } from '../PermissionDialog.js' -import type { PermissionRequestProps } from '../PermissionRequest.js' -import { logUnaryPermissionEvent } from '../utils.js' +import React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { Select } from '../../CustomSelect/select.js'; +import { usePermissionRequestLogging } from '../hooks.js'; +import { PermissionDialog } from '../PermissionDialog.js'; +import type { PermissionRequestProps } from '../PermissionRequest.js'; +import { logUnaryPermissionEvent } from '../utils.js'; export function ReviewArtifactPermissionRequest({ toolUseConfirm, @@ -13,47 +13,40 @@ export function ReviewArtifactPermissionRequest({ workerBadge, }: PermissionRequestProps): React.ReactNode { const { title, annotations, summary } = toolUseConfirm.input as { - title?: string - annotations?: Array<{ line?: number; message: string; severity?: string }> - summary?: string - } + title?: string; + annotations?: Array<{ line?: number; message: string; severity?: string }>; + summary?: string; + }; const unaryEvent = { completion_type: 'tool_use_single' as const, language_name: 'none', - } - usePermissionRequestLogging(toolUseConfirm, unaryEvent) + }; + usePermissionRequestLogging(toolUseConfirm, unaryEvent); - const annotationCount = annotations?.length ?? 0 + const annotationCount = annotations?.length ?? 0; function handleResponse(value: 'yes' | 'no'): void { if (value === 'yes') { - logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept') - toolUseConfirm.onAllow(toolUseConfirm.input, []) - onDone() + logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept'); + toolUseConfirm.onAllow(toolUseConfirm.input, []); + onDone(); } else { - logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'reject') - toolUseConfirm.onReject() - onReject() - onDone() + logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'reject'); + toolUseConfirm.onReject(); + onReject(); + onDone(); } } return ( - + - - Claude wants to review{title ? `: ${title}` : ' an artifact'}. - + Claude wants to review{title ? `: ${title}` : ' an artifact'}. - {annotationCount} annotation{annotationCount !== 1 ? 's' : ''} will - be presented. + {annotationCount} annotation{annotationCount !== 1 ? 's' : ''} will be presented. {summary ? Summary: {summary} : null} @@ -70,5 +63,5 @@ export function ReviewArtifactPermissionRequest({ - ) + ); } diff --git a/src/components/permissions/SandboxPermissionRequest.tsx b/src/components/permissions/SandboxPermissionRequest.tsx index cf7ef1195..70f776abe 100644 --- a/src/components/permissions/SandboxPermissionRequest.tsx +++ b/src/components/permissions/SandboxPermissionRequest.tsx @@ -1,23 +1,17 @@ -import * as React from 'react' -import { Box, Text } from '@anthropic/ink' -import { - type NetworkHostPattern, - shouldAllowManagedSandboxDomainsOnly, -} from 'src/utils/sandbox/sandbox-adapter.js' +import * as React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { type NetworkHostPattern, shouldAllowManagedSandboxDomainsOnly } from 'src/utils/sandbox/sandbox-adapter.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from '../../services/analytics/index.js' -import { Select } from '../CustomSelect/select.js' -import { PermissionDialog } from './PermissionDialog.js' +} from '../../services/analytics/index.js'; +import { Select } from '../CustomSelect/select.js'; +import { PermissionDialog } from './PermissionDialog.js'; export type SandboxPermissionRequestProps = { - hostPattern: NetworkHostPattern - onUserResponse: (response: { - allow: boolean - persistToSettings: boolean - }) => void -} + hostPattern: NetworkHostPattern; + onUserResponse: (response: { allow: boolean; persistToSettings: boolean }) => void; +}; export function SandboxPermissionRequest({ hostPattern: { host }, @@ -30,25 +24,24 @@ export function SandboxPermissionRequest({ if (process.env.USER_TYPE === 'ant') { logEvent('tengu_sandbox_network_dialog_result', { host: host as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - result: - value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + result: value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); } switch (value) { case 'yes': - onUserResponse({ allow: true, persistToSettings: false }) - break + onUserResponse({ allow: true, persistToSettings: false }); + break; case 'yes-dont-ask-again': - onUserResponse({ allow: true, persistToSettings: true }) - break + onUserResponse({ allow: true, persistToSettings: true }); + break; case 'no': - onUserResponse({ allow: false, persistToSettings: false }) - break + onUserResponse({ allow: false, persistToSettings: false }); + break; } } - const managedDomainsOnly = shouldAllowManagedSandboxDomainsOnly() + const managedDomainsOnly = shouldAllowManagedSandboxDomainsOnly(); const options = [ { label: 'Yes', value: 'yes' }, @@ -72,7 +65,7 @@ export function SandboxPermissionRequest({ ), value: 'no', }, - ] + ]; return ( @@ -92,15 +85,14 @@ export function SandboxPermissionRequest({ if (process.env.USER_TYPE === 'ant') { logEvent('tengu_sandbox_network_dialog_result', { host: host as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - result: - 'cancel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + result: 'cancel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); } - onUserResponse({ allow: false, persistToSettings: false }) + onUserResponse({ allow: false, persistToSettings: false }); }} /> - ) + ); } diff --git a/src/components/permissions/SedEditPermissionRequest/SedEditPermissionRequest.tsx b/src/components/permissions/SedEditPermissionRequest/SedEditPermissionRequest.tsx index 4ccd2da67..74f66d876 100644 --- a/src/components/permissions/SedEditPermissionRequest/SedEditPermissionRequest.tsx +++ b/src/components/permissions/SedEditPermissionRequest/SedEditPermissionRequest.tsx @@ -1,30 +1,27 @@ -import { basename, relative } from 'path' -import React, { Suspense, use, useMemo } from 'react' -import { FileEditToolDiff } from 'src/components/FileEditToolDiff.js' -import { getCwd } from 'src/utils/cwd.js' -import { isENOENT } from 'src/utils/errors.js' -import { detectEncodingForResolvedPath } from 'src/utils/fileRead.js' -import { getFsImplementation } from 'src/utils/fsOperations.js' -import { Text } from '@anthropic/ink' -import { BashTool } from '@claude-code-best/builtin-tools/tools/BashTool/BashTool.js' +import { basename, relative } from 'path'; +import React, { Suspense, use, useMemo } from 'react'; +import { FileEditToolDiff } from 'src/components/FileEditToolDiff.js'; +import { getCwd } from 'src/utils/cwd.js'; +import { isENOENT } from 'src/utils/errors.js'; +import { detectEncodingForResolvedPath } from 'src/utils/fileRead.js'; +import { getFsImplementation } from 'src/utils/fsOperations.js'; +import { Text } from '@anthropic/ink'; +import { BashTool } from '@claude-code-best/builtin-tools/tools/BashTool/BashTool.js'; import { applySedSubstitution, type SedEditInfo, -} from '@claude-code-best/builtin-tools/tools/BashTool/sedEditParser.js' -import { FilePermissionDialog } from '../FilePermissionDialog/FilePermissionDialog.js' -import type { PermissionRequestProps } from '../PermissionRequest.js' +} from '@claude-code-best/builtin-tools/tools/BashTool/sedEditParser.js'; +import { FilePermissionDialog } from '../FilePermissionDialog/FilePermissionDialog.js'; +import type { PermissionRequestProps } from '../PermissionRequest.js'; type SedEditPermissionRequestProps = PermissionRequestProps & { - sedInfo: SedEditInfo -} + sedInfo: SedEditInfo; +}; -type FileReadResult = { oldContent: string; fileExists: boolean } +type FileReadResult = { oldContent: string; fileExists: boolean }; -export function SedEditPermissionRequest({ - sedInfo, - ...props -}: SedEditPermissionRequestProps): React.ReactNode { - const { filePath } = sedInfo +export function SedEditPermissionRequest({ sedInfo, ...props }: SedEditPermissionRequestProps): React.ReactNode { + const { filePath } = sedInfo; // Read file content async so mount doesn't block React commit on disk I/O. // Large files would otherwise hang the dialog before it renders. @@ -35,28 +32,24 @@ export function SedEditPermissionRequest({ // Detect encoding first (sync 4KB read — negligible) so UTF-16LE BOMs // render correctly. This matches what readFileSync did before the // async conversion. - const encoding = detectEncodingForResolvedPath(filePath) - const raw = await getFsImplementation().readFile(filePath, { encoding }) + const encoding = detectEncodingForResolvedPath(filePath); + const raw = await getFsImplementation().readFile(filePath, { encoding }); return { oldContent: raw.replaceAll('\r\n', '\n'), fileExists: true, - } + }; })().catch((e: unknown): FileReadResult => { - if (!isENOENT(e)) throw e - return { oldContent: '', fileExists: false } + if (!isENOENT(e)) throw e; + return { oldContent: '', fileExists: false }; }), [filePath], - ) + ); return ( - + - ) + ); } function SedEditPermissionRequestInner({ @@ -64,20 +57,20 @@ function SedEditPermissionRequestInner({ contentPromise, ...props }: SedEditPermissionRequestProps & { - contentPromise: Promise + contentPromise: Promise; }): React.ReactNode { - const { filePath } = sedInfo - const { oldContent, fileExists } = use(contentPromise) + const { filePath } = sedInfo; + const { oldContent, fileExists } = use(contentPromise); // Compute the new content by applying the sed substitution const newContent = useMemo(() => { - return applySedSubstitution(oldContent, sedInfo) - }, [oldContent, sedInfo]) + return applySedSubstitution(oldContent, sedInfo); + }, [oldContent, sedInfo]); // Create the edit representation for the diff const edits = useMemo(() => { if (oldContent === newContent) { - return [] + return []; } return [ { @@ -85,29 +78,29 @@ function SedEditPermissionRequestInner({ new_string: newContent, replace_all: false, }, - ] - }, [oldContent, newContent]) + ]; + }, [oldContent, newContent]); // Determine appropriate message when no changes const noChangesMessage = useMemo(() => { if (!fileExists) { - return 'File does not exist' + return 'File does not exist'; } - return 'Pattern did not match any content' - }, [fileExists]) + return 'Pattern did not match any content'; + }, [fileExists]); // Parse input and add _simulatedSedEdit to ensure what user previewed // is exactly what gets written (prevents sed/JS regex differences) const parseInput = (input: unknown) => { - const parsed = BashTool.inputSchema.parse(input) + const parsed = BashTool.inputSchema.parse(input); return { ...parsed, _simulatedSedEdit: { filePath, newContent, }, - } - } + }; + }; return ( - Do you want to make this edit to{' '} - {basename(filePath)}? + Do you want to make this edit to {basename(filePath)}? } content={ @@ -135,5 +127,5 @@ function SedEditPermissionRequestInner({ parseInput={parseInput} workerBadge={props.workerBadge} /> - ) + ); } diff --git a/src/components/permissions/SedEditPermissionRequest/src/components/FileEditToolDiff.ts b/src/components/permissions/SedEditPermissionRequest/src/components/FileEditToolDiff.ts index d6d114f60..8b1551f7d 100644 --- a/src/components/permissions/SedEditPermissionRequest/src/components/FileEditToolDiff.ts +++ b/src/components/permissions/SedEditPermissionRequest/src/components/FileEditToolDiff.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type FileEditToolDiff = any; +export type FileEditToolDiff = any diff --git a/src/components/permissions/SedEditPermissionRequest/src/utils/cwd.ts b/src/components/permissions/SedEditPermissionRequest/src/utils/cwd.ts index 76c192ed8..4bd56a824 100644 --- a/src/components/permissions/SedEditPermissionRequest/src/utils/cwd.ts +++ b/src/components/permissions/SedEditPermissionRequest/src/utils/cwd.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getCwd = any; +export type getCwd = any diff --git a/src/components/permissions/SedEditPermissionRequest/src/utils/errors.ts b/src/components/permissions/SedEditPermissionRequest/src/utils/errors.ts index 8ccaeabf1..988b192ee 100644 --- a/src/components/permissions/SedEditPermissionRequest/src/utils/errors.ts +++ b/src/components/permissions/SedEditPermissionRequest/src/utils/errors.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type isENOENT = any; +export type isENOENT = any diff --git a/src/components/permissions/SedEditPermissionRequest/src/utils/fileRead.ts b/src/components/permissions/SedEditPermissionRequest/src/utils/fileRead.ts index 69d200068..60cd89922 100644 --- a/src/components/permissions/SedEditPermissionRequest/src/utils/fileRead.ts +++ b/src/components/permissions/SedEditPermissionRequest/src/utils/fileRead.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type detectEncodingForResolvedPath = any; +export type detectEncodingForResolvedPath = any diff --git a/src/components/permissions/SedEditPermissionRequest/src/utils/fsOperations.ts b/src/components/permissions/SedEditPermissionRequest/src/utils/fsOperations.ts index d30ccea0a..276e80162 100644 --- a/src/components/permissions/SedEditPermissionRequest/src/utils/fsOperations.ts +++ b/src/components/permissions/SedEditPermissionRequest/src/utils/fsOperations.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getFsImplementation = any; +export type getFsImplementation = any diff --git a/src/components/permissions/SkillPermissionRequest/SkillPermissionRequest.tsx b/src/components/permissions/SkillPermissionRequest/SkillPermissionRequest.tsx index 234f7d970..f54e2c93c 100644 --- a/src/components/permissions/SkillPermissionRequest/SkillPermissionRequest.tsx +++ b/src/components/permissions/SkillPermissionRequest/SkillPermissionRequest.tsx @@ -1,47 +1,33 @@ -import React, { useCallback, useMemo } from 'react' -import { logError } from 'src/utils/log.js' -import { getOriginalCwd } from '../../../bootstrap/state.js' -import { Box, Text } from '@anthropic/ink' -import { sanitizeToolNameForAnalytics } from '../../../services/analytics/metadata.js' -import { SKILL_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SkillTool/constants.js' -import { SkillTool } from '@claude-code-best/builtin-tools/tools/SkillTool/SkillTool.js' -import { env } from '../../../utils/env.js' -import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js' -import { logUnaryEvent } from '../../../utils/unaryLogging.js' -import { type UnaryEvent, usePermissionRequestLogging } from '../hooks.js' -import { PermissionDialog } from '../PermissionDialog.js' -import { - PermissionPrompt, - type PermissionPromptOption, - type ToolAnalyticsContext, -} from '../PermissionPrompt.js' -import type { PermissionRequestProps } from '../PermissionRequest.js' -import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js' +import React, { useCallback, useMemo } from 'react'; +import { logError } from 'src/utils/log.js'; +import { getOriginalCwd } from '../../../bootstrap/state.js'; +import { Box, Text } from '@anthropic/ink'; +import { sanitizeToolNameForAnalytics } from '../../../services/analytics/metadata.js'; +import { SKILL_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SkillTool/constants.js'; +import { SkillTool } from '@claude-code-best/builtin-tools/tools/SkillTool/SkillTool.js'; +import { env } from '../../../utils/env.js'; +import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js'; +import { logUnaryEvent } from '../../../utils/unaryLogging.js'; +import { type UnaryEvent, usePermissionRequestLogging } from '../hooks.js'; +import { PermissionDialog } from '../PermissionDialog.js'; +import { PermissionPrompt, type PermissionPromptOption, type ToolAnalyticsContext } from '../PermissionPrompt.js'; +import type { PermissionRequestProps } from '../PermissionRequest.js'; +import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js'; -type SkillOptionValue = 'yes' | 'yes-exact' | 'yes-prefix' | 'no' +type SkillOptionValue = 'yes' | 'yes-exact' | 'yes-prefix' | 'no'; -export function SkillPermissionRequest( - props: PermissionRequestProps, -): React.ReactNode { - const { - toolUseConfirm, - onDone, - onReject, - verbose: _verbose, - workerBadge, - } = props +export function SkillPermissionRequest(props: PermissionRequestProps): React.ReactNode { + const { toolUseConfirm, onDone, onReject, verbose: _verbose, workerBadge } = props; const parseInput = (input: unknown): string => { - const result = SkillTool.inputSchema.safeParse(input) + const result = SkillTool.inputSchema.safeParse(input); if (!result.success) { - logError( - new Error(`Failed to parse skill tool input: ${result.error.message}`), - ) - return '' + logError(new Error(`Failed to parse skill tool input: ${result.error.message}`)); + return ''; } - return result.data.skill - } + return result.data.skill; + }; - const skill = parseInput(toolUseConfirm.input) + const skill = parseInput(toolUseConfirm.input); // Check if this is a command using metadata from checkPermissions const commandObj = @@ -49,7 +35,7 @@ export function SkillPermissionRequest( toolUseConfirm.permissionResult.metadata && 'command' in toolUseConfirm.permissionResult.metadata ? toolUseConfirm.permissionResult.metadata.command - : undefined + : undefined; const unaryEvent = useMemo( () => ({ @@ -57,12 +43,12 @@ export function SkillPermissionRequest( language_name: 'none', }), [], - ) + ); - usePermissionRequestLogging(toolUseConfirm, unaryEvent) + usePermissionRequestLogging(toolUseConfirm, unaryEvent); - const originalCwd = getOriginalCwd() - const showAlwaysAllowOptions = shouldShowAlwaysAllowOptions() + const originalCwd = getOriginalCwd(); + const showAlwaysAllowOptions = shouldShowAlwaysAllowOptions(); const options = useMemo((): PermissionPromptOption[] => { const baseOptions: PermissionPromptOption[] = [ { @@ -70,36 +56,34 @@ export function SkillPermissionRequest( value: 'yes', feedbackConfig: { type: 'accept' }, }, - ] + ]; // Only add "always allow" options when not restricted by allowManagedPermissionRulesOnly - const alwaysAllowOptions: PermissionPromptOption[] = [] + const alwaysAllowOptions: PermissionPromptOption[] = []; if (showAlwaysAllowOptions) { // Add exact match option alwaysAllowOptions.push({ label: ( - Yes, and don't ask again for {skill} in{' '} - {originalCwd} + Yes, and don't ask again for {skill} in {originalCwd} ), value: 'yes-exact', - }) + }); // Add prefix option if the skill has arguments - const spaceIndex = skill.indexOf(' ') + const spaceIndex = skill.indexOf(' '); if (spaceIndex > 0) { - const commandPrefix = skill.substring(0, spaceIndex) + const commandPrefix = skill.substring(0, spaceIndex); alwaysAllowOptions.push({ label: ( - Yes, and don't ask again for{' '} - {commandPrefix + ':*'} commands in{' '} + Yes, and don't ask again for {commandPrefix + ':*'} commands in{' '} {originalCwd} ), value: 'yes-prefix', - }) + }); } } @@ -107,10 +91,10 @@ export function SkillPermissionRequest( label: 'No', value: 'no', feedbackConfig: { type: 'reject' }, - } + }; - return [...baseOptions, ...alwaysAllowOptions, noOption] - }, [skill, originalCwd, showAlwaysAllowOptions]) + return [...baseOptions, ...alwaysAllowOptions, noOption]; + }, [skill, originalCwd, showAlwaysAllowOptions]); const toolAnalyticsContext = useMemo( (): ToolAnalyticsContext => ({ @@ -118,7 +102,7 @@ export function SkillPermissionRequest( isMcp: toolUseConfirm.tool.isMcp ?? false, }), [toolUseConfirm.tool.name, toolUseConfirm.tool.isMcp], - ) + ); const handleSelect = useCallback( (value: SkillOptionValue, feedback?: string) => { @@ -132,10 +116,10 @@ export function SkillPermissionRequest( message_id: toolUseConfirm.assistantMessage.message.id!, platform: env.platform, }, - }) - toolUseConfirm.onAllow(toolUseConfirm.input, [], feedback) - onDone() - break + }); + toolUseConfirm.onAllow(toolUseConfirm.input, [], feedback); + onDone(); + break; case 'yes-exact': { void logUnaryEvent({ completion_type: 'tool_use_single', @@ -145,7 +129,7 @@ export function SkillPermissionRequest( message_id: toolUseConfirm.assistantMessage.message.id!, platform: env.platform, }, - }) + }); toolUseConfirm.onAllow(toolUseConfirm.input, [ { @@ -159,9 +143,9 @@ export function SkillPermissionRequest( behavior: 'allow', destination: 'localSettings', }, - ]) - onDone() - break + ]); + onDone(); + break; } case 'yes-prefix': { void logUnaryEvent({ @@ -172,12 +156,11 @@ export function SkillPermissionRequest( message_id: toolUseConfirm.assistantMessage.message.id!, platform: env.platform, }, - }) + }); // Extract the skill prefix (everything before the first space) - const spaceIndex = skill.indexOf(' ') - const commandPrefix = - spaceIndex > 0 ? skill.substring(0, spaceIndex) : skill + const spaceIndex = skill.indexOf(' '); + const commandPrefix = spaceIndex > 0 ? skill.substring(0, spaceIndex) : skill; toolUseConfirm.onAllow(toolUseConfirm.input, [ { @@ -191,9 +174,9 @@ export function SkillPermissionRequest( behavior: 'allow', destination: 'localSettings', }, - ]) - onDone() - break + ]); + onDone(); + break; } case 'no': void logUnaryEvent({ @@ -204,15 +187,15 @@ export function SkillPermissionRequest( message_id: toolUseConfirm.assistantMessage.message.id!, platform: env.platform, }, - }) - toolUseConfirm.onReject(feedback) - onReject() - onDone() - break + }); + toolUseConfirm.onReject(feedback); + onReject(); + onDone(); + break; } }, [toolUseConfirm, onDone, onReject, skill], - ) + ); const handleCancel = useCallback(() => { void logUnaryEvent({ @@ -223,11 +206,11 @@ export function SkillPermissionRequest( message_id: toolUseConfirm.assistantMessage.message.id!, platform: env.platform, }, - }) - toolUseConfirm.onReject() - onReject() - onDone() - }, [toolUseConfirm, onDone, onReject]) + }); + toolUseConfirm.onReject(); + onReject(); + onDone(); + }, [toolUseConfirm, onDone, onReject]); return ( @@ -237,10 +220,7 @@ export function SkillPermissionRequest( - + - ) + ); } diff --git a/src/components/permissions/SkillPermissionRequest/src/utils/log.ts b/src/components/permissions/SkillPermissionRequest/src/utils/log.ts index cf30e90da..30df9cbcb 100644 --- a/src/components/permissions/SkillPermissionRequest/src/utils/log.ts +++ b/src/components/permissions/SkillPermissionRequest/src/utils/log.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type logError = any; +export type logError = any diff --git a/src/components/permissions/WebFetchPermissionRequest/WebFetchPermissionRequest.tsx b/src/components/permissions/WebFetchPermissionRequest/WebFetchPermissionRequest.tsx index f91f3431b..e3bfac8e6 100644 --- a/src/components/permissions/WebFetchPermissionRequest/WebFetchPermissionRequest.tsx +++ b/src/components/permissions/WebFetchPermissionRequest/WebFetchPermissionRequest.tsx @@ -1,28 +1,25 @@ -import React, { useMemo } from 'react' -import { Box, Text, useTheme } from '@anthropic/ink' -import { WebFetchTool } from '@claude-code-best/builtin-tools/tools/WebFetchTool/WebFetchTool.js' -import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js' -import { - type OptionWithDescription, - Select, -} from '../../CustomSelect/select.js' -import { type UnaryEvent, usePermissionRequestLogging } from '../hooks.js' -import { PermissionDialog } from '../PermissionDialog.js' -import type { PermissionRequestProps } from '../PermissionRequest.js' -import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js' -import { logUnaryPermissionEvent } from '../utils.js' +import React, { useMemo } from 'react'; +import { Box, Text, useTheme } from '@anthropic/ink'; +import { WebFetchTool } from '@claude-code-best/builtin-tools/tools/WebFetchTool/WebFetchTool.js'; +import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js'; +import { type OptionWithDescription, Select } from '../../CustomSelect/select.js'; +import { type UnaryEvent, usePermissionRequestLogging } from '../hooks.js'; +import { PermissionDialog } from '../PermissionDialog.js'; +import type { PermissionRequestProps } from '../PermissionRequest.js'; +import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js'; +import { logUnaryPermissionEvent } from '../utils.js'; function inputToPermissionRuleContent(input: { [k: string]: unknown }): string { try { - const parsedInput = WebFetchTool.inputSchema.safeParse(input) + const parsedInput = WebFetchTool.inputSchema.safeParse(input); if (!parsedInput.success) { - return `input:${input.toString()}` + return `input:${input.toString()}`; } - const { url } = parsedInput.data - const hostname = new URL(url).hostname - return `domain:${hostname}` + const { url } = parsedInput.data; + const hostname = new URL(url).hostname; + return `domain:${hostname}`; } catch { - return `input:${input.toString()}` + return `input:${input.toString()}`; } } @@ -33,29 +30,26 @@ export function WebFetchPermissionRequest({ verbose, workerBadge, }: PermissionRequestProps): React.ReactNode { - const [theme] = useTheme() + const [theme] = useTheme(); // url is already validated by the input schema - const { url } = toolUseConfirm.input as { url: string } + const { url } = toolUseConfirm.input as { url: string }; // Extract hostname from URL - const hostname = new URL(url).hostname + const hostname = new URL(url).hostname; - const unaryEvent = useMemo( - () => ({ completion_type: 'tool_use_single', language_name: 'none' }), - [], - ) + const unaryEvent = useMemo(() => ({ completion_type: 'tool_use_single', language_name: 'none' }), []); - usePermissionRequestLogging(toolUseConfirm, unaryEvent) + usePermissionRequestLogging(toolUseConfirm, unaryEvent); // Generate permission options specific to domains - const showAlwaysAllowOptions = shouldShowAlwaysAllowOptions() + const showAlwaysAllowOptions = shouldShowAlwaysAllowOptions(); const options = useMemo((): OptionWithDescription[] => { const result: OptionWithDescription[] = [ { label: 'Yes', value: 'yes', }, - ] + ]; if (showAlwaysAllowOptions) { result.push({ @@ -65,7 +59,7 @@ export function WebFetchPermissionRequest({ ), value: 'yes-dont-ask-again-domain', - }) + }); } result.push({ @@ -75,25 +69,25 @@ export function WebFetchPermissionRequest({ ), value: 'no', - }) + }); - return result - }, [hostname, showAlwaysAllowOptions]) + return result; + }, [hostname, showAlwaysAllowOptions]); function onChange(newValue: string) { switch (newValue) { case 'yes': - logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept') - toolUseConfirm.onAllow(toolUseConfirm.input, []) - onDone() - break + logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept'); + toolUseConfirm.onAllow(toolUseConfirm.input, []); + onDone(); + break; case 'yes-dont-ask-again-domain': { - logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept') - const ruleContent = inputToPermissionRuleContent(toolUseConfirm.input) + logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept'); + const ruleContent = inputToPermissionRuleContent(toolUseConfirm.input); const ruleValue = { toolName: toolUseConfirm.tool.name, ruleContent, - } + }; // Pass permission update directly to onAllow toolUseConfirm.onAllow(toolUseConfirm.input, [ @@ -103,16 +97,16 @@ export function WebFetchPermissionRequest({ behavior: 'allow', destination: 'localSettings', }, - ]) - onDone() - break + ]); + onDone(); + break; } case 'no': - logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'reject') - toolUseConfirm.onReject() - onReject() - onDone() - break + logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'reject'); + toolUseConfirm.onReject(); + onReject(); + onDone(); + break; } } @@ -120,29 +114,19 @@ export function WebFetchPermissionRequest({ - {WebFetchTool.renderToolUseMessage( - toolUseConfirm.input as { url: string; prompt: string }, - { - theme, - verbose, - }, - )} + {WebFetchTool.renderToolUseMessage(toolUseConfirm.input as { url: string; prompt: string }, { + theme, + verbose, + })} {toolUseConfirm.description} - + Do you want to allow Claude to fetch this content? - onChange('no')} /> - ) + ); } diff --git a/src/components/permissions/WorkerBadge.tsx b/src/components/permissions/WorkerBadge.tsx index 959346e6e..14f79877b 100644 --- a/src/components/permissions/WorkerBadge.tsx +++ b/src/components/permissions/WorkerBadge.tsx @@ -1,27 +1,24 @@ -import * as React from 'react' -import { BLACK_CIRCLE } from '../../constants/figures.js' -import { Box, Text } from '@anthropic/ink' -import { toInkColor } from '../../utils/ink.js' +import * as React from 'react'; +import { BLACK_CIRCLE } from '../../constants/figures.js'; +import { Box, Text } from '@anthropic/ink'; +import { toInkColor } from '../../utils/ink.js'; export type WorkerBadgeProps = { - name: string - color: string -} + name: string; + color: string; +}; /** * Renders a colored badge showing the worker's name for permission prompts. * Used to indicate which swarm worker is requesting the permission. */ -export function WorkerBadge({ - name, - color, -}: WorkerBadgeProps): React.ReactNode { - const inkColor = toInkColor(color) +export function WorkerBadge({ name, color }: WorkerBadgeProps): React.ReactNode { + const inkColor = toInkColor(color); return ( {BLACK_CIRCLE} @{name} - ) + ); } diff --git a/src/components/permissions/WorkerPendingPermission.tsx b/src/components/permissions/WorkerPendingPermission.tsx index 2d7ef596e..25cac40c4 100644 --- a/src/components/permissions/WorkerPendingPermission.tsx +++ b/src/components/permissions/WorkerPendingPermission.tsx @@ -1,37 +1,25 @@ -import * as React from 'react' -import { Box, Text } from '@anthropic/ink' -import { - getAgentName, - getTeammateColor, - getTeamName, -} from '../../utils/teammate.js' -import { Spinner } from '../Spinner.js' -import { WorkerBadge } from './WorkerBadge.js' +import * as React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { getAgentName, getTeammateColor, getTeamName } from '../../utils/teammate.js'; +import { Spinner } from '../Spinner.js'; +import { WorkerBadge } from './WorkerBadge.js'; type Props = { - toolName: string - description: string -} + toolName: string; + description: string; +}; /** * Visual indicator shown on workers while waiting for leader to approve a permission request. * Displays the pending tool with a spinner and information about what's being requested. */ -export function WorkerPendingPermission({ - toolName, - description, -}: Props): React.ReactNode { - const teamName = getTeamName() - const agentName = getAgentName() - const agentColor = getTeammateColor() +export function WorkerPendingPermission({ toolName, description }: Props): React.ReactNode { + const teamName = getTeamName(); + const agentName = getAgentName(); + const agentColor = getTeammateColor(); return ( - + @@ -66,5 +54,5 @@ export function WorkerPendingPermission({ )} - ) + ); } diff --git a/src/components/permissions/rules/AddPermissionRules.tsx b/src/components/permissions/rules/AddPermissionRules.tsx index e62442c3c..b134c1f26 100644 --- a/src/components/permissions/rules/AddPermissionRules.tsx +++ b/src/components/permissions/rules/AddPermissionRules.tsx @@ -1,65 +1,54 @@ -import * as React from 'react' -import { useCallback } from 'react' -import { Select } from '../../../components/CustomSelect/select.js' -import { Box, Dialog, Text } from '@anthropic/ink' -import type { ToolPermissionContext } from '../../../Tool.js' +import * as React from 'react'; +import { useCallback } from 'react'; +import { Select } from '../../../components/CustomSelect/select.js'; +import { Box, Dialog, Text } from '@anthropic/ink'; +import type { ToolPermissionContext } from '../../../Tool.js'; import type { PermissionBehavior, PermissionRule, PermissionRuleValue, -} from '../../../utils/permissions/PermissionRule.js' -import { - applyPermissionUpdate, - persistPermissionUpdate, -} from '../../../utils/permissions/PermissionUpdate.js' -import { permissionRuleValueToString } from '../../../utils/permissions/permissionRuleParser.js' -import { - detectUnreachableRules, - type UnreachableRule, -} from '../../../utils/permissions/shadowedRuleDetection.js' -import { SandboxManager } from '../../../utils/sandbox/sandbox-adapter.js' -import { - type EditableSettingSource, - SOURCES, -} from '../../../utils/settings/constants.js' -import { getRelativeSettingsFilePathForSource } from '../../../utils/settings/settings.js' -import { plural } from '../../../utils/stringUtils.js' -import type { OptionWithDescription } from '../../CustomSelect/select.js' -import { PermissionRuleDescription } from './PermissionRuleDescription.js' +} from '../../../utils/permissions/PermissionRule.js'; +import { applyPermissionUpdate, persistPermissionUpdate } from '../../../utils/permissions/PermissionUpdate.js'; +import { permissionRuleValueToString } from '../../../utils/permissions/permissionRuleParser.js'; +import { detectUnreachableRules, type UnreachableRule } from '../../../utils/permissions/shadowedRuleDetection.js'; +import { SandboxManager } from '../../../utils/sandbox/sandbox-adapter.js'; +import { type EditableSettingSource, SOURCES } from '../../../utils/settings/constants.js'; +import { getRelativeSettingsFilePathForSource } from '../../../utils/settings/settings.js'; +import { plural } from '../../../utils/stringUtils.js'; +import type { OptionWithDescription } from '../../CustomSelect/select.js'; +import { PermissionRuleDescription } from './PermissionRuleDescription.js'; -export function optionForPermissionSaveDestination( - saveDestination: EditableSettingSource, -): OptionWithDescription { +export function optionForPermissionSaveDestination(saveDestination: EditableSettingSource): OptionWithDescription { switch (saveDestination) { case 'localSettings': return { label: 'Project settings (local)', description: `Saved in ${getRelativeSettingsFilePathForSource('localSettings')}`, value: saveDestination, - } + }; case 'projectSettings': return { label: 'Project settings', description: `Checked in at ${getRelativeSettingsFilePathForSource('projectSettings')}`, value: saveDestination, - } + }; case 'userSettings': return { label: 'User settings', description: `Saved in at ~/.claude/settings.json`, value: saveDestination, - } + }; } } type Props = { - onAddRules: (rules: PermissionRule[], unreachable?: UnreachableRule[]) => void - onCancel: () => void - ruleValues: PermissionRuleValue[] - ruleBehavior: PermissionBehavior - initialContext: ToolPermissionContext - setToolPermissionContext: (newContext: ToolPermissionContext) => void -} + onAddRules: (rules: PermissionRule[], unreachable?: UnreachableRule[]) => void; + onCancel: () => void; + ruleValues: PermissionRuleValue[]; + ruleBehavior: PermissionBehavior; + initialContext: ToolPermissionContext; + setToolPermissionContext: (newContext: ToolPermissionContext) => void; +}; export function AddPermissionRules({ onAddRules, @@ -69,22 +58,22 @@ export function AddPermissionRules({ initialContext, setToolPermissionContext, }: Props): React.ReactNode { - const allOptions = SOURCES.map(optionForPermissionSaveDestination) + const allOptions = SOURCES.map(optionForPermissionSaveDestination); const onSelect = useCallback( (selectedValue: string) => { if (selectedValue === 'cancel') { - onCancel() - return + onCancel(); + return; } else if ((SOURCES as readonly string[]).includes(selectedValue)) { - const destination = selectedValue as EditableSettingSource + const destination = selectedValue as EditableSettingSource; const updatedContext = applyPermissionUpdate(initialContext, { type: 'addRules', rules: ruleValues, behavior: ruleBehavior, destination, - }) + }); // Persist to settings persistPermissionUpdate({ @@ -92,59 +81,43 @@ export function AddPermissionRules({ rules: ruleValues, behavior: ruleBehavior, destination, - }) + }); - setToolPermissionContext(updatedContext) + setToolPermissionContext(updatedContext); const rules: PermissionRule[] = ruleValues.map(ruleValue => ({ ruleValue, ruleBehavior, source: destination, - })) + })); // Check for unreachable rules among the ones we just added const sandboxAutoAllowEnabled = - SandboxManager.isSandboxingEnabled() && - SandboxManager.isAutoAllowBashIfSandboxedEnabled() + SandboxManager.isSandboxingEnabled() && SandboxManager.isAutoAllowBashIfSandboxedEnabled(); const allUnreachable = detectUnreachableRules(updatedContext, { sandboxAutoAllowEnabled, - }) + }); // Filter to only rules we just added const newUnreachable = allUnreachable.filter(u => ruleValues.some( - rv => - rv.toolName === u.rule.ruleValue.toolName && - rv.ruleContent === u.rule.ruleValue.ruleContent, + rv => rv.toolName === u.rule.ruleValue.toolName && rv.ruleContent === u.rule.ruleValue.ruleContent, ), - ) + ); - onAddRules( - rules, - newUnreachable.length > 0 ? newUnreachable : undefined, - ) + onAddRules(rules, newUnreachable.length > 0 ? newUnreachable : undefined); } }, - [ - onAddRules, - onCancel, - ruleValues, - ruleBehavior, - initialContext, - setToolPermissionContext, - ], - ) + [onAddRules, onCancel, ruleValues, ruleBehavior, initialContext, setToolPermissionContext], + ); - const title = `Add ${ruleBehavior} permission ${plural(ruleValues.length, 'rule')}` + const title = `Add ${ruleBehavior} permission ${plural(ruleValues.length, 'rule')}`; return ( {ruleValues.map(ruleValue => ( - + {permissionRuleValueToString(ruleValue)} @@ -153,12 +126,10 @@ export function AddPermissionRules({ - {ruleValues.length === 1 - ? 'Where should this rule be saved?' - : 'Where should these rules be saved?'} + {ruleValues.length === 1 ? 'Where should this rule be saved?' : 'Where should these rules be saved?'} handleSelect('no')} - /> + - ) + ); } diff --git a/src/components/permissions/rules/WorkspaceTab.tsx b/src/components/permissions/rules/WorkspaceTab.tsx index 6aa410686..799a1cd29 100644 --- a/src/components/permissions/rules/WorkspaceTab.tsx +++ b/src/components/permissions/rules/WorkspaceTab.tsx @@ -1,28 +1,25 @@ -import figures from 'figures' -import * as React from 'react' -import { useCallback, useEffect } from 'react' -import { getOriginalCwd } from '../../../bootstrap/state.js' -import type { CommandResultDisplay } from '../../../commands.js' -import { Select } from '../../../components/CustomSelect/select.js' -import { Box, Text, useTabHeaderFocus } from '@anthropic/ink' -import type { ToolPermissionContext } from '../../../Tool.js' +import figures from 'figures'; +import * as React from 'react'; +import { useCallback, useEffect } from 'react'; +import { getOriginalCwd } from '../../../bootstrap/state.js'; +import type { CommandResultDisplay } from '../../../commands.js'; +import { Select } from '../../../components/CustomSelect/select.js'; +import { Box, Text, useTabHeaderFocus } from '@anthropic/ink'; +import type { ToolPermissionContext } from '../../../Tool.js'; type Props = { - onExit: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void - toolPermissionContext: ToolPermissionContext - onRequestAddDirectory: () => void - onRequestRemoveDirectory: (path: string) => void - onHeaderFocusChange?: (focused: boolean) => void -} + onExit: (result?: string, options?: { display?: CommandResultDisplay }) => void; + toolPermissionContext: ToolPermissionContext; + onRequestAddDirectory: () => void; + onRequestRemoveDirectory: (path: string) => void; + onHeaderFocusChange?: (focused: boolean) => void; +}; type DirectoryItem = { - path: string - isCurrent: boolean - isDeletable: boolean -} + path: string; + isCurrent: boolean; + isDeletable: boolean; +}; export function WorkspaceTab({ onExit, @@ -31,57 +28,50 @@ export function WorkspaceTab({ onRequestRemoveDirectory, onHeaderFocusChange, }: Props): React.ReactNode { - const { headerFocused, focusHeader } = useTabHeaderFocus() + const { headerFocused, focusHeader } = useTabHeaderFocus(); useEffect(() => { - onHeaderFocusChange?.(headerFocused) - }, [headerFocused, onHeaderFocusChange]) + onHeaderFocusChange?.(headerFocused); + }, [headerFocused, onHeaderFocusChange]); // Get only additional workspace directories (not the current working directory) const additionalDirectories = React.useMemo((): DirectoryItem[] => { - return Array.from( - toolPermissionContext.additionalWorkingDirectories.keys(), - ).map(path => ({ + return Array.from(toolPermissionContext.additionalWorkingDirectories.keys()).map(path => ({ path, isCurrent: false, isDeletable: true, - })) - }, [toolPermissionContext.additionalWorkingDirectories]) + })); + }, [toolPermissionContext.additionalWorkingDirectories]); const handleDirectorySelect = useCallback( (selectedValue: string) => { if (selectedValue === 'add-directory') { - onRequestAddDirectory() - return + onRequestAddDirectory(); + return; } - const directory = additionalDirectories.find( - d => d.path === selectedValue, - ) + const directory = additionalDirectories.find(d => d.path === selectedValue); if (directory && directory.isDeletable) { - onRequestRemoveDirectory(directory.path) + onRequestRemoveDirectory(directory.path); } }, [additionalDirectories, onRequestAddDirectory, onRequestRemoveDirectory], - ) + ); - const handleCancel = useCallback( - () => onExit('Workspace dialog dismissed', { display: 'system' }), - [onExit], - ) + const handleCancel = useCallback(() => onExit('Workspace dialog dismissed', { display: 'system' }), [onExit]); // Main list view options const options = React.useMemo(() => { const opts = additionalDirectories.map(dir => ({ label: dir.path, value: dir.path, - })) + })); opts.push({ label: `Add directory${figures.ellipsis}`, value: 'add-directory', - }) + }); - return opts - }, [additionalDirectories]) + return opts; + }, [additionalDirectories]); // Main list view return ( @@ -100,5 +90,5 @@ export function WorkspaceTab({ isDisabled={headerFocused} /> - ) + ); } diff --git a/src/components/permissions/rules/src/state/AppState.ts b/src/components/permissions/rules/src/state/AppState.ts index cc3978f9a..53048a173 100644 --- a/src/components/permissions/rules/src/state/AppState.ts +++ b/src/components/permissions/rules/src/state/AppState.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type useAppState = any; -export type useSetAppState = any; +export type useAppState = any +export type useSetAppState = any diff --git a/src/components/permissions/rules/src/utils/permissions/PermissionUpdate.ts b/src/components/permissions/rules/src/utils/permissions/PermissionUpdate.ts index 9d49451cb..210dd1a77 100644 --- a/src/components/permissions/rules/src/utils/permissions/PermissionUpdate.ts +++ b/src/components/permissions/rules/src/utils/permissions/PermissionUpdate.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type applyPermissionUpdate = any; -export type persistPermissionUpdate = any; +export type applyPermissionUpdate = any +export type persistPermissionUpdate = any diff --git a/src/components/permissions/rules/src/utils/permissions/PermissionUpdateSchema.ts b/src/components/permissions/rules/src/utils/permissions/PermissionUpdateSchema.ts index 7f663cb14..bc57a1c6c 100644 --- a/src/components/permissions/rules/src/utils/permissions/PermissionUpdateSchema.ts +++ b/src/components/permissions/rules/src/utils/permissions/PermissionUpdateSchema.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type PermissionUpdateDestination = any; +export type PermissionUpdateDestination = any diff --git a/src/components/permissions/shellPermissionHelpers.tsx b/src/components/permissions/shellPermissionHelpers.tsx index 9b7c945f0..1ea9bebad 100644 --- a/src/components/permissions/shellPermissionHelpers.tsx +++ b/src/components/permissions/shellPermissionHelpers.tsx @@ -1,46 +1,45 @@ -import { basename, sep } from 'path' -import React, { type ReactNode } from 'react' -import { getOriginalCwd } from '../../bootstrap/state.js' -import { Text } from '@anthropic/ink' -import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js' -import { permissionRuleExtractPrefix } from '../../utils/permissions/shellRuleMatching.js' +import { basename, sep } from 'path'; +import React, { type ReactNode } from 'react'; +import { getOriginalCwd } from '../../bootstrap/state.js'; +import { Text } from '@anthropic/ink'; +import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js'; +import { permissionRuleExtractPrefix } from '../../utils/permissions/shellRuleMatching.js'; function commandListDisplay(commands: string[]): ReactNode { switch (commands.length) { case 0: - return '' + return ''; case 1: - return {commands[0]} + return {commands[0]}; case 2: return ( {commands[0]} and {commands[1]} - ) + ); default: return ( - {commands.slice(0, -1).join(', ')}, and{' '} - {commands.slice(-1)[0]} + {commands.slice(0, -1).join(', ')}, and {commands.slice(-1)[0]} - ) + ); } } function commandListDisplayTruncated(commands: string[]): ReactNode { // Check if the plain text representation would be too long - const plainText = commands.join(', ') + const plainText = commands.join(', '); if (plainText.length > 50) { - return 'similar' + return 'similar'; } - return commandListDisplay(commands) + return commandListDisplay(commands); } function formatPathList(paths: string[]): ReactNode { - if (paths.length === 0) return '' + if (paths.length === 0) return ''; // Extract directory names from paths - const names = paths.map(p => basename(p) || p) + const names = paths.map(p => basename(p) || p); if (names.length === 1) { return ( @@ -48,7 +47,7 @@ function formatPathList(paths: string[]): ReactNode { {names[0]} {sep} - ) + ); } if (names.length === 2) { return ( @@ -57,7 +56,7 @@ function formatPathList(paths: string[]): ReactNode { {sep} and {names[1]} {sep} - ) + ); } // For 3+, show first two with "and N more" @@ -67,7 +66,7 @@ function formatPathList(paths: string[]): ReactNode { {sep}, {names[1]} {sep} and {paths.length - 2} more - ) + ); } /** @@ -82,83 +81,67 @@ export function generateShellSuggestionsLabel( commandTransform?: (command: string) => string, ): ReactNode | null { // Collect all rules for display - const allRules = suggestions - .filter(s => s.type === 'addRules') - .flatMap(s => s.rules || []) + const allRules = suggestions.filter(s => s.type === 'addRules').flatMap(s => s.rules || []); // Separate Read rules from shell rules - const readRules = allRules.filter(r => r.toolName === 'Read') - const shellRules = allRules.filter(r => r.toolName === shellToolName) + const readRules = allRules.filter(r => r.toolName === 'Read'); + const shellRules = allRules.filter(r => r.toolName === shellToolName); // Get directory info - const directories = suggestions - .filter(s => s.type === 'addDirectories') - .flatMap(s => s.directories || []) + const directories = suggestions.filter(s => s.type === 'addDirectories').flatMap(s => s.directories || []); // Extract paths from Read rules (keep separate from directories) - const readPaths = readRules - .map(r => r.ruleContent?.replace('/**', '') || '') - .filter(p => p) + const readPaths = readRules.map(r => r.ruleContent?.replace('/**', '') || '').filter(p => p); // Extract shell command prefixes, optionally transforming for display const shellCommands = [ ...new Set( shellRules.flatMap(rule => { - if (!rule.ruleContent) return [] - const command = - permissionRuleExtractPrefix(rule.ruleContent) ?? rule.ruleContent - return commandTransform ? commandTransform(command) : command + if (!rule.ruleContent) return []; + const command = permissionRuleExtractPrefix(rule.ruleContent) ?? rule.ruleContent; + return commandTransform ? commandTransform(command) : command; }), ), - ] + ]; // Check what we have - const hasDirectories = directories.length > 0 - const hasReadPaths = readPaths.length > 0 - const hasCommands = shellCommands.length > 0 + const hasDirectories = directories.length > 0; + const hasReadPaths = readPaths.length > 0; + const hasCommands = shellCommands.length > 0; // Handle single type cases if (hasReadPaths && !hasDirectories && !hasCommands) { // Only Read rules - use "reading from" language if (readPaths.length === 1) { - const firstPath = readPaths[0]! - const dirName = basename(firstPath) || firstPath + const firstPath = readPaths[0]!; + const dirName = basename(firstPath) || firstPath; return ( Yes, allow reading from {dirName} {sep} from this project - ) + ); } // Multiple read paths - return ( - - Yes, allow reading from {formatPathList(readPaths)} from this project - - ) + return Yes, allow reading from {formatPathList(readPaths)} from this project; } if (hasDirectories && !hasReadPaths && !hasCommands) { // Only directory permissions - use "access to" language if (directories.length === 1) { - const firstDir = directories[0]! - const dirName = basename(firstDir) || firstDir + const firstDir = directories[0]!; + const dirName = basename(firstDir) || firstDir; return ( Yes, and always allow access to {dirName} {sep} from this project - ) + ); } // Multiple directories - return ( - - Yes, and always allow access to {formatPathList(directories)} from this - project - - ) + return Yes, and always allow access to {formatPathList(directories)} from this project; } if (hasCommands && !hasDirectories && !hasReadPaths) { @@ -166,48 +149,40 @@ export function generateShellSuggestionsLabel( return ( {"Yes, and don't ask again for "} - {commandListDisplayTruncated(shellCommands)} commands in{' '} - {getOriginalCwd()} + {commandListDisplayTruncated(shellCommands)} commands in {getOriginalCwd()} - ) + ); } // Handle mixed cases if ((hasDirectories || hasReadPaths) && !hasCommands) { // Combine directories and read paths since they're both path access - const allPaths = [...directories, ...readPaths] + const allPaths = [...directories, ...readPaths]; if (hasDirectories && hasReadPaths) { // Mixed - use generic "access to" - return ( - - Yes, and always allow access to {formatPathList(allPaths)} from this - project - - ) + return Yes, and always allow access to {formatPathList(allPaths)} from this project; } } if ((hasDirectories || hasReadPaths) && hasCommands) { // Build descriptive message for both types - const allPaths = [...directories, ...readPaths] + const allPaths = [...directories, ...readPaths]; // Keep it concise but informative if (allPaths.length === 1 && shellCommands.length === 1) { return ( - Yes, and allow access to {formatPathList(allPaths)} and{' '} - {commandListDisplayTruncated(shellCommands)} commands + Yes, and allow access to {formatPathList(allPaths)} and {commandListDisplayTruncated(shellCommands)} commands - ) + ); } return ( - Yes, and allow {formatPathList(allPaths)} access and{' '} - {commandListDisplayTruncated(shellCommands)} commands + Yes, and allow {formatPathList(allPaths)} access and {commandListDisplayTruncated(shellCommands)} commands - ) + ); } - return null + return null; } diff --git a/src/components/permissions/src/ink.ts b/src/components/permissions/src/ink.ts index 51d6eb4b7..7371bcca6 100644 --- a/src/components/permissions/src/ink.ts +++ b/src/components/permissions/src/ink.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type Box = any; -export type Text = any; +export type Box = any +export type Text = any diff --git a/src/components/permissions/src/services/analytics/index.ts b/src/components/permissions/src/services/analytics/index.ts index 142e7b6f5..2e0f796da 100644 --- a/src/components/permissions/src/services/analytics/index.ts +++ b/src/components/permissions/src/services/analytics/index.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any; -export type logEvent = any; +export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any +export type logEvent = any diff --git a/src/components/permissions/src/services/analytics/metadata.ts b/src/components/permissions/src/services/analytics/metadata.ts index 807602756..8d346ef8e 100644 --- a/src/components/permissions/src/services/analytics/metadata.ts +++ b/src/components/permissions/src/services/analytics/metadata.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type sanitizeToolNameForAnalytics = any; +export type sanitizeToolNameForAnalytics = any diff --git a/src/components/permissions/src/tools/BashTool/BashTool.ts b/src/components/permissions/src/tools/BashTool/BashTool.ts index 7a3ea3cc5..0e57d5e17 100644 --- a/src/components/permissions/src/tools/BashTool/BashTool.ts +++ b/src/components/permissions/src/tools/BashTool/BashTool.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type BashTool = any; +export type BashTool = any diff --git a/src/components/permissions/src/tools/EnterPlanModeTool/EnterPlanModeTool.ts b/src/components/permissions/src/tools/EnterPlanModeTool/EnterPlanModeTool.ts index c4d43e44a..5d1ff0405 100644 --- a/src/components/permissions/src/tools/EnterPlanModeTool/EnterPlanModeTool.ts +++ b/src/components/permissions/src/tools/EnterPlanModeTool/EnterPlanModeTool.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type EnterPlanModeTool = any; +export type EnterPlanModeTool = any diff --git a/src/components/permissions/src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts b/src/components/permissions/src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts index f9708d2b3..1e7971d36 100644 --- a/src/components/permissions/src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts +++ b/src/components/permissions/src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type ExitPlanModeV2Tool = any; +export type ExitPlanModeV2Tool = any diff --git a/src/components/permissions/src/utils/bash/commands.ts b/src/components/permissions/src/utils/bash/commands.ts index 8886e5cc6..e817a69c9 100644 --- a/src/components/permissions/src/utils/bash/commands.ts +++ b/src/components/permissions/src/utils/bash/commands.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type splitCommand_DEPRECATED = any; +export type splitCommand_DEPRECATED = any diff --git a/src/components/permissions/src/utils/permissions/PermissionResult.ts b/src/components/permissions/src/utils/permissions/PermissionResult.ts index 7958ed68e..9b45eb981 100644 --- a/src/components/permissions/src/utils/permissions/PermissionResult.ts +++ b/src/components/permissions/src/utils/permissions/PermissionResult.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type PermissionDecisionReason = any; -export type PermissionResult = any; +export type PermissionDecisionReason = any +export type PermissionResult = any diff --git a/src/components/permissions/src/utils/permissions/PermissionUpdate.ts b/src/components/permissions/src/utils/permissions/PermissionUpdate.ts index 5779475d9..c0bcd3a59 100644 --- a/src/components/permissions/src/utils/permissions/PermissionUpdate.ts +++ b/src/components/permissions/src/utils/permissions/PermissionUpdate.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type extractRules = any; -export type hasRules = any; +export type extractRules = any +export type hasRules = any diff --git a/src/components/permissions/src/utils/permissions/permissionRuleParser.ts b/src/components/permissions/src/utils/permissions/permissionRuleParser.ts index 37691b2d9..9dff2e26c 100644 --- a/src/components/permissions/src/utils/permissions/permissionRuleParser.ts +++ b/src/components/permissions/src/utils/permissions/permissionRuleParser.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type permissionRuleValueToString = any; +export type permissionRuleValueToString = any diff --git a/src/components/permissions/src/utils/sandbox/sandbox-adapter.ts b/src/components/permissions/src/utils/sandbox/sandbox-adapter.ts index 5dd1d93d7..0f69c175e 100644 --- a/src/components/permissions/src/utils/sandbox/sandbox-adapter.ts +++ b/src/components/permissions/src/utils/sandbox/sandbox-adapter.ts @@ -1,4 +1,4 @@ // Auto-generated type stub — replace with real implementation -export type NetworkHostPattern = any; -export type shouldAllowManagedSandboxDomainsOnly = any; -export type SandboxManager = any; +export type NetworkHostPattern = any +export type shouldAllowManagedSandboxDomainsOnly = any +export type SandboxManager = any diff --git a/src/components/sandbox/SandboxConfigTab.tsx b/src/components/sandbox/SandboxConfigTab.tsx index 37e00ce56..83620fb98 100644 --- a/src/components/sandbox/SandboxConfigTab.tsx +++ b/src/components/sandbox/SandboxConfigTab.tsx @@ -1,15 +1,12 @@ -import * as React from 'react' -import { Box, Text } from '@anthropic/ink' -import { - SandboxManager, - shouldAllowManagedSandboxDomainsOnly, -} from '../../utils/sandbox/sandbox-adapter.js' +import * as React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { SandboxManager, shouldAllowManagedSandboxDomainsOnly } from '../../utils/sandbox/sandbox-adapter.js'; export function SandboxConfigTab(): React.ReactNode { - const isEnabled = SandboxManager.isSandboxingEnabled() + const isEnabled = SandboxManager.isSandboxingEnabled(); // Show warnings (e.g., seccomp not available on Linux) - const depCheck = SandboxManager.checkDependencies() + const depCheck = SandboxManager.checkDependencies(); const warningsNote = depCheck.warnings.length > 0 ? ( @@ -19,7 +16,7 @@ export function SandboxConfigTab(): React.ReactNode { ))} - ) : null + ) : null; if (!isEnabled) { return ( @@ -27,15 +24,15 @@ export function SandboxConfigTab(): React.ReactNode { Sandbox is not enabled {warningsNote} - ) + ); } - const fsReadConfig = SandboxManager.getFsReadConfig() - const fsWriteConfig = SandboxManager.getFsWriteConfig() - const networkConfig = SandboxManager.getNetworkRestrictionConfig() - const allowUnixSockets = SandboxManager.getAllowUnixSockets() - const excludedCommands = SandboxManager.getExcludedCommands() - const globPatternWarnings = SandboxManager.getLinuxGlobPatternWarnings() + const fsReadConfig = SandboxManager.getFsReadConfig(); + const fsWriteConfig = SandboxManager.getFsWriteConfig(); + const networkConfig = SandboxManager.getNetworkRestrictionConfig(); + const allowUnixSockets = SandboxManager.getAllowUnixSockets(); + const excludedCommands = SandboxManager.getExcludedCommands(); + const globPatternWarnings = SandboxManager.getLinuxGlobPatternWarnings(); return ( @@ -44,9 +41,7 @@ export function SandboxConfigTab(): React.ReactNode { Excluded Commands: - - {excludedCommands.length > 0 ? excludedCommands.join(', ') : 'None'} - + {excludedCommands.length > 0 ? excludedCommands.join(', ') : 'None'} {/* Filesystem Read Restrictions */} @@ -56,12 +51,9 @@ export function SandboxConfigTab(): React.ReactNode { Filesystem Read Restrictions: Denied: {fsReadConfig.denyOnly.join(', ')} - {fsReadConfig.allowWithinDeny && - fsReadConfig.allowWithinDeny.length > 0 && ( - - Allowed within denied: {fsReadConfig.allowWithinDeny.join(', ')} - - )} + {fsReadConfig.allowWithinDeny && fsReadConfig.allowWithinDeny.length > 0 && ( + Allowed within denied: {fsReadConfig.allowWithinDeny.join(', ')} + )} )} @@ -73,34 +65,25 @@ export function SandboxConfigTab(): React.ReactNode { Allowed: {fsWriteConfig.allowOnly.join(', ')} {fsWriteConfig.denyWithinAllow.length > 0 && ( - - Denied within allowed: {fsWriteConfig.denyWithinAllow.join(', ')} - + Denied within allowed: {fsWriteConfig.denyWithinAllow.join(', ')} )} )} {/* Network Restrictions */} {((networkConfig.allowedHosts && networkConfig.allowedHosts.length > 0) || - (networkConfig.deniedHosts && - networkConfig.deniedHosts.length > 0)) && ( + (networkConfig.deniedHosts && networkConfig.deniedHosts.length > 0)) && ( Network Restrictions {shouldAllowManagedSandboxDomainsOnly() ? ' (Managed)' : ''}: - {networkConfig.allowedHosts && - networkConfig.allowedHosts.length > 0 && ( - - Allowed: {networkConfig.allowedHosts.join(', ')} - - )} - {networkConfig.deniedHosts && - networkConfig.deniedHosts.length > 0 && ( - - Denied: {networkConfig.deniedHosts.join(', ')} - - )} + {networkConfig.allowedHosts && networkConfig.allowedHosts.length > 0 && ( + Allowed: {networkConfig.allowedHosts.join(', ')} + )} + {networkConfig.deniedHosts && networkConfig.deniedHosts.length > 0 && ( + Denied: {networkConfig.deniedHosts.join(', ')} + )} )} @@ -121,15 +104,13 @@ export function SandboxConfigTab(): React.ReactNode { ⚠ Warning: Glob patterns not fully supported on Linux - The following patterns will be ignored:{' '} - {globPatternWarnings.slice(0, 3).join(', ')} - {globPatternWarnings.length > 3 && - ` (${globPatternWarnings.length - 3} more)`} + The following patterns will be ignored: {globPatternWarnings.slice(0, 3).join(', ')} + {globPatternWarnings.length > 3 && ` (${globPatternWarnings.length - 3} more)`} )} {warningsNote} - ) + ); } diff --git a/src/components/sandbox/SandboxDependenciesTab.tsx b/src/components/sandbox/SandboxDependenciesTab.tsx index 24efdf1ef..ebe59289e 100644 --- a/src/components/sandbox/SandboxDependenciesTab.tsx +++ b/src/components/sandbox/SandboxDependenciesTab.tsx @@ -1,15 +1,15 @@ -import React from 'react' -import { Box, Text } from '@anthropic/ink' -import { getPlatform } from '../../utils/platform.js' -import type { SandboxDependencyCheck } from '../../utils/sandbox/sandbox-adapter.js' +import React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { getPlatform } from '../../utils/platform.js'; +import type { SandboxDependencyCheck } from '../../utils/sandbox/sandbox-adapter.js'; type Props = { - depCheck: SandboxDependencyCheck -} + depCheck: SandboxDependencyCheck; +}; export function SandboxDependenciesTab({ depCheck }: Props): React.ReactNode { - const platform = getPlatform() - const isMac = platform === 'macos' + const platform = getPlatform(); + const isMac = platform === 'macos'; // ripgrep is required on all platforms (used to scan for dangerous dirs). // On macOS, seatbelt is built into the OS — ripgrep is the only runtime dep. @@ -18,18 +18,18 @@ export function SandboxDependenciesTab({ depCheck }: Props): React.ReactNode { // #31804: previously this tab unconditionally rendered Linux deps (bwrap, // socat, seccomp). When ripgrep was missing on macOS, users saw confusing // Linux install instructions and no mention of the actual problem. - const rgMissing = depCheck.errors.some(e => e.includes('ripgrep')) - const bwrapMissing = depCheck.errors.some(e => e.includes('bwrap')) - const socatMissing = depCheck.errors.some(e => e.includes('socat')) - const seccompMissing = depCheck.warnings.length > 0 + const rgMissing = depCheck.errors.some(e => e.includes('ripgrep')); + const bwrapMissing = depCheck.errors.some(e => e.includes('bwrap')); + const socatMissing = depCheck.errors.some(e => e.includes('socat')); + const seccompMissing = depCheck.warnings.length > 0; // Any errors we don't have a dedicated row for — render verbatim so they // aren't silently swallowed (e.g. "Unsupported platform" or future deps). const otherErrors = depCheck.errors.filter( e => !e.includes('ripgrep') && !e.includes('bwrap') && !e.includes('socat'), - ) + ); - const rgInstallHint = isMac ? 'brew install ripgrep' : 'apt install ripgrep' + const rgInstallHint = isMac ? 'brew install ripgrep' : 'apt install ripgrep'; return ( @@ -43,12 +43,7 @@ export function SandboxDependenciesTab({ depCheck }: Props): React.ReactNode { - ripgrep (rg):{' '} - {rgMissing ? ( - not found - ) : ( - found - )} + ripgrep (rg): {rgMissing ? not found : found} {rgMissing && ( @@ -62,25 +57,14 @@ export function SandboxDependenciesTab({ depCheck }: Props): React.ReactNode { bubblewrap (bwrap):{' '} - {bwrapMissing ? ( - not installed - ) : ( - installed - )} + {bwrapMissing ? not installed : installed} - {bwrapMissing && ( - {' '}· apt install bubblewrap - )} + {bwrapMissing && {' '}· apt install bubblewrap} - socat:{' '} - {socatMissing ? ( - not installed - ) : ( - installed - )} + socat: {socatMissing ? not installed : installed} {socatMissing && {' '}· apt install socat} @@ -88,26 +72,14 @@ export function SandboxDependenciesTab({ depCheck }: Props): React.ReactNode { seccomp filter:{' '} - {seccompMissing ? ( - not installed - ) : ( - installed - )} - {seccompMissing && ( - (required to block unix domain sockets) - )} + {seccompMissing ? not installed : installed} + {seccompMissing && (required to block unix domain sockets)} {seccompMissing && ( - - {' '}· npm install -g @anthropic-ai/sandbox-runtime - - - {' '}· or copy vendor/seccomp/* from sandbox-runtime and set - - - {' '}sandbox.seccomp.bpfPath and applyPath in settings.json - + {' '}· npm install -g @anthropic-ai/sandbox-runtime + {' '}· or copy vendor/seccomp/* from sandbox-runtime and set + {' '}sandbox.seccomp.bpfPath and applyPath in settings.json )} @@ -120,5 +92,5 @@ export function SandboxDependenciesTab({ depCheck }: Props): React.ReactNode { ))} - ) + ); } diff --git a/src/components/sandbox/SandboxDoctorSection.tsx b/src/components/sandbox/SandboxDoctorSection.tsx index effa9500e..242490032 100644 --- a/src/components/sandbox/SandboxDoctorSection.tsx +++ b/src/components/sandbox/SandboxDoctorSection.tsx @@ -1,28 +1,26 @@ -import React from 'react' -import { Box, Text } from '@anthropic/ink' -import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' +import React from 'react'; +import { Box, Text } from '@anthropic/ink'; +import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'; export function SandboxDoctorSection(): React.ReactNode { if (!SandboxManager.isSupportedPlatform()) { - return null + return null; } if (!SandboxManager.isSandboxEnabledInSettings()) { - return null + return null; } - const depCheck = SandboxManager.checkDependencies() - const hasErrors = depCheck.errors.length > 0 - const hasWarnings = depCheck.warnings.length > 0 + const depCheck = SandboxManager.checkDependencies(); + const hasErrors = depCheck.errors.length > 0; + const hasWarnings = depCheck.warnings.length > 0; if (!hasErrors && !hasWarnings) { - return null + return null; } - const statusColor = hasErrors ? ('error' as const) : ('warning' as const) - const statusText = hasErrors - ? 'Missing dependencies' - : 'Available (with warnings)' + const statusColor = hasErrors ? ('error' as const) : ('warning' as const); + const statusText = hasErrors ? 'Missing dependencies' : 'Available (with warnings)'; return ( @@ -40,9 +38,7 @@ export function SandboxDoctorSection(): React.ReactNode { └ {w} ))} - {hasErrors && ( - └ Run /sandbox for install instructions - )} + {hasErrors && └ Run /sandbox for install instructions} - ) + ); } diff --git a/src/components/sandbox/SandboxOverridesTab.tsx b/src/components/sandbox/SandboxOverridesTab.tsx index 257dcc670..17f93002a 100644 --- a/src/components/sandbox/SandboxOverridesTab.tsx +++ b/src/components/sandbox/SandboxOverridesTab.tsx @@ -1,101 +1,78 @@ -import React from 'react' -import { Box, color, Link, Text, useTheme, useTabHeaderFocus } from '@anthropic/ink' -import type { CommandResultDisplay } from '../../types/command.js' -import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' -import { Select } from '../CustomSelect/select.js' +import React from 'react'; +import { Box, color, Link, Text, useTheme, useTabHeaderFocus } from '@anthropic/ink'; +import type { CommandResultDisplay } from '../../types/command.js'; +import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'; +import { Select } from '../CustomSelect/select.js'; type Props = { - onComplete: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void -} + onComplete: (result?: string, options?: { display?: CommandResultDisplay }) => void; +}; -type OverrideMode = 'open' | 'closed' +type OverrideMode = 'open' | 'closed'; export function SandboxOverridesTab({ onComplete }: Props): React.ReactNode { - const isEnabled = SandboxManager.isSandboxingEnabled() - const isLocked = SandboxManager.areSandboxSettingsLockedByPolicy() - const currentAllowUnsandboxed = SandboxManager.areUnsandboxedCommandsAllowed() + const isEnabled = SandboxManager.isSandboxingEnabled(); + const isLocked = SandboxManager.areSandboxSettingsLockedByPolicy(); + const currentAllowUnsandboxed = SandboxManager.areUnsandboxedCommandsAllowed(); if (!isEnabled) { return ( - - Sandbox is not enabled. Enable sandbox to configure override settings. - + Sandbox is not enabled. Enable sandbox to configure override settings. - ) + ); } if (isLocked) { return ( - Override settings are managed by a higher-priority configuration and - cannot be changed locally. + Override settings are managed by a higher-priority configuration and cannot be changed locally. - Current setting:{' '} - {currentAllowUnsandboxed - ? 'Allow unsandboxed fallback' - : 'Strict sandbox mode'} + Current setting: {currentAllowUnsandboxed ? 'Allow unsandboxed fallback' : 'Strict sandbox mode'} - ) + ); } - return ( - - ) + return ; } // Split so useTabHeaderFocus() only runs when the Select renders. Calling it // above the early returns registers a down-arrow opt-in even when we return // static text — pressing ↓ then blurs the header with no way back. -function OverridesSelect({ - onComplete, - currentMode, -}: Props & { currentMode: OverrideMode }): React.ReactNode { - const [theme] = useTheme() - const { headerFocused, focusHeader } = useTabHeaderFocus() - const currentIndicator = color('success', theme)(`(current)`) +function OverridesSelect({ onComplete, currentMode }: Props & { currentMode: OverrideMode }): React.ReactNode { + const [theme] = useTheme(); + const { headerFocused, focusHeader } = useTabHeaderFocus(); + const currentIndicator = color('success', theme)(`(current)`); const options = [ { - label: - currentMode === 'open' - ? `Allow unsandboxed fallback ${currentIndicator}` - : 'Allow unsandboxed fallback', + label: currentMode === 'open' ? `Allow unsandboxed fallback ${currentIndicator}` : 'Allow unsandboxed fallback', value: 'open', }, { - label: - currentMode === 'closed' - ? `Strict sandbox mode ${currentIndicator}` - : 'Strict sandbox mode', + label: currentMode === 'closed' ? `Strict sandbox mode ${currentIndicator}` : 'Strict sandbox mode', value: 'closed', }, - ] + ]; async function handleSelect(value: string) { - const mode = value as OverrideMode + const mode = value as OverrideMode; await SandboxManager.setSandboxSettings({ allowUnsandboxedCommands: mode === 'open', - }) + }); const message = mode === 'open' ? '✓ Unsandboxed fallback allowed - commands can run outside sandbox when necessary' - : '✓ Strict sandbox mode - all commands must run in sandbox or be excluded via the `excludedCommands` option' + : '✓ Strict sandbox mode - all commands must run in sandbox or be excluded via the `excludedCommands` option'; - onComplete(message) + onComplete(message); } return ( @@ -115,16 +92,15 @@ function OverridesSelect({ Allow unsandboxed fallback: {' '} - When a command fails due to sandbox restrictions, Claude can retry - with dangerouslyDisableSandbox to run outside the sandbox (falling - back to default permissions). + When a command fails due to sandbox restrictions, Claude can retry with dangerouslyDisableSandbox to run + outside the sandbox (falling back to default permissions). Strict sandbox mode: {' '} - All bash commands invoked by the model must run in the sandbox unless - they are explicitly listed in excludedCommands. + All bash commands invoked by the model must run in the sandbox unless they are explicitly listed in + excludedCommands. Learn more:{' '} @@ -134,5 +110,5 @@ function OverridesSelect({ - ) + ); } diff --git a/src/components/sandbox/SandboxSettings.tsx b/src/components/sandbox/SandboxSettings.tsx index 005d071e1..5212628f5 100644 --- a/src/components/sandbox/SandboxSettings.tsx +++ b/src/components/sandbox/SandboxSettings.tsx @@ -1,47 +1,41 @@ -import React from 'react' -import { Box, color, Link, Text, useTheme, Pane, Tab, Tabs, useTabHeaderFocus } from '@anthropic/ink' -import { useKeybindings } from '../../keybindings/useKeybinding.js' -import type { CommandResultDisplay } from '../../types/command.js' -import type { SandboxDependencyCheck } from '../../utils/sandbox/sandbox-adapter.js' -import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' -import { getSettings_DEPRECATED } from '../../utils/settings/settings.js' -import { Select } from '../CustomSelect/select.js' -import { SandboxConfigTab } from './SandboxConfigTab.js' -import { SandboxDependenciesTab } from './SandboxDependenciesTab.js' -import { SandboxOverridesTab } from './SandboxOverridesTab.js' +import React from 'react'; +import { Box, color, Link, Text, useTheme, Pane, Tab, Tabs, useTabHeaderFocus } from '@anthropic/ink'; +import { useKeybindings } from '../../keybindings/useKeybinding.js'; +import type { CommandResultDisplay } from '../../types/command.js'; +import type { SandboxDependencyCheck } from '../../utils/sandbox/sandbox-adapter.js'; +import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'; +import { getSettings_DEPRECATED } from '../../utils/settings/settings.js'; +import { Select } from '../CustomSelect/select.js'; +import { SandboxConfigTab } from './SandboxConfigTab.js'; +import { SandboxDependenciesTab } from './SandboxDependenciesTab.js'; +import { SandboxOverridesTab } from './SandboxOverridesTab.js'; type Props = { - onComplete: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void - depCheck: SandboxDependencyCheck -} + onComplete: (result?: string, options?: { display?: CommandResultDisplay }) => void; + depCheck: SandboxDependencyCheck; +}; -type SandboxMode = 'auto-allow' | 'regular' | 'disabled' +type SandboxMode = 'auto-allow' | 'regular' | 'disabled'; -export function SandboxSettings({ - onComplete, - depCheck, -}: Props): React.ReactNode { - const [theme] = useTheme() - const currentEnabled = SandboxManager.isSandboxingEnabled() - const currentAutoAllow = SandboxManager.isAutoAllowBashIfSandboxedEnabled() - const hasWarnings = depCheck.warnings.length > 0 - const settings = getSettings_DEPRECATED() - const allowAllUnixSockets = settings.sandbox?.network?.allowAllUnixSockets +export function SandboxSettings({ onComplete, depCheck }: Props): React.ReactNode { + const [theme] = useTheme(); + const currentEnabled = SandboxManager.isSandboxingEnabled(); + const currentAutoAllow = SandboxManager.isAutoAllowBashIfSandboxedEnabled(); + const hasWarnings = depCheck.warnings.length > 0; + const settings = getSettings_DEPRECATED(); + const allowAllUnixSockets = settings.sandbox?.network?.allowAllUnixSockets; // Show warning if seccomp missing AND user hasn't allowed all unix sockets - const showSocketWarning = hasWarnings && !allowAllUnixSockets + const showSocketWarning = hasWarnings && !allowAllUnixSockets; // Determine current mode const getCurrentMode = (): SandboxMode => { - if (!currentEnabled) return 'disabled' - if (currentAutoAllow) return 'auto-allow' - return 'regular' - } + if (!currentEnabled) return 'disabled'; + if (currentAutoAllow) return 'auto-allow'; + return 'regular'; + }; - const currentMode = getCurrentMode() - const currentIndicator = color('success', theme)(`(current)`) + const currentMode = getCurrentMode(); + const currentIndicator = color('success', theme)(`(current)`); const options = [ { @@ -59,39 +53,36 @@ export function SandboxSettings({ value: 'regular', }, { - label: - currentMode === 'disabled' - ? `No Sandbox ${currentIndicator}` - : 'No Sandbox', + label: currentMode === 'disabled' ? `No Sandbox ${currentIndicator}` : 'No Sandbox', value: 'disabled', }, - ] + ]; async function handleSelect(value: string) { - const mode = value as SandboxMode + const mode = value as SandboxMode; switch (mode) { case 'auto-allow': await SandboxManager.setSandboxSettings({ enabled: true, autoAllowBashIfSandboxed: true, - }) - onComplete('✓ Sandbox enabled with auto-allow for bash commands') - break + }); + onComplete('✓ Sandbox enabled with auto-allow for bash commands'); + break; case 'regular': await SandboxManager.setSandboxSettings({ enabled: true, autoAllowBashIfSandboxed: false, - }) - onComplete('✓ Sandbox enabled with regular bash permissions') - break + }); + onComplete('✓ Sandbox enabled with regular bash permissions'); + break; case 'disabled': await SandboxManager.setSandboxSettings({ enabled: false, autoAllowBashIfSandboxed: false, - }) - onComplete('○ Sandbox disabled') - break + }); + onComplete('○ Sandbox disabled'); + break; } } @@ -100,7 +91,7 @@ export function SandboxSettings({ 'confirm:no': () => onComplete(undefined, { display: 'skip' }), }, { context: 'Settings' }, - ) + ); const modeTab = ( @@ -111,21 +102,21 @@ export function SandboxSettings({ onComplete={onComplete} /> - ) + ); const overridesTab = ( - ) + ); const configTab = ( - ) + ); - const hasErrors = depCheck.errors.length > 0 + const hasErrors = depCheck.errors.length > 0; // If required deps missing, only show Dependencies tab // If only optional deps missing, show all tabs @@ -146,7 +137,7 @@ export function SandboxSettings({ : []), overridesTab, configTab, - ] + ]; return ( @@ -154,7 +145,7 @@ export function SandboxSettings({ {tabs} - ) + ); } function SandboxModeTab({ @@ -163,19 +154,17 @@ function SandboxModeTab({ onSelect, onComplete, }: { - showSocketWarning: boolean - options: Array<{ label: string; value: string }> - onSelect: (value: string) => void - onComplete: Props['onComplete'] + showSocketWarning: boolean; + options: Array<{ label: string; value: string }>; + onSelect: (value: string) => void; + onComplete: Props['onComplete']; }): React.ReactNode { - const { headerFocused, focusHeader } = useTabHeaderFocus() + const { headerFocused, focusHeader } = useTabHeaderFocus(); return ( {showSocketWarning && ( - - Cannot block unix domain sockets (see Dependencies tab) - + Cannot block unix domain sockets (see Dependencies tab) )} @@ -193,17 +182,13 @@ function SandboxModeTab({ Auto-allow mode: {' '} - Commands will try to run in the sandbox automatically, and attempts to - run outside of the sandbox fallback to regular permissions. Explicit - ask/deny rules are always respected. + Commands will try to run in the sandbox automatically, and attempts to run outside of the sandbox fallback to + regular permissions. Explicit ask/deny rules are always respected. - Learn more:{' '} - - code.claude.com/docs/en/sandboxing - + Learn more: code.claude.com/docs/en/sandboxing - ) + ); } diff --git a/src/components/shell/ExpandShellOutputContext.tsx b/src/components/shell/ExpandShellOutputContext.tsx index cc6628b64..4398d8e47 100644 --- a/src/components/shell/ExpandShellOutputContext.tsx +++ b/src/components/shell/ExpandShellOutputContext.tsx @@ -1,5 +1,5 @@ -import * as React from 'react' -import { useContext } from 'react' +import * as React from 'react'; +import { useContext } from 'react'; /** * Context to indicate that shell output should be shown in full (not truncated). @@ -8,18 +8,10 @@ import { useContext } from 'react' * This follows the same pattern as MessageResponseContext and SubAgentContext - * a boolean context that child components can check to modify their behavior. */ -const ExpandShellOutputContext = React.createContext(false) +const ExpandShellOutputContext = React.createContext(false); -export function ExpandShellOutputProvider({ - children, -}: { - children: React.ReactNode -}): React.ReactNode { - return ( - - {children} - - ) +export function ExpandShellOutputProvider({ children }: { children: React.ReactNode }): React.ReactNode { + return {children}; } /** @@ -27,5 +19,5 @@ export function ExpandShellOutputProvider({ * indicating the shell output should be shown in full rather than truncated. */ export function useExpandShellOutput(): boolean { - return useContext(ExpandShellOutputContext) + return useContext(ExpandShellOutputContext); } diff --git a/src/components/shell/OutputLine.tsx b/src/components/shell/OutputLine.tsx index 0b2c280af..615239c37 100644 --- a/src/components/shell/OutputLine.tsx +++ b/src/components/shell/OutputLine.tsx @@ -1,54 +1,54 @@ -import * as React from 'react' -import { useMemo } from 'react' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' -import { Ansi, Text } from '@anthropic/ink' -import { createHyperlink } from '../../utils/hyperlink.js' +import * as React from 'react'; +import { useMemo } from 'react'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import { Ansi, Text } from '@anthropic/ink'; +import { createHyperlink } from '../../utils/hyperlink.js'; -import { jsonParse, jsonStringify } from '../../utils/slowOperations.js' -import { renderTruncatedContent } from '../../utils/terminal.js' -import { MessageResponse } from '../MessageResponse.js' -import { InVirtualListContext } from '../messageActions.js' -import { useExpandShellOutput } from './ExpandShellOutputContext.js' +import { jsonParse, jsonStringify } from '../../utils/slowOperations.js'; +import { renderTruncatedContent } from '../../utils/terminal.js'; +import { MessageResponse } from '../MessageResponse.js'; +import { InVirtualListContext } from '../messageActions.js'; +import { useExpandShellOutput } from './ExpandShellOutputContext.js'; export function tryFormatJson(line: string): string { try { - const parsed = jsonParse(line) - const stringified = jsonStringify(parsed) + const parsed = jsonParse(line); + const stringified = jsonStringify(parsed); // Check if precision was lost during JSON round-trip // This happens when large integers exceed Number.MAX_SAFE_INTEGER // We normalize both strings by removing whitespace and unnecessary // escapes (\/ is valid but optional in JSON) for comparison - const normalizedOriginal = line.replace(/\\\//g, '/').replace(/\s+/g, '') - const normalizedStringified = stringified.replace(/\s+/g, '') + const normalizedOriginal = line.replace(/\\\//g, '/').replace(/\s+/g, ''); + const normalizedStringified = stringified.replace(/\s+/g, ''); if (normalizedOriginal !== normalizedStringified) { // Precision loss detected - return original line unformatted - return line + return line; } - return jsonStringify(parsed, null, 2) + return jsonStringify(parsed, null, 2); } catch { - return line + return line; } } -const MAX_JSON_FORMAT_LENGTH = 10_000 +const MAX_JSON_FORMAT_LENGTH = 10_000; export function tryJsonFormatContent(content: string): string { if (content.length > MAX_JSON_FORMAT_LENGTH) { - return content + return content; } - const allLines = content.split('\n') - return allLines.map(tryFormatJson).join('\n') + const allLines = content.split('\n'); + return allLines.map(tryFormatJson).join('\n'); } // Match http(s) URLs inside JSON string values. Conservative: no quotes, // no whitespace, no trailing comma/brace that'd be JSON structure. -const URL_IN_JSON = /https?:\/\/[^\s"'<>\\]+/g +const URL_IN_JSON = /https?:\/\/[^\s"'<>\\]+/g; export function linkifyUrlsInText(content: string): string { - return content.replace(URL_IN_JSON, url => createHyperlink(url)) + return content.replace(URL_IN_JSON, url => createHyperlink(url)); } export function OutputLine({ @@ -58,34 +58,32 @@ export function OutputLine({ isWarning, linkifyUrls, }: { - content: string - verbose: boolean - isError?: boolean - isWarning?: boolean - linkifyUrls?: boolean + content: string; + verbose: boolean; + isError?: boolean; + isWarning?: boolean; + linkifyUrls?: boolean; }): React.ReactNode { - const { columns } = useTerminalSize() + const { columns } = useTerminalSize(); // Context-based expansion for latest user shell output (from ! commands) - const expandShellOutput = useExpandShellOutput() - const inVirtualList = React.useContext(InVirtualListContext) + const expandShellOutput = useExpandShellOutput(); + const inVirtualList = React.useContext(InVirtualListContext); // Show full output if verbose mode OR if this is the latest user shell output - const shouldShowFull = verbose || expandShellOutput + const shouldShowFull = verbose || expandShellOutput; const formattedContent = useMemo(() => { - let formatted = tryJsonFormatContent(content) + let formatted = tryJsonFormatContent(content); if (linkifyUrls) { - formatted = linkifyUrlsInText(formatted) + formatted = linkifyUrlsInText(formatted); } if (shouldShowFull) { - return stripUnderlineAnsi(formatted) + return stripUnderlineAnsi(formatted); } - return stripUnderlineAnsi( - renderTruncatedContent(formatted, columns, inVirtualList), - ) - }, [content, shouldShowFull, columns, linkifyUrls, inVirtualList]) + return stripUnderlineAnsi(renderTruncatedContent(formatted, columns, inVirtualList)); + }, [content, shouldShowFull, columns, linkifyUrls, inVirtualList]); - const color = isError ? 'error' : isWarning ? 'warning' : undefined + const color = isError ? 'error' : isWarning ? 'warning' : undefined; return ( @@ -93,7 +91,7 @@ export function OutputLine({ {formattedContent} - ) + ); } /** @@ -106,7 +104,8 @@ export function OutputLine({ export function stripUnderlineAnsi(content: string): string { return content.replace( // eslint-disable-next-line no-control-regex + // biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ANSI escape code regex /\u001b\[([0-9]+;)*4(;[0-9]+)*m|\u001b\[4(;[0-9]+)*m|\u001b\[([0-9]+;)*4m/g, '', - ) + ); } diff --git a/src/components/shell/ShellProgressMessage.tsx b/src/components/shell/ShellProgressMessage.tsx index a99bdbd0d..e8aada93e 100644 --- a/src/components/shell/ShellProgressMessage.tsx +++ b/src/components/shell/ShellProgressMessage.tsx @@ -1,21 +1,21 @@ -import React from 'react' -import stripAnsi from 'strip-ansi' -import { Box, Text } from '@anthropic/ink' -import { formatFileSize } from '../../utils/format.js' -import { MessageResponse } from '../MessageResponse.js' -import { OffscreenFreeze } from '../OffscreenFreeze.js' -import { ShellTimeDisplay } from './ShellTimeDisplay.js' +import React from 'react'; +import stripAnsi from 'strip-ansi'; +import { Box, Text } from '@anthropic/ink'; +import { formatFileSize } from '../../utils/format.js'; +import { MessageResponse } from '../MessageResponse.js'; +import { OffscreenFreeze } from '../OffscreenFreeze.js'; +import { ShellTimeDisplay } from './ShellTimeDisplay.js'; type Props = { - output: string - fullOutput: string - elapsedTimeSeconds?: number - totalLines?: number - totalBytes?: number - timeoutMs?: number - taskId?: string - verbose: boolean -} + output: string; + fullOutput: string; + elapsedTimeSeconds?: number; + totalLines?: number; + totalBytes?: number; + timeoutMs?: number; + taskId?: string; + verbose: boolean; +}; export function ShellProgressMessage({ output, @@ -26,10 +26,10 @@ export function ShellProgressMessage({ timeoutMs, verbose, }: Props): React.ReactNode { - const strippedFullOutput = stripAnsi(fullOutput.trim()) - const strippedOutput = stripAnsi(output.trim()) - const lines = strippedOutput.split('\n').filter(line => line) - const displayLines = verbose ? strippedFullOutput : lines.slice(-5).join('\n') + const strippedFullOutput = stripAnsi(fullOutput.trim()); + const strippedOutput = stripAnsi(output.trim()); + const lines = strippedOutput.split('\n').filter(line => line); + const displayLines = verbose ? strippedFullOutput : lines.slice(-5).join('\n'); // OffscreenFreeze: BashTool yields progress (elapsedTimeSeconds) every second. // If this line scrolls into scrollback, each tick forces a full terminal reset. @@ -40,48 +40,36 @@ export function ShellProgressMessage({ Running… - + - ) + ); } // Not truncated: "+2 lines" (total exceeds displayed 5) // Truncated: "~2000 lines" (extrapolated estimate from tail sample) - const extraLines = totalLines ? Math.max(0, totalLines - 5) : 0 - let lineStatus = '' + const extraLines = totalLines ? Math.max(0, totalLines - 5) : 0; + let lineStatus = ''; if (!verbose && totalBytes && totalLines) { - lineStatus = `~${totalLines} lines` + lineStatus = `~${totalLines} lines`; } else if (!verbose && extraLines > 0) { - lineStatus = `+${extraLines} lines` + lineStatus = `+${extraLines} lines`; } return ( - + {displayLines} {lineStatus ? {lineStatus} : null} - - {totalBytes ? ( - {formatFileSize(totalBytes)} - ) : null} + + {totalBytes ? {formatFileSize(totalBytes)} : null} - ) + ); } diff --git a/src/components/shell/ShellTimeDisplay.tsx b/src/components/shell/ShellTimeDisplay.tsx index 67b5c373a..32fff276e 100644 --- a/src/components/shell/ShellTimeDisplay.tsx +++ b/src/components/shell/ShellTimeDisplay.tsx @@ -1,28 +1,23 @@ -import React from 'react' -import { Text } from '@anthropic/ink' -import { formatDuration } from '../../utils/format.js' +import React from 'react'; +import { Text } from '@anthropic/ink'; +import { formatDuration } from '../../utils/format.js'; type Props = { - elapsedTimeSeconds?: number - timeoutMs?: number -} + elapsedTimeSeconds?: number; + timeoutMs?: number; +}; -export function ShellTimeDisplay({ - elapsedTimeSeconds, - timeoutMs, -}: Props): React.ReactNode { +export function ShellTimeDisplay({ elapsedTimeSeconds, timeoutMs }: Props): React.ReactNode { if (elapsedTimeSeconds === undefined && !timeoutMs) { - return null + return null; } - const timeout = timeoutMs - ? formatDuration(timeoutMs, { hideTrailingZeros: true }) - : undefined + const timeout = timeoutMs ? formatDuration(timeoutMs, { hideTrailingZeros: true }) : undefined; if (elapsedTimeSeconds === undefined) { - return {`(timeout ${timeout})`} + return {`(timeout ${timeout})`}; } - const elapsed = formatDuration(elapsedTimeSeconds * 1000) + const elapsed = formatDuration(elapsedTimeSeconds * 1000); if (timeout) { - return {`(${elapsed} · timeout ${timeout})`} + return {`(${elapsed} · timeout ${timeout})`}; } - return {`(${elapsed})`} + return {`(${elapsed})`}; } diff --git a/src/components/skills/SkillsMenu.tsx b/src/components/skills/SkillsMenu.tsx index 6bbf48bf2..c14e02a27 100644 --- a/src/components/skills/SkillsMenu.tsx +++ b/src/components/skills/SkillsMenu.tsx @@ -1,56 +1,44 @@ -import capitalize from 'lodash-es/capitalize.js' -import * as React from 'react' -import { useMemo } from 'react' +import capitalize from 'lodash-es/capitalize.js'; +import * as React from 'react'; +import { useMemo } from 'react'; import { type Command, type CommandBase, type CommandResultDisplay, getCommandName, type PromptCommand, -} from '../../commands.js' -import { Box, Text } from '@anthropic/ink' -import type { Theme } from '@anthropic/ink' -import { - estimateSkillFrontmatterTokens, - getSkillsPath, -} from '../../skills/loadSkillsDir.js' -import { getDisplayPath } from '../../utils/file.js' -import { formatTokens } from '../../utils/format.js' -import { - getSettingSourceName, - type SettingSource, -} from '../../utils/settings/constants.js' -import { plural } from '../../utils/stringUtils.js' -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' -import { Dialog } from '@anthropic/ink' +} from '../../commands.js'; +import { Box, Text } from '@anthropic/ink'; +import type { Theme } from '@anthropic/ink'; +import { estimateSkillFrontmatterTokens, getSkillsPath } from '../../skills/loadSkillsDir.js'; +import { getDisplayPath } from '../../utils/file.js'; +import { formatTokens } from '../../utils/format.js'; +import { getSettingSourceName, type SettingSource } from '../../utils/settings/constants.js'; +import { plural } from '../../utils/stringUtils.js'; +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; +import { Dialog } from '@anthropic/ink'; // Skills are always PromptCommands with CommandBase properties -type SkillCommand = CommandBase & PromptCommand +type SkillCommand = CommandBase & PromptCommand; -type SkillSource = SettingSource | 'plugin' | 'mcp' +type SkillSource = SettingSource | 'plugin' | 'mcp'; type Props = { - onExit: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void - commands: Command[] -} + onExit: (result?: string, options?: { display?: CommandResultDisplay }) => void; + commands: Command[]; +}; function getSourceTitle(source: SkillSource): string { if (source === 'plugin') { - return 'Plugin skills' + return 'Plugin skills'; } if (source === 'mcp') { - return 'MCP skills' + return 'MCP skills'; } - return `${capitalize(getSettingSourceName(source))} skills` + return `${capitalize(getSettingSourceName(source))} skills`; } -function getSourceSubtitle( - source: SkillSource, - skills: SkillCommand[], -): string | undefined { +function getSourceSubtitle(source: SkillSource, skills: SkillCommand[]): string | undefined { // MCP skills show server names; file-based skills show filesystem paths. // Skill names are `:`, not `mcp____…`. if (source === 'mcp') { @@ -58,21 +46,17 @@ function getSourceSubtitle( ...new Set( skills .map(s => { - const idx = s.name.indexOf(':') - return idx > 0 ? s.name.slice(0, idx) : null + const idx = s.name.indexOf(':'); + return idx > 0 ? s.name.slice(0, idx) : null; }) .filter((n): n is string => n != null), ), - ] - return servers.length > 0 ? servers.join(', ') : undefined + ]; + return servers.length > 0 ? servers.join(', ') : undefined; } - const skillsPath = getDisplayPath(getSkillsPath(source, 'skills')) - const hasCommandsSkills = skills.some( - s => s.loadedFrom === 'commands_DEPRECATED', - ) - return hasCommandsSkills - ? `${skillsPath}, ${getDisplayPath(getSkillsPath(source, 'commands'))}` - : skillsPath + const skillsPath = getDisplayPath(getSkillsPath(source, 'skills')); + const hasCommandsSkills = skills.some(s => s.loadedFrom === 'commands_DEPRECATED'); + return hasCommandsSkills ? `${skillsPath}, ${getDisplayPath(getSkillsPath(source, 'commands'))}` : skillsPath; } export function SkillsMenu({ onExit, commands }: Props): React.ReactNode { @@ -85,8 +69,8 @@ export function SkillsMenu({ onExit, commands }: Props): React.ReactNode { cmd.loadedFrom === 'commands_DEPRECATED' || cmd.loadedFrom === 'plugin' || cmd.loadedFrom === 'mcp'), - ) - }, [commands]) + ); + }, [commands]); const skillsBySource = useMemo((): Record => { const groups: Record = { @@ -97,95 +81,74 @@ export function SkillsMenu({ onExit, commands }: Props): React.ReactNode { flagSettings: [], plugin: [], mcp: [], - } + }; for (const skill of skills) { - const source = skill.source as SkillSource + const source = skill.source as SkillSource; if (source in groups) { - groups[source].push(skill) + groups[source].push(skill); } } for (const group of Object.values(groups)) { - group.sort((a, b) => getCommandName(a).localeCompare(getCommandName(b))) + group.sort((a, b) => getCommandName(a).localeCompare(getCommandName(b))); } - return groups - }, [skills]) + return groups; + }, [skills]); const handleCancel = (): void => { - onExit('Skills dialog dismissed', { display: 'system' }) - } + onExit('Skills dialog dismissed', { display: 'system' }); + }; if (skills.length === 0) { return ( - - - Create skills in .claude/skills/ or ~/.claude/skills/ - + + Create skills in .claude/skills/ or ~/.claude/skills/ - + - ) + ); } - const getScopeTag = ( - source: string, - ): { label: string; color: string } | undefined => { + const getScopeTag = (source: string): { label: string; color: string } | undefined => { switch (source) { case 'projectSettings': case 'localSettings': - return { label: 'local', color: 'yellow' } + return { label: 'local', color: 'yellow' }; case 'userSettings': - return { label: 'global', color: 'cyan' } + return { label: 'global', color: 'cyan' }; case 'policySettings': - return { label: 'managed', color: 'magenta' } + return { label: 'managed', color: 'magenta' }; default: - return undefined + return undefined; } - } + }; const renderSkill = (skill: SkillCommand) => { - const estimatedTokens = estimateSkillFrontmatterTokens(skill) - const tokenDisplay = `~${formatTokens(estimatedTokens)}` - const pluginName = - skill.source === 'plugin' - ? skill.pluginInfo?.pluginManifest.name - : undefined - const scopeTag = getScopeTag(skill.source) + const estimatedTokens = estimateSkillFrontmatterTokens(skill); + const tokenDisplay = `~${formatTokens(estimatedTokens)}`; + const pluginName = skill.source === 'plugin' ? skill.pluginInfo?.pluginManifest.name : undefined; + const scopeTag = getScopeTag(skill.source); return ( {getCommandName(skill)} - {scopeTag && ( - [{scopeTag.label}] - - )} + {scopeTag && [{scopeTag.label}]} - {pluginName ? ` · ${pluginName}` : ''} · {tokenDisplay} description - tokens + {pluginName ? ` · ${pluginName}` : ''} · {tokenDisplay} description tokens - ) - } + ); + }; const renderSkillGroup = (source: SkillSource) => { - const groupSkills = skillsBySource[source] - if (groupSkills.length === 0) return null + const groupSkills = skillsBySource[source]; + if (groupSkills.length === 0) return null; - const title = getSourceTitle(source) - const subtitle = getSourceSubtitle(source, groupSkills) + const title = getSourceTitle(source); + const subtitle = getSourceSubtitle(source, groupSkills); return ( @@ -197,8 +160,8 @@ export function SkillsMenu({ onExit, commands }: Props): React.ReactNode { {groupSkills.map(skill => renderSkill(skill))} - ) - } + ); + }; return ( - + - ) + ); } diff --git a/src/components/src/bootstrap/state.ts b/src/components/src/bootstrap/state.ts index c6af340fa..b274336b7 100644 --- a/src/components/src/bootstrap/state.ts +++ b/src/components/src/bootstrap/state.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getLastAPIRequest = any; +export type getLastAPIRequest = any diff --git a/src/components/src/commands.ts b/src/components/src/commands.ts index 8552dd207..4d2f681ce 100644 --- a/src/components/src/commands.ts +++ b/src/components/src/commands.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type CommandResultDisplay = any; +export type CommandResultDisplay = any diff --git a/src/components/src/components/shell/OutputLine.ts b/src/components/src/components/shell/OutputLine.ts index 9c5093c9a..fbd140f05 100644 --- a/src/components/src/components/shell/OutputLine.ts +++ b/src/components/src/components/shell/OutputLine.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type stripUnderlineAnsi = any; +export type stripUnderlineAnsi = any diff --git a/src/components/src/hooks/useExitOnCtrlCDWithKeybindings.ts b/src/components/src/hooks/useExitOnCtrlCDWithKeybindings.ts index 38810d68c..317a9c014 100644 --- a/src/components/src/hooks/useExitOnCtrlCDWithKeybindings.ts +++ b/src/components/src/hooks/useExitOnCtrlCDWithKeybindings.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useExitOnCtrlCDWithKeybindings = any; +export type useExitOnCtrlCDWithKeybindings = any diff --git a/src/components/src/hooks/useTerminalSize.ts b/src/components/src/hooks/useTerminalSize.ts index 4a0ef3ea3..fdaf2e999 100644 --- a/src/components/src/hooks/useTerminalSize.ts +++ b/src/components/src/hooks/useTerminalSize.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useTerminalSize = any; +export type useTerminalSize = any diff --git a/src/components/src/services/analytics/firstPartyEventLogger.ts b/src/components/src/services/analytics/firstPartyEventLogger.ts index 3387d83a1..5791e6926 100644 --- a/src/components/src/services/analytics/firstPartyEventLogger.ts +++ b/src/components/src/services/analytics/firstPartyEventLogger.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type logEventTo1P = any; +export type logEventTo1P = any diff --git a/src/components/src/services/analytics/index.ts b/src/components/src/services/analytics/index.ts index ce0a9a827..eca4493cf 100644 --- a/src/components/src/services/analytics/index.ts +++ b/src/components/src/services/analytics/index.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type logEvent = any; -export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any; +export type logEvent = any +export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any diff --git a/src/components/src/state/AppState.ts b/src/components/src/state/AppState.ts index cc3978f9a..53048a173 100644 --- a/src/components/src/state/AppState.ts +++ b/src/components/src/state/AppState.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type useAppState = any; -export type useSetAppState = any; +export type useAppState = any +export type useSetAppState = any diff --git a/src/components/src/tools/FileEditTool/types.ts b/src/components/src/tools/FileEditTool/types.ts index 077f10550..bc2085aba 100644 --- a/src/components/src/tools/FileEditTool/types.ts +++ b/src/components/src/tools/FileEditTool/types.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type FileEditOutput = any; +export type FileEditOutput = any diff --git a/src/components/src/tools/FileWriteTool/FileWriteTool.ts b/src/components/src/tools/FileWriteTool/FileWriteTool.ts index 50716571f..3a75203ed 100644 --- a/src/components/src/tools/FileWriteTool/FileWriteTool.ts +++ b/src/components/src/tools/FileWriteTool/FileWriteTool.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type Output = any; +export type Output = any diff --git a/src/components/src/utils/background/remote/preconditions.ts b/src/components/src/utils/background/remote/preconditions.ts index 4c7df781f..e35b2da52 100644 --- a/src/components/src/utils/background/remote/preconditions.ts +++ b/src/components/src/utils/background/remote/preconditions.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type checkIsGitClean = any; -export type checkNeedsClaudeAiLogin = any; +export type checkIsGitClean = any +export type checkNeedsClaudeAiLogin = any diff --git a/src/components/src/utils/conversationRecovery.ts b/src/components/src/utils/conversationRecovery.ts index ecd18bdac..4a003ab02 100644 --- a/src/components/src/utils/conversationRecovery.ts +++ b/src/components/src/utils/conversationRecovery.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type TeleportRemoteResponse = any; +export type TeleportRemoteResponse = any diff --git a/src/components/src/utils/cwd.ts b/src/components/src/utils/cwd.ts index 76c192ed8..4bd56a824 100644 --- a/src/components/src/utils/cwd.ts +++ b/src/components/src/utils/cwd.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getCwd = any; +export type getCwd = any diff --git a/src/components/src/utils/debug.ts b/src/components/src/utils/debug.ts index c64d5960c..5a0f1d515 100644 --- a/src/components/src/utils/debug.ts +++ b/src/components/src/utils/debug.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type logForDebugging = any; +export type logForDebugging = any diff --git a/src/components/src/utils/envDynamic.ts b/src/components/src/utils/envDynamic.ts index f97791718..f3eea282f 100644 --- a/src/components/src/utils/envDynamic.ts +++ b/src/components/src/utils/envDynamic.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type envDynamic = any; +export type envDynamic = any diff --git a/src/components/src/utils/fastMode.ts b/src/components/src/utils/fastMode.ts index cf4c9c15a..1c0638137 100644 --- a/src/components/src/utils/fastMode.ts +++ b/src/components/src/utils/fastMode.ts @@ -1,5 +1,5 @@ // Auto-generated type stub — replace with real implementation -export type FAST_MODE_MODEL_DISPLAY = any; -export type isFastModeAvailable = any; -export type isFastModeCooldown = any; -export type isFastModeEnabled = any; +export type FAST_MODE_MODEL_DISPLAY = any +export type isFastModeAvailable = any +export type isFastModeCooldown = any +export type isFastModeEnabled = any diff --git a/src/components/src/utils/fileHistory.ts b/src/components/src/utils/fileHistory.ts index d3a0a3eb9..e1afb14d8 100644 --- a/src/components/src/utils/fileHistory.ts +++ b/src/components/src/utils/fileHistory.ts @@ -1,5 +1,5 @@ // Auto-generated type stub — replace with real implementation -export type DiffStats = any; -export type fileHistoryCanRestore = any; -export type fileHistoryEnabled = any; -export type fileHistoryGetDiffStats = any; +export type DiffStats = any +export type fileHistoryCanRestore = any +export type fileHistoryEnabled = any +export type fileHistoryGetDiffStats = any diff --git a/src/components/src/utils/gracefulShutdown.ts b/src/components/src/utils/gracefulShutdown.ts index 6b72be424..58d9f0c53 100644 --- a/src/components/src/utils/gracefulShutdown.ts +++ b/src/components/src/utils/gracefulShutdown.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type gracefulShutdown = any; -export type gracefulShutdownSync = any; +export type gracefulShutdown = any +export type gracefulShutdownSync = any diff --git a/src/components/src/utils/log.ts b/src/components/src/utils/log.ts index cf30e90da..30df9cbcb 100644 --- a/src/components/src/utils/log.ts +++ b/src/components/src/utils/log.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type logError = any; +export type logError = any diff --git a/src/components/src/utils/messages.ts b/src/components/src/utils/messages.ts index e84ec855e..86ae0f462 100644 --- a/src/components/src/utils/messages.ts +++ b/src/components/src/utils/messages.ts @@ -1,4 +1,4 @@ // Auto-generated type stub — replace with real implementation -export type extractTag = any; -export type getLastAssistantMessage = any; -export type normalizeMessagesForAPI = any; +export type extractTag = any +export type getLastAssistantMessage = any +export type normalizeMessagesForAPI = any diff --git a/src/components/src/utils/permissions/PermissionMode.ts b/src/components/src/utils/permissions/PermissionMode.ts index 1bc6199f9..799935c26 100644 --- a/src/components/src/utils/permissions/PermissionMode.ts +++ b/src/components/src/utils/permissions/PermissionMode.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type PermissionMode = any; +export type PermissionMode = any diff --git a/src/components/src/utils/platform.ts b/src/components/src/utils/platform.ts index b6686f812..c7486cc77 100644 --- a/src/components/src/utils/platform.ts +++ b/src/components/src/utils/platform.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getPlatform = any; +export type getPlatform = any diff --git a/src/components/src/utils/process.ts b/src/components/src/utils/process.ts index 4fde4749b..15c34582e 100644 --- a/src/components/src/utils/process.ts +++ b/src/components/src/utils/process.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type writeToStdout = any; +export type writeToStdout = any diff --git a/src/components/src/utils/sandbox/sandbox-ui-utils.ts b/src/components/src/utils/sandbox/sandbox-ui-utils.ts index f514e53a9..a008b1faa 100644 --- a/src/components/src/utils/sandbox/sandbox-ui-utils.ts +++ b/src/components/src/utils/sandbox/sandbox-ui-utils.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type removeSandboxViolationTags = any; +export type removeSandboxViolationTags = any diff --git a/src/components/src/utils/set.ts b/src/components/src/utils/set.ts index 9450ee339..06eae7cc6 100644 --- a/src/components/src/utils/set.ts +++ b/src/components/src/utils/set.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type every = any; +export type every = any diff --git a/src/components/src/utils/teleport/api.ts b/src/components/src/utils/teleport/api.ts index d532f65b9..de2d2167c 100644 --- a/src/components/src/utils/teleport/api.ts +++ b/src/components/src/utils/teleport/api.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type CodeSession = any; -export type fetchCodeSessionsFromSessionsAPI = any; +export type CodeSession = any +export type fetchCodeSessionsFromSessionsAPI = any diff --git a/src/components/src/utils/theme.ts b/src/components/src/utils/theme.ts index c6999a678..833b24799 100644 --- a/src/components/src/utils/theme.ts +++ b/src/components/src/utils/theme.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type Theme = any; +export type Theme = any diff --git a/src/components/tasks/AsyncAgentDetailDialog.tsx b/src/components/tasks/AsyncAgentDetailDialog.tsx index 2070b9a68..0d1d66243 100644 --- a/src/components/tasks/AsyncAgentDetailDialog.tsx +++ b/src/components/tasks/AsyncAgentDetailDialog.tsx @@ -1,42 +1,32 @@ -import React, { useMemo } from 'react' -import type { DeepImmutable } from 'src/types/utils.js' -import { useElapsedTime } from '../../hooks/useElapsedTime.js' -import { type KeyboardEvent, Box, Text, useTheme } from '@anthropic/ink' -import { useKeybindings } from '../../keybindings/useKeybinding.js' -import { getEmptyToolPermissionContext } from '../../Tool.js' -import type { LocalAgentTaskState } from '../../tasks/LocalAgentTask/LocalAgentTask.js' -import { getTools } from '../../tools.js' -import { formatNumber } from '../../utils/format.js' -import { extractTag } from '../../utils/messages.js' -import { Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink' -import { UserPlanMessage } from '../messages/UserPlanMessage.js' -import { renderToolActivity } from './renderToolActivity.js' -import { getTaskStatusColor, getTaskStatusIcon } from './taskStatusUtils.js' +import React, { useMemo } from 'react'; +import type { DeepImmutable } from 'src/types/utils.js'; +import { useElapsedTime } from '../../hooks/useElapsedTime.js'; +import { type KeyboardEvent, Box, Text, useTheme } from '@anthropic/ink'; +import { useKeybindings } from '../../keybindings/useKeybinding.js'; +import { getEmptyToolPermissionContext } from '../../Tool.js'; +import type { LocalAgentTaskState } from '../../tasks/LocalAgentTask/LocalAgentTask.js'; +import { getTools } from '../../tools.js'; +import { formatNumber } from '../../utils/format.js'; +import { extractTag } from '../../utils/messages.js'; +import { Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink'; +import { UserPlanMessage } from '../messages/UserPlanMessage.js'; +import { renderToolActivity } from './renderToolActivity.js'; +import { getTaskStatusColor, getTaskStatusIcon } from './taskStatusUtils.js'; type Props = { - agent: DeepImmutable - onDone: () => void - onKillAgent?: () => void - onBack?: () => void -} + agent: DeepImmutable; + onDone: () => void; + onKillAgent?: () => void; + onBack?: () => void; +}; -export function AsyncAgentDetailDialog({ - agent, - onDone, - onKillAgent, - onBack, -}: Props): React.ReactNode { - const [theme] = useTheme() +export function AsyncAgentDetailDialog({ agent, onDone, onKillAgent, onBack }: Props): React.ReactNode { + const [theme] = useTheme(); // Get tools for rendering activity messages - const tools = useMemo(() => getTools(getEmptyToolPermissionContext()), []) + const tools = useMemo(() => getTools(getEmptyToolPermissionContext()), []); - const elapsedTime = useElapsedTime( - agent.startTime, - agent.status === 'running', - 1000, - agent.totalPausedMs ?? 0, - ) + const elapsedTime = useElapsedTime(agent.startTime, agent.status === 'running', 1000, agent.totalPausedMs ?? 0); // Restore confirm:yes (Enter/y) dismissal — Dialog handles confirm:no (Esc) // internally but does NOT auto-wire confirm:yes. @@ -45,7 +35,7 @@ export function AsyncAgentDetailDialog({ 'confirm:yes': onDone, }, { context: 'Confirmation' }, - ) + ); // Component-specific shortcuts shown in UI hints (x=stop) and // navigation keys (space=dismiss, left=back). These are context-dependent @@ -54,36 +44,31 @@ export function AsyncAgentDetailDialog({ // confirm:yes (Enter/y) is handled by useKeybindings above. const handleKeyDown = (e: KeyboardEvent) => { if (e.key === ' ') { - e.preventDefault() - onDone() + e.preventDefault(); + onDone(); } else if (e.key === 'left' && onBack) { - e.preventDefault() - onBack() + e.preventDefault(); + onBack(); } else if (e.key === 'x' && agent.status === 'running' && onKillAgent) { - e.preventDefault() - onKillAgent() + e.preventDefault(); + onKillAgent(); } - } + }; // Extract plan from prompt - if present, we show the plan instead of the prompt - const planContent = extractTag(agent.prompt, 'plan') + const planContent = extractTag(agent.prompt, 'plan'); - const displayPrompt = - agent.prompt.length > 300 - ? agent.prompt.substring(0, 297) + '…' - : agent.prompt + const displayPrompt = agent.prompt.length > 300 ? agent.prompt.substring(0, 297) + '…' : agent.prompt; // Get tokens and tool uses (from result if completed, otherwise from progress) - const tokenCount = agent.result?.totalTokens ?? agent.progress?.tokenCount - const toolUseCount = - agent.result?.totalToolUseCount ?? agent.progress?.toolUseCount + const tokenCount = agent.result?.totalTokens ?? agent.progress?.tokenCount; + const toolUseCount = agent.result?.totalToolUseCount ?? agent.progress?.toolUseCount; const title = ( - {agent.selectedAgent?.agentType ?? 'agent'} ›{' '} - {agent.description || 'Async agent'} + {agent.selectedAgent?.agentType ?? 'agent'} › {agent.description || 'Async agent'} - ) + ); // Build subtitle with status and stats const subtitle = ( @@ -91,19 +76,13 @@ export function AsyncAgentDetailDialog({ {agent.status !== 'running' && ( {getTaskStatusIcon(agent.status)}{' '} - {agent.status === 'completed' - ? 'Completed' - : agent.status === 'failed' - ? 'Failed' - : 'Stopped'} + {agent.status === 'completed' ? 'Completed' : agent.status === 'failed' ? 'Failed' : 'Stopped'} {' · '} )} {elapsedTime} - {tokenCount !== undefined && tokenCount > 0 && ( - <> · {formatNumber(tokenCount)} tokens - )} + {tokenCount !== undefined && tokenCount > 0 && <> · {formatNumber(tokenCount)} tokens} {toolUseCount !== undefined && toolUseCount > 0 && ( <> {' '} @@ -112,15 +91,10 @@ export function AsyncAgentDetailDialog({ )} - ) + ); return ( - + {onBack && } - {agent.status === 'running' && onKillAgent && ( - - )} + {agent.status === 'running' && onKillAgent && } ) } @@ -150,14 +122,8 @@ export function AsyncAgentDetailDialog({ Progress {agent.progress.recentActivities.map((activity, i) => ( - - {i === agent.progress!.recentActivities!.length - 1 - ? '› ' - : ' '} + + {i === agent.progress!.recentActivities!.length - 1 ? '› ' : ' '} {renderToolActivity(activity, tools, theme)} ))} @@ -193,5 +159,5 @@ export function AsyncAgentDetailDialog({ - ) + ); } diff --git a/src/components/tasks/BackgroundTask.tsx b/src/components/tasks/BackgroundTask.tsx index 62a3e6268..267b09173 100644 --- a/src/components/tasks/BackgroundTask.tsx +++ b/src/components/tasks/BackgroundTask.tsx @@ -1,38 +1,31 @@ -import * as React from 'react' -import { Text } from '@anthropic/ink' -import { toInkColor } from '../../utils/ink.js' -import type { BackgroundTaskState } from 'src/tasks/types.js' -import type { DeepImmutable } from 'src/types/utils.js' -import { truncate } from 'src/utils/format.js' +import * as React from 'react'; +import { Text } from '@anthropic/ink'; +import { toInkColor } from '../../utils/ink.js'; +import type { BackgroundTaskState } from 'src/tasks/types.js'; +import type { DeepImmutable } from 'src/types/utils.js'; +import { truncate } from 'src/utils/format.js'; -import { plural } from 'src/utils/stringUtils.js' -import { DIAMOND_FILLED, DIAMOND_OPEN } from '../../constants/figures.js' -import { RemoteSessionProgress } from './RemoteSessionProgress.js' -import { ShellProgress, TaskStatusText } from './ShellProgress.js' -import { describeTeammateActivity } from './taskStatusUtils.js' +import { plural } from 'src/utils/stringUtils.js'; +import { DIAMOND_FILLED, DIAMOND_OPEN } from '../../constants/figures.js'; +import { RemoteSessionProgress } from './RemoteSessionProgress.js'; +import { ShellProgress, TaskStatusText } from './ShellProgress.js'; +import { describeTeammateActivity } from './taskStatusUtils.js'; type Props = { - task: DeepImmutable - maxActivityWidth?: number -} + task: DeepImmutable; + maxActivityWidth?: number; +}; -export function BackgroundTask({ - task, - maxActivityWidth, -}: Props): React.ReactNode { - const activityLimit = maxActivityWidth ?? 40 +export function BackgroundTask({ task, maxActivityWidth }: Props): React.ReactNode { + const activityLimit = maxActivityWidth ?? 40; switch (task.type) { case 'local_bash': return ( - {truncate( - task.kind === 'monitor' ? task.description : task.command, - activityLimit, - true, - )}{' '} + {truncate(task.kind === 'monitor' ? task.description : task.command, activityLimit, true)}{' '} - ) + ); case 'remote_agent': { // Lite-review renders its own rainbow line (title + live counts), // so we don't prefix the title — the rainbow already includes it. @@ -41,9 +34,9 @@ export function BackgroundTask({ - ) + ); } - const running = task.status === 'running' || task.status === 'pending' + const running = task.status === 'running' || task.status === 'pending'; return ( {running ? DIAMOND_OPEN : DIAMOND_FILLED} @@ -51,7 +44,7 @@ export function BackgroundTask({ · - ) + ); } case 'local_agent': return ( @@ -60,27 +53,21 @@ export function BackgroundTask({ - ) + ); case 'in_process_teammate': { - const activity = describeTeammateActivity(task) + const activity = describeTeammateActivity(task); return ( - - @{task.identity.agentName} - + @{task.identity.agentName} : {truncate(activity, activityLimit, true)} - ) + ); } case 'local_workflow': { - const _task = task as Record + const _task = task as Record; return ( {truncate( @@ -97,14 +84,10 @@ export function BackgroundTask({ ? 'done' : undefined } - suffix={ - task.status === 'completed' && !task.notified - ? ', unread' - : undefined - } + suffix={task.status === 'completed' && !task.notified ? ', unread' : undefined} /> - ) + ); } case 'monitor_mcp': return ( @@ -113,20 +96,16 @@ export function BackgroundTask({ - ) + ); case 'dream': { - const n = task.filesTouched.length + const n = task.filesTouched.length; const detail = task.phase === 'updating' && n > 0 ? `${n} ${plural(n, 'file')}` - : `${task.sessionsReviewing} ${plural(task.sessionsReviewing, 'session')}` + : `${task.sessionsReviewing} ${plural(task.sessionsReviewing, 'session')}`; return ( {task.description}{' '} @@ -136,14 +115,10 @@ export function BackgroundTask({ - ) + ); } } } diff --git a/src/components/tasks/BackgroundTaskStatus.tsx b/src/components/tasks/BackgroundTaskStatus.tsx index 4813c86eb..21948f074 100644 --- a/src/components/tasks/BackgroundTaskStatus.tsx +++ b/src/components/tasks/BackgroundTaskStatus.tsx @@ -1,38 +1,31 @@ -import figures from 'figures' -import * as React from 'react' -import { useMemo, useState } from 'react' -import { useTerminalSize } from 'src/hooks/useTerminalSize.js' -import { stringWidth } from '@anthropic/ink' -import { useAppState, useSetAppState } from 'src/state/AppState.js' -import { - enterTeammateView, - exitTeammateView, -} from 'src/state/teammateViewHelpers.js' -import { isPanelAgentTask } from 'src/tasks/LocalAgentTask/LocalAgentTask.js' -import { getPillLabel, pillNeedsCta } from 'src/tasks/pillLabel.js' -import { - type BackgroundTaskState, - isBackgroundTask, - type TaskState, -} from 'src/tasks/types.js' -import { calculateHorizontalScrollWindow } from 'src/utils/horizontalScroll.js' -import { Box, Text } from '@anthropic/ink' +import figures from 'figures'; +import * as React from 'react'; +import { useMemo, useState } from 'react'; +import { useTerminalSize } from 'src/hooks/useTerminalSize.js'; +import { stringWidth } from '@anthropic/ink'; +import { useAppState, useSetAppState } from 'src/state/AppState.js'; +import { enterTeammateView, exitTeammateView } from 'src/state/teammateViewHelpers.js'; +import { isPanelAgentTask } from 'src/tasks/LocalAgentTask/LocalAgentTask.js'; +import { getPillLabel, pillNeedsCta } from 'src/tasks/pillLabel.js'; +import { type BackgroundTaskState, isBackgroundTask, type TaskState } from 'src/tasks/types.js'; +import { calculateHorizontalScrollWindow } from 'src/utils/horizontalScroll.js'; +import { Box, Text } from '@anthropic/ink'; import { AGENT_COLOR_TO_THEME_COLOR, AGENT_COLORS, type AgentColorName, -} from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js' -import type { Theme } from '../../utils/theme.js' -import { KeyboardShortcutHint } from '@anthropic/ink' -import { shouldHideTasksFooter } from './taskStatusUtils.js' +} from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js'; +import type { Theme } from '../../utils/theme.js'; +import { KeyboardShortcutHint } from '@anthropic/ink'; +import { shouldHideTasksFooter } from './taskStatusUtils.js'; type Props = { - tasksSelected: boolean - isViewingTeammate?: boolean - teammateFooterIndex?: number - isLeaderIdle?: boolean - onOpenDialog?: (taskId?: string) => void -} + tasksSelected: boolean; + isViewingTeammate?: boolean; + teammateFooterIndex?: number; + isLeaderIdle?: boolean; + onOpenDialog?: (taskId?: string) => void; +}; export function BackgroundTaskStatus({ tasksSelected, @@ -41,43 +34,34 @@ export function BackgroundTaskStatus({ isLeaderIdle = false, onOpenDialog, }: Props): React.ReactNode { - const setAppState = useSetAppState() - const { columns } = useTerminalSize() - const tasks = useAppState(s => s.tasks) - const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId) + const setAppState = useSetAppState(); + const { columns } = useTerminalSize(); + const tasks = useAppState(s => s.tasks); + const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId); const runningTasks = useMemo( () => (Object.values(tasks ?? {}) as TaskState[]).filter( - t => - isBackgroundTask(t) && - !(process.env.USER_TYPE === 'ant' && isPanelAgentTask(t)), + t => isBackgroundTask(t) && !(process.env.USER_TYPE === 'ant' && isPanelAgentTask(t)), ), [tasks], - ) + ); // Check if all tasks are in-process teammates (team mode) // In spinner-tree mode, don't show teammate pills (teammates appear in the spinner tree) - const expandedView = useAppState(s => s.expandedView) - const showSpinnerTree = expandedView === 'teammates' + const expandedView = useAppState(s => s.expandedView); + const showSpinnerTree = expandedView === 'teammates'; const allTeammates = - !showSpinnerTree && - runningTasks.length > 0 && - runningTasks.every(t => t.type === 'in_process_teammate') + !showSpinnerTree && runningTasks.length > 0 && runningTasks.every(t => t.type === 'in_process_teammate'); // Memoize teammate-related computations at the top level (rules of hooks) const teammateEntries = useMemo( () => runningTasks - .filter( - (t): t is BackgroundTaskState & { type: 'in_process_teammate' } => - t.type === 'in_process_teammate', - ) - .sort((a, b) => - a.identity.agentName.localeCompare(b.identity.agentName), - ), + .filter((t): t is BackgroundTaskState & { type: 'in_process_teammate' } => t.type === 'in_process_teammate') + .sort((a, b) => a.identity.agentName.localeCompare(b.identity.agentName)), [runningTasks], - ) + ); // Build array of all pills with their activity state // Each pill is "@{name}" and separator is " " (1 char) @@ -90,67 +74,64 @@ export function BackgroundTaskStatus({ color: undefined as keyof Theme | undefined, isIdle: isLeaderIdle, taskId: undefined as string | undefined, - } + }; const teammatePills = teammateEntries.map(t => ({ name: t.identity.agentName, color: getAgentThemeColor(t.identity.color), isIdle: t.isIdle, taskId: t.id, - })) + })); // Only sort teammates when not selecting to avoid reordering during navigation if (!tasksSelected) { teammatePills.sort((a, b) => { // Active agents first, idle agents last - if (a.isIdle !== b.isIdle) return a.isIdle ? 1 : -1 - return 0 // Keep original order within each group - }) + if (a.isIdle !== b.isIdle) return a.isIdle ? 1 : -1; + return 0; // Keep original order within each group + }); } // main always first, then sorted teammates - const pills = [mainPill, ...teammatePills] + const pills = [mainPill, ...teammatePills]; // Add idx after sorting - return pills.map((pill, i) => ({ ...pill, idx: i })) - }, [teammateEntries, isLeaderIdle, tasksSelected]) + return pills.map((pill, i) => ({ ...pill, idx: i })); + }, [teammateEntries, isLeaderIdle, tasksSelected]); // Calculate pill widths (including separator space, except first) const pillWidths = useMemo( () => allPills.map((pill, i) => { - const pillText = `@${pill.name}` + const pillText = `@${pill.name}`; // First pill has no leading space, others have 1 space separator - return stringWidth(pillText) + (i > 0 ? 1 : 0) + return stringWidth(pillText) + (i > 0 ? 1 : 0); }), [allPills], - ) + ); if (allTeammates || (!showSpinnerTree && isViewingTeammate)) { - const selectedIdx = tasksSelected ? teammateFooterIndex : -1 + const selectedIdx = tasksSelected ? teammateFooterIndex : -1; // Which agent is currently foregrounded (bold) - const viewedIdx = viewingAgentTaskId - ? teammateEntries.findIndex(t => t.id === viewingAgentTaskId) + 1 - : 0 // 0 = main/leader + const viewedIdx = viewingAgentTaskId ? teammateEntries.findIndex(t => t.id === viewingAgentTaskId) + 1 : 0; // 0 = main/leader // Calculate available width for pills // Reserve space for: arrows, hint, and minimal padding // Pills are rendered on their own line when in team mode - const ARROW_WIDTH = 2 // arrow char + space - const HINT_WIDTH = 20 // shift+↓ to expand - const PADDING = 4 // minimal safety margin - const availableWidth = Math.max(20, columns - HINT_WIDTH - PADDING) + const ARROW_WIDTH = 2; // arrow char + space + const HINT_WIDTH = 20; // shift+↓ to expand + const PADDING = 4; // minimal safety margin + const availableWidth = Math.max(20, columns - HINT_WIDTH - PADDING); // Calculate visible window of pills - const { startIndex, endIndex, showLeftArrow, showRightArrow } = - calculateHorizontalScrollWindow( - pillWidths, - availableWidth, - ARROW_WIDTH, - selectedIdx >= 0 ? selectedIdx : 0, - ) + const { startIndex, endIndex, showLeftArrow, showRightArrow } = calculateHorizontalScrollWindow( + pillWidths, + availableWidth, + ARROW_WIDTH, + selectedIdx >= 0 ? selectedIdx : 0, + ); - const visiblePills = allPills.slice(startIndex, endIndex) + const visiblePills = allPills.slice(startIndex, endIndex); return ( <> @@ -158,7 +139,7 @@ export function BackgroundTaskStatus({ {visiblePills.map((pill, i) => { // First visible pill has no leading separator // (left arrow already provides spacing if present) - const needsSeparator = i > 0 + const needsSeparator = i > 0; return ( {needsSeparator && } @@ -169,13 +150,11 @@ export function BackgroundTaskStatus({ isViewed={viewedIdx === pill.idx} isIdle={pill.isIdle} onClick={() => - pill.taskId - ? enterTeammateView(pill.taskId, setAppState) - : exitTeammateView(setAppState) + pill.taskId ? enterTeammateView(pill.taskId, setAppState) : exitTeammateView(setAppState) } /> - ) + ); })} {showRightArrow && {figures.arrowRight}} @@ -183,17 +162,17 @@ export function BackgroundTaskStatus({ - ) + ); } // In spinner-tree mode, don't show any footer status for teammates // (they appear in the spinner tree above) if (shouldHideTasksFooter(tasks ?? {}, showSpinnerTree)) { - return null + return null; } if (runningTasks.length === 0) { - return null + return null; } return ( @@ -201,35 +180,26 @@ export function BackgroundTaskStatus({ {getPillLabel(runningTasks)} - {pillNeedsCta(runningTasks) && ( - · {figures.arrowDown} to view - )} + {pillNeedsCta(runningTasks) && · {figures.arrowDown} to view} - ) + ); } type AgentPillProps = { - name: string - color?: keyof Theme - isSelected: boolean - isViewed: boolean - isIdle: boolean - onClick?: () => void -} + name: string; + color?: keyof Theme; + isSelected: boolean; + isViewed: boolean; + isIdle: boolean; + onClick?: () => void; +}; -function AgentPill({ - name, - color, - isSelected, - isViewed, - isIdle, - onClick, -}: AgentPillProps): React.ReactNode { - const [hover, setHover] = useState(false) +function AgentPill({ name, color, isSelected, isViewed, isIdle, onClick }: AgentPillProps): React.ReactNode { + const [hover, setHover] = useState(false); // Hover mirrors the keyboard-selected look so the affordance is familiar. - const highlighted = isSelected || hover + const highlighted = isSelected || hover; - let label: React.ReactNode + let label: React.ReactNode; if (highlighted) { label = color ? ( @@ -239,37 +209,33 @@ function AgentPill({ @{name} - ) + ); } else if (isIdle) { label = ( @{name} - ) + ); } else if (isViewed) { label = ( @{name} - ) + ); } else { label = ( @{name} - ) + ); } - if (!onClick) return label + if (!onClick) return label; return ( - setHover(true)} - onMouseLeave={() => setHover(false)} - > + setHover(true)} onMouseLeave={() => setHover(false)}> {label} - ) + ); } function SummaryPill({ @@ -277,34 +243,28 @@ function SummaryPill({ onClick, children, }: { - selected: boolean - onClick?: () => void - children: React.ReactNode + selected: boolean; + onClick?: () => void; + children: React.ReactNode; }): React.ReactNode { - const [hover, setHover] = useState(false) + const [hover, setHover] = useState(false); const label = ( {children} - ) - if (!onClick) return label + ); + if (!onClick) return label; return ( - setHover(true)} - onMouseLeave={() => setHover(false)} - > + setHover(true)} onMouseLeave={() => setHover(false)}> {label} - ) + ); } -function getAgentThemeColor( - colorName: string | undefined, -): keyof Theme | undefined { - if (!colorName) return undefined +function getAgentThemeColor(colorName: string | undefined): keyof Theme | undefined { + if (!colorName) return undefined; if (AGENT_COLORS.includes(colorName as AgentColorName)) { - return AGENT_COLOR_TO_THEME_COLOR[colorName as AgentColorName] + return AGENT_COLOR_TO_THEME_COLOR[colorName as AgentColorName]; } - return undefined + return undefined; } diff --git a/src/components/tasks/BackgroundTasksDialog.tsx b/src/components/tasks/BackgroundTasksDialog.tsx index 80249e9ea..9fdd89f1a 100644 --- a/src/components/tasks/BackgroundTasksDialog.tsx +++ b/src/components/tasks/BackgroundTasksDialog.tsx @@ -1,158 +1,131 @@ -import { feature } from 'bun:bundle' -import figures from 'figures' -import type { AgentId } from '../../types/ids.js' -import React, { - type ReactNode, - useEffect, - useEffectEvent, - useMemo, - useRef, - useState, -} from 'react' -import { isCoordinatorMode } from 'src/coordinator/coordinatorMode.js' -import { useTerminalSize } from 'src/hooks/useTerminalSize.js' -import { useAppState, useSetAppState } from 'src/state/AppState.js' -import { - enterTeammateView, - exitTeammateView, -} from 'src/state/teammateViewHelpers.js' -import type { ToolUseContext } from 'src/Tool.js' -import { - DreamTask, - type DreamTaskState, -} from 'src/tasks/DreamTask/DreamTask.js' -import { InProcessTeammateTask } from 'src/tasks/InProcessTeammateTask/InProcessTeammateTask.js' -import type { InProcessTeammateTaskState } from 'src/tasks/InProcessTeammateTask/types.js' -import type { LocalAgentTaskState } from 'src/tasks/LocalAgentTask/LocalAgentTask.js' -import { LocalAgentTask } from 'src/tasks/LocalAgentTask/LocalAgentTask.js' -import type { LocalShellTaskState } from 'src/tasks/LocalShellTask/guards.js' -import { LocalShellTask } from 'src/tasks/LocalShellTask/LocalShellTask.js' +import { feature } from 'bun:bundle'; +import figures from 'figures'; +import type { AgentId } from '../../types/ids.js'; +import React, { type ReactNode, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react'; +import { isCoordinatorMode } from 'src/coordinator/coordinatorMode.js'; +import { useTerminalSize } from 'src/hooks/useTerminalSize.js'; +import { useAppState, useSetAppState } from 'src/state/AppState.js'; +import { enterTeammateView, exitTeammateView } from 'src/state/teammateViewHelpers.js'; +import type { ToolUseContext } from 'src/Tool.js'; +import { DreamTask, type DreamTaskState } from 'src/tasks/DreamTask/DreamTask.js'; +import { InProcessTeammateTask } from 'src/tasks/InProcessTeammateTask/InProcessTeammateTask.js'; +import type { InProcessTeammateTaskState } from 'src/tasks/InProcessTeammateTask/types.js'; +import type { LocalAgentTaskState } from 'src/tasks/LocalAgentTask/LocalAgentTask.js'; +import { LocalAgentTask } from 'src/tasks/LocalAgentTask/LocalAgentTask.js'; +import type { LocalShellTaskState } from 'src/tasks/LocalShellTask/guards.js'; +import { LocalShellTask } from 'src/tasks/LocalShellTask/LocalShellTask.js'; // Type import is erased at build time — safe even though module is ant-gated. -import type { LocalWorkflowTaskState } from 'src/tasks/LocalWorkflowTask/LocalWorkflowTask.js' -import type { MonitorMcpTaskState } from 'src/tasks/MonitorMcpTask/MonitorMcpTask.js' -import { - RemoteAgentTask, - type RemoteAgentTaskState, -} from 'src/tasks/RemoteAgentTask/RemoteAgentTask.js' -import { - type BackgroundTaskState, - isBackgroundTask, - type TaskState, -} from 'src/tasks/types.js' -import type { DeepImmutable } from 'src/types/utils.js' -import { intersperse } from 'src/utils/array.js' -import { TEAM_LEAD_NAME } from 'src/utils/swarm/constants.js' -import { stopUltraplan } from '../../commands/ultraplan.js' -import type { CommandResultDisplay } from '../../commands.js' -import { useRegisterOverlay } from '../../context/overlayContext.js' -import type { ExitState } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' -import { type KeyboardEvent, Box, Text } from '@anthropic/ink' -import { useKeybindings } from '../../keybindings/useKeybinding.js' -import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js' -import { count } from '../../utils/array.js' -import { Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink' -import { AsyncAgentDetailDialog } from './AsyncAgentDetailDialog.js' -import { BackgroundTask as BackgroundTaskComponent } from './BackgroundTask.js' -import { DreamDetailDialog } from './DreamDetailDialog.js' -import { InProcessTeammateDetailDialog } from './InProcessTeammateDetailDialog.js' -import { RemoteSessionDetailDialog } from './RemoteSessionDetailDialog.js' -import { ShellDetailDialog } from './ShellDetailDialog.js' +import type { LocalWorkflowTaskState } from 'src/tasks/LocalWorkflowTask/LocalWorkflowTask.js'; +import type { MonitorMcpTaskState } from 'src/tasks/MonitorMcpTask/MonitorMcpTask.js'; +import { RemoteAgentTask, type RemoteAgentTaskState } from 'src/tasks/RemoteAgentTask/RemoteAgentTask.js'; +import { type BackgroundTaskState, isBackgroundTask, type TaskState } from 'src/tasks/types.js'; +import type { DeepImmutable } from 'src/types/utils.js'; +import { intersperse } from 'src/utils/array.js'; +import { TEAM_LEAD_NAME } from 'src/utils/swarm/constants.js'; +import { stopUltraplan } from '../../commands/ultraplan.js'; +import type { CommandResultDisplay } from '../../commands.js'; +import { useRegisterOverlay } from '../../context/overlayContext.js'; +import type { ExitState } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; +import { type KeyboardEvent, Box, Text } from '@anthropic/ink'; +import { useKeybindings } from '../../keybindings/useKeybinding.js'; +import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js'; +import { count } from '../../utils/array.js'; +import { Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink'; +import { AsyncAgentDetailDialog } from './AsyncAgentDetailDialog.js'; +import { BackgroundTask as BackgroundTaskComponent } from './BackgroundTask.js'; +import { DreamDetailDialog } from './DreamDetailDialog.js'; +import { InProcessTeammateDetailDialog } from './InProcessTeammateDetailDialog.js'; +import { RemoteSessionDetailDialog } from './RemoteSessionDetailDialog.js'; +import { ShellDetailDialog } from './ShellDetailDialog.js'; -type ViewState = { mode: 'list' } | { mode: 'detail'; itemId: string } +type ViewState = { mode: 'list' } | { mode: 'detail'; itemId: string }; type Props = { - onDone: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void - toolUseContext: ToolUseContext - initialDetailTaskId?: string -} + onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void; + toolUseContext: ToolUseContext; + initialDetailTaskId?: string; +}; type ListItem = | { - id: string - type: 'local_bash' - label: string - status: string - task: DeepImmutable + id: string; + type: 'local_bash'; + label: string; + status: string; + task: DeepImmutable; } | { - id: string - type: 'remote_agent' - label: string - status: string - task: DeepImmutable + id: string; + type: 'remote_agent'; + label: string; + status: string; + task: DeepImmutable; } | { - id: string - type: 'local_agent' - label: string - status: string - task: DeepImmutable + id: string; + type: 'local_agent'; + label: string; + status: string; + task: DeepImmutable; } | { - id: string - type: 'in_process_teammate' - label: string - status: string - task: DeepImmutable + id: string; + type: 'in_process_teammate'; + label: string; + status: string; + task: DeepImmutable; } | { - id: string - type: 'local_workflow' - label: string - status: string - task: DeepImmutable + id: string; + type: 'local_workflow'; + label: string; + status: string; + task: DeepImmutable; } | { - id: string - type: 'monitor_mcp' - label: string - status: string - task: DeepImmutable + id: string; + type: 'monitor_mcp'; + label: string; + status: string; + task: DeepImmutable; } | { - id: string - type: 'dream' - label: string - status: string - task: DeepImmutable + id: string; + type: 'dream'; + label: string; + status: string; + task: DeepImmutable; } | { - id: string - type: 'leader' - label: string - status: 'running' - } + id: string; + type: 'leader'; + label: string; + status: 'running'; + }; // WORKFLOW_SCRIPTS is ant-only (build_flags.yaml). Static imports would leak // ~1.3K lines into external builds. Gate with feature() + require so the // bundler can dead-code-eliminate the branch. /* eslint-disable @typescript-eslint/no-require-imports */ const WorkflowDetailDialog = feature('WORKFLOW_SCRIPTS') - ? ( - require('./WorkflowDetailDialog.js') as typeof import('./WorkflowDetailDialog.js') - ).WorkflowDetailDialog - : null + ? (require('./WorkflowDetailDialog.js') as typeof import('./WorkflowDetailDialog.js')).WorkflowDetailDialog + : null; const workflowTaskModule = feature('WORKFLOW_SCRIPTS') ? (require('src/tasks/LocalWorkflowTask/LocalWorkflowTask.js') as typeof import('src/tasks/LocalWorkflowTask/LocalWorkflowTask.js')) - : null -const killWorkflowTask = workflowTaskModule?.killWorkflowTask ?? null -const skipWorkflowAgent = workflowTaskModule?.skipWorkflowAgent ?? null -const retryWorkflowAgent = workflowTaskModule?.retryWorkflowAgent ?? null + : null; +const killWorkflowTask = workflowTaskModule?.killWorkflowTask ?? null; +const skipWorkflowAgent = workflowTaskModule?.skipWorkflowAgent ?? null; +const retryWorkflowAgent = workflowTaskModule?.retryWorkflowAgent ?? null; // Relative path, not `src/...` path-mapping — Bun's DCE can statically // resolve + eliminate `./` requires, but path-mapped strings stay opaque // and survive as dead literals in the bundle. Matches tasks.ts pattern. const monitorMcpModule = feature('MONITOR_TOOL') ? (require('../../tasks/MonitorMcpTask/MonitorMcpTask.js') as typeof import('../../tasks/MonitorMcpTask/MonitorMcpTask.js')) - : null -const killMonitorMcp = monitorMcpModule?.killMonitorMcp ?? null + : null; +const killMonitorMcp = monitorMcpModule?.killMonitorMcp ?? null; const MonitorMcpDetailDialog = feature('MONITOR_TOOL') - ? ( - require('./MonitorMcpDetailDialog.js') as typeof import('./MonitorMcpDetailDialog.js') - ).MonitorMcpDetailDialog - : null + ? (require('./MonitorMcpDetailDialog.js') as typeof import('./MonitorMcpDetailDialog.js')).MonitorMcpDetailDialog + : null; /* eslint-enable @typescript-eslint/no-require-imports */ // Helper to get filtered background tasks (excludes foregrounded local_agent) @@ -160,53 +133,40 @@ function getSelectableBackgroundTasks( tasks: Record | undefined, foregroundedTaskId: string | undefined, ): TaskState[] { - const backgroundTasks = Object.values(tasks ?? {}).filter(isBackgroundTask) - return backgroundTasks.filter( - task => !(task.type === 'local_agent' && task.id === foregroundedTaskId), - ) + const backgroundTasks = Object.values(tasks ?? {}).filter(isBackgroundTask); + return backgroundTasks.filter(task => !(task.type === 'local_agent' && task.id === foregroundedTaskId)); } -export function BackgroundTasksDialog({ - onDone, - toolUseContext, - initialDetailTaskId, -}: Props): React.ReactNode { - const tasks = useAppState(s => s.tasks) - const foregroundedTaskId = useAppState(s => s.foregroundedTaskId) - const showSpinnerTree = useAppState(s => s.expandedView) === 'teammates' - const setAppState = useSetAppState() - const killAgentsShortcut = useShortcutDisplay( - 'chat:killAgents', - 'Chat', - 'ctrl+x ctrl+k', - ) - const typedTasks = tasks as Record | undefined +export function BackgroundTasksDialog({ onDone, toolUseContext, initialDetailTaskId }: Props): React.ReactNode { + const tasks = useAppState(s => s.tasks); + const foregroundedTaskId = useAppState(s => s.foregroundedTaskId); + const showSpinnerTree = useAppState(s => s.expandedView) === 'teammates'; + const setAppState = useSetAppState(); + const killAgentsShortcut = useShortcutDisplay('chat:killAgents', 'Chat', 'ctrl+x ctrl+k'); + const typedTasks = tasks as Record | undefined; // Track if we skipped list view on mount (for back button behavior) - const skippedListOnMount = useRef(false) + const skippedListOnMount = useRef(false); // Compute initial view state - skip list if caller provided a specific task, // or if there's exactly one task const [viewState, setViewState] = useState(() => { if (initialDetailTaskId) { - skippedListOnMount.current = true - return { mode: 'detail', itemId: initialDetailTaskId } + skippedListOnMount.current = true; + return { mode: 'detail', itemId: initialDetailTaskId }; } - const allItems = getSelectableBackgroundTasks( - typedTasks, - foregroundedTaskId, - ) + const allItems = getSelectableBackgroundTasks(typedTasks, foregroundedTaskId); if (allItems.length === 1) { - skippedListOnMount.current = true - return { mode: 'detail', itemId: allItems[0]!.id } + skippedListOnMount.current = true; + return { mode: 'detail', itemId: allItems[0]!.id }; } - return { mode: 'list' } - }) - const [selectedIndex, setSelectedIndex] = useState(0) + return { mode: 'list' }; + }); + const [selectedIndex, setSelectedIndex] = useState(0); // Register as modal overlay so parent Chat keybindings (up/down for history) // are deactivated while this dialog is open - useRegisterOverlay('background-tasks-dialog') + useRegisterOverlay('background-tasks-dialog'); // Memoize the sorted and categorized items together to ensure stable references const { @@ -220,32 +180,26 @@ export function BackgroundTasksDialog({ allSelectableItems, } = useMemo(() => { // Filter to only show running/pending background tasks, matching the status bar count - const backgroundTasks = Object.values(typedTasks ?? {}).filter( - isBackgroundTask, - ) - const allItems = backgroundTasks.map(toListItem) + const backgroundTasks = Object.values(typedTasks ?? {}).filter(isBackgroundTask); + const allItems = backgroundTasks.map(toListItem); const sorted = allItems.sort((a, b) => { - const aStatus = a.status - const bStatus = b.status - if (aStatus === 'running' && bStatus !== 'running') return -1 - if (aStatus !== 'running' && bStatus === 'running') return 1 - const aTime = 'task' in a ? a.task.startTime : 0 - const bTime = 'task' in b ? b.task.startTime : 0 - return bTime - aTime - }) - const bash = sorted.filter(item => item.type === 'local_bash') - const remote = sorted.filter(item => item.type === 'remote_agent') + const aStatus = a.status; + const bStatus = b.status; + if (aStatus === 'running' && bStatus !== 'running') return -1; + if (aStatus !== 'running' && bStatus === 'running') return 1; + const aTime = 'task' in a ? a.task.startTime : 0; + const bTime = 'task' in b ? b.task.startTime : 0; + return bTime - aTime; + }); + const bash = sorted.filter(item => item.type === 'local_bash'); + const remote = sorted.filter(item => item.type === 'remote_agent'); // Exclude foregrounded task - it's being viewed in the main UI, not a background task - const agent = sorted.filter( - item => item.type === 'local_agent' && item.id !== foregroundedTaskId, - ) - const workflows = sorted.filter(item => item.type === 'local_workflow') - const monitorMcp = sorted.filter(item => item.type === 'monitor_mcp') - const dreamTasks = sorted.filter(item => item.type === 'dream') + const agent = sorted.filter(item => item.type === 'local_agent' && item.id !== foregroundedTaskId); + const workflows = sorted.filter(item => item.type === 'local_workflow'); + const monitorMcp = sorted.filter(item => item.type === 'monitor_mcp'); + const dreamTasks = sorted.filter(item => item.type === 'dream'); // In spinner-tree mode, exclude teammates from the dialog (they appear in the tree) - const teammates = showSpinnerTree - ? [] - : sorted.filter(item => item.type === 'in_process_teammate') + const teammates = showSpinnerTree ? [] : sorted.filter(item => item.type === 'in_process_teammate'); // Add leader entry when there are teammates, so users can foreground back to leader const leaderItem: ListItem[] = teammates.length > 0 @@ -257,7 +211,7 @@ export function BackgroundTasksDialog({ status: 'running', }, ] - : [] + : []; return { bashTasks: bash, remoteSessions: remote, @@ -279,167 +233,135 @@ export function BackgroundTasksDialog({ ...workflows, ...dreamTasks, ], - } - }, [typedTasks, foregroundedTaskId, showSpinnerTree]) + }; + }, [typedTasks, foregroundedTaskId, showSpinnerTree]); - const currentSelection = allSelectableItems[selectedIndex] ?? null + const currentSelection = allSelectableItems[selectedIndex] ?? null; // Use configurable keybindings for standard navigation and confirm/cancel. // confirm:no is handled by Dialog's onCancel prop. useKeybindings( { 'confirm:previous': () => setSelectedIndex(prev => Math.max(0, prev - 1)), - 'confirm:next': () => - setSelectedIndex(prev => - Math.min(allSelectableItems.length - 1, prev + 1), - ), + 'confirm:next': () => setSelectedIndex(prev => Math.min(allSelectableItems.length - 1, prev + 1)), 'confirm:yes': () => { - const current = allSelectableItems[selectedIndex] + const current = allSelectableItems[selectedIndex]; if (current) { if (current.type === 'leader') { - exitTeammateView(setAppState) - onDone('Viewing leader', { display: 'system' }) + exitTeammateView(setAppState); + onDone('Viewing leader', { display: 'system' }); } else { - setViewState({ mode: 'detail', itemId: current.id }) + setViewState({ mode: 'detail', itemId: current.id }); } } }, }, { context: 'Confirmation', isActive: viewState.mode === 'list' }, - ) + ); // Component-specific shortcuts (x=stop, f=foreground, right=zoom) shown in UI. // These are task-type and status dependent, not standard dialog keybindings. const handleKeyDown = (e: KeyboardEvent) => { // Only handle input when in list mode - if (viewState.mode !== 'list') return + if (viewState.mode !== 'list') return; if (e.key === 'left') { - e.preventDefault() - onDone('Background tasks dialog dismissed', { display: 'system' }) - return + e.preventDefault(); + onDone('Background tasks dialog dismissed', { display: 'system' }); + return; } // Compute current selection at the time of the key press - const currentSelection = allSelectableItems[selectedIndex] - if (!currentSelection) return // everything below requires a selection + const currentSelection = allSelectableItems[selectedIndex]; + if (!currentSelection) return; // everything below requires a selection if (e.key === 'x') { - e.preventDefault() - if ( - currentSelection.type === 'local_bash' && - currentSelection.status === 'running' - ) { - void killShellTask(currentSelection.id) - } else if ( - currentSelection.type === 'local_agent' && - currentSelection.status === 'running' - ) { - void killAgentTask(currentSelection.id) - } else if ( - currentSelection.type === 'in_process_teammate' && - currentSelection.status === 'running' - ) { - void killTeammateTask(currentSelection.id) + e.preventDefault(); + if (currentSelection.type === 'local_bash' && currentSelection.status === 'running') { + void killShellTask(currentSelection.id); + } else if (currentSelection.type === 'local_agent' && currentSelection.status === 'running') { + void killAgentTask(currentSelection.id); + } else if (currentSelection.type === 'in_process_teammate' && currentSelection.status === 'running') { + void killTeammateTask(currentSelection.id); } else if ( currentSelection.type === 'local_workflow' && currentSelection.status === 'running' && killWorkflowTask ) { - killWorkflowTask(currentSelection.id, setAppState) - } else if ( - currentSelection.type === 'monitor_mcp' && - currentSelection.status === 'running' && - killMonitorMcp - ) { - killMonitorMcp(currentSelection.id, setAppState) - } else if ( - currentSelection.type === 'dream' && - currentSelection.status === 'running' - ) { - void killDreamTask(currentSelection.id) - } else if ( - currentSelection.type === 'remote_agent' && - currentSelection.status === 'running' - ) { + killWorkflowTask(currentSelection.id, setAppState); + } else if (currentSelection.type === 'monitor_mcp' && currentSelection.status === 'running' && killMonitorMcp) { + killMonitorMcp(currentSelection.id, setAppState); + } else if (currentSelection.type === 'dream' && currentSelection.status === 'running') { + void killDreamTask(currentSelection.id); + } else if (currentSelection.type === 'remote_agent' && currentSelection.status === 'running') { if (currentSelection.task.isUltraplan) { - void stopUltraplan( - currentSelection.id, - currentSelection.task.sessionId, - setAppState, - ) + void stopUltraplan(currentSelection.id, currentSelection.task.sessionId, setAppState); } else { - void killRemoteAgentTask(currentSelection.id) + void killRemoteAgentTask(currentSelection.id); } } } if (e.key === 'f') { - if ( - currentSelection.type === 'in_process_teammate' && - currentSelection.status === 'running' - ) { - e.preventDefault() - enterTeammateView(currentSelection.id, setAppState) - onDone('Viewing teammate', { display: 'system' }) + if (currentSelection.type === 'in_process_teammate' && currentSelection.status === 'running') { + e.preventDefault(); + enterTeammateView(currentSelection.id, setAppState); + onDone('Viewing teammate', { display: 'system' }); } else if (currentSelection.type === 'leader') { - e.preventDefault() - exitTeammateView(setAppState) - onDone('Viewing leader', { display: 'system' }) + e.preventDefault(); + exitTeammateView(setAppState); + onDone('Viewing leader', { display: 'system' }); } } - } + }; async function killShellTask(taskId: string): Promise { - await LocalShellTask.kill(taskId, setAppState) + await LocalShellTask.kill(taskId, setAppState); } async function killAgentTask(taskId: string): Promise { - await LocalAgentTask.kill(taskId, setAppState) + await LocalAgentTask.kill(taskId, setAppState); } async function killTeammateTask(taskId: string): Promise { - await InProcessTeammateTask.kill(taskId, setAppState) + await InProcessTeammateTask.kill(taskId, setAppState); } async function killDreamTask(taskId: string): Promise { - await DreamTask.kill(taskId, setAppState) + await DreamTask.kill(taskId, setAppState); } async function killRemoteAgentTask(taskId: string): Promise { - await RemoteAgentTask.kill(taskId, setAppState) + await RemoteAgentTask.kill(taskId, setAppState); } // Wrap onDone in useEffectEvent to get a stable reference that always calls // the current onDone callback without causing the effect to re-fire. - const onDoneEvent = useEffectEvent(onDone) + const onDoneEvent = useEffectEvent(onDone); useEffect(() => { if (viewState.mode !== 'list') { - const task = (typedTasks ?? {})[viewState.itemId] + const task = (typedTasks ?? {})[viewState.itemId]; // Workflow tasks get a grace: their detail view stays open through // completion so the user sees the final state before eviction. - if ( - !task || - (task.type !== 'local_workflow' && !isBackgroundTask(task)) - ) { + if (!task || (task.type !== 'local_workflow' && !isBackgroundTask(task))) { // Task was removed or is no longer a background task (e.g. killed). // If we skipped the list on mount, close the dialog entirely. if (skippedListOnMount.current) { onDoneEvent('Background tasks dialog dismissed', { display: 'system', - }) + }); } else { - setViewState({ mode: 'list' }) + setViewState({ mode: 'list' }); } } } - const totalItems = allSelectableItems.length + const totalItems = allSelectableItems.length; if (selectedIndex >= totalItems && totalItems > 0) { - setSelectedIndex(totalItems - 1) + setSelectedIndex(totalItems - 1); } - }, [viewState, typedTasks, selectedIndex, allSelectableItems, onDoneEvent]) + }, [viewState, typedTasks, selectedIndex, allSelectableItems, onDoneEvent]); // Helper to go back to list view (or close dialog if we skipped list on // mount AND there's still only ≤1 item). Checking current count prevents @@ -447,18 +369,18 @@ export function BackgroundTasksDialog({ // then a second task started, 'back' should show the list — not close. const goBackToList = () => { if (skippedListOnMount.current && allSelectableItems.length <= 1) { - onDone('Background tasks dialog dismissed', { display: 'system' }) + onDone('Background tasks dialog dismissed', { display: 'system' }); } else { - skippedListOnMount.current = false - setViewState({ mode: 'list' }) + skippedListOnMount.current = false; + setViewState({ mode: 'list' }); } - } + }; // If an item is selected, show the appropriate view if (viewState.mode !== 'list' && typedTasks) { - const task = typedTasks[viewState.itemId] + const task = typedTasks[viewState.itemId]; if (!task) { - return null + return null; } // Detail mode - show appropriate detail dialog @@ -472,7 +394,7 @@ export function BackgroundTasksDialog({ onBack={goBackToList} key={`shell-${task.id}`} /> - ) + ); case 'local_agent': return ( - ) + ); case 'remote_agent': return ( - void stopUltraplan(task.id, task.sessionId, setAppState) + ? () => void stopUltraplan(task.id, task.sessionId, setAppState) : () => void killRemoteAgentTask(task.id) } key={`session-${task.id}`} /> - ) + ); case 'in_process_teammate': return ( void killTeammateTask(task.id) - : undefined - } + onKill={task.status === 'running' ? () => void killTeammateTask(task.id) : undefined} onBack={goBackToList} onForeground={ task.status === 'running' ? () => { - enterTeammateView(task.id, setAppState) - onDone('Viewing teammate', { display: 'system' }) + enterTeammateView(task.id, setAppState); + onDone('Viewing teammate', { display: 'system' }); } : undefined } key={`teammate-${task.id}`} /> - ) + ); case 'local_workflow': - if (!WorkflowDetailDialog) return null + if (!WorkflowDetailDialog) return null; return ( void} onKill={ - task.status === 'running' && killWorkflowTask - ? () => killWorkflowTask(task.id, setAppState) - : undefined + task.status === 'running' && killWorkflowTask ? () => killWorkflowTask(task.id, setAppState) : undefined } onSkipAgent={ task.status === 'running' && skipWorkflowAgent @@ -547,21 +462,19 @@ export function BackgroundTasksDialog({ onBack={goBackToList} key={`workflow-${task.id}`} /> - ) + ); case 'monitor_mcp': - if (!MonitorMcpDetailDialog) return null + if (!MonitorMcpDetailDialog) return null; return ( killMonitorMcp(task.id, setAppState) - : undefined + task.status === 'running' && killMonitorMcp ? () => killMonitorMcp(task.id, setAppState) : undefined } onBack={goBackToList} key={`monitor-mcp-${task.id}`} /> - ) + ); case 'dream': return ( void killDreamTask(task.id) - : undefined - } + onKill={task.status === 'running' ? () => void killDreamTask(task.id) : undefined} key={`dream-${task.id}`} /> - ) + ); } } - const runningBashCount = count(bashTasks, _ => _.status === 'running') + const runningBashCount = count(bashTasks, _ => _.status === 'running'); const runningAgentCount = - count( - remoteSessions, - _ => _.status === 'running' || _.status === 'pending', - ) + count(agentTasks, _ => _.status === 'running') - const runningTeammateCount = count(teammateTasks, _ => _.status === 'running') + count(remoteSessions, _ => _.status === 'running' || _.status === 'pending') + + count(agentTasks, _ => _.status === 'running'); + const runningTeammateCount = count(teammateTasks, _ => _.status === 'running'); const subtitle = intersperse( [ ...(runningTeammateCount > 0 ? [ - {runningTeammateCount}{' '} - {runningTeammateCount !== 1 ? 'agents' : 'agent'} + {runningTeammateCount} {runningTeammateCount !== 1 ? 'agents' : 'agent'} , ] : []), ...(runningBashCount > 0 ? [ - {runningBashCount}{' '} - {runningBashCount !== 1 ? 'active shells' : 'active shell'} + {runningBashCount} {runningBashCount !== 1 ? 'active shells' : 'active shell'} , ] : []), ...(runningAgentCount > 0 ? [ - {runningAgentCount}{' '} - {runningAgentCount !== 1 ? 'active agents' : 'active agent'} + {runningAgentCount} {runningAgentCount !== 1 ? 'active agents' : 'active agent'} , ] : []), ], index => · , - ) + ); const actions = [ , , - ...(currentSelection?.type === 'in_process_teammate' && - currentSelection.status === 'running' - ? [ - , - ] + ...(currentSelection?.type === 'in_process_teammate' && currentSelection.status === 'running' + ? [] : []), ...((currentSelection?.type === 'local_bash' || currentSelection?.type === 'local_agent' || @@ -644,34 +541,22 @@ export function BackgroundTasksDialog({ ? [] : []), ...(agentTasks.some(t => t.status === 'running') - ? [ - , - ] + ? [] : []), , - ] + ]; - const handleCancel = () => - onDone('Background tasks dialog dismissed', { display: 'system' }) + const handleCancel = () => onDone('Background tasks dialog dismissed', { display: 'system' }); function renderInputGuide(exitState: ExitState): React.ReactNode { if (exitState.pending) { - return Press {exitState.keyName} again to exit + return Press {exitState.keyName} again to exit; } - return {actions} + return {actions}; } return ( - + {subtitle}} @@ -685,64 +570,40 @@ export function BackgroundTasksDialog({ {teammateTasks.length > 0 && ( - {(bashTasks.length > 0 || - remoteSessions.length > 0 || - agentTasks.length > 0) && ( + {(bashTasks.length > 0 || remoteSessions.length > 0 || agentTasks.length > 0) && ( - {' '}Agents ( - {count(teammateTasks, i => i.type !== 'leader')}) + {' '}Agents ({count(teammateTasks, i => i.type !== 'leader')}) )} - + )} {bashTasks.length > 0 && ( - 0 ? 1 : 0} - > - {(teammateTasks.length > 0 || - remoteSessions.length > 0 || - agentTasks.length > 0) && ( + 0 ? 1 : 0}> + {(teammateTasks.length > 0 || remoteSessions.length > 0 || agentTasks.length > 0) && ( {' '}Shells ({bashTasks.length}) )} {bashTasks.map(item => ( - + ))} )} {mcpMonitors.length > 0 && ( - 0 || bashTasks.length > 0 ? 1 : 0 - } - > + 0 || bashTasks.length > 0 ? 1 : 0}> {' '}Monitors ({mcpMonitors.length}) {mcpMonitors.map(item => ( - + ))} @@ -751,25 +612,14 @@ export function BackgroundTasksDialog({ {remoteSessions.length > 0 && ( 0 || - bashTasks.length > 0 || - mcpMonitors.length > 0 - ? 1 - : 0 - } + marginTop={teammateTasks.length > 0 || bashTasks.length > 0 || mcpMonitors.length > 0 ? 1 : 0} > - {' '}Remote agents ({remoteSessions.length} - ) + {' '}Remote agents ({remoteSessions.length}) {remoteSessions.map(item => ( - + ))} @@ -792,11 +642,7 @@ export function BackgroundTasksDialog({ {agentTasks.map(item => ( - + ))} @@ -820,11 +666,7 @@ export function BackgroundTasksDialog({ {workflowTasks.map(item => ( - + ))} @@ -846,11 +688,7 @@ export function BackgroundTasksDialog({ > {dreamTasks.map(item => ( - + ))} @@ -859,7 +697,7 @@ export function BackgroundTasksDialog({ )} - ) + ); } function toListItem(task: BackgroundTaskState): ListItem { @@ -871,7 +709,7 @@ function toListItem(task: BackgroundTaskState): ListItem { label: task.kind === 'monitor' ? task.description : task.command, status: task.status, task, - } + }; case 'remote_agent': return { id: task.id, @@ -879,7 +717,7 @@ function toListItem(task: BackgroundTaskState): ListItem { label: task.title, status: task.status, task, - } + }; case 'local_agent': return { id: task.id, @@ -887,7 +725,7 @@ function toListItem(task: BackgroundTaskState): ListItem { label: task.description, status: task.status, task, - } + }; case 'in_process_teammate': return { id: task.id, @@ -895,7 +733,7 @@ function toListItem(task: BackgroundTaskState): ListItem { label: `@${task.identity.agentName}`, status: task.status, task, - } + }; case 'local_workflow': return { id: task.id, @@ -903,7 +741,7 @@ function toListItem(task: BackgroundTaskState): ListItem { label: task.summary ?? task.description, status: task.status, task, - } + }; case 'monitor_mcp': return { id: task.id, @@ -911,7 +749,7 @@ function toListItem(task: BackgroundTaskState): ListItem { label: task.description, status: task.status, task, - } + }; case 'dream': return { id: task.id, @@ -919,69 +757,56 @@ function toListItem(task: BackgroundTaskState): ListItem { label: task.description, status: task.status, task, - } + }; } } -function Item({ - item, - isSelected, -}: { - item: ListItem - isSelected: boolean -}): ReactNode { - const { columns } = useTerminalSize() +function Item({ item, isSelected }: { item: ListItem; isSelected: boolean }): ReactNode { + const { columns } = useTerminalSize(); // Dialog border (2) + padding (2) + pointer prefix (2) + name/status overhead (~20) - const maxActivityWidth = Math.max(30, columns - 26) + const maxActivityWidth = Math.max(30, columns - 26); // In coordinator mode, use grey pointer instead of blue - const useGreyPointer = isCoordinatorMode() + const useGreyPointer = isCoordinatorMode(); return ( - - {isSelected ? figures.pointer + ' ' : ' '} - + {isSelected ? figures.pointer + ' ' : ' '} {item.type === 'leader' ? ( @{TEAM_LEAD_NAME} ) : ( - + )} - ) + ); } function TeammateTaskGroups({ teammateTasks, currentSelectionId, }: { - teammateTasks: ListItem[] - currentSelectionId: string | undefined + teammateTasks: ListItem[]; + currentSelectionId: string | undefined; }): ReactNode { // Separate leader from teammates, group teammates by team - const leaderItems = teammateTasks.filter(i => i.type === 'leader') - const teammateItems = teammateTasks.filter( - i => i.type === 'in_process_teammate', - ) - const teams = new Map() + const leaderItems = teammateTasks.filter(i => i.type === 'leader'); + const teammateItems = teammateTasks.filter(i => i.type === 'in_process_teammate'); + const teams = new Map(); for (const item of teammateItems) { - const teamName = item.task.identity.teamName - const group = teams.get(teamName) + const teamName = item.task.identity.teamName; + const group = teams.get(teamName); if (group) { - group.push(item) + group.push(item); } else { - teams.set(teamName, [item]) + teams.set(teamName, [item]); } } - const teamEntries = [...teams.entries()] + const teamEntries = [...teams.entries()]; return ( <> {teamEntries.map(([teamName, items]) => { - const memberCount = items.length + leaderItems.length + const memberCount = items.length + leaderItems.length; return ( @@ -989,22 +814,14 @@ function TeammateTaskGroups({ {/* Render leader first within each team */} {leaderItems.map(item => ( - + ))} {items.map(item => ( - + ))} - ) + ); })} - ) + ); } diff --git a/src/components/tasks/DreamDetailDialog.tsx b/src/components/tasks/DreamDetailDialog.tsx index 67baab993..16040c77f 100644 --- a/src/components/tasks/DreamDetailDialog.tsx +++ b/src/components/tasks/DreamDetailDialog.tsx @@ -1,75 +1,58 @@ -import React from 'react' -import type { DeepImmutable } from 'src/types/utils.js' -import { useElapsedTime } from '../../hooks/useElapsedTime.js' -import { type KeyboardEvent, Box, Text } from '@anthropic/ink' -import { useKeybindings } from '../../keybindings/useKeybinding.js' -import type { DreamTaskState } from '../../tasks/DreamTask/DreamTask.js' -import { plural } from '../../utils/stringUtils.js' -import { Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink' +import React from 'react'; +import type { DeepImmutable } from 'src/types/utils.js'; +import { useElapsedTime } from '../../hooks/useElapsedTime.js'; +import { type KeyboardEvent, Box, Text } from '@anthropic/ink'; +import { useKeybindings } from '../../keybindings/useKeybinding.js'; +import type { DreamTaskState } from '../../tasks/DreamTask/DreamTask.js'; +import { plural } from '../../utils/stringUtils.js'; +import { Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink'; type Props = { - task: DeepImmutable - onDone: () => void - onBack?: () => void - onKill?: () => void -} + task: DeepImmutable; + onDone: () => void; + onBack?: () => void; + onKill?: () => void; +}; // How many recent turns to render. Earlier turns collapse to a count. -const VISIBLE_TURNS = 6 +const VISIBLE_TURNS = 6; -export function DreamDetailDialog({ - task, - onDone, - onBack, - onKill, -}: Props): React.ReactNode { - const elapsedTime = useElapsedTime( - task.startTime, - task.status === 'running', - 1000, - 0, - ) +export function DreamDetailDialog({ task, onDone, onBack, onKill }: Props): React.ReactNode { + const elapsedTime = useElapsedTime(task.startTime, task.status === 'running', 1000, 0); // Dialog handles confirm:no (Esc) → onCancel. Wire confirm:yes (Enter/y) too. - useKeybindings({ 'confirm:yes': onDone }, { context: 'Confirmation' }) + useKeybindings({ 'confirm:yes': onDone }, { context: 'Confirmation' }); const handleKeyDown = (e: KeyboardEvent) => { if (e.key === ' ') { - e.preventDefault() - onDone() + e.preventDefault(); + onDone(); } else if (e.key === 'left' && onBack) { - e.preventDefault() - onBack() + e.preventDefault(); + onBack(); } else if (e.key === 'x' && task.status === 'running' && onKill) { - e.preventDefault() - onKill() + e.preventDefault(); + onKill(); } - } + }; // Turns with text to show. Tool-only turns (text='') are dropped entirely — // the per-turn toolUseCount already captures that work. - const visibleTurns = task.turns.filter(t => t.text !== '') - const shown = visibleTurns.slice(-VISIBLE_TURNS) - const hidden = visibleTurns.length - shown.length + const visibleTurns = task.turns.filter(t => t.text !== ''); + const shown = visibleTurns.slice(-VISIBLE_TURNS); + const hidden = visibleTurns.length - shown.length; return ( - + - {elapsedTime} · reviewing {task.sessionsReviewing}{' '} - {plural(task.sessionsReviewing, 'session')} + {elapsedTime} · reviewing {task.sessionsReviewing} {plural(task.sessionsReviewing, 'session')} {task.filesTouched.length > 0 && ( <> {' '} - · {task.filesTouched.length}{' '} - {plural(task.filesTouched.length, 'file')} touched + · {task.filesTouched.length} {plural(task.filesTouched.length, 'file')} touched )} @@ -83,9 +66,7 @@ export function DreamDetailDialog({ {onBack && } - {task.status === 'running' && onKill && ( - - )} + {task.status === 'running' && onKill && } ) } @@ -103,9 +84,7 @@ export function DreamDetailDialog({ {shown.length === 0 ? ( - - {task.status === 'running' ? 'Starting…' : '(no text output)'} - + {task.status === 'running' ? 'Starting…' : '(no text output)'} ) : ( <> {hidden > 0 && ( @@ -118,8 +97,7 @@ export function DreamDetailDialog({ {turn.text} {turn.toolUseCount > 0 && ( - {' '}({turn.toolUseCount}{' '} - {plural(turn.toolUseCount, 'tool')}) + {' '}({turn.toolUseCount} {plural(turn.toolUseCount, 'tool')}) )} @@ -129,5 +107,5 @@ export function DreamDetailDialog({ - ) + ); } diff --git a/src/components/tasks/InProcessTeammateDetailDialog.tsx b/src/components/tasks/InProcessTeammateDetailDialog.tsx index c0a755a60..b2b845191 100644 --- a/src/components/tasks/InProcessTeammateDetailDialog.tsx +++ b/src/components/tasks/InProcessTeammateDetailDialog.tsx @@ -1,25 +1,25 @@ -import React, { useMemo } from 'react' -import type { DeepImmutable } from 'src/types/utils.js' -import { useElapsedTime } from '../../hooks/useElapsedTime.js' -import { type KeyboardEvent, Box, Text, useTheme } from '@anthropic/ink' -import { useKeybindings } from '../../keybindings/useKeybinding.js' -import { getEmptyToolPermissionContext } from '../../Tool.js' -import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js' -import { getTools } from '../../tools.js' -import { formatNumber, truncateToWidth } from '../../utils/format.js' +import React, { useMemo } from 'react'; +import type { DeepImmutable } from 'src/types/utils.js'; +import { useElapsedTime } from '../../hooks/useElapsedTime.js'; +import { type KeyboardEvent, Box, Text, useTheme } from '@anthropic/ink'; +import { useKeybindings } from '../../keybindings/useKeybinding.js'; +import { getEmptyToolPermissionContext } from '../../Tool.js'; +import type { InProcessTeammateTaskState } from '../../tasks/InProcessTeammateTask/types.js'; +import { getTools } from '../../tools.js'; +import { formatNumber, truncateToWidth } from '../../utils/format.js'; -import { Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink' -import { toInkColor } from '../../utils/ink.js' -import { renderToolActivity } from './renderToolActivity.js' -import { describeTeammateActivity } from './taskStatusUtils.js' +import { Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink'; +import { toInkColor } from '../../utils/ink.js'; +import { renderToolActivity } from './renderToolActivity.js'; +import { describeTeammateActivity } from './taskStatusUtils.js'; type Props = { - teammate: DeepImmutable - onDone: () => void - onKill?: () => void - onBack?: () => void - onForeground?: () => void -} + teammate: DeepImmutable; + onDone: () => void; + onKill?: () => void; + onBack?: () => void; + onForeground?: () => void; +}; export function InProcessTeammateDetailDialog({ teammate, onDone, @@ -27,15 +27,15 @@ export function InProcessTeammateDetailDialog({ onBack, onForeground, }: Props): React.ReactNode { - const [theme] = useTheme() - const tools = useMemo(() => getTools(getEmptyToolPermissionContext()), []) + const [theme] = useTheme(); + const tools = useMemo(() => getTools(getEmptyToolPermissionContext()), []); const elapsedTime = useElapsedTime( teammate.startTime, teammate.status === 'running', 1000, teammate.totalPausedMs ?? 0, - ) + ); // Restore confirm:yes (Enter/y) dismissal — Dialog handles confirm:no (Esc) useKeybindings( @@ -43,67 +43,49 @@ export function InProcessTeammateDetailDialog({ 'confirm:yes': onDone, }, { context: 'Confirmation' }, - ) + ); const handleKeyDown = (e: KeyboardEvent) => { if (e.key === ' ') { - e.preventDefault() - onDone() + e.preventDefault(); + onDone(); } else if (e.key === 'left' && onBack) { - e.preventDefault() - onBack() + e.preventDefault(); + onBack(); } else if (e.key === 'x' && teammate.status === 'running' && onKill) { - e.preventDefault() - onKill() + e.preventDefault(); + onKill(); } else if (e.key === 'f' && teammate.status === 'running' && onForeground) { - e.preventDefault() - onForeground() + e.preventDefault(); + onForeground(); } - } + }; - const activity = describeTeammateActivity(teammate) + const activity = describeTeammateActivity(teammate); - const tokenCount = - teammate.result?.totalTokens ?? teammate.progress?.tokenCount - const toolUseCount = - teammate.result?.totalToolUseCount ?? teammate.progress?.toolUseCount + const tokenCount = teammate.result?.totalTokens ?? teammate.progress?.tokenCount; + const toolUseCount = teammate.result?.totalToolUseCount ?? teammate.progress?.toolUseCount; - const displayPrompt = truncateToWidth(teammate.prompt, 300) + const displayPrompt = truncateToWidth(teammate.prompt, 300); const title = ( - - @{teammate.identity.agentName} - + @{teammate.identity.agentName} {activity && ({activity})} - ) + ); const subtitle = ( {teammate.status !== 'running' && ( - - {teammate.status === 'completed' - ? 'Completed' - : teammate.status === 'failed' - ? 'Failed' - : 'Stopped'} + + {teammate.status === 'completed' ? 'Completed' : teammate.status === 'failed' ? 'Failed' : 'Stopped'} {' · '} )} {elapsedTime} - {tokenCount !== undefined && tokenCount > 0 && ( - <> · {formatNumber(tokenCount)} tokens - )} + {tokenCount !== undefined && tokenCount > 0 && <> · {formatNumber(tokenCount)} tokens} {toolUseCount !== undefined && toolUseCount > 0 && ( <> {' '} @@ -112,15 +94,10 @@ export function InProcessTeammateDetailDialog({ )} - ) + ); return ( - + {onBack && } - {teammate.status === 'running' && onKill && ( - - )} + {teammate.status === 'running' && onKill && } {teammate.status === 'running' && onForeground && ( )} @@ -152,14 +127,8 @@ export function InProcessTeammateDetailDialog({ Progress {teammate.progress.recentActivities.map((activity, i) => ( - - {i === teammate.progress!.recentActivities!.length - 1 - ? '› ' - : ' '} + + {i === teammate.progress!.recentActivities!.length - 1 ? '› ' : ' '} {renderToolActivity(activity, tools, theme)} ))} @@ -187,5 +156,5 @@ export function InProcessTeammateDetailDialog({ )} - ) + ); } diff --git a/src/components/tasks/MonitorMcpDetailDialog.tsx b/src/components/tasks/MonitorMcpDetailDialog.tsx index 5e1d3b433..ebadcb419 100644 --- a/src/components/tasks/MonitorMcpDetailDialog.tsx +++ b/src/components/tasks/MonitorMcpDetailDialog.tsx @@ -1,50 +1,38 @@ -import React from 'react' -import type { DeepImmutable } from 'src/types/utils.js' -import { useElapsedTime } from '../../hooks/useElapsedTime.js' -import { Box, Text, type KeyboardEvent } from '@anthropic/ink' -import { useKeybindings } from '../../keybindings/useKeybinding.js' -import type { MonitorMcpTaskState } from '../../tasks/MonitorMcpTask/MonitorMcpTask.js' -import { Byline } from '../design-system/Byline.js' -import { Dialog } from '../design-system/Dialog.js' -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' +import React from 'react'; +import type { DeepImmutable } from 'src/types/utils.js'; +import { useElapsedTime } from '../../hooks/useElapsedTime.js'; +import { Box, Text, type KeyboardEvent } from '@anthropic/ink'; +import { useKeybindings } from '../../keybindings/useKeybinding.js'; +import type { MonitorMcpTaskState } from '../../tasks/MonitorMcpTask/MonitorMcpTask.js'; +import { Byline } from '../design-system/Byline.js'; +import { Dialog } from '../design-system/Dialog.js'; +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; type Props = { - task: DeepImmutable - onBack?: () => void - onKill?: () => void -} + task: DeepImmutable; + onBack?: () => void; + onKill?: () => void; +}; /** * Detail dialog for MCP monitor tasks shown in the Shift+Down background * tasks overlay. Displays the server name, resource URI, and current status. * Follows the DreamDetailDialog/ShellDetailDialog pattern. */ -export function MonitorMcpDetailDialog({ - task, - onBack, - onKill, -}: Props): React.ReactNode { - const elapsedTime = useElapsedTime( - task.startTime, - task.status === 'running', - 1000, - 0, - ) +export function MonitorMcpDetailDialog({ task, onBack, onKill }: Props): React.ReactNode { + const elapsedTime = useElapsedTime(task.startTime, task.status === 'running', 1000, 0); - useKeybindings( - {}, - { context: 'MonitorMcpDetail' }, - ) + useKeybindings({}, { context: 'MonitorMcpDetail' }); const handleKeyDown = (e: KeyboardEvent): void => { if (e.key === 'left' && onBack) { - e.preventDefault() - onBack() + e.preventDefault(); + onBack(); } else if (e.key === 'x' && task.status === 'running' && onKill) { - e.preventDefault() - onKill() + e.preventDefault(); + onKill(); } - } + }; return ( @@ -58,13 +46,9 @@ export function MonitorMcpDetailDialog({ onCancel={onBack ?? (() => {})} inputGuide={() => ( - {onBack && ( - - )} + {onBack && } - {task.status === 'running' && onKill && ( - - )} + {task.status === 'running' && onKill && } )} > @@ -96,5 +80,5 @@ export function MonitorMcpDetailDialog({ - ) + ); } diff --git a/src/components/tasks/RemoteSessionDetailDialog.tsx b/src/components/tasks/RemoteSessionDetailDialog.tsx index 5e45dfbb5..d43eb9d7d 100644 --- a/src/components/tasks/RemoteSessionDetailDialog.tsx +++ b/src/components/tasks/RemoteSessionDetailDialog.tsx @@ -1,46 +1,37 @@ -import figures from 'figures' -import React, { useMemo, useState } from 'react' -import type { SDKMessage } from 'src/entrypoints/agentSdkTypes.js' -import type { ToolUseContext } from 'src/Tool.js' -import type { DeepImmutable } from 'src/types/utils.js' -import type { CommandResultDisplay } from '../../commands.js' -import { DIAMOND_FILLED, DIAMOND_OPEN } from '../../constants/figures.js' -import { useElapsedTime } from '../../hooks/useElapsedTime.js' -import { type KeyboardEvent, Box, Link, Text } from '@anthropic/ink' -import type { RemoteAgentTaskState } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js' -import { getRemoteTaskSessionUrl } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js' -import { - AGENT_TOOL_NAME, - LEGACY_AGENT_TOOL_NAME, -} from '@claude-code-best/builtin-tools/tools/AgentTool/constants.js' -import { ASK_USER_QUESTION_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/AskUserQuestionTool/prompt.js' -import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/ExitPlanModeTool/constants.js' -import { openBrowser } from '../../utils/browser.js' -import { errorMessage } from '../../utils/errors.js' -import { formatDuration, truncateToWidth } from '../../utils/format.js' -import { toInternalMessages } from '../../utils/messages/mappers.js' -import { EMPTY_LOOKUPS, normalizeMessages } from '../../utils/messages.js' -import { plural } from '../../utils/stringUtils.js' -import { teleportResumeCodeSession } from '../../utils/teleport.js' -import { Select } from '../CustomSelect/select.js' -import { Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink' -import { Message } from '../Message.js' -import { - formatReviewStageCounts, - RemoteSessionProgress, -} from './RemoteSessionProgress.js' -import { AssistantMessage } from 'src/types/message.js' +import figures from 'figures'; +import React, { useMemo, useState } from 'react'; +import type { SDKMessage } from 'src/entrypoints/agentSdkTypes.js'; +import type { ToolUseContext } from 'src/Tool.js'; +import type { DeepImmutable } from 'src/types/utils.js'; +import type { CommandResultDisplay } from '../../commands.js'; +import { DIAMOND_FILLED, DIAMOND_OPEN } from '../../constants/figures.js'; +import { useElapsedTime } from '../../hooks/useElapsedTime.js'; +import { type KeyboardEvent, Box, Link, Text } from '@anthropic/ink'; +import type { RemoteAgentTaskState } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js'; +import { getRemoteTaskSessionUrl } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js'; +import { AGENT_TOOL_NAME, LEGACY_AGENT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/AgentTool/constants.js'; +import { ASK_USER_QUESTION_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/AskUserQuestionTool/prompt.js'; +import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/ExitPlanModeTool/constants.js'; +import { openBrowser } from '../../utils/browser.js'; +import { errorMessage } from '../../utils/errors.js'; +import { formatDuration, truncateToWidth } from '../../utils/format.js'; +import { toInternalMessages } from '../../utils/messages/mappers.js'; +import { EMPTY_LOOKUPS, normalizeMessages } from '../../utils/messages.js'; +import { plural } from '../../utils/stringUtils.js'; +import { teleportResumeCodeSession } from '../../utils/teleport.js'; +import { Select } from '../CustomSelect/select.js'; +import { Byline, Dialog, KeyboardShortcutHint } from '@anthropic/ink'; +import { Message } from '../Message.js'; +import { formatReviewStageCounts, RemoteSessionProgress } from './RemoteSessionProgress.js'; +import { AssistantMessage } from 'src/types/message.js'; type Props = { - session: DeepImmutable - toolUseContext: ToolUseContext - onDone: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void - onBack?: () => void - onKill?: () => void -} + session: DeepImmutable; + toolUseContext: ToolUseContext; + onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void; + onBack?: () => void; + onKill?: () => void; +}; // Compact one-line summary: tool name + first meaningful string arg. // Lighter than tool.renderToolUseMessage (no registry lookup / schema parse). @@ -49,119 +40,89 @@ type Props = { export function formatToolUseSummary(name: string, input: unknown): string { // plan_ready phase is only reached via ExitPlanMode tool if (name === EXIT_PLAN_MODE_V2_TOOL_NAME) { - return 'Review the plan in Claude Code on the web' + return 'Review the plan in Claude Code on the web'; } - if (!input || typeof input !== 'object') return name + if (!input || typeof input !== 'object') return name; // AskUserQuestion: show the question text as a CTA, not the tool name. // Input shape is {questions: [{question, header, options}]}. if (name === ASK_USER_QUESTION_TOOL_NAME && 'questions' in input) { - const qs = input.questions + const qs = input.questions; if (Array.isArray(qs) && qs[0] && typeof qs[0] === 'object') { // Prefer question (full text) over header (max-12-char tag). header // is a required schema field so checking it first would make the // question fallback dead code. const q = - 'question' in qs[0] && - typeof qs[0].question === 'string' && - qs[0].question + 'question' in qs[0] && typeof qs[0].question === 'string' && qs[0].question ? qs[0].question : 'header' in qs[0] && typeof qs[0].header === 'string' ? qs[0].header - : null + : null; if (q) { - const oneLine = q.replace(/\s+/g, ' ').trim() - return `Answer in browser: ${truncateToWidth(oneLine, 50)}` + const oneLine = q.replace(/\s+/g, ' ').trim(); + return `Answer in browser: ${truncateToWidth(oneLine, 50)}`; } } } for (const v of Object.values(input)) { if (typeof v === 'string' && v.trim()) { - const oneLine = v.replace(/\s+/g, ' ').trim() - return `${name} ${truncateToWidth(oneLine, 60)}` + const oneLine = v.replace(/\s+/g, ' ').trim(); + return `${name} ${truncateToWidth(oneLine, 60)}`; } } - return name + return name; } const PHASE_LABEL = { needs_input: 'input required', plan_ready: 'ready', -} as const +} as const; const AGENT_VERB = { needs_input: 'waiting', plan_ready: 'done', -} as const +} as const; -function UltraplanSessionDetail({ - session, - onDone, - onBack, - onKill, -}: Omit): React.ReactNode { - const running = session.status === 'running' || session.status === 'pending' - const phase = session.ultraplanPhase - const statusText = running - ? phase - ? PHASE_LABEL[phase] - : 'running' - : session.status - const elapsedTime = useElapsedTime( - session.startTime, - running, - 1000, - 0, - session.endTime, - ) +function UltraplanSessionDetail({ session, onDone, onBack, onKill }: Omit): React.ReactNode { + const running = session.status === 'running' || session.status === 'pending'; + const phase = session.ultraplanPhase; + const statusText = running ? (phase ? PHASE_LABEL[phase] : 'running') : session.status; + const elapsedTime = useElapsedTime(session.startTime, running, 1000, 0, session.endTime); // Counts are eventually correct (lag ≤ poll interval). agentsWorking starts // at 1 (the main session agent) and increments per subagent spawn. toolCalls // is main-session only — subagent calls may not surface in this stream. const { agentsWorking, toolCalls, lastToolCall } = useMemo(() => { - let spawns = 0 - let calls = 0 - let lastBlock: { name: string; input: unknown } | null = null + let spawns = 0; + let calls = 0; + let lastBlock: { name: string; input: unknown } | null = null; for (const msg of session.log) { - if (msg.type !== 'assistant') continue - const content = (msg.message as { content?: unknown[] })?.content ?? [] - for (const block of content as Array<{type: string; name: string; input: unknown}>) { - if (block.type !== 'tool_use') continue - calls++ - lastBlock = block - if ( - block.name === AGENT_TOOL_NAME || - block.name === LEGACY_AGENT_TOOL_NAME - ) { - spawns++ + if (msg.type !== 'assistant') continue; + const content = (msg.message as { content?: unknown[] })?.content ?? []; + for (const block of content as Array<{ type: string; name: string; input: unknown }>) { + if (block.type !== 'tool_use') continue; + calls++; + lastBlock = block; + if (block.name === AGENT_TOOL_NAME || block.name === LEGACY_AGENT_TOOL_NAME) { + spawns++; } } } return { agentsWorking: 1 + spawns, toolCalls: calls, - lastToolCall: lastBlock - ? formatToolUseSummary(lastBlock.name, lastBlock.input) - : null, - } - }, [session.log]) + lastToolCall: lastBlock ? formatToolUseSummary(lastBlock.name, lastBlock.input) : null, + }; + }, [session.log]); - const sessionUrl = getRemoteTaskSessionUrl(session.sessionId) - const goBackOrClose = - onBack ?? - (() => onDone('Remote session details dismissed', { display: 'system' })) - const [confirmingStop, setConfirmingStop] = useState(false) + const sessionUrl = getRemoteTaskSessionUrl(session.sessionId); + const goBackOrClose = onBack ?? (() => onDone('Remote session details dismissed', { display: 'system' })); + const [confirmingStop, setConfirmingStop] = useState(false); if (confirmingStop) { return ( - setConfirmingStop(false)} - color="background" - > + setConfirmingStop(false)} color="background"> - - This will terminate the Claude Code on the web session. - + This will terminate the Claude Code on the web session. { if (v === 'stop') { - onKill?.() - goBackOrClose() + onKill?.(); + goBackOrClose(); } else { - setConfirmingStop(false) + setConfirmingStop(false); } }} /> - ) + ); } const options: { label: string; value: MenuAction }[] = completed @@ -385,37 +313,33 @@ function ReviewSessionDetail({ ] : [ { label: 'Open in Claude Code on the web', value: 'open' }, - ...(onKill && running - ? [{ label: 'Stop ultrareview', value: 'stop' as const }] - : []), + ...(onKill && running ? [{ label: 'Stop ultrareview', value: 'stop' as const }] : []), { label: 'Back', value: 'back' }, - ] + ]; const handleSelect = (action: MenuAction) => { switch (action) { case 'open': - void openBrowser(sessionUrl) - onDone() - break + void openBrowser(sessionUrl); + onDone(); + break; case 'stop': - setConfirmingStop(true) - break + setConfirmingStop(true); + break; case 'back': - goBackOrClose() - break + goBackOrClose(); + break; case 'dismiss': - handleClose() - break + handleClose(); + break; } - } + }; return ( - - {completed ? DIAMOND_FILLED : DIAMOND_OPEN}{' '} - + {completed ? DIAMOND_FILLED : DIAMOND_OPEN} ultrareview {' · '} @@ -455,18 +379,12 @@ function ReviewSessionDetail({ + = Record> = ( tool: ToolType, input: Input, toolUseContext: ToolUseContext, assistantMessage: AssistantMessage, toolUseID: string, forceDecision?: PermissionDecision, -) => Promise> +) => Promise>; function useCanUseTool( - setToolUseConfirmQueue: React.Dispatch< - React.SetStateAction - >, + setToolUseConfirmQueue: React.Dispatch>, setToolPermissionContext: (context: ToolPermissionContext) => void, ): CanUseToolFn { return useCallback( - async ( - tool, - input, - toolUseContext, - assistantMessage, - toolUseID, - forceDecision, - ) => { + async (tool, input, toolUseContext, assistantMessage, toolUseID, forceDecision) => { return new Promise(resolve => { const ctx = createPermissionContext( tool, @@ -76,20 +58,14 @@ function useCanUseTool( toolUseID, setToolPermissionContext, createPermissionQueueOps(setToolUseConfirmQueue), - ) + ); - if (ctx.resolveIfAborted(resolve)) return + if (ctx.resolveIfAborted(resolve)) return; const decisionPromise = forceDecision !== undefined ? Promise.resolve(forceDecision) - : hasPermissionsToUseTool( - tool, - input, - toolUseContext, - assistantMessage, - toolUseID, - ) + : hasPermissionsToUseTool(tool, input, toolUseContext, assistantMessage, toolUseID); return decisionPromise .then(async result => { @@ -97,52 +73,44 @@ function useCanUseTool( if (process.env.USER_TYPE === 'ant') { logEvent('tengu_internal_tool_permission_decision', { toolName: sanitizeToolNameForAnalytics(tool.name), - behavior: - result.behavior as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + behavior: result.behavior as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, // Note: input contains code/filepaths, only log for ants - input: jsonStringify( - input, - ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - messageID: - ctx.messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + input: jsonStringify(input) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + messageID: ctx.messageId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, isMcp: tool.isMcp ?? false, - }) + }); } // Has permissions to use tool, granted in config if (result.behavior === 'allow') { - if (ctx.resolveIfAborted(resolve)) return + if (ctx.resolveIfAborted(resolve)) return; // Track auto mode classifier approvals for UI display if ( feature('TRANSCRIPT_CLASSIFIER') && result.decisionReason?.type === 'classifier' && result.decisionReason.classifier === 'auto-mode' ) { - setYoloClassifierApproval( - toolUseID, - result.decisionReason.reason, - ) + setYoloClassifierApproval(toolUseID, result.decisionReason.reason); } - ctx.logDecision({ decision: 'accept', source: 'config' }) + ctx.logDecision({ decision: 'accept', source: 'config' }); resolve( ctx.buildAllow(result.updatedInput ?? input, { decisionReason: result.decisionReason, }), - ) - return + ); + return; } - const appState = toolUseContext.getAppState() + const appState = toolUseContext.getAppState(); const description = await tool.description(input as never, { - isNonInteractiveSession: - toolUseContext.options.isNonInteractiveSession, + isNonInteractiveSession: toolUseContext.options.isNonInteractiveSession, toolPermissionContext: appState.toolPermissionContext, tools: toolUseContext.options.tools, - }) + }); - if (ctx.resolveIfAborted(resolve)) return + if (ctx.resolveIfAborted(resolve)) return; // Does not have permissions to use tool, check the behavior switch (result.behavior) { @@ -156,7 +124,7 @@ function useCanUseTool( toolUseID, }, { decision: 'reject', source: 'config' }, - ) + ); if ( feature('TRANSCRIPT_CLASSIFIER') && result.decisionReason?.type === 'classifier' && @@ -167,49 +135,40 @@ function useCanUseTool( display: description, reason: result.decisionReason.reason ?? '', timestamp: Date.now(), - }) + }); toolUseContext.addNotification?.({ key: 'auto-mode-denied', priority: 'immediate', jsx: ( <> - - {tool.userFacingName(input).toLowerCase()} denied by - auto mode - + {tool.userFacingName(input).toLowerCase()} denied by auto mode · /permissions ), - }) + }); } - resolve(result) - return + resolve(result); + return; } case 'ask': { // For coordinator workers, await automated checks before showing dialog. // Background workers should only interrupt the user when automated checks can't decide. - if ( - appState.toolPermissionContext - .awaitAutomatedChecksBeforeDialog - ) { - const coordinatorDecision = await handleCoordinatorPermission( - { - ctx, - ...(feature('BASH_CLASSIFIER') - ? { - pendingClassifierCheck: - result.pendingClassifierCheck, - } - : {}), - updatedInput: result.updatedInput, - suggestions: result.suggestions, - permissionMode: appState.toolPermissionContext.mode, - }, - ) + if (appState.toolPermissionContext.awaitAutomatedChecksBeforeDialog) { + const coordinatorDecision = await handleCoordinatorPermission({ + ctx, + ...(feature('BASH_CLASSIFIER') + ? { + pendingClassifierCheck: result.pendingClassifierCheck, + } + : {}), + updatedInput: result.updatedInput, + suggestions: result.suggestions, + permissionMode: appState.toolPermissionContext.mode, + }); if (coordinatorDecision) { - resolve(coordinatorDecision) - return + resolve(coordinatorDecision); + return; } // null means neither automated check resolved -- fall through to dialog below. // Hooks already ran, classifier already consumed. @@ -217,7 +176,7 @@ function useCanUseTool( // After awaiting automated checks, verify the request wasn't aborted // while we were waiting. Without this check, a stale dialog could appear. - if (ctx.resolveIfAborted(resolve)) return + if (ctx.resolveIfAborted(resolve)) return; // For swarm workers, try classifier auto-approval then // forward permission requests to the leader via mailbox. @@ -231,10 +190,10 @@ function useCanUseTool( : {}), updatedInput: result.updatedInput, suggestions: result.suggestions, - }) + }); if (swarmDecision) { - resolve(swarmDecision) - return + resolve(swarmDecision); + return; } // Grace period: wait up to 2s for speculative classifier @@ -243,12 +202,9 @@ function useCanUseTool( feature('BASH_CLASSIFIER') && result.pendingClassifierCheck && tool.name === BASH_TOOL_NAME && - !appState.toolPermissionContext - .awaitAutomatedChecksBeforeDialog + !appState.toolPermissionContext.awaitAutomatedChecksBeforeDialog ) { - const speculativePromise = peekSpeculativeClassifierCheck( - (input as { command: string }).command, - ) + const speculativePromise = peekSpeculativeClassifierCheck((input as { command: string }).command); if (speculativePromise) { const raceResult = await Promise.race([ speculativePromise.then(r => ({ @@ -259,9 +215,9 @@ function useCanUseTool( // eslint-disable-next-line no-restricted-syntax -- resolves with a value, not void setTimeout(res, 2000, { type: 'timeout' as const }), ), - ]) + ]); - if (ctx.resolveIfAborted(resolve)) return + if (ctx.resolveIfAborted(resolve)) return; if ( raceResult.type === 'result' && @@ -270,34 +226,27 @@ function useCanUseTool( feature('BASH_CLASSIFIER') ) { // Classifier approved within grace period — skip dialog - void consumeSpeculativeClassifierCheck( - (input as { command: string }).command, - ) + void consumeSpeculativeClassifierCheck((input as { command: string }).command); - const matchedRule = - raceResult.result.matchedDescription ?? undefined + const matchedRule = raceResult.result.matchedDescription ?? undefined; if (matchedRule) { - setClassifierApproval(toolUseID, matchedRule) + setClassifierApproval(toolUseID, matchedRule); } ctx.logDecision({ decision: 'accept', source: { type: 'classifier' }, - }) + }); resolve( - ctx.buildAllow( - result.updatedInput ?? - (input as Record), - { - decisionReason: { - type: 'classifier' as const, - classifier: 'bash_allow' as const, - reason: `Allowed by prompt rule: "${raceResult.result.matchedDescription}"`, - }, + ctx.buildAllow(result.updatedInput ?? (input as Record), { + decisionReason: { + type: 'classifier' as const, + classifier: 'bash_allow' as const, + reason: `Allowed by prompt rule: "${raceResult.result.matchedDescription}"`, }, - ), - ) - return + }), + ); + return; } // Timeout or no match — fall through to show dialog } @@ -309,46 +258,37 @@ function useCanUseTool( ctx, description, result, - awaitAutomatedChecksBeforeDialog: - appState.toolPermissionContext - .awaitAutomatedChecksBeforeDialog, - bridgeCallbacks: feature('BRIDGE_MODE') - ? appState.replBridgePermissionCallbacks - : undefined, + awaitAutomatedChecksBeforeDialog: appState.toolPermissionContext.awaitAutomatedChecksBeforeDialog, + bridgeCallbacks: feature('BRIDGE_MODE') ? appState.replBridgePermissionCallbacks : undefined, channelCallbacks: - feature('KAIROS') || feature('KAIROS_CHANNELS') - ? appState.channelPermissionCallbacks - : undefined, + feature('KAIROS') || feature('KAIROS_CHANNELS') ? appState.channelPermissionCallbacks : undefined, }, resolve, - ) + ); - return + return; } } }) .catch(error => { - if ( - error instanceof AbortError || - error instanceof APIUserAbortError - ) { + if (error instanceof AbortError || error instanceof APIUserAbortError) { logForDebugging( `Permission check threw ${error.constructor.name} for tool=${tool.name}: ${error.message}`, - ) - ctx.logCancelled() - resolve(ctx.cancelAndAbort(undefined, true)) + ); + ctx.logCancelled(); + resolve(ctx.cancelAndAbort(undefined, true)); } else { - logError(error) - resolve(ctx.cancelAndAbort(undefined, true)) + logError(error); + resolve(ctx.cancelAndAbort(undefined, true)); } }) .finally(() => { - clearClassifierChecking(toolUseID) - }) - }) + clearClassifierChecking(toolUseID); + }); + }); }, [setToolUseConfirmQueue, setToolPermissionContext], - ) + ); } -export default useCanUseTool +export default useCanUseTool; diff --git a/src/hooks/useChromeExtensionNotification.tsx b/src/hooks/useChromeExtensionNotification.tsx index 01dbd1597..bb681d45d 100644 --- a/src/hooks/useChromeExtensionNotification.tsx +++ b/src/hooks/useChromeExtensionNotification.tsx @@ -1,56 +1,45 @@ -import * as React from 'react' -import { Text } from '@anthropic/ink' -import { isClaudeAISubscriber } from '../utils/auth.js' -import { - isChromeExtensionInstalled, - shouldEnableClaudeInChrome, -} from '../utils/claudeInChrome/setup.js' -import { isRunningOnHomespace } from '../utils/envUtils.js' -import { useStartupNotification } from './notifs/useStartupNotification.js' +import * as React from 'react'; +import { Text } from '@anthropic/ink'; +import { isClaudeAISubscriber } from '../utils/auth.js'; +import { isChromeExtensionInstalled, shouldEnableClaudeInChrome } from '../utils/claudeInChrome/setup.js'; +import { isRunningOnHomespace } from '../utils/envUtils.js'; +import { useStartupNotification } from './notifs/useStartupNotification.js'; function getChromeFlag(): boolean | undefined { if (process.argv.includes('--chrome')) { - return true + return true; } if (process.argv.includes('--no-chrome')) { - return false + return false; } - return undefined + return undefined; } export function useChromeExtensionNotification(): void { useStartupNotification(async () => { - const chromeFlag = getChromeFlag() - if (!shouldEnableClaudeInChrome(chromeFlag)) return null + const chromeFlag = getChromeFlag(); + if (!shouldEnableClaudeInChrome(chromeFlag)) return null; // Claude in Chrome is only supported for claude.ai subscribers (unless user is ant) if (process.env.USER_TYPE !== 'ant' && !isClaudeAISubscriber()) { return { key: 'chrome-requires-subscription', - jsx: ( - - Claude in Chrome requires a claude.ai subscription - - ), + jsx: Claude in Chrome requires a claude.ai subscription, priority: 'immediate', timeoutMs: 5000, - } + }; } - const installed = await isChromeExtensionInstalled() + const installed = await isChromeExtensionInstalled(); if (!installed && !isRunningOnHomespace()) { // Skip notification on Homespace since Chrome setup requires different steps (see go/hsproxy) return { key: 'chrome-extension-not-detected', - jsx: ( - - Chrome extension not detected · https://claude.ai/chrome to install - - ), + jsx: Chrome extension not detected · https://claude.ai/chrome to install, // TODO(hackyon): Lower the priority if the claude-in-chrome integration is no longer opt-in priority: 'immediate', timeoutMs: 3000, - } + }; } if (chromeFlag === undefined) { // Show low priority notification only when Chrome is enabled by default @@ -59,8 +48,8 @@ export function useChromeExtensionNotification(): void { key: 'claude-in-chrome-default-enabled', text: `Claude in Chrome enabled · /chrome`, priority: 'low', - } + }; } - return null - }) + return null; + }); } diff --git a/src/hooks/useClaudeCodeHintRecommendation.tsx b/src/hooks/useClaudeCodeHintRecommendation.tsx index 4d2a76af5..268ce082b 100644 --- a/src/hooks/useClaudeCodeHintRecommendation.tsx +++ b/src/hooks/useClaudeCodeHintRecommendation.tsx @@ -8,117 +8,101 @@ * anything that reaches this hook is worth resolving. */ -import * as React from 'react' -import { useNotifications } from '../context/notifications.js' +import * as React from 'react'; +import { useNotifications } from '../context/notifications.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, type AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, logEvent, -} from '../services/analytics/index.js' +} from '../services/analytics/index.js'; import { clearPendingHint, getPendingHintSnapshot, markShownThisSession, subscribeToPendingHint, -} from '../utils/claudeCodeHints.js' -import { logForDebugging } from '../utils/debug.js' +} from '../utils/claudeCodeHints.js'; +import { logForDebugging } from '../utils/debug.js'; import { disableHintRecommendations, markHintPluginShown, type PluginHintRecommendation, resolvePluginHint, -} from '../utils/plugins/hintRecommendation.js' -import { installPluginFromMarketplace } from '../utils/plugins/pluginInstallationHelpers.js' -import { - installPluginAndNotify, - usePluginRecommendationBase, -} from './usePluginRecommendationBase.js' +} from '../utils/plugins/hintRecommendation.js'; +import { installPluginFromMarketplace } from '../utils/plugins/pluginInstallationHelpers.js'; +import { installPluginAndNotify, usePluginRecommendationBase } from './usePluginRecommendationBase.js'; type UseClaudeCodeHintRecommendationResult = { - recommendation: PluginHintRecommendation | null - handleResponse: (response: 'yes' | 'no' | 'disable') => void -} + recommendation: PluginHintRecommendation | null; + handleResponse: (response: 'yes' | 'no' | 'disable') => void; +}; export function useClaudeCodeHintRecommendation(): UseClaudeCodeHintRecommendationResult { - const pendingHint = React.useSyncExternalStore( - subscribeToPendingHint, - getPendingHintSnapshot, - ) - const { addNotification } = useNotifications() - const { recommendation, clearRecommendation, tryResolve } = - usePluginRecommendationBase() + const pendingHint = React.useSyncExternalStore(subscribeToPendingHint, getPendingHintSnapshot); + const { addNotification } = useNotifications(); + const { recommendation, clearRecommendation, tryResolve } = usePluginRecommendationBase(); React.useEffect(() => { - if (!pendingHint) return + if (!pendingHint) return; tryResolve(async () => { - const resolved = await resolvePluginHint(pendingHint) + const resolved = await resolvePluginHint(pendingHint); if (resolved) { logForDebugging( `[useClaudeCodeHintRecommendation] surfacing ${resolved.pluginId} from ${resolved.sourceCommand}`, - ) - markShownThisSession() + ); + markShownThisSession(); } // Drop the slot — but only if it still holds the hint we just // resolved. A newer hint may have overwritten it during the async // lookup; don't clobber that. if (getPendingHintSnapshot() === pendingHint) { - clearPendingHint() + clearPendingHint(); } - return resolved - }) - }, [pendingHint, tryResolve]) + return resolved; + }); + }, [pendingHint, tryResolve]); const handleResponse = React.useCallback( (response: 'yes' | 'no' | 'disable') => { - if (!recommendation) return + if (!recommendation) return; // Record show-once here, not at resolution-time — the dialog may have // been blocked by a higher-priority focusedInputDialog and never // rendered. Auto-dismiss reaches this via onResponse('no'). - markHintPluginShown(recommendation.pluginId) + markHintPluginShown(recommendation.pluginId); logEvent('tengu_plugin_hint_response', { - _PROTO_plugin_name: - recommendation.pluginName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, - _PROTO_marketplace_name: - recommendation.marketplaceName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, - response: - response as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + _PROTO_plugin_name: recommendation.pluginName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, + _PROTO_marketplace_name: recommendation.marketplaceName as AnalyticsMetadata_I_VERIFIED_THIS_IS_PII_TAGGED, + response: response as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); switch (response) { case 'yes': { - const { pluginId, pluginName, marketplaceName } = recommendation - void installPluginAndNotify( - pluginId, - pluginName, - 'hint-plugin', - addNotification, - async pluginData => { - const result = await installPluginFromMarketplace({ - pluginId, - entry: pluginData.entry, - marketplaceName, - scope: 'user', - trigger: 'hint', - }) - if (!result.success) { - throw new Error(!result.success ? (result as { error: string }).error : 'Unknown error') - } - }, - ) - break + const { pluginId, pluginName, marketplaceName } = recommendation; + void installPluginAndNotify(pluginId, pluginName, 'hint-plugin', addNotification, async pluginData => { + const result = await installPluginFromMarketplace({ + pluginId, + entry: pluginData.entry, + marketplaceName, + scope: 'user', + trigger: 'hint', + }); + if (!result.success) { + throw new Error(!result.success ? (result as { error: string }).error : 'Unknown error'); + } + }); + break; } case 'disable': - disableHintRecommendations() - break + disableHintRecommendations(); + break; case 'no': - break + break; } - clearRecommendation() + clearRecommendation(); }, [recommendation, addNotification, clearRecommendation], - ) + ); - return { recommendation, handleResponse } + return { recommendation, handleResponse }; } diff --git a/src/hooks/useCommandKeybindings.tsx b/src/hooks/useCommandKeybindings.tsx index 416a07ce7..38c9ba375 100644 --- a/src/hooks/useCommandKeybindings.tsx +++ b/src/hooks/useCommandKeybindings.tsx @@ -8,11 +8,11 @@ * Commands triggered via keybinding are treated as "immediate" - they execute right * away and preserve the user's existing input text (the prompt is not cleared). */ -import { useMemo } from 'react' -import { useIsModalOverlayActive } from '../context/overlayContext.js' -import { useOptionalKeybindingContext } from '../keybindings/KeybindingContext.js' -import { useKeybindings } from '../keybindings/useKeybinding.js' -import type { PromptInputHelpers } from '../utils/handlePromptSubmit.js' +import { useMemo } from 'react'; +import { useIsModalOverlayActive } from '../context/overlayContext.js'; +import { useOptionalKeybindingContext } from '../keybindings/KeybindingContext.js'; +import { useKeybindings } from '../keybindings/useKeybinding.js'; +import type { PromptInputHelpers } from '../utils/handlePromptSubmit.js'; type Props = { // onSubmit accepts additional parameters beyond what we pass here, @@ -20,63 +20,57 @@ type Props = { onSubmit: ( input: string, helpers: PromptInputHelpers, - ...rest: [ - speculationAccept?: undefined, - options?: { fromKeybinding?: boolean }, - ] - ) => void + ...rest: [speculationAccept?: undefined, options?: { fromKeybinding?: boolean }] + ) => void; /** Set to false to disable command keybindings (e.g., when a dialog is open) */ - isActive?: boolean -} + isActive?: boolean; +}; const NOOP_HELPERS: PromptInputHelpers = { setCursorOffset: () => {}, clearBuffer: () => {}, resetHistory: () => {}, -} +}; /** * Registers keybinding handlers for all "command:*" actions found in the * user's keybinding configuration. When triggered, each handler submits * the corresponding slash command (e.g., "command:commit" submits "/commit"). */ -export function CommandKeybindingHandlers({ - onSubmit, - isActive = true, -}: Props): null { - const keybindingContext = useOptionalKeybindingContext() - const isModalOverlayActive = useIsModalOverlayActive() +export function CommandKeybindingHandlers({ onSubmit, isActive = true }: Props): null { + const keybindingContext = useOptionalKeybindingContext(); + const isModalOverlayActive = useIsModalOverlayActive(); // Extract command actions from parsed bindings const commandActions = useMemo(() => { - if (!keybindingContext) return new Set() - const actions = new Set() + if (!keybindingContext) return new Set(); + const actions = new Set(); for (const binding of keybindingContext.bindings) { if (binding.action?.startsWith('command:')) { - actions.add(binding.action) + actions.add(binding.action); } } - return actions - }, [keybindingContext]) + return actions; + }, [keybindingContext]); // Build handler map for all command actions const handlers = useMemo(() => { - const map: Record void> = {} + const map: Record void> = {}; for (const action of commandActions) { - const commandName = action.slice('command:'.length) + const commandName = action.slice('command:'.length); map[action] = () => { onSubmit(`/${commandName}`, NOOP_HELPERS, undefined, { fromKeybinding: true, - }) - } + }); + }; } - return map - }, [commandActions, onSubmit]) + return map; + }, [commandActions, onSubmit]); useKeybindings(handlers, { context: 'Chat', isActive: isActive && !isModalOverlayActive, - }) + }); - return null + return null; } diff --git a/src/hooks/useDirectConnect.ts b/src/hooks/useDirectConnect.ts index 7b2a162ae..09734fe5f 100644 --- a/src/hooks/useDirectConnect.ts +++ b/src/hooks/useDirectConnect.ts @@ -16,7 +16,10 @@ import { import type { Tool } from '../Tool.js' import { findToolByName } from '../Tool.js' import type { Message as MessageType } from '../types/message.js' -import type { PermissionAskDecision, PermissionUpdate } from '../types/permissions.js' +import type { + PermissionAskDecision, + PermissionUpdate, +} from '../types/permissions.js' import { logForDebugging } from '../utils/debug.js' import { gracefulShutdown } from '../utils/gracefulShutdown.js' import type { RemoteMessageContent } from '../utils/teleport/api.js' diff --git a/src/hooks/useGlobalKeybindings.tsx b/src/hooks/useGlobalKeybindings.tsx index 5668748fc..0a8bd68ba 100644 --- a/src/hooks/useGlobalKeybindings.tsx +++ b/src/hooks/useGlobalKeybindings.tsx @@ -4,31 +4,31 @@ * Must be rendered inside KeybindingSetup to have access to the keybinding context. * This component renders nothing - it just registers the keybinding handlers. */ -import { feature } from 'bun:bundle' -import { useCallback } from 'react' -import { instances } from '@anthropic/ink' -import { useKeybinding } from '../keybindings/useKeybinding.js' -import type { Screen } from '../screens/REPL.js' -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' +import { feature } from 'bun:bundle'; +import { useCallback } from 'react'; +import { instances } from '@anthropic/ink'; +import { useKeybinding } from '../keybindings/useKeybinding.js'; +import type { Screen } from '../screens/REPL.js'; +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from '../services/analytics/index.js' -import { useAppState, useSetAppState } from '../state/AppState.js' -import { count } from '../utils/array.js' -import { getTerminalPanel } from '../utils/terminalPanel.js' +} from '../services/analytics/index.js'; +import { useAppState, useSetAppState } from '../state/AppState.js'; +import { count } from '../utils/array.js'; +import { getTerminalPanel } from '../utils/terminalPanel.js'; type Props = { - screen: Screen - setScreen: React.Dispatch> - showAllInTranscript: boolean - setShowAllInTranscript: React.Dispatch> - messageCount: number - onEnterTranscript?: () => void - onExitTranscript?: () => void - virtualScrollActive?: boolean - searchBarOpen?: boolean -} + screen: Screen; + setScreen: React.Dispatch>; + showAllInTranscript: boolean; + setShowAllInTranscript: React.Dispatch>; + messageCount: number; + onEnterTranscript?: () => void; + onExitTranscript?: () => void; + virtualScrollActive?: boolean; + searchBarOpen?: boolean; +}; /** * Registers global keybinding handlers for: @@ -48,53 +48,45 @@ export function GlobalKeybindingHandlers({ virtualScrollActive, searchBarOpen = false, }: Props): null { - const expandedView = useAppState(s => s.expandedView) - const setAppState = useSetAppState() + const expandedView = useAppState(s => s.expandedView); + const setAppState = useSetAppState(); // Toggle todo list (ctrl+t) - cycles through views const handleToggleTodos = useCallback(() => { logEvent('tengu_toggle_todos', { is_expanded: expandedView === 'tasks', - }) + }); setAppState(prev => { const { getAllInProcessTeammateTasks } = // eslint-disable-next-line @typescript-eslint/no-require-imports - require('../tasks/InProcessTeammateTask/InProcessTeammateTask.js') as typeof import('../tasks/InProcessTeammateTask/InProcessTeammateTask.js') - const hasTeammates = - count( - getAllInProcessTeammateTasks(prev.tasks), - t => t.status === 'running', - ) > 0 + require('../tasks/InProcessTeammateTask/InProcessTeammateTask.js') as typeof import('../tasks/InProcessTeammateTask/InProcessTeammateTask.js'); + const hasTeammates = count(getAllInProcessTeammateTasks(prev.tasks), t => t.status === 'running') > 0; if (hasTeammates) { // Both exist: none → tasks → teammates → none switch (prev.expandedView) { case 'none': - return { ...prev, expandedView: 'tasks' as const } + return { ...prev, expandedView: 'tasks' as const }; case 'tasks': - return { ...prev, expandedView: 'teammates' as const } + return { ...prev, expandedView: 'teammates' as const }; case 'teammates': - return { ...prev, expandedView: 'none' as const } + return { ...prev, expandedView: 'none' as const }; } } // Only tasks: none ↔ tasks return { ...prev, - expandedView: - prev.expandedView === 'tasks' - ? ('none' as const) - : ('tasks' as const), - } - }) - }, [expandedView, setAppState]) + expandedView: prev.expandedView === 'tasks' ? ('none' as const) : ('tasks' as const), + }; + }); + }, [expandedView, setAppState]); // Toggle transcript mode (ctrl+o). Two-way prompt ↔ transcript. // Brief view has its own dedicated toggle on ctrl+shift+b. const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s => s.isBriefOnly) - : false + ? useAppState(s => s.isBriefOnly) + : false; const handleToggleTranscript = useCallback(() => { if (feature('KAIROS') || feature('KAIROS_BRIEF')) { // Escape hatch: GB kill-switch while defaultView=chat was persisted @@ -104,30 +96,30 @@ export function GlobalKeybindingHandlers({ // isBriefOnly (Messages.tsx filter is gated on !isTranscriptMode). /* eslint-disable @typescript-eslint/no-require-imports */ const { isBriefEnabled } = - require('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') + require('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js'); /* eslint-enable @typescript-eslint/no-require-imports */ if (!isBriefEnabled() && isBriefOnly && screen !== 'transcript') { setAppState(prev => { - if (!prev.isBriefOnly) return prev - return { ...prev, isBriefOnly: false } - }) - return + if (!prev.isBriefOnly) return prev; + return { ...prev, isBriefOnly: false }; + }); + return; } } - const isEnteringTranscript = screen !== 'transcript' + const isEnteringTranscript = screen !== 'transcript'; logEvent('tengu_toggle_transcript', { is_entering: isEnteringTranscript, show_all: showAllInTranscript, message_count: messageCount, - }) - setScreen(s => (s === 'transcript' ? 'prompt' : 'transcript')) - setShowAllInTranscript(false) + }); + setScreen(s => (s === 'transcript' ? 'prompt' : 'transcript')); + setShowAllInTranscript(false); if (isEnteringTranscript && onEnterTranscript) { - onEnterTranscript() + onEnterTranscript(); } if (!isEnteringTranscript && onExitTranscript) { - onExitTranscript() + onExitTranscript(); } }, [ screen, @@ -139,35 +131,29 @@ export function GlobalKeybindingHandlers({ setAppState, onEnterTranscript, onExitTranscript, - ]) + ]); // Toggle showing all messages in transcript mode (ctrl+e) const handleToggleShowAll = useCallback(() => { logEvent('tengu_transcript_toggle_show_all', { is_expanding: !showAllInTranscript, message_count: messageCount, - }) - setShowAllInTranscript(prev => !prev) - }, [showAllInTranscript, setShowAllInTranscript, messageCount]) + }); + setShowAllInTranscript(prev => !prev); + }, [showAllInTranscript, setShowAllInTranscript, messageCount]); // Exit transcript mode (ctrl+c or escape) const handleExitTranscript = useCallback(() => { logEvent('tengu_transcript_exit', { show_all: showAllInTranscript, message_count: messageCount, - }) - setScreen('prompt') - setShowAllInTranscript(false) + }); + setScreen('prompt'); + setShowAllInTranscript(false); if (onExitTranscript) { - onExitTranscript() + onExitTranscript(); } - }, [ - setScreen, - showAllInTranscript, - setShowAllInTranscript, - messageCount, - onExitTranscript, - ]) + }, [setScreen, showAllInTranscript, setShowAllInTranscript, messageCount, onExitTranscript]); // Toggle brief-only view (ctrl+shift+b). Pure display filter toggle — // does not touch opt-in state. Asymmetric gate (mirrors /brief): OFF @@ -177,35 +163,33 @@ export function GlobalKeybindingHandlers({ if (feature('KAIROS') || feature('KAIROS_BRIEF')) { /* eslint-disable @typescript-eslint/no-require-imports */ const { isBriefEnabled } = - require('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') + require('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js'); /* eslint-enable @typescript-eslint/no-require-imports */ - if (!isBriefEnabled() && !isBriefOnly) return - const next = !isBriefOnly + if (!isBriefEnabled() && !isBriefOnly) return; + const next = !isBriefOnly; logEvent('tengu_brief_mode_toggled', { enabled: next, gated: false, - source: - 'keybinding' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + source: 'keybinding' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); setAppState(prev => { - if (prev.isBriefOnly === next) return prev - return { ...prev, isBriefOnly: next } - }) + if (prev.isBriefOnly === next) return prev; + return { ...prev, isBriefOnly: next }; + }); } - }, [isBriefOnly, setAppState]) + }, [isBriefOnly, setAppState]); // Register keybinding handlers useKeybinding('app:toggleTodos', handleToggleTodos, { context: 'Global', - }) + }); useKeybinding('app:toggleTranscript', handleToggleTranscript, { context: 'Global', - }) + }); if (feature('KAIROS') || feature('KAIROS_BRIEF')) { - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant useKeybinding('app:toggleBrief', handleToggleBrief, { context: 'Global', - }) + }); } // Register teammate keybinding @@ -215,41 +199,41 @@ export function GlobalKeybindingHandlers({ setAppState(prev => ({ ...prev, showTeammateMessagePreview: !prev.showTeammateMessagePreview, - })) + })); }, { context: 'Global', }, - ) + ); // Toggle built-in terminal panel (meta+j). // toggle() blocks in spawnSync until the user detaches from tmux. const handleToggleTerminal = useCallback(() => { if (feature('TERMINAL_PANEL')) { if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_terminal_panel', false)) { - return + return; } - getTerminalPanel().toggle() + getTerminalPanel().toggle(); } - }, []) + }, []); useKeybinding('app:toggleTerminal', handleToggleTerminal, { context: 'Global', - }) + }); // Clear screen and force full redraw (ctrl+l). Recovery path when the // terminal was cleared externally (macOS Cmd+K) and Ink's diff engine // thinks unchanged cells don't need repainting. const handleRedraw = useCallback(() => { - instances.get(process.stdout)?.forceRedraw() - }, []) - useKeybinding('app:redraw', handleRedraw, { context: 'Global' }) + instances.get(process.stdout)?.forceRedraw(); + }, []); + useKeybinding('app:redraw', handleRedraw, { context: 'Global' }); // Transcript-specific bindings (only active when in transcript mode) - const isInTranscript = screen === 'transcript' + const isInTranscript = screen === 'transcript'; useKeybinding('transcript:toggleShowAll', handleToggleShowAll, { context: 'Transcript', isActive: isInTranscript && !virtualScrollActive, - }) + }); useKeybinding('transcript:exit', handleExitTranscript, { context: 'Transcript', // Bar-open is a mode (owns keystrokes). Navigating (highlights @@ -258,7 +242,7 @@ export function GlobalKeybindingHandlers({ // so without this gate its onCancel AND this handler would both // fire on one Esc (child registers first, fires first, bubbles). isActive: isInTranscript && !searchBarOpen, - }) + }); - return null + return null; } diff --git a/src/hooks/useIDEIntegration.tsx b/src/hooks/useIDEIntegration.tsx index 786146ee7..4d1b36954 100644 --- a/src/hooks/useIDEIntegration.tsx +++ b/src/hooks/useIDEIntegration.tsx @@ -1,26 +1,22 @@ -import { useEffect } from 'react' -import type { ScopedMcpServerConfig } from '../services/mcp/types.js' -import { getGlobalConfig } from '../utils/config.js' -import { isEnvDefinedFalsy, isEnvTruthy } from '../utils/envUtils.js' -import type { DetectedIDEInfo } from '../utils/ide.js' +import { useEffect } from 'react'; +import type { ScopedMcpServerConfig } from '../services/mcp/types.js'; +import { getGlobalConfig } from '../utils/config.js'; +import { isEnvDefinedFalsy, isEnvTruthy } from '../utils/envUtils.js'; +import type { DetectedIDEInfo } from '../utils/ide.js'; import { type IDEExtensionInstallationStatus, type IdeType, initializeIdeIntegration, isSupportedTerminal, -} from '../utils/ide.js' +} from '../utils/ide.js'; type UseIDEIntegrationProps = { - autoConnectIdeFlag?: boolean - ideToInstallExtension: IdeType | null - setDynamicMcpConfig: React.Dispatch< - React.SetStateAction | undefined> - > - setShowIdeOnboarding: React.Dispatch> - setIDEInstallationState: React.Dispatch< - React.SetStateAction - > -} + autoConnectIdeFlag?: boolean; + ideToInstallExtension: IdeType | null; + setDynamicMcpConfig: React.Dispatch | undefined>>; + setShowIdeOnboarding: React.Dispatch>; + setIDEInstallationState: React.Dispatch>; +}; export function useIDEIntegration({ autoConnectIdeFlag, @@ -32,11 +28,11 @@ export function useIDEIntegration({ useEffect(() => { function addIde(ide: DetectedIDEInfo | null) { if (!ide) { - return + return; } // Check if auto-connect is enabled - const globalConfig = getGlobalConfig() + const globalConfig = getGlobalConfig(); const autoConnectEnabled = (globalConfig.autoConnectIde || autoConnectIdeFlag || @@ -46,16 +42,16 @@ export function useIDEIntegration({ process.env.CLAUDE_CODE_SSE_PORT || ideToInstallExtension || isEnvTruthy(process.env.CLAUDE_CODE_AUTO_CONNECT_IDE)) && - !isEnvDefinedFalsy(process.env.CLAUDE_CODE_AUTO_CONNECT_IDE) + !isEnvDefinedFalsy(process.env.CLAUDE_CODE_AUTO_CONNECT_IDE); if (!autoConnectEnabled) { - return + return; } setDynamicMcpConfig(prev => { // Only add the IDE if we don't already have one if (prev?.ide) { - return prev + return prev; } return { ...prev, @@ -67,8 +63,8 @@ export function useIDEIntegration({ ideRunningInWindows: ide.ideRunningInWindows, scope: 'dynamic' as const, }, - } - }) + }; + }); } // Use the new utility function @@ -77,12 +73,6 @@ export function useIDEIntegration({ ideToInstallExtension, () => setShowIdeOnboarding(true), status => setIDEInstallationState(status), - ) - }, [ - autoConnectIdeFlag, - ideToInstallExtension, - setDynamicMcpConfig, - setShowIdeOnboarding, - setIDEInstallationState, - ]) + ); + }, [autoConnectIdeFlag, ideToInstallExtension, setDynamicMcpConfig, setShowIdeOnboarding, setIDEInstallationState]); } diff --git a/src/hooks/useIssueFlagBanner.ts b/src/hooks/useIssueFlagBanner.ts index c21789cec..49161fe95 100644 --- a/src/hooks/useIssueFlagBanner.ts +++ b/src/hooks/useIssueFlagBanner.ts @@ -97,16 +97,13 @@ export function useIssueFlagBanner( return false } - // biome-ignore lint/correctness/useHookAtTopLevel: process.env.USER_TYPE is a compile-time constant const lastTriggeredAtRef = useRef(0) - // biome-ignore lint/correctness/useHookAtTopLevel: process.env.USER_TYPE is a compile-time constant const activeForSubmitRef = useRef(-1) // Memoize the O(messages) scans. This hook runs on every REPL render // (including every keystroke), but messages is stable during typing. // isSessionContainerCompatible walks all messages + regex-tests each // bash command — by far the heaviest work here. - // biome-ignore lint/correctness/useHookAtTopLevel: process.env.USER_TYPE is a compile-time constant const shouldTrigger = useMemo( () => isSessionContainerCompatible(messages) && hasFrictionSignal(messages), [messages], diff --git a/src/hooks/useLspPluginRecommendation.tsx b/src/hooks/useLspPluginRecommendation.tsx index 610431a63..69bb0a638 100644 --- a/src/hooks/useLspPluginRecommendation.tsx +++ b/src/hooks/useLspPluginRecommendation.tsx @@ -10,170 +10,138 @@ * Only shows one recommendation per session. */ -import { extname, join } from 'path' -import * as React from 'react' -import { - hasShownLspRecommendationThisSession, - setLspRecommendationShownThisSession, -} from '../bootstrap/state.js' -import { useNotifications } from '../context/notifications.js' -import { useAppState } from '../state/AppState.js' -import { saveGlobalConfig } from '../utils/config.js' -import { logForDebugging } from '../utils/debug.js' -import { logError } from '../utils/log.js' -import { - addToNeverSuggest, - getMatchingLspPlugins, - incrementIgnoredCount, -} from '../utils/plugins/lspRecommendation.js' -import { cacheAndRegisterPlugin } from '../utils/plugins/pluginInstallationHelpers.js' -import { - getSettingsForSource, - updateSettingsForSource, -} from '../utils/settings/settings.js' -import { - installPluginAndNotify, - usePluginRecommendationBase, -} from './usePluginRecommendationBase.js' +import { extname, join } from 'path'; +import * as React from 'react'; +import { hasShownLspRecommendationThisSession, setLspRecommendationShownThisSession } from '../bootstrap/state.js'; +import { useNotifications } from '../context/notifications.js'; +import { useAppState } from '../state/AppState.js'; +import { saveGlobalConfig } from '../utils/config.js'; +import { logForDebugging } from '../utils/debug.js'; +import { logError } from '../utils/log.js'; +import { addToNeverSuggest, getMatchingLspPlugins, incrementIgnoredCount } from '../utils/plugins/lspRecommendation.js'; +import { cacheAndRegisterPlugin } from '../utils/plugins/pluginInstallationHelpers.js'; +import { getSettingsForSource, updateSettingsForSource } from '../utils/settings/settings.js'; +import { installPluginAndNotify, usePluginRecommendationBase } from './usePluginRecommendationBase.js'; // Threshold for detecting timeout vs explicit dismiss (ms) // Menu auto-dismisses at 30s, so anything over 28s is likely timeout -const TIMEOUT_THRESHOLD_MS = 28_000 +const TIMEOUT_THRESHOLD_MS = 28_000; export type LspRecommendationState = { - pluginId: string - pluginName: string - pluginDescription?: string - fileExtension: string - shownAt: number // Timestamp for timeout detection -} | null + pluginId: string; + pluginName: string; + pluginDescription?: string; + fileExtension: string; + shownAt: number; // Timestamp for timeout detection +} | null; type UseLspPluginRecommendationResult = { - recommendation: LspRecommendationState - handleResponse: (response: 'yes' | 'no' | 'never' | 'disable') => void -} + recommendation: LspRecommendationState; + handleResponse: (response: 'yes' | 'no' | 'never' | 'disable') => void; +}; export function useLspPluginRecommendation(): UseLspPluginRecommendationResult { - const trackedFiles = useAppState(s => s.fileHistory.trackedFiles) - const { addNotification } = useNotifications() - const checkedFilesRef = React.useRef>(new Set()) + const trackedFiles = useAppState(s => s.fileHistory.trackedFiles); + const { addNotification } = useNotifications(); + const checkedFilesRef = React.useRef>(new Set()); const { recommendation, clearRecommendation, tryResolve } = - usePluginRecommendationBase>() + usePluginRecommendationBase>(); React.useEffect(() => { tryResolve(async () => { - if (hasShownLspRecommendationThisSession()) return null + if (hasShownLspRecommendationThisSession()) return null; - const newFiles: string[] = [] + const newFiles: string[] = []; for (const file of trackedFiles) { if (!checkedFilesRef.current.has(file)) { - checkedFilesRef.current.add(file) - newFiles.push(file) + checkedFilesRef.current.add(file); + newFiles.push(file); } } for (const filePath of newFiles) { try { - const matches = await getMatchingLspPlugins(filePath) - const match = matches[0] // official plugins prioritized + const matches = await getMatchingLspPlugins(filePath); + const match = matches[0]; // official plugins prioritized if (match) { - logForDebugging( - `[useLspPluginRecommendation] Found match: ${match.pluginName} for ${filePath}`, - ) - setLspRecommendationShownThisSession(true) + logForDebugging(`[useLspPluginRecommendation] Found match: ${match.pluginName} for ${filePath}`); + setLspRecommendationShownThisSession(true); return { pluginId: match.pluginId, pluginName: match.pluginName, pluginDescription: match.description, fileExtension: extname(filePath), shownAt: Date.now(), - } + }; } } catch (error) { - logError(error) + logError(error); } } - return null - }) - }, [trackedFiles, tryResolve]) + return null; + }); + }, [trackedFiles, tryResolve]); const handleResponse = React.useCallback( (response: 'yes' | 'no' | 'never' | 'disable') => { - if (!recommendation) return + if (!recommendation) return; - const { pluginId, pluginName, shownAt } = recommendation + const { pluginId, pluginName, shownAt } = recommendation; - logForDebugging( - `[useLspPluginRecommendation] User response: ${response} for ${pluginName}`, - ) + logForDebugging(`[useLspPluginRecommendation] User response: ${response} for ${pluginName}`); switch (response) { case 'yes': - void installPluginAndNotify( - pluginId, - pluginName, - 'lsp-plugin', - addNotification, - async pluginData => { - logForDebugging( - `[useLspPluginRecommendation] Installing plugin: ${pluginId}`, - ) - const localSourcePath = - typeof pluginData.entry.source === 'string' - ? join( - pluginData.marketplaceInstallLocation, - pluginData.entry.source, - ) - : undefined - await cacheAndRegisterPlugin( - pluginId, - pluginData.entry, - 'user', - undefined, // projectPath - not needed for user scope - localSourcePath, - ) - // Enable in user settings so it loads on restart - const settings = getSettingsForSource('userSettings') - updateSettingsForSource('userSettings', { - enabledPlugins: { - ...settings?.enabledPlugins, - [pluginId]: true, - }, - }) - logForDebugging( - `[useLspPluginRecommendation] Plugin installed: ${pluginId}`, - ) - }, - ) - break + void installPluginAndNotify(pluginId, pluginName, 'lsp-plugin', addNotification, async pluginData => { + logForDebugging(`[useLspPluginRecommendation] Installing plugin: ${pluginId}`); + const localSourcePath = + typeof pluginData.entry.source === 'string' + ? join(pluginData.marketplaceInstallLocation, pluginData.entry.source) + : undefined; + await cacheAndRegisterPlugin( + pluginId, + pluginData.entry, + 'user', + undefined, // projectPath - not needed for user scope + localSourcePath, + ); + // Enable in user settings so it loads on restart + const settings = getSettingsForSource('userSettings'); + updateSettingsForSource('userSettings', { + enabledPlugins: { + ...settings?.enabledPlugins, + [pluginId]: true, + }, + }); + logForDebugging(`[useLspPluginRecommendation] Plugin installed: ${pluginId}`); + }); + break; case 'no': { - const elapsed = Date.now() - shownAt + const elapsed = Date.now() - shownAt; if (elapsed >= TIMEOUT_THRESHOLD_MS) { - logForDebugging( - `[useLspPluginRecommendation] Timeout detected (${elapsed}ms), incrementing ignored count`, - ) - incrementIgnoredCount() + logForDebugging(`[useLspPluginRecommendation] Timeout detected (${elapsed}ms), incrementing ignored count`); + incrementIgnoredCount(); } - break + break; } case 'never': - addToNeverSuggest(pluginId) - break + addToNeverSuggest(pluginId); + break; case 'disable': saveGlobalConfig(current => { - if (current.lspRecommendationDisabled) return current - return { ...current, lspRecommendationDisabled: true } - }) - break + if (current.lspRecommendationDisabled) return current; + return { ...current, lspRecommendationDisabled: true }; + }); + break; } - clearRecommendation() + clearRecommendation(); }, [recommendation, addNotification, clearRecommendation], - ) + ); - return { recommendation, handleResponse } + return { recommendation, handleResponse }; } diff --git a/src/hooks/useManagePlugins.ts b/src/hooks/useManagePlugins.ts index 6e230b252..f8b13ffbb 100644 --- a/src/hooks/useManagePlugins.ts +++ b/src/hooks/useManagePlugins.ts @@ -52,7 +52,8 @@ export function useManagePlugins({ const initialPluginLoad = useCallback(async () => { try { // Load all plugins - capture errors array - const { enabled, disabled, errors }: PluginLoadResult = await loadAllPlugins() + const { enabled, disabled, errors }: PluginLoadResult = + await loadAllPlugins() // Detect delisted plugins, auto-uninstall them, and record as flagged. await detectAndUninstallDelistedPlugins() @@ -189,9 +190,17 @@ export function useManagePlugins({ if (!p.hooksConfig) return sum return ( sum + - (Object.values(p.hooksConfig) as Array | undefined>).reduce( + ( + Object.values(p.hooksConfig) as Array< + Array<{ hooks: unknown[] }> | undefined + > + ).reduce( (s, matchers) => - s + (matchers?.reduce((h: number, m: { hooks: unknown[] }) => h + m.hooks.length, 0) ?? 0), + s + + (matchers?.reduce( + (h: number, m: { hooks: unknown[] }) => h + m.hooks.length, + 0, + ) ?? 0), 0, ) ) diff --git a/src/hooks/useMasterMonitor.ts b/src/hooks/useMasterMonitor.ts index e2659692a..b308375b2 100644 --- a/src/hooks/useMasterMonitor.ts +++ b/src/hooks/useMasterMonitor.ts @@ -134,7 +134,10 @@ const MUTED_DROPPABLE_TYPES = new Set([ * Centralized mute check used by both attachPipeEntryEmitter and * useMasterMonitor's inline handler — keeps the two gates in sync. */ -export function shouldDropMutedMessage(slaveName: string, msgType: string): boolean { +export function shouldDropMutedMessage( + slaveName: string, + msgType: string, +): boolean { if (hasSendOverride(slaveName)) return false if (!isMasterPipeMuted(slaveName)) return false return MUTED_DROPPABLE_TYPES.has(msgType) @@ -193,7 +196,8 @@ function attachPipeEntryEmitter(name: string, client: PipeClient): void { data: JSON.stringify({ requestId: payload.requestId, behavior: 'deny', - feedback: 'Permission auto-denied: pipe is logically disconnected.', + feedback: + 'Permission auto-denied: pipe is logically disconnected.', }), }) } @@ -205,7 +209,10 @@ function attachPipeEntryEmitter(name: string, client: PipeClient): void { } // Clear /send override when slave turn completes - if ((msg.type === 'done' || msg.type === 'error') && hasSendOverride(name)) { + if ( + (msg.type === 'done' || msg.type === 'error') && + hasSendOverride(name) + ) { removeSendOverride(name) } @@ -222,7 +229,9 @@ function emitSlaveClientRegistryChanged(): void { } } -export function subscribeToSlaveClientRegistry(listener: () => void): () => void { +export function subscribeToSlaveClientRegistry( + listener: () => void, +): () => void { _slaveClientRegistryListeners.add(listener) return () => { _slaveClientRegistryListeners.delete(listener) @@ -315,7 +324,10 @@ export function useMasterMonitor(): void { } // Clear /send override when slave turn completes - if ((msg.type === 'done' || msg.type === 'error') && hasSendOverride(slaveName)) { + if ( + (msg.type === 'done' || msg.type === 'error') && + hasSendOverride(slaveName) + ) { removeSendOverride(slaveName) } diff --git a/src/hooks/useOfficialMarketplaceNotification.tsx b/src/hooks/useOfficialMarketplaceNotification.tsx index 6ba179e9b..2d6f24c69 100644 --- a/src/hooks/useOfficialMarketplaceNotification.tsx +++ b/src/hooks/useOfficialMarketplaceNotification.tsx @@ -1,9 +1,9 @@ -import * as React from 'react' -import type { Notification } from '../context/notifications.js' -import { Text } from '@anthropic/ink' -import { logForDebugging } from '../utils/debug.js' -import { checkAndInstallOfficialMarketplace } from '../utils/plugins/officialMarketplaceStartupCheck.js' -import { useStartupNotification } from './notifs/useStartupNotification.js' +import * as React from 'react'; +import type { Notification } from '../context/notifications.js'; +import { Text } from '@anthropic/ink'; +import { logForDebugging } from '../utils/debug.js'; +import { checkAndInstallOfficialMarketplace } from '../utils/plugins/officialMarketplaceStartupCheck.js'; +import { useStartupNotification } from './notifs/useStartupNotification.js'; /** * Hook that handles official marketplace auto-installation and shows @@ -11,49 +11,36 @@ import { useStartupNotification } from './notifs/useStartupNotification.js' */ export function useOfficialMarketplaceNotification(): void { useStartupNotification(async () => { - const result = await checkAndInstallOfficialMarketplace() - const notifs: Notification[] = [] + const result = await checkAndInstallOfficialMarketplace(); + const notifs: Notification[] = []; // Check for config save failure first - this is critical if (result.configSaveFailed) { - logForDebugging('Showing marketplace config save failure notification') + logForDebugging('Showing marketplace config save failure notification'); notifs.push({ key: 'marketplace-config-save-failed', - jsx: ( - - Failed to save marketplace retry info · Check ~/.claude.json - permissions - - ), + jsx: Failed to save marketplace retry info · Check ~/.claude.json permissions, priority: 'immediate', timeoutMs: 10000, - }) + }); } if (result.installed) { - logForDebugging('Showing marketplace installation success notification') + logForDebugging('Showing marketplace installation success notification'); notifs.push({ key: 'marketplace-installed', - jsx: ( - - ✓ Anthropic marketplace installed · /plugin to see available plugins - - ), + jsx: ✓ Anthropic marketplace installed · /plugin to see available plugins, priority: 'immediate', timeoutMs: 7000, - }) + }); } else if (result.skipped && result.reason === 'unknown') { - logForDebugging('Showing marketplace installation failure notification') + logForDebugging('Showing marketplace installation failure notification'); notifs.push({ key: 'marketplace-install-failed', - jsx: ( - - Failed to install Anthropic marketplace · Will retry on next startup - - ), + jsx: Failed to install Anthropic marketplace · Will retry on next startup, priority: 'immediate', timeoutMs: 8000, - }) + }); } // Don't show notifications for: // - already_installed (user already has it) @@ -62,6 +49,6 @@ export function useOfficialMarketplaceNotification(): void { // - git_unavailable (marketplace is a nice-to-have; if git is missing // or is a non-functional macOS xcrun shim, retry silently on backoff // rather than nagging — the user will sort git out for other reasons) - return notifs - }) + return notifs; + }); } diff --git a/src/hooks/usePipeMuteSync.ts b/src/hooks/usePipeMuteSync.ts index 4bddd0451..4ecf0c621 100644 --- a/src/hooks/usePipeMuteSync.ts +++ b/src/hooks/usePipeMuteSync.ts @@ -26,7 +26,9 @@ import { } from './useMasterMonitor.js' type UsePipeMuteSyncDeps = { - setToolUseConfirmQueue: (action: React.SetStateAction[]>) => void + setToolUseConfirmQueue: ( + action: React.SetStateAction[]>, + ) => void } export function usePipeMuteSync({ @@ -99,7 +101,9 @@ export function usePipeMuteSync({ // onAbort may throw if client disconnected — safe to ignore } } - return queue.filter((item: Record) => item.pipeName !== name) + return queue.filter( + (item: Record) => item.pipeName !== name, + ) }) // Send relay_mute to slave @@ -129,7 +133,13 @@ export function usePipeMuteSync({ } prevMutedRef.current = nextMuted - }, [routeMode, selectedPipes, registryVersion, sendOverrideVersion, setToolUseConfirmQueue]) + }, [ + routeMode, + selectedPipes, + registryVersion, + sendOverrideVersion, + setToolUseConfirmQueue, + ]) // Cleanup on unmount: clear all master-side mute state useEffect(() => { diff --git a/src/hooks/usePipeRelay.ts b/src/hooks/usePipeRelay.ts index 313d54720..a2d908c0d 100644 --- a/src/hooks/usePipeRelay.ts +++ b/src/hooks/usePipeRelay.ts @@ -23,20 +23,17 @@ export type PipeRelayHandle = { export function usePipeRelay(): PipeRelayHandle { const pipeReturnHadErrorRef = useRef(false) - const relayPipeMessage = useCallback( - (message: PipeMessage): boolean => { - const relay = getPipeRelay() - if (typeof relay !== 'function') { - return false - } - if (isRelayMuted()) { - return false - } - relay(message) - return true - }, - [], - ) + const relayPipeMessage = useCallback((message: PipeMessage): boolean => { + const relay = getPipeRelay() + if (typeof relay !== 'function') { + return false + } + if (isRelayMuted()) { + return false + } + relay(message) + return true + }, []) return { relayPipeMessage, pipeReturnHadErrorRef } } diff --git a/src/hooks/usePluginRecommendationBase.tsx b/src/hooks/usePluginRecommendationBase.tsx index 1e2fd7b5d..5312a7d6a 100644 --- a/src/hooks/usePluginRecommendationBase.tsx +++ b/src/hooks/usePluginRecommendationBase.tsx @@ -4,16 +4,16 @@ * and success/failure notification JSX so new sources stay small. */ -import figures from 'figures' -import * as React from 'react' -import { getIsRemoteMode } from '../bootstrap/state.js' -import type { useNotifications } from '../context/notifications.js' -import { Text } from '@anthropic/ink' -import { logError } from '../utils/log.js' -import { getPluginById } from '../utils/plugins/marketplaceManager.js' +import figures from 'figures'; +import * as React from 'react'; +import { getIsRemoteMode } from '../bootstrap/state.js'; +import type { useNotifications } from '../context/notifications.js'; +import { Text } from '@anthropic/ink'; +import { logError } from '../utils/log.js'; +import { getPluginById } from '../utils/plugins/marketplaceManager.js'; -type AddNotification = ReturnType['addNotification'] -type PluginData = NonNullable>> +type AddNotification = ReturnType['addNotification']; +type PluginData = NonNullable>>; /** * Call tryResolve inside a useEffect; it applies standard gates (remote @@ -22,38 +22,35 @@ type PluginData = NonNullable>> * identity tracks recommendation, so clearing re-triggers resolution. */ export function usePluginRecommendationBase(): { - recommendation: T | null - clearRecommendation: () => void - tryResolve: (resolve: () => Promise) => void + recommendation: T | null; + clearRecommendation: () => void; + tryResolve: (resolve: () => Promise) => void; } { - const [recommendation, setRecommendation] = React.useState(null) - const isCheckingRef = React.useRef(false) + const [recommendation, setRecommendation] = React.useState(null); + const isCheckingRef = React.useRef(false); const tryResolve = React.useCallback( (resolve: () => Promise) => { - if (getIsRemoteMode()) return - if (recommendation) return - if (isCheckingRef.current) return + if (getIsRemoteMode()) return; + if (recommendation) return; + if (isCheckingRef.current) return; - isCheckingRef.current = true + isCheckingRef.current = true; void resolve() .then(rec => { - if (rec) setRecommendation(rec) + if (rec) setRecommendation(rec); }) .catch(logError) .finally(() => { - isCheckingRef.current = false - }) + isCheckingRef.current = false; + }); }, [recommendation], - ) + ); - const clearRecommendation = React.useCallback( - () => setRecommendation(null), - [], - ) + const clearRecommendation = React.useCallback(() => setRecommendation(null), []); - return { recommendation, clearRecommendation, tryResolve } + return { recommendation, clearRecommendation, tryResolve }; } /** Look up plugin, run install(), emit standard success/failure notification. */ @@ -65,11 +62,11 @@ export async function installPluginAndNotify( install: (pluginData: PluginData) => Promise, ): Promise { try { - const pluginData = await getPluginById(pluginId) + const pluginData = await getPluginById(pluginId); if (!pluginData) { - throw new Error(`Plugin ${pluginId} not found in marketplace`) + throw new Error(`Plugin ${pluginId} not found in marketplace`); } - await install(pluginData) + await install(pluginData); addNotification({ key: `${keyPrefix}-installed`, jsx: ( @@ -79,14 +76,14 @@ export async function installPluginAndNotify( ), priority: 'immediate', timeoutMs: 5000, - }) + }); } catch (error) { - logError(error) + logError(error); addNotification({ key: `${keyPrefix}-install-failed`, jsx: Failed to install {pluginName}, priority: 'immediate', timeoutMs: 5000, - }) + }); } } diff --git a/src/hooks/usePromptsFromClaudeInChrome.tsx b/src/hooks/usePromptsFromClaudeInChrome.tsx index 6b9d34def..7334b0d4c 100644 --- a/src/hooks/usePromptsFromClaudeInChrome.tsx +++ b/src/hooks/usePromptsFromClaudeInChrome.tsx @@ -1,19 +1,13 @@ -import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs' -import { useEffect, useRef } from 'react' -import { logError } from 'src/utils/log.js' -import { z } from 'zod/v4' -import { callIdeRpc } from '../services/mcp/client.js' -import type { - ConnectedMCPServer, - MCPServerConnection, -} from '../services/mcp/types.js' -import type { PermissionMode } from '../types/permissions.js' -import { - CLAUDE_IN_CHROME_MCP_SERVER_NAME, - isTrackedClaudeInChromeTabId, -} from '../utils/claudeInChrome/common.js' -import { lazySchema } from '../utils/lazySchema.js' -import { enqueuePendingNotification } from '../utils/messageQueueManager.js' +import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'; +import { useEffect, useRef } from 'react'; +import { logError } from 'src/utils/log.js'; +import { z } from 'zod/v4'; +import { callIdeRpc } from '../services/mcp/client.js'; +import type { ConnectedMCPServer, MCPServerConnection } from '../services/mcp/types.js'; +import type { PermissionMode } from '../types/permissions.js'; +import { CLAUDE_IN_CHROME_MCP_SERVER_NAME, isTrackedClaudeInChromeTabId } from '../utils/claudeInChrome/common.js'; +import { lazySchema } from '../utils/lazySchema.js'; +import { enqueuePendingNotification } from '../utils/messageQueueManager.js'; // Schema for the prompt notification from Chrome extension (JSON-RPC 2.0 format) const ClaudeInChromePromptNotificationSchema = lazySchema(() => @@ -24,19 +18,14 @@ const ClaudeInChromePromptNotificationSchema = lazySchema(() => image: z .object({ type: z.literal('base64'), - media_type: z.enum([ - 'image/jpeg', - 'image/png', - 'image/gif', - 'image/webp', - ]), + media_type: z.enum(['image/jpeg', 'image/png', 'image/gif', 'image/webp']), data: z.string(), }) .optional(), tabId: z.number().optional(), }), }), -) +); /** * A hook that listens for prompt notifications from the Claude for Chrome extension, @@ -46,84 +35,72 @@ export function usePromptsFromClaudeInChrome( mcpClients: MCPServerConnection[], toolPermissionMode: PermissionMode, ): void { - const mcpClientRef = useRef(undefined) + const mcpClientRef = useRef(undefined); useEffect(() => { if (process.env.USER_TYPE !== 'ant') { - return + return; } - const mcpClient = findChromeClient(mcpClients) + const mcpClient = findChromeClient(mcpClients); if (mcpClientRef.current !== mcpClient) { - mcpClientRef.current = mcpClient + mcpClientRef.current = mcpClient; } if (mcpClient) { - mcpClient.client.setNotificationHandler( - ClaudeInChromePromptNotificationSchema(), - notification => { - if (mcpClientRef.current !== mcpClient) { - return - } - const { tabId, prompt, image } = notification.params + mcpClient.client.setNotificationHandler(ClaudeInChromePromptNotificationSchema(), notification => { + if (mcpClientRef.current !== mcpClient) { + return; + } + const { tabId, prompt, image } = notification.params; - // Process notifications from tabs we're tracking since notifications are broadcasted - if ( - typeof tabId !== 'number' || - !isTrackedClaudeInChromeTabId(tabId) - ) { - return - } + // Process notifications from tabs we're tracking since notifications are broadcasted + if (typeof tabId !== 'number' || !isTrackedClaudeInChromeTabId(tabId)) { + return; + } - try { - // Build content blocks if there's an image, otherwise just use the prompt string - if (image) { - const contentBlocks: ContentBlockParam[] = [ - { type: 'text', text: prompt }, - { - type: 'image', - source: { - type: image.type, - media_type: image.media_type, - data: image.data, - }, + try { + // Build content blocks if there's an image, otherwise just use the prompt string + if (image) { + const contentBlocks: ContentBlockParam[] = [ + { type: 'text', text: prompt }, + { + type: 'image', + source: { + type: image.type, + media_type: image.media_type, + data: image.data, }, - ] - enqueuePendingNotification({ - value: contentBlocks, - mode: 'prompt', - }) - } else { - enqueuePendingNotification({ value: prompt, mode: 'prompt' }) - } - } catch (error) { - logError(error as Error) + }, + ]; + enqueuePendingNotification({ + value: contentBlocks, + mode: 'prompt', + }); + } else { + enqueuePendingNotification({ value: prompt, mode: 'prompt' }); } - }, - ) + } catch (error) { + logError(error as Error); + } + }); } - }, [mcpClients]) + }, [mcpClients]); // Sync permission mode with Chrome extension whenever it changes useEffect(() => { - const chromeClient = findChromeClient(mcpClients) - if (!chromeClient) return + const chromeClient = findChromeClient(mcpClients); + if (!chromeClient) return; - const chromeMode = - toolPermissionMode === 'bypassPermissions' - ? 'skip_all_permission_checks' - : 'ask' + const chromeMode = toolPermissionMode === 'bypassPermissions' ? 'skip_all_permission_checks' : 'ask'; - void callIdeRpc('set_permission_mode', { mode: chromeMode }, chromeClient) - }, [mcpClients, toolPermissionMode]) + void callIdeRpc('set_permission_mode', { mode: chromeMode }, chromeClient); + }, [mcpClients, toolPermissionMode]); } -function findChromeClient( - clients: MCPServerConnection[], -): ConnectedMCPServer | undefined { +function findChromeClient(clients: MCPServerConnection[]): ConnectedMCPServer | undefined { return clients.find( (client): client is ConnectedMCPServer => - client.type === 'connected' && - client.name === CLAUDE_IN_CHROME_MCP_SERVER_NAME, - ) + client.type === 'connected' && client.name === CLAUDE_IN_CHROME_MCP_SERVER_NAME, + ); } diff --git a/src/hooks/useRemoteSession.ts b/src/hooks/useRemoteSession.ts index 0663f8b5d..971048b73 100644 --- a/src/hooks/useRemoteSession.ts +++ b/src/hooks/useRemoteSession.ts @@ -20,7 +20,10 @@ import type { AppState } from '../state/AppStateStore.js' import type { Tool } from '../Tool.js' import { findToolByName } from '../Tool.js' import type { Message as MessageType } from '../types/message.js' -import type { PermissionAskDecision, PermissionUpdate } from '../types/permissions.js' +import type { + PermissionAskDecision, + PermissionUpdate, +} from '../types/permissions.js' import { logForDebugging } from '../utils/debug.js' import { truncateToWidth } from '../utils/format.js' import { @@ -156,9 +159,11 @@ export function useRemoteSession({ const manager = new RemoteSessionManager(config, { onMessage: sdkMessage => { const parts = [`type=${sdkMessage.type}`] - if ('subtype' in sdkMessage) parts.push(`subtype=${sdkMessage.subtype as string}`) + if ('subtype' in sdkMessage) + parts.push(`subtype=${sdkMessage.subtype as string}`) if (sdkMessage.type === 'user') { - const c = (sdkMessage.message as { content?: unknown } | undefined)?.content + const c = (sdkMessage.message as { content?: unknown } | undefined) + ?.content parts.push( `content=${Array.isArray(c) ? c.map(b => b.type).join(',') : typeof c}`, ) @@ -249,7 +254,9 @@ export function useRemoteSession({ // and inProcessRunner.ts; without this the set grows unbounded for the // session lifetime (BQ: CCR cohort shows 5.2x higher RSS slope). if (setInProgressToolUseIDs && sdkMessage.type === 'user') { - const content = (sdkMessage.message as { content?: unknown } | undefined)?.content + const content = ( + sdkMessage.message as { content?: unknown } | undefined + )?.content if (Array.isArray(content)) { const resultIds: string[] = [] for (const block of content) { @@ -291,7 +298,9 @@ export function useRemoteSession({ setInProgressToolUseIDs && converted.message.type === 'assistant' ) { - const contentArr = Array.isArray(converted.message.message?.content) ? converted.message.message.content : [] + const contentArr = Array.isArray(converted.message.message?.content) + ? converted.message.message.content + : [] const toolUseIds = contentArr .filter(block => block.type === 'tool_use') .map(block => (block as { id: string }).id) diff --git a/src/hooks/useReplBridge.tsx b/src/hooks/useReplBridge.tsx index 2b9899117..642bb3d87 100644 --- a/src/hooks/useReplBridge.tsx +++ b/src/hooks/useReplBridge.tsx @@ -1,53 +1,43 @@ -import { feature } from 'bun:bundle' -import React, { useCallback, useEffect, useRef } from 'react' -import { setMainLoopModelOverride } from '../bootstrap/state.js' +import { feature } from 'bun:bundle'; +import React, { useCallback, useEffect, useRef } from 'react'; +import { setMainLoopModelOverride } from '../bootstrap/state.js'; import { type BridgePermissionCallbacks, type BridgePermissionResponse, isBridgePermissionResponse, -} from '../bridge/bridgePermissionCallbacks.js' -import { buildBridgeConnectUrl } from '../bridge/bridgeStatusUtil.js' -import { extractInboundMessageFields } from '../bridge/inboundMessages.js' -import type { BridgeState, ReplBridgeHandle } from '../bridge/replBridge.js' -import { setReplBridgeHandle } from '../bridge/replBridgeHandle.js' -import type { Command } from '../commands.js' -import { getSlashCommandToolSkills, isBridgeSafeCommand } from '../commands.js' -import { getRemoteSessionUrl } from '../constants/product.js' -import { useNotifications } from '../context/notifications.js' -import type { - PermissionMode, - SDKMessage, -} from '../entrypoints/agentSdkTypes.js' -import type { SDKControlResponse } from '../entrypoints/sdk/controlTypes.js' -import { Text } from '@anthropic/ink' -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js' -import { - useAppState, - useAppStateStore, - useSetAppState, -} from '../state/AppState.js' -import type { Message } from '../types/message.js' -import { getCwd } from '../utils/cwd.js' -import { logForDebugging } from '../utils/debug.js' -import { errorMessage } from '../utils/errors.js' -import { enqueue } from '../utils/messageQueueManager.js' -import { buildSystemInitMessage } from '../utils/messages/systemInit.js' -import { - createBridgeStatusMessage, - createSystemMessage, -} from '../utils/messages.js' +} from '../bridge/bridgePermissionCallbacks.js'; +import { buildBridgeConnectUrl } from '../bridge/bridgeStatusUtil.js'; +import { extractInboundMessageFields } from '../bridge/inboundMessages.js'; +import type { BridgeState, ReplBridgeHandle } from '../bridge/replBridge.js'; +import { setReplBridgeHandle } from '../bridge/replBridgeHandle.js'; +import type { Command } from '../commands.js'; +import { getSlashCommandToolSkills, isBridgeSafeCommand } from '../commands.js'; +import { getRemoteSessionUrl } from '../constants/product.js'; +import { useNotifications } from '../context/notifications.js'; +import type { PermissionMode, SDKMessage } from '../entrypoints/agentSdkTypes.js'; +import type { SDKControlResponse } from '../entrypoints/sdk/controlTypes.js'; +import { Text } from '@anthropic/ink'; +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../services/analytics/growthbook.js'; +import { useAppState, useAppStateStore, useSetAppState } from '../state/AppState.js'; +import type { Message } from '../types/message.js'; +import { getCwd } from '../utils/cwd.js'; +import { logForDebugging } from '../utils/debug.js'; +import { errorMessage } from '../utils/errors.js'; +import { enqueue } from '../utils/messageQueueManager.js'; +import { buildSystemInitMessage } from '../utils/messages/systemInit.js'; +import { createBridgeStatusMessage, createSystemMessage } from '../utils/messages.js'; import { getAutoModeUnavailableNotification, getAutoModeUnavailableReason, isAutoModeGateEnabled, isBypassPermissionsModeDisabled, transitionPermissionMode, -} from '../utils/permissions/permissionSetup.js' -import { getLeaderToolUseConfirmQueue } from '../utils/swarm/leaderPermissionBridge.js' -import { ContentBlockParam } from '@anthropic-ai/sdk/resources' +} from '../utils/permissions/permissionSetup.js'; +import { getLeaderToolUseConfirmQueue } from '../utils/swarm/leaderPermissionBridge.js'; +import { ContentBlockParam } from '@anthropic-ai/sdk/resources'; /** How long after a failure before replBridgeEnabled is auto-cleared (stops retries). */ -export const BRIDGE_FAILURE_DISMISS_MS = 10_000 +export const BRIDGE_FAILURE_DISMISS_MS = 10_000; /** * Max consecutive initReplBridge failures before the hook stops re-attempting @@ -58,7 +48,7 @@ export const BRIDGE_FAILURE_DISMISS_MS = 10_000 * top stuck client generated 2,879 × 401/day alone (17% of all 401s on the * route). */ -const MAX_CONSECUTIVE_INIT_FAILURES = 3 +const MAX_CONSECUTIVE_INIT_FAILURES = 3; /** * Hook that initializes an always-on bridge connection in the background @@ -78,45 +68,39 @@ export function useReplBridge( commands: readonly Command[], mainLoopModel: string, ): { sendBridgeResult: () => void } { - const handleRef = useRef(null) - const teardownPromiseRef = useRef | undefined>(undefined) - const lastWrittenIndexRef = useRef(0) + const handleRef = useRef(null); + const teardownPromiseRef = useRef | undefined>(undefined); + const lastWrittenIndexRef = useRef(0); // Tracks UUIDs already flushed as initial messages. Persists across // bridge reconnections so Bridge #2+ only sends new messages — sending // duplicate UUIDs causes the server to kill the WebSocket. - const flushedUUIDsRef = useRef(new Set()) - const failureTimeoutRef = useRef | undefined>( - undefined, - ) + const flushedUUIDsRef = useRef(new Set()); + const failureTimeoutRef = useRef | undefined>(undefined); // Persists across effect re-runs (unlike the effect's local state). Reset // only on successful init. Hits MAX_CONSECUTIVE_INIT_FAILURES → fuse blown // for the session, regardless of replBridgeEnabled re-toggling. - const consecutiveFailuresRef = useRef(0) - const setAppState = useSetAppState() - const commandsRef = useRef(commands) - commandsRef.current = commands - const mainLoopModelRef = useRef(mainLoopModel) - mainLoopModelRef.current = mainLoopModel - const messagesRef = useRef(messages) - messagesRef.current = messages - const store = useAppStateStore() - const { addNotification } = useNotifications() + const consecutiveFailuresRef = useRef(0); + const setAppState = useSetAppState(); + const commandsRef = useRef(commands); + commandsRef.current = commands; + const mainLoopModelRef = useRef(mainLoopModel); + mainLoopModelRef.current = mainLoopModel; + const messagesRef = useRef(messages); + messagesRef.current = messages; + const store = useAppStateStore(); + const { addNotification } = useNotifications(); const replBridgeEnabled = feature('BRIDGE_MODE') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s => s.replBridgeEnabled) - : false + ? useAppState(s => s.replBridgeEnabled) + : false; const replBridgeConnected = feature('BRIDGE_MODE') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s => s.replBridgeConnected) - : false + ? useAppState(s => s.replBridgeConnected) + : false; const replBridgeOutboundOnly = feature('BRIDGE_MODE') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s => s.replBridgeOutboundOnly) - : false + ? useAppState(s => s.replBridgeOutboundOnly) + : false; const replBridgeInitialName = feature('BRIDGE_MODE') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s => s.replBridgeInitialName) - : undefined + ? useAppState(s => s.replBridgeInitialName) + : undefined; // Initialize/teardown bridge when enabled state changes. // Passes current messages as initialMessages so the remote session @@ -126,11 +110,11 @@ export function useReplBridge( // negative pattern (if (!feature(...)) return) does NOT eliminate // dynamic imports below. if (feature('BRIDGE_MODE')) { - if (!replBridgeEnabled) return + if (!replBridgeEnabled) return; - const outboundOnly = replBridgeOutboundOnly + const outboundOnly = replBridgeOutboundOnly; function notifyBridgeFailed(detail?: string): void { - if (outboundOnly) return + if (outboundOnly) return; addNotification({ key: 'bridge-failed', jsx: ( @@ -140,33 +124,32 @@ export function useReplBridge( ), priority: 'immediate', - }) + }); } if (consecutiveFailuresRef.current >= MAX_CONSECUTIVE_INIT_FAILURES) { logForDebugging( `[bridge:repl] Hook: ${consecutiveFailuresRef.current} consecutive init failures, not retrying this session`, - ) + ); // Clear replBridgeEnabled so /remote-control doesn't mistakenly show // BridgeDisconnectDialog for a bridge that never connected. - const fuseHint = 'disabled after repeated failures · restart to retry' - notifyBridgeFailed(fuseHint) + const fuseHint = 'disabled after repeated failures · restart to retry'; + notifyBridgeFailed(fuseHint); setAppState(prev => { - if (prev.replBridgeError === fuseHint && !prev.replBridgeEnabled) - return prev + if (prev.replBridgeError === fuseHint && !prev.replBridgeEnabled) return prev; return { ...prev, replBridgeError: fuseHint, replBridgeEnabled: false, - } - }) - return + }; + }); + return; } - let cancelled = false + let cancelled = false; // Capture messages.length now so we don't re-send initial messages // through writeMessages after the bridge connects. - const initialMessageCount = messages.length + const initialMessageCount = messages.length; void (async () => { try { @@ -175,22 +158,16 @@ export function useReplBridge( // the previous teardown races with the new register call, and the // server may tear down the freshly-created environment. if (teardownPromiseRef.current) { - logForDebugging( - '[bridge:repl] Hook: waiting for previous teardown to complete before re-init', - ) - await teardownPromiseRef.current - teardownPromiseRef.current = undefined - logForDebugging( - '[bridge:repl] Hook: previous teardown complete, proceeding with re-init', - ) + logForDebugging('[bridge:repl] Hook: waiting for previous teardown to complete before re-init'); + await teardownPromiseRef.current; + teardownPromiseRef.current = undefined; + logForDebugging('[bridge:repl] Hook: previous teardown complete, proceeding with re-init'); } - if (cancelled) return + if (cancelled) return; // Dynamic import so the module is tree-shaken in external builds - const { initReplBridge } = await import('../bridge/initReplBridge.js') - const { shouldShowAppUpgradeMessage } = await import( - '../bridge/envLessBridgeConfig.js' - ) + const { initReplBridge } = await import('../bridge/initReplBridge.js'); + const { shouldShowAppUpgradeMessage } = await import('../bridge/envLessBridgeConfig.js'); // Assistant mode: perpetual bridge session — claude.ai shows one // continuous conversation across CLI restarts instead of a new @@ -201,10 +178,10 @@ export function useReplBridge( // pointer-clear so the session survives clean exits, not just // crashes. Non-assistant bridges clear the pointer on teardown // (crash-recovery only). - let perpetual = false + let perpetual = false; if (feature('KAIROS')) { - const { isAssistantMode } = await import('../assistant/index.js') - perpetual = isAssistantMode() + const { isAssistantMode } = await import('../assistant/index.js'); + perpetual = isAssistantMode(); } // When a user message arrives from claude.ai, inject it into the REPL. @@ -217,35 +194,31 @@ export function useReplBridge( // later, which is fine (web messages aren't rapid-fire). async function handleInboundMessage(msg: SDKMessage): Promise { try { - const fields = extractInboundMessageFields(msg) - if (!fields) return + const fields = extractInboundMessageFields(msg); + if (!fields) return; - const { uuid } = fields + const { uuid } = fields; // Dynamic import keeps the bridge code out of non-BRIDGE_MODE builds. - const { resolveAndPrepend } = await import( - '../bridge/inboundAttachments.js' - ) - const rawContent = fields.content - let sanitized: string | Array<{ type: string; [key: string]: unknown }> = typeof rawContent === 'string' ? rawContent : rawContent as unknown as Array<{ type: string; [key: string]: unknown }> + const { resolveAndPrepend } = await import('../bridge/inboundAttachments.js'); + const rawContent = fields.content; + let sanitized: string | Array<{ type: string; [key: string]: unknown }> = + typeof rawContent === 'string' + ? rawContent + : (rawContent as unknown as Array<{ type: string; [key: string]: unknown }>); if (feature('KAIROS_GITHUB_WEBHOOKS')) { /* eslint-disable @typescript-eslint/no-require-imports */ const { sanitizeInboundWebhookContent } = - require('../bridge/webhookSanitizer.js') as typeof import('../bridge/webhookSanitizer.js') + require('../bridge/webhookSanitizer.js') as typeof import('../bridge/webhookSanitizer.js'); /* eslint-enable @typescript-eslint/no-require-imports */ if (typeof sanitized === 'string') { - sanitized = sanitizeInboundWebhookContent(sanitized) + sanitized = sanitizeInboundWebhookContent(sanitized); } } - const content = await resolveAndPrepend(msg, sanitized as string | ContentBlockParam[]) + const content = await resolveAndPrepend(msg, sanitized as string | ContentBlockParam[]); - const preview = - typeof content === 'string' - ? content.slice(0, 80) - : `[${content.length} content blocks]` - logForDebugging( - `[bridge:repl] Injecting inbound user message: ${preview}${uuid ? ` uuid=${uuid}` : ''}`, - ) + const preview = typeof content === 'string' ? content.slice(0, 80) : `[${content.length} content blocks]`; + logForDebugging(`[bridge:repl] Injecting inbound user message: ${preview}${uuid ? ` uuid=${uuid}` : ''}`); enqueue({ value: content, mode: 'prompt' as const, @@ -257,59 +230,45 @@ export function useReplBridge( // intact for any code path that checks skipSlashCommands directly. skipSlashCommands: true, bridgeOrigin: true, - }) + }); } catch (e) { - logForDebugging( - `[bridge:repl] handleInboundMessage failed: ${e}`, - { level: 'error' }, - ) + logForDebugging(`[bridge:repl] handleInboundMessage failed: ${e}`, { level: 'error' }); } } // State change callback — maps bridge lifecycle events to AppState. - function handleStateChange( - state: BridgeState, - detail?: string, - ): void { - if (cancelled) return + function handleStateChange(state: BridgeState, detail?: string): void { + if (cancelled) return; if (outboundOnly) { - logForDebugging( - `[bridge:repl] Mirror state=${state}${detail ? ` detail=${detail}` : ''}`, - ) + logForDebugging(`[bridge:repl] Mirror state=${state}${detail ? ` detail=${detail}` : ''}`); // Sync replBridgeConnected so the forwarding effect starts/stops // writing as the transport comes up or dies. if (state === 'failed') { setAppState(prev => { - if (!prev.replBridgeConnected) return prev - return { ...prev, replBridgeConnected: false } - }) + if (!prev.replBridgeConnected) return prev; + return { ...prev, replBridgeConnected: false }; + }); } else if (state === 'ready' || state === 'connected') { setAppState(prev => { - if (prev.replBridgeConnected) return prev - return { ...prev, replBridgeConnected: true } - }) + if (prev.replBridgeConnected) return prev; + return { ...prev, replBridgeConnected: true }; + }); } - return + return; } - const handle = handleRef.current + const handle = handleRef.current; switch (state) { case 'ready': setAppState(prev => { const connectUrl = handle && handle.environmentId !== '' - ? buildBridgeConnectUrl( - handle.environmentId, - handle.sessionIngressUrl, - ) - : prev.replBridgeConnectUrl + ? buildBridgeConnectUrl(handle.environmentId, handle.sessionIngressUrl) + : prev.replBridgeConnectUrl; const sessionUrl = handle - ? getRemoteSessionUrl( - handle.bridgeSessionId, - handle.sessionIngressUrl, - ) - : prev.replBridgeSessionUrl - const envId = handle?.environmentId - const sessionId = handle?.bridgeSessionId + ? getRemoteSessionUrl(handle.bridgeSessionId, handle.sessionIngressUrl) + : prev.replBridgeSessionUrl; + const envId = handle?.environmentId; + const sessionId = handle?.bridgeSessionId; if ( prev.replBridgeConnected && !prev.replBridgeSessionActive && @@ -319,7 +278,7 @@ export function useReplBridge( prev.replBridgeEnvironmentId === envId && prev.replBridgeSessionId === sessionId ) { - return prev + return prev; } return { ...prev, @@ -331,37 +290,32 @@ export function useReplBridge( replBridgeEnvironmentId: envId, replBridgeSessionId: sessionId, replBridgeError: undefined, - } - }) - break + }; + }); + break; case 'connected': { setAppState(prev => { - if (prev.replBridgeSessionActive) return prev + if (prev.replBridgeSessionActive) return prev; return { ...prev, replBridgeConnected: true, replBridgeSessionActive: true, replBridgeReconnecting: false, replBridgeError: undefined, - } - }) + }; + }); // Send system/init so remote clients (web/iOS/Android) get // session metadata. REPL uses query() directly — never hits // QueryEngine's SDKMessage layer — so this is the only path // to put system/init on the REPL-bridge wire. Skills load is // async (memoized, cheap after REPL startup); fire-and-forget // so the connected-state transition isn't blocked. - if ( - getFeatureValue_CACHED_MAY_BE_STALE( - 'tengu_bridge_system_init', - false, - ) - ) { + if (getFeatureValue_CACHED_MAY_BE_STALE('tengu_bridge_system_init', false)) { void (async () => { try { - const skills = await getSlashCommandToolSkills(getCwd()) - if (cancelled) return - const state = store.getState() + const skills = await getSlashCommandToolSkills(getCwd()); + if (cancelled) return; + const state = store.getState(); handleRef.current?.writeSdkMessages([ buildSystemInitMessage({ // tools/mcpClients/plugins redacted for REPL-bridge: @@ -375,94 +329,82 @@ export function useReplBridge( tools: [], mcpClients: [], model: mainLoopModelRef.current, - permissionMode: state.toolPermissionContext - .mode as PermissionMode, // TODO: avoid the cast + permissionMode: state.toolPermissionContext.mode as PermissionMode, // TODO: avoid the cast // Remote clients can only invoke bridge-safe commands — // advertising unsafe ones (local-jsx, unallowed local) // would let mobile/web attempt them and hit errors. - commands: - commandsRef.current.filter(isBridgeSafeCommand), + commands: commandsRef.current.filter(isBridgeSafeCommand), agents: state.agentDefinitions.activeAgents, skills, plugins: [], fastMode: state.fastMode, }), - ]) + ]); } catch (err) { - logForDebugging( - `[bridge:repl] Failed to send system/init: ${errorMessage(err)}`, - { level: 'error' }, - ) + logForDebugging(`[bridge:repl] Failed to send system/init: ${errorMessage(err)}`, { + level: 'error', + }); } - })() + })(); } - break + break; } case 'reconnecting': setAppState(prev => { - if (prev.replBridgeReconnecting) return prev + if (prev.replBridgeReconnecting) return prev; return { ...prev, replBridgeReconnecting: true, replBridgeSessionActive: false, - } - }) - break + }; + }); + break; case 'failed': // Clear any previous failure dismiss timer - clearTimeout(failureTimeoutRef.current) - notifyBridgeFailed(detail) + clearTimeout(failureTimeoutRef.current); + notifyBridgeFailed(detail); setAppState(prev => ({ ...prev, replBridgeError: detail, replBridgeReconnecting: false, replBridgeSessionActive: false, replBridgeConnected: false, - })) + })); // Auto-disable after timeout so the hook stops retrying. failureTimeoutRef.current = setTimeout(() => { - if (cancelled) return - failureTimeoutRef.current = undefined + if (cancelled) return; + failureTimeoutRef.current = undefined; setAppState(prev => { - if (!prev.replBridgeError) return prev + if (!prev.replBridgeError) return prev; return { ...prev, replBridgeEnabled: false, replBridgeError: undefined, - } - }) - }, BRIDGE_FAILURE_DISMISS_MS) - break + }; + }); + }, BRIDGE_FAILURE_DISMISS_MS); + break; } } // Map of pending bridge permission response handlers, keyed by request_id. // Each entry is an onResponse handler waiting for CCR to reply. - const pendingPermissionHandlers = new Map< - string, - (response: BridgePermissionResponse) => void - >() + const pendingPermissionHandlers = new Map void>(); // Dispatch incoming control_response messages to registered handlers function handlePermissionResponse(msg: SDKControlResponse): void { - const requestId = msg.response?.request_id - if (!requestId) return - const handler = pendingPermissionHandlers.get(requestId) + const requestId = msg.response?.request_id; + if (!requestId) return; + const handler = pendingPermissionHandlers.get(requestId); if (!handler) { - logForDebugging( - `[bridge:repl] No handler for control_response request_id=${requestId}`, - ) - return + logForDebugging(`[bridge:repl] No handler for control_response request_id=${requestId}`); + return; } - pendingPermissionHandlers.delete(requestId) + pendingPermissionHandlers.delete(requestId); // Extract the permission decision from the control_response payload - const inner = msg.response - if ( - inner.subtype === 'success' && - inner.response && - isBridgePermissionResponse(inner.response) - ) { - handler(inner.response) + const inner = msg.response; + if (inner.subtype === 'success' && inner.response && isBridgePermissionResponse(inner.response)) { + handler(inner.response); } } @@ -472,22 +414,22 @@ export function useReplBridge( onInboundMessage: handleInboundMessage, onPermissionResponse: handlePermissionResponse, onInterrupt() { - abortControllerRef.current?.abort() + abortControllerRef.current?.abort(); }, onSetModel(model) { - const resolved = model === 'default' ? null : (model ?? null) - setMainLoopModelOverride(resolved) + const resolved = model === 'default' ? null : (model ?? null); + setMainLoopModelOverride(resolved); setAppState(prev => { - if (prev.mainLoopModelForSession === resolved) return prev - return { ...prev, mainLoopModelForSession: resolved } - }) + if (prev.mainLoopModelForSession === resolved) return prev; + return { ...prev, mainLoopModelForSession: resolved }; + }); }, onSetMaxThinkingTokens(maxTokens) { - const enabled = maxTokens !== null + const enabled = maxTokens !== null; setAppState(prev => { - if (prev.thinkingEnabled === enabled) return prev - return { ...prev, thinkingEnabled: enabled } - }) + if (prev.thinkingEnabled === enabled) return prev; + return { ...prev, thinkingEnabled: enabled }; + }); }, onSetPermissionMode(mode) { // Policy guards MUST fire before transitionPermissionMode — @@ -506,57 +448,46 @@ export function useReplBridge( ok: false, error: 'Cannot set permission mode to bypassPermissions because it is disabled by settings or configuration', - } + }; } - if ( - !store.getState().toolPermissionContext - .isBypassPermissionsModeAvailable - ) { + if (!store.getState().toolPermissionContext.isBypassPermissionsModeAvailable) { return { ok: false, error: 'Cannot set permission mode to bypassPermissions because the session was not launched with --dangerously-skip-permissions', - } + }; } } - if ( - feature('TRANSCRIPT_CLASSIFIER') && - mode === 'auto' && - !isAutoModeGateEnabled() - ) { - const reason = getAutoModeUnavailableReason() + if (feature('TRANSCRIPT_CLASSIFIER') && mode === 'auto' && !isAutoModeGateEnabled()) { + const reason = getAutoModeUnavailableReason(); return { ok: false, error: reason ? `Cannot set permission mode to auto: ${getAutoModeUnavailableNotification(reason)}` : 'Cannot set permission mode to auto', - } + }; } // Guards passed — apply via the centralized transition so // prePlanMode stashing and auto-mode state sync all fire. setAppState(prev => { - const current = prev.toolPermissionContext.mode - if (current === mode) return prev - const next = transitionPermissionMode( - current, - mode, - prev.toolPermissionContext, - ) + const current = prev.toolPermissionContext.mode; + if (current === mode) return prev; + const next = transitionPermissionMode(current, mode, prev.toolPermissionContext); return { ...prev, toolPermissionContext: { ...next, mode }, - } - }) + }; + }); // Recheck queued permission prompts now that mode changed. setImmediate(() => { getLeaderToolUseConfirmQueue()?.(currentQueue => { currentQueue.forEach(item => { - void item.recheckPermission() - }) - return currentQueue - }) - }) - return { ok: true } + void item.recheckPermission(); + }); + return currentQueue; + }); + }); + return { ok: true }; }, onStateChange: handleStateChange, initialMessages: messages.length > 0 ? messages : undefined, @@ -564,62 +495,57 @@ export function useReplBridge( previouslyFlushedUUIDs: flushedUUIDsRef.current, initialName: replBridgeInitialName, perpetual, - }) + }); if (cancelled) { // Effect was cancelled while initReplBridge was in flight. // Tear down the handle to avoid leaking resources (poll loop, // WebSocket, registered environment, cleanup callback). logForDebugging( `[bridge:repl] Hook: init cancelled during flight, tearing down${handle ? ` env=${handle.environmentId}` : ''}`, - ) + ); if (handle) { - void handle.teardown() + void handle.teardown(); } - return + return; } if (!handle) { // initReplBridge returned null — a precondition failed. For most // cases (no_oauth, policy_denied, etc.) onStateChange('failed') // already fired with a specific hint. The GrowthBook-gate-off case // is intentionally silent — not a failure, just not rolled out. - consecutiveFailuresRef.current++ + consecutiveFailuresRef.current++; logForDebugging( `[bridge:repl] Init returned null (precondition or session creation failed); consecutive failures: ${consecutiveFailuresRef.current}`, - ) - clearTimeout(failureTimeoutRef.current) + ); + clearTimeout(failureTimeoutRef.current); setAppState(prev => ({ ...prev, - replBridgeError: - prev.replBridgeError ?? 'check debug logs for details', - })) + replBridgeError: prev.replBridgeError ?? 'check debug logs for details', + })); failureTimeoutRef.current = setTimeout(() => { - if (cancelled) return - failureTimeoutRef.current = undefined + if (cancelled) return; + failureTimeoutRef.current = undefined; setAppState(prev => { - if (!prev.replBridgeError) return prev + if (!prev.replBridgeError) return prev; return { ...prev, replBridgeEnabled: false, replBridgeError: undefined, - } - }) - }, BRIDGE_FAILURE_DISMISS_MS) - return + }; + }); + }, BRIDGE_FAILURE_DISMISS_MS); + return; } - handleRef.current = handle - setReplBridgeHandle(handle) - consecutiveFailuresRef.current = 0 + handleRef.current = handle; + setReplBridgeHandle(handle); + consecutiveFailuresRef.current = 0; // Skip initial messages in the forwarding effect — they were // already loaded as session events during creation. - lastWrittenIndexRef.current = initialMessageCount + lastWrittenIndexRef.current = initialMessageCount; if (outboundOnly) { setAppState(prev => { - if ( - prev.replBridgeConnected && - prev.replBridgeSessionId === handle.bridgeSessionId - ) - return prev + if (prev.replBridgeConnected && prev.replBridgeSessionId === handle.bridgeSessionId) return prev; return { ...prev, replBridgeConnected: true, @@ -627,24 +553,14 @@ export function useReplBridge( replBridgeSessionUrl: undefined, replBridgeConnectUrl: undefined, replBridgeError: undefined, - } - }) - logForDebugging( - `[bridge:repl] Mirror initialized, session=${handle.bridgeSessionId}`, - ) + }; + }); + logForDebugging(`[bridge:repl] Mirror initialized, session=${handle.bridgeSessionId}`); } else { // Build bridge permission callbacks so the interactive permission // handler can race bridge responses against local user interaction. const permissionCallbacks: BridgePermissionCallbacks = { - sendRequest( - requestId, - toolName, - input, - toolUseId, - description, - permissionSuggestions, - blockedPath, - ) { + sendRequest(requestId, toolName, input, toolUseId, description, permissionSuggestions, blockedPath) { handle.sendControlRequest({ type: 'control_request', request_id: requestId, @@ -654,15 +570,13 @@ export function useReplBridge( input, tool_use_id: toolUseId, description, - ...(permissionSuggestions - ? { permission_suggestions: permissionSuggestions } - : {}), + ...(permissionSuggestions ? { permission_suggestions: permissionSuggestions } : {}), ...(blockedPath ? { blocked_path: blockedPath } : {}), }, - }) + }); }, sendResponse(requestId, response) { - const payload: Record = { ...response } + const payload: Record = { ...response }; handle.sendControlResponse({ type: 'control_response', response: { @@ -670,41 +584,32 @@ export function useReplBridge( request_id: requestId, response: payload, }, - }) + }); }, cancelRequest(requestId) { - handle.sendControlCancelRequest(requestId) + handle.sendControlCancelRequest(requestId); }, onResponse(requestId, handler) { - pendingPermissionHandlers.set(requestId, handler) + pendingPermissionHandlers.set(requestId, handler); return () => { - pendingPermissionHandlers.delete(requestId) - } + pendingPermissionHandlers.delete(requestId); + }; }, - } + }; setAppState(prev => ({ ...prev, replBridgePermissionCallbacks: permissionCallbacks, - })) - const url = getRemoteSessionUrl( - handle.bridgeSessionId, - handle.sessionIngressUrl, - ) + })); + const url = getRemoteSessionUrl(handle.bridgeSessionId, handle.sessionIngressUrl); // environmentId === '' signals the v2 env-less path. buildBridgeConnectUrl // builds an env-specific connect URL, which doesn't exist without an env. - const hasEnv = handle.environmentId !== '' + const hasEnv = handle.environmentId !== ''; const connectUrl = hasEnv - ? buildBridgeConnectUrl( - handle.environmentId, - handle.sessionIngressUrl, - ) - : undefined + ? buildBridgeConnectUrl(handle.environmentId, handle.sessionIngressUrl) + : undefined; setAppState(prev => { - if ( - prev.replBridgeConnected && - prev.replBridgeSessionUrl === url - ) { - return prev + if (prev.replBridgeConnected && prev.replBridgeSessionUrl === url) { + return prev; } return { ...prev, @@ -714,17 +619,15 @@ export function useReplBridge( replBridgeEnvironmentId: handle.environmentId, replBridgeSessionId: handle.bridgeSessionId, replBridgeError: undefined, - } - }) + }; + }); // Show bridge status with URL in the transcript. perpetual (KAIROS // assistant mode) falls back to v1 at initReplBridge.ts — skip the // v2-only upgrade nudge for them. Own try/catch so a cosmetic // GrowthBook hiccup doesn't hit the outer init-failure handler. - const upgradeNudge = !perpetual - ? await shouldShowAppUpgradeMessage().catch(() => false) - : false - if (cancelled) return + const upgradeNudge = !perpetual ? await shouldShowAppUpgradeMessage().catch(() => false) : false; + if (cancelled) return; setMessages(prev => [ ...prev, createBridgeStatusMessage( @@ -733,11 +636,9 @@ export function useReplBridge( ? 'Please upgrade to the latest version of the Claude mobile app to see your Remote Control sessions.' : undefined, ), - ]) + ]); - logForDebugging( - `[bridge:repl] Hook initialized, session=${handle.bridgeSessionId}`, - ) + logForDebugging(`[bridge:repl] Hook initialized, session=${handle.bridgeSessionId}`); } } catch (err) { // Never crash the REPL — surface the error in the UI. @@ -746,61 +647,54 @@ export function useReplBridge( // error), don't count that toward the fuse or spam a stale error // into the UI. Also fixes pre-existing spurious setAppState/ // setMessages on cancelled throws. - if (cancelled) return - consecutiveFailuresRef.current++ - const errMsg = errorMessage(err) + if (cancelled) return; + consecutiveFailuresRef.current++; + const errMsg = errorMessage(err); logForDebugging( `[bridge:repl] Init failed: ${errMsg}; consecutive failures: ${consecutiveFailuresRef.current}`, - ) - clearTimeout(failureTimeoutRef.current) - notifyBridgeFailed(errMsg) + ); + clearTimeout(failureTimeoutRef.current); + notifyBridgeFailed(errMsg); setAppState(prev => ({ ...prev, replBridgeError: errMsg, - })) + })); failureTimeoutRef.current = setTimeout(() => { - if (cancelled) return - failureTimeoutRef.current = undefined + if (cancelled) return; + failureTimeoutRef.current = undefined; setAppState(prev => { - if (!prev.replBridgeError) return prev + if (!prev.replBridgeError) return prev; return { ...prev, replBridgeEnabled: false, replBridgeError: undefined, - } - }) - }, BRIDGE_FAILURE_DISMISS_MS) + }; + }); + }, BRIDGE_FAILURE_DISMISS_MS); if (!outboundOnly) { setMessages(prev => [ ...prev, - createSystemMessage( - `Remote Control failed to connect: ${errMsg}`, - 'warning', - ), - ]) + createSystemMessage(`Remote Control failed to connect: ${errMsg}`, 'warning'), + ]); } } - })() + })(); return () => { - cancelled = true - clearTimeout(failureTimeoutRef.current) - failureTimeoutRef.current = undefined + cancelled = true; + clearTimeout(failureTimeoutRef.current); + failureTimeoutRef.current = undefined; if (handleRef.current) { logForDebugging( `[bridge:repl] Hook cleanup: starting teardown for env=${handleRef.current.environmentId} session=${handleRef.current.bridgeSessionId}`, - ) - teardownPromiseRef.current = handleRef.current.teardown() - handleRef.current = null - setReplBridgeHandle(null) + ); + teardownPromiseRef.current = handleRef.current.teardown(); + handleRef.current = null; + setReplBridgeHandle(null); } setAppState(prev => { - if ( - !prev.replBridgeConnected && - !prev.replBridgeSessionActive && - !prev.replBridgeError - ) { - return prev + if (!prev.replBridgeConnected && !prev.replBridgeSessionActive && !prev.replBridgeError) { + return prev; } return { ...prev, @@ -813,18 +707,12 @@ export function useReplBridge( replBridgeSessionId: undefined, replBridgeError: undefined, replBridgePermissionCallbacks: undefined, - } - }) - lastWrittenIndexRef.current = 0 - } + }; + }); + lastWrittenIndexRef.current = 0; + }; } - }, [ - replBridgeEnabled, - replBridgeOutboundOnly, - setAppState, - setMessages, - addNotification, - ]) + }, [replBridgeEnabled, replBridgeOutboundOnly, setAppState, setMessages, addNotification]); // Write new messages as they appear. // Also re-runs when replBridgeConnected changes (bridge finishes init), @@ -832,10 +720,10 @@ export function useReplBridge( useEffect(() => { // Positive feature() guard — see first useEffect comment if (feature('BRIDGE_MODE')) { - if (!replBridgeConnected) return + if (!replBridgeConnected) return; - const handle = handleRef.current - if (!handle) return + const handle = handleRef.current; + if (!handle) return; // Clamp the index in case messages were compacted (array shortened). // After compaction the ref could exceed messages.length, and without @@ -843,36 +731,36 @@ export function useReplBridge( if (lastWrittenIndexRef.current > messages.length) { logForDebugging( `[bridge:repl] Compaction detected: lastWrittenIndex=${lastWrittenIndexRef.current} > messages.length=${messages.length}, clamping`, - ) + ); } - const startIndex = Math.min(lastWrittenIndexRef.current, messages.length) + const startIndex = Math.min(lastWrittenIndexRef.current, messages.length); // Collect new messages since last write - const newMessages: Message[] = [] + const newMessages: Message[] = []; for (let i = startIndex; i < messages.length; i++) { - const msg = messages[i] + const msg = messages[i]; if ( msg && (msg.type === 'user' || msg.type === 'assistant' || (msg.type === 'system' && msg.subtype === 'local_command')) ) { - newMessages.push(msg) + newMessages.push(msg); } } - lastWrittenIndexRef.current = messages.length + lastWrittenIndexRef.current = messages.length; if (newMessages.length > 0) { - handle.writeMessages(newMessages) + handle.writeMessages(newMessages); } } - }, [messages, replBridgeConnected]) + }, [messages, replBridgeConnected]); const sendBridgeResult = useCallback(() => { if (feature('BRIDGE_MODE')) { - handleRef.current?.sendResult() + handleRef.current?.sendResult(); } - }, []) + }, []); - return { sendBridgeResult } + return { sendBridgeResult }; } diff --git a/src/hooks/useSSHSession.ts b/src/hooks/useSSHSession.ts index 0ee717e3e..d1fb25b74 100644 --- a/src/hooks/useSSHSession.ts +++ b/src/hooks/useSSHSession.ts @@ -21,7 +21,10 @@ import { isSessionEndMessage, } from '../remote/sdkMessageAdapter.js' import type { SSHSession } from '../ssh/createSSHSession.js' -import type { SSHSessionManager, SSHPermissionRequest } from '../ssh/SSHSessionManager.js' +import type { + SSHSessionManager, + SSHPermissionRequest, +} from '../ssh/SSHSessionManager.js' import type { Tool } from '../Tool.js' import { findToolByName } from '../Tool.js' import type { Message as MessageType } from '../types/message.js' @@ -98,7 +101,9 @@ export function useSSHSession({ createToolStub(request.tool_name) const syntheticMessage = createSyntheticAssistantMessage( - request as unknown as Parameters[0], + request as unknown as Parameters< + typeof createSyntheticAssistantMessage + >[0], requestId, ) diff --git a/src/hooks/useTeleportResume.tsx b/src/hooks/useTeleportResume.tsx index bc0e1fb0e..2843bac48 100644 --- a/src/hooks/useTeleportResume.tsx +++ b/src/hooks/useTeleportResume.tsx @@ -1,72 +1,62 @@ -import { useCallback, useState } from 'react' -import { setTeleportedSessionInfo } from 'src/bootstrap/state.js' +import { useCallback, useState } from 'react'; +import { setTeleportedSessionInfo } from 'src/bootstrap/state.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from 'src/services/analytics/index.js' -import type { TeleportRemoteResponse } from 'src/utils/conversationRecovery.js' -import type { CodeSession } from 'src/utils/teleport/api.js' -import { errorMessage, TeleportOperationError } from '../utils/errors.js' -import { teleportResumeCodeSession } from '../utils/teleport.js' +} from 'src/services/analytics/index.js'; +import type { TeleportRemoteResponse } from 'src/utils/conversationRecovery.js'; +import type { CodeSession } from 'src/utils/teleport/api.js'; +import { errorMessage, TeleportOperationError } from '../utils/errors.js'; +import { teleportResumeCodeSession } from '../utils/teleport.js'; export type TeleportResumeError = { - message: string - formattedMessage?: string - isOperationError: boolean -} + message: string; + formattedMessage?: string; + isOperationError: boolean; +}; -export type TeleportSource = 'cliArg' | 'localCommand' +export type TeleportSource = 'cliArg' | 'localCommand'; export function useTeleportResume(source: TeleportSource) { - const [isResuming, setIsResuming] = useState(false) - const [error, setError] = useState(null) - const [selectedSession, setSelectedSession] = useState( - null, - ) + const [isResuming, setIsResuming] = useState(false); + const [error, setError] = useState(null); + const [selectedSession, setSelectedSession] = useState(null); const resumeSession = useCallback( async (session: CodeSession): Promise => { - setIsResuming(true) - setError(null) - setSelectedSession(session) + setIsResuming(true); + setError(null); + setSelectedSession(session); // Log teleport session selection logEvent('tengu_teleport_resume_session', { - source: - source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - session_id: - session.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + source: source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + session_id: session.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); try { - const result = await teleportResumeCodeSession(session.id) + const result = await teleportResumeCodeSession(session.id); // Track teleported session for reliability logging - setTeleportedSessionInfo({ sessionId: session.id }) - setIsResuming(false) - return result + setTeleportedSessionInfo({ sessionId: session.id }); + setIsResuming(false); + return result; } catch (err) { const teleportError: TeleportResumeError = { - message: - err instanceof TeleportOperationError - ? err.message - : errorMessage(err), - formattedMessage: - err instanceof TeleportOperationError - ? err.formattedMessage - : undefined, + message: err instanceof TeleportOperationError ? err.message : errorMessage(err), + formattedMessage: err instanceof TeleportOperationError ? err.formattedMessage : undefined, isOperationError: err instanceof TeleportOperationError, - } - setError(teleportError) - setIsResuming(false) - return null + }; + setError(teleportError); + setIsResuming(false); + return null; } }, [source], - ) + ); const clearError = useCallback(() => { - setError(null) - }, []) + setError(null); + }, []); return { resumeSession, @@ -74,5 +64,5 @@ export function useTeleportResume(source: TeleportSource) { error, selectedSession, clearError, - } + }; } diff --git a/src/hooks/useTextInput.ts b/src/hooks/useTextInput.ts index 21e0dbf19..5cf16e261 100644 --- a/src/hooks/useTextInput.ts +++ b/src/hooks/useTextInput.ts @@ -24,6 +24,7 @@ import type { ImageDimensions } from '../utils/imageResizer.js' import { isModifierPressed, prewarmModifiers } from '../utils/modifiers.js' import { useDoublePress } from './useDoublePress.js' +// biome-ignore lint/suspicious/noConfusingVoidType: void is the correct return type for cursor handlers that return nothing type MaybeCursor = void | Cursor type InputHandler = (input: string) => MaybeCursor type InputMapper = (input: string) => MaybeCursor diff --git a/src/hooks/useTypeahead.tsx b/src/hooks/useTypeahead.tsx index 6625586d3..1eece26e3 100644 --- a/src/hooks/useTypeahead.tsx +++ b/src/hooks/useTypeahead.tsx @@ -584,7 +584,6 @@ export function useTypeahead({ const debouncedFetchSlackChannels = useDebounceCallback(fetchSlackChannels, 150); // Handle immediate suggestion logic (cheap operations) - // biome-ignore lint/correctness/useExhaustiveDependencies: store is a stable context ref, read imperatively at call-time const updateSuggestions = useCallback( async (value: string, inputCursorOffset?: number): Promise => { // Use provided cursor offset or fall back to ref (avoids dependency on cursorOffset) diff --git a/src/hooks/useVoiceIntegration.tsx b/src/hooks/useVoiceIntegration.tsx index b798a61ae..6897b1834 100644 --- a/src/hooks/useVoiceIntegration.tsx +++ b/src/hooks/useVoiceIntegration.tsx @@ -1,84 +1,69 @@ -import { feature } from 'bun:bundle' -import * as React from 'react' -import { useCallback, useEffect, useMemo, useRef } from 'react' -import { useNotifications } from '../context/notifications.js' -import { useIsModalOverlayActive } from '../context/overlayContext.js' -import { - useGetVoiceState, - useSetVoiceState, - useVoiceState, -} from '../context/voice.js' -import { KeyboardEvent, useInput } from '@anthropic/ink' +import { feature } from 'bun:bundle'; +import * as React from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { useNotifications } from '../context/notifications.js'; +import { useIsModalOverlayActive } from '../context/overlayContext.js'; +import { useGetVoiceState, useSetVoiceState, useVoiceState } from '../context/voice.js'; +import { KeyboardEvent, useInput } from '@anthropic/ink'; // backward-compat bridge until REPL wires handleKeyDown to -import { useOptionalKeybindingContext } from '../keybindings/KeybindingContext.js' -import { keystrokesEqual } from '../keybindings/resolver.js' -import type { ParsedKeystroke } from '../keybindings/types.js' -import { normalizeFullWidthSpace } from '../utils/stringUtils.js' -import { useVoiceEnabled } from './useVoiceEnabled.js' +import { useOptionalKeybindingContext } from '../keybindings/KeybindingContext.js'; +import { keystrokesEqual } from '../keybindings/resolver.js'; +import type { ParsedKeystroke } from '../keybindings/types.js'; +import { normalizeFullWidthSpace } from '../utils/stringUtils.js'; +import { useVoiceEnabled } from './useVoiceEnabled.js'; // Dead code elimination: conditional import for voice input hook. /* eslint-disable @typescript-eslint/no-require-imports */ // Capture the module namespace, not the function: spyOn() mutates the module // object, so `voiceNs.useVoice(...)` resolves to the spy even if this module // was loaded before the spy was installed (test ordering independence). -const voiceNs: { useVoice: typeof import('./useVoice.js').useVoice } = feature( - 'VOICE_MODE', -) +const voiceNs: { useVoice: typeof import('./useVoice.js').useVoice } = feature('VOICE_MODE') ? require('./useVoice.js') : { - useVoice: ({ - enabled: _e, - }: { - onTranscript: (t: string) => void - enabled: boolean - }) => ({ + useVoice: ({ enabled: _e }: { onTranscript: (t: string) => void; enabled: boolean }) => ({ state: 'idle' as const, handleKeyEvent: (_fallbackMs?: number) => {}, }), - } + }; /* eslint-enable @typescript-eslint/no-require-imports */ // Maximum gap (ms) between key presses to count as held (auto-repeat). // Terminal auto-repeat fires every 30-80ms; 120ms covers jitter while // excluding normal typing speed (100-300ms between keystrokes). -const RAPID_KEY_GAP_MS = 120 +const RAPID_KEY_GAP_MS = 120; // Fallback (ms) for modifier-combo first-press activation. Must match // FIRST_PRESS_FALLBACK_MS in useVoice.ts. Covers the max OS initial // key-repeat delay (~2s on macOS with slider at "Long") so holding a // modifier combo doesn't fragment into two sessions when the first // auto-repeat arrives after the default 600ms REPEAT_FALLBACK_MS. -const MODIFIER_FIRST_PRESS_FALLBACK_MS = 2000 +const MODIFIER_FIRST_PRESS_FALLBACK_MS = 2000; // Number of rapid consecutive key events required to activate voice. // Only applies to bare-char bindings (space, v, etc.) where a single press // could be normal typing. Modifier combos activate on the first press. -const HOLD_THRESHOLD = 5 +const HOLD_THRESHOLD = 5; // Number of rapid key events to start showing warmup feedback. -const WARMUP_THRESHOLD = 2 +const WARMUP_THRESHOLD = 2; // Match a KeyboardEvent against a ParsedKeystroke. Replaces the legacy // matchesKeystroke(input, Key, ...) path which assumed useInput's raw // `input` arg — KeyboardEvent.key holds normalized names (e.g. 'space', // 'f9') that getKeyName() didn't handle, so modifier combos and f-keys // silently failed to match after the onKeyDown migration (#23524). -function matchesKeyboardEvent( - e: KeyboardEvent, - target: ParsedKeystroke, -): boolean { +function matchesKeyboardEvent(e: KeyboardEvent, target: ParsedKeystroke): boolean { // KeyboardEvent stores key names; ParsedKeystroke stores ' ' for space // and 'enter' for return (see parser.ts case 'space'/'return'). - const key = - e.key === 'space' ? ' ' : e.key === 'return' ? 'enter' : e.key.toLowerCase() - if (key !== target.key) return false - if (e.ctrl !== target.ctrl) return false - if (e.shift !== target.shift) return false + const key = e.key === 'space' ? ' ' : e.key === 'return' ? 'enter' : e.key.toLowerCase(); + if (key !== target.key) return false; + if (e.ctrl !== target.ctrl) return false; + if (e.shift !== target.shift) return false; // KeyboardEvent.meta folds alt|option (terminal limitation — esc-prefix); // ParsedKeystroke has both alt and meta as aliases for the same thing. - if (e.meta !== (target.alt || target.meta)) return false - if (e.superKey !== target.super) return false - return true + if (e.meta !== (target.alt || target.meta)) return false; + if (e.superKey !== target.super) return false; + return true; } // Hardcoded default for when there's no KeybindingProvider at all (e.g. @@ -92,60 +77,60 @@ const DEFAULT_VOICE_KEYSTROKE: ParsedKeystroke = { shift: false, meta: false, super: false, -} +}; type InsertTextHandle = { - insert: (text: string) => void - setInputWithCursor: (value: string, cursor: number) => void - cursorOffset: number -} + insert: (text: string) => void; + setInputWithCursor: (value: string, cursor: number) => void; + cursorOffset: number; +}; type UseVoiceIntegrationArgs = { - setInputValueRaw: React.Dispatch> - inputValueRef: React.RefObject - insertTextRef: React.RefObject -} + setInputValueRaw: React.Dispatch>; + inputValueRef: React.RefObject; + insertTextRef: React.RefObject; +}; -type InterimRange = { start: number; end: number } +type InterimRange = { start: number; end: number }; type StripOpts = { // Which char to strip (the configured hold key). Defaults to space. - char?: string + char?: string; // Capture the voice prefix/suffix anchor at the stripped position. - anchor?: boolean + anchor?: boolean; // Minimum trailing count to leave behind — prevents stripping the // intentional warmup chars when defensively cleaning up leaks. - floor?: number -} + floor?: number; +}; type UseVoiceIntegrationResult = { // Returns the number of trailing chars remaining after stripping. - stripTrailing: (maxStrip: number, opts?: StripOpts) => number + stripTrailing: (maxStrip: number, opts?: StripOpts) => number; // Undo the gap space and reset anchor refs after a failed voice activation. - resetAnchor: () => void - handleKeyEvent: (fallbackMs?: number) => void - interimRange: InterimRange | null -} + resetAnchor: () => void; + handleKeyEvent: (fallbackMs?: number) => void; + interimRange: InterimRange | null; +}; export function useVoiceIntegration({ setInputValueRaw, inputValueRef, insertTextRef, }: UseVoiceIntegrationArgs): UseVoiceIntegrationResult { - const { addNotification } = useNotifications() + const { addNotification } = useNotifications(); // Tracks the input content before/after the cursor when voice starts, // so interim transcripts can be inserted at the cursor position without // clobbering surrounding user text. - const voicePrefixRef = useRef(null) - const voiceSuffixRef = useRef('') + const voicePrefixRef = useRef(null); + const voiceSuffixRef = useRef(''); // Tracks the last input value this hook wrote (via anchor, interim effect, // or handleVoiceTranscript). If inputValueRef.current diverges, the user // submitted or edited — both write paths bail to avoid clobbering. This is // the only guard that correctly handles empty-prefix-empty-suffix: a // startsWith('')/endsWith('') check vacuously passes, and a length check // can't distinguish a cleared input from a never-set one. - const lastSetInputRef = useRef(null) + const lastSetInputRef = useRef(null); // Strip trailing hold-key chars (and optionally capture the voice // anchor). Called during warmup (to clean up chars that leaked past @@ -160,29 +145,22 @@ export function useVoiceIntegration({ // trailing chars remaining after stripping. When nothing changes, no // state update is performed. const stripTrailing = useCallback( - ( - maxStrip: number, - { char = ' ', anchor = false, floor = 0 }: StripOpts = {}, - ) => { - const prev = inputValueRef.current - const offset = insertTextRef.current?.cursorOffset ?? prev.length - const beforeCursor = prev.slice(0, offset) - const afterCursor = prev.slice(offset) + (maxStrip: number, { char = ' ', anchor = false, floor = 0 }: StripOpts = {}) => { + const prev = inputValueRef.current; + const offset = insertTextRef.current?.cursorOffset ?? prev.length; + const beforeCursor = prev.slice(0, offset); + const afterCursor = prev.slice(offset); // When the hold key is space, also count full-width spaces (U+3000) // that a CJK IME may have inserted for the same physical key. // U+3000 is BMP single-code-unit so indices align with beforeCursor. - const scan = - char === ' ' ? normalizeFullWidthSpace(beforeCursor) : beforeCursor - let trailing = 0 - while ( - trailing < scan.length && - scan[scan.length - 1 - trailing] === char - ) { - trailing++ + const scan = char === ' ' ? normalizeFullWidthSpace(beforeCursor) : beforeCursor; + let trailing = 0; + while (trailing < scan.length && scan[scan.length - 1 - trailing] === char) { + trailing++; } - const stripCount = Math.max(0, Math.min(trailing - floor, maxStrip)) - const remaining = trailing - stripCount - const stripped = beforeCursor.slice(0, beforeCursor.length - stripCount) + const stripCount = Math.max(0, Math.min(trailing - floor, maxStrip)); + const remaining = trailing - stripCount; + const stripped = beforeCursor.slice(0, beforeCursor.length - stripCount); // When anchoring with a non-space suffix, insert a gap space so the // waveform cursor sits on the gap instead of covering the first // suffix letter. The interim transcript effect maintains this same @@ -192,26 +170,26 @@ export function useVoiceIntegration({ // voice (voiceState stayed 'idle'), the cleanup effect didn't fire and // the old anchor is stale. anchor=true is only passed on the single // activation call, never during recording, so overwrite is safe. - let gap = '' + let gap = ''; if (anchor) { - voicePrefixRef.current = stripped - voiceSuffixRef.current = afterCursor + voicePrefixRef.current = stripped; + voiceSuffixRef.current = afterCursor; if (afterCursor.length > 0 && !/^\s/.test(afterCursor)) { - gap = ' ' + gap = ' '; } } - const newValue = stripped + gap + afterCursor - if (anchor) lastSetInputRef.current = newValue - if (newValue === prev && stripCount === 0) return remaining + const newValue = stripped + gap + afterCursor; + if (anchor) lastSetInputRef.current = newValue; + if (newValue === prev && stripCount === 0) return remaining; if (insertTextRef.current) { - insertTextRef.current.setInputWithCursor(newValue, stripped.length) + insertTextRef.current.setInputWithCursor(newValue, stripped.length); } else { - setInputValueRaw(newValue) + setInputValueRaw(newValue); } - return remaining + return remaining; }, [setInputValueRaw, inputValueRef, insertTextRef], - ) + ); // Undo the gap space inserted by stripTrailing(..., {anchor:true}) and // reset the voice prefix/suffix refs. Called when voice activation fails @@ -220,123 +198,113 @@ export function useVoiceIntegration({ // reach the stale anchor. Without this, the gap space and stale refs // persist in the input. const resetAnchor = useCallback(() => { - const prefix = voicePrefixRef.current - if (prefix === null) return - const suffix = voiceSuffixRef.current - voicePrefixRef.current = null - voiceSuffixRef.current = '' - const restored = prefix + suffix + const prefix = voicePrefixRef.current; + if (prefix === null) return; + const suffix = voiceSuffixRef.current; + voicePrefixRef.current = null; + voiceSuffixRef.current = ''; + const restored = prefix + suffix; if (insertTextRef.current) { - insertTextRef.current.setInputWithCursor(restored, prefix.length) + insertTextRef.current.setInputWithCursor(restored, prefix.length); } else { - setInputValueRaw(restored) + setInputValueRaw(restored); } - }, [setInputValueRaw, insertTextRef]) + }, [setInputValueRaw, insertTextRef]); // Voice state selectors. useVoiceEnabled = user intent (settings) + // auth + GB kill-switch, with the auth half memoized on authVersion so // render loops never hit a cold keychain spawn. - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false + const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false; const voiceState = feature('VOICE_MODE') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useVoiceState(s => s.voiceState) - : ('idle' as const) + ? useVoiceState(s => s.voiceState) + : ('idle' as const); const voiceInterimTranscript = feature('VOICE_MODE') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useVoiceState(s => s.voiceInterimTranscript) - : '' + ? useVoiceState(s => s.voiceInterimTranscript) + : ''; // Set the voice anchor for focus mode (where recording starts via terminal // focus, not key hold). Key-hold sets the anchor in stripTrailing. useEffect(() => { - if (!feature('VOICE_MODE')) return + if (!feature('VOICE_MODE')) return; if (voiceState === 'recording' && voicePrefixRef.current === null) { - const input = inputValueRef.current - const offset = insertTextRef.current?.cursorOffset ?? input.length - voicePrefixRef.current = input.slice(0, offset) - voiceSuffixRef.current = input.slice(offset) - lastSetInputRef.current = input + const input = inputValueRef.current; + const offset = insertTextRef.current?.cursorOffset ?? input.length; + voicePrefixRef.current = input.slice(0, offset); + voiceSuffixRef.current = input.slice(offset); + lastSetInputRef.current = input; } if (voiceState === 'idle') { - voicePrefixRef.current = null - voiceSuffixRef.current = '' - lastSetInputRef.current = null + voicePrefixRef.current = null; + voiceSuffixRef.current = ''; + lastSetInputRef.current = null; } - }, [voiceState, inputValueRef, insertTextRef]) + }, [voiceState, inputValueRef, insertTextRef]); // Live-update the prompt input with the interim transcript as voice // transcribes speech. The prefix (user-typed text before the cursor) is // preserved and the transcript is inserted between prefix and suffix. useEffect(() => { - if (!feature('VOICE_MODE')) return - if (voicePrefixRef.current === null) return - const prefix = voicePrefixRef.current - const suffix = voiceSuffixRef.current + if (!feature('VOICE_MODE')) return; + if (voicePrefixRef.current === null) return; + const prefix = voicePrefixRef.current; + const suffix = voiceSuffixRef.current; // Submit race: if the input isn't what this hook last set it to, the // user submitted (clearing it) or edited it. voicePrefixRef is only // cleared on voiceState→idle, so it's still set during the 'processing' // window between CloseStream and WS close — this catches refined // TranscriptText arriving then and re-filling a cleared input. - if (inputValueRef.current !== lastSetInputRef.current) return - const needsSpace = - prefix.length > 0 && - !/\s$/.test(prefix) && - voiceInterimTranscript.length > 0 + if (inputValueRef.current !== lastSetInputRef.current) return; + const needsSpace = prefix.length > 0 && !/\s$/.test(prefix) && voiceInterimTranscript.length > 0; // Don't gate on voiceInterimTranscript.length -- when interim clears to '' // after handleVoiceTranscript sets the final text, the trailing space // between prefix and suffix must still be preserved. - const needsTrailingSpace = suffix.length > 0 && !/^\s/.test(suffix) - const leadingSpace = needsSpace ? ' ' : '' - const trailingSpace = needsTrailingSpace ? ' ' : '' - const newValue = - prefix + leadingSpace + voiceInterimTranscript + trailingSpace + suffix + const needsTrailingSpace = suffix.length > 0 && !/^\s/.test(suffix); + const leadingSpace = needsSpace ? ' ' : ''; + const trailingSpace = needsTrailingSpace ? ' ' : ''; + const newValue = prefix + leadingSpace + voiceInterimTranscript + trailingSpace + suffix; // Position cursor after the transcribed text (before suffix) - const cursorPos = - prefix.length + leadingSpace.length + voiceInterimTranscript.length + const cursorPos = prefix.length + leadingSpace.length + voiceInterimTranscript.length; if (insertTextRef.current) { - insertTextRef.current.setInputWithCursor(newValue, cursorPos) + insertTextRef.current.setInputWithCursor(newValue, cursorPos); } else { - setInputValueRaw(newValue) + setInputValueRaw(newValue); } - lastSetInputRef.current = newValue - }, [voiceInterimTranscript, setInputValueRaw, inputValueRef, insertTextRef]) + lastSetInputRef.current = newValue; + }, [voiceInterimTranscript, setInputValueRaw, inputValueRef, insertTextRef]); const handleVoiceTranscript = useCallback( (text: string) => { - if (!feature('VOICE_MODE')) return - const prefix = voicePrefixRef.current + if (!feature('VOICE_MODE')) return; + const prefix = voicePrefixRef.current; // No voice anchor — voice was reset (or never started). Nothing to do. - if (prefix === null) return - const suffix = voiceSuffixRef.current + if (prefix === null) return; + const suffix = voiceSuffixRef.current; // Submit race: finishRecording() → user presses Enter (input cleared) // → WebSocket close → this callback fires with stale prefix/suffix. // If the input isn't what this hook last set (via the interim effect // or anchor), the user submitted or edited — don't re-fill. Comparing // against `text.length` would false-positive when the final is longer // than the interim (ASR routinely adds punctuation/corrections). - if (inputValueRef.current !== lastSetInputRef.current) return - const needsSpace = - prefix.length > 0 && !/\s$/.test(prefix) && text.length > 0 - const needsTrailingSpace = - suffix.length > 0 && !/^\s/.test(suffix) && text.length > 0 - const leadingSpace = needsSpace ? ' ' : '' - const trailingSpace = needsTrailingSpace ? ' ' : '' - const newInput = prefix + leadingSpace + text + trailingSpace + suffix + if (inputValueRef.current !== lastSetInputRef.current) return; + const needsSpace = prefix.length > 0 && !/\s$/.test(prefix) && text.length > 0; + const needsTrailingSpace = suffix.length > 0 && !/^\s/.test(suffix) && text.length > 0; + const leadingSpace = needsSpace ? ' ' : ''; + const trailingSpace = needsTrailingSpace ? ' ' : ''; + const newInput = prefix + leadingSpace + text + trailingSpace + suffix; // Position cursor after the transcribed text (before suffix) - const cursorPos = prefix.length + leadingSpace.length + text.length + const cursorPos = prefix.length + leadingSpace.length + text.length; if (insertTextRef.current) { - insertTextRef.current.setInputWithCursor(newInput, cursorPos) + insertTextRef.current.setInputWithCursor(newInput, cursorPos); } else { - setInputValueRaw(newInput) + setInputValueRaw(newInput); } - lastSetInputRef.current = newInput + lastSetInputRef.current = newInput; // Update the prefix to include this chunk so focus mode can continue // appending subsequent transcripts after it. - voicePrefixRef.current = prefix + leadingSpace + text + voicePrefixRef.current = prefix + leadingSpace + text; }, [setInputValueRaw, inputValueRef, insertTextRef], - ) + ); const voice = voiceNs.useVoice({ onTranscript: handleVoiceTranscript, @@ -347,34 +315,31 @@ export function useVoiceIntegration({ color: 'error', priority: 'immediate', timeoutMs: 10_000, - }) + }); }, enabled: voiceEnabled, focusMode: false, - }) + }); // Compute the character range of interim (not-yet-finalized) transcript // text in the input value, so the UI can dim it. const interimRange = useMemo((): InterimRange | null => { - if (!feature('VOICE_MODE')) return null - if (voicePrefixRef.current === null) return null - if (voiceInterimTranscript.length === 0) return null - const prefix = voicePrefixRef.current - const needsSpace = - prefix.length > 0 && - !/\s$/.test(prefix) && - voiceInterimTranscript.length > 0 - const start = prefix.length + (needsSpace ? 1 : 0) - const end = start + voiceInterimTranscript.length - return { start, end } - }, [voiceInterimTranscript]) + if (!feature('VOICE_MODE')) return null; + if (voicePrefixRef.current === null) return null; + if (voiceInterimTranscript.length === 0) return null; + const prefix = voicePrefixRef.current; + const needsSpace = prefix.length > 0 && !/\s$/.test(prefix) && voiceInterimTranscript.length > 0; + const start = prefix.length + (needsSpace ? 1 : 0); + const end = start + voiceInterimTranscript.length; + return { start, end }; + }, [voiceInterimTranscript]); return { stripTrailing, resetAnchor, handleKeyEvent: voice.handleKeyEvent, interimRange, - } + }; } /** @@ -407,21 +372,19 @@ export function useVoiceKeybindingHandler({ resetAnchor, isActive, }: { - voiceHandleKeyEvent: (fallbackMs?: number) => void - stripTrailing: (maxStrip: number, opts?: StripOpts) => number - resetAnchor: () => void - isActive: boolean + voiceHandleKeyEvent: (fallbackMs?: number) => void; + stripTrailing: (maxStrip: number, opts?: StripOpts) => number; + resetAnchor: () => void; + isActive: boolean; }): { handleKeyDown: (e: KeyboardEvent) => void } { - const getVoiceState = useGetVoiceState() - const setVoiceState = useSetVoiceState() - const keybindingContext = useOptionalKeybindingContext() - const isModalOverlayActive = useIsModalOverlayActive() - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false + const getVoiceState = useGetVoiceState(); + const setVoiceState = useSetVoiceState(); + const keybindingContext = useOptionalKeybindingContext(); + const isModalOverlayActive = useIsModalOverlayActive(); + const voiceEnabled = feature('VOICE_MODE') ? useVoiceEnabled() : false; const voiceState = feature('VOICE_MODE') - ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useVoiceState(s => s.voiceState) - : 'idle' + ? useVoiceState(s => s.voiceState) + : 'idle'; // Find the configured key for voice:pushToTalk from keybinding context. // Forward iteration with last-wins (matching the resolver): if a later @@ -433,22 +396,22 @@ export function useVoiceKeybindingHandler({ // is also bound in Settings/Confirmation/Plugin (select:accept etc.); // without the filter those would null out the default. const voiceKeystroke = useMemo((): ParsedKeystroke | null => { - if (!keybindingContext) return DEFAULT_VOICE_KEYSTROKE - let result: ParsedKeystroke | null = null + if (!keybindingContext) return DEFAULT_VOICE_KEYSTROKE; + let result: ParsedKeystroke | null = null; for (const binding of keybindingContext.bindings) { - if (binding.context !== 'Chat') continue - if (binding.chord.length !== 1) continue - const ks = binding.chord[0] - if (!ks) continue + if (binding.context !== 'Chat') continue; + if (binding.chord.length !== 1) continue; + const ks = binding.chord[0]; + if (!ks) continue; if (binding.action === 'voice:pushToTalk') { - result = ks + result = ks; } else if (result !== null && keystrokesEqual(ks, result)) { // A later binding overrides this chord (null unbind or reassignment) - result = null + result = null; } } - return result - }, [keybindingContext]) + return result; + }, [keybindingContext]); // If the binding is a bare (unmodified) single printable char, terminal // auto-repeat may batch N keystrokes into one input event (e.g. "vvv"), @@ -465,9 +428,9 @@ export function useVoiceKeybindingHandler({ !voiceKeystroke.meta && !voiceKeystroke.super ? voiceKeystroke.key - : null + : null; - const rapidCountRef = useRef(0) + const rapidCountRef = useRef(0); // How many rapid chars we intentionally let through to the text // input (the first WARMUP_THRESHOLD). The activation strip removes // up to this many + the activation event's potential leak. For the @@ -476,15 +439,15 @@ export function useVoiceKeybindingHandler({ // one pre-existing char if the input already ended in the bound // letter (e.g. "hav" + hold "v" → "ha"). We don't track that // boundary — it's best-effort and the warning says so. - const charsInInputRef = useRef(0) + const charsInInputRef = useRef(0); // Trailing-char count remaining after the activation strip — these // belong to the user's anchored prefix and must be preserved during // recording's defensive leak cleanup. - const recordingFloorRef = useRef(0) + const recordingFloorRef = useRef(0); // True when the current recording was started by key-hold (not focus). // Used to avoid swallowing keypresses during focus-mode recording. - const isHoldActiveRef = useRef(false) - const resetTimerRef = useRef | null>(null) + const isHoldActiveRef = useRef(false); + const resetTimerRef = useRef | null>(null); // Reset hold state as soon as we leave 'recording'. The physical hold // ends when key-repeat stops (state → 'processing'); keeping the ref @@ -492,19 +455,19 @@ export function useVoiceKeybindingHandler({ // while the transcript finalizes. useEffect(() => { if (voiceState !== 'recording') { - isHoldActiveRef.current = false - rapidCountRef.current = 0 - charsInInputRef.current = 0 - recordingFloorRef.current = 0 + isHoldActiveRef.current = false; + rapidCountRef.current = 0; + charsInInputRef.current = 0; + recordingFloorRef.current = 0; setVoiceState(prev => { - if (!prev.voiceWarmingUp) return prev - return { ...prev, voiceWarmingUp: false } - }) + if (!prev.voiceWarmingUp) return prev; + return { ...prev, voiceWarmingUp: false }; + }); } - }, [voiceState, setVoiceState]) + }, [voiceState, setVoiceState]); const handleKeyDown = (e: KeyboardEvent): void => { - if (!voiceEnabled) return + if (!voiceEnabled) return; // PromptInput is not a valid transcript target — let the hold key // flow through instead of swallowing it into stale refs (#33556). @@ -514,37 +477,32 @@ export function useVoiceKeybindingHandler({ // /plugin. Mirrors CommandKeybindingHandlers' isActive gate. // - isModalOverlayActive: overlay (permission dialog, Select with // onCancel) has focus; PromptInput is mounted but focus=false. - if (!isActive || isModalOverlayActive) return + if (!isActive || isModalOverlayActive) return; // null means the user overrode the default (null-unbind/reassign) — // hold-to-talk is disabled via binding. To toggle the feature // itself, use /voice. - if (voiceKeystroke === null) return + if (voiceKeystroke === null) return; // Match the configured key. Bare chars match by content (handles // batched auto-repeat like "vvv") with a modifier reject so e.g. // ctrl+v doesn't trip a "v" binding. Modifier combos go through // matchesKeyboardEvent (one event per repeat, no batching). - let repeatCount: number + let repeatCount: number; if (bareChar !== null) { - if (e.ctrl || e.meta || e.shift) return + if (e.ctrl || e.meta || e.shift) return; // When bound to space, also accept U+3000 (full-width space) — // CJK IMEs emit it for the same physical key. - const normalized = - bareChar === ' ' ? normalizeFullWidthSpace(e.key) : e.key + const normalized = bareChar === ' ' ? normalizeFullWidthSpace(e.key) : e.key; // Fast-path: normal typing (any char that isn't the bound one) // bails here without allocating. The repeat() check only matters // for batched auto-repeat (input.length > 1) which is rare. - if (normalized[0] !== bareChar) return - if ( - normalized.length > 1 && - normalized !== bareChar.repeat(normalized.length) - ) - return - repeatCount = normalized.length + if (normalized[0] !== bareChar) return; + if (normalized.length > 1 && normalized !== bareChar.repeat(normalized.length)) return; + repeatCount = normalized.length; } else { - if (!matchesKeyboardEvent(e, voiceKeystroke)) return - repeatCount = 1 + if (!matchesKeyboardEvent(e, voiceKeystroke)) return; + repeatCount = 1; } // Guard: only swallow keypresses when recording was triggered by @@ -554,22 +512,22 @@ export function useVoiceKeybindingHandler({ // from the store so that if voiceHandleKeyEvent() fails to transition // state (module not loaded, stream unavailable) we don't permanently // swallow keypresses. - const currentVoiceState = getVoiceState().voiceState + const currentVoiceState = getVoiceState().voiceState; if (isHoldActiveRef.current && currentVoiceState !== 'idle') { // Already recording — swallow continued keypresses and forward // to voice for release detection. For bare chars, defensively // strip in case the text input handler fired before this one // (listener order is not guaranteed). Modifier combos don't // insert text, so nothing to strip. - e.stopImmediatePropagation() + e.stopImmediatePropagation(); if (bareChar !== null) { stripTrailing(repeatCount, { char: bareChar, floor: recordingFloorRef.current, - }) + }); } - voiceHandleKeyEvent() - return + voiceHandleKeyEvent(); + return; } // Non-hold recording (focus-mode) or processing is active. @@ -579,12 +537,12 @@ export function useVoiceKeybindingHandler({ // hit the warmup else-branch (swallow only). Bare chars flow through // unconditionally — user may be typing during focus-recording. if (currentVoiceState !== 'idle') { - if (bareChar === null) e.stopImmediatePropagation() - return + if (bareChar === null) e.stopImmediatePropagation(); + return; } - const countBefore = rapidCountRef.current - rapidCountRef.current += repeatCount + const countBefore = rapidCountRef.current; + rapidCountRef.current += repeatCount; // ── Activation ──────────────────────────────────────────── // Handled first so the warmup branch below does NOT also run @@ -594,37 +552,37 @@ export function useVoiceKeybindingHandler({ // typed accidentally, so the hold threshold (which exists to // distinguish typing a space from holding space) doesn't apply. if (bareChar === null || rapidCountRef.current >= HOLD_THRESHOLD) { - e.stopImmediatePropagation() + e.stopImmediatePropagation(); if (resetTimerRef.current) { - clearTimeout(resetTimerRef.current) - resetTimerRef.current = null + clearTimeout(resetTimerRef.current); + resetTimerRef.current = null; } - rapidCountRef.current = 0 - isHoldActiveRef.current = true + rapidCountRef.current = 0; + isHoldActiveRef.current = true; setVoiceState(prev => { - if (!prev.voiceWarmingUp) return prev - return { ...prev, voiceWarmingUp: false } - }) + if (!prev.voiceWarmingUp) return prev; + return { ...prev, voiceWarmingUp: false }; + }); if (bareChar !== null) { // Strip the intentional warmup chars plus this event's leak // (if text input fired first). Cap covers both; min(trailing) // handles the no-leak case. Anchor the voice prefix here. // The return value (remaining) becomes the floor for // recording-time leak cleanup. - recordingFloorRef.current = stripTrailing( - charsInInputRef.current + repeatCount, - { char: bareChar, anchor: true }, - ) - charsInInputRef.current = 0 - voiceHandleKeyEvent() + recordingFloorRef.current = stripTrailing(charsInInputRef.current + repeatCount, { + char: bareChar, + anchor: true, + }); + charsInInputRef.current = 0; + voiceHandleKeyEvent(); } else { // Modifier combo: nothing inserted, nothing to strip. Just // anchor the voice prefix at the current cursor position. // Longer fallback: this call is at t=0 (before auto-repeat), // so the gap to the next keypress is the OS initial repeat // *delay* (up to ~2s), not the repeat *rate* (~30-80ms). - stripTrailing(0, { anchor: true }) - voiceHandleKeyEvent(MODIFIER_FIRST_PRESS_FALLBACK_MS) + stripTrailing(0, { anchor: true }); + voiceHandleKeyEvent(MODIFIER_FIRST_PRESS_FALLBACK_MS); } // If voice failed to transition (module not loaded, stream // unavailable, stale enabled), clear the ref so a later @@ -633,10 +591,10 @@ export function useVoiceKeybindingHandler({ // immediate. The anchor set by stripTrailing above will // be overwritten on retry (anchor always overwrites now). if (getVoiceState().voiceState === 'idle') { - isHoldActiveRef.current = false - resetAnchor() + isHoldActiveRef.current = false; + resetAnchor(); } - return + return; } // ── Warmup (bare-char only; modifier combos activated above) ── @@ -649,43 +607,43 @@ export function useVoiceKeybindingHandler({ // no-op when nothing leaked. Check countBefore so the event that // crosses the threshold still flows through (terminal batching). if (countBefore >= WARMUP_THRESHOLD) { - e.stopImmediatePropagation() + e.stopImmediatePropagation(); stripTrailing(repeatCount, { char: bareChar, floor: charsInInputRef.current, - }) + }); } else { - charsInInputRef.current += repeatCount + charsInInputRef.current += repeatCount; } // Show warmup feedback once we detect a hold pattern if (rapidCountRef.current >= WARMUP_THRESHOLD) { setVoiceState(prev => { - if (prev.voiceWarmingUp) return prev - return { ...prev, voiceWarmingUp: true } - }) + if (prev.voiceWarmingUp) return prev; + return { ...prev, voiceWarmingUp: true }; + }); } if (resetTimerRef.current) { - clearTimeout(resetTimerRef.current) + clearTimeout(resetTimerRef.current); } resetTimerRef.current = setTimeout( (resetTimerRef, rapidCountRef, charsInInputRef, setVoiceState) => { - resetTimerRef.current = null - rapidCountRef.current = 0 - charsInInputRef.current = 0 + resetTimerRef.current = null; + rapidCountRef.current = 0; + charsInInputRef.current = 0; setVoiceState(prev => { - if (!prev.voiceWarmingUp) return prev - return { ...prev, voiceWarmingUp: false } - }) + if (!prev.voiceWarmingUp) return prev; + return { ...prev, voiceWarmingUp: false }; + }); }, RAPID_KEY_GAP_MS, resetTimerRef, rapidCountRef, charsInInputRef, setVoiceState, - ) - } + ); + }; // Backward-compat bridge: REPL.tsx doesn't yet wire handleKeyDown to // . Subscribe via useInput and adapt InputEvent → @@ -693,30 +651,30 @@ export function useVoiceKeybindingHandler({ // TODO(onKeyDown-migration): remove once REPL passes handleKeyDown. useInput( (_input, _key, event) => { - const kbEvent = new KeyboardEvent(event.keypress) - handleKeyDown(kbEvent) + const kbEvent = new KeyboardEvent(event.keypress); + handleKeyDown(kbEvent); // handleKeyDown stopped the adapter event, not the InputEvent the // emitter actually checks — forward it so the text input's useInput // listener is skipped and held spaces don't leak into the prompt. if (kbEvent.didStopImmediatePropagation()) { - event.stopImmediatePropagation() + event.stopImmediatePropagation(); } }, { isActive }, - ) + ); - return { handleKeyDown } + return { handleKeyDown }; } // TODO(onKeyDown-migration): temporary shim so existing JSX callers // () keep compiling. Remove once REPL.tsx // wires handleKeyDown directly. export function VoiceKeybindingHandler(props: { - voiceHandleKeyEvent: (fallbackMs?: number) => void - stripTrailing: (maxStrip: number, opts?: StripOpts) => number - resetAnchor: () => void - isActive: boolean + voiceHandleKeyEvent: (fallbackMs?: number) => void; + stripTrailing: (maxStrip: number, opts?: StripOpts) => number; + resetAnchor: () => void; + isActive: boolean; }): null { - useVoiceKeybindingHandler(props) - return null + useVoiceKeybindingHandler(props); + return null; } diff --git a/src/interactiveHelpers.tsx b/src/interactiveHelpers.tsx index 7c66edd1a..be78043f6 100644 --- a/src/interactiveHelpers.tsx +++ b/src/interactiveHelpers.tsx @@ -1,11 +1,8 @@ -import { feature } from 'bun:bundle' -import { appendFileSync } from 'fs' -import React from 'react' -import { logEvent } from 'src/services/analytics/index.js' -import { - gracefulShutdown, - gracefulShutdownSync, -} from 'src/utils/gracefulShutdown.js' +import { feature } from 'bun:bundle'; +import { appendFileSync } from 'fs'; +import React from 'react'; +import { logEvent } from 'src/services/analytics/index.js'; +import { gracefulShutdown, gracefulShutdownSync } from 'src/utils/gracefulShutdown.js'; import { type ChannelEntry, getAllowedChannels, @@ -13,64 +10,58 @@ import { setHasDevChannels, setSessionTrustAccepted, setStatsStore, -} from './bootstrap/state.js' -import type { Command } from './commands.js' -import { createStatsStore, type StatsStore } from './context/stats.js' -import { getSystemContext } from './context.js' -import { initializeTelemetryAfterTrust } from './entrypoints/init.js' -import { isSynchronizedOutputSupported } from '@anthropic/ink' -import type { RenderOptions, Root, TextProps } from '@anthropic/ink' -import { KeybindingSetup } from './keybindings/KeybindingProviderSetup.js' -import { startDeferredPrefetches } from './main.js' +} from './bootstrap/state.js'; +import type { Command } from './commands.js'; +import { createStatsStore, type StatsStore } from './context/stats.js'; +import { getSystemContext } from './context.js'; +import { initializeTelemetryAfterTrust } from './entrypoints/init.js'; +import { isSynchronizedOutputSupported } from '@anthropic/ink'; +import type { RenderOptions, Root, TextProps } from '@anthropic/ink'; +import { KeybindingSetup } from './keybindings/KeybindingProviderSetup.js'; +import { startDeferredPrefetches } from './main.js'; import { checkGate_CACHED_OR_BLOCKING, initializeGrowthBook, resetGrowthBook, -} from './services/analytics/growthbook.js' -import { isQualifiedForGrove } from './services/api/grove.js' -import { handleMcpjsonServerApprovals } from './services/mcpServerApproval.js' -import { AppStateProvider } from './state/AppState.js' -import { onChangeAppState } from './state/onChangeAppState.js' -import { normalizeApiKeyForConfig } from './utils/authPortable.js' +} from './services/analytics/growthbook.js'; +import { isQualifiedForGrove } from './services/api/grove.js'; +import { handleMcpjsonServerApprovals } from './services/mcpServerApproval.js'; +import { AppStateProvider } from './state/AppState.js'; +import { onChangeAppState } from './state/onChangeAppState.js'; +import { normalizeApiKeyForConfig } from './utils/authPortable.js'; import { getExternalClaudeMdIncludes, getMemoryFiles, shouldShowClaudeMdExternalIncludesWarning, -} from './utils/claudemd.js' +} from './utils/claudemd.js'; import { checkHasTrustDialogAccepted, getCustomApiKeyStatus, getGlobalConfig, saveGlobalConfig, -} from './utils/config.js' -import { updateDeepLinkTerminalPreference } from './utils/deepLink/terminalPreference.js' -import { isEnvTruthy, isRunningOnHomespace } from './utils/envUtils.js' -import { type FpsMetrics, FpsTracker } from './utils/fpsTracker.js' -import { updateGithubRepoPathMapping } from './utils/githubRepoPathMapping.js' -import { applyConfigEnvironmentVariables } from './utils/managedEnv.js' -import type { PermissionMode } from './utils/permissions/PermissionMode.js' -import { getBaseRenderOptions } from './utils/renderOptions.js' -import { getSettingsWithAllErrors } from './utils/settings/allErrors.js' -import { - hasAutoModeOptIn, - hasSkipDangerousModePermissionPrompt, -} from './utils/settings/settings.js' +} from './utils/config.js'; +import { updateDeepLinkTerminalPreference } from './utils/deepLink/terminalPreference.js'; +import { isEnvTruthy, isRunningOnHomespace } from './utils/envUtils.js'; +import { type FpsMetrics, FpsTracker } from './utils/fpsTracker.js'; +import { updateGithubRepoPathMapping } from './utils/githubRepoPathMapping.js'; +import { applyConfigEnvironmentVariables } from './utils/managedEnv.js'; +import type { PermissionMode } from './utils/permissions/PermissionMode.js'; +import { getBaseRenderOptions } from './utils/renderOptions.js'; +import { getSettingsWithAllErrors } from './utils/settings/allErrors.js'; +import { hasAutoModeOptIn, hasSkipDangerousModePermissionPrompt } from './utils/settings/settings.js'; export function completeOnboarding(): void { saveGlobalConfig(current => ({ ...current, hasCompletedOnboarding: true, lastOnboardingVersion: MACRO.VERSION, - })) + })); } -export function showDialog( - root: Root, - renderer: (done: (result: T) => void) => React.ReactNode, -): Promise { +export function showDialog(root: Root, renderer: (done: (result: T) => void) => React.ReactNode): Promise { return new Promise(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)); + }); } /** @@ -79,12 +70,8 @@ export function showDialog( * 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, -): Promise { - return exitWithMessage(root, message, { color: 'error', beforeExit }) +export async function exitWithError(root: Root, message: string, beforeExit?: () => Promise): Promise { + return exitWithMessage(root, message, { color: 'error', beforeExit }); } /** @@ -97,21 +84,19 @@ export async function exitWithMessage( root: Root, message: string, options?: { - color?: TextProps['color'] - exitCode?: number - beforeExit?: () => Promise + color?: TextProps['color']; + exitCode?: number; + beforeExit?: () => Promise; }, ): Promise { - const { Text } = await import('@anthropic/ink') - const color = options?.color - const exitCode = options?.exitCode ?? 1 - root.render( - color ? {message} : {message}, - ) - root.unmount() - await options?.beforeExit?.() + const { Text } = await import('@anthropic/ink'); + const color = options?.color; + const exitCode = options?.exitCode ?? 1; + root.render(color ? {message} : {message}); + root.unmount(); + await options?.beforeExit?.(); // eslint-disable-next-line custom-rules/no-process-exit -- exit after Ink unmount - process.exit(exitCode) + process.exit(exitCode); } /** @@ -127,21 +112,18 @@ export function showSetupDialog( {renderer(done)} - )) + )); } /** * 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 { - root.render(element) - startDeferredPrefetches() - await root.waitUntilExit() - await gracefulShutdown(0) +export async function renderAndRun(root: Root, element: React.ReactNode): Promise { + root.render(element); + startDeferredPrefetches(); + await root.waitUntilExit(); + await gracefulShutdown(0); } export async function showSetupScreens( @@ -157,29 +139,29 @@ export async function showSetupScreens( isEnvTruthy(false) || process.env.IS_DEMO // Skip onboarding in demo mode ) { - return false + return false; } - const config = getGlobalConfig() - let onboardingShown = false + const config = getGlobalConfig(); + let onboardingShown = false; if ( !config.theme || !config.hasCompletedOnboarding // always show onboarding at least once ) { - onboardingShown = true - const { Onboarding } = await import('./components/Onboarding.js') + onboardingShown = true; + const { Onboarding } = await import('./components/Onboarding.js'); await showSetupDialog( root, done => ( { - completeOnboarding() - void done() + completeOnboarding(); + void done(); }} /> ), { onChangeAppState }, - ) + ); } // Always show the trust dialog in interactive sessions, regardless of permission mode. @@ -193,83 +175,71 @@ export async function showSetupScreens( // If it returns true, the TrustDialog would auto-resolve regardless of // security features, so we can skip the dynamic import and render cycle. if (!checkHasTrustDialogAccepted()) { - const { TrustDialog } = await import( - './components/TrustDialog/TrustDialog.js' - ) - await showSetupDialog(root, done => ( - - )) + const { TrustDialog } = await import('./components/TrustDialog/TrustDialog.js'); + await showSetupDialog(root, done => ); } // Signal that trust has been verified for this session. // GrowthBook checks this to decide whether to include auth headers. - setSessionTrustAccepted(true) + setSessionTrustAccepted(true); // Reset and reinitialize GrowthBook after trust is established. // Defense for login/logout: clears any prior client so the next init // picks up fresh auth headers. - resetGrowthBook() - void initializeGrowthBook() + resetGrowthBook(); + void initializeGrowthBook(); // Now that trust is established, prefetch system context if it wasn't already - void getSystemContext() + void getSystemContext(); // If settings are valid, check for any mcp.json servers that need approval - const { errors: allErrors } = getSettingsWithAllErrors() + const { errors: allErrors } = getSettingsWithAllErrors(); if (allErrors.length === 0) { - await handleMcpjsonServerApprovals(root) + await handleMcpjsonServerApprovals(root); } // Check for claude.md includes that need approval if (await shouldShowClaudeMdExternalIncludesWarning()) { - const externalIncludes = getExternalClaudeMdIncludes( - await getMemoryFiles(true), - ) - const { ClaudeMdExternalIncludesDialog } = await import( - './components/ClaudeMdExternalIncludesDialog.js' - ) + const externalIncludes = getExternalClaudeMdIncludes(await getMemoryFiles(true)); + const { ClaudeMdExternalIncludesDialog } = await import('./components/ClaudeMdExternalIncludesDialog.js'); await showSetupDialog(root, done => ( - - )) + + )); } } // Track current repo path for teleport directory switching (fire-and-forget) // This must happen AFTER trust to prevent untrusted directories from poisoning the mapping - void updateGithubRepoPathMapping() + void updateGithubRepoPathMapping(); if (feature('LODESTONE')) { - updateDeepLinkTerminalPreference() + updateDeepLinkTerminalPreference(); } // Apply full environment variables after trust dialog is accepted OR in bypass mode // In bypass mode (CI/CD, automation), we trust the environment so apply all variables // In normal mode, this happens after the trust dialog is accepted // This includes potentially dangerous environment variables from untrusted sources - applyConfigEnvironmentVariables() + applyConfigEnvironmentVariables(); // Initialize telemetry after env vars are applied so OTEL endpoint env vars and // otelHeadersHelper (which requires trust to execute) are available. // Defer to next tick so the OTel dynamic import resolves after first render // instead of during the pre-render microtask queue. - setImmediate(() => initializeTelemetryAfterTrust()) + setImmediate(() => initializeTelemetryAfterTrust()); if (await isQualifiedForGrove()) { - const { GroveDialog } = await import('src/components/grove/Grove.js') + const { GroveDialog } = await import('src/components/grove/Grove.js'); const decision = await showSetupDialog(root, done => ( - )) + )); if (decision === 'escape') { - logEvent('tengu_grove_policy_exited', {}) - gracefulShutdownSync(0) - return false + logEvent('tengu_grove_policy_exited', {}); + gracefulShutdownSync(0); + return false; } } @@ -277,36 +247,24 @@ export async function showSetupScreens( // On homespace, ANTHROPIC_API_KEY is preserved in process.env for child // processes but ignored by Claude Code itself (see auth.ts). if (process.env.ANTHROPIC_API_KEY && !isRunningOnHomespace()) { - const customApiKeyTruncated = normalizeApiKeyForConfig( - process.env.ANTHROPIC_API_KEY, - ) - const keyStatus = getCustomApiKeyStatus(customApiKeyTruncated) + const customApiKeyTruncated = normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY); + const keyStatus = getCustomApiKeyStatus(customApiKeyTruncated); if (keyStatus === 'new') { - const { ApproveApiKey } = await import('./components/ApproveApiKey.js') + const { ApproveApiKey } = await import('./components/ApproveApiKey.js'); await showSetupDialog( root, - done => ( - - ), + done => , { onChangeAppState }, - ) + ); } } if ( - (permissionMode === 'bypassPermissions' || - allowDangerouslySkipPermissions) && + (permissionMode === 'bypassPermissions' || allowDangerouslySkipPermissions) && !hasSkipDangerousModePermissionPrompt() ) { - const { BypassPermissionsModeDialog } = await import( - './components/BypassPermissionsModeDialog.js' - ) - await showSetupDialog(root, done => ( - - )) + const { BypassPermissionsModeDialog } = await import('./components/BypassPermissionsModeDialog.js'); + await showSetupDialog(root, done => ); } if (feature('TRANSCRIPT_CLASSIFIER')) { @@ -315,16 +273,10 @@ export async function showSetupScreens( // 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' - ) + const { AutoModeOptInDialog } = await import('./components/AutoModeOptInDialog.js'); await showSetupDialog(root, done => ( - gracefulShutdownSync(1)} - declineExits - /> - )) + gracefulShutdownSync(1)} declineExits /> + )); } } @@ -342,15 +294,14 @@ export async function showSetupScreens( // 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 @@ -359,80 +310,65 @@ export async function showSetupScreens( // (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' - ) + const { DevChannelsDialog } = await import('./components/DevChannelsDialog.js'); await showSetupDialog(root, done => ( { // Mark dev entries per-entry so the allowlist bypass doesn't leak // to --channels entries when both flags are passed. - setAllowedChannels([ - ...getAllowedChannels(), - ...devChannels.map(c => ({ ...c, dev: true })), - ]) - setHasDevChannels(true) - void done() + setAllowedChannels([...getAllowedChannels(), ...devChannels.map(c => ({ ...c, dev: true }))]); + setHasDevChannels(true); + void done(); }} /> - )) + )); } } } // Show Chrome onboarding for first-time Claude in Chrome users - if ( - claudeInChrome && - !getGlobalConfig().hasCompletedClaudeInChromeOnboarding - ) { - const { ClaudeInChromeOnboarding } = await import( - './components/ClaudeInChromeOnboarding.js' - ) - await showSetupDialog(root, done => ( - - )) + if (claudeInChrome && !getGlobalConfig().hasCompletedClaudeInChromeOnboarding) { + const { ClaudeInChromeOnboarding } = await import('./components/ClaudeInChromeOnboarding.js'); + await showSetupDialog(root, 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 @@ -444,30 +380,30 @@ export function getRenderContext(exitOnCtrlC: boolean): { ...event.phases, rss: process.memoryUsage.rss(), cpu: process.cpuUsage(), - }) + '\n' + }) + '\n'; // eslint-disable-next-line custom-rules/no-sync-fs -- bench-only, sync so no frames dropped on exit - appendFileSync(frameTimingLogPath, line) + appendFileSync(frameTimingLogPath, line); } // Skip flicker reporting for terminals with synchronized output — // DEC 2026 buffers between BSU/ESU so clear+redraw is atomic. if (isSynchronizedOutputSupported()) { - return + return; } for (const flicker of event.flickers) { if (flicker.reason === 'resize') { - continue + continue; } - const now = Date.now() + const now = Date.now(); if (now - lastFlickerTime < 1000) { logEvent('tengu_flicker', { desiredHeight: flicker.desiredHeight, actualHeight: flicker.availableHeight, reason: flicker.reason, - } as unknown as Record) + } as unknown as Record); } - lastFlickerTime = now + lastFlickerTime = now; } }, }, - } + }; } diff --git a/src/keybindings/KeybindingContext.tsx b/src/keybindings/KeybindingContext.tsx index 10cbb8507..7ca92253b 100644 --- a/src/keybindings/KeybindingContext.tsx +++ b/src/keybindings/KeybindingContext.tsx @@ -4,4 +4,4 @@ export { useKeybindingContext, useOptionalKeybindingContext, useRegisterKeybindingContext, -} from '@anthropic/ink' +} from '@anthropic/ink'; diff --git a/src/keybindings/KeybindingProviderSetup.tsx b/src/keybindings/KeybindingProviderSetup.tsx index f288a7f65..d17ab334b 100644 --- a/src/keybindings/KeybindingProviderSetup.tsx +++ b/src/keybindings/KeybindingProviderSetup.tsx @@ -4,22 +4,22 @@ * Wires up app-specific dependencies (notification system, binding loading, * file watching, debug logging) and re-exports as KeybindingSetup. */ -import { useCallback } from 'react' -import { useNotifications } from '../context/notifications.js' -import { count } from '../utils/array.js' -import { logForDebugging } from '../utils/debug.js' -import { plural } from '../utils/stringUtils.js' -import { KeybindingSetup as InkKeybindingSetup } from '@anthropic/ink' -import type { KeybindingWarning } from '@anthropic/ink' +import { useCallback } from 'react'; +import { useNotifications } from '../context/notifications.js'; +import { count } from '../utils/array.js'; +import { logForDebugging } from '../utils/debug.js'; +import { plural } from '../utils/stringUtils.js'; +import { KeybindingSetup as InkKeybindingSetup } from '@anthropic/ink'; +import type { KeybindingWarning } from '@anthropic/ink'; import { initializeKeybindingWatcher, loadKeybindingsSyncWithWarnings, subscribeToKeybindingChanges, -} from './loadUserBindings.js' +} from './loadUserBindings.js'; type Props = { - children: React.ReactNode -} + children: React.ReactNode; +}; /** * Keybinding provider with default + user bindings and hot-reload support. @@ -42,29 +42,29 @@ type Props = { * - Chord support with automatic timeout */ export function KeybindingSetup({ children }: Props): React.ReactNode { - const { addNotification, removeNotification } = useNotifications() + const { addNotification, removeNotification } = useNotifications(); const handleWarnings = useCallback( (warnings: KeybindingWarning[], _isReload: boolean) => { - const notificationKey = 'keybinding-config-warning' + const notificationKey = 'keybinding-config-warning'; if (warnings.length === 0) { - removeNotification(notificationKey) - return + removeNotification(notificationKey); + return; } - const errorCount = count(warnings, w => w.severity === 'error') - const warnCount = count(warnings, w => w.severity === 'warning') + const errorCount = count(warnings, w => w.severity === 'error'); + const warnCount = count(warnings, w => w.severity === 'warning'); - let message: string + let message: string; if (errorCount > 0 && warnCount > 0) { - message = `Found ${errorCount} keybinding ${plural(errorCount, 'error')} and ${warnCount} ${plural(warnCount, 'warning')}` + message = `Found ${errorCount} keybinding ${plural(errorCount, 'error')} and ${warnCount} ${plural(warnCount, 'warning')}`; } else if (errorCount > 0) { - message = `Found ${errorCount} keybinding ${plural(errorCount, 'error')}` + message = `Found ${errorCount} keybinding ${plural(errorCount, 'error')}`; } else { - message = `Found ${warnCount} keybinding ${plural(warnCount, 'warning')}` + message = `Found ${warnCount} keybinding ${plural(warnCount, 'warning')}`; } - message += ' · /doctor for details' + message += ' · /doctor for details'; addNotification({ key: notificationKey, @@ -72,10 +72,10 @@ export function KeybindingSetup({ children }: Props): React.ReactNode { color: errorCount > 0 ? 'error' : 'warning', priority: errorCount > 0 ? 'immediate' : 'high', timeoutMs: 60000, - }) + }); }, [addNotification, removeNotification], - ) + ); return ( {children} - ) + ); } diff --git a/src/keybindings/src/utils/semver.ts b/src/keybindings/src/utils/semver.ts index 28b438729..b75e3c79a 100644 --- a/src/keybindings/src/utils/semver.ts +++ b/src/keybindings/src/utils/semver.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type satisfies = any; +export type satisfies = any diff --git a/src/main.tsx b/src/main.tsx index ecb8ff067..656304fb9 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,163 +6,146 @@ // key) in parallel — isRemoteManagedSettingsEligible() otherwise reads them // sequentially via sync spawn inside applySafeConfigEnvironmentVariables() // (~65ms on every macOS startup) -import { profileCheckpoint, profileReport } from "./utils/startupProfiler.js"; +import { profileCheckpoint, profileReport } from './utils/startupProfiler.js'; // eslint-disable-next-line custom-rules/no-top-level-side-effects -profileCheckpoint("main_tsx_entry"); +profileCheckpoint('main_tsx_entry'); -import { startMdmRawRead } from "./utils/settings/mdm/rawRead.js"; +import { startMdmRawRead } from './utils/settings/mdm/rawRead.js'; // eslint-disable-next-line custom-rules/no-top-level-side-effects startMdmRawRead(); -import { - ensureKeychainPrefetchCompleted, - startKeychainPrefetch, -} from "./utils/secureStorage/keychainPrefetch.js"; +import { ensureKeychainPrefetchCompleted, startKeychainPrefetch } from './utils/secureStorage/keychainPrefetch.js'; // eslint-disable-next-line custom-rules/no-top-level-side-effects startKeychainPrefetch(); -import { feature } from "bun:bundle"; +import { feature } from 'bun:bundle'; +import { Command as CommanderCommand, InvalidArgumentError, Option } from '@commander-js/extra-typings'; +import chalk from 'chalk'; +import { readFileSync } from 'fs'; +import mapValues from 'lodash-es/mapValues.js'; +import pickBy from 'lodash-es/pickBy.js'; +import uniqBy from 'lodash-es/uniqBy.js'; +import React from 'react'; +import { getOauthConfig } from './constants/oauth.js'; +import { getRemoteSessionUrl } from './constants/product.js'; +import { getSystemContext, getUserContext } from './context.js'; +import { init, initializeTelemetryAfterTrust } from './entrypoints/init.js'; +import { addToHistory } from './history.js'; +import type { Root } from '@anthropic/ink'; +import { launchRepl } from './replLauncher.js'; import { - Command as CommanderCommand, - InvalidArgumentError, - Option, -} from '@commander-js/extra-typings' -import chalk from 'chalk' -import { readFileSync } from 'fs' -import mapValues from 'lodash-es/mapValues.js' -import pickBy from 'lodash-es/pickBy.js' -import uniqBy from 'lodash-es/uniqBy.js' -import React from 'react' -import { getOauthConfig } from './constants/oauth.js' -import { getRemoteSessionUrl } from './constants/product.js' -import { getSystemContext, getUserContext } from './context.js' -import { init, initializeTelemetryAfterTrust } from './entrypoints/init.js' -import { addToHistory } from './history.js' -import type { Root } from '@anthropic/ink' -import { launchRepl } from './replLauncher.js' + hasGrowthBookEnvOverride, + initializeGrowthBook, + refreshGrowthBookAfterAuthChange, +} from './services/analytics/growthbook.js'; +import { fetchBootstrapData } from './services/api/bootstrap.js'; import { - hasGrowthBookEnvOverride, - initializeGrowthBook, - refreshGrowthBookAfterAuthChange, -} from "./services/analytics/growthbook.js"; -import { fetchBootstrapData } from "./services/api/bootstrap.js"; + type DownloadResult, + downloadSessionFiles, + type FilesApiConfig, + parseFileSpecs, +} from './services/api/filesApi.js'; +import { prefetchPassesEligibility } from './services/api/referral.js'; +import type { McpSdkServerConfig, McpServerConfig, ScopedMcpServerConfig } from './services/mcp/types.js'; import { - type DownloadResult, - downloadSessionFiles, - type FilesApiConfig, - parseFileSpecs, -} from "./services/api/filesApi.js"; -import { prefetchPassesEligibility } from "./services/api/referral.js"; -import type { - McpSdkServerConfig, - McpServerConfig, - ScopedMcpServerConfig, -} from "./services/mcp/types.js"; + isPolicyAllowed, + loadPolicyLimits, + refreshPolicyLimits, + waitForPolicyLimitsToLoad, +} from './services/policyLimits/index.js'; +import { loadRemoteManagedSettings, refreshRemoteManagedSettings } from './services/remoteManagedSettings/index.js'; +import type { ToolInputJSONSchema } from './Tool.js'; import { - isPolicyAllowed, - loadPolicyLimits, - refreshPolicyLimits, - waitForPolicyLimitsToLoad, -} from "./services/policyLimits/index.js"; + createSyntheticOutputTool, + isSyntheticOutputToolEnabled, +} from '@claude-code-best/builtin-tools/tools/SyntheticOutputTool/SyntheticOutputTool.js'; +import { getTools } from './tools.js'; import { - loadRemoteManagedSettings, - refreshRemoteManagedSettings, -} from "./services/remoteManagedSettings/index.js"; -import type { ToolInputJSONSchema } from "./Tool.js"; + canUserConfigureAdvisor, + getInitialAdvisorSetting, + isAdvisorEnabled, + isValidAdvisorModel, + modelSupportsAdvisor, +} from './utils/advisor.js'; +import { isAgentSwarmsEnabled } from './utils/agentSwarmsEnabled.js'; +import { count, uniq } from './utils/array.js'; +import { installAsciicastRecorder } from './utils/asciicast.js'; import { - createSyntheticOutputTool, - isSyntheticOutputToolEnabled, -} from "@claude-code-best/builtin-tools/tools/SyntheticOutputTool/SyntheticOutputTool.js"; -import { getTools } from "./tools.js"; + getSubscriptionType, + isClaudeAISubscriber, + prefetchAwsCredentialsAndBedRockInfoIfSafe, + prefetchGcpCredentialsIfSafe, + validateForceLoginOrg, +} from './utils/auth.js'; import { - canUserConfigureAdvisor, - getInitialAdvisorSetting, - isAdvisorEnabled, - isValidAdvisorModel, - modelSupportsAdvisor, -} from "./utils/advisor.js"; -import { isAgentSwarmsEnabled } from "./utils/agentSwarmsEnabled.js"; -import { count, uniq } from "./utils/array.js"; -import { installAsciicastRecorder } from "./utils/asciicast.js"; + checkHasTrustDialogAccepted, + getGlobalConfig, + getRemoteControlAtStartup, + isAutoUpdaterDisabled, + saveGlobalConfig, +} from './utils/config.js'; +import { seedEarlyInput, stopCapturingEarlyInput } from './utils/earlyInput.js'; +import { getInitialEffortSetting, parseEffortValue } from './utils/effort.js'; import { - getSubscriptionType, - isClaudeAISubscriber, - prefetchAwsCredentialsAndBedRockInfoIfSafe, - prefetchGcpCredentialsIfSafe, - validateForceLoginOrg, -} from "./utils/auth.js"; -import { - checkHasTrustDialogAccepted, - getGlobalConfig, - getRemoteControlAtStartup, - isAutoUpdaterDisabled, - saveGlobalConfig, -} from "./utils/config.js"; -import { seedEarlyInput, stopCapturingEarlyInput } from "./utils/earlyInput.js"; -import { getInitialEffortSetting, parseEffortValue } from "./utils/effort.js"; -import { - getInitialFastModeSetting, - isFastModeEnabled, - prefetchFastModeStatus, - resolveFastModeStatusFromCache, -} from "./utils/fastMode.js"; -import { applyConfigEnvironmentVariables } from "./utils/managedEnv.js"; -import { createSystemMessage, createUserMessage } from "./utils/messages.js"; -import { getPlatform } from "./utils/platform.js"; -import { getBaseRenderOptions } from "./utils/renderOptions.js"; -import { getSessionIngressAuthToken } from "./utils/sessionIngressAuth.js"; -import { settingsChangeDetector } from "./utils/settings/changeDetector.js"; -import { skillChangeDetector } from "./utils/skills/skillChangeDetector.js"; -import { jsonParse, writeFileSync_DEPRECATED } from "./utils/slowOperations.js"; -import { computeInitialTeamContext } from "./utils/swarm/reconnection.js"; -import { initializeWarningHandler } from "./utils/warningHandler.js"; -import { isWorktreeModeEnabled } from "./utils/worktreeModeEnabled.js"; + getInitialFastModeSetting, + isFastModeEnabled, + prefetchFastModeStatus, + resolveFastModeStatusFromCache, +} from './utils/fastMode.js'; +import { applyConfigEnvironmentVariables } from './utils/managedEnv.js'; +import { createSystemMessage, createUserMessage } from './utils/messages.js'; +import { getPlatform } from './utils/platform.js'; +import { getBaseRenderOptions } from './utils/renderOptions.js'; +import { getSessionIngressAuthToken } from './utils/sessionIngressAuth.js'; +import { settingsChangeDetector } from './utils/settings/changeDetector.js'; +import { skillChangeDetector } from './utils/skills/skillChangeDetector.js'; +import { jsonParse, writeFileSync_DEPRECATED } from './utils/slowOperations.js'; +import { computeInitialTeamContext } from './utils/swarm/reconnection.js'; +import { initializeWarningHandler } from './utils/warningHandler.js'; +import { isWorktreeModeEnabled } from './utils/worktreeModeEnabled.js'; // Lazy require to avoid circular dependency: teammate.ts -> AppState.tsx -> ... -> main.tsx /* eslint-disable @typescript-eslint/no-require-imports */ -const getTeammateUtils = () => - require("./utils/teammate.js") as typeof import("./utils/teammate.js"); +const getTeammateUtils = () => require('./utils/teammate.js') as typeof import('./utils/teammate.js'); const getTeammatePromptAddendum = () => - require("./utils/swarm/teammatePromptAddendum.js") as typeof import("./utils/swarm/teammatePromptAddendum.js"); + require('./utils/swarm/teammatePromptAddendum.js') as typeof import('./utils/swarm/teammatePromptAddendum.js'); const getTeammateModeSnapshot = () => - require("./utils/swarm/backends/teammateModeSnapshot.js") as typeof import("./utils/swarm/backends/teammateModeSnapshot.js"); + require('./utils/swarm/backends/teammateModeSnapshot.js') as typeof import('./utils/swarm/backends/teammateModeSnapshot.js'); /* eslint-enable @typescript-eslint/no-require-imports */ // Dead code elimination: conditional import for COORDINATOR_MODE /* eslint-disable @typescript-eslint/no-require-imports */ -const coordinatorModeModule = feature("COORDINATOR_MODE") - ? (require("./coordinator/coordinatorMode.js") as typeof import("./coordinator/coordinatorMode.js")) - : null; +const coordinatorModeModule = feature('COORDINATOR_MODE') + ? (require('./coordinator/coordinatorMode.js') as typeof import('./coordinator/coordinatorMode.js')) + : null; /* eslint-enable @typescript-eslint/no-require-imports */ // Dead code elimination: conditional import for KAIROS (assistant mode) /* eslint-disable @typescript-eslint/no-require-imports */ -const assistantModule = feature("KAIROS") - ? (require("./assistant/index.js") as typeof import("./assistant/index.js")) - : null; -const kairosGate = feature("KAIROS") - ? (require("./assistant/gate.js") as typeof import("./assistant/gate.js")) - : null; +const assistantModule = feature('KAIROS') + ? (require('./assistant/index.js') as typeof import('./assistant/index.js')) + : null; +const kairosGate = feature('KAIROS') ? (require('./assistant/gate.js') as typeof import('./assistant/gate.js')) : null; -import { relative, resolve } from "path"; -import { isAnalyticsDisabled } from "src/services/analytics/config.js"; -import { getFeatureValue_CACHED_MAY_BE_STALE } from "src/services/analytics/growthbook.js"; +import { relative, resolve } from 'path'; +import { isAnalyticsDisabled } from 'src/services/analytics/config.js'; +import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from 'src/services/analytics/index.js' -import { initializeAnalyticsGates } from 'src/services/analytics/sink.js' +} from 'src/services/analytics/index.js'; +import { initializeAnalyticsGates } from 'src/services/analytics/sink.js'; import { - getOriginalCwd, - setAdditionalDirectoriesForClaudeMd, - setIsRemoteMode, - setMainLoopModelOverride, - setMainThreadAgentType, - setTeleportedSessionInfo, -} from "./bootstrap/state.js"; -import { filterCommandsForRemoteMode, getCommands } from "./commands.js"; -import type { StatsStore } from "./context/stats.js"; + getOriginalCwd, + setAdditionalDirectoriesForClaudeMd, + setIsRemoteMode, + setMainLoopModelOverride, + setMainThreadAgentType, + setTeleportedSessionInfo, +} from './bootstrap/state.js'; +import { filterCommandsForRemoteMode, getCommands } from './commands.js'; +import type { StatsStore } from './context/stats.js'; import { launchAssistantInstallWizard, launchAssistantSessionChooser, @@ -171,284 +154,214 @@ import { launchSnapshotUpdateDialog, launchTeleportRepoMismatchDialog, launchTeleportResumeWrapper, -} from './dialogLaunchers.js' -import { SHOW_CURSOR } from '@anthropic/ink' +} from './dialogLaunchers.js'; +import { SHOW_CURSOR } from '@anthropic/ink'; import { - exitWithError, - exitWithMessage, - getRenderContext, - renderAndRun, - showSetupScreens, -} from "./interactiveHelpers.js"; -import { initBuiltinPlugins } from "./plugins/bundled/index.js"; + exitWithError, + exitWithMessage, + getRenderContext, + renderAndRun, + showSetupScreens, +} from './interactiveHelpers.js'; +import { initBuiltinPlugins } from './plugins/bundled/index.js'; /* eslint-enable @typescript-eslint/no-require-imports */ -import { checkQuotaStatus } from "./services/claudeAiLimits.js"; +import { checkQuotaStatus } from './services/claudeAiLimits.js'; +import { getMcpToolsCommandsAndResources, prefetchAllMcpResources } from './services/mcp/client.js'; +import { VALID_INSTALLABLE_SCOPES, VALID_UPDATE_SCOPES } from './services/plugins/pluginCliCommands.js'; +import { initBundledSkills } from './skills/bundled/index.js'; +import type { AgentColorName } from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js'; import { - getMcpToolsCommandsAndResources, - prefetchAllMcpResources, -} from "./services/mcp/client.js"; + getActiveAgentsFromList, + getAgentDefinitionsWithOverrides, + isBuiltInAgent, + isCustomAgent, + parseAgentsFromJson, +} from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'; +import type { LogOption } from './types/logs.js'; +import type { Message as MessageType } from './types/message.js'; import { - VALID_INSTALLABLE_SCOPES, - VALID_UPDATE_SCOPES, -} from "./services/plugins/pluginCliCommands.js"; -import { initBundledSkills } from "./skills/bundled/index.js"; -import type { AgentColorName } from "@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js"; + CLAUDE_IN_CHROME_SKILL_HINT, + CLAUDE_IN_CHROME_SKILL_HINT_WITH_WEBBROWSER, +} from './utils/claudeInChrome/prompt.js'; import { - getActiveAgentsFromList, - getAgentDefinitionsWithOverrides, - isBuiltInAgent, - isCustomAgent, - parseAgentsFromJson, -} from "@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js"; -import type { LogOption } from "./types/logs.js"; -import type { Message as MessageType } from "./types/message.js"; + setupClaudeInChrome, + shouldAutoEnableClaudeInChrome, + shouldEnableClaudeInChrome, +} from './utils/claudeInChrome/setup.js'; +import { getContextWindowForModel } from './utils/context.js'; +import { loadConversationForResume } from './utils/conversationRecovery.js'; +import { buildDeepLinkBanner } from './utils/deepLink/banner.js'; +import { hasNodeOption, isBareMode, isEnvTruthy, isInProtectedNamespace } from './utils/envUtils.js'; +import { refreshExampleCommands } from './utils/exampleCommands.js'; +import type { FpsMetrics } from './utils/fpsTracker.js'; +import { getWorktreePaths } from './utils/getWorktreePaths.js'; +import { findGitRoot, getBranch, getIsGit, getWorktreeCount } from './utils/git.js'; +import { getGhAuthStatus } from './utils/github/ghAuthStatus.js'; +import { safeParseJSON } from './utils/json.js'; +import { logError } from './utils/log.js'; +import { getModelDeprecationWarning } from './utils/model/deprecation.js'; import { - CLAUDE_IN_CHROME_SKILL_HINT, - CLAUDE_IN_CHROME_SKILL_HINT_WITH_WEBBROWSER, -} from "./utils/claudeInChrome/prompt.js"; + getDefaultMainLoopModel, + getUserSpecifiedModelSetting, + normalizeModelStringForAPI, + parseUserSpecifiedModel, +} from './utils/model/model.js'; +import { ensureModelStringsInitialized } from './utils/model/modelStrings.js'; +import { PERMISSION_MODES } from './utils/permissions/PermissionMode.js'; import { - setupClaudeInChrome, - shouldAutoEnableClaudeInChrome, - shouldEnableClaudeInChrome, -} from "./utils/claudeInChrome/setup.js"; -import { getContextWindowForModel } from "./utils/context.js"; -import { loadConversationForResume } from "./utils/conversationRecovery.js"; -import { buildDeepLinkBanner } from "./utils/deepLink/banner.js"; + checkAndDisableBypassPermissions, + getAutoModeEnabledStateIfCached, + initializeToolPermissionContext, + initialPermissionModeFromCLI, + isDefaultPermissionModeAuto, + parseToolListFromCLI, + removeDangerousPermissions, + stripDangerousPermissionsForAutoMode, + verifyAutoModeGateAccess, +} from './utils/permissions/permissionSetup.js'; +import { cleanupOrphanedPluginVersionsInBackground } from './utils/plugins/cacheUtils.js'; +import { initializeVersionedPlugins } from './utils/plugins/installedPluginsManager.js'; +import { getManagedPluginNames } from './utils/plugins/managedPlugins.js'; +import { getGlobExclusionsForPluginCache } from './utils/plugins/orphanedPluginFilter.js'; +import { getPluginSeedDirs } from './utils/plugins/pluginDirectories.js'; +import { countFilesRoundedRg } from './utils/ripgrep.js'; +import { processSessionStartHooks, processSetupHooks } from './utils/sessionStart.js'; import { - hasNodeOption, - isBareMode, - isEnvTruthy, - isInProtectedNamespace, -} from "./utils/envUtils.js"; -import { refreshExampleCommands } from "./utils/exampleCommands.js"; -import type { FpsMetrics } from "./utils/fpsTracker.js"; -import { getWorktreePaths } from "./utils/getWorktreePaths.js"; + cacheSessionTitle, + getSessionIdFromLog, + loadTranscriptFromFile, + saveAgentSetting, + saveMode, + searchSessionsByCustomTitle, + sessionIdExists, +} from './utils/sessionStorage.js'; +import { ensureMdmSettingsLoaded } from './utils/settings/mdm/settings.js'; import { - findGitRoot, - getBranch, - getIsGit, - getWorktreeCount, -} from "./utils/git.js"; -import { getGhAuthStatus } from "./utils/github/ghAuthStatus.js"; -import { safeParseJSON } from "./utils/json.js"; -import { logError } from "./utils/log.js"; -import { getModelDeprecationWarning } from "./utils/model/deprecation.js"; -import { - getDefaultMainLoopModel, - getUserSpecifiedModelSetting, - normalizeModelStringForAPI, - parseUserSpecifiedModel, -} from "./utils/model/model.js"; -import { ensureModelStringsInitialized } from "./utils/model/modelStrings.js"; -import { PERMISSION_MODES } from "./utils/permissions/PermissionMode.js"; -import { - checkAndDisableBypassPermissions, - getAutoModeEnabledStateIfCached, - initializeToolPermissionContext, - initialPermissionModeFromCLI, - isDefaultPermissionModeAuto, - parseToolListFromCLI, - removeDangerousPermissions, - stripDangerousPermissionsForAutoMode, - verifyAutoModeGateAccess, -} from "./utils/permissions/permissionSetup.js"; -import { cleanupOrphanedPluginVersionsInBackground } from "./utils/plugins/cacheUtils.js"; -import { initializeVersionedPlugins } from "./utils/plugins/installedPluginsManager.js"; -import { getManagedPluginNames } from "./utils/plugins/managedPlugins.js"; -import { getGlobExclusionsForPluginCache } from "./utils/plugins/orphanedPluginFilter.js"; -import { getPluginSeedDirs } from "./utils/plugins/pluginDirectories.js"; -import { countFilesRoundedRg } from "./utils/ripgrep.js"; -import { - processSessionStartHooks, - processSetupHooks, -} from "./utils/sessionStart.js"; -import { - cacheSessionTitle, - getSessionIdFromLog, - loadTranscriptFromFile, - saveAgentSetting, - saveMode, - searchSessionsByCustomTitle, - sessionIdExists, -} from "./utils/sessionStorage.js"; -import { ensureMdmSettingsLoaded } from "./utils/settings/mdm/settings.js"; -import { - getInitialSettings, - getManagedSettingsKeysForLogging, - getSettingsForSource, - getSettingsWithErrors, -} from "./utils/settings/settings.js"; -import { resetSettingsCache } from "./utils/settings/settingsCache.js"; -import type { ValidationError } from "./utils/settings/validation.js"; -import { - DEFAULT_TASKS_MODE_TASK_LIST_ID, - TASK_STATUSES, -} from "./utils/tasks.js"; -import { - logPluginLoadErrors, - logPluginsEnabledForSession, -} from "./utils/telemetry/pluginTelemetry.js"; -import { logSkillsLoaded } from "./utils/telemetry/skillLoadedEvent.js"; -import { generateTempFilePath } from "./utils/tempfile.js"; -import { validateUuid } from "./utils/uuid.js"; + getInitialSettings, + getManagedSettingsKeysForLogging, + getSettingsForSource, + getSettingsWithErrors, +} from './utils/settings/settings.js'; +import { resetSettingsCache } from './utils/settings/settingsCache.js'; +import type { ValidationError } from './utils/settings/validation.js'; +import { DEFAULT_TASKS_MODE_TASK_LIST_ID, TASK_STATUSES } from './utils/tasks.js'; +import { logPluginLoadErrors, logPluginsEnabledForSession } from './utils/telemetry/pluginTelemetry.js'; +import { logSkillsLoaded } from './utils/telemetry/skillLoadedEvent.js'; +import { generateTempFilePath } from './utils/tempfile.js'; +import { validateUuid } from './utils/uuid.js'; // Plugin startup checks are now handled non-blockingly in REPL.tsx -import { registerMcpAddCommand } from "src/commands/mcp/addCommand.js"; -import { registerMcpXaaIdpCommand } from "src/commands/mcp/xaaIdpCommand.js"; -import { logPermissionContextForAnts } from "src/services/internalLogging.js"; -import { fetchClaudeAIMcpConfigsIfEligible } from "src/services/mcp/claudeai.js"; -import { clearServerCache } from "src/services/mcp/client.js"; +import { registerMcpAddCommand } from 'src/commands/mcp/addCommand.js'; +import { registerMcpXaaIdpCommand } from 'src/commands/mcp/xaaIdpCommand.js'; +import { logPermissionContextForAnts } from 'src/services/internalLogging.js'; +import { fetchClaudeAIMcpConfigsIfEligible } from 'src/services/mcp/claudeai.js'; +import { clearServerCache } from 'src/services/mcp/client.js'; import { - areMcpConfigsAllowedWithEnterpriseMcpConfig, - dedupClaudeAiMcpServers, - doesEnterpriseMcpConfigExist, - filterMcpServersByPolicy, - getClaudeCodeMcpConfigs, - getMcpServerSignature, - parseMcpConfig, - parseMcpConfigFromFilePath, -} from "src/services/mcp/config.js"; + areMcpConfigsAllowedWithEnterpriseMcpConfig, + dedupClaudeAiMcpServers, + doesEnterpriseMcpConfigExist, + filterMcpServersByPolicy, + getClaudeCodeMcpConfigs, + getMcpServerSignature, + parseMcpConfig, + parseMcpConfigFromFilePath, +} from 'src/services/mcp/config.js'; +import { excludeCommandsByServer, excludeResourcesByServer } from 'src/services/mcp/utils.js'; +import { isXaaEnabled } from 'src/services/mcp/xaaIdpLogin.js'; +import { getRelevantTips } from 'src/services/tips/tipRegistry.js'; +import { logContextMetrics } from 'src/utils/api.js'; +import { CLAUDE_IN_CHROME_MCP_SERVER_NAME, isClaudeInChromeMCPServer } from 'src/utils/claudeInChrome/common.js'; +import { registerCleanup } from 'src/utils/cleanupRegistry.js'; +import { eagerParseCliFlag } from 'src/utils/cliArgs.js'; +import { createEmptyAttributionState } from 'src/utils/commitAttribution.js'; +import { countConcurrentSessions, registerSession, updateSessionName } from 'src/utils/concurrentSessions.js'; +import { getCwd } from 'src/utils/cwd.js'; +import { logForDebugging, setHasFormattedOutput } from 'src/utils/debug.js'; +import { errorMessage, getErrnoCode, isENOENT, TeleportOperationError, toError } from 'src/utils/errors.js'; +import { getFsImplementation, safeResolvePath } from 'src/utils/fsOperations.js'; +import { gracefulShutdown, gracefulShutdownSync } from 'src/utils/gracefulShutdown.js'; +import { setAllHookEventsEnabled } from 'src/utils/hooks/hookEvents.js'; +import { refreshModelCapabilities } from 'src/utils/model/modelCapabilities.js'; +import { peekForStdinData, writeToStderr } from 'src/utils/process.js'; +import { setCwd } from 'src/utils/Shell.js'; +import { type ProcessedResume, processResumedConversation } from 'src/utils/sessionRestore.js'; +import { parseSettingSourcesFlag } from 'src/utils/settings/constants.js'; +import { plural } from 'src/utils/stringUtils.js'; import { - excludeCommandsByServer, - excludeResourcesByServer, -} from "src/services/mcp/utils.js"; -import { isXaaEnabled } from "src/services/mcp/xaaIdpLogin.js"; -import { getRelevantTips } from "src/services/tips/tipRegistry.js"; -import { logContextMetrics } from "src/utils/api.js"; -import { - CLAUDE_IN_CHROME_MCP_SERVER_NAME, - isClaudeInChromeMCPServer, -} from "src/utils/claudeInChrome/common.js"; -import { registerCleanup } from "src/utils/cleanupRegistry.js"; -import { eagerParseCliFlag } from "src/utils/cliArgs.js"; -import { createEmptyAttributionState } from "src/utils/commitAttribution.js"; -import { - countConcurrentSessions, - registerSession, - updateSessionName, -} from "src/utils/concurrentSessions.js"; -import { getCwd } from "src/utils/cwd.js"; -import { logForDebugging, setHasFormattedOutput } from "src/utils/debug.js"; -import { - errorMessage, - getErrnoCode, - isENOENT, - TeleportOperationError, - toError, -} from "src/utils/errors.js"; -import { - getFsImplementation, - safeResolvePath, -} from "src/utils/fsOperations.js"; -import { - gracefulShutdown, - gracefulShutdownSync, -} from "src/utils/gracefulShutdown.js"; -import { setAllHookEventsEnabled } from "src/utils/hooks/hookEvents.js"; -import { refreshModelCapabilities } from "src/utils/model/modelCapabilities.js"; -import { peekForStdinData, writeToStderr } from "src/utils/process.js"; -import { setCwd } from "src/utils/Shell.js"; -import { - type ProcessedResume, - processResumedConversation, -} from "src/utils/sessionRestore.js"; -import { parseSettingSourcesFlag } from "src/utils/settings/constants.js"; -import { plural } from "src/utils/stringUtils.js"; -import { - type ChannelEntry, - getInitialMainLoopModel, - getIsNonInteractiveSession, - getSdkBetas, - getSessionId, - getUserMsgOptIn, - setAllowedChannels, - setAllowedSettingSources, - setChromeFlagOverride, - setClientType, - setCwdState, - setDirectConnectServerUrl, - setFlagSettingsPath, - setInitialMainLoopModel, - setInlinePlugins, - setIsInteractive, - setKairosActive, - setOriginalCwd, - setQuestionPreviewFormat, - setSdkBetas, - setSessionBypassPermissionsMode, - setSessionPersistenceDisabled, - setSessionSource, - setUserMsgOptIn, - switchSession, -} from "./bootstrap/state.js"; + type ChannelEntry, + getInitialMainLoopModel, + getIsNonInteractiveSession, + getSdkBetas, + getSessionId, + getUserMsgOptIn, + setAllowedChannels, + setAllowedSettingSources, + setChromeFlagOverride, + setClientType, + setCwdState, + setDirectConnectServerUrl, + setFlagSettingsPath, + setInitialMainLoopModel, + setInlinePlugins, + setIsInteractive, + setKairosActive, + setOriginalCwd, + setQuestionPreviewFormat, + setSdkBetas, + setSessionBypassPermissionsMode, + setSessionPersistenceDisabled, + setSessionSource, + setUserMsgOptIn, + switchSession, +} from './bootstrap/state.js'; /* eslint-disable @typescript-eslint/no-require-imports */ -const autoModeStateModule = feature("TRANSCRIPT_CLASSIFIER") - ? (require("./utils/permissions/autoModeState.js") as typeof import("./utils/permissions/autoModeState.js")) - : null; +const autoModeStateModule = feature('TRANSCRIPT_CLASSIFIER') + ? (require('./utils/permissions/autoModeState.js') as typeof import('./utils/permissions/autoModeState.js')) + : null; // TeleportRepoMismatchDialog, TeleportResumeWrapper dynamically imported at call sites -import { migrateBypassPermissionsAcceptedToSettings } from "./migrations/migrateBypassPermissionsAcceptedToSettings.js"; -import { migrateEnableAllProjectMcpServersToSettings } from "./migrations/migrateEnableAllProjectMcpServersToSettings.js"; -import { migrateFennecToOpus } from "./migrations/migrateFennecToOpus.js"; -import { migrateLegacyOpusToCurrent } from "./migrations/migrateLegacyOpusToCurrent.js"; -import { migrateOpusToOpus1m } from "./migrations/migrateOpusToOpus1m.js"; -import { migrateReplBridgeEnabledToRemoteControlAtStartup } from "./migrations/migrateReplBridgeEnabledToRemoteControlAtStartup.js"; -import { migrateSonnet1mToSonnet45 } from "./migrations/migrateSonnet1mToSonnet45.js"; -import { migrateSonnet45ToSonnet46 } from "./migrations/migrateSonnet45ToSonnet46.js"; -import { resetAutoModeOptInForDefaultOffer } from "./migrations/resetAutoModeOptInForDefaultOffer.js"; -import { resetProToOpusDefault } from "./migrations/resetProToOpusDefault.js"; -import { createRemoteSessionConfig } from "./remote/RemoteSessionManager.js"; +import { migrateBypassPermissionsAcceptedToSettings } from './migrations/migrateBypassPermissionsAcceptedToSettings.js'; +import { migrateEnableAllProjectMcpServersToSettings } from './migrations/migrateEnableAllProjectMcpServersToSettings.js'; +import { migrateFennecToOpus } from './migrations/migrateFennecToOpus.js'; +import { migrateLegacyOpusToCurrent } from './migrations/migrateLegacyOpusToCurrent.js'; +import { migrateOpusToOpus1m } from './migrations/migrateOpusToOpus1m.js'; +import { migrateReplBridgeEnabledToRemoteControlAtStartup } from './migrations/migrateReplBridgeEnabledToRemoteControlAtStartup.js'; +import { migrateSonnet1mToSonnet45 } from './migrations/migrateSonnet1mToSonnet45.js'; +import { migrateSonnet45ToSonnet46 } from './migrations/migrateSonnet45ToSonnet46.js'; +import { resetAutoModeOptInForDefaultOffer } from './migrations/resetAutoModeOptInForDefaultOffer.js'; +import { resetProToOpusDefault } from './migrations/resetProToOpusDefault.js'; +import { createRemoteSessionConfig } from './remote/RemoteSessionManager.js'; /* eslint-enable @typescript-eslint/no-require-imports */ // teleportWithProgress dynamically imported at call site +import { createDirectConnectSession, DirectConnectError } from './server/createDirectConnectSession.js'; +import { initializeLspServerManager } from './services/lsp/manager.js'; +import { shouldEnablePromptSuggestion } from './services/PromptSuggestion/promptSuggestion.js'; +import { type AppState, getDefaultAppState, IDLE_SPECULATION_STATE } from './state/AppStateStore.js'; +import { onChangeAppState } from './state/onChangeAppState.js'; +import { createStore } from './state/store.js'; +import { asSessionId } from './types/ids.js'; +import { filterAllowedSdkBetas } from './utils/betas.js'; +import { isInBundledMode, isRunningWithBun } from './utils/bundledMode.js'; +import { logForDiagnosticsNoPII } from './utils/diagLogs.js'; +import { filterExistingPaths, getKnownPathsForRepo } from './utils/githubRepoPathMapping.js'; +import { clearPluginCache, loadAllPluginsCacheOnly } from './utils/plugins/pluginLoader.js'; +import { migrateChangelogFromConfig } from './utils/releaseNotes.js'; +import { SandboxManager } from './utils/sandbox/sandbox-adapter.js'; +import { fetchSession, prepareApiRequest } from './utils/teleport/api.js'; import { - createDirectConnectSession, - DirectConnectError, -} from "./server/createDirectConnectSession.js"; -import { initializeLspServerManager } from "./services/lsp/manager.js"; -import { shouldEnablePromptSuggestion } from "./services/PromptSuggestion/promptSuggestion.js"; -import { - type AppState, - getDefaultAppState, - IDLE_SPECULATION_STATE, -} from "./state/AppStateStore.js"; -import { onChangeAppState } from "./state/onChangeAppState.js"; -import { createStore } from "./state/store.js"; -import { asSessionId } from "./types/ids.js"; -import { filterAllowedSdkBetas } from "./utils/betas.js"; -import { isInBundledMode, isRunningWithBun } from "./utils/bundledMode.js"; -import { logForDiagnosticsNoPII } from "./utils/diagLogs.js"; -import { - filterExistingPaths, - getKnownPathsForRepo, -} from "./utils/githubRepoPathMapping.js"; -import { - clearPluginCache, - loadAllPluginsCacheOnly, -} from "./utils/plugins/pluginLoader.js"; -import { migrateChangelogFromConfig } from "./utils/releaseNotes.js"; -import { SandboxManager } from "./utils/sandbox/sandbox-adapter.js"; -import { fetchSession, prepareApiRequest } from "./utils/teleport/api.js"; -import { - checkOutTeleportedSessionBranch, - processMessagesForTeleportResume, - teleportToRemoteWithErrorHandling, - validateGitState, - validateSessionRepository, -} from "./utils/teleport.js"; -import { - shouldEnableThinkingByDefault, - type ThinkingConfig, -} from './utils/thinking.js' -import { initUser, resetUserCache } from './utils/user.js' -import { - getTmuxInstallInstructions, - isTmuxAvailable, - parsePRReference, -} from "./utils/worktree.js"; + checkOutTeleportedSessionBranch, + processMessagesForTeleportResume, + teleportToRemoteWithErrorHandling, + validateGitState, + validateSessionRepository, +} from './utils/teleport.js'; +import { shouldEnableThinkingByDefault, type ThinkingConfig } from './utils/thinking.js'; +import { initUser, resetUserCache } from './utils/user.js'; +import { getTmuxInstallInstructions, isTmuxAvailable, parsePRReference } from './utils/worktree.js'; // eslint-disable-next-line custom-rules/no-top-level-side-effects -profileCheckpoint("main_tsx_imports_loaded"); +profileCheckpoint('main_tsx_imports_loaded'); /** * Log managed settings keys to Statsig for analytics. @@ -456,60 +369,54 @@ profileCheckpoint("main_tsx_imports_loaded"); * and environment variables are applied before model resolution. */ function logManagedSettings(): void { - try { - const policySettings = getSettingsForSource("policySettings"); - if (policySettings) { - const allKeys = getManagedSettingsKeysForLogging(policySettings); - logEvent("tengu_managed_settings_loaded", { - keyCount: allKeys.length, - keys: allKeys.join( - ",", - ) as unknown as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }); - } - } catch { - // Silently ignore errors - this is just for analytics - } + try { + const policySettings = getSettingsForSource('policySettings'); + if (policySettings) { + const allKeys = getManagedSettingsKeysForLogging(policySettings); + logEvent('tengu_managed_settings_loaded', { + keyCount: allKeys.length, + keys: allKeys.join(',') as unknown as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + } + } catch { + // Silently ignore errors - this is just for analytics + } } // Check if running in debug/inspection mode function isBeingDebugged() { - const isBun = isRunningWithBun(); + const isBun = isRunningWithBun(); - // Check for inspect flags in process arguments (including all variants) - const hasInspectArg = process.execArgv.some((arg) => { - if (isBun) { - // Note: Bun has an issue with single-file executables where application arguments - // from process.argv leak into process.execArgv (similar to https://github.com/oven-sh/bun/issues/11673) - // This breaks use of --debug mode if we omit this branch - // We're fine to skip that check, because Bun doesn't support Node.js legacy --debug or --debug-brk flags - return /--inspect(-brk)?/.test(arg); - } else { - // In Node.js, check for both --inspect and legacy --debug flags - return /--inspect(-brk)?|--debug(-brk)?/.test(arg); - } - }); + // Check for inspect flags in process arguments (including all variants) + const hasInspectArg = process.execArgv.some(arg => { + if (isBun) { + // Note: Bun has an issue with single-file executables where application arguments + // from process.argv leak into process.execArgv (similar to https://github.com/oven-sh/bun/issues/11673) + // This breaks use of --debug mode if we omit this branch + // We're fine to skip that check, because Bun doesn't support Node.js legacy --debug or --debug-brk flags + return /--inspect(-brk)?/.test(arg); + } else { + // In Node.js, check for both --inspect and legacy --debug flags + return /--inspect(-brk)?|--debug(-brk)?/.test(arg); + } + }); - // Check if NODE_OPTIONS contains inspect flags - const hasInspectEnv = - process.env.NODE_OPTIONS && - /--inspect(-brk)?|--debug(-brk)?/.test(process.env.NODE_OPTIONS); + // Check if NODE_OPTIONS contains inspect flags + const hasInspectEnv = process.env.NODE_OPTIONS && /--inspect(-brk)?|--debug(-brk)?/.test(process.env.NODE_OPTIONS); - // Check if inspector is available and active (indicates debugging) - try { - // Dynamic import would be better but is async - use global object instead - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const inspector = (global as any).require("inspector"); - const hasInspectorUrl = !!inspector.url(); - return hasInspectorUrl || hasInspectArg || hasInspectEnv; - } catch { - // Ignore error and fall back to argument detection - return hasInspectArg || hasInspectEnv; - } + // Check if inspector is available and active (indicates debugging) + try { + // Dynamic import would be better but is async - use global object instead + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const inspector = (global as any).require('inspector'); + const hasInspectorUrl = !!inspector.url(); + return hasInspectorUrl || hasInspectArg || hasInspectEnv; + } catch { + // Ignore error and fall back to argument detection + return hasInspectArg || hasInspectEnv; + } } - - /** * Per-session skill/plugin telemetry. Called from both the interactive path * and the headless -p path (before runHeadless) — both go through @@ -517,97 +424,80 @@ function isBeingDebugged() { * call sites here rather than one here + one in QueryEngine. */ function logSessionTelemetry(): void { - const model = parseUserSpecifiedModel( - getInitialMainLoopModel() ?? getDefaultMainLoopModel(), - ); - void logSkillsLoaded( - getCwd(), - getContextWindowForModel(model, getSdkBetas()), - ); - void loadAllPluginsCacheOnly() - .then(({ enabled, errors }) => { - const managedNames = getManagedPluginNames(); - logPluginsEnabledForSession( - enabled, - managedNames, - getPluginSeedDirs(), - ); - logPluginLoadErrors(errors, managedNames); - }) - .catch((err) => logError(err)); + const model = parseUserSpecifiedModel(getInitialMainLoopModel() ?? getDefaultMainLoopModel()); + void logSkillsLoaded(getCwd(), getContextWindowForModel(model, getSdkBetas())); + void loadAllPluginsCacheOnly() + .then(({ enabled, errors }) => { + const managedNames = getManagedPluginNames(); + logPluginsEnabledForSession(enabled, managedNames, getPluginSeedDirs()); + logPluginLoadErrors(errors, managedNames); + }) + .catch(err => logError(err)); } function getCertEnvVarTelemetry(): Record { - const result: Record = {}; - if (process.env.NODE_EXTRA_CA_CERTS) { - result.has_node_extra_ca_certs = true; - } - if (process.env.CLAUDE_CODE_CLIENT_CERT) { - result.has_client_cert = true; - } - if (hasNodeOption("--use-system-ca")) { - result.has_use_system_ca = true; - } - if (hasNodeOption("--use-openssl-ca")) { - result.has_use_openssl_ca = true; - } - return result; + const result: Record = {}; + if (process.env.NODE_EXTRA_CA_CERTS) { + result.has_node_extra_ca_certs = true; + } + if (process.env.CLAUDE_CODE_CLIENT_CERT) { + result.has_client_cert = true; + } + if (hasNodeOption('--use-system-ca')) { + result.has_use_system_ca = true; + } + if (hasNodeOption('--use-openssl-ca')) { + result.has_use_openssl_ca = true; + } + return result; } async function logStartupTelemetry(): Promise { - if (isAnalyticsDisabled()) return; - const [isGit, worktreeCount, ghAuthStatus] = await Promise.all([ - getIsGit(), - getWorktreeCount(), - getGhAuthStatus(), - ]); + if (isAnalyticsDisabled()) return; + const [isGit, worktreeCount, ghAuthStatus] = await Promise.all([getIsGit(), getWorktreeCount(), getGhAuthStatus()]); - logEvent("tengu_startup_telemetry", { - is_git: isGit, - worktree_count: worktreeCount, - gh_auth_status: - ghAuthStatus as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - sandbox_enabled: SandboxManager.isSandboxingEnabled(), - are_unsandboxed_commands_allowed: - SandboxManager.areUnsandboxedCommandsAllowed(), - is_auto_bash_allowed_if_sandbox_enabled: - SandboxManager.isAutoAllowBashIfSandboxedEnabled(), - auto_updater_disabled: isAutoUpdaterDisabled(), - prefers_reduced_motion: - getInitialSettings().prefersReducedMotion ?? false, - ...getCertEnvVarTelemetry(), - }); + logEvent('tengu_startup_telemetry', { + is_git: isGit, + worktree_count: worktreeCount, + gh_auth_status: ghAuthStatus as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + sandbox_enabled: SandboxManager.isSandboxingEnabled(), + are_unsandboxed_commands_allowed: SandboxManager.areUnsandboxedCommandsAllowed(), + is_auto_bash_allowed_if_sandbox_enabled: SandboxManager.isAutoAllowBashIfSandboxedEnabled(), + auto_updater_disabled: isAutoUpdaterDisabled(), + prefers_reduced_motion: getInitialSettings().prefersReducedMotion ?? false, + ...getCertEnvVarTelemetry(), + }); } // @[MODEL LAUNCH]: Consider any migrations you may need for model strings. See migrateSonnet1mToSonnet45.ts for an example. // Bump this when adding a new sync migration so existing users re-run the set. const CURRENT_MIGRATION_VERSION = 11; function runMigrations(): void { - if (getGlobalConfig().migrationVersion !== CURRENT_MIGRATION_VERSION) { - migrateBypassPermissionsAcceptedToSettings(); - migrateEnableAllProjectMcpServersToSettings(); - resetProToOpusDefault(); - migrateSonnet1mToSonnet45(); - migrateLegacyOpusToCurrent(); - migrateSonnet45ToSonnet46(); - migrateOpusToOpus1m(); - migrateReplBridgeEnabledToRemoteControlAtStartup(); - if (feature("TRANSCRIPT_CLASSIFIER")) { - resetAutoModeOptInForDefaultOffer(); - } - if (process.env.USER_TYPE === "ant") { - migrateFennecToOpus(); - } - saveGlobalConfig((prev) => - prev.migrationVersion === CURRENT_MIGRATION_VERSION - ? prev - : { ...prev, migrationVersion: CURRENT_MIGRATION_VERSION }, - ); - } - // Async migration - fire and forget since it's non-blocking - migrateChangelogFromConfig().catch(() => { - // Silently ignore migration errors - will retry on next startup - }); + if (getGlobalConfig().migrationVersion !== CURRENT_MIGRATION_VERSION) { + migrateBypassPermissionsAcceptedToSettings(); + migrateEnableAllProjectMcpServersToSettings(); + resetProToOpusDefault(); + migrateSonnet1mToSonnet45(); + migrateLegacyOpusToCurrent(); + migrateSonnet45ToSonnet46(); + migrateOpusToOpus1m(); + migrateReplBridgeEnabledToRemoteControlAtStartup(); + if (feature('TRANSCRIPT_CLASSIFIER')) { + resetAutoModeOptInForDefaultOffer(); + } + if (process.env.USER_TYPE === 'ant') { + migrateFennecToOpus(); + } + saveGlobalConfig(prev => + prev.migrationVersion === CURRENT_MIGRATION_VERSION + ? prev + : { ...prev, migrationVersion: CURRENT_MIGRATION_VERSION }, + ); + } + // Async migration - fire and forget since it's non-blocking + migrateChangelogFromConfig().catch(() => { + // Silently ignore migration errors - will retry on next startup + }); } /** @@ -617,31 +507,25 @@ function runMigrations(): void { * non-interactive mode where trust is implicit. */ function prefetchSystemContextIfSafe(): void { - const isNonInteractiveSession = getIsNonInteractiveSession(); + const isNonInteractiveSession = getIsNonInteractiveSession(); - // In non-interactive mode (--print), trust dialog is skipped and - // execution is considered trusted (as documented in help text) - if (isNonInteractiveSession) { - logForDiagnosticsNoPII( - "info", - "prefetch_system_context_non_interactive", - ); - void getSystemContext(); - return; - } + // In non-interactive mode (--print), trust dialog is skipped and + // execution is considered trusted (as documented in help text) + if (isNonInteractiveSession) { + logForDiagnosticsNoPII('info', 'prefetch_system_context_non_interactive'); + void getSystemContext(); + return; + } - // In interactive mode, only prefetch if trust has already been established - const hasTrust = checkHasTrustDialogAccepted(); - if (hasTrust) { - logForDiagnosticsNoPII("info", "prefetch_system_context_has_trust"); - void getSystemContext(); - } else { - logForDiagnosticsNoPII( - "info", - "prefetch_system_context_skipped_no_trust", - ); - } - // Otherwise, don't prefetch - wait for trust to be established first + // In interactive mode, only prefetch if trust has already been established + const hasTrust = checkHasTrustDialogAccepted(); + if (hasTrust) { + logForDiagnosticsNoPII('info', 'prefetch_system_context_has_trust'); + void getSystemContext(); + } else { + logForDiagnosticsNoPII('info', 'prefetch_system_context_skipped_no_trust'); + } + // Otherwise, don't prefetch - wait for trust to be established first } /** @@ -651,142 +535,118 @@ function prefetchSystemContextIfSafe(): void { * Call this after the REPL has been rendered. */ export function startDeferredPrefetches(): void { - // This function runs after first render, so it doesn't block the initial paint. - // However, the spawned processes and async work still contend for CPU and event - // loop time, which skews startup benchmarks (CPU profiles, time-to-first-render - // measurements). Skip all of it when we're only measuring startup performance. - if ( - isEnvTruthy(process.env.CLAUDE_CODE_EXIT_AFTER_FIRST_RENDER) || - // --bare: skip ALL prefetches. These are cache-warms for the REPL's - // first-turn responsiveness (initUser, getUserContext, tips, countFiles, - // modelCapabilities, change detectors). Scripted -p calls don't have a - // "user is typing" window to hide this work in — it's pure overhead on - // the critical path. - isBareMode() - ) { - return; - } + // This function runs after first render, so it doesn't block the initial paint. + // However, the spawned processes and async work still contend for CPU and event + // loop time, which skews startup benchmarks (CPU profiles, time-to-first-render + // measurements). Skip all of it when we're only measuring startup performance. + if ( + isEnvTruthy(process.env.CLAUDE_CODE_EXIT_AFTER_FIRST_RENDER) || + // --bare: skip ALL prefetches. These are cache-warms for the REPL's + // first-turn responsiveness (initUser, getUserContext, tips, countFiles, + // modelCapabilities, change detectors). Scripted -p calls don't have a + // "user is typing" window to hide this work in — it's pure overhead on + // the critical path. + isBareMode() + ) { + return; + } - // Process-spawning prefetches (consumed at first API call, user is still typing) - void initUser(); - void getUserContext(); - prefetchSystemContextIfSafe(); - void getRelevantTips(); - if ( - isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) && - !isEnvTruthy(process.env.CLAUDE_CODE_SKIP_BEDROCK_AUTH) - ) { - void prefetchAwsCredentialsAndBedRockInfoIfSafe(); - } - if ( - isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) && - !isEnvTruthy(process.env.CLAUDE_CODE_SKIP_VERTEX_AUTH) - ) { - void prefetchGcpCredentialsIfSafe(); - } - void countFilesRoundedRg(getCwd(), AbortSignal.timeout(3000), []); + // Process-spawning prefetches (consumed at first API call, user is still typing) + void initUser(); + void getUserContext(); + prefetchSystemContextIfSafe(); + void getRelevantTips(); + if (isEnvTruthy(process.env.CLAUDE_CODE_USE_BEDROCK) && !isEnvTruthy(process.env.CLAUDE_CODE_SKIP_BEDROCK_AUTH)) { + void prefetchAwsCredentialsAndBedRockInfoIfSafe(); + } + if (isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) && !isEnvTruthy(process.env.CLAUDE_CODE_SKIP_VERTEX_AUTH)) { + void prefetchGcpCredentialsIfSafe(); + } + void countFilesRoundedRg(getCwd(), AbortSignal.timeout(3000), []); - // Analytics and feature flag initialization - void initializeAnalyticsGates(); + // Analytics and feature flag initialization + void initializeAnalyticsGates(); - void refreshModelCapabilities(); + void refreshModelCapabilities(); - // File change detectors deferred from init() to unblock first render - void settingsChangeDetector.initialize(); - if (!isBareMode()) { - void skillChangeDetector.initialize(); - } + // File change detectors deferred from init() to unblock first render + void settingsChangeDetector.initialize(); + if (!isBareMode()) { + void skillChangeDetector.initialize(); + } - // Event loop stall detector — logs when the main thread is blocked >500ms - if (process.env.USER_TYPE === "ant") { - void import("./utils/eventLoopStallDetector.js").then((m) => - m.startEventLoopStallDetector(), - ); - } + // Event loop stall detector — logs when the main thread is blocked >500ms + if (process.env.USER_TYPE === 'ant') { + void import('./utils/eventLoopStallDetector.js').then(m => m.startEventLoopStallDetector()); + } } function loadSettingsFromFlag(settingsFile: string): void { - try { - const trimmedSettings = settingsFile.trim(); - const looksLikeJson = - trimmedSettings.startsWith("{") && trimmedSettings.endsWith("}"); + try { + const trimmedSettings = settingsFile.trim(); + const looksLikeJson = trimmedSettings.startsWith('{') && trimmedSettings.endsWith('}'); - let settingsPath: string; + let settingsPath: string; - if (looksLikeJson) { - // It's a JSON string - validate and create temp file - const parsedJson = safeParseJSON(trimmedSettings); - if (!parsedJson) { - process.stderr.write( - chalk.red("Error: Invalid JSON provided to --settings\n"), - ); - process.exit(1); - } + if (looksLikeJson) { + // It's a JSON string - validate and create temp file + const parsedJson = safeParseJSON(trimmedSettings); + if (!parsedJson) { + process.stderr.write(chalk.red('Error: Invalid JSON provided to --settings\n')); + process.exit(1); + } - // Create a temporary file and write the JSON to it. - // Use a content-hash-based path instead of random UUID to avoid - // busting the Anthropic API prompt cache. The settings path ends up - // in the Bash tool's sandbox denyWithinAllow list, which is part of - // the tool description sent to the API. A random UUID per subprocess - // changes the tool description on every query() call, invalidating - // the cache prefix and causing a 12x input token cost penalty. - // The content hash ensures identical settings produce the same path - // across process boundaries (each SDK query() spawns a new process). - settingsPath = generateTempFilePath("claude-settings", ".json", { - contentHash: trimmedSettings, - }); - writeFileSync_DEPRECATED(settingsPath, trimmedSettings, "utf8"); - } else { - // It's a file path - resolve and validate by attempting to read - const { resolvedPath: resolvedSettingsPath } = safeResolvePath( - getFsImplementation(), - settingsFile, - ); - try { - readFileSync(resolvedSettingsPath, "utf8"); - } catch (e) { - if (isENOENT(e)) { - process.stderr.write( - chalk.red( - `Error: Settings file not found: ${resolvedSettingsPath}\n`, - ), - ); - process.exit(1); - } - throw e; - } - settingsPath = resolvedSettingsPath; - } + // Create a temporary file and write the JSON to it. + // Use a content-hash-based path instead of random UUID to avoid + // busting the Anthropic API prompt cache. The settings path ends up + // in the Bash tool's sandbox denyWithinAllow list, which is part of + // the tool description sent to the API. A random UUID per subprocess + // changes the tool description on every query() call, invalidating + // the cache prefix and causing a 12x input token cost penalty. + // The content hash ensures identical settings produce the same path + // across process boundaries (each SDK query() spawns a new process). + settingsPath = generateTempFilePath('claude-settings', '.json', { + contentHash: trimmedSettings, + }); + writeFileSync_DEPRECATED(settingsPath, trimmedSettings, 'utf8'); + } else { + // It's a file path - resolve and validate by attempting to read + const { resolvedPath: resolvedSettingsPath } = safeResolvePath(getFsImplementation(), settingsFile); + try { + readFileSync(resolvedSettingsPath, 'utf8'); + } catch (e) { + if (isENOENT(e)) { + process.stderr.write(chalk.red(`Error: Settings file not found: ${resolvedSettingsPath}\n`)); + process.exit(1); + } + throw e; + } + settingsPath = resolvedSettingsPath; + } - setFlagSettingsPath(settingsPath); - resetSettingsCache(); - } catch (error) { - if (error instanceof Error) { - logError(error); - } - process.stderr.write( - chalk.red(`Error processing settings: ${errorMessage(error)}\n`), - ); - process.exit(1); - } + setFlagSettingsPath(settingsPath); + resetSettingsCache(); + } catch (error) { + if (error instanceof Error) { + logError(error); + } + process.stderr.write(chalk.red(`Error processing settings: ${errorMessage(error)}\n`)); + process.exit(1); + } } function loadSettingSourcesFromFlag(settingSourcesArg: string): void { - try { - const sources = parseSettingSourcesFlag(settingSourcesArg); - setAllowedSettingSources(sources); - resetSettingsCache(); - } catch (error) { - if (error instanceof Error) { - logError(error); - } - process.stderr.write( - chalk.red( - `Error processing --setting-sources: ${errorMessage(error)}\n`, - ), - ); - process.exit(1); - } + try { + const sources = parseSettingSourcesFlag(settingSourcesArg); + setAllowedSettingSources(sources); + resetSettingsCache(); + } catch (error) { + if (error instanceof Error) { + logError(error); + } + process.stderr.write(chalk.red(`Error processing --setting-sources: ${errorMessage(error)}\n`)); + process.exit(1); + } } /** @@ -794,5700 +654,4551 @@ function loadSettingSourcesFromFlag(settingSourcesArg: string): void { * This ensures settings are filtered from the start of initialization */ function eagerLoadSettings(): void { - profileCheckpoint("eagerLoadSettings_start"); - // Parse --settings flag early to ensure settings are loaded before init() - const settingsFile = eagerParseCliFlag("--settings"); - if (settingsFile) { - loadSettingsFromFlag(settingsFile); - } + profileCheckpoint('eagerLoadSettings_start'); + // Parse --settings flag early to ensure settings are loaded before init() + const settingsFile = eagerParseCliFlag('--settings'); + if (settingsFile) { + loadSettingsFromFlag(settingsFile); + } - // Parse --setting-sources flag early to control which sources are loaded - const settingSourcesArg = eagerParseCliFlag("--setting-sources"); - if (settingSourcesArg !== undefined) { - loadSettingSourcesFromFlag(settingSourcesArg); - } - profileCheckpoint("eagerLoadSettings_end"); + // Parse --setting-sources flag early to control which sources are loaded + const settingSourcesArg = eagerParseCliFlag('--setting-sources'); + if (settingSourcesArg !== undefined) { + loadSettingSourcesFromFlag(settingSourcesArg); + } + profileCheckpoint('eagerLoadSettings_end'); } function initializeEntrypoint(isNonInteractive: boolean): void { - // Skip if already set (e.g., by SDK or other entrypoints) - if (process.env.CLAUDE_CODE_ENTRYPOINT) { - return; - } + // Skip if already set (e.g., by SDK or other entrypoints) + if (process.env.CLAUDE_CODE_ENTRYPOINT) { + return; + } - const cliArgs = process.argv.slice(2); + const cliArgs = process.argv.slice(2); - // Check for MCP serve command (handle flags before mcp serve, e.g., --debug mcp serve) - const mcpIndex = cliArgs.indexOf("mcp"); - if (mcpIndex !== -1 && cliArgs[mcpIndex + 1] === "serve") { - process.env.CLAUDE_CODE_ENTRYPOINT = "mcp"; - return; - } + // Check for MCP serve command (handle flags before mcp serve, e.g., --debug mcp serve) + const mcpIndex = cliArgs.indexOf('mcp'); + if (mcpIndex !== -1 && cliArgs[mcpIndex + 1] === 'serve') { + process.env.CLAUDE_CODE_ENTRYPOINT = 'mcp'; + return; + } - if (isEnvTruthy(process.env.CLAUDE_CODE_ACTION)) { - process.env.CLAUDE_CODE_ENTRYPOINT = "claude-code-github-action"; - return; - } + if (isEnvTruthy(process.env.CLAUDE_CODE_ACTION)) { + process.env.CLAUDE_CODE_ENTRYPOINT = 'claude-code-github-action'; + return; + } - // Note: 'local-agent' entrypoint is set by the local agent mode launcher - // via CLAUDE_CODE_ENTRYPOINT env var (handled by early return above) + // Note: 'local-agent' entrypoint is set by the local agent mode launcher + // via CLAUDE_CODE_ENTRYPOINT env var (handled by early return above) - // Set based on interactive status - process.env.CLAUDE_CODE_ENTRYPOINT = isNonInteractive ? "sdk-cli" : "cli"; + // Set based on interactive status + process.env.CLAUDE_CODE_ENTRYPOINT = isNonInteractive ? 'sdk-cli' : 'cli'; } // Set by early argv processing when `claude open ` is detected (interactive mode only) type PendingConnect = { - url: string | undefined; - authToken: string | undefined; - dangerouslySkipPermissions: boolean; + url: string | undefined; + authToken: string | undefined; + dangerouslySkipPermissions: boolean; }; -const _pendingConnect: PendingConnect | undefined = feature("DIRECT_CONNECT") - ? { - url: undefined, - authToken: undefined, - dangerouslySkipPermissions: false, - } - : undefined; +const _pendingConnect: PendingConnect | undefined = feature('DIRECT_CONNECT') + ? { + url: undefined, + authToken: undefined, + dangerouslySkipPermissions: false, + } + : undefined; // Set by early argv processing when `claude assistant [sessionId]` is detected type PendingAssistantChat = { sessionId?: string; discover: boolean }; -const _pendingAssistantChat: PendingAssistantChat | undefined = feature( - "KAIROS", -) - ? { sessionId: undefined, discover: false } - : undefined; +const _pendingAssistantChat: PendingAssistantChat | undefined = feature('KAIROS') + ? { sessionId: undefined, discover: false } + : undefined; // `claude ssh [dir]` — parsed from argv early (same pattern as // DIRECT_CONNECT above) so the main command path can pick it up and hand // the REPL an SSH-backed session instead of a local one. type PendingSSH = { - host: string | undefined; - cwd: string | undefined; - permissionMode: string | undefined; - dangerouslySkipPermissions: boolean; - /** --local: spawn the child CLI directly, skip ssh/probe/deploy. e2e test mode. */ - local: boolean; - /** Extra CLI args to forward to the remote CLI on initial spawn (--resume, -c). */ - extraCliArgs: string[]; + host: string | undefined; + cwd: string | undefined; + permissionMode: string | undefined; + dangerouslySkipPermissions: boolean; + /** --local: spawn the child CLI directly, skip ssh/probe/deploy. e2e test mode. */ + local: boolean; + /** Extra CLI args to forward to the remote CLI on initial spawn (--resume, -c). */ + extraCliArgs: string[]; }; -const _pendingSSH: PendingSSH | undefined = feature("SSH_REMOTE") - ? { - host: undefined, - cwd: undefined, - permissionMode: undefined, - dangerouslySkipPermissions: false, - local: false, - extraCliArgs: [], - } - : undefined; +const _pendingSSH: PendingSSH | undefined = feature('SSH_REMOTE') + ? { + host: undefined, + cwd: undefined, + permissionMode: undefined, + dangerouslySkipPermissions: false, + local: false, + extraCliArgs: [], + } + : undefined; export async function main() { - profileCheckpoint("main_function_start"); + profileCheckpoint('main_function_start'); - // SECURITY: Prevent Windows from executing commands from current directory - // This must be set before ANY command execution to prevent PATH hijacking attacks - // See: https://docs.microsoft.com/en-us/windows/win32/api/processenv/nf-processenv-searchpathw - process.env.NoDefaultCurrentDirectoryInExePath = "1"; + // SECURITY: Prevent Windows from executing commands from current directory + // This must be set before ANY command execution to prevent PATH hijacking attacks + // See: https://docs.microsoft.com/en-us/windows/win32/api/processenv/nf-processenv-searchpathw + process.env.NoDefaultCurrentDirectoryInExePath = '1'; - // Initialize warning handler early to catch warnings - initializeWarningHandler(); + // Initialize warning handler early to catch warnings + initializeWarningHandler(); - process.on("exit", () => { - resetCursor(); - }); - process.on("SIGINT", () => { - // In print mode, print.ts registers its own SIGINT handler that aborts - // the in-flight query and calls gracefulShutdown; skip here to avoid - // preempting it with a synchronous process.exit(). - if (process.argv.includes("-p") || process.argv.includes("--print")) { - return; - } - process.exit(0); - }); - profileCheckpoint("main_warning_handler_initialized"); + process.on('exit', () => { + resetCursor(); + }); + process.on('SIGINT', () => { + // In print mode, print.ts registers its own SIGINT handler that aborts + // the in-flight query and calls gracefulShutdown; skip here to avoid + // preempting it with a synchronous process.exit(). + if (process.argv.includes('-p') || process.argv.includes('--print')) { + return; + } + process.exit(0); + }); + profileCheckpoint('main_warning_handler_initialized'); - // Check for cc:// or cc+unix:// URL in argv — rewrite so the main command - // handles it, giving the full interactive TUI instead of a stripped-down subcommand. - // For headless (-p), we rewrite to the internal `open` subcommand. - if (feature("DIRECT_CONNECT")) { - const rawCliArgs = process.argv.slice(2); - const ccIdx = rawCliArgs.findIndex( - (a) => a.startsWith("cc://") || a.startsWith("cc+unix://"), - ); - if (ccIdx !== -1 && _pendingConnect) { - const ccUrl = rawCliArgs[ccIdx]!; - const { parseConnectUrl } = - await import("./server/parseConnectUrl.js"); - const parsed = parseConnectUrl(ccUrl); - _pendingConnect.dangerouslySkipPermissions = rawCliArgs.includes( - "--dangerously-skip-permissions", - ); + // Check for cc:// or cc+unix:// URL in argv — rewrite so the main command + // handles it, giving the full interactive TUI instead of a stripped-down subcommand. + // For headless (-p), we rewrite to the internal `open` subcommand. + if (feature('DIRECT_CONNECT')) { + const rawCliArgs = process.argv.slice(2); + const ccIdx = rawCliArgs.findIndex(a => a.startsWith('cc://') || a.startsWith('cc+unix://')); + if (ccIdx !== -1 && _pendingConnect) { + const ccUrl = rawCliArgs[ccIdx]!; + const { parseConnectUrl } = await import('./server/parseConnectUrl.js'); + const parsed = parseConnectUrl(ccUrl); + _pendingConnect.dangerouslySkipPermissions = rawCliArgs.includes('--dangerously-skip-permissions'); - if (rawCliArgs.includes("-p") || rawCliArgs.includes("--print")) { - // Headless: rewrite to internal `open` subcommand - const stripped = rawCliArgs.filter((_, i) => i !== ccIdx); - const dspIdx = stripped.indexOf( - "--dangerously-skip-permissions", - ); - if (dspIdx !== -1) { - stripped.splice(dspIdx, 1); - } - process.argv = [ - process.argv[0]!, - process.argv[1]!, - "open", - ccUrl, - ...stripped, - ]; - } else { - // Interactive: strip cc:// URL and flags, run main command - _pendingConnect.url = parsed.serverUrl; - _pendingConnect.authToken = parsed.authToken; - const stripped = rawCliArgs.filter((_, i) => i !== ccIdx); - const dspIdx = stripped.indexOf( - "--dangerously-skip-permissions", - ); - if (dspIdx !== -1) { - stripped.splice(dspIdx, 1); - } - process.argv = [ - process.argv[0]!, - process.argv[1]!, - ...stripped, - ]; - } - } - } + if (rawCliArgs.includes('-p') || rawCliArgs.includes('--print')) { + // Headless: rewrite to internal `open` subcommand + const stripped = rawCliArgs.filter((_, i) => i !== ccIdx); + const dspIdx = stripped.indexOf('--dangerously-skip-permissions'); + if (dspIdx !== -1) { + stripped.splice(dspIdx, 1); + } + process.argv = [process.argv[0]!, process.argv[1]!, 'open', ccUrl, ...stripped]; + } else { + // Interactive: strip cc:// URL and flags, run main command + _pendingConnect.url = parsed.serverUrl; + _pendingConnect.authToken = parsed.authToken; + const stripped = rawCliArgs.filter((_, i) => i !== ccIdx); + const dspIdx = stripped.indexOf('--dangerously-skip-permissions'); + if (dspIdx !== -1) { + stripped.splice(dspIdx, 1); + } + process.argv = [process.argv[0]!, process.argv[1]!, ...stripped]; + } + } + } - // Handle deep link URIs early — this is invoked by the OS protocol handler - // and should bail out before full init since it only needs to parse the URI - // and open a terminal. - if (feature("LODESTONE")) { - const handleUriIdx = process.argv.indexOf("--handle-uri"); - if (handleUriIdx !== -1 && process.argv[handleUriIdx + 1]) { - const { enableConfigs } = await import("./utils/config.js"); - enableConfigs(); - const uri = process.argv[handleUriIdx + 1]!; - const { handleDeepLinkUri } = - await import("./utils/deepLink/protocolHandler.js"); - const exitCode = await handleDeepLinkUri(uri); - process.exit(exitCode); - } + // Handle deep link URIs early — this is invoked by the OS protocol handler + // and should bail out before full init since it only needs to parse the URI + // and open a terminal. + if (feature('LODESTONE')) { + const handleUriIdx = process.argv.indexOf('--handle-uri'); + if (handleUriIdx !== -1 && process.argv[handleUriIdx + 1]) { + const { enableConfigs } = await import('./utils/config.js'); + enableConfigs(); + const uri = process.argv[handleUriIdx + 1]!; + const { handleDeepLinkUri } = await import('./utils/deepLink/protocolHandler.js'); + const exitCode = await handleDeepLinkUri(uri); + process.exit(exitCode); + } - // macOS URL handler: when LaunchServices launches our .app bundle, the - // URL arrives via Apple Event (not argv). LaunchServices overwrites - // __CFBundleIdentifier to the launching bundle's ID, which is a precise - // positive signal — cheaper than importing and guessing with heuristics. - if ( - process.platform === "darwin" && - process.env.__CFBundleIdentifier === - "com.anthropic.claude-code-url-handler" - ) { - const { enableConfigs } = await import("./utils/config.js"); - enableConfigs(); - const { handleUrlSchemeLaunch } = - await import("./utils/deepLink/protocolHandler.js"); - const urlSchemeResult = await handleUrlSchemeLaunch(); - process.exit(urlSchemeResult ?? 1); - } - } + // macOS URL handler: when LaunchServices launches our .app bundle, the + // URL arrives via Apple Event (not argv). LaunchServices overwrites + // __CFBundleIdentifier to the launching bundle's ID, which is a precise + // positive signal — cheaper than importing and guessing with heuristics. + if (process.platform === 'darwin' && process.env.__CFBundleIdentifier === 'com.anthropic.claude-code-url-handler') { + const { enableConfigs } = await import('./utils/config.js'); + enableConfigs(); + const { handleUrlSchemeLaunch } = await import('./utils/deepLink/protocolHandler.js'); + const urlSchemeResult = await handleUrlSchemeLaunch(); + process.exit(urlSchemeResult ?? 1); + } + } - // `claude assistant [sessionId]` — stash and strip so the main - // command handles it, giving the full interactive TUI. Position-0 only - // (matching the ssh pattern below) — indexOf would false-positive on - // `claude -p "explain assistant"`. Root-flag-before-subcommand - // (e.g. `--debug assistant`) falls through to the stub, which - // prints usage. - if (feature("KAIROS") && _pendingAssistantChat) { - const rawArgs = process.argv.slice(2); - if (rawArgs[0] === "assistant") { - const nextArg = rawArgs[1]; - if (nextArg && !nextArg.startsWith("-")) { - _pendingAssistantChat.sessionId = nextArg; - rawArgs.splice(0, 2); // drop 'assistant' and sessionId - process.argv = [process.argv[0]!, process.argv[1]!, ...rawArgs]; - } else if (!nextArg) { - _pendingAssistantChat.discover = true; - rawArgs.splice(0, 1); // drop 'assistant' - process.argv = [process.argv[0]!, process.argv[1]!, ...rawArgs]; - } - // else: `claude assistant --help` → fall through to stub - } - } + // `claude assistant [sessionId]` — stash and strip so the main + // command handles it, giving the full interactive TUI. Position-0 only + // (matching the ssh pattern below) — indexOf would false-positive on + // `claude -p "explain assistant"`. Root-flag-before-subcommand + // (e.g. `--debug assistant`) falls through to the stub, which + // prints usage. + if (feature('KAIROS') && _pendingAssistantChat) { + const rawArgs = process.argv.slice(2); + if (rawArgs[0] === 'assistant') { + const nextArg = rawArgs[1]; + if (nextArg && !nextArg.startsWith('-')) { + _pendingAssistantChat.sessionId = nextArg; + rawArgs.splice(0, 2); // drop 'assistant' and sessionId + process.argv = [process.argv[0]!, process.argv[1]!, ...rawArgs]; + } else if (!nextArg) { + _pendingAssistantChat.discover = true; + rawArgs.splice(0, 1); // drop 'assistant' + process.argv = [process.argv[0]!, process.argv[1]!, ...rawArgs]; + } + // else: `claude assistant --help` → fall through to stub + } + } - // `claude ssh [dir]` — strip from argv so the main command handler - // runs (full interactive TUI), stash the host/dir for the REPL branch at - // ~line 3720 to pick up. Headless (-p) mode not supported in v1: SSH - // sessions need the local REPL to drive them (interrupt, permissions). - if (feature("SSH_REMOTE") && _pendingSSH) { - const rawCliArgs = process.argv.slice(2); - // SSH-specific flags can appear before the host positional (e.g. - // `ssh --permission-mode auto host /tmp` — standard POSIX flags-before- - // positionals). Pull them all out BEFORE checking whether a host was - // given, so `claude ssh --permission-mode auto host` and `claude ssh host - // --permission-mode auto` are equivalent. The host check below only needs - // to guard against `-h`/`--help` (which commander should handle). - if (rawCliArgs[0] === "ssh") { - const localIdx = rawCliArgs.indexOf("--local"); - if (localIdx !== -1) { - _pendingSSH.local = true; - rawCliArgs.splice(localIdx, 1); - } - const dspIdx = rawCliArgs.indexOf("--dangerously-skip-permissions"); - if (dspIdx !== -1) { - _pendingSSH.dangerouslySkipPermissions = true; - rawCliArgs.splice(dspIdx, 1); - } - const pmIdx = rawCliArgs.indexOf("--permission-mode"); - if ( - pmIdx !== -1 && - rawCliArgs[pmIdx + 1] && - !rawCliArgs[pmIdx + 1]!.startsWith("-") - ) { - _pendingSSH.permissionMode = rawCliArgs[pmIdx + 1]; - rawCliArgs.splice(pmIdx, 2); - } - const pmEqIdx = rawCliArgs.findIndex((a) => - a.startsWith("--permission-mode="), - ); - if (pmEqIdx !== -1) { - _pendingSSH.permissionMode = rawCliArgs[pmEqIdx]!.split("=")[1]; - rawCliArgs.splice(pmEqIdx, 1); - } - // Forward session-resume + model flags to the remote CLI's initial spawn. - // --continue/-c and --resume operate on the REMOTE session history - // (which persists under the remote's ~/.claude/projects//). - // --model controls which model the remote uses. - const extractFlag = ( - flag: string, - opts: { hasValue?: boolean; as?: string } = {}, - ) => { - const i = rawCliArgs.indexOf(flag); - if (i !== -1) { - _pendingSSH.extraCliArgs.push(opts.as ?? flag); - const val = rawCliArgs[i + 1]; - if (opts.hasValue && val && !val.startsWith("-")) { - _pendingSSH.extraCliArgs.push(val); - rawCliArgs.splice(i, 2); - } else { - rawCliArgs.splice(i, 1); - } - } - const eqI = rawCliArgs.findIndex((a) => - a.startsWith(`${flag}=`), - ); - if (eqI !== -1) { - _pendingSSH.extraCliArgs.push( - opts.as ?? flag, - rawCliArgs[eqI]!.slice(flag.length + 1), - ); - rawCliArgs.splice(eqI, 1); - } - }; - extractFlag("-c", { as: "--continue" }); - extractFlag("--continue"); - extractFlag("--resume", { hasValue: true }); - extractFlag("--model", { hasValue: true }); - } - // After pre-extraction, any remaining dash-arg at [1] is either -h/--help - // (commander handles) or an unknown-to-ssh flag (fall through to commander - // so it surfaces a proper error). Only a non-dash arg is the host. - if ( - rawCliArgs[0] === "ssh" && - rawCliArgs[1] && - !rawCliArgs[1].startsWith("-") - ) { - _pendingSSH.host = rawCliArgs[1]; - // Optional positional cwd. - let consumed = 2; - if (rawCliArgs[2] && !rawCliArgs[2].startsWith("-")) { - _pendingSSH.cwd = rawCliArgs[2]; - consumed = 3; - } - const rest = rawCliArgs.slice(consumed); + // `claude ssh [dir]` — strip from argv so the main command handler + // runs (full interactive TUI), stash the host/dir for the REPL branch at + // ~line 3720 to pick up. Headless (-p) mode not supported in v1: SSH + // sessions need the local REPL to drive them (interrupt, permissions). + if (feature('SSH_REMOTE') && _pendingSSH) { + const rawCliArgs = process.argv.slice(2); + // SSH-specific flags can appear before the host positional (e.g. + // `ssh --permission-mode auto host /tmp` — standard POSIX flags-before- + // positionals). Pull them all out BEFORE checking whether a host was + // given, so `claude ssh --permission-mode auto host` and `claude ssh host + // --permission-mode auto` are equivalent. The host check below only needs + // to guard against `-h`/`--help` (which commander should handle). + if (rawCliArgs[0] === 'ssh') { + const localIdx = rawCliArgs.indexOf('--local'); + if (localIdx !== -1) { + _pendingSSH.local = true; + rawCliArgs.splice(localIdx, 1); + } + const dspIdx = rawCliArgs.indexOf('--dangerously-skip-permissions'); + if (dspIdx !== -1) { + _pendingSSH.dangerouslySkipPermissions = true; + rawCliArgs.splice(dspIdx, 1); + } + const pmIdx = rawCliArgs.indexOf('--permission-mode'); + if (pmIdx !== -1 && rawCliArgs[pmIdx + 1] && !rawCliArgs[pmIdx + 1]!.startsWith('-')) { + _pendingSSH.permissionMode = rawCliArgs[pmIdx + 1]; + rawCliArgs.splice(pmIdx, 2); + } + const pmEqIdx = rawCliArgs.findIndex(a => a.startsWith('--permission-mode=')); + if (pmEqIdx !== -1) { + _pendingSSH.permissionMode = rawCliArgs[pmEqIdx]!.split('=')[1]; + rawCliArgs.splice(pmEqIdx, 1); + } + // Forward session-resume + model flags to the remote CLI's initial spawn. + // --continue/-c and --resume operate on the REMOTE session history + // (which persists under the remote's ~/.claude/projects//). + // --model controls which model the remote uses. + const extractFlag = (flag: string, opts: { hasValue?: boolean; as?: string } = {}) => { + const i = rawCliArgs.indexOf(flag); + if (i !== -1) { + _pendingSSH.extraCliArgs.push(opts.as ?? flag); + const val = rawCliArgs[i + 1]; + if (opts.hasValue && val && !val.startsWith('-')) { + _pendingSSH.extraCliArgs.push(val); + rawCliArgs.splice(i, 2); + } else { + rawCliArgs.splice(i, 1); + } + } + const eqI = rawCliArgs.findIndex(a => a.startsWith(`${flag}=`)); + if (eqI !== -1) { + _pendingSSH.extraCliArgs.push(opts.as ?? flag, rawCliArgs[eqI]!.slice(flag.length + 1)); + rawCliArgs.splice(eqI, 1); + } + }; + extractFlag('-c', { as: '--continue' }); + extractFlag('--continue'); + extractFlag('--resume', { hasValue: true }); + extractFlag('--model', { hasValue: true }); + } + // After pre-extraction, any remaining dash-arg at [1] is either -h/--help + // (commander handles) or an unknown-to-ssh flag (fall through to commander + // so it surfaces a proper error). Only a non-dash arg is the host. + if (rawCliArgs[0] === 'ssh' && rawCliArgs[1] && !rawCliArgs[1].startsWith('-')) { + _pendingSSH.host = rawCliArgs[1]; + // Optional positional cwd. + let consumed = 2; + if (rawCliArgs[2] && !rawCliArgs[2].startsWith('-')) { + _pendingSSH.cwd = rawCliArgs[2]; + consumed = 3; + } + const rest = rawCliArgs.slice(consumed); - // Headless (-p) mode is not supported with SSH in v1 — reject early - // so the flag doesn't silently cause local execution. - if (rest.includes("-p") || rest.includes("--print")) { - process.stderr.write( - "Error: headless (-p/--print) mode is not supported with claude ssh\n", - ); - gracefulShutdownSync(1); - return; - } + // Headless (-p) mode is not supported with SSH in v1 — reject early + // so the flag doesn't silently cause local execution. + if (rest.includes('-p') || rest.includes('--print')) { + process.stderr.write('Error: headless (-p/--print) mode is not supported with claude ssh\n'); + gracefulShutdownSync(1); + return; + } - // Rewrite argv so the main command sees remaining flags but not `ssh`. - process.argv = [process.argv[0]!, process.argv[1]!, ...rest]; - } - } + // Rewrite argv so the main command sees remaining flags but not `ssh`. + process.argv = [process.argv[0]!, process.argv[1]!, ...rest]; + } + } - // Check for -p/--print and --init-only flags early to set isInteractiveSession before init() - // This is needed because telemetry initialization calls auth functions that need this flag - const cliArgs = process.argv.slice(2); - const hasPrintFlag = cliArgs.includes("-p") || cliArgs.includes("--print"); - const hasInitOnlyFlag = cliArgs.includes("--init-only"); - const hasSdkUrl = cliArgs.some((arg) => arg.startsWith("--sdk-url")); - const forceInteractive = isEnvTruthy(process.env.CLAUDE_CODE_FORCE_INTERACTIVE); - const isNonInteractive = - hasPrintFlag || hasInitOnlyFlag || hasSdkUrl || (!forceInteractive && !process.stdout.isTTY); + // Check for -p/--print and --init-only flags early to set isInteractiveSession before init() + // This is needed because telemetry initialization calls auth functions that need this flag + const cliArgs = process.argv.slice(2); + const hasPrintFlag = cliArgs.includes('-p') || cliArgs.includes('--print'); + const hasInitOnlyFlag = cliArgs.includes('--init-only'); + const hasSdkUrl = cliArgs.some(arg => arg.startsWith('--sdk-url')); + const forceInteractive = isEnvTruthy(process.env.CLAUDE_CODE_FORCE_INTERACTIVE); + const isNonInteractive = hasPrintFlag || hasInitOnlyFlag || hasSdkUrl || (!forceInteractive && !process.stdout.isTTY); - // Stop capturing early input for non-interactive modes - if (isNonInteractive) { - stopCapturingEarlyInput(); - } + // Stop capturing early input for non-interactive modes + if (isNonInteractive) { + stopCapturingEarlyInput(); + } - // Set simplified tracking fields - const isInteractive = !isNonInteractive; - setIsInteractive(isInteractive); + // Set simplified tracking fields + const isInteractive = !isNonInteractive; + setIsInteractive(isInteractive); - // Initialize entrypoint based on mode - needs to be set before any event is logged - initializeEntrypoint(isNonInteractive); + // Initialize entrypoint based on mode - needs to be set before any event is logged + initializeEntrypoint(isNonInteractive); - // Determine client type - const clientType = (() => { - if (isEnvTruthy(process.env.GITHUB_ACTIONS)) return "github-action"; - if (process.env.CLAUDE_CODE_ENTRYPOINT === "sdk-ts") - return "sdk-typescript"; - if (process.env.CLAUDE_CODE_ENTRYPOINT === "sdk-py") - return "sdk-python"; - if (process.env.CLAUDE_CODE_ENTRYPOINT === "sdk-cli") return "sdk-cli"; - if (process.env.CLAUDE_CODE_ENTRYPOINT === "claude-vscode") - return "claude-vscode"; - if (process.env.CLAUDE_CODE_ENTRYPOINT === "local-agent") - return "local-agent"; - if (process.env.CLAUDE_CODE_ENTRYPOINT === "claude-desktop") - return "claude-desktop"; + // Determine client type + const clientType = (() => { + if (isEnvTruthy(process.env.GITHUB_ACTIONS)) return 'github-action'; + if (process.env.CLAUDE_CODE_ENTRYPOINT === 'sdk-ts') return 'sdk-typescript'; + if (process.env.CLAUDE_CODE_ENTRYPOINT === 'sdk-py') return 'sdk-python'; + if (process.env.CLAUDE_CODE_ENTRYPOINT === 'sdk-cli') return 'sdk-cli'; + if (process.env.CLAUDE_CODE_ENTRYPOINT === 'claude-vscode') return 'claude-vscode'; + if (process.env.CLAUDE_CODE_ENTRYPOINT === 'local-agent') return 'local-agent'; + if (process.env.CLAUDE_CODE_ENTRYPOINT === 'claude-desktop') return 'claude-desktop'; - // Check if session-ingress token is provided (indicates remote session) - const hasSessionIngressToken = - process.env.CLAUDE_CODE_SESSION_ACCESS_TOKEN || - process.env.CLAUDE_CODE_WEBSOCKET_AUTH_FILE_DESCRIPTOR; - if ( - process.env.CLAUDE_CODE_ENTRYPOINT === "remote" || - hasSessionIngressToken - ) { - return "remote"; - } + // Check if session-ingress token is provided (indicates remote session) + const hasSessionIngressToken = + process.env.CLAUDE_CODE_SESSION_ACCESS_TOKEN || process.env.CLAUDE_CODE_WEBSOCKET_AUTH_FILE_DESCRIPTOR; + if (process.env.CLAUDE_CODE_ENTRYPOINT === 'remote' || hasSessionIngressToken) { + return 'remote'; + } - return "cli"; - })(); - setClientType(clientType); + return 'cli'; + })(); + setClientType(clientType); - const previewFormat = process.env.CLAUDE_CODE_QUESTION_PREVIEW_FORMAT; - if (previewFormat === "markdown" || previewFormat === "html") { - setQuestionPreviewFormat(previewFormat); - } else if ( - !clientType.startsWith("sdk-") && - // Desktop and CCR pass previewFormat via toolConfig; when the feature is - // gated off they pass undefined — don't override that with markdown. - clientType !== "claude-desktop" && - clientType !== "local-agent" && - clientType !== "remote" - ) { - setQuestionPreviewFormat("markdown"); - } + const previewFormat = process.env.CLAUDE_CODE_QUESTION_PREVIEW_FORMAT; + if (previewFormat === 'markdown' || previewFormat === 'html') { + setQuestionPreviewFormat(previewFormat); + } else if ( + !clientType.startsWith('sdk-') && + // Desktop and CCR pass previewFormat via toolConfig; when the feature is + // gated off they pass undefined — don't override that with markdown. + clientType !== 'claude-desktop' && + clientType !== 'local-agent' && + clientType !== 'remote' + ) { + setQuestionPreviewFormat('markdown'); + } - // Tag sessions created via `claude remote-control` so the backend can identify them - if (process.env.CLAUDE_CODE_ENVIRONMENT_KIND === "bridge") { - setSessionSource("remote-control"); - } + // Tag sessions created via `claude remote-control` so the backend can identify them + if (process.env.CLAUDE_CODE_ENVIRONMENT_KIND === 'bridge') { + setSessionSource('remote-control'); + } - profileCheckpoint("main_client_type_determined"); + profileCheckpoint('main_client_type_determined'); - // Parse and load settings flags early, before init() - eagerLoadSettings(); + // Parse and load settings flags early, before init() + eagerLoadSettings(); - profileCheckpoint("main_before_run"); + profileCheckpoint('main_before_run'); - await run(); - profileCheckpoint("main_after_run"); + await run(); + profileCheckpoint('main_after_run'); } async function getInputPrompt( - prompt: string, - inputFormat: "text" | "stream-json", + prompt: string, + inputFormat: 'text' | 'stream-json', ): Promise> { - if ( - !process.stdin.isTTY && - // Input hijacking breaks MCP. - !process.argv.includes("mcp") - ) { - if (inputFormat === "stream-json") { - return process.stdin; - } - process.stdin.setEncoding("utf8"); - let data = ""; - const onData = (chunk: string) => { - data += chunk; - }; - process.stdin.on("data", onData); - // If no data arrives in 3s, stop waiting and warn. Stdin is likely an - // inherited pipe from a parent that isn't writing (subprocess spawned - // without explicit stdin handling). 3s covers slow producers like curl, - // jq on large files, python with import overhead. The warning makes - // silent data loss visible for the rare producer that's slower still. - const timedOut = await peekForStdinData(process.stdin, 3000); - process.stdin.off("data", onData); - if (timedOut) { - process.stderr.write( - "Warning: no stdin data received in 3s, proceeding without it. " + - "If piping from a slow command, redirect stdin explicitly: < /dev/null to skip, or wait longer.\n", - ); - } - return [prompt, data].filter(Boolean).join("\n"); - } - return prompt; + if ( + !process.stdin.isTTY && + // Input hijacking breaks MCP. + !process.argv.includes('mcp') + ) { + if (inputFormat === 'stream-json') { + return process.stdin; + } + process.stdin.setEncoding('utf8'); + let data = ''; + const onData = (chunk: string) => { + data += chunk; + }; + process.stdin.on('data', onData); + // If no data arrives in 3s, stop waiting and warn. Stdin is likely an + // inherited pipe from a parent that isn't writing (subprocess spawned + // without explicit stdin handling). 3s covers slow producers like curl, + // jq on large files, python with import overhead. The warning makes + // silent data loss visible for the rare producer that's slower still. + const timedOut = await peekForStdinData(process.stdin, 3000); + process.stdin.off('data', onData); + if (timedOut) { + process.stderr.write( + 'Warning: no stdin data received in 3s, proceeding without it. ' + + 'If piping from a slow command, redirect stdin explicitly: < /dev/null to skip, or wait longer.\n', + ); + } + return [prompt, data].filter(Boolean).join('\n'); + } + return prompt; } async function run(): Promise { - profileCheckpoint("run_function_start"); - - // Create help config that sorts options by long option name. - // Commander supports compareOptions at runtime but @commander-js/extra-typings - // doesn't include it in the type definitions, so we use Object.assign to add it. - function createSortedHelpConfig(): { - sortSubcommands: true; - sortOptions: true; - } { - const getOptionSortKey = (opt: Option): string => - opt.long?.replace(/^--/, "") ?? opt.short?.replace(/^-/, "") ?? ""; - return Object.assign( - { sortSubcommands: true, sortOptions: true } as const, - { - compareOptions: (a: Option, b: Option) => - getOptionSortKey(a).localeCompare(getOptionSortKey(b)), - }, - ); - } - const program = new CommanderCommand() - .configureHelp(createSortedHelpConfig()) - .enablePositionalOptions(); - profileCheckpoint("run_commander_initialized"); - - // Use preAction hook to run initialization only when executing a command, - // not when displaying help. This avoids the need for env variable signaling. - program.hook("preAction", async (thisCommand) => { - profileCheckpoint("preAction_start"); - // Await async subprocess loads started at module evaluation (lines 12-20). - // Nearly free — subprocesses complete during the ~135ms of imports above. - // Must resolve before init() which triggers the first settings read - // (applySafeConfigEnvironmentVariables → getSettingsForSource('policySettings') - // → isRemoteManagedSettingsEligible → sync keychain reads otherwise ~65ms). - await Promise.all([ - ensureMdmSettingsLoaded(), - ensureKeychainPrefetchCompleted(), - ]); - profileCheckpoint("preAction_after_mdm"); - await init(); - profileCheckpoint("preAction_after_init"); - - // process.title on Windows sets the console title directly; on POSIX, - // terminal shell integration may mirror the process name to the tab. - // After init() so settings.json env can also gate this (gh-4765). - if (!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_TERMINAL_TITLE)) { - process.title = "claude"; - } - - // Attach logging sinks so subcommand handlers can use logEvent/logError. - // Before PR #11106 logEvent dispatched directly; after, events queue until - // a sink attaches. setup() attaches sinks for the default command, but - // subcommands (doctor, mcp, plugin, auth) never call setup() and would - // silently drop events on process.exit(). Both inits are idempotent. - const { initSinks } = await import("./utils/sinks.js"); - initSinks(); - profileCheckpoint("preAction_after_sinks"); - - // gh-33508: --plugin-dir is a top-level program option. The default - // action reads it from its own options destructure, but subcommands - // (plugin list, plugin install, mcp *) have their own actions and - // never see it. Wire it up here so getInlinePlugins() works everywhere. - // thisCommand.opts() is typed {} here because this hook is attached - // before .option('--plugin-dir', ...) in the chain — extra-typings - // builds the type as options are added. Narrow with a runtime guard; - // the collect accumulator + [] default guarantee string[] in practice. - const pluginDir = thisCommand.getOptionValue("pluginDir"); - if ( - Array.isArray(pluginDir) && - pluginDir.length > 0 && - pluginDir.every((p) => typeof p === "string") - ) { - setInlinePlugins(pluginDir); - clearPluginCache("preAction: --plugin-dir inline plugins"); - } - - runMigrations(); - profileCheckpoint("preAction_after_migrations"); - - // Load remote managed settings for enterprise customers (non-blocking) - // Fails open - if fetch fails, continues without remote settings - // Settings are applied via hot-reload when they arrive - // Must happen after init() to ensure config reading is allowed - void loadRemoteManagedSettings(); - void loadPolicyLimits(); - - profileCheckpoint("preAction_after_remote_settings"); - - // Load settings sync (non-blocking, fail-open) - // CLI: uploads local settings to remote (CCR download is handled by print.ts) - if (feature("UPLOAD_USER_SETTINGS")) { - void import("./services/settingsSync/index.js").then((m) => - m.uploadUserSettingsInBackground(), - ); - } - - profileCheckpoint("preAction_after_settings_sync"); - }); - - program - .name("claude") - .description( - `Claude Code - starts an interactive session by default, use -p/--print for non-interactive output`, - ) - .argument("[prompt]", "Your prompt", String) - // Subcommands inherit helpOption via commander's copyInheritedSettings — - // setting it once here covers mcp, plugin, auth, and all other subcommands. - .helpOption("-h, --help", "Display help for command") - .option( - "-d, --debug [filter]", - 'Enable debug mode with optional category filtering (e.g., "api,hooks" or "!1p,!file")', - (_value: string | true) => { - // If value is provided, it will be the filter string - // If not provided but flag is present, value will be true - // The actual filtering is handled in debug.ts by parsing process.argv - return true; - }, - ) - .addOption( - new Option("--debug-to-stderr", "Enable debug mode (to stderr)") - .argParser(Boolean) - .hideHelp(), - ) - .option( - "--debug-file ", - "Write debug logs to a specific file path (implicitly enables debug mode)", - () => true, - ) - .option( - "--verbose", - "Override verbose mode setting from config", - () => true, - ) - .option( - "-p, --print", - "Print response and exit (useful for pipes). Note: The workspace trust dialog is skipped when Claude is run with the -p mode. Only use this flag in directories you trust.", - () => true, - ) - .option( - "--bare", - "Minimal mode: skip hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery. Sets CLAUDE_CODE_SIMPLE=1. Anthropic auth is strictly ANTHROPIC_API_KEY or apiKeyHelper via --settings (OAuth and keychain are never read). 3P providers (Bedrock/Vertex/Foundry) use their own credentials. Skills still resolve via /skill-name. Explicitly provide context via: --system-prompt[-file], --append-system-prompt[-file], --add-dir (CLAUDE.md dirs), --mcp-config, --settings, --agents, --plugin-dir.", - () => true, - ) - .addOption( - new Option( - "--init", - "Run Setup hooks with init trigger, then continue", - ).hideHelp(), - ) - .addOption( - new Option( - "--init-only", - "Run Setup and SessionStart:startup hooks, then exit", - ).hideHelp(), - ) - .addOption( - new Option( - "--maintenance", - "Run Setup hooks with maintenance trigger, then continue", - ).hideHelp(), - ) - .addOption( - new Option( - "--output-format ", - 'Output format (only works with --print): "text" (default), "json" (single result), or "stream-json" (realtime streaming)', - ).choices(["text", "json", "stream-json"]), - ) - .addOption( - new Option( - "--json-schema ", - "JSON Schema for structured output validation. " + - 'Example: {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}', - ).argParser(String), - ) - .option( - "--include-hook-events", - "Include all hook lifecycle events in the output stream (only works with --output-format=stream-json)", - () => true, - ) - .option( - "--include-partial-messages", - "Include partial message chunks as they arrive (only works with --print and --output-format=stream-json)", - () => true, - ) - .addOption( - new Option( - "--input-format ", - 'Input format (only works with --print): "text" (default), or "stream-json" (realtime streaming input)', - ).choices(["text", "stream-json"]), - ) - .option( - "--mcp-debug", - "[DEPRECATED. Use --debug instead] Enable MCP debug mode (shows MCP server errors)", - () => true, - ) - .option( - "--dangerously-skip-permissions", - "Bypass all permission checks. Recommended only for sandboxes with no internet access.", - () => true, - ) - .option( - "--allow-dangerously-skip-permissions", - "Enable bypassing all permission checks as an option, without it being enabled by default. Recommended only for sandboxes with no internet access.", - () => true, - ) - .addOption( - new Option( - "--thinking ", - "Thinking mode: enabled (equivalent to adaptive), disabled", - ) - .choices(["enabled", "adaptive", "disabled"]) - .hideHelp(), - ) - .addOption( - new Option( - "--max-thinking-tokens ", - "[DEPRECATED. Use --thinking instead for newer models] Maximum number of thinking tokens (only works with --print)", - ) - .argParser(Number) - .hideHelp(), - ) - .addOption( - new Option( - "--max-turns ", - "Maximum number of agentic turns in non-interactive mode. This will early exit the conversation after the specified number of turns. (only works with --print)", - ) - .argParser(Number) - .hideHelp(), - ) - .addOption( - new Option( - "--max-budget-usd ", - "Maximum dollar amount to spend on API calls (only works with --print)", - ).argParser((value) => { - const amount = Number(value); - if (isNaN(amount) || amount <= 0) { - throw new Error( - "--max-budget-usd must be a positive number greater than 0", - ); - } - return amount; - }), - ) - .addOption( - new Option( - "--task-budget ", - "API-side task budget in tokens (output_config.task_budget)", - ) - .argParser((value) => { - const tokens = Number(value); - if ( - isNaN(tokens) || - tokens <= 0 || - !Number.isInteger(tokens) - ) { - throw new Error( - "--task-budget must be a positive integer", - ); - } - return tokens; - }) - .hideHelp(), - ) - .option( - "--replay-user-messages", - "Re-emit user messages from stdin back on stdout for acknowledgment (only works with --input-format=stream-json and --output-format=stream-json)", - () => true, - ) - .addOption( - new Option( - "--enable-auth-status", - "Enable auth status messages in SDK mode", - ) - .default(false) - .hideHelp(), - ) - .option( - "--allowedTools, --allowed-tools ", - 'Comma or space-separated list of tool names to allow (e.g. "Bash(git:*) Edit")', - ) - .option( - "--tools ", - 'Specify the list of available tools from the built-in set. Use "" to disable all tools, "default" to use all tools, or specify tool names (e.g. "Bash,Edit,Read").', - ) - .option( - "--disallowedTools, --disallowed-tools ", - 'Comma or space-separated list of tool names to deny (e.g. "Bash(git:*) Edit")', - ) - .option( - "--mcp-config ", - "Load MCP servers from JSON files or strings (space-separated)", - ) - .addOption( - new Option( - "--permission-prompt-tool ", - "MCP tool to use for permission prompts (only works with --print)", - ) - .argParser(String) - .hideHelp(), - ) - .addOption( - new Option( - "--system-prompt ", - "System prompt to use for the session", - ).argParser(String), - ) - .addOption( - new Option( - "--system-prompt-file ", - "Read system prompt from a file", - ) - .argParser(String) - .hideHelp(), - ) - .addOption( - new Option( - "--append-system-prompt ", - "Append a system prompt to the default system prompt", - ).argParser(String), - ) - .addOption( - new Option( - "--append-system-prompt-file ", - "Read system prompt from a file and append to the default system prompt", - ) - .argParser(String) - .hideHelp(), - ) - .addOption( - new Option( - "--permission-mode ", - "Permission mode to use for the session", - ) - .argParser(String) - .choices(PERMISSION_MODES), - ) - .option( - "-c, --continue", - "Continue the most recent conversation in the current directory", - () => true, - ) - .option( - "-r, --resume [value]", - "Resume a conversation by session ID, or open interactive picker with optional search term", - (value) => value || true, - ) - .option( - "--fork-session", - "When resuming, create a new session ID instead of reusing the original (use with --resume or --continue)", - () => true, - ) - .addOption( - new Option( - "--prefill ", - "Pre-fill the prompt input with text without submitting it", - ).hideHelp(), - ) - .addOption( - new Option( - "--deep-link-origin", - "Signal that this session was launched from a deep link", - ).hideHelp(), - ) - .addOption( - new Option( - "--deep-link-repo ", - "Repo slug the deep link ?repo= parameter resolved to the current cwd", - ).hideHelp(), - ) - .addOption( - new Option( - "--deep-link-last-fetch ", - "FETCH_HEAD mtime in epoch ms, precomputed by the deep link trampoline", - ) - .argParser((v) => { - const n = Number(v); - return Number.isFinite(n) ? n : undefined; - }) - .hideHelp(), - ) - .option( - "--from-pr [value]", - "Resume a session linked to a PR by PR number/URL, or open interactive picker with optional search term", - (value) => value || true, - ) - .option( - "--no-session-persistence", - "Disable session persistence - sessions will not be saved to disk and cannot be resumed (only works with --print)", - ) - .addOption( - new Option( - "--resume-session-at ", - "When resuming, only messages up to and including the assistant message with (use with --resume in print mode)", - ) - .argParser(String) - .hideHelp(), - ) - .addOption( - new Option( - "--rewind-files ", - "Restore files to state at the specified user message and exit (requires --resume)", - ).hideHelp(), - ) - // @[MODEL LAUNCH]: Update the example model ID in the --model help text. - .option( - "--model ", - `Model for the current session. Provide an alias for the latest model (e.g. 'sonnet' or 'opus') or a model's full name (e.g. 'claude-sonnet-4-6').`, - ) - .addOption( - new Option( - "--effort ", - `Effort level for the current session (low, medium, high, max)`, - ).argParser((rawValue: string) => { - const value = rawValue.toLowerCase(); - const allowed = ["low", "medium", "high", "max"]; - if (!allowed.includes(value)) { - throw new InvalidArgumentError( - `It must be one of: ${allowed.join(", ")}`, - ); - } - return value; - }), - ) - .option( - "--agent ", - `Agent for the current session. Overrides the 'agent' setting.`, - ) - .option( - "--betas ", - "Beta headers to include in API requests (API key users only)", - ) - .option( - "--fallback-model ", - "Enable automatic fallback to specified model when default model is overloaded (only works with --print)", - ) - .addOption( - new Option( - "--workload ", - "Workload tag for billing-header attribution (cc_workload). Process-scoped; set by SDK daemon callers that spawn subprocesses for cron work. (only works with --print)", - ).hideHelp(), - ) - .option( - "--settings ", - "Path to a settings JSON file or a JSON string to load additional settings from", - ) - .option( - "--add-dir ", - "Additional directories to allow tool access to", - ) - .option( - "--ide", - "Automatically connect to IDE on startup if exactly one valid IDE is available", - () => true, - ) - .option( - "--strict-mcp-config", - "Only use MCP servers from --mcp-config, ignoring all other MCP configurations", - () => true, - ) - .option( - "--session-id ", - "Use a specific session ID for the conversation (must be a valid UUID)", - ) - .option( - "-n, --name ", - "Set a display name for this session (shown in /resume and terminal title)", - ) - .option( - "--agents ", - 'JSON object defining custom agents (e.g. \'{"reviewer": {"description": "Reviews code", "prompt": "You are a code reviewer"}}\')', - ) - .option( - "--setting-sources ", - "Comma-separated list of setting sources to load (user, project, local).", - ) - // gh-33508: (variadic) consumed everything until the next - // --flag. `claude --plugin-dir /path mcp add --transport http` swallowed - // `mcp` and `add` as paths, then choked on --transport as an unknown - // top-level option. Single-value + collect accumulator means each - // --plugin-dir takes exactly one arg; repeat the flag for multiple dirs. - .option( - "--plugin-dir ", - "Load plugins from a directory for this session only (repeatable: --plugin-dir A --plugin-dir B)", - (val: string, prev: string[]) => [...prev, val], - [] as string[], - ) - .option("--disable-slash-commands", "Disable all skills", () => true) - .option("--chrome", "Enable Claude in Chrome integration") - .option("--no-chrome", "Disable Claude in Chrome integration") - .option( - "--file ", - "File resources to download at startup. Format: file_id:relative_path (e.g., --file file_abc:doc.txt file_def:img.png)", - ) - .action(async (prompt, options) => { - profileCheckpoint("action_handler_start"); - - // --bare = one-switch minimal mode. Sets SIMPLE so all the existing - // gates fire (CLAUDE.md, skills, hooks inside executeHooks, agent - // dir-walk). Must be set before setup() / any of the gated work runs. - if ((options as { bare?: boolean }).bare) { - process.env.CLAUDE_CODE_SIMPLE = "1"; - } - - // Ignore "code" as a prompt - treat it the same as no prompt - if (prompt === "code") { - logEvent("tengu_code_prompt_ignored", {}); - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.warn( - chalk.yellow( - "Tip: You can launch Claude Code with just `claude`", - ), - ); - prompt = undefined; - } - - // Log event for any single-word prompt - if ( - prompt && - typeof prompt === "string" && - !/\s/.test(prompt) && - prompt.length > 0 - ) { - logEvent("tengu_single_word_prompt", { length: prompt.length }); - } - - // Assistant mode: when .claude/settings.json has assistant: true AND - // the tengu_kairos GrowthBook gate is on, force brief on. Permission - // mode is left to the user — settings defaultMode or --permission-mode - // apply as normal. REPL-typed messages already default to 'next' - // priority (messageQueueManager.enqueue) so they drain mid-turn between - // tool calls. SendUserMessage (BriefTool) is enabled via the brief env - // var. SleepTool stays disabled (its isEnabled() gates on proactive). - // kairosEnabled is computed once here and reused at the - // getAssistantSystemPromptAddendum() call site further down. - // - // Trust gate: .claude/settings.json is attacker-controllable in an - // untrusted clone. We run ~1000 lines before showSetupScreens() shows - // the trust dialog, and by then we've already appended - // .claude/agents/assistant.md to the system prompt. Refuse to activate - // until the directory has been explicitly trusted. - let kairosEnabled = false; - let assistantTeamContext: - | Awaited< - ReturnType< - NonNullable< - typeof assistantModule - >["initializeAssistantTeam"] - > - > - | undefined; - if ( - feature("KAIROS") && - (options as { assistant?: boolean }).assistant && - assistantModule - ) { - // --assistant (Agent SDK daemon mode): force the latch before - // isAssistantMode() runs below. The daemon has already checked - // entitlement — don't make the child re-check tengu_kairos. - assistantModule.markAssistantForced(); - } - if ( - feature("KAIROS") && - assistantModule && - (assistantModule.isAssistantForced() || - (options as Record).assistant === true) && - // Spawned teammates share the leader's cwd + settings.json, so - // the flag is true for them too. --agent-id being set - // means we ARE a spawned teammate (extractTeammateOptions runs - // ~170 lines later so check the raw commander option) — don't - // re-init the team or override teammateMode/proactive/brief. - !(options as { agentId?: unknown }).agentId && - kairosGate - ) { - if (!checkHasTrustDialogAccepted()) { - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.warn( - chalk.yellow( - "Assistant mode disabled: directory is not trusted. Accept the trust dialog and restart.", - ), - ); - } else { - // Blocking gate check — returns cached `true` instantly; if disk - // cache is false/missing, lazily inits GrowthBook and fetches fresh - // (max ~5s). --assistant skips the gate entirely (daemon is - // pre-entitled). - kairosEnabled = - assistantModule.isAssistantForced() || - (await kairosGate.isKairosEnabled()); - if (kairosEnabled) { - const opts = options as { brief?: boolean }; - opts.brief = true; - setKairosActive(true); - // Pre-seed an in-process team so Agent(name: "foo") spawns - // teammates without TeamCreate. Must run BEFORE setup() captures - // the teammateMode snapshot (initializeAssistantTeam calls - // setCliTeammateModeOverride internally). - assistantTeamContext = - await assistantModule.initializeAssistantTeam(); - } - } - } - - const { - debug = false, - debugToStderr = false, - dangerouslySkipPermissions, - allowDangerouslySkipPermissions = false, - tools: baseTools = [], - allowedTools = [], - disallowedTools = [], - mcpConfig = [], - permissionMode: permissionModeCli, - addDir = [], - fallbackModel, - betas = [], - ide = false, - sessionId, - includeHookEvents, - includePartialMessages, - } = options; - - if (options.prefill) { - seedEarlyInput(options.prefill); - } - - // Promise for file downloads - started early, awaited before REPL renders - let fileDownloadPromise: Promise | undefined; - - const agentsJson = options.agents; - const agentCli = options.agent; - if (feature("BG_SESSIONS") && agentCli) { - process.env.CLAUDE_CODE_AGENT = agentCli; - } - - // NOTE: LSP manager initialization is intentionally deferred until after - // the trust dialog is accepted. This prevents plugin LSP servers from - // executing code in untrusted directories before user consent. - - // Extract these separately so they can be modified if needed - let outputFormat = options.outputFormat; - let inputFormat = options.inputFormat; - let verbose = options.verbose ?? getGlobalConfig().verbose; - let print = options.print; - const init = options.init ?? false; - const initOnly = options.initOnly ?? false; - const maintenance = options.maintenance ?? false; - - // Extract disable slash commands flag - const disableSlashCommands = options.disableSlashCommands || false; - - // Extract tasks mode options (ant-only) - const tasksOption = - process.env.USER_TYPE === "ant" && - (options as { tasks?: boolean | string }).tasks; - const taskListId = tasksOption - ? typeof tasksOption === "string" - ? tasksOption - : DEFAULT_TASKS_MODE_TASK_LIST_ID - : undefined; - if (process.env.USER_TYPE === "ant" && taskListId) { - process.env.CLAUDE_CODE_TASK_LIST_ID = taskListId; - } - - // Extract worktree option - // worktree can be true (flag without value) or a string (custom name or PR reference) - const worktreeOption = isWorktreeModeEnabled() - ? (options as { worktree?: boolean | string }).worktree - : undefined; - let worktreeName = - typeof worktreeOption === "string" ? worktreeOption : undefined; - const worktreeEnabled = worktreeOption !== undefined; - - // Check if worktree name is a PR reference (#N or GitHub PR URL) - let worktreePRNumber: number | undefined; - if (worktreeName) { - const prNum = parsePRReference(worktreeName); - if (prNum !== null) { - worktreePRNumber = prNum; - worktreeName = undefined; // slug will be generated in setup() - } - } - - // Extract tmux option (requires --worktree) - const tmuxEnabled = - isWorktreeModeEnabled() && - (options as { tmux?: boolean }).tmux === true; - - // Validate tmux option - if (tmuxEnabled) { - if (!worktreeEnabled) { - process.stderr.write( - chalk.red("Error: --tmux requires --worktree\n"), - ); - process.exit(1); - } - if (getPlatform() === "windows") { - process.stderr.write( - chalk.red( - "Error: --tmux is not supported on Windows\n", - ), - ); - process.exit(1); - } - if (!(await isTmuxAvailable())) { - process.stderr.write( - chalk.red( - `Error: tmux is not installed.\n${getTmuxInstallInstructions()}\n`, - ), - ); - process.exit(1); - } - } - - // Extract teammate options (for tmux-spawned agents) - // Declared outside the if block so it's accessible later for system prompt addendum - let storedTeammateOpts: TeammateOptions | undefined; - if (isAgentSwarmsEnabled()) { - // Extract agent identity options (for tmux-spawned agents) - // These replace the CLAUDE_CODE_* environment variables - const teammateOpts = extractTeammateOptions(options); - storedTeammateOpts = teammateOpts; - - // If any teammate identity option is provided, all three required ones must be present - const hasAnyTeammateOpt = - teammateOpts.agentId || - teammateOpts.agentName || - teammateOpts.teamName; - const hasAllRequiredTeammateOpts = - teammateOpts.agentId && - teammateOpts.agentName && - teammateOpts.teamName; - - if (hasAnyTeammateOpt && !hasAllRequiredTeammateOpts) { - process.stderr.write( - chalk.red( - "Error: --agent-id, --agent-name, and --team-name must all be provided together\n", - ), - ); - process.exit(1); - } - - // If teammate identity is provided via CLI, set up dynamicTeamContext - if ( - teammateOpts.agentId && - teammateOpts.agentName && - teammateOpts.teamName - ) { - getTeammateUtils().setDynamicTeamContext?.({ - agentId: teammateOpts.agentId, - agentName: teammateOpts.agentName, - teamName: teammateOpts.teamName, - color: teammateOpts.agentColor, - planModeRequired: - teammateOpts.planModeRequired ?? false, - parentSessionId: teammateOpts.parentSessionId, - }); - } - - // Set teammate mode CLI override if provided - // This must be done before setup() captures the snapshot - if (teammateOpts.teammateMode) { - getTeammateModeSnapshot().setCliTeammateModeOverride?.( - teammateOpts.teammateMode, - ); - } - } - - // Extract remote sdk options - const sdkUrl = (options as { sdkUrl?: string }).sdkUrl ?? undefined; - - // Allow env var to enable partial messages (used by sandbox gateway for baku) - const effectiveIncludePartialMessages = - includePartialMessages || - isEnvTruthy(process.env.CLAUDE_CODE_INCLUDE_PARTIAL_MESSAGES); - - // Enable all hook event types when explicitly requested via SDK option - // or when running in CLAUDE_CODE_REMOTE mode (CCR needs them). - // Without this, only SessionStart and Setup events are emitted. - if ( - includeHookEvents || - isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) - ) { - setAllHookEventsEnabled(true); - } - - // Auto-set input/output formats, verbose mode, and print mode when SDK URL is provided - if (sdkUrl) { - // If SDK URL is provided, automatically use stream-json formats unless explicitly set - if (!inputFormat) { - inputFormat = "stream-json"; - } - if (!outputFormat) { - outputFormat = "stream-json"; - } - // Auto-enable verbose mode unless explicitly disabled or already set - if (options.verbose === undefined) { - verbose = true; - } - // Auto-enable print mode unless explicitly disabled - if (!options.print) { - print = true; - } - } - - // Extract teleport option - const teleport = - (options as { teleport?: string | true }).teleport ?? null; - - // Extract remote option (can be true if no description provided, or a string) - const remoteOption = (options as { remote?: string | true }).remote; - const remote = remoteOption === true ? "" : (remoteOption ?? null); - - // Extract --remote-control / --rc flag (enable bridge in interactive session) - const remoteControlOption = - (options as { remoteControl?: string | true }).remoteControl ?? - (options as { rc?: string | true }).rc; - // Actual bridge check is deferred to after showSetupScreens() so that - // trust is established and GrowthBook has auth headers. - let remoteControl = false; - const remoteControlName = - typeof remoteControlOption === "string" && - remoteControlOption.length > 0 - ? remoteControlOption - : undefined; - - // Validate session ID if provided - if (sessionId) { - // Check for conflicting flags - // --session-id can be used with --continue or --resume when --fork-session is also provided - // (to specify a custom ID for the forked session) - if ( - (options.continue || options.resume) && - !options.forkSession - ) { - process.stderr.write( - chalk.red( - "Error: --session-id can only be used with --continue or --resume if --fork-session is also specified.\n", - ), - ); - process.exit(1); - } - - // When --sdk-url is provided (bridge/remote mode), the session ID is a - // server-assigned tagged ID (e.g. "session_local_01...") rather than a - // UUID. Skip UUID validation and local existence checks in that case. - if (!sdkUrl) { - const validatedSessionId = validateUuid(sessionId); - if (!validatedSessionId) { - process.stderr.write( - chalk.red( - "Error: Invalid session ID. Must be a valid UUID.\n", - ), - ); - process.exit(1); - } - - // Check if session ID already exists - if (sessionIdExists(validatedSessionId)) { - process.stderr.write( - chalk.red( - `Error: Session ID ${validatedSessionId} is already in use.\n`, - ), - ); - process.exit(1); - } - } - } - - // Download file resources if specified via --file flag - const fileSpecs = (options as { file?: string[] }).file; - if (fileSpecs && fileSpecs.length > 0) { - // Get session ingress token (provided by EnvManager via CLAUDE_CODE_SESSION_ACCESS_TOKEN) - const sessionToken = getSessionIngressAuthToken(); - if (!sessionToken) { - process.stderr.write( - chalk.red( - "Error: Session token required for file downloads. CLAUDE_CODE_SESSION_ACCESS_TOKEN must be set.\n", - ), - ); - process.exit(1); - } - - // Resolve session ID: prefer remote session ID, fall back to internal session ID - const fileSessionId = - process.env.CLAUDE_CODE_REMOTE_SESSION_ID || getSessionId(); - - const files = parseFileSpecs(fileSpecs); - if (files.length > 0) { - // Use ANTHROPIC_BASE_URL if set (by EnvManager), otherwise use OAuth config - // This ensures consistency with session ingress API in all environments - const config: FilesApiConfig = { - baseUrl: - process.env.ANTHROPIC_BASE_URL || - getOauthConfig().BASE_API_URL, - oauthToken: sessionToken, - sessionId: fileSessionId, - }; - - // Start download without blocking startup - await before REPL renders - fileDownloadPromise = downloadSessionFiles(files, config); - } - } - - // Get isNonInteractiveSession from state (was set before init()) - const isNonInteractiveSession = getIsNonInteractiveSession(); - - // Validate that fallback model is different from main model - if ( - fallbackModel && - options.model && - fallbackModel === options.model - ) { - process.stderr.write( - chalk.red( - "Error: Fallback model cannot be the same as the main model. Please specify a different model for --fallback-model.\n", - ), - ); - process.exit(1); - } - - // Handle system prompt options - let systemPrompt = options.systemPrompt; - if (options.systemPromptFile) { - if (options.systemPrompt) { - process.stderr.write( - chalk.red( - "Error: Cannot use both --system-prompt and --system-prompt-file. Please use only one.\n", - ), - ); - process.exit(1); - } - - try { - const filePath = resolve(options.systemPromptFile); - systemPrompt = readFileSync(filePath, "utf8"); - } catch (error) { - const code = getErrnoCode(error); - if (code === "ENOENT") { - process.stderr.write( - chalk.red( - `Error: System prompt file not found: ${resolve(options.systemPromptFile)}\n`, - ), - ); - process.exit(1); - } - process.stderr.write( - chalk.red( - `Error reading system prompt file: ${errorMessage(error)}\n`, - ), - ); - process.exit(1); - } - } - - // Handle append system prompt options - let appendSystemPrompt = options.appendSystemPrompt; - if (options.appendSystemPromptFile) { - if (options.appendSystemPrompt) { - process.stderr.write( - chalk.red( - "Error: Cannot use both --append-system-prompt and --append-system-prompt-file. Please use only one.\n", - ), - ); - process.exit(1); - } - - try { - const filePath = resolve(options.appendSystemPromptFile); - appendSystemPrompt = readFileSync(filePath, "utf8"); - } catch (error) { - const code = getErrnoCode(error); - if (code === "ENOENT") { - process.stderr.write( - chalk.red( - `Error: Append system prompt file not found: ${resolve(options.appendSystemPromptFile)}\n`, - ), - ); - process.exit(1); - } - process.stderr.write( - chalk.red( - `Error reading append system prompt file: ${errorMessage(error)}\n`, - ), - ); - process.exit(1); - } - } - - // Add teammate-specific system prompt addendum for tmux teammates - if ( - isAgentSwarmsEnabled() && - storedTeammateOpts?.agentId && - storedTeammateOpts?.agentName && - storedTeammateOpts?.teamName - ) { - const addendum = - getTeammatePromptAddendum().TEAMMATE_SYSTEM_PROMPT_ADDENDUM; - appendSystemPrompt = appendSystemPrompt - ? `${appendSystemPrompt}\n\n${addendum}` - : addendum; - } - - const { - mode: permissionMode, - notification: permissionModeNotification, - } = initialPermissionModeFromCLI({ - permissionModeCli, - dangerouslySkipPermissions, - }); - - // Store session bypass permissions mode for trust dialog check - setSessionBypassPermissionsMode( - permissionMode === "bypassPermissions", - ); - if (feature("TRANSCRIPT_CLASSIFIER")) { - // autoModeFlagCli is the "did the user intend auto this session" signal. - // Set when: --enable-auto-mode, --permission-mode auto, resolved mode - // is auto, OR settings defaultMode is auto but the gate denied it - // (permissionMode resolved to default with no explicit CLI override). - // Used by verifyAutoModeGateAccess to decide whether to notify on - // auto-unavailable, and by tengu_auto_mode_config opt-in carousel. - if ( - (options as { enableAutoMode?: boolean }).enableAutoMode || - permissionModeCli === "auto" || - permissionMode === "auto" || - (!permissionModeCli && isDefaultPermissionModeAuto()) - ) { - autoModeStateModule?.setAutoModeFlagCli(true); - } - } - - // Parse the MCP config files/strings if provided - let dynamicMcpConfig: Record = { - // Built-in MCP servers (default disabled, user enables via /mcp) - "mcp-chrome": { - type: "http", - url: "http://127.0.0.1:12306/mcp", - scope: "dynamic", - "headers": { - "Authorization": "Bearer my-static-token", - } - }, - }; - - if (mcpConfig && mcpConfig.length > 0) { - // Process mcpConfig array - const processedConfigs = mcpConfig - .map((config) => config.trim()) - .filter((config) => config.length > 0); - - let allConfigs: Record = {}; - const allErrors: ValidationError[] = []; - - for (const configItem of processedConfigs) { - let configs: Record | null = null; - let errors: ValidationError[] = []; - - // First try to parse as JSON string - const parsedJson = safeParseJSON(configItem); - if (parsedJson) { - const result = parseMcpConfig({ - configObject: parsedJson, - filePath: "command line", - expandVars: true, - scope: "dynamic", - }); - if (result.config) { - configs = result.config.mcpServers; - } else { - errors = result.errors; - } - } else { - // Try as file path - const configPath = resolve(configItem); - const result = parseMcpConfigFromFilePath({ - filePath: configPath, - expandVars: true, - scope: "dynamic", - }); - if (result.config) { - configs = result.config.mcpServers; - } else { - errors = result.errors; - } - } - - if (errors.length > 0) { - allErrors.push(...errors); - } else if (configs) { - // Merge configs, later ones override earlier ones - allConfigs = { ...allConfigs, ...configs }; - } - } - - if (allErrors.length > 0) { - const formattedErrors = allErrors - .map( - (err) => - `${err.path ? err.path + ": " : ""}${err.message}`, - ) - .join("\n"); - logForDebugging( - `--mcp-config validation failed (${allErrors.length} errors): ${formattedErrors}`, - { - level: "error", - }, - ); - process.stderr.write( - `Error: Invalid MCP configuration:\n${formattedErrors}\n`, - ); - process.exit(1); - } - - if (Object.keys(allConfigs).length > 0) { - // SDK hosts (Nest/Desktop) own their server naming and may reuse - // built-in names — skip reserved-name checks for type:'sdk'. - const nonSdkConfigNames = Object.entries(allConfigs) - .filter(([, config]) => config.type !== "sdk") - .map(([name]) => name); - - let reservedNameError: string | null = null; - if (nonSdkConfigNames.some(isClaudeInChromeMCPServer)) { - reservedNameError = `Invalid MCP configuration: "${CLAUDE_IN_CHROME_MCP_SERVER_NAME}" is a reserved MCP name.`; - } else if (feature("CHICAGO_MCP")) { - const { - isComputerUseMCPServer, - COMPUTER_USE_MCP_SERVER_NAME, - } = await import("src/utils/computerUse/common.js"); - if (nonSdkConfigNames.some(isComputerUseMCPServer)) { - reservedNameError = `Invalid MCP configuration: "${COMPUTER_USE_MCP_SERVER_NAME}" is a reserved MCP name.`; - } - } - if (reservedNameError) { - // stderr+exit(1) — a throw here becomes a silent unhandled - // rejection in stream-json mode (void main() in cli.tsx). - process.stderr.write(`Error: ${reservedNameError}\n`); - process.exit(1); - } - - // Add dynamic scope to all configs. type:'sdk' entries pass through - // unchanged — they're extracted into sdkMcpConfigs downstream and - // passed to print.ts. The Python SDK relies on this path (it doesn't - // send sdkMcpServers in the initialize message). Dropping them here - // broke Coworker (inc-5122). The policy filter below already exempts - // type:'sdk', and the entries are inert without an SDK transport on - // stdin, so there's no bypass risk from letting them through. - const scopedConfigs = mapValues(allConfigs, (config) => ({ - ...config, - scope: "dynamic" as const, - })); - - // Enforce managed policy (allowedMcpServers / deniedMcpServers) on - // --mcp-config servers. Without this, the CLI flag bypasses the - // enterprise allowlist that user/project/local configs go through in - // getClaudeCodeMcpConfigs — callers spread dynamicMcpConfig back on - // top of filtered results. Filter here at the source so all - // downstream consumers see the policy-filtered set. - const { allowed, blocked } = - filterMcpServersByPolicy(scopedConfigs); - if (blocked.length > 0) { - process.stderr.write( - `Warning: MCP ${plural(blocked.length, "server")} blocked by enterprise policy: ${blocked.join(", ")}\n`, - ); - } - dynamicMcpConfig = { ...dynamicMcpConfig, ...(allowed as Record) }; - } - } - - // Extract Claude in Chrome option and enforce claude.ai subscriber check (unless user is ant) - const chromeOpts = options as { chrome?: boolean }; - // Store the explicit CLI flag so teammates can inherit it - setChromeFlagOverride(chromeOpts.chrome); - const enableClaudeInChrome = - shouldEnableClaudeInChrome(chromeOpts.chrome) && - (process.env.USER_TYPE === "ant" || isClaudeAISubscriber()); - const autoEnableClaudeInChrome = - !enableClaudeInChrome && shouldAutoEnableClaudeInChrome(); - - if (enableClaudeInChrome) { - const platform = getPlatform(); - try { - logEvent("tengu_claude_in_chrome_setup", { - platform: - platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }); - - const { - mcpConfig: chromeMcpConfig, - allowedTools: chromeMcpTools, - systemPrompt: chromeSystemPrompt, - } = setupClaudeInChrome(); - dynamicMcpConfig = { - ...dynamicMcpConfig, - ...chromeMcpConfig, - }; - allowedTools.push(...chromeMcpTools); - if (chromeSystemPrompt) { - appendSystemPrompt = appendSystemPrompt - ? `${chromeSystemPrompt}\n\n${appendSystemPrompt}` - : chromeSystemPrompt; - } - } catch (error) { - logEvent("tengu_claude_in_chrome_setup_failed", { - platform: - platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }); - logForDebugging(`[Claude in Chrome] Error: ${error}`); - logError(error); - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.error( - `Error: Failed to run with Claude in Chrome.`, - ); - process.exit(1); - } - } else if (autoEnableClaudeInChrome) { - try { - const { mcpConfig: chromeMcpConfig } = - setupClaudeInChrome(); - dynamicMcpConfig = { - ...dynamicMcpConfig, - ...chromeMcpConfig, - }; - - const hint = - feature("WEB_BROWSER_TOOL") && - typeof Bun !== "undefined" && - "WebView" in Bun - ? CLAUDE_IN_CHROME_SKILL_HINT_WITH_WEBBROWSER - : CLAUDE_IN_CHROME_SKILL_HINT; - appendSystemPrompt = appendSystemPrompt - ? `${appendSystemPrompt}\n\n${hint}` - : hint; - } catch (error) { - // Silently skip any errors for the auto-enable - logForDebugging( - `[Claude in Chrome] Error (auto-enable): ${error}`, - ); - } - } - - // Extract strict MCP config flag - const strictMcpConfig = options.strictMcpConfig || false; - - // Check if enterprise MCP configuration exists. When it does, only allow dynamic MCP - // configs that contain special server types (sdk) - if (doesEnterpriseMcpConfigExist()) { - if (strictMcpConfig) { - process.stderr.write( - chalk.red( - "You cannot use --strict-mcp-config when an enterprise MCP config is present", - ), - ); - process.exit(1); - } - - // For --mcp-config, allow if all servers are internal types (sdk) - if ( - dynamicMcpConfig && - !areMcpConfigsAllowedWithEnterpriseMcpConfig( - dynamicMcpConfig, - ) - ) { - process.stderr.write( - chalk.red( - "You cannot dynamically configure MCP servers when an enterprise MCP config is present", - ), - ); - process.exit(1); - } - } - - // chicago MCP: guarded Computer Use (app allowlist + frontmost gate + - // SCContentFilter screenshots). Ant-only, GrowthBook-gated — failures - // are silent (this is dogfooding). Platform + interactive checks inline - // so non-macOS / print-mode ants skip the heavy @ant/computer-use-mcp - // import entirely. gates.js is light (type-only package import). - // - // Placed AFTER the enterprise-MCP-config check: that check rejects any - // dynamicMcpConfig entry with `type !== 'sdk'`, and our config is - // `type: 'stdio'`. An enterprise-config ant with the GB gate on would - // otherwise process.exit(1). Chrome has the same latent issue but has - // shipped without incident; chicago places itself correctly. - if ( - feature("CHICAGO_MCP") && - getPlatform() !== "unknown" && - !getIsNonInteractiveSession() - ) { - try { - const { getChicagoEnabled } = - await import("src/utils/computerUse/gates.js"); - if (getChicagoEnabled()) { - const { setupComputerUseMCP } = - await import("src/utils/computerUse/setup.js"); - const { mcpConfig, allowedTools: cuTools } = - setupComputerUseMCP(); - dynamicMcpConfig = { - ...dynamicMcpConfig, - ...mcpConfig, - }; - allowedTools.push(...cuTools); - } - } catch (error) { - logForDebugging( - `[Computer Use MCP] Setup failed: ${errorMessage(error)}`, - ); - } - } - - // Store additional directories for CLAUDE.md loading (controlled by env var) - setAdditionalDirectoriesForClaudeMd(addDir); - - // Channel server allowlist from --channels flag — servers whose - // inbound push notifications should register this session. The option - // is added inside a feature() block so TS doesn't know about it - // on the options type — same pattern as --assistant at main.tsx:1824. - // devChannels is deferred: showSetupScreens shows a confirmation dialog - // and only appends to allowedChannels on accept. - let devChannels: ChannelEntry[] | undefined; - if (feature("KAIROS") || feature("KAIROS_CHANNELS")) { - // Parse plugin:name@marketplace / server:Y tags into typed entries. - // Tag decides trust model downstream: plugin-kind hits marketplace - // verification + GrowthBook allowlist, server-kind always fails - // allowlist (schema is plugin-only) unless dev flag is set. - // Untagged or marketplace-less plugin entries are hard errors — - // silently not-matching in the gate would look like channels are - // "on" but nothing ever fires. - const parseChannelEntries = ( - raw: string[], - flag: string, - ): ChannelEntry[] => { - const entries: ChannelEntry[] = []; - const bad: string[] = []; - for (const c of raw) { - if (c.startsWith("plugin:")) { - const rest = c.slice(7); - const at = rest.indexOf("@"); - if (at <= 0 || at === rest.length - 1) { - bad.push(c); - } else { - entries.push({ - kind: "plugin", - name: rest.slice(0, at), - marketplace: rest.slice(at + 1), - }); - } - } else if (c.startsWith("server:") && c.length > 7) { - entries.push({ kind: "server", name: c.slice(7) }); - } else { - bad.push(c); - } - } - if (bad.length > 0) { - process.stderr.write( - chalk.red( - `${flag} entries must be tagged: ${bad.join(", ")}\n` + - ` plugin:@ — plugin-provided channel (allowlist enforced)\n` + - ` server: — manually configured MCP server\n`, - ), - ); - process.exit(1); - } - return entries; - }; - - const channelOpts = options as { - channels?: string[]; - dangerouslyLoadDevelopmentChannels?: string[]; - }; - const rawChannels = channelOpts.channels; - const rawDev = channelOpts.dangerouslyLoadDevelopmentChannels; - // Always parse + set. ChannelsNotice reads getAllowedChannels() and - // renders the appropriate branch (disabled/noAuth/policyBlocked/ - // listening) in the startup screen. gateChannelServer() enforces. - // --channels works in both interactive and print/SDK modes; dev-channels - // stays interactive-only (requires a confirmation dialog). - let channelEntries: ChannelEntry[] = []; - if (rawChannels && rawChannels.length > 0) { - channelEntries = parseChannelEntries( - rawChannels, - "--channels", - ); - setAllowedChannels(channelEntries); - } - if (!isNonInteractiveSession) { - if (rawDev && rawDev.length > 0) { - devChannels = parseChannelEntries( - rawDev, - "--dangerously-load-development-channels", - ); - } - } - // Flag-usage telemetry. Plugin identifiers are logged (same tier as - // tengu_plugin_installed — public-registry-style names); server-kind - // names are not (MCP-server-name tier, opt-in-only elsewhere). - // Per-server gate outcomes land in tengu_mcp_channel_gate once - // servers connect. Dev entries go through a confirmation dialog after - // this — dev_plugins captures what was typed, not what was accepted. - if ( - channelEntries.length > 0 || - (devChannels?.length ?? 0) > 0 - ) { - const joinPluginIds = (entries: ChannelEntry[]) => { - const ids = entries.flatMap((e) => - e.kind === "plugin" - ? [`${e.name}@${e.marketplace}`] - : [], - ); - return ids.length > 0 - ? (ids - .sort() - .join( - ",", - ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) - : undefined; - }; - logEvent("tengu_mcp_channel_flags", { - channels_count: channelEntries.length, - dev_count: devChannels?.length ?? 0, - plugins: joinPluginIds(channelEntries), - dev_plugins: joinPluginIds(devChannels ?? []), - }); - } - } - - // SDK opt-in for SendUserMessage via --tools. All sessions require - // explicit opt-in; listing it in --tools signals intent. Runs BEFORE - // initializeToolPermissionContext so getToolsForDefaultPreset() sees - // the tool as enabled when computing the base-tools disallow filter. - // Conditional require avoids leaking the tool-name string into - // external builds. - if ( - (feature("KAIROS") || feature("KAIROS_BRIEF")) && - baseTools.length > 0 - ) { - /* eslint-disable @typescript-eslint/no-require-imports */ - const { BRIEF_TOOL_NAME, LEGACY_BRIEF_TOOL_NAME } = - require("@claude-code-best/builtin-tools/tools/BriefTool/prompt.js") as typeof import("@claude-code-best/builtin-tools/tools/BriefTool/prompt.js"); - const { isBriefEntitled } = - require("@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js") as typeof import("@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js"); - /* eslint-enable @typescript-eslint/no-require-imports */ - const parsed = parseToolListFromCLI(baseTools); - if ( - (parsed.includes(BRIEF_TOOL_NAME) || - parsed.includes(LEGACY_BRIEF_TOOL_NAME)) && - isBriefEntitled() - ) { - setUserMsgOptIn(true); - } - } - - // This await replaces blocking existsSync/statSync calls that were already in - // the startup path. Wall-clock time is unchanged; we just yield to the event - // loop during the fs I/O instead of blocking it. See #19661. - const initResult = await initializeToolPermissionContext({ - allowedToolsCli: allowedTools, - disallowedToolsCli: disallowedTools, - baseToolsCli: baseTools, - permissionMode, - allowDangerouslySkipPermissions, - addDirs: addDir, - }); - let toolPermissionContext = initResult.toolPermissionContext; - const { - warnings, - dangerousPermissions, - overlyBroadBashPermissions, - } = initResult; - - // Handle overly broad shell allow rules for ant users (Bash(*), PowerShell(*)) - if ( - process.env.USER_TYPE === "ant" && - overlyBroadBashPermissions.length > 0 - ) { - for (const permission of overlyBroadBashPermissions) { - logForDebugging( - `Ignoring overly broad shell permission ${permission.ruleDisplay} from ${permission.sourceDisplay}`, - ); - } - toolPermissionContext = removeDangerousPermissions( - toolPermissionContext, - overlyBroadBashPermissions, - ); - } - - if ( - feature("TRANSCRIPT_CLASSIFIER") && - dangerousPermissions.length > 0 - ) { - toolPermissionContext = stripDangerousPermissionsForAutoMode( - toolPermissionContext, - ); - } - - // Print any warnings from initialization - warnings.forEach((warning) => { - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.error(warning); - }); - - - // claude.ai config fetch: -p mode only (interactive uses useManageMCPConnections - // two-phase loading). Kicked off here to overlap with setup(); awaited - // before runHeadless so single-turn -p sees connectors. Skipped under - // enterprise/strict MCP to preserve policy boundaries. - const claudeaiConfigPromise: Promise< - Record - > = - isNonInteractiveSession && - !strictMcpConfig && - !doesEnterpriseMcpConfigExist() && - // --bare / SIMPLE: skip claude.ai proxy servers (datadog, Gmail, - // Slack, BigQuery, PubMed — 6-14s each to connect). Scripted calls - // that need MCP pass --mcp-config explicitly. - !isBareMode() - ? fetchClaudeAIMcpConfigsIfEligible().then((configs) => { - const { allowed, blocked } = - filterMcpServersByPolicy(configs); - if (blocked.length > 0) { - process.stderr.write( - `Warning: claude.ai MCP ${plural(blocked.length, "server")} blocked by enterprise policy: ${blocked.join(", ")}\n`, - ); - } - return allowed; - }) - : Promise.resolve({}); - - // Kick off MCP config loading early (safe - just reads files, no execution). - // Both interactive and -p use getClaudeCodeMcpConfigs (local file reads only). - // The local promise is awaited later (before prefetchAllMcpResources) to - // overlap config I/O with setup(), commands loading, and trust dialog. - logForDebugging("[STARTUP] Loading MCP configs..."); - const mcpConfigStart = Date.now(); - let mcpConfigResolvedMs: number | undefined; - // --bare skips auto-discovered MCP (.mcp.json, user settings, plugins) — - // only explicit --mcp-config works. dynamicMcpConfig is spread onto - // allMcpConfigs downstream so it survives this skip. - const mcpConfigPromise = ( - strictMcpConfig || isBareMode() - ? Promise.resolve({ - servers: {} as Record< - string, - ScopedMcpServerConfig - >, - }) - : getClaudeCodeMcpConfigs(dynamicMcpConfig) - ).then((result) => { - mcpConfigResolvedMs = Date.now() - mcpConfigStart; - return result; - }); - - // NOTE: We do NOT call prefetchAllMcpResources here - that's deferred until after trust dialog - - if ( - inputFormat && - inputFormat !== "text" && - inputFormat !== "stream-json" - ) { - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.error(`Error: Invalid input format "${inputFormat}".`); - process.exit(1); - } - if ( - inputFormat === "stream-json" && - outputFormat !== "stream-json" - ) { - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.error( - `Error: --input-format=stream-json requires output-format=stream-json.`, - ); - process.exit(1); - } - - // Validate sdkUrl is only used with appropriate formats (formats are auto-set above) - if (sdkUrl) { - if ( - inputFormat !== "stream-json" || - outputFormat !== "stream-json" - ) { - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.error( - `Error: --sdk-url requires both --input-format=stream-json and --output-format=stream-json.`, - ); - process.exit(1); - } - } - - // Validate replayUserMessages is only used with stream-json formats - if (options.replayUserMessages) { - if ( - inputFormat !== "stream-json" || - outputFormat !== "stream-json" - ) { - // biome-ignore lint/suspicious/noConsole:: intentional console output - console.error( - `Error: --replay-user-messages requires both --input-format=stream-json and --output-format=stream-json.`, - ); - process.exit(1); - } - } - - // Validate includePartialMessages is only used with print mode and stream-json output - if (effectiveIncludePartialMessages) { - if ( - !isNonInteractiveSession || - outputFormat !== "stream-json" - ) { - writeToStderr( - `Error: --include-partial-messages requires --print and --output-format=stream-json.`, - ); - process.exit(1); - } - } - - // Validate --no-session-persistence is only used with print mode - if ( - options.sessionPersistence === false && - !isNonInteractiveSession - ) { - writeToStderr( - `Error: --no-session-persistence can only be used with --print mode.`, - ); - process.exit(1); - } - - const effectivePrompt = prompt || ""; - let inputPrompt = await getInputPrompt( - effectivePrompt, - (inputFormat ?? "text") as "text" | "stream-json", - ); - profileCheckpoint("action_after_input_prompt"); - - // Activate proactive mode BEFORE getTools() so SleepTool.isEnabled() - // (which returns isProactiveActive()) passes and Sleep is included. - // The later REPL-path maybeActivateProactive() calls are idempotent. - maybeActivateProactive(options); - - let tools = getTools(toolPermissionContext); - - // Apply coordinator mode tool filtering for headless path - // (mirrors useMergedTools.ts filtering for REPL/interactive path) - if ( - feature("COORDINATOR_MODE") && - isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) - ) { - const { applyCoordinatorToolFilter } = - await import("./utils/toolPool.js"); - tools = applyCoordinatorToolFilter(tools); - } - - profileCheckpoint("action_tools_loaded"); - - let jsonSchema: ToolInputJSONSchema | undefined; - if ( - isSyntheticOutputToolEnabled({ isNonInteractiveSession }) && - options.jsonSchema - ) { - jsonSchema = jsonParse( - options.jsonSchema, - ) as ToolInputJSONSchema; - } - - if (jsonSchema) { - const syntheticOutputResult = - createSyntheticOutputTool(jsonSchema); - if ("tool" in syntheticOutputResult) { - // Add SyntheticOutputTool to the tools array AFTER getTools() filtering. - // This tool is excluded from normal filtering (see tools.ts) because it's - // an implementation detail for structured output, not a user-controlled tool. - tools = [...tools, syntheticOutputResult.tool]; - - logEvent("tengu_structured_output_enabled", { - schema_property_count: Object.keys( - (jsonSchema.properties as Record< - string, - unknown - >) || {}, - ) - .length as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - has_required_fields: Boolean( - jsonSchema.required, - ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }); - } else { - logEvent("tengu_structured_output_failure", { - error: "Invalid JSON schema" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }); - } - } - - // IMPORTANT: setup() must be called before any other code that depends on the cwd or worktree setup - profileCheckpoint("action_before_setup"); - logForDebugging("[STARTUP] Running setup()..."); - const setupStart = Date.now(); - const { setup } = await import("./setup.js"); - const messagingSocketPath = feature("UDS_INBOX") - ? (options as { messagingSocketPath?: string }) - .messagingSocketPath - : undefined; - // Parallelize setup() with commands+agents loading. setup()'s ~28ms is - // mostly startUdsMessaging (socket bind, ~20ms) — not disk-bound, so it - // doesn't contend with getCommands' file reads. Gated on !worktreeEnabled - // since --worktree makes setup() process.chdir() (setup.ts:203), and - // commands/agents need the post-chdir cwd. - const preSetupCwd = getCwd(); - // Register bundled skills/plugins before kicking getCommands() — they're - // pure in-memory array pushes (<1ms, zero I/O) that getBundledSkills() - // reads synchronously. Previously ran inside setup() after ~20ms of - // await points, so the parallel getCommands() memoized an empty list. - if (process.env.CLAUDE_CODE_ENTRYPOINT !== "local-agent") { - initBuiltinPlugins(); - initBundledSkills(); - } - const setupPromise = setup( - preSetupCwd, - permissionMode, - allowDangerouslySkipPermissions, - worktreeEnabled, - worktreeName, - tmuxEnabled, - sessionId ? validateUuid(sessionId) : undefined, - worktreePRNumber, - messagingSocketPath, - ); - const commandsPromise = worktreeEnabled - ? null - : getCommands(preSetupCwd); - const agentDefsPromise = worktreeEnabled - ? null - : getAgentDefinitionsWithOverrides(preSetupCwd); - // Suppress transient unhandledRejection if these reject during the - // ~28ms setupPromise await before Promise.all joins them below. - commandsPromise?.catch(() => {}); - agentDefsPromise?.catch(() => {}); - await setupPromise; - logForDebugging( - `[STARTUP] setup() completed in ${Date.now() - setupStart}ms`, - ); - profileCheckpoint("action_after_setup"); - - // Replay user messages into stream-json only when the socket was - // explicitly requested. The auto-generated socket is passive — it - // lets tools inject if they want to, but turning it on by default - // shouldn't reshape stream-json for SDK consumers who never touch it. - // Callers who inject and also want those injections visible in the - // stream pass --messaging-socket-path explicitly (or --replay-user-messages). - let effectiveReplayUserMessages = !!options.replayUserMessages; - if (feature("UDS_INBOX")) { - if ( - !effectiveReplayUserMessages && - outputFormat === "stream-json" - ) { - effectiveReplayUserMessages = !!( - options as { messagingSocketPath?: string } - ).messagingSocketPath; - } - } - - if (getIsNonInteractiveSession()) { - // Apply full merged settings env now (including project-scoped - // .claude/settings.json PATH/GIT_DIR/GIT_WORK_TREE) so gitExe() and - // the git spawn below see it. Trust is implicit in -p mode; the - // docstring at managedEnv.ts:96-97 says this applies "potentially - // dangerous environment variables such as LD_PRELOAD, PATH" from all - // sources. The later call in the isNonInteractiveSession block below - // is idempotent (Object.assign, configureGlobalAgents ejects prior - // interceptor) and picks up any plugin-contributed env after plugin - // init. Project settings are already loaded here: - // applySafeConfigEnvironmentVariables in init() called - // getSettings_DEPRECATED at managedEnv.ts:86 which merges all enabled - // sources including projectSettings/localSettings. - applyConfigEnvironmentVariables(); - - // Spawn git status/log/branch now so the subprocess execution overlaps - // with the getCommands await below and startDeferredPrefetches. After - // setup() so cwd is final (setup.ts:254 may process.chdir(worktreePath) - // for --worktree) and after the applyConfigEnvironmentVariables above - // so PATH/GIT_DIR/GIT_WORK_TREE from all sources (trusted + project) - // are applied. getSystemContext is memoized; the - // prefetchSystemContextIfSafe call in startDeferredPrefetches becomes - // a cache hit. The microtask from await getIsGit() drains at the - // getCommands Promise.all await below. Trust is implicit in -p mode - // (same gate as prefetchSystemContextIfSafe). - void getSystemContext(); - // Kick getUserContext now too — its first await (fs.readFile in - // getMemoryFiles) yields naturally, so the CLAUDE.md directory walk - // runs during the ~280ms overlap window before the context - // Promise.all join in print.ts. The void getUserContext() in - // startDeferredPrefetches becomes a memoize cache-hit. - void getUserContext(); - // Kick ensureModelStringsInitialized now — for Bedrock this triggers - // a 100-200ms profile fetch that was awaited serially at - // print.ts:739. updateBedrockModelStrings is sequential()-wrapped so - // the await joins the in-flight fetch. Non-Bedrock is a sync - // early-return (zero-cost). - void ensureModelStringsInitialized(); - } - - // Apply --name: cache-only so no orphan file is created before the - // session ID is finalized by --continue/--resume. materializeSessionFile - // persists it on the first user message; REPL's useTerminalTitle reads it - // via getCurrentSessionTitle. - const sessionNameArg = options.name?.trim(); - if (sessionNameArg) { - cacheSessionTitle(sessionNameArg); - } - - // Ant model aliases (capybara-fast etc.) resolve via the - // tengu_ant_model_override GrowthBook flag. _CACHED_MAY_BE_STALE reads - // disk synchronously; disk is populated by a fire-and-forget write. On a - // cold cache, parseUserSpecifiedModel returns the unresolved alias, the - // API 404s, and -p exits before the async write lands — crashloop on - // fresh pods. Awaiting init here populates the in-memory payload map that - // _CACHED_MAY_BE_STALE now checks first. Gated so the warm path stays - // non-blocking: - // - explicit model via --model or ANTHROPIC_MODEL (both feed alias resolution) - // - no env override (which short-circuits _CACHED_MAY_BE_STALE before disk) - // - flag absent from disk (== null also catches pre-#22279 poisoned null) - const explicitModel = options.model || process.env.ANTHROPIC_MODEL; - if ( - process.env.USER_TYPE === "ant" && - explicitModel && - explicitModel !== "default" && - !hasGrowthBookEnvOverride("tengu_ant_model_override") && - getGlobalConfig().cachedGrowthBookFeatures?.[ - "tengu_ant_model_override" - ] == null - ) { - await initializeGrowthBook(); - } - - // Special case the default model with the null keyword - // NOTE: Model resolution happens after setup() to ensure trust is established before AWS auth - const userSpecifiedModel = - options.model === "default" - ? getDefaultMainLoopModel() - : options.model; - const userSpecifiedFallbackModel = - fallbackModel === "default" - ? getDefaultMainLoopModel() - : fallbackModel; - - // Reuse preSetupCwd unless setup() chdir'd (worktreeEnabled). Saves a - // getCwd() syscall in the common path. - const currentCwd = worktreeEnabled ? getCwd() : preSetupCwd; - logForDebugging("[STARTUP] Loading commands and agents..."); - const commandsStart = Date.now(); - // Join the promises kicked before setup() (or start fresh if - // worktreeEnabled gated the early kick). Both memoized by cwd. - const [commands, agentDefinitionsResult] = await Promise.all([ - commandsPromise ?? getCommands(currentCwd), - agentDefsPromise ?? - getAgentDefinitionsWithOverrides(currentCwd), - ]); - logForDebugging( - `[STARTUP] Commands and agents loaded in ${Date.now() - commandsStart}ms`, - ); - profileCheckpoint("action_commands_loaded"); - - // Parse CLI agents if provided via --agents flag - let cliAgents: typeof agentDefinitionsResult.activeAgents = []; - if (agentsJson) { - try { - const parsedAgents = safeParseJSON(agentsJson); - if (parsedAgents) { - cliAgents = parseAgentsFromJson( - parsedAgents, - "flagSettings", - ); - } - } catch (error) { - logError(error); - } - } - - // Merge CLI agents with existing ones - const allAgents = [ - ...agentDefinitionsResult.allAgents, - ...cliAgents, - ]; - const agentDefinitions = { - ...agentDefinitionsResult, - allAgents, - activeAgents: getActiveAgentsFromList(allAgents), - }; - - // Look up main thread agent from CLI flag or settings - const agentSetting = agentCli ?? getInitialSettings().agent; - let mainThreadAgentDefinition: - | (typeof agentDefinitions.activeAgents)[number] - | undefined; - if (agentSetting) { - mainThreadAgentDefinition = agentDefinitions.activeAgents.find( - (agent) => agent.agentType === agentSetting, - ); - if (!mainThreadAgentDefinition) { - logForDebugging( - `Warning: agent "${agentSetting}" not found. ` + - `Available agents: ${agentDefinitions.activeAgents.map((a) => a.agentType).join(", ")}. ` + - `Using default behavior.`, - ); - } - } - - // Store the main thread agent type in bootstrap state so hooks can access it - setMainThreadAgentType(mainThreadAgentDefinition?.agentType); - - // Log agent flag usage — only log agent name for built-in agents to avoid leaking custom agent names - if (mainThreadAgentDefinition) { - logEvent("tengu_agent_flag", { - agentType: isBuiltInAgent(mainThreadAgentDefinition) - ? (mainThreadAgentDefinition.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) - : ("custom" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS), - ...(agentCli && { - source: "cli" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }), - }); - } - - // Persist agent setting to session transcript for resume view display and restoration - if (mainThreadAgentDefinition?.agentType) { - saveAgentSetting(mainThreadAgentDefinition.agentType); - } - - // Apply the agent's system prompt for non-interactive sessions - // (interactive mode uses buildEffectiveSystemPrompt instead) - if ( - isNonInteractiveSession && - mainThreadAgentDefinition && - !systemPrompt && - !isBuiltInAgent(mainThreadAgentDefinition) - ) { - const agentSystemPrompt = - mainThreadAgentDefinition.getSystemPrompt(); - if (agentSystemPrompt) { - systemPrompt = agentSystemPrompt; - } - } - - // initialPrompt goes first so its slash command (if any) is processed; - // user-provided text becomes trailing context. - // Only concatenate when inputPrompt is a string. When it's an - // AsyncIterable (SDK stream-json mode), template interpolation would - // call .toString() producing "[object Object]". The AsyncIterable case - // is handled in print.ts via structuredIO.prependUserMessage(). - if (mainThreadAgentDefinition?.initialPrompt) { - if (typeof inputPrompt === "string") { - inputPrompt = inputPrompt - ? `${mainThreadAgentDefinition.initialPrompt}\n\n${inputPrompt}` - : mainThreadAgentDefinition.initialPrompt; - } else if (!inputPrompt) { - inputPrompt = mainThreadAgentDefinition.initialPrompt; - } - } - - // Compute effective model early so hooks can run in parallel with MCP - // If user didn't specify a model but agent has one, use the agent's model - let effectiveModel = userSpecifiedModel; - if ( - !effectiveModel && - mainThreadAgentDefinition?.model && - mainThreadAgentDefinition.model !== "inherit" - ) { - effectiveModel = parseUserSpecifiedModel( - mainThreadAgentDefinition.model, - ); - } - - setMainLoopModelOverride(effectiveModel); - - // Compute resolved model for hooks (use user-specified model at launch) - setInitialMainLoopModel(getUserSpecifiedModelSetting() || null); - const initialMainLoopModel = getInitialMainLoopModel(); - const resolvedInitialModel = parseUserSpecifiedModel( - initialMainLoopModel ?? getDefaultMainLoopModel(), - ); - - let advisorModel: string | undefined; - if (isAdvisorEnabled()) { - const advisorOption = canUserConfigureAdvisor() - ? (options as { advisor?: string }).advisor - : undefined; - if (advisorOption) { - logForDebugging(`[AdvisorTool] --advisor ${advisorOption}`); - if (!modelSupportsAdvisor(resolvedInitialModel)) { - process.stderr.write( - chalk.red( - `Error: The model "${resolvedInitialModel}" does not support the advisor tool.\n`, - ), - ); - process.exit(1); - } - const normalizedAdvisorModel = normalizeModelStringForAPI( - parseUserSpecifiedModel(advisorOption), - ); - if (!isValidAdvisorModel(normalizedAdvisorModel)) { - process.stderr.write( - chalk.red( - `Error: The model "${advisorOption}" cannot be used as an advisor.\n`, - ), - ); - process.exit(1); - } - } - advisorModel = canUserConfigureAdvisor() - ? (advisorOption ?? getInitialAdvisorSetting()) - : advisorOption; - if (advisorModel) { - logForDebugging( - `[AdvisorTool] Advisor model: ${advisorModel}`, - ); - } - } - - // For tmux teammates with --agent-type, append the custom agent's prompt - if ( - isAgentSwarmsEnabled() && - storedTeammateOpts?.agentId && - storedTeammateOpts?.agentName && - storedTeammateOpts?.teamName && - storedTeammateOpts?.agentType - ) { - // Look up the custom agent definition - const customAgent = agentDefinitions.activeAgents.find( - (a) => a.agentType === storedTeammateOpts.agentType, - ); - if (customAgent) { - // Get the prompt - need to handle both built-in and custom agents - let customPrompt: string | undefined; - if (customAgent.source === "built-in") { - // Built-in agents have getSystemPrompt that takes toolUseContext - // We can't access full toolUseContext here, so skip for now - logForDebugging( - `[teammate] Built-in agent ${storedTeammateOpts.agentType} - skipping custom prompt (not supported)`, - ); - } else { - // Custom agents have getSystemPrompt that takes no args - customPrompt = customAgent.getSystemPrompt(); - } - - // Log agent memory loaded event for tmux teammates - if (customAgent.memory) { - logEvent("tengu_agent_memory_loaded", { - ...(process.env.USER_TYPE === "ant" && { - agent_type: - customAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }), - scope: customAgent.memory as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - source: "teammate" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }); - } - - if (customPrompt) { - const customInstructions = `\n# Custom Agent Instructions\n${customPrompt}`; - appendSystemPrompt = appendSystemPrompt - ? `${appendSystemPrompt}\n\n${customInstructions}` - : customInstructions; - } - } else { - logForDebugging( - `[teammate] Custom agent ${storedTeammateOpts.agentType} not found in available agents`, - ); - } - } - - maybeActivateBrief(options); - // defaultView: 'chat' is a persisted opt-in — check entitlement and set - // userMsgOptIn so the tool + prompt section activate. Interactive-only: - // defaultView is a display preference; SDK sessions have no display, and - // the assistant installer writes defaultView:'chat' to settings.local.json - // which would otherwise leak into --print sessions in the same directory. - // Runs right after maybeActivateBrief() so all startup opt-in paths fire - // BEFORE any isBriefEnabled() read below (proactive prompt's - // briefVisibility). A persisted 'chat' after a GB kill-switch falls - // through (entitlement fails). - if ( - (feature("KAIROS") || feature("KAIROS_BRIEF")) && - !getIsNonInteractiveSession() && - !getUserMsgOptIn() && - getInitialSettings().defaultView === "chat" - ) { - /* eslint-disable @typescript-eslint/no-require-imports */ - const { isBriefEntitled } = - require("@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js") as typeof import("@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js"); - /* eslint-enable @typescript-eslint/no-require-imports */ - if (isBriefEntitled()) { - setUserMsgOptIn(true); - } - } - // Coordinator mode has its own system prompt and filters out Sleep, so - // the generic proactive prompt would tell it to call a tool it can't - // access and conflict with delegation instructions. - if ( - (feature("PROACTIVE") || feature("KAIROS")) && - ((options as { proactive?: boolean }).proactive || - isEnvTruthy(process.env.CLAUDE_CODE_PROACTIVE)) && - !coordinatorModeModule?.isCoordinatorMode() - ) { - /* eslint-disable @typescript-eslint/no-require-imports */ - const briefVisibility = - feature("KAIROS") || feature("KAIROS_BRIEF") - ? ( - require("@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js") as typeof import("@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js") - ).isBriefEnabled() - ? "Call SendUserMessage at checkpoints to mark where things stand." - : "The user will see any text you output." - : "The user will see any text you output."; - /* eslint-enable @typescript-eslint/no-require-imports */ - const proactivePrompt = `\n# Proactive Mode\n\nYou are in proactive mode. Take initiative — explore, act, and make progress without waiting for instructions.\n\nStart by briefly greeting the user.\n\nYou will receive periodic prompts. These are check-ins. Do whatever seems most useful, or call Sleep if there's nothing to do. ${briefVisibility}`; - appendSystemPrompt = appendSystemPrompt - ? `${appendSystemPrompt}\n\n${proactivePrompt}` - : proactivePrompt; - } - - if (feature("KAIROS") && kairosEnabled && assistantModule) { - const assistantAddendum = - assistantModule.getAssistantSystemPromptAddendum(); - appendSystemPrompt = appendSystemPrompt - ? `${appendSystemPrompt}\n\n${assistantAddendum}` - : assistantAddendum; - } - - // Ink root is only needed for interactive sessions — patchConsole in the - // Ink constructor would swallow console output in headless mode. - let root!: Root; - let getFpsMetrics!: () => FpsMetrics | undefined; - let stats!: StatsStore; - - // Show setup screens after commands are loaded - if (!isNonInteractiveSession) { - const ctx = getRenderContext(false); - getFpsMetrics = ctx.getFpsMetrics; - stats = ctx.stats; - // Install asciicast recorder before Ink mounts (ant-only, opt-in via CLAUDE_CODE_TERMINAL_RECORDING=1) - if (process.env.USER_TYPE === "ant") { - installAsciicastRecorder(); - } - - const { createRoot } = await import('@anthropic/ink') - root = await createRoot(ctx.renderOptions) - - // Log startup time now, before any blocking dialog renders. Logging - // from REPL's first render (the old location) included however long - // the user sat on trust/OAuth/onboarding/resume-picker — p99 was ~70s - // dominated by dialog-wait time, not code-path startup. - logEvent("tengu_timer", { - event: "startup" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - durationMs: Math.round(process.uptime() * 1000), - }); - - logForDebugging("[STARTUP] Running showSetupScreens()..."); - const setupScreensStart = Date.now(); - const onboardingShown = await showSetupScreens( - root, - permissionMode, - allowDangerouslySkipPermissions, - commands, - enableClaudeInChrome, - devChannels, - ); - logForDebugging( - `[STARTUP] showSetupScreens() completed in ${Date.now() - setupScreensStart}ms`, - ); - - // Now that trust is established and GrowthBook has auth headers, - // resolve the --remote-control / --rc entitlement gate. - if ( - feature("BRIDGE_MODE") && - remoteControlOption !== undefined - ) { - const { getBridgeDisabledReason } = - await import("./bridge/bridgeEnabled.js"); - const disabledReason = await getBridgeDisabledReason(); - remoteControl = disabledReason === null; - if (disabledReason) { - process.stderr.write( - chalk.yellow( - `${disabledReason}\n--rc flag ignored.\n`, - ), - ); - } - } - - // Check for pending agent memory snapshot updates (only for --agent mode, ant-only) - if ( - feature("AGENT_MEMORY_SNAPSHOT") && - mainThreadAgentDefinition && - isCustomAgent(mainThreadAgentDefinition) && - mainThreadAgentDefinition.memory && - mainThreadAgentDefinition.pendingSnapshotUpdate - ) { - const agentDef = mainThreadAgentDefinition; - const choice = await launchSnapshotUpdateDialog(root, { - agentType: agentDef.agentType, - scope: agentDef.memory!, - snapshotTimestamp: - agentDef.pendingSnapshotUpdate!.snapshotTimestamp, - }); - if (choice === "merge") { - const { buildMergePrompt } = - await import("./components/agents/SnapshotUpdateDialog.js"); - const mergePrompt = buildMergePrompt( - agentDef.agentType, - agentDef.memory!, - ); - inputPrompt = inputPrompt - ? `${mergePrompt}\n\n${inputPrompt}` - : mergePrompt; - } - agentDef.pendingSnapshotUpdate = undefined; - } - - // Skip executing /login if we just completed onboarding for it - if ( - onboardingShown && - prompt?.trim().toLowerCase() === "/login" - ) { - prompt = ""; - } - - if (onboardingShown) { - // Refresh auth-dependent services now that the user has logged in during onboarding. - // Keep in sync with the post-login logic in src/commands/login.tsx - void refreshRemoteManagedSettings(); - void refreshPolicyLimits(); - // Clear user data cache BEFORE GrowthBook refresh so it picks up fresh credentials - resetUserCache(); - // Refresh GrowthBook after login to get updated feature flags (e.g., for claude.ai MCPs) - refreshGrowthBookAfterAuthChange(); - // Clear any stale trusted device token then enroll for Remote Control. - // Both self-gate on tengu_sessions_elevated_auth_enforcement internally - // — enrollTrustedDevice() via checkGate_CACHED_OR_BLOCKING (awaits - // the GrowthBook reinit above), clearTrustedDeviceToken() via the - // sync cached check (acceptable since clear is idempotent). - void import("./bridge/trustedDevice.js").then((m) => { - m.clearTrustedDeviceToken(); - return m.enrollTrustedDevice(); - }); - } - - // Validate that the active token's org matches forceLoginOrgUUID (if set - // in managed settings). Runs after onboarding so managed settings and - // login state are fully loaded. - const orgValidation = await validateForceLoginOrg(); - if (!orgValidation.valid) { - await exitWithError(root, (orgValidation as { valid: false; message: string }).message); - } - } - - // If gracefulShutdown was initiated (e.g., user rejected trust dialog), - // process.exitCode will be set. Skip all subsequent operations that could - // trigger code execution before the process exits (e.g. we don't want apiKeyHelper - // to run if trust was not established). - if (process.exitCode !== undefined) { - logForDebugging( - "Graceful shutdown initiated, skipping further initialization", - ); - return; - } - - // Initialize LSP manager AFTER trust is established (or in non-interactive mode - // where trust is implicit). This prevents plugin LSP servers from executing - // code in untrusted directories before user consent. - // Must be after inline plugins are set (if any) so --plugin-dir LSP servers are included. - initializeLspServerManager(); - - // Show settings validation errors after trust is established - // MCP config errors don't block settings from loading, so exclude them - if (!isNonInteractiveSession) { - const { errors } = getSettingsWithErrors(); - const nonMcpErrors = errors.filter((e) => !e.mcpErrorMetadata); - if (nonMcpErrors.length > 0) { - await launchInvalidSettingsDialog(root, { - settingsErrors: nonMcpErrors, - onExit: () => gracefulShutdownSync(1), - }); - } - } - - // Check quota status, fast mode, passes eligibility, and bootstrap data - // after trust is established. These make API calls which could trigger - // apiKeyHelper execution. - // --bare / SIMPLE: skip — these are cache-warms for the REPL's - // first-turn responsiveness (quota, passes, fastMode, bootstrap data). Fast - // mode doesn't apply to the Agent SDK anyway (see getFastModeUnavailableReason). - const bgRefreshThrottleMs = getFeatureValue_CACHED_MAY_BE_STALE( - "tengu_cicada_nap_ms", - 0, - ); - const lastPrefetched = getGlobalConfig().startupPrefetchedAt ?? 0; - const skipStartupPrefetches = - isBareMode() || - (bgRefreshThrottleMs > 0 && - Date.now() - lastPrefetched < bgRefreshThrottleMs); - - if (!skipStartupPrefetches) { - const lastPrefetchedInfo = - lastPrefetched > 0 - ? ` last ran ${Math.round((Date.now() - lastPrefetched) / 1000)}s ago` - : ""; - logForDebugging( - `Starting background startup prefetches${lastPrefetchedInfo}`, - ); - - checkQuotaStatus().catch((error) => logError(error)); - - // Fetch bootstrap data from the server and update all cache values. - void fetchBootstrapData(); - - // TODO: Consolidate other prefetches into a single bootstrap request. - void prefetchPassesEligibility(); - if ( - !getFeatureValue_CACHED_MAY_BE_STALE( - "tengu_miraculo_the_bard", - false, - ) - ) { - void prefetchFastModeStatus(); - } else { - // Kill switch skips the network call, not org-policy enforcement. - // Resolve from cache so orgStatus doesn't stay 'pending' (which - // getFastModeUnavailableReason treats as permissive). - resolveFastModeStatusFromCache(); - } - if (bgRefreshThrottleMs > 0) { - saveGlobalConfig((current) => ({ - ...current, - startupPrefetchedAt: Date.now(), - })); - } - } else { - logForDebugging( - `Skipping startup prefetches, last ran ${Math.round((Date.now() - lastPrefetched) / 1000)}s ago`, - ); - // Resolve fast mode org status from cache (no network) - resolveFastModeStatusFromCache(); - } - - if (!isNonInteractiveSession) { - void refreshExampleCommands(); // Pre-fetch example commands (runs git log, no API call) - } - - // Resolve MCP configs (started early, overlaps with setup/trust dialog work) - const { servers: existingMcpConfigs } = await mcpConfigPromise; - logForDebugging( - `[STARTUP] MCP configs resolved in ${mcpConfigResolvedMs}ms (awaited at +${Date.now() - mcpConfigStart}ms)`, - ); - // CLI flag (--mcp-config) should override file-based configs, matching settings precedence - const allMcpConfigs = { - ...existingMcpConfigs, - ...dynamicMcpConfig, - }; - - // Separate SDK configs from regular MCP configs - const sdkMcpConfigs: Record = {}; - const regularMcpConfigs: Record = {}; - - for (const [name, config] of Object.entries(allMcpConfigs)) { - const typedConfig = config as - | ScopedMcpServerConfig - | McpSdkServerConfig; - if (typedConfig.type === "sdk") { - sdkMcpConfigs[name] = typedConfig as McpSdkServerConfig; - } else { - regularMcpConfigs[name] = - typedConfig as ScopedMcpServerConfig; - } - } - - profileCheckpoint("action_mcp_configs_loaded"); - - // Prefetch MCP resources after trust dialog (this is where execution happens). - // Interactive mode only: print mode defers connects until headlessStore exists - // and pushes per-server (below), so ToolSearch's pending-client handling works - // and one slow server doesn't block the batch. - const localMcpPromise = isNonInteractiveSession - ? Promise.resolve({ clients: [], tools: [], commands: [] }) - : prefetchAllMcpResources(regularMcpConfigs); - const claudeaiMcpPromise = isNonInteractiveSession - ? Promise.resolve({ clients: [], tools: [], commands: [] }) - : claudeaiConfigPromise.then((configs) => - Object.keys(configs).length > 0 - ? prefetchAllMcpResources(configs) - : { clients: [], tools: [], commands: [] }, - ); - // Merge with dedup by name: each prefetchAllMcpResources call independently - // adds helper tools (ListMcpResourcesTool, ReadMcpResourceTool) via - // local dedup flags, so merging two calls can yield duplicates. print.ts - // already uniqBy's the final tool pool, but dedup here keeps appState clean. - const mcpPromise = Promise.all([ - localMcpPromise, - claudeaiMcpPromise, - ]).then(([local, claudeai]) => ({ - clients: [...local.clients, ...claudeai.clients], - tools: uniqBy([...local.tools, ...claudeai.tools], "name"), - commands: uniqBy( - [...local.commands, ...claudeai.commands], - "name", - ), - })); - - // Start hooks early so they run in parallel with MCP connections. - // Skip for initOnly/init/maintenance (handled separately), non-interactive - // (handled via setupTrigger), and resume/continue (conversationRecovery.ts - // fires 'resume' instead — without this guard, hooks fire TWICE on /resume - // and the second systemMessage clobbers the first. gh-30825) - const hooksPromise = - initOnly || - init || - maintenance || - isNonInteractiveSession || - options.continue || - options.resume - ? null - : processSessionStartHooks("startup", { - agentType: mainThreadAgentDefinition?.agentType, - model: resolvedInitialModel, - }); - - // MCP never blocks REPL render OR turn 1 TTFT. useManageMCPConnections - // populates appState.mcp async as servers connect (connectToServer is - // memoized — the prefetch calls above and the hook converge on the same - // connections). getToolUseContext reads store.getState() fresh via - // computeTools(), so turn 1 sees whatever's connected by query time. - // Slow servers populate for turn 2+. Matches interactive-no-prompt - // behavior. Print mode: per-server push into headlessStore (below). - const hookMessages: Awaited> = []; - // Suppress transient unhandledRejection — the prefetch warms the - // memoized connectToServer cache but nobody awaits it in interactive. - mcpPromise.catch(() => {}); - - const mcpClients: Awaited["clients"] = []; - const mcpTools: Awaited["tools"] = []; - const mcpCommands: Awaited["commands"] = []; - - let thinkingEnabled = shouldEnableThinkingByDefault(); - let thinkingConfig: ThinkingConfig = - thinkingEnabled !== false - ? { type: "adaptive" } - : { type: "disabled" }; - - if ( - options.thinking === "adaptive" || - options.thinking === "enabled" - ) { - thinkingEnabled = true; - thinkingConfig = { type: "adaptive" }; - } else if (options.thinking === "disabled") { - thinkingEnabled = false; - thinkingConfig = { type: "disabled" }; - } else { - const maxThinkingTokens = process.env.MAX_THINKING_TOKENS - ? parseInt(process.env.MAX_THINKING_TOKENS, 10) - : options.maxThinkingTokens; - if (maxThinkingTokens !== undefined) { - if (maxThinkingTokens > 0) { - thinkingEnabled = true; - thinkingConfig = { - type: "enabled", - budgetTokens: maxThinkingTokens, - }; - } else if (maxThinkingTokens === 0) { - thinkingEnabled = false; - thinkingConfig = { type: "disabled" }; - } - } - } - - logForDiagnosticsNoPII("info", "started", { - version: MACRO.VERSION, - is_native_binary: isInBundledMode(), - }); - - registerCleanup(async () => { - logForDiagnosticsNoPII("info", "exited"); - }); - - void logTenguInit({ - hasInitialPrompt: Boolean(prompt), - hasStdin: Boolean(inputPrompt), - verbose, - debug, - debugToStderr, - print: print ?? false, - outputFormat: outputFormat ?? "text", - inputFormat: inputFormat ?? "text", - numAllowedTools: allowedTools.length, - numDisallowedTools: disallowedTools.length, - mcpClientCount: Object.keys(allMcpConfigs).length, - worktreeEnabled, - skipWebFetchPreflight: - getInitialSettings().skipWebFetchPreflight, - githubActionInputs: process.env.GITHUB_ACTION_INPUTS, - dangerouslySkipPermissionsPassed: - dangerouslySkipPermissions ?? false, - permissionMode, - modeIsBypass: permissionMode === "bypassPermissions", - allowDangerouslySkipPermissionsPassed: - allowDangerouslySkipPermissions, - systemPromptFlag: systemPrompt - ? options.systemPromptFile - ? "file" - : "flag" - : undefined, - appendSystemPromptFlag: appendSystemPrompt - ? options.appendSystemPromptFile - ? "file" - : "flag" - : undefined, - thinkingConfig, - assistantActivationPath: - feature("KAIROS") && kairosEnabled - ? assistantModule?.getAssistantActivationPath() - : undefined, - }); - - // Log context metrics once at initialization - void logContextMetrics(regularMcpConfigs, toolPermissionContext); - - void logPermissionContextForAnts(null, "initialization"); - - logManagedSettings(); - - // Register PID file for concurrent-session detection (~/.claude/sessions/) - // and fire multi-clauding telemetry. Lives here (not init.ts) so only the - // REPL path registers — not subcommands like `claude doctor`. Chained: - // count must run after register's write completes or it misses our own file. - void registerSession().then((registered) => { - if (!registered) return; - if (sessionNameArg) { - void updateSessionName(sessionNameArg); - } - void countConcurrentSessions().then((count) => { - if (count >= 2) { - logEvent("tengu_concurrent_sessions", { - num_sessions: count, - }); - } - }); - }); - - // Initialize versioned plugins system (triggers V1→V2 migration if - // needed). Then run orphan GC, THEN warm the Grep/Glob exclusion cache. - // Sequencing matters: the warmup scans disk for .orphaned_at markers, - // so it must see the GC's Pass 1 (remove markers from reinstalled - // versions) and Pass 2 (stamp unmarked orphans) already applied. The - // warm also lands before autoupdate (fires on first submit in REPL) - // can orphan this session's active version underneath us. - // --bare / SIMPLE: skip plugin version sync + orphan cleanup. These - // are install/upgrade bookkeeping that scripted calls don't need — - // the next interactive session will reconcile. The await here was - // blocking -p on a marketplace round-trip. - if (isBareMode()) { - // skip — no-op - } else if (isNonInteractiveSession) { - // In headless mode, await to ensure plugin sync completes before CLI exits - await initializeVersionedPlugins(); - profileCheckpoint("action_after_plugins_init"); - void cleanupOrphanedPluginVersionsInBackground().then(() => - getGlobExclusionsForPluginCache(), - ); - } else { - // In interactive mode, fire-and-forget — this is purely bookkeeping - // that doesn't affect runtime behavior of the current session - void initializeVersionedPlugins().then(async () => { - profileCheckpoint("action_after_plugins_init"); - await cleanupOrphanedPluginVersionsInBackground(); - void getGlobExclusionsForPluginCache(); - }); - } - - const setupTrigger = - initOnly || init ? "init" : maintenance ? "maintenance" : null; - if (initOnly) { - applyConfigEnvironmentVariables(); - await processSetupHooks("init", { forceSyncExecution: true }); - await processSessionStartHooks("startup", { - forceSyncExecution: true, - }); - gracefulShutdownSync(0); - return; - } - - // --print mode - if (isNonInteractiveSession) { - if (outputFormat === "stream-json" || outputFormat === "json") { - setHasFormattedOutput(true); - } - - // Apply full environment variables in print mode since trust dialog is bypassed - // This includes potentially dangerous environment variables from untrusted sources - // but print mode is considered trusted (as documented in help text) - applyConfigEnvironmentVariables(); - - // Initialize telemetry after env vars are applied so OTEL endpoint env vars and - // otelHeadersHelper (which requires trust to execute) are available. - initializeTelemetryAfterTrust(); - - // Kick SessionStart hooks now so the subprocess spawn overlaps with - // MCP connect + plugin init + print.ts import below. loadInitialMessages - // joins this at print.ts:4397. Guarded same as loadInitialMessages — - // continue/resume/teleport paths don't fire startup hooks (or fire them - // conditionally inside the resume branch, where this promise is - // undefined and the ?? fallback runs). Also skip when setupTrigger is - // set — those paths run setup hooks first (print.ts:544), and session - // start hooks must wait until setup completes. - const sessionStartHooksPromise = - options.continue || - options.resume || - teleport || - setupTrigger - ? undefined - : processSessionStartHooks("startup"); - // Suppress transient unhandledRejection if this rejects before - // loadInitialMessages awaits it. Downstream await still observes the - // rejection — this just prevents the spurious global handler fire. - sessionStartHooksPromise?.catch(() => {}); - - profileCheckpoint("before_validateForceLoginOrg"); - // Validate org restriction for non-interactive sessions - const orgValidation = await validateForceLoginOrg(); - if (!orgValidation.valid) { - process.stderr.write((orgValidation as { valid: false; message: string }).message + "\n"); - process.exit(1); - } - - // Headless mode supports all prompt commands and some local commands - // If disableSlashCommands is true, return empty array - const commandsHeadless = disableSlashCommands - ? [] - : commands.filter( - (command) => - (command.type === "prompt" && - !command.disableNonInteractive) || - (command.type === "local" && - command.supportsNonInteractive), - ); - - const defaultState = getDefaultAppState(); - const headlessInitialState: AppState = { - ...defaultState, - mcp: { - ...defaultState.mcp, - clients: mcpClients, - commands: mcpCommands, - tools: mcpTools, - }, - toolPermissionContext, - effortValue: - parseEffortValue(options.effort) ?? - getInitialEffortSetting(), - ...(isFastModeEnabled() && { - fastMode: getInitialFastModeSetting( - effectiveModel ?? null, - ), - }), - ...(isAdvisorEnabled() && advisorModel && { advisorModel }), - // kairosEnabled gates the async fire-and-forget path in - // executeForkedSlashCommand (processSlashCommand.tsx:132) and - // AgentTool's shouldRunAsync. The REPL initialState sets this at - // ~3459; headless was defaulting to false, so the daemon child's - // scheduled tasks and Agent-tool calls ran synchronously — N - // overdue cron tasks on spawn = N serial subagent turns blocking - // user input. Computed at :1620, well before this branch. - ...(feature("KAIROS") ? { kairosEnabled } : {}), - }; - - // Init app state - const headlessStore = createStore( - headlessInitialState, - onChangeAppState, - ); - - // Check if bypassPermissions should be disabled based on Statsig gate - // This runs in parallel to the code below, to avoid blocking the main loop. - if ( - toolPermissionContext.mode === "bypassPermissions" || - allowDangerouslySkipPermissions - ) { - void checkAndDisableBypassPermissions( - toolPermissionContext, - ); - } - - // Async check of auto mode gate — corrects state and disables auto if needed. - // Gated on TRANSCRIPT_CLASSIFIER (not USER_TYPE) so GrowthBook kill switch runs for external builds too. - if (feature("TRANSCRIPT_CLASSIFIER")) { - void verifyAutoModeGateAccess( - toolPermissionContext, - headlessStore.getState().fastMode, - ).then(({ updateContext }) => { - headlessStore.setState((prev) => { - const nextCtx = updateContext( - prev.toolPermissionContext, - ); - if (nextCtx === prev.toolPermissionContext) - return prev; - return { ...prev, toolPermissionContext: nextCtx }; - }); - }); - } - - // Set global state for session persistence - if (options.sessionPersistence === false) { - setSessionPersistenceDisabled(true); - } - - // Store SDK betas in global state for context window calculation - // Only store allowed betas (filters by allowlist and subscriber status) - setSdkBetas(filterAllowedSdkBetas(betas)); - - // Print-mode MCP: per-server incremental push into headlessStore. - // Mirrors useManageMCPConnections — push pending first (so ToolSearch's - // pending-check at ToolSearchTool.ts:334 sees them), then replace with - // connected/failed as each server settles. - const connectMcpBatch = ( - configs: Record, - label: string, - ): Promise => { - if (Object.keys(configs).length === 0) - return Promise.resolve(); - headlessStore.setState((prev) => ({ - ...prev, - mcp: { - ...prev.mcp, - clients: [ - ...prev.mcp.clients, - ...Object.entries(configs).map( - ([name, config]) => ({ - name, - type: "pending" as const, - config, - }), - ), - ], - }, - })); - return getMcpToolsCommandsAndResources( - ({ client, tools, commands }) => { - headlessStore.setState((prev) => ({ - ...prev, - mcp: { - ...prev.mcp, - clients: prev.mcp.clients.some( - (c) => c.name === client.name, - ) - ? prev.mcp.clients.map((c) => - c.name === client.name - ? client - : c, - ) - : [...prev.mcp.clients, client], - tools: uniqBy( - [...prev.mcp.tools, ...tools], - "name", - ), - commands: uniqBy( - [...prev.mcp.commands, ...commands], - "name", - ), - }, - })); - }, - configs, - ).catch((err) => - logForDebugging(`[MCP] ${label} connect error: ${err}`), - ); - }; - // Await all MCP configs — print mode is often single-turn, so - // "late-connecting servers visible next turn" doesn't help. SDK init - // message and turn-1 tool list both need configured MCP tools present. - // Zero-server case is free via the early return in connectMcpBatch. - // Connectors parallelize inside getMcpToolsCommandsAndResources - // (processBatched with Promise.all). claude.ai is awaited too — its - // fetch was kicked off early (line ~2558) so only residual time blocks - // here. --bare skips claude.ai entirely for perf-sensitive scripts. - profileCheckpoint("before_connectMcp"); - await connectMcpBatch(regularMcpConfigs, "regular"); - profileCheckpoint("after_connectMcp"); - // Dedup: suppress plugin MCP servers that duplicate a claude.ai - // connector (connector wins), then connect claude.ai servers. - // Bounded wait — #23725 made this blocking so single-turn -p sees - // connectors, but with 40+ slow connectors tengu_startup_perf p99 - // climbed to 76s. If fetch+connect doesn't finish in time, proceed; - // the promise keeps running and updates headlessStore in the - // background so turn 2+ still sees connectors. - const CLAUDE_AI_MCP_TIMEOUT_MS = 5_000; - const claudeaiConnect = claudeaiConfigPromise.then( - (claudeaiConfigs) => { - if (Object.keys(claudeaiConfigs).length > 0) { - const claudeaiSigs = new Set(); - for (const config of Object.values( - claudeaiConfigs, - )) { - const sig = getMcpServerSignature(config); - if (sig) claudeaiSigs.add(sig); - } - const suppressed = new Set(); - for (const [name, config] of Object.entries( - regularMcpConfigs, - )) { - if (!name.startsWith("plugin:")) continue; - const sig = getMcpServerSignature(config); - if (sig && claudeaiSigs.has(sig)) - suppressed.add(name); - } - if (suppressed.size > 0) { - logForDebugging( - `[MCP] Lazy dedup: suppressing ${suppressed.size} plugin server(s) that duplicate claude.ai connectors: ${[...suppressed].join(", ")}`, - ); - // Disconnect before filtering from state. Only connected - // servers need cleanup — clearServerCache on a never-connected - // server triggers a real connect just to kill it (memoize - // cache-miss path, see useManageMCPConnections.ts:870). - for (const c of headlessStore.getState().mcp - .clients) { - if ( - !suppressed.has(c.name) || - c.type !== "connected" - ) - continue; - c.client.onclose = undefined; - void clearServerCache( - c.name, - c.config, - ).catch(() => {}); - } - headlessStore.setState((prev) => { - let { - clients, - tools, - commands, - resources, - } = prev.mcp; - clients = clients.filter( - (c) => !suppressed.has(c.name), - ); - tools = tools.filter( - (t) => - !t.mcpInfo || - !suppressed.has( - t.mcpInfo.serverName, - ), - ); - for (const name of suppressed) { - commands = excludeCommandsByServer( - commands, - name, - ); - resources = excludeResourcesByServer( - resources, - name, - ); - } - return { - ...prev, - mcp: { - ...prev.mcp, - clients, - tools, - commands, - resources, - }, - }; - }); - } - } - // Suppress claude.ai connectors that duplicate an enabled - // manual server (URL-signature match). Plugin dedup above only - // handles `plugin:*` keys; this catches manual `.mcp.json` entries. - // plugin:* must be excluded here — step 1 already suppressed - // those (claude.ai wins); leaving them in suppresses the - // connector too, and neither survives (gh-39974). - const nonPluginConfigs = pickBy( - regularMcpConfigs, - (_, n) => !n.startsWith("plugin:"), - ); - const { servers: dedupedClaudeAi } = - dedupClaudeAiMcpServers( - claudeaiConfigs, - nonPluginConfigs, - ); - return connectMcpBatch(dedupedClaudeAi, "claudeai"); - }, - ); - let claudeaiTimer: ReturnType | undefined; - const claudeaiTimedOut = await Promise.race([ - claudeaiConnect.then(() => false), - new Promise((resolve) => { - claudeaiTimer = setTimeout( - (r) => r(true), - CLAUDE_AI_MCP_TIMEOUT_MS, - resolve, - ); - }), - ]); - if (claudeaiTimer) clearTimeout(claudeaiTimer); - if (claudeaiTimedOut) { - logForDebugging( - `[MCP] claude.ai connectors not ready after ${CLAUDE_AI_MCP_TIMEOUT_MS}ms — proceeding; background connection continues`, - ); - } - profileCheckpoint("after_connectMcp_claudeai"); - - // In headless mode, start deferred prefetches immediately (no user typing delay) - // --bare / SIMPLE: startDeferredPrefetches early-returns internally. - // backgroundHousekeeping (initExtractMemories, pruneShellSnapshots, - // cleanupOldMessageFiles) and sdkHeapDumpMonitor are all bookkeeping - // that scripted calls don't need — the next interactive session reconciles. - if (!isBareMode()) { - startDeferredPrefetches(); - void import("./utils/backgroundHousekeeping.js").then((m) => - m.startBackgroundHousekeeping(), - ); - if (process.env.USER_TYPE === "ant") { - void import("./utils/sdkHeapDumpMonitor.js").then((m) => - m.startSdkMemoryMonitor(), - ); - } - } - - logSessionTelemetry(); - profileCheckpoint("before_print_import"); - const { runHeadless } = await import("src/cli/print.js"); - profileCheckpoint("after_print_import"); - void runHeadless( - inputPrompt, - () => headlessStore.getState(), - headlessStore.setState, - commandsHeadless, - tools, - sdkMcpConfigs, - agentDefinitions.activeAgents, - { - continue: options.continue, - resume: options.resume, - verbose: verbose, - outputFormat: outputFormat, - jsonSchema, - permissionPromptToolName: options.permissionPromptTool, - allowedTools, - thinkingConfig, - maxTurns: options.maxTurns, - maxBudgetUsd: options.maxBudgetUsd, - taskBudget: options.taskBudget - ? { total: options.taskBudget } - : undefined, - systemPrompt, - appendSystemPrompt, - userSpecifiedModel: effectiveModel, - fallbackModel: userSpecifiedFallbackModel, - teleport, - sdkUrl, - replayUserMessages: effectiveReplayUserMessages, - includePartialMessages: effectiveIncludePartialMessages, - forkSession: options.forkSession || false, - resumeSessionAt: options.resumeSessionAt || undefined, - rewindFiles: options.rewindFiles, - enableAuthStatus: options.enableAuthStatus, - agent: agentCli, - workload: options.workload, - setupTrigger: setupTrigger ?? undefined, - sessionStartHooksPromise, - }, - ); - return; - } - - // Log model config at startup - logEvent("tengu_startup_manual_model_config", { - cli_flag: - options.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - env_var: process.env - .ANTHROPIC_MODEL as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - settings_file: (getInitialSettings() || {}) - .model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - subscriptionType: - getSubscriptionType() as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - agent: agentSetting as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }); - - // Get deprecation warning for the initial model (resolvedInitialModel computed earlier for hooks parallelization) - const deprecationWarning = - getModelDeprecationWarning(resolvedInitialModel); - - // Build initial notification queue - const initialNotifications: Array<{ - key: string; - text: string; - color?: "warning"; - priority: "high"; - }> = []; - if (permissionModeNotification) { - initialNotifications.push({ - key: "permission-mode-notification", - text: permissionModeNotification, - priority: "high", - }); - } - if (deprecationWarning) { - initialNotifications.push({ - key: "model-deprecation-warning", - text: deprecationWarning, - color: "warning", - priority: "high", - }); - } - if (overlyBroadBashPermissions.length > 0) { - const displayList = uniq( - overlyBroadBashPermissions.map((p) => p.ruleDisplay), - ); - const displays = displayList.join(", "); - const sources = uniq( - overlyBroadBashPermissions.map((p) => p.sourceDisplay), - ).join(", "); - const n = displayList.length; - initialNotifications.push({ - key: "overly-broad-bash-notification", - text: `${displays} allow ${plural(n, "rule")} from ${sources} ${plural(n, "was", "were")} ignored \u2014 not available for Ants, please use auto-mode instead`, - color: "warning", - priority: "high", - }); - } - - const effectiveToolPermissionContext = { - ...toolPermissionContext, - mode: - isAgentSwarmsEnabled() && - getTeammateUtils().isPlanModeRequired() - ? ("plan" as const) - : toolPermissionContext.mode, - }; - // All startup opt-in paths (--tools, --brief, defaultView) have fired - // above; initialIsBriefOnly just reads the resulting state. - const initialIsBriefOnly = - feature("KAIROS") || feature("KAIROS_BRIEF") - ? getUserMsgOptIn() - : false; - const fullRemoteControl = - remoteControl || getRemoteControlAtStartup() || kairosEnabled; - let ccrMirrorEnabled = false; - if (feature("CCR_MIRROR") && !fullRemoteControl) { - /* eslint-disable @typescript-eslint/no-require-imports */ - const { isCcrMirrorEnabled } = - require("./bridge/bridgeEnabled.js") as typeof import("./bridge/bridgeEnabled.js"); - /* eslint-enable @typescript-eslint/no-require-imports */ - ccrMirrorEnabled = isCcrMirrorEnabled(); - } - - const initialState: AppState = { - settings: getInitialSettings(), - tasks: {}, - agentNameRegistry: new Map(), - verbose: verbose ?? getGlobalConfig().verbose ?? false, - mainLoopModel: initialMainLoopModel, - mainLoopModelForSession: null, - isBriefOnly: initialIsBriefOnly, - expandedView: getGlobalConfig().showSpinnerTree - ? "teammates" - : getGlobalConfig().showExpandedTodos - ? "tasks" - : "none", - showTeammateMessagePreview: isAgentSwarmsEnabled() - ? false - : undefined, - selectedIPAgentIndex: -1, - coordinatorTaskIndex: -1, - viewSelectionMode: "none", - footerSelection: null, - toolPermissionContext: effectiveToolPermissionContext, - agent: mainThreadAgentDefinition?.agentType, - agentDefinitions, - mcp: { - clients: [], - tools: [], - commands: [], - resources: {}, - pluginReconnectKey: 0, - }, - plugins: { - enabled: [], - disabled: [], - commands: [], - errors: [], - installationStatus: { - marketplaces: [], - plugins: [], - }, - needsRefresh: false, - }, - statusLineText: undefined, - kairosEnabled, - remoteSessionUrl: undefined, - remoteConnectionStatus: "connecting", - remoteBackgroundTaskCount: 0, - replBridgeEnabled: fullRemoteControl || ccrMirrorEnabled, - replBridgeExplicit: remoteControl, - replBridgeOutboundOnly: ccrMirrorEnabled, - replBridgeConnected: false, - replBridgeSessionActive: false, - replBridgeReconnecting: false, - replBridgeConnectUrl: undefined, - replBridgeSessionUrl: undefined, - replBridgeEnvironmentId: undefined, - replBridgeSessionId: undefined, - replBridgeError: undefined, - replBridgeInitialName: remoteControlName, - showRemoteCallout: false, - notifications: { - current: null, - queue: initialNotifications, - }, - elicitation: { - queue: [], - }, - todos: {}, - remoteAgentTaskSuggestions: [], - fileHistory: { - snapshots: [], - trackedFiles: new Set(), - snapshotSequence: 0, - }, - attribution: createEmptyAttributionState(), - thinkingEnabled, - promptSuggestionEnabled: shouldEnablePromptSuggestion(), - sessionHooks: new Map(), - inbox: { - messages: [], - }, - promptSuggestion: { - text: null, - promptId: null, - shownAt: 0, - acceptedAt: 0, - generationRequestId: null, - }, - speculation: IDLE_SPECULATION_STATE, - speculationSessionTimeSavedMs: 0, - skillImprovement: { - suggestion: null, - }, - workerSandboxPermissions: { - queue: [], - selectedIndex: 0, - }, - pendingWorkerRequest: null, - pendingSandboxRequest: null, - authVersion: 0, - initialMessage: inputPrompt - ? { - message: createUserMessage({ - content: String(inputPrompt), - }), - } - : null, - effortValue: - parseEffortValue(options.effort) ?? - getInitialEffortSetting(), - activeOverlays: new Set(), - fastMode: getInitialFastModeSetting(resolvedInitialModel), - ...(isAdvisorEnabled() && advisorModel && { advisorModel }), - // Compute teamContext synchronously to avoid useEffect setState during render. - // KAIROS: assistantTeamContext takes precedence — set earlier in the - // KAIROS block so Agent(name: "foo") can spawn in-process teammates - // without TeamCreate. computeInitialTeamContext() is for tmux-spawned - // teammates reading their own identity, not the assistant-mode leader. - teamContext: (feature("KAIROS") - ? (assistantTeamContext ?? computeInitialTeamContext()) - : computeInitialTeamContext()) as AppState["teamContext"], - }; - - // Add CLI initial prompt to history - if (inputPrompt) { - addToHistory(String(inputPrompt)); - } - - const initialTools = mcpTools; - - // Increment numStartups synchronously — first-render readers like - // shouldShowEffortCallout (via useState initializer) need the updated - // value before setImmediate fires. Defer only telemetry. - saveGlobalConfig((current) => ({ - ...current, - numStartups: (current.numStartups ?? 0) + 1, - })); - setImmediate(() => { - void logStartupTelemetry(); - logSessionTelemetry(); - }); - - // Set up per-turn session environment data uploader (ant-only build). - // Default-enabled for all ant users when working in an Anthropic-owned - // repo. Captures git/filesystem state (NOT transcripts) at each turn so - // environments can be recreated at any user message index. Gating: - // - Build-time: this import is stubbed in external builds. - // - Runtime: uploader checks github.com/anthropics/* remote + gcloud auth. - // - Safety: CLAUDE_CODE_DISABLE_SESSION_DATA_UPLOAD=1 bypasses (tests set this). - // Import is dynamic + async to avoid adding startup latency. - const sessionUploaderPromise = - process.env.USER_TYPE === "ant" - ? import("./utils/sessionDataUploader.js") - : null; - - // Defer session uploader resolution to the onTurnComplete callback to avoid - // adding a new top-level await in main.tsx (performance-critical path). - // The per-turn auth logic in sessionDataUploader.ts handles unauthenticated - // state gracefully (re-checks each turn, so auth recovery mid-session works). - const uploaderReady = sessionUploaderPromise - ? sessionUploaderPromise - .then((mod) => mod.createSessionTurnUploader()) - .catch(() => null) - : null; - - const sessionConfig = { - debug: debug || debugToStderr, - commands: [...commands, ...mcpCommands], - initialTools, - mcpClients, - autoConnectIdeFlag: ide, - mainThreadAgentDefinition, - disableSlashCommands, - dynamicMcpConfig, - strictMcpConfig, - systemPrompt, - appendSystemPrompt, - taskListId, - thinkingConfig, - ...(uploaderReady && { - onTurnComplete: (messages: MessageType[]) => { - void uploaderReady.then((uploader) => - (uploader as ((msgs: MessageType[]) => void) | null)?.(messages), - ); - }, - }), - }; - - // Shared context for processResumedConversation calls - const resumeContext = { - modeApi: coordinatorModeModule, - mainThreadAgentDefinition, - agentDefinitions, - currentCwd, - cliAgents, - initialState, - }; - - if (options.continue) { - // Continue the most recent conversation directly - let resumeSucceeded = false; - try { - const resumeStart = performance.now(); - - // Clear stale caches before resuming to ensure fresh file/skill discovery - const { clearSessionCaches } = - await import("./commands/clear/caches.js"); - clearSessionCaches(); - - const result = await loadConversationForResume( - undefined /* sessionId */, - undefined /* sourceFile */, - ); - if (!result) { - logEvent("tengu_continue", { - success: false, - }); - return await exitWithError( - root, - "No conversation found to continue", - ); - } - - const loaded = await processResumedConversation( - result, - { - forkSession: !!options.forkSession, - includeAttribution: true, - transcriptPath: result.fullPath, - }, - resumeContext, - ); - - if (loaded.restoredAgentDef) { - mainThreadAgentDefinition = loaded.restoredAgentDef; - } - - maybeActivateProactive(options); - maybeActivateBrief(options); - - logEvent("tengu_continue", { - success: true, - resume_duration_ms: Math.round( - performance.now() - resumeStart, - ), - }); - resumeSucceeded = true; - - await launchRepl( - root, - { - getFpsMetrics, - stats, - initialState: loaded.initialState, - }, - { - ...sessionConfig, - mainThreadAgentDefinition: - loaded.restoredAgentDef ?? - mainThreadAgentDefinition, - initialMessages: loaded.messages, - initialFileHistorySnapshots: - loaded.fileHistorySnapshots, - initialContentReplacements: - loaded.contentReplacements, - initialAgentName: loaded.agentName, - initialAgentColor: loaded.agentColor, - }, - renderAndRun, - ); - } catch (error) { - if (!resumeSucceeded) { - logEvent("tengu_continue", { - success: false, - }); - } - logError(error); - process.exit(1); - } - } else if (feature("DIRECT_CONNECT") && _pendingConnect?.url) { - // `claude connect ` — full interactive TUI connected to a remote server - let directConnectConfig; - try { - const session = await createDirectConnectSession({ - serverUrl: _pendingConnect.url, - authToken: _pendingConnect.authToken, - cwd: getOriginalCwd(), - dangerouslySkipPermissions: - _pendingConnect.dangerouslySkipPermissions, - }); - if (session.workDir) { - setOriginalCwd(session.workDir); - setCwdState(session.workDir); - } - setDirectConnectServerUrl(_pendingConnect.url); - directConnectConfig = session.config; - } catch (err) { - return await exitWithError( - root, - err instanceof DirectConnectError - ? err.message - : String(err), - () => gracefulShutdown(1), - ); - } - - const connectInfoMessage = createSystemMessage( - `Connected to server at ${_pendingConnect.url}\nSession: ${directConnectConfig.sessionId}`, - "info", - ); - - await launchRepl( - root, - { getFpsMetrics, stats, initialState }, - { - debug: debug || debugToStderr, - commands, - initialTools: [], - initialMessages: [connectInfoMessage], - mcpClients: [], - autoConnectIdeFlag: ide, - mainThreadAgentDefinition, - disableSlashCommands, - directConnectConfig, - thinkingConfig, - }, - renderAndRun, - ); - return; - } else if (feature("SSH_REMOTE") && _pendingSSH?.host) { - // `claude ssh [dir]` — probe remote, deploy binary if needed, - // spawn ssh with unix-socket -R forward to a local auth proxy, hand - // the REPL an SSHSession. Tools run remotely, UI renders locally. - // `--local` skips probe/deploy/ssh and spawns the current binary - // directly with the same env — e2e test of the proxy/auth plumbing. - const { - createSSHSession, - createLocalSSHSession, - SSHSessionError, - } = await import("./ssh/createSSHSession.js"); - let sshSession: import('./ssh/createSSHSession.js').SSHSession | undefined; - try { - if (_pendingSSH.local) { - process.stderr.write( - "Starting local ssh-proxy test session...\n", - ); - sshSession = await createLocalSSHSession({ - cwd: _pendingSSH.cwd, - permissionMode: _pendingSSH.permissionMode, - dangerouslySkipPermissions: - _pendingSSH.dangerouslySkipPermissions, - }); - } else { - process.stderr.write( - `Connecting to ${_pendingSSH.host}…\n`, - ); - // In-place progress: \r + EL0 (erase to end of line). Final \n on - // success so the next message lands on a fresh line. No-op when - // stderr isn't a TTY (piped/redirected) — \r would just emit noise. - const isTTY = process.stderr.isTTY; - let hadProgress = false; - sshSession = await createSSHSession( - { - host: _pendingSSH.host, - cwd: _pendingSSH.cwd, - localVersion: MACRO.VERSION, - permissionMode: _pendingSSH.permissionMode, - dangerouslySkipPermissions: - _pendingSSH.dangerouslySkipPermissions, - extraCliArgs: _pendingSSH.extraCliArgs, - }, - isTTY - ? { - onProgress: (msg: string) => { - hadProgress = true; - process.stderr.write( - `\r ${msg}\x1b[K`, - ); - }, - } - : {}, - ); - if (hadProgress) process.stderr.write("\n"); - } - setOriginalCwd(sshSession.remoteCwd); - setCwdState(sshSession.remoteCwd); - setDirectConnectServerUrl( - _pendingSSH.local ? "local" : _pendingSSH.host, - ); - } catch (err) { - return await exitWithError( - root, - err instanceof SSHSessionError - ? err.message - : String(err), - () => gracefulShutdown(1), - ); - } - - const sshInfoMessage = createSystemMessage( - _pendingSSH.local - ? `Local ssh-proxy test session\ncwd: ${sshSession.remoteCwd}\nAuth: unix socket → local proxy` - : `SSH session to ${_pendingSSH.host}\nRemote cwd: ${sshSession.remoteCwd}\nAuth: unix socket -R → local proxy`, - "info", - ); - - await launchRepl( - root, - { getFpsMetrics, stats, initialState }, - { - debug: debug || debugToStderr, - commands, - initialTools: [], - initialMessages: [sshInfoMessage], - mcpClients: [], - autoConnectIdeFlag: ide, - mainThreadAgentDefinition, - disableSlashCommands, - sshSession, - thinkingConfig, - }, - renderAndRun, - ); - return; - } else if ( - feature("KAIROS") && - _pendingAssistantChat && - (_pendingAssistantChat.sessionId || - _pendingAssistantChat.discover) - ) { - // `claude assistant [sessionId]` — REPL as a pure viewer client - // of a remote assistant session. The agentic loop runs remotely; this - // process streams live events and POSTs messages. History is lazy- - // loaded by useAssistantHistory on scroll-up (no blocking fetch here). - const { discoverAssistantSessions } = - await import("./assistant/sessionDiscovery.js"); - - let targetSessionId = _pendingAssistantChat.sessionId; - - // Discovery flow — list bridge environments, filter sessions - if (!targetSessionId) { - let sessions; - try { - sessions = await discoverAssistantSessions(); - } catch (e) { - return await exitWithError( - root, - `Failed to discover sessions: ${e instanceof Error ? e.message : e}`, - () => gracefulShutdown(1), - ); - } - if (sessions.length === 0) { - let installedDir: string | null; - try { - installedDir = - await launchAssistantInstallWizard(root); - } catch (e) { - return await exitWithError( - root, - `Assistant installation failed: ${e instanceof Error ? e.message : e}`, - () => gracefulShutdown(1), - ); - } - if (installedDir === null) { - await gracefulShutdown(0); - process.exit(0); - } - // The daemon needs a few seconds to spin up its worker and - // establish a bridge session before discovery will find it. - return await exitWithMessage( - root, - `Assistant installed in ${installedDir}. The daemon is starting up — run \`claude assistant\` again in a few seconds to connect.`, - { - exitCode: 0, - beforeExit: () => gracefulShutdown(0), - }, - ); - } - if (sessions.length === 1) { - targetSessionId = sessions[0]!.id; - } else { - const picked = await launchAssistantSessionChooser( - root, - { - sessions, - }, - ); - if (!picked) { - await gracefulShutdown(0); - process.exit(0); - } - targetSessionId = picked; - } - } - - // Auth — call prepareApiRequest() once for orgUUID, but use a - // getAccessToken closure for the token so reconnects get fresh tokens. - const { - checkAndRefreshOAuthTokenIfNeeded, - getClaudeAIOAuthTokens, - } = await import("./utils/auth.js"); - await checkAndRefreshOAuthTokenIfNeeded(); - let apiCreds; - try { - apiCreds = await prepareApiRequest(); - } catch (e) { - return await exitWithError( - root, - `Error: ${e instanceof Error ? e.message : "Failed to authenticate"}`, - () => gracefulShutdown(1), - ); - } - const getAccessToken = (): string => - getClaudeAIOAuthTokens()?.accessToken ?? - apiCreds.accessToken; - - // Brief mode activation: setKairosActive(true) satisfies BOTH opt-in - // and entitlement for isBriefEnabled() (BriefTool.ts:124-132). - setKairosActive(true); - setUserMsgOptIn(true); - setIsRemoteMode(true); - - const remoteSessionConfig = createRemoteSessionConfig( - targetSessionId, - getAccessToken, - apiCreds.orgUUID, - /* hasInitialPrompt */ false, - /* viewerOnly */ true, - ); - - const infoMessage = createSystemMessage( - `Attached to assistant session ${targetSessionId.slice(0, 8)}…`, - "info", - ); - - const assistantInitialState: AppState = { - ...initialState, - isBriefOnly: true, - kairosEnabled: false, - replBridgeEnabled: false, - }; - - const remoteCommands = filterCommandsForRemoteMode(commands); - await launchRepl( - root, - { - getFpsMetrics, - stats, - initialState: assistantInitialState, - }, - { - debug: debug || debugToStderr, - commands: remoteCommands, - initialTools: [], - initialMessages: [infoMessage], - mcpClients: [], - autoConnectIdeFlag: ide, - mainThreadAgentDefinition, - disableSlashCommands, - remoteSessionConfig, - thinkingConfig, - }, - renderAndRun, - ); - return; - } else if ( - options.resume || - options.fromPr || - teleport || - remote !== null - ) { - // Handle resume flow - from file (ant-only), session ID, or interactive selector - - // Clear stale caches before resuming to ensure fresh file/skill discovery - const { clearSessionCaches } = - await import("./commands/clear/caches.js"); - clearSessionCaches(); - - let messages: MessageType[] | null = null; - let processedResume: ProcessedResume | undefined; - - let maybeSessionId = validateUuid(options.resume); - let searchTerm: string | undefined; - // Store full LogOption when found by custom title (for cross-worktree resume) - let matchedLog: LogOption | null = null; - // PR filter for --from-pr flag - let filterByPr: boolean | number | string | undefined; - - // Handle --from-pr flag - if (options.fromPr) { - if (options.fromPr === true) { - // Show all sessions with linked PRs - filterByPr = true; - } else if (typeof options.fromPr === "string") { - // Could be a PR number or URL - filterByPr = options.fromPr; - } - } - - // If resume value is not a UUID, try exact match by custom title first - if ( - options.resume && - typeof options.resume === "string" && - !maybeSessionId - ) { - const trimmedValue = options.resume.trim(); - if (trimmedValue) { - const matches = await searchSessionsByCustomTitle( - trimmedValue, - { - exact: true, - }, - ); - - if (matches.length === 1) { - // Exact match found - store full LogOption for cross-worktree resume - matchedLog = matches[0]!; - maybeSessionId = - getSessionIdFromLog(matchedLog) ?? null; - } else { - // No match or multiple matches - use as search term for picker - searchTerm = trimmedValue; - } - } - } - - // --remote and --teleport both create/resume Claude Code Web (CCR) sessions. - // Remote Control (--rc) is a separate feature gated in initReplBridge.ts. - if (remote !== null || teleport) { - await waitForPolicyLimitsToLoad(); - if (!isPolicyAllowed("allow_remote_sessions")) { - return await exitWithError( - root, - "Error: Remote sessions are disabled by your organization's policy.", - () => gracefulShutdown(1), - ); - } - } - - if (remote !== null) { - // Create remote session (optionally with initial prompt) - const hasInitialPrompt = remote.length > 0; - - // Check if TUI mode is enabled - description is only optional in TUI mode - const isRemoteTuiEnabled = - getFeatureValue_CACHED_MAY_BE_STALE( - "tengu_remote_backend", - false, - ); - if (!isRemoteTuiEnabled && !hasInitialPrompt) { - return await exitWithError( - root, - 'Error: --remote requires a description.\nUsage: claude --remote "your task description"', - () => gracefulShutdown(1), - ); - } - - logEvent("tengu_remote_create_session", { - has_initial_prompt: String( - hasInitialPrompt, - ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }); - - // Pass current branch so CCR clones the repo at the right revision - const currentBranch = await getBranch(); - const createdSession = - await teleportToRemoteWithErrorHandling( - root, - hasInitialPrompt ? remote : null, - new AbortController().signal, - currentBranch || undefined, - ); - if (!createdSession) { - logEvent("tengu_remote_create_session_error", { - error: "unable_to_create_session" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }); - return await exitWithError( - root, - "Error: Unable to create remote session", - () => gracefulShutdown(1), - ); - } - logEvent("tengu_remote_create_session_success", { - session_id: - createdSession.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }); - - // Check if new remote TUI mode is enabled via feature gate - if (!isRemoteTuiEnabled) { - // Original behavior: print session info and exit - process.stdout.write( - `Created remote session: ${createdSession.title}\n`, - ); - process.stdout.write( - `View: ${getRemoteSessionUrl(createdSession.id)}?m=0\n`, - ); - process.stdout.write( - `Resume with: claude --teleport ${createdSession.id}\n`, - ); - await gracefulShutdown(0); - process.exit(0); - } - - // New behavior: start local TUI with CCR engine - // Mark that we're in remote mode for command visibility - setIsRemoteMode(true); - switchSession(asSessionId(createdSession.id)); - - // Get OAuth credentials for remote session - let apiCreds: { accessToken: string; orgUUID: string }; - try { - apiCreds = await prepareApiRequest(); - } catch (error) { - logError(toError(error)); - return await exitWithError( - root, - `Error: ${errorMessage(error) || "Failed to authenticate"}`, - () => gracefulShutdown(1), - ); - } - - // Create remote session config for the REPL - const { getClaudeAIOAuthTokens: getTokensForRemote } = - await import("./utils/auth.js"); - const getAccessTokenForRemote = (): string => - getTokensForRemote()?.accessToken ?? - apiCreds.accessToken; - const remoteSessionConfig = createRemoteSessionConfig( - createdSession.id, - getAccessTokenForRemote, - apiCreds.orgUUID, - hasInitialPrompt, - ); - - // Add remote session info as initial system message - const remoteSessionUrl = `${getRemoteSessionUrl(createdSession.id)}?m=0`; - const remoteInfoMessage = createSystemMessage( - `/remote-control is active. Code in CLI or at ${remoteSessionUrl}`, - "info", - ); - - // Create initial user message from the prompt if provided (CCR echoes it back but we ignore that) - const initialUserMessage = hasInitialPrompt - ? createUserMessage({ content: remote }) - : null; - - // Set remote session URL in app state for footer indicator - const remoteInitialState = { - ...initialState, - remoteSessionUrl, - }; - - // Pre-filter commands to only include remote-safe ones. - // CCR's init response may further refine the list (via handleRemoteInit in REPL). - const remoteCommands = - filterCommandsForRemoteMode(commands); - await launchRepl( - root, - { - getFpsMetrics, - stats, - initialState: remoteInitialState, - }, - { - debug: debug || debugToStderr, - commands: remoteCommands, - initialTools: [], - initialMessages: initialUserMessage - ? [remoteInfoMessage, initialUserMessage] - : [remoteInfoMessage], - mcpClients: [], - autoConnectIdeFlag: ide, - mainThreadAgentDefinition, - disableSlashCommands, - remoteSessionConfig, - thinkingConfig, - }, - renderAndRun, - ); - return; - } else if (teleport) { - if (teleport === true || teleport === "") { - // Interactive mode: show task selector and handle resume - logEvent("tengu_teleport_interactive_mode", {}); - logForDebugging( - "selectAndResumeTeleportTask: Starting teleport flow...", - ); - const teleportResult = - await launchTeleportResumeWrapper(root); - if (!teleportResult) { - // User cancelled or error occurred - await gracefulShutdown(0); - process.exit(0); - } - const { branchError } = - await checkOutTeleportedSessionBranch( - teleportResult.branch, - ); - messages = processMessagesForTeleportResume( - teleportResult.log, - branchError, - ); - } else if (typeof teleport === "string") { - logEvent("tengu_teleport_resume_session", { - mode: "direct" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }); - try { - // First, fetch session and validate repository before checking git state - const sessionData = await fetchSession(teleport); - const repoValidation = - await validateSessionRepository(sessionData); - - // Handle repo mismatch or not in repo cases - if ( - repoValidation.status === "mismatch" || - repoValidation.status === "not_in_repo" - ) { - const sessionRepo = repoValidation.sessionRepo; - if (sessionRepo) { - // Check for known paths - const knownPaths = - getKnownPathsForRepo(sessionRepo); - const existingPaths = - await filterExistingPaths(knownPaths); - - if (existingPaths.length > 0) { - // Show directory switch dialog - const selectedPath = - await launchTeleportRepoMismatchDialog( - root, - { - targetRepo: sessionRepo, - initialPaths: existingPaths, - }, - ); - - if (selectedPath) { - // Change to the selected directory - process.chdir(selectedPath); - setCwd(selectedPath); - setOriginalCwd(selectedPath); - } else { - // User cancelled - await gracefulShutdown(0); - } - } else { - // No known paths - show original error - throw new TeleportOperationError( - `You must run claude --teleport ${teleport} from a checkout of ${sessionRepo}.`, - chalk.red( - `You must run claude --teleport ${teleport} from a checkout of ${chalk.bold(sessionRepo)}.\n`, - ), - ); - } - } - } else if (repoValidation.status === "error") { - throw new TeleportOperationError( - repoValidation.errorMessage || - "Failed to validate session", - chalk.red( - `Error: ${repoValidation.errorMessage || "Failed to validate session"}\n`, - ), - ); - } - - await validateGitState(); - - // Use progress UI for teleport - const { teleportWithProgress } = - await import("./components/TeleportProgress.js"); - const result = await teleportWithProgress( - root, - teleport, - ); - // Track teleported session for reliability logging - setTeleportedSessionInfo({ sessionId: teleport }); - messages = result.messages; - } catch (error) { - if (error instanceof TeleportOperationError) { - process.stderr.write( - error.formattedMessage + "\n", - ); - } else { - logError(error); - process.stderr.write( - chalk.red( - `Error: ${errorMessage(error)}\n`, - ), - ); - } - await gracefulShutdown(1); - } - } - } - if (process.env.USER_TYPE === "ant") { - if ( - options.resume && - typeof options.resume === "string" && - !maybeSessionId - ) { - // Check for ccshare URL (e.g. https://go/ccshare/boris-20260311-211036) - const { parseCcshareId, loadCcshare } = - await import("./utils/ccshareResume.js"); - const ccshareId = parseCcshareId(options.resume); - if (ccshareId) { - try { - const resumeStart = performance.now(); - const logOption = await loadCcshare(ccshareId); - const result = await loadConversationForResume( - logOption, - undefined, - ); - if (result) { - processedResume = - await processResumedConversation( - result, - { - forkSession: true, - transcriptPath: result.fullPath, - }, - resumeContext, - ); - if (processedResume.restoredAgentDef) { - mainThreadAgentDefinition = - processedResume.restoredAgentDef; - } - logEvent("tengu_session_resumed", { - entrypoint: - "ccshare" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - success: true, - resume_duration_ms: Math.round( - performance.now() - resumeStart, - ), - }); - } else { - logEvent("tengu_session_resumed", { - entrypoint: - "ccshare" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - success: false, - }); - } - } catch (error) { - logEvent("tengu_session_resumed", { - entrypoint: - "ccshare" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - success: false, - }); - logError(error); - await exitWithError( - root, - `Unable to resume from ccshare: ${errorMessage(error)}`, - () => gracefulShutdown(1), - ); - } - } else { - const resolvedPath = resolve(options.resume); - try { - const resumeStart = performance.now(); - let logOption; - try { - // Attempt to load as a transcript file; ENOENT falls through to session-ID handling - logOption = - await loadTranscriptFromFile( - resolvedPath, - ); - } catch (error) { - if (!isENOENT(error)) throw error; - // ENOENT: not a file path — fall through to session-ID handling - } - if (logOption) { - const result = - await loadConversationForResume( - logOption, - undefined /* sourceFile */, - ); - if (result) { - processedResume = - await processResumedConversation( - result, - { - forkSession: - !!options.forkSession, - transcriptPath: - result.fullPath, - }, - resumeContext, - ); - if (processedResume.restoredAgentDef) { - mainThreadAgentDefinition = - processedResume.restoredAgentDef; - } - logEvent("tengu_session_resumed", { - entrypoint: - "file" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - success: true, - resume_duration_ms: Math.round( - performance.now() - resumeStart, - ), - }); - } else { - logEvent("tengu_session_resumed", { - entrypoint: - "file" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - success: false, - }); - } - } - } catch (error) { - logEvent("tengu_session_resumed", { - entrypoint: - "file" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - success: false, - }); - logError(error); - await exitWithError( - root, - `Unable to load transcript from file: ${options.resume}`, - () => gracefulShutdown(1), - ); - } - } - } - } - - // If not loaded as a file, try as session ID - if (maybeSessionId) { - // Resume specific session by ID - const sessionId = maybeSessionId; - try { - const resumeStart = performance.now(); - // Use matchedLog if available (for cross-worktree resume by custom title) - // Otherwise fall back to sessionId string (for direct UUID resume) - const result = await loadConversationForResume( - matchedLog ?? sessionId, - undefined, - ); - - if (!result) { - logEvent("tengu_session_resumed", { - entrypoint: - "cli_flag" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - success: false, - }); - return await exitWithError( - root, - `No conversation found with session ID: ${sessionId}`, - ); - } - - const fullPath = - matchedLog?.fullPath ?? result.fullPath; - processedResume = await processResumedConversation( - result, - { - forkSession: !!options.forkSession, - sessionIdOverride: sessionId, - transcriptPath: fullPath, - }, - resumeContext, - ); - - if (processedResume.restoredAgentDef) { - mainThreadAgentDefinition = - processedResume.restoredAgentDef; - } - logEvent("tengu_session_resumed", { - entrypoint: - "cli_flag" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - success: true, - resume_duration_ms: Math.round( - performance.now() - resumeStart, - ), - }); - } catch (error) { - logEvent("tengu_session_resumed", { - entrypoint: - "cli_flag" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - success: false, - }); - logError(error); - await exitWithError( - root, - `Failed to resume session ${sessionId}`, - ); - } - } - - // Await file downloads before rendering REPL (files must be available) - if (fileDownloadPromise) { - try { - const results = await fileDownloadPromise; - const failedCount = count(results, (r) => !r.success); - if (failedCount > 0) { - process.stderr.write( - chalk.yellow( - `Warning: ${failedCount}/${results.length} file(s) failed to download.\n`, - ), - ); - } - } catch (error) { - return await exitWithError( - root, - `Error downloading files: ${errorMessage(error)}`, - ); - } - } - - // If we have a processed resume or teleport messages, render the REPL - const resumeData = - processedResume ?? - (Array.isArray(messages) - ? { - messages, - fileHistorySnapshots: undefined, - agentName: undefined, - agentColor: undefined as - | AgentColorName - | undefined, - restoredAgentDef: mainThreadAgentDefinition, - initialState, - contentReplacements: undefined, - } - : undefined); - if (resumeData) { - maybeActivateProactive(options); - maybeActivateBrief(options); - - await launchRepl( - root, - { - getFpsMetrics, - stats, - initialState: resumeData.initialState, - }, - { - ...sessionConfig, - mainThreadAgentDefinition: - resumeData.restoredAgentDef ?? - mainThreadAgentDefinition, - initialMessages: resumeData.messages, - initialFileHistorySnapshots: - resumeData.fileHistorySnapshots, - initialContentReplacements: - resumeData.contentReplacements, - initialAgentName: resumeData.agentName, - initialAgentColor: resumeData.agentColor, - }, - renderAndRun, - ); - } else { - // Show interactive selector (includes same-repo worktrees) - // Note: ResumeConversation loads logs internally to ensure proper GC after selection - await launchResumeChooser( - root, - { getFpsMetrics, stats, initialState }, - getWorktreePaths(getOriginalCwd()), - { - ...sessionConfig, - initialSearchQuery: searchTerm, - forkSession: options.forkSession, - filterByPr, - }, - ); - } - } else { - // Pass unresolved hooks promise to REPL so it can render immediately - // instead of blocking ~500ms waiting for SessionStart hooks to finish. - // REPL will inject hook messages when they resolve and await them before - // the first API call so the model always sees hook context. - const pendingHookMessages = - hooksPromise && hookMessages.length === 0 - ? hooksPromise - : undefined; - - profileCheckpoint("action_after_hooks"); - maybeActivateProactive(options); - maybeActivateBrief(options); - // Persist the current mode for fresh sessions so future resumes know what mode was used - if (feature("COORDINATOR_MODE")) { - saveMode( - coordinatorModeModule?.isCoordinatorMode() - ? "coordinator" - : "normal", - ); - } - - // If launched via a deep link, show a provenance banner so the user - // knows the session originated externally. Linux xdg-open and - // browsers with "always allow" set dispatch the link with no OS-level - // confirmation, so this is the only signal the user gets that the - // prompt — and the working directory / CLAUDE.md it implies — came - // from an external source rather than something they typed. - let deepLinkBanner: ReturnType< - typeof createSystemMessage - > | null = null; - if (feature("LODESTONE")) { - if (options.deepLinkOrigin) { - logEvent("tengu_deep_link_opened", { - has_prefill: Boolean(options.prefill), - has_repo: Boolean(options.deepLinkRepo), - }); - deepLinkBanner = createSystemMessage( - buildDeepLinkBanner({ - cwd: getCwd(), - prefillLength: options.prefill?.length, - repo: options.deepLinkRepo, - lastFetch: - options.deepLinkLastFetch !== undefined - ? new Date(options.deepLinkLastFetch) - : undefined, - }), - "warning", - ); - } else if (options.prefill) { - deepLinkBanner = createSystemMessage( - "Launched with a pre-filled prompt — review it before pressing Enter.", - "warning", - ); - } - } - const initialMessages = deepLinkBanner - ? [deepLinkBanner, ...hookMessages] - : hookMessages.length > 0 - ? hookMessages - : undefined; - - await launchRepl( - root, - { getFpsMetrics, stats, initialState }, - { - ...sessionConfig, - initialMessages, - pendingHookMessages, - }, - renderAndRun, - ); - } - }) - .version( - `${MACRO.VERSION} (Claude Code)`, - "-v, --version", - "Output the version number", - ); - - // Worktree flags - program.option( - "-w, --worktree [name]", - "Create a new git worktree for this session (optionally specify a name)", - ); - program.option( - "--tmux", - "Create a tmux session for the worktree (requires --worktree). Uses iTerm2 native panes when available; use --tmux=classic for traditional tmux.", - ); - - if (canUserConfigureAdvisor()) { - program.addOption( - new Option( - "--advisor ", - "Enable the server-side advisor tool with the specified model (alias or full ID).", - ).hideHelp(), - ); - } - - if (process.env.USER_TYPE === "ant") { - program.addOption( - new Option( - "--delegate-permissions", - "[ANT-ONLY] Alias for --permission-mode auto.", - ).implies({ - permissionMode: "auto", - }), - ); - program.addOption( - new Option( - "--dangerously-skip-permissions-with-classifiers", - "[ANT-ONLY] Deprecated alias for --permission-mode auto.", - ) - .hideHelp() - .implies({ permissionMode: "auto" }), - ); - program.addOption( - new Option( - "--afk", - "[ANT-ONLY] Deprecated alias for --permission-mode auto.", - ) - .hideHelp() - .implies({ permissionMode: "auto" }), - ); - program.addOption( - new Option( - "--tasks [id]", - '[ANT-ONLY] Tasks mode: watch for tasks and auto-process them. Optional id is used as both the task list ID and agent ID (defaults to "tasklist").', - ) - .argParser(String) - .hideHelp(), - ); - program.option( - "--agent-teams", - "[ANT-ONLY] Force Claude to use multi-agent mode for solving problems", - () => true, - ); - } - - if (feature("TRANSCRIPT_CLASSIFIER")) { - program.addOption( - new Option("--enable-auto-mode", "Opt in to auto mode").hideHelp(), - ); - } - - if (feature("PROACTIVE") || feature("KAIROS")) { - program.addOption( - new Option("--proactive", "Start in proactive autonomous mode"), - ); - } - - if (feature("UDS_INBOX")) { - program.addOption( - new Option( - "--messaging-socket-path ", - "Unix domain socket path for the UDS messaging server (defaults to a tmp path)", - ), - ); - } - - if (feature("KAIROS") || feature("KAIROS_BRIEF")) { - program.addOption( - new Option( - "--brief", - "Enable SendUserMessage tool for agent-to-user communication", - ), - ); - } - if (feature("KAIROS")) { - program.addOption( - new Option( - "--assistant", - "Force assistant mode (Agent SDK daemon use)", - ).hideHelp(), - ); - } - if (feature("KAIROS") || feature("KAIROS_CHANNELS")) { - program.addOption( - new Option( - "--channels ", - "MCP servers whose channel notifications (inbound push) should register this session. Space-separated server names.", - ).hideHelp(), - ); - program.addOption( - new Option( - "--dangerously-load-development-channels ", - "Load channel servers not on the approved allowlist. For local channel development only. Shows a confirmation dialog at startup.", - ).hideHelp(), - ); - } - - // Teammate identity options (set by leader when spawning tmux teammates) - // These replace the CLAUDE_CODE_* environment variables - program.addOption( - new Option("--agent-id ", "Teammate agent ID").hideHelp(), - ); - program.addOption( - new Option("--agent-name ", "Teammate display name").hideHelp(), - ); - program.addOption( - new Option( - "--team-name ", - "Team name for swarm coordination", - ).hideHelp(), - ); - program.addOption( - new Option("--agent-color ", "Teammate UI color").hideHelp(), - ); - program.addOption( - new Option( - "--plan-mode-required", - "Require plan mode before implementation", - ).hideHelp(), - ); - program.addOption( - new Option( - "--parent-session-id ", - "Parent session ID for analytics correlation", - ).hideHelp(), - ); - program.addOption( - new Option( - "--teammate-mode ", - 'How to spawn teammates: "tmux", "in-process", or "auto"', - ) - .choices(["auto", "tmux", "in-process"]) - .hideHelp(), - ); - program.addOption( - new Option( - "--agent-type ", - "Custom agent type for this teammate", - ).hideHelp(), - ); - - // Enable SDK URL for all builds but hide from help - program.addOption( - new Option( - "--sdk-url ", - "Use remote WebSocket endpoint for SDK I/O streaming (only with -p and stream-json format)", - ).hideHelp(), - ); - - // Enable teleport/remote flags for all builds but keep them undocumented until GA - program.addOption( - new Option( - "--teleport [session]", - "Resume a teleport session, optionally specify session ID", - ).hideHelp(), - ); - program.addOption( - new Option( - "--remote [description]", - "Create a remote session with the given description", - ).hideHelp(), - ); - if (feature("BRIDGE_MODE")) { - program.addOption( - new Option( - "--remote-control [name]", - "Start an interactive session with Remote Control enabled (optionally named)", - ) - .argParser((value) => value || true) - .hideHelp(), - ); - program.addOption( - new Option("--rc [name]", "Alias for --remote-control") - .argParser((value) => value || true) - .hideHelp(), - ); - } - - if (feature("HARD_FAIL")) { - program.addOption( - new Option( - "--hard-fail", - "Crash on logError calls instead of silently logging", - ).hideHelp(), - ); - } - - profileCheckpoint("run_main_options_built"); - - // -p/--print mode: skip subcommand registration. The 52 subcommands - // (mcp, auth, plugin, skill, task, config, doctor, update, etc.) are - // never dispatched in print mode — commander routes the prompt to the - // default action. The subcommand registration path was measured at ~65ms - // on baseline — mostly the isBridgeEnabled() call (25ms settings Zod parse - // + 40ms sync keychain subprocess), both hidden by the try/catch that - // always returns false before enableConfigs(). cc:// URLs are rewritten to - // `open` at main() line ~851 BEFORE this runs, so argv check is safe here. - const isPrintMode = - process.argv.includes("-p") || process.argv.includes("--print"); - const isCcUrl = process.argv.some( - (a) => a.startsWith("cc://") || a.startsWith("cc+unix://"), - ); - if (isPrintMode && !isCcUrl) { - profileCheckpoint("run_before_parse"); - await program.parseAsync(process.argv); - profileCheckpoint("run_after_parse"); - return program; - } - - // claude mcp - - const mcp = program - .command("mcp") - .description("Configure and manage MCP servers") - .configureHelp(createSortedHelpConfig()) - .enablePositionalOptions(); - - mcp.command("serve") - .description(`Start the Claude Code MCP server`) - .option("-d, --debug", "Enable debug mode", () => true) - .option( - "--verbose", - "Override verbose mode setting from config", - () => true, - ) - .action( - async ({ - debug, - verbose, - }: { - debug?: boolean; - verbose?: boolean; - }) => { - const { mcpServeHandler } = - await import("./cli/handlers/mcp.js"); - await mcpServeHandler({ debug, verbose }); - }, - ); - - // Register the mcp add subcommand (extracted for testability) - registerMcpAddCommand(mcp); - - if (isXaaEnabled()) { - registerMcpXaaIdpCommand(mcp); - } - - mcp.command("remove ") - .description("Remove an MCP server") - .option( - "-s, --scope ", - "Configuration scope (local, user, or project) - if not specified, removes from whichever scope it exists in", - ) - .action(async (name: string, options: { scope?: string }) => { - const { mcpRemoveHandler } = await import("./cli/handlers/mcp.js"); - await mcpRemoveHandler(name, options); - }); - - mcp.command("list") - .description( - "List configured MCP servers. Note: The workspace trust dialog is skipped and stdio servers from .mcp.json are spawned for health checks. Only use this command in directories you trust.", - ) - .action(async () => { - const { mcpListHandler } = await import("./cli/handlers/mcp.js"); - await mcpListHandler(); - }); - - mcp.command("get ") - .description( - "Get details about an MCP server. Note: The workspace trust dialog is skipped and stdio servers from .mcp.json are spawned for health checks. Only use this command in directories you trust.", - ) - .action(async (name: string) => { - const { mcpGetHandler } = await import("./cli/handlers/mcp.js"); - await mcpGetHandler(name); - }); - - mcp.command("add-json ") - .description("Add an MCP server (stdio or SSE) with a JSON string") - .option( - "-s, --scope ", - "Configuration scope (local, user, or project)", - "local", - ) - .option( - "--client-secret", - "Prompt for OAuth client secret (or set MCP_CLIENT_SECRET env var)", - ) - .action( - async ( - name: string, - json: string, - options: { scope?: string; clientSecret?: true }, - ) => { - const { mcpAddJsonHandler } = - await import("./cli/handlers/mcp.js"); - await mcpAddJsonHandler(name, json, options); - }, - ); - - mcp.command("add-from-claude-desktop") - .description( - "Import MCP servers from Claude Desktop (Mac and WSL only)", - ) - .option( - "-s, --scope ", - "Configuration scope (local, user, or project)", - "local", - ) - .action(async (options: { scope?: string }) => { - const { mcpAddFromDesktopHandler } = - await import("./cli/handlers/mcp.js"); - await mcpAddFromDesktopHandler(options); - }); - - mcp.command("reset-project-choices") - .description( - "Reset all approved and rejected project-scoped (.mcp.json) servers within this project", - ) - .action(async () => { - const { mcpResetChoicesHandler } = - await import("./cli/handlers/mcp.js"); - await mcpResetChoicesHandler(); - }); - - // claude server - if (feature("DIRECT_CONNECT")) { - program - .command("server") - .description("Start a Claude Code session server") - .option("--port ", "HTTP port", "0") - .option("--host ", "Bind address", "0.0.0.0") - .option("--auth-token ", "Bearer token for auth") - .option("--unix ", "Listen on a unix domain socket") - .option( - "--workspace ", - "Default working directory for sessions that do not specify cwd", - ) - .option( - "--idle-timeout ", - "Idle timeout for detached sessions in ms (0 = never expire)", - "600000", - ) - .option( - "--max-sessions ", - "Maximum concurrent sessions (0 = unlimited)", - "32", - ) - .action( - async (opts: { - port: string; - host: string; - authToken?: string; - unix?: string; - workspace?: string; - idleTimeout: string; - maxSessions: string; - }) => { - const { randomBytes } = await import("crypto"); - const { startServer } = await import("./server/server.js"); - const { SessionManager } = - await import("./server/sessionManager.js"); - const { DangerousBackend } = - await import("./server/backends/dangerousBackend.js"); - const { printBanner } = - await import("./server/serverBanner.js"); - const { createServerLogger } = - await import("./server/serverLog.js"); - const { - writeServerLock, - removeServerLock, - probeRunningServer, - } = await import("./server/lockfile.js"); - - const existing = await probeRunningServer(); - if (existing) { - process.stderr.write( - `A claude server is already running (pid ${existing.pid}) at ${existing.httpUrl}\n`, - ); - process.exit(1); - } - - const authToken = - opts.authToken ?? - `sk-ant-cc-${randomBytes(16).toString("base64url")}`; - - const config = { - port: parseInt(opts.port, 10), - host: opts.host, - authToken, - unix: opts.unix, - workspace: opts.workspace, - idleTimeoutMs: parseInt(opts.idleTimeout, 10), - maxSessions: parseInt(opts.maxSessions, 10), - }; - - const backend = new DangerousBackend(); - const sessionManager = new SessionManager(backend, { - idleTimeoutMs: config.idleTimeoutMs, - maxSessions: config.maxSessions, - }); - const logger = createServerLogger(); - - const server = startServer(config, sessionManager, logger); - const actualPort = server.port ?? config.port; - printBanner(config, authToken, actualPort); - - await writeServerLock({ - pid: process.pid, - port: actualPort, - host: config.host, - httpUrl: config.unix - ? `unix:${config.unix}` - : `http://${config.host}:${actualPort}`, - startedAt: Date.now(), - }); - - let shuttingDown = false; - const shutdown = async () => { - if (shuttingDown) return; - shuttingDown = true; - // Stop accepting new connections before tearing down sessions. - server.stop(true); - await sessionManager.destroyAll(); - await removeServerLock(); - process.exit(0); - }; - process.once("SIGINT", () => void shutdown()); - process.once("SIGTERM", () => void shutdown()); - }, - ); - } - - // `claude ssh [dir]` — registered here only so --help shows it. - // The actual interactive flow is handled by early argv rewriting in main() - // (parallels the DIRECT_CONNECT/cc:// pattern above). If commander reaches - // this action it means the argv rewrite didn't fire (e.g. user ran - // `claude ssh` with no host) — just print usage. - if (feature("SSH_REMOTE")) { - program - .command("ssh [dir]") - .description( - "Run Claude Code on a remote host over SSH. Deploys the binary and " + - "tunnels API auth back through your local machine — no remote setup needed.", - ) - .option( - "--permission-mode ", - "Permission mode for the remote session", - ) - .option( - "--dangerously-skip-permissions", - "Skip all permission prompts on the remote (dangerous)", - ) - .option( - "--local", - "e2e test mode — spawn the child CLI locally (skip ssh/deploy). " + - "Exercises the auth proxy and unix-socket plumbing without a remote host.", - ) - .action(async () => { - // Argv rewriting in main() should have consumed `ssh ` before - // commander runs. Reaching here means host was missing or the - // rewrite predicate didn't match. - process.stderr.write( - "Usage: claude ssh [dir]\n\n" + - "Runs Claude Code on a remote Linux host. You don't need to install\n" + - "anything on the remote or run `claude auth login` there — the binary is\n" + - "deployed over SSH and API auth tunnels back through your local machine.\n", - ); - process.exit(1); - }); - } - - // claude connect — subcommand only handles -p (headless) mode. - // Interactive mode (without -p) is handled by early argv rewriting in main() - // which redirects to the main command with full TUI support. - if (feature("DIRECT_CONNECT")) { - program - .command("open ") - .description( - "Connect to a Claude Code server (internal — use cc:// URLs)", - ) - .option("-p, --print [prompt]", "Print mode (headless)") - .option( - "--output-format ", - "Output format: text, json, stream-json", - "text", - ) - .action( - async ( - ccUrl: string, - opts: { - print?: string | true; - outputFormat?: string; - }, - ) => { - const { parseConnectUrl } = - await import("./server/parseConnectUrl.js"); - const { serverUrl, authToken } = parseConnectUrl(ccUrl); - - let connectConfig; - try { - const session = await createDirectConnectSession({ - serverUrl, - authToken, - cwd: getOriginalCwd(), - dangerouslySkipPermissions: - _pendingConnect?.dangerouslySkipPermissions, - }); - if (session.workDir) { - setOriginalCwd(session.workDir); - setCwdState(session.workDir); - } - setDirectConnectServerUrl(serverUrl); - connectConfig = session.config; - } catch (err) { - // biome-ignore lint/suspicious/noConsole: intentional error output - console.error( - err instanceof DirectConnectError - ? err.message - : String(err), - ); - process.exit(1); - } - - const { runConnectHeadless } = - await import("./server/connectHeadless.js"); - - const prompt = - typeof opts.print === "string" ? opts.print : ""; - const interactive = opts.print === true; - await runConnectHeadless( - connectConfig, - prompt, - opts.outputFormat, - interactive, - ); - }, - ); - } - - // claude auth - - const auth = program - .command("auth") - .description("Manage authentication") - .configureHelp(createSortedHelpConfig()); - - auth.command("login") - .description("Sign in to your Anthropic account") - .option( - "--email ", - "Pre-populate email address on the login page", - ) - .option("--sso", "Force SSO login flow") - .option( - "--console", - "Use Anthropic Console (API usage billing) instead of Claude subscription", - ) - .option("--claudeai", "Use Claude subscription (default)") - .action( - async ({ - email, - sso, - console: useConsole, - claudeai, - }: { - email?: string; - sso?: boolean; - console?: boolean; - claudeai?: boolean; - }) => { - const { authLogin } = await import("./cli/handlers/auth.js"); - await authLogin({ email, sso, console: useConsole, claudeai }); - }, - ); - - auth.command("status") - .description("Show authentication status") - .option("--json", "Output as JSON (default)") - .option("--text", "Output as human-readable text") - .action(async (opts: { json?: boolean; text?: boolean }) => { - const { authStatus } = await import("./cli/handlers/auth.js"); - await authStatus(opts); - }); - - auth.command("logout") - .description("Log out from your Anthropic account") - .action(async () => { - const { authLogout } = await import("./cli/handlers/auth.js"); - await authLogout(); - }); - - /** - * Helper function to handle marketplace command errors consistently. - * Logs the error and exits the process with status 1. - * @param error The error that occurred - * @param action Description of the action that failed - */ - // Hidden flag on all plugin/marketplace subcommands to target cowork_plugins. - const coworkOption = () => - new Option("--cowork", "Use cowork_plugins directory").hideHelp(); - - // Plugin validate command - const pluginCmd = program - .command("plugin") - .alias("plugins") - .description("Manage Claude Code plugins") - .configureHelp(createSortedHelpConfig()); - - pluginCmd - .command("validate ") - .description("Validate a plugin or marketplace manifest") - .addOption(coworkOption()) - .action(async (manifestPath: string, options: { cowork?: boolean }) => { - const { pluginValidateHandler } = - await import("./cli/handlers/plugins.js"); - await pluginValidateHandler(manifestPath, options); - }); - - // Plugin list command - pluginCmd - .command("list") - .description("List installed plugins") - .option("--json", "Output as JSON") - .option( - "--available", - "Include available plugins from marketplaces (requires --json)", - ) - .addOption(coworkOption()) - .action( - async (options: { - json?: boolean; - available?: boolean; - cowork?: boolean; - }) => { - const { pluginListHandler } = - await import("./cli/handlers/plugins.js"); - await pluginListHandler(options); - }, - ); - - // Marketplace subcommands - const marketplaceCmd = pluginCmd - .command("marketplace") - .description("Manage Claude Code marketplaces") - .configureHelp(createSortedHelpConfig()); - - marketplaceCmd - .command("add ") - .description("Add a marketplace from a URL, path, or GitHub repo") - .addOption(coworkOption()) - .option( - "--sparse ", - "Limit checkout to specific directories via git sparse-checkout (for monorepos). Example: --sparse .claude-plugin plugins", - ) - .option( - "--scope ", - "Where to declare the marketplace: user (default), project, or local", - ) - .action( - async ( - source: string, - options: { - cowork?: boolean; - sparse?: string[]; - scope?: string; - }, - ) => { - const { marketplaceAddHandler } = - await import("./cli/handlers/plugins.js"); - await marketplaceAddHandler(source, options); - }, - ); - - marketplaceCmd - .command("list") - .description("List all configured marketplaces") - .option("--json", "Output as JSON") - .addOption(coworkOption()) - .action(async (options: { json?: boolean; cowork?: boolean }) => { - const { marketplaceListHandler } = - await import("./cli/handlers/plugins.js"); - await marketplaceListHandler(options); - }); - - marketplaceCmd - .command("remove ") - .alias("rm") - .description("Remove a configured marketplace") - .addOption(coworkOption()) - .action(async (name: string, options: { cowork?: boolean }) => { - const { marketplaceRemoveHandler } = - await import("./cli/handlers/plugins.js"); - await marketplaceRemoveHandler(name, options); - }); - - marketplaceCmd - .command("update [name]") - .description( - "Update marketplace(s) from their source - updates all if no name specified", - ) - .addOption(coworkOption()) - .action( - async (name: string | undefined, options: { cowork?: boolean }) => { - const { marketplaceUpdateHandler } = - await import("./cli/handlers/plugins.js"); - await marketplaceUpdateHandler(name, options); - }, - ); - - // Plugin install command - pluginCmd - .command("install ") - .alias("i") - .description( - "Install a plugin from available marketplaces (use plugin@marketplace for specific marketplace)", - ) - .option( - "-s, --scope ", - "Installation scope: user, project, or local", - "user", - ) - .addOption(coworkOption()) - .action( - async ( - plugin: string, - options: { scope?: string; cowork?: boolean }, - ) => { - const { pluginInstallHandler } = - await import("./cli/handlers/plugins.js"); - await pluginInstallHandler(plugin, options); - }, - ); - - // Plugin uninstall command - pluginCmd - .command("uninstall ") - .alias("remove") - .alias("rm") - .description("Uninstall an installed plugin") - .option( - "-s, --scope ", - "Uninstall from scope: user, project, or local", - "user", - ) - .option( - "--keep-data", - "Preserve the plugin's persistent data directory (~/.claude/plugins/data/{id}/)", - ) - .addOption(coworkOption()) - .action( - async ( - plugin: string, - options: { - scope?: string; - cowork?: boolean; - keepData?: boolean; - }, - ) => { - const { pluginUninstallHandler } = - await import("./cli/handlers/plugins.js"); - await pluginUninstallHandler(plugin, options); - }, - ); - - // Plugin enable command - pluginCmd - .command("enable ") - .description("Enable a disabled plugin") - .option( - "-s, --scope ", - `Installation scope: ${VALID_INSTALLABLE_SCOPES.join(", ")} (default: auto-detect)`, - ) - .addOption(coworkOption()) - .action( - async ( - plugin: string, - options: { scope?: string; cowork?: boolean }, - ) => { - const { pluginEnableHandler } = - await import("./cli/handlers/plugins.js"); - await pluginEnableHandler(plugin, options); - }, - ); - - // Plugin disable command - pluginCmd - .command("disable [plugin]") - .description("Disable an enabled plugin") - .option("-a, --all", "Disable all enabled plugins") - .option( - "-s, --scope ", - `Installation scope: ${VALID_INSTALLABLE_SCOPES.join(", ")} (default: auto-detect)`, - ) - .addOption(coworkOption()) - .action( - async ( - plugin: string | undefined, - options: { scope?: string; cowork?: boolean; all?: boolean }, - ) => { - const { pluginDisableHandler } = - await import("./cli/handlers/plugins.js"); - await pluginDisableHandler(plugin, options); - }, - ); - - // Plugin update command - pluginCmd - .command("update ") - .description( - "Update a plugin to the latest version (restart required to apply)", - ) - .option( - "-s, --scope ", - `Installation scope: ${VALID_UPDATE_SCOPES.join(", ")} (default: user)`, - ) - .addOption(coworkOption()) - .action( - async ( - plugin: string, - options: { scope?: string; cowork?: boolean }, - ) => { - const { pluginUpdateHandler } = - await import("./cli/handlers/plugins.js"); - await pluginUpdateHandler(plugin, options); - }, - ); - // END ANT-ONLY + profileCheckpoint('run_function_start'); + + // Create help config that sorts options by long option name. + // Commander supports compareOptions at runtime but @commander-js/extra-typings + // doesn't include it in the type definitions, so we use Object.assign to add it. + function createSortedHelpConfig(): { + sortSubcommands: true; + sortOptions: true; + } { + const getOptionSortKey = (opt: Option): string => + opt.long?.replace(/^--/, '') ?? opt.short?.replace(/^-/, '') ?? ''; + return Object.assign({ sortSubcommands: true, sortOptions: true } as const, { + compareOptions: (a: Option, b: Option) => getOptionSortKey(a).localeCompare(getOptionSortKey(b)), + }); + } + const program = new CommanderCommand().configureHelp(createSortedHelpConfig()).enablePositionalOptions(); + profileCheckpoint('run_commander_initialized'); + + // Use preAction hook to run initialization only when executing a command, + // not when displaying help. This avoids the need for env variable signaling. + program.hook('preAction', async thisCommand => { + profileCheckpoint('preAction_start'); + // Await async subprocess loads started at module evaluation (lines 12-20). + // Nearly free — subprocesses complete during the ~135ms of imports above. + // Must resolve before init() which triggers the first settings read + // (applySafeConfigEnvironmentVariables → getSettingsForSource('policySettings') + // → isRemoteManagedSettingsEligible → sync keychain reads otherwise ~65ms). + await Promise.all([ensureMdmSettingsLoaded(), ensureKeychainPrefetchCompleted()]); + profileCheckpoint('preAction_after_mdm'); + await init(); + profileCheckpoint('preAction_after_init'); + + // process.title on Windows sets the console title directly; on POSIX, + // terminal shell integration may mirror the process name to the tab. + // After init() so settings.json env can also gate this (gh-4765). + if (!isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_TERMINAL_TITLE)) { + process.title = 'claude'; + } + + // Attach logging sinks so subcommand handlers can use logEvent/logError. + // Before PR #11106 logEvent dispatched directly; after, events queue until + // a sink attaches. setup() attaches sinks for the default command, but + // subcommands (doctor, mcp, plugin, auth) never call setup() and would + // silently drop events on process.exit(). Both inits are idempotent. + const { initSinks } = await import('./utils/sinks.js'); + initSinks(); + profileCheckpoint('preAction_after_sinks'); + + // gh-33508: --plugin-dir is a top-level program option. The default + // action reads it from its own options destructure, but subcommands + // (plugin list, plugin install, mcp *) have their own actions and + // never see it. Wire it up here so getInlinePlugins() works everywhere. + // thisCommand.opts() is typed {} here because this hook is attached + // before .option('--plugin-dir', ...) in the chain — extra-typings + // builds the type as options are added. Narrow with a runtime guard; + // the collect accumulator + [] default guarantee string[] in practice. + const pluginDir = thisCommand.getOptionValue('pluginDir'); + if (Array.isArray(pluginDir) && pluginDir.length > 0 && pluginDir.every(p => typeof p === 'string')) { + setInlinePlugins(pluginDir); + clearPluginCache('preAction: --plugin-dir inline plugins'); + } + + runMigrations(); + profileCheckpoint('preAction_after_migrations'); + + // Load remote managed settings for enterprise customers (non-blocking) + // Fails open - if fetch fails, continues without remote settings + // Settings are applied via hot-reload when they arrive + // Must happen after init() to ensure config reading is allowed + void loadRemoteManagedSettings(); + void loadPolicyLimits(); + + profileCheckpoint('preAction_after_remote_settings'); + + // Load settings sync (non-blocking, fail-open) + // CLI: uploads local settings to remote (CCR download is handled by print.ts) + if (feature('UPLOAD_USER_SETTINGS')) { + void import('./services/settingsSync/index.js').then(m => m.uploadUserSettingsInBackground()); + } + + profileCheckpoint('preAction_after_settings_sync'); + }); + + program + .name('claude') + .description(`Claude Code - starts an interactive session by default, use -p/--print for non-interactive output`) + .argument('[prompt]', 'Your prompt', String) + // Subcommands inherit helpOption via commander's copyInheritedSettings — + // setting it once here covers mcp, plugin, auth, and all other subcommands. + .helpOption('-h, --help', 'Display help for command') + .option( + '-d, --debug [filter]', + 'Enable debug mode with optional category filtering (e.g., "api,hooks" or "!1p,!file")', + (_value: string | true) => { + // If value is provided, it will be the filter string + // If not provided but flag is present, value will be true + // The actual filtering is handled in debug.ts by parsing process.argv + return true; + }, + ) + .addOption(new Option('--debug-to-stderr', 'Enable debug mode (to stderr)').argParser(Boolean).hideHelp()) + .option( + '--debug-file ', + 'Write debug logs to a specific file path (implicitly enables debug mode)', + () => true, + ) + .option('--verbose', 'Override verbose mode setting from config', () => true) + .option( + '-p, --print', + 'Print response and exit (useful for pipes). Note: The workspace trust dialog is skipped when Claude is run with the -p mode. Only use this flag in directories you trust.', + () => true, + ) + .option( + '--bare', + 'Minimal mode: skip hooks, LSP, plugin sync, attribution, auto-memory, background prefetches, keychain reads, and CLAUDE.md auto-discovery. Sets CLAUDE_CODE_SIMPLE=1. Anthropic auth is strictly ANTHROPIC_API_KEY or apiKeyHelper via --settings (OAuth and keychain are never read). 3P providers (Bedrock/Vertex/Foundry) use their own credentials. Skills still resolve via /skill-name. Explicitly provide context via: --system-prompt[-file], --append-system-prompt[-file], --add-dir (CLAUDE.md dirs), --mcp-config, --settings, --agents, --plugin-dir.', + () => true, + ) + .addOption(new Option('--init', 'Run Setup hooks with init trigger, then continue').hideHelp()) + .addOption(new Option('--init-only', 'Run Setup and SessionStart:startup hooks, then exit').hideHelp()) + .addOption(new Option('--maintenance', 'Run Setup hooks with maintenance trigger, then continue').hideHelp()) + .addOption( + new Option( + '--output-format ', + 'Output format (only works with --print): "text" (default), "json" (single result), or "stream-json" (realtime streaming)', + ).choices(['text', 'json', 'stream-json']), + ) + .addOption( + new Option( + '--json-schema ', + 'JSON Schema for structured output validation. ' + + 'Example: {"type":"object","properties":{"name":{"type":"string"}},"required":["name"]}', + ).argParser(String), + ) + .option( + '--include-hook-events', + 'Include all hook lifecycle events in the output stream (only works with --output-format=stream-json)', + () => true, + ) + .option( + '--include-partial-messages', + 'Include partial message chunks as they arrive (only works with --print and --output-format=stream-json)', + () => true, + ) + .addOption( + new Option( + '--input-format ', + 'Input format (only works with --print): "text" (default), or "stream-json" (realtime streaming input)', + ).choices(['text', 'stream-json']), + ) + .option( + '--mcp-debug', + '[DEPRECATED. Use --debug instead] Enable MCP debug mode (shows MCP server errors)', + () => true, + ) + .option( + '--dangerously-skip-permissions', + 'Bypass all permission checks. Recommended only for sandboxes with no internet access.', + () => true, + ) + .option( + '--allow-dangerously-skip-permissions', + 'Enable bypassing all permission checks as an option, without it being enabled by default. Recommended only for sandboxes with no internet access.', + () => true, + ) + .addOption( + new Option('--thinking ', 'Thinking mode: enabled (equivalent to adaptive), disabled') + .choices(['enabled', 'adaptive', 'disabled']) + .hideHelp(), + ) + .addOption( + new Option( + '--max-thinking-tokens ', + '[DEPRECATED. Use --thinking instead for newer models] Maximum number of thinking tokens (only works with --print)', + ) + .argParser(Number) + .hideHelp(), + ) + .addOption( + new Option( + '--max-turns ', + 'Maximum number of agentic turns in non-interactive mode. This will early exit the conversation after the specified number of turns. (only works with --print)', + ) + .argParser(Number) + .hideHelp(), + ) + .addOption( + new Option( + '--max-budget-usd ', + 'Maximum dollar amount to spend on API calls (only works with --print)', + ).argParser(value => { + const amount = Number(value); + if (isNaN(amount) || amount <= 0) { + throw new Error('--max-budget-usd must be a positive number greater than 0'); + } + return amount; + }), + ) + .addOption( + new Option('--task-budget ', 'API-side task budget in tokens (output_config.task_budget)') + .argParser(value => { + const tokens = Number(value); + if (isNaN(tokens) || tokens <= 0 || !Number.isInteger(tokens)) { + throw new Error('--task-budget must be a positive integer'); + } + return tokens; + }) + .hideHelp(), + ) + .option( + '--replay-user-messages', + 'Re-emit user messages from stdin back on stdout for acknowledgment (only works with --input-format=stream-json and --output-format=stream-json)', + () => true, + ) + .addOption(new Option('--enable-auth-status', 'Enable auth status messages in SDK mode').default(false).hideHelp()) + .option( + '--allowedTools, --allowed-tools ', + 'Comma or space-separated list of tool names to allow (e.g. "Bash(git:*) Edit")', + ) + .option( + '--tools ', + 'Specify the list of available tools from the built-in set. Use "" to disable all tools, "default" to use all tools, or specify tool names (e.g. "Bash,Edit,Read").', + ) + .option( + '--disallowedTools, --disallowed-tools ', + 'Comma or space-separated list of tool names to deny (e.g. "Bash(git:*) Edit")', + ) + .option('--mcp-config ', 'Load MCP servers from JSON files or strings (space-separated)') + .addOption( + new Option('--permission-prompt-tool ', 'MCP tool to use for permission prompts (only works with --print)') + .argParser(String) + .hideHelp(), + ) + .addOption(new Option('--system-prompt ', 'System prompt to use for the session').argParser(String)) + .addOption(new Option('--system-prompt-file ', 'Read system prompt from a file').argParser(String).hideHelp()) + .addOption( + new Option('--append-system-prompt ', 'Append a system prompt to the default system prompt').argParser( + String, + ), + ) + .addOption( + new Option( + '--append-system-prompt-file ', + 'Read system prompt from a file and append to the default system prompt', + ) + .argParser(String) + .hideHelp(), + ) + .addOption( + new Option('--permission-mode ', 'Permission mode to use for the session') + .argParser(String) + .choices(PERMISSION_MODES), + ) + .option('-c, --continue', 'Continue the most recent conversation in the current directory', () => true) + .option( + '-r, --resume [value]', + 'Resume a conversation by session ID, or open interactive picker with optional search term', + value => value || true, + ) + .option( + '--fork-session', + 'When resuming, create a new session ID instead of reusing the original (use with --resume or --continue)', + () => true, + ) + .addOption(new Option('--prefill ', 'Pre-fill the prompt input with text without submitting it').hideHelp()) + .addOption(new Option('--deep-link-origin', 'Signal that this session was launched from a deep link').hideHelp()) + .addOption( + new Option( + '--deep-link-repo ', + 'Repo slug the deep link ?repo= parameter resolved to the current cwd', + ).hideHelp(), + ) + .addOption( + new Option('--deep-link-last-fetch ', 'FETCH_HEAD mtime in epoch ms, precomputed by the deep link trampoline') + .argParser(v => { + const n = Number(v); + return Number.isFinite(n) ? n : undefined; + }) + .hideHelp(), + ) + .option( + '--from-pr [value]', + 'Resume a session linked to a PR by PR number/URL, or open interactive picker with optional search term', + value => value || true, + ) + .option( + '--no-session-persistence', + 'Disable session persistence - sessions will not be saved to disk and cannot be resumed (only works with --print)', + ) + .addOption( + new Option( + '--resume-session-at ', + 'When resuming, only messages up to and including the assistant message with (use with --resume in print mode)', + ) + .argParser(String) + .hideHelp(), + ) + .addOption( + new Option( + '--rewind-files ', + 'Restore files to state at the specified user message and exit (requires --resume)', + ).hideHelp(), + ) + // @[MODEL LAUNCH]: Update the example model ID in the --model help text. + .option( + '--model ', + `Model for the current session. Provide an alias for the latest model (e.g. 'sonnet' or 'opus') or a model's full name (e.g. 'claude-sonnet-4-6').`, + ) + .addOption( + new Option('--effort ', `Effort level for the current session (low, medium, high, max)`).argParser( + (rawValue: string) => { + const value = rawValue.toLowerCase(); + const allowed = ['low', 'medium', 'high', 'max']; + if (!allowed.includes(value)) { + throw new InvalidArgumentError(`It must be one of: ${allowed.join(', ')}`); + } + return value; + }, + ), + ) + .option('--agent ', `Agent for the current session. Overrides the 'agent' setting.`) + .option('--betas ', 'Beta headers to include in API requests (API key users only)') + .option( + '--fallback-model ', + 'Enable automatic fallback to specified model when default model is overloaded (only works with --print)', + ) + .addOption( + new Option( + '--workload ', + 'Workload tag for billing-header attribution (cc_workload). Process-scoped; set by SDK daemon callers that spawn subprocesses for cron work. (only works with --print)', + ).hideHelp(), + ) + .option( + '--settings ', + 'Path to a settings JSON file or a JSON string to load additional settings from', + ) + .option('--add-dir ', 'Additional directories to allow tool access to') + .option('--ide', 'Automatically connect to IDE on startup if exactly one valid IDE is available', () => true) + .option( + '--strict-mcp-config', + 'Only use MCP servers from --mcp-config, ignoring all other MCP configurations', + () => true, + ) + .option('--session-id ', 'Use a specific session ID for the conversation (must be a valid UUID)') + .option('-n, --name ', 'Set a display name for this session (shown in /resume and terminal title)') + .option( + '--agents ', + 'JSON object defining custom agents (e.g. \'{"reviewer": {"description": "Reviews code", "prompt": "You are a code reviewer"}}\')', + ) + .option('--setting-sources ', 'Comma-separated list of setting sources to load (user, project, local).') + // gh-33508: (variadic) consumed everything until the next + // --flag. `claude --plugin-dir /path mcp add --transport http` swallowed + // `mcp` and `add` as paths, then choked on --transport as an unknown + // top-level option. Single-value + collect accumulator means each + // --plugin-dir takes exactly one arg; repeat the flag for multiple dirs. + .option( + '--plugin-dir ', + 'Load plugins from a directory for this session only (repeatable: --plugin-dir A --plugin-dir B)', + (val: string, prev: string[]) => [...prev, val], + [] as string[], + ) + .option('--disable-slash-commands', 'Disable all skills', () => true) + .option('--chrome', 'Enable Claude in Chrome integration') + .option('--no-chrome', 'Disable Claude in Chrome integration') + .option( + '--file ', + 'File resources to download at startup. Format: file_id:relative_path (e.g., --file file_abc:doc.txt file_def:img.png)', + ) + .action(async (prompt, options) => { + profileCheckpoint('action_handler_start'); + + // --bare = one-switch minimal mode. Sets SIMPLE so all the existing + // gates fire (CLAUDE.md, skills, hooks inside executeHooks, agent + // dir-walk). Must be set before setup() / any of the gated work runs. + if ((options as { bare?: boolean }).bare) { + process.env.CLAUDE_CODE_SIMPLE = '1'; + } + + // Ignore "code" as a prompt - treat it the same as no prompt + if (prompt === 'code') { + logEvent('tengu_code_prompt_ignored', {}); + console.warn(chalk.yellow('Tip: You can launch Claude Code with just `claude`')); + prompt = undefined; + } + + // Log event for any single-word prompt + if (prompt && typeof prompt === 'string' && !/\s/.test(prompt) && prompt.length > 0) { + logEvent('tengu_single_word_prompt', { length: prompt.length }); + } + + // Assistant mode: when .claude/settings.json has assistant: true AND + // the tengu_kairos GrowthBook gate is on, force brief on. Permission + // mode is left to the user — settings defaultMode or --permission-mode + // apply as normal. REPL-typed messages already default to 'next' + // priority (messageQueueManager.enqueue) so they drain mid-turn between + // tool calls. SendUserMessage (BriefTool) is enabled via the brief env + // var. SleepTool stays disabled (its isEnabled() gates on proactive). + // kairosEnabled is computed once here and reused at the + // getAssistantSystemPromptAddendum() call site further down. + // + // Trust gate: .claude/settings.json is attacker-controllable in an + // untrusted clone. We run ~1000 lines before showSetupScreens() shows + // the trust dialog, and by then we've already appended + // .claude/agents/assistant.md to the system prompt. Refuse to activate + // until the directory has been explicitly trusted. + let kairosEnabled = false; + let assistantTeamContext: + | Awaited['initializeAssistantTeam']>> + | undefined; + if (feature('KAIROS') && (options as { assistant?: boolean }).assistant && assistantModule) { + // --assistant (Agent SDK daemon mode): force the latch before + // isAssistantMode() runs below. The daemon has already checked + // entitlement — don't make the child re-check tengu_kairos. + assistantModule.markAssistantForced(); + } + if ( + feature('KAIROS') && + assistantModule && + (assistantModule.isAssistantForced() || (options as Record).assistant === true) && + // Spawned teammates share the leader's cwd + settings.json, so + // the flag is true for them too. --agent-id being set + // means we ARE a spawned teammate (extractTeammateOptions runs + // ~170 lines later so check the raw commander option) — don't + // re-init the team or override teammateMode/proactive/brief. + !(options as { agentId?: unknown }).agentId && + kairosGate + ) { + if (!checkHasTrustDialogAccepted()) { + console.warn( + chalk.yellow('Assistant mode disabled: directory is not trusted. Accept the trust dialog and restart.'), + ); + } else { + // Blocking gate check — returns cached `true` instantly; if disk + // cache is false/missing, lazily inits GrowthBook and fetches fresh + // (max ~5s). --assistant skips the gate entirely (daemon is + // pre-entitled). + kairosEnabled = assistantModule.isAssistantForced() || (await kairosGate.isKairosEnabled()); + if (kairosEnabled) { + const opts = options as { brief?: boolean }; + opts.brief = true; + setKairosActive(true); + // Pre-seed an in-process team so Agent(name: "foo") spawns + // teammates without TeamCreate. Must run BEFORE setup() captures + // the teammateMode snapshot (initializeAssistantTeam calls + // setCliTeammateModeOverride internally). + assistantTeamContext = await assistantModule.initializeAssistantTeam(); + } + } + } + + const { + debug = false, + debugToStderr = false, + dangerouslySkipPermissions, + allowDangerouslySkipPermissions = false, + tools: baseTools = [], + allowedTools = [], + disallowedTools = [], + mcpConfig = [], + permissionMode: permissionModeCli, + addDir = [], + fallbackModel, + betas = [], + ide = false, + sessionId, + includeHookEvents, + includePartialMessages, + } = options; + + if (options.prefill) { + seedEarlyInput(options.prefill); + } + + // Promise for file downloads - started early, awaited before REPL renders + let fileDownloadPromise: Promise | undefined; + + const agentsJson = options.agents; + const agentCli = options.agent; + if (feature('BG_SESSIONS') && agentCli) { + process.env.CLAUDE_CODE_AGENT = agentCli; + } + + // NOTE: LSP manager initialization is intentionally deferred until after + // the trust dialog is accepted. This prevents plugin LSP servers from + // executing code in untrusted directories before user consent. + + // Extract these separately so they can be modified if needed + let outputFormat = options.outputFormat; + let inputFormat = options.inputFormat; + let verbose = options.verbose ?? getGlobalConfig().verbose; + let print = options.print; + const init = options.init ?? false; + const initOnly = options.initOnly ?? false; + const maintenance = options.maintenance ?? false; + + // Extract disable slash commands flag + const disableSlashCommands = options.disableSlashCommands || false; + + // Extract tasks mode options (ant-only) + const tasksOption = process.env.USER_TYPE === 'ant' && (options as { tasks?: boolean | string }).tasks; + const taskListId = tasksOption + ? typeof tasksOption === 'string' + ? tasksOption + : DEFAULT_TASKS_MODE_TASK_LIST_ID + : undefined; + if (process.env.USER_TYPE === 'ant' && taskListId) { + process.env.CLAUDE_CODE_TASK_LIST_ID = taskListId; + } + + // Extract worktree option + // worktree can be true (flag without value) or a string (custom name or PR reference) + const worktreeOption = isWorktreeModeEnabled() + ? (options as { worktree?: boolean | string }).worktree + : undefined; + let worktreeName = typeof worktreeOption === 'string' ? worktreeOption : undefined; + const worktreeEnabled = worktreeOption !== undefined; + + // Check if worktree name is a PR reference (#N or GitHub PR URL) + let worktreePRNumber: number | undefined; + if (worktreeName) { + const prNum = parsePRReference(worktreeName); + if (prNum !== null) { + worktreePRNumber = prNum; + worktreeName = undefined; // slug will be generated in setup() + } + } + + // Extract tmux option (requires --worktree) + const tmuxEnabled = isWorktreeModeEnabled() && (options as { tmux?: boolean }).tmux === true; + + // Validate tmux option + if (tmuxEnabled) { + if (!worktreeEnabled) { + process.stderr.write(chalk.red('Error: --tmux requires --worktree\n')); + process.exit(1); + } + if (getPlatform() === 'windows') { + process.stderr.write(chalk.red('Error: --tmux is not supported on Windows\n')); + process.exit(1); + } + if (!(await isTmuxAvailable())) { + process.stderr.write(chalk.red(`Error: tmux is not installed.\n${getTmuxInstallInstructions()}\n`)); + process.exit(1); + } + } + + // Extract teammate options (for tmux-spawned agents) + // Declared outside the if block so it's accessible later for system prompt addendum + let storedTeammateOpts: TeammateOptions | undefined; + if (isAgentSwarmsEnabled()) { + // Extract agent identity options (for tmux-spawned agents) + // These replace the CLAUDE_CODE_* environment variables + const teammateOpts = extractTeammateOptions(options); + storedTeammateOpts = teammateOpts; + + // If any teammate identity option is provided, all three required ones must be present + const hasAnyTeammateOpt = teammateOpts.agentId || teammateOpts.agentName || teammateOpts.teamName; + const hasAllRequiredTeammateOpts = teammateOpts.agentId && teammateOpts.agentName && teammateOpts.teamName; + + if (hasAnyTeammateOpt && !hasAllRequiredTeammateOpts) { + process.stderr.write( + chalk.red('Error: --agent-id, --agent-name, and --team-name must all be provided together\n'), + ); + process.exit(1); + } + + // If teammate identity is provided via CLI, set up dynamicTeamContext + if (teammateOpts.agentId && teammateOpts.agentName && teammateOpts.teamName) { + getTeammateUtils().setDynamicTeamContext?.({ + agentId: teammateOpts.agentId, + agentName: teammateOpts.agentName, + teamName: teammateOpts.teamName, + color: teammateOpts.agentColor, + planModeRequired: teammateOpts.planModeRequired ?? false, + parentSessionId: teammateOpts.parentSessionId, + }); + } + + // Set teammate mode CLI override if provided + // This must be done before setup() captures the snapshot + if (teammateOpts.teammateMode) { + getTeammateModeSnapshot().setCliTeammateModeOverride?.(teammateOpts.teammateMode); + } + } + + // Extract remote sdk options + const sdkUrl = (options as { sdkUrl?: string }).sdkUrl ?? undefined; + + // Allow env var to enable partial messages (used by sandbox gateway for baku) + const effectiveIncludePartialMessages = + includePartialMessages || isEnvTruthy(process.env.CLAUDE_CODE_INCLUDE_PARTIAL_MESSAGES); + + // Enable all hook event types when explicitly requested via SDK option + // or when running in CLAUDE_CODE_REMOTE mode (CCR needs them). + // Without this, only SessionStart and Setup events are emitted. + if (includeHookEvents || isEnvTruthy(process.env.CLAUDE_CODE_REMOTE)) { + setAllHookEventsEnabled(true); + } + + // Auto-set input/output formats, verbose mode, and print mode when SDK URL is provided + if (sdkUrl) { + // If SDK URL is provided, automatically use stream-json formats unless explicitly set + if (!inputFormat) { + inputFormat = 'stream-json'; + } + if (!outputFormat) { + outputFormat = 'stream-json'; + } + // Auto-enable verbose mode unless explicitly disabled or already set + if (options.verbose === undefined) { + verbose = true; + } + // Auto-enable print mode unless explicitly disabled + if (!options.print) { + print = true; + } + } + + // Extract teleport option + const teleport = (options as { teleport?: string | true }).teleport ?? null; + + // Extract remote option (can be true if no description provided, or a string) + const remoteOption = (options as { remote?: string | true }).remote; + const remote = remoteOption === true ? '' : (remoteOption ?? null); + + // Extract --remote-control / --rc flag (enable bridge in interactive session) + const remoteControlOption = + (options as { remoteControl?: string | true }).remoteControl ?? (options as { rc?: string | true }).rc; + // Actual bridge check is deferred to after showSetupScreens() so that + // trust is established and GrowthBook has auth headers. + let remoteControl = false; + const remoteControlName = + typeof remoteControlOption === 'string' && remoteControlOption.length > 0 ? remoteControlOption : undefined; + + // Validate session ID if provided + if (sessionId) { + // Check for conflicting flags + // --session-id can be used with --continue or --resume when --fork-session is also provided + // (to specify a custom ID for the forked session) + if ((options.continue || options.resume) && !options.forkSession) { + process.stderr.write( + chalk.red( + 'Error: --session-id can only be used with --continue or --resume if --fork-session is also specified.\n', + ), + ); + process.exit(1); + } + + // When --sdk-url is provided (bridge/remote mode), the session ID is a + // server-assigned tagged ID (e.g. "session_local_01...") rather than a + // UUID. Skip UUID validation and local existence checks in that case. + if (!sdkUrl) { + const validatedSessionId = validateUuid(sessionId); + if (!validatedSessionId) { + process.stderr.write(chalk.red('Error: Invalid session ID. Must be a valid UUID.\n')); + process.exit(1); + } + + // Check if session ID already exists + if (sessionIdExists(validatedSessionId)) { + process.stderr.write(chalk.red(`Error: Session ID ${validatedSessionId} is already in use.\n`)); + process.exit(1); + } + } + } + + // Download file resources if specified via --file flag + const fileSpecs = (options as { file?: string[] }).file; + if (fileSpecs && fileSpecs.length > 0) { + // Get session ingress token (provided by EnvManager via CLAUDE_CODE_SESSION_ACCESS_TOKEN) + const sessionToken = getSessionIngressAuthToken(); + if (!sessionToken) { + process.stderr.write( + chalk.red( + 'Error: Session token required for file downloads. CLAUDE_CODE_SESSION_ACCESS_TOKEN must be set.\n', + ), + ); + process.exit(1); + } + + // Resolve session ID: prefer remote session ID, fall back to internal session ID + const fileSessionId = process.env.CLAUDE_CODE_REMOTE_SESSION_ID || getSessionId(); + + const files = parseFileSpecs(fileSpecs); + if (files.length > 0) { + // Use ANTHROPIC_BASE_URL if set (by EnvManager), otherwise use OAuth config + // This ensures consistency with session ingress API in all environments + const config: FilesApiConfig = { + baseUrl: process.env.ANTHROPIC_BASE_URL || getOauthConfig().BASE_API_URL, + oauthToken: sessionToken, + sessionId: fileSessionId, + }; + + // Start download without blocking startup - await before REPL renders + fileDownloadPromise = downloadSessionFiles(files, config); + } + } + + // Get isNonInteractiveSession from state (was set before init()) + const isNonInteractiveSession = getIsNonInteractiveSession(); + + // Validate that fallback model is different from main model + if (fallbackModel && options.model && fallbackModel === options.model) { + process.stderr.write( + chalk.red( + 'Error: Fallback model cannot be the same as the main model. Please specify a different model for --fallback-model.\n', + ), + ); + process.exit(1); + } + + // Handle system prompt options + let systemPrompt = options.systemPrompt; + if (options.systemPromptFile) { + if (options.systemPrompt) { + process.stderr.write( + chalk.red('Error: Cannot use both --system-prompt and --system-prompt-file. Please use only one.\n'), + ); + process.exit(1); + } + + try { + const filePath = resolve(options.systemPromptFile); + systemPrompt = readFileSync(filePath, 'utf8'); + } catch (error) { + const code = getErrnoCode(error); + if (code === 'ENOENT') { + process.stderr.write( + chalk.red(`Error: System prompt file not found: ${resolve(options.systemPromptFile)}\n`), + ); + process.exit(1); + } + process.stderr.write(chalk.red(`Error reading system prompt file: ${errorMessage(error)}\n`)); + process.exit(1); + } + } + + // Handle append system prompt options + let appendSystemPrompt = options.appendSystemPrompt; + if (options.appendSystemPromptFile) { + if (options.appendSystemPrompt) { + process.stderr.write( + chalk.red( + 'Error: Cannot use both --append-system-prompt and --append-system-prompt-file. Please use only one.\n', + ), + ); + process.exit(1); + } + + try { + const filePath = resolve(options.appendSystemPromptFile); + appendSystemPrompt = readFileSync(filePath, 'utf8'); + } catch (error) { + const code = getErrnoCode(error); + if (code === 'ENOENT') { + process.stderr.write( + chalk.red(`Error: Append system prompt file not found: ${resolve(options.appendSystemPromptFile)}\n`), + ); + process.exit(1); + } + process.stderr.write(chalk.red(`Error reading append system prompt file: ${errorMessage(error)}\n`)); + process.exit(1); + } + } + + // Add teammate-specific system prompt addendum for tmux teammates + if ( + isAgentSwarmsEnabled() && + storedTeammateOpts?.agentId && + storedTeammateOpts?.agentName && + storedTeammateOpts?.teamName + ) { + const addendum = getTeammatePromptAddendum().TEAMMATE_SYSTEM_PROMPT_ADDENDUM; + appendSystemPrompt = appendSystemPrompt ? `${appendSystemPrompt}\n\n${addendum}` : addendum; + } + + const { mode: permissionMode, notification: permissionModeNotification } = initialPermissionModeFromCLI({ + permissionModeCli, + dangerouslySkipPermissions, + }); + + // Store session bypass permissions mode for trust dialog check + setSessionBypassPermissionsMode(permissionMode === 'bypassPermissions'); + if (feature('TRANSCRIPT_CLASSIFIER')) { + // autoModeFlagCli is the "did the user intend auto this session" signal. + // Set when: --enable-auto-mode, --permission-mode auto, resolved mode + // is auto, OR settings defaultMode is auto but the gate denied it + // (permissionMode resolved to default with no explicit CLI override). + // Used by verifyAutoModeGateAccess to decide whether to notify on + // auto-unavailable, and by tengu_auto_mode_config opt-in carousel. + if ( + (options as { enableAutoMode?: boolean }).enableAutoMode || + permissionModeCli === 'auto' || + permissionMode === 'auto' || + (!permissionModeCli && isDefaultPermissionModeAuto()) + ) { + autoModeStateModule?.setAutoModeFlagCli(true); + } + } + + // Parse the MCP config files/strings if provided + let dynamicMcpConfig: Record = { + // Built-in MCP servers (default disabled, user enables via /mcp) + 'mcp-chrome': { + type: 'http', + url: 'http://127.0.0.1:12306/mcp', + scope: 'dynamic', + headers: { + Authorization: 'Bearer my-static-token', + }, + }, + }; + + if (mcpConfig && mcpConfig.length > 0) { + // Process mcpConfig array + const processedConfigs = mcpConfig.map(config => config.trim()).filter(config => config.length > 0); + + let allConfigs: Record = {}; + const allErrors: ValidationError[] = []; + + for (const configItem of processedConfigs) { + let configs: Record | null = null; + let errors: ValidationError[] = []; + + // First try to parse as JSON string + const parsedJson = safeParseJSON(configItem); + if (parsedJson) { + const result = parseMcpConfig({ + configObject: parsedJson, + filePath: 'command line', + expandVars: true, + scope: 'dynamic', + }); + if (result.config) { + configs = result.config.mcpServers; + } else { + errors = result.errors; + } + } else { + // Try as file path + const configPath = resolve(configItem); + const result = parseMcpConfigFromFilePath({ + filePath: configPath, + expandVars: true, + scope: 'dynamic', + }); + if (result.config) { + configs = result.config.mcpServers; + } else { + errors = result.errors; + } + } + + if (errors.length > 0) { + allErrors.push(...errors); + } else if (configs) { + // Merge configs, later ones override earlier ones + allConfigs = { ...allConfigs, ...configs }; + } + } + + if (allErrors.length > 0) { + const formattedErrors = allErrors.map(err => `${err.path ? err.path + ': ' : ''}${err.message}`).join('\n'); + logForDebugging(`--mcp-config validation failed (${allErrors.length} errors): ${formattedErrors}`, { + level: 'error', + }); + process.stderr.write(`Error: Invalid MCP configuration:\n${formattedErrors}\n`); + process.exit(1); + } + + if (Object.keys(allConfigs).length > 0) { + // SDK hosts (Nest/Desktop) own their server naming and may reuse + // built-in names — skip reserved-name checks for type:'sdk'. + const nonSdkConfigNames = Object.entries(allConfigs) + .filter(([, config]) => config.type !== 'sdk') + .map(([name]) => name); + + let reservedNameError: string | null = null; + if (nonSdkConfigNames.some(isClaudeInChromeMCPServer)) { + reservedNameError = `Invalid MCP configuration: "${CLAUDE_IN_CHROME_MCP_SERVER_NAME}" is a reserved MCP name.`; + } else if (feature('CHICAGO_MCP')) { + const { isComputerUseMCPServer, COMPUTER_USE_MCP_SERVER_NAME } = await import( + 'src/utils/computerUse/common.js' + ); + if (nonSdkConfigNames.some(isComputerUseMCPServer)) { + reservedNameError = `Invalid MCP configuration: "${COMPUTER_USE_MCP_SERVER_NAME}" is a reserved MCP name.`; + } + } + if (reservedNameError) { + // stderr+exit(1) — a throw here becomes a silent unhandled + // rejection in stream-json mode (void main() in cli.tsx). + process.stderr.write(`Error: ${reservedNameError}\n`); + process.exit(1); + } + + // Add dynamic scope to all configs. type:'sdk' entries pass through + // unchanged — they're extracted into sdkMcpConfigs downstream and + // passed to print.ts. The Python SDK relies on this path (it doesn't + // send sdkMcpServers in the initialize message). Dropping them here + // broke Coworker (inc-5122). The policy filter below already exempts + // type:'sdk', and the entries are inert without an SDK transport on + // stdin, so there's no bypass risk from letting them through. + const scopedConfigs = mapValues(allConfigs, config => ({ + ...config, + scope: 'dynamic' as const, + })); + + // Enforce managed policy (allowedMcpServers / deniedMcpServers) on + // --mcp-config servers. Without this, the CLI flag bypasses the + // enterprise allowlist that user/project/local configs go through in + // getClaudeCodeMcpConfigs — callers spread dynamicMcpConfig back on + // top of filtered results. Filter here at the source so all + // downstream consumers see the policy-filtered set. + const { allowed, blocked } = filterMcpServersByPolicy(scopedConfigs); + if (blocked.length > 0) { + process.stderr.write( + `Warning: MCP ${plural(blocked.length, 'server')} blocked by enterprise policy: ${blocked.join(', ')}\n`, + ); + } + dynamicMcpConfig = { ...dynamicMcpConfig, ...(allowed as Record) }; + } + } + + // Extract Claude in Chrome option and enforce claude.ai subscriber check (unless user is ant) + const chromeOpts = options as { chrome?: boolean }; + // Store the explicit CLI flag so teammates can inherit it + setChromeFlagOverride(chromeOpts.chrome); + const enableClaudeInChrome = + shouldEnableClaudeInChrome(chromeOpts.chrome) && (process.env.USER_TYPE === 'ant' || isClaudeAISubscriber()); + const autoEnableClaudeInChrome = !enableClaudeInChrome && shouldAutoEnableClaudeInChrome(); + + if (enableClaudeInChrome) { + const platform = getPlatform(); + try { + logEvent('tengu_claude_in_chrome_setup', { + platform: platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + + const { + mcpConfig: chromeMcpConfig, + allowedTools: chromeMcpTools, + systemPrompt: chromeSystemPrompt, + } = setupClaudeInChrome(); + dynamicMcpConfig = { + ...dynamicMcpConfig, + ...chromeMcpConfig, + }; + allowedTools.push(...chromeMcpTools); + if (chromeSystemPrompt) { + appendSystemPrompt = appendSystemPrompt + ? `${chromeSystemPrompt}\n\n${appendSystemPrompt}` + : chromeSystemPrompt; + } + } catch (error) { + logEvent('tengu_claude_in_chrome_setup_failed', { + platform: platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + logForDebugging(`[Claude in Chrome] Error: ${error}`); + logError(error); + console.error(`Error: Failed to run with Claude in Chrome.`); + process.exit(1); + } + } else if (autoEnableClaudeInChrome) { + try { + const { mcpConfig: chromeMcpConfig } = setupClaudeInChrome(); + dynamicMcpConfig = { + ...dynamicMcpConfig, + ...chromeMcpConfig, + }; + + const hint = + feature('WEB_BROWSER_TOOL') && typeof Bun !== 'undefined' && 'WebView' in Bun + ? CLAUDE_IN_CHROME_SKILL_HINT_WITH_WEBBROWSER + : CLAUDE_IN_CHROME_SKILL_HINT; + appendSystemPrompt = appendSystemPrompt ? `${appendSystemPrompt}\n\n${hint}` : hint; + } catch (error) { + // Silently skip any errors for the auto-enable + logForDebugging(`[Claude in Chrome] Error (auto-enable): ${error}`); + } + } + + // Extract strict MCP config flag + const strictMcpConfig = options.strictMcpConfig || false; + + // Check if enterprise MCP configuration exists. When it does, only allow dynamic MCP + // configs that contain special server types (sdk) + if (doesEnterpriseMcpConfigExist()) { + if (strictMcpConfig) { + process.stderr.write( + chalk.red('You cannot use --strict-mcp-config when an enterprise MCP config is present'), + ); + process.exit(1); + } + + // For --mcp-config, allow if all servers are internal types (sdk) + if (dynamicMcpConfig && !areMcpConfigsAllowedWithEnterpriseMcpConfig(dynamicMcpConfig)) { + process.stderr.write( + chalk.red('You cannot dynamically configure MCP servers when an enterprise MCP config is present'), + ); + process.exit(1); + } + } + + // chicago MCP: guarded Computer Use (app allowlist + frontmost gate + + // SCContentFilter screenshots). Ant-only, GrowthBook-gated — failures + // are silent (this is dogfooding). Platform + interactive checks inline + // so non-macOS / print-mode ants skip the heavy @ant/computer-use-mcp + // import entirely. gates.js is light (type-only package import). + // + // Placed AFTER the enterprise-MCP-config check: that check rejects any + // dynamicMcpConfig entry with `type !== 'sdk'`, and our config is + // `type: 'stdio'`. An enterprise-config ant with the GB gate on would + // otherwise process.exit(1). Chrome has the same latent issue but has + // shipped without incident; chicago places itself correctly. + if (feature('CHICAGO_MCP') && getPlatform() !== 'unknown' && !getIsNonInteractiveSession()) { + try { + const { getChicagoEnabled } = await import('src/utils/computerUse/gates.js'); + if (getChicagoEnabled()) { + const { setupComputerUseMCP } = await import('src/utils/computerUse/setup.js'); + const { mcpConfig, allowedTools: cuTools } = setupComputerUseMCP(); + dynamicMcpConfig = { + ...dynamicMcpConfig, + ...mcpConfig, + }; + allowedTools.push(...cuTools); + } + } catch (error) { + logForDebugging(`[Computer Use MCP] Setup failed: ${errorMessage(error)}`); + } + } + + // Store additional directories for CLAUDE.md loading (controlled by env var) + setAdditionalDirectoriesForClaudeMd(addDir); + + // Channel server allowlist from --channels flag — servers whose + // inbound push notifications should register this session. The option + // is added inside a feature() block so TS doesn't know about it + // on the options type — same pattern as --assistant at main.tsx:1824. + // devChannels is deferred: showSetupScreens shows a confirmation dialog + // and only appends to allowedChannels on accept. + let devChannels: ChannelEntry[] | undefined; + if (feature('KAIROS') || feature('KAIROS_CHANNELS')) { + // Parse plugin:name@marketplace / server:Y tags into typed entries. + // Tag decides trust model downstream: plugin-kind hits marketplace + // verification + GrowthBook allowlist, server-kind always fails + // allowlist (schema is plugin-only) unless dev flag is set. + // Untagged or marketplace-less plugin entries are hard errors — + // silently not-matching in the gate would look like channels are + // "on" but nothing ever fires. + const parseChannelEntries = (raw: string[], flag: string): ChannelEntry[] => { + const entries: ChannelEntry[] = []; + const bad: string[] = []; + for (const c of raw) { + if (c.startsWith('plugin:')) { + const rest = c.slice(7); + const at = rest.indexOf('@'); + if (at <= 0 || at === rest.length - 1) { + bad.push(c); + } else { + entries.push({ + kind: 'plugin', + name: rest.slice(0, at), + marketplace: rest.slice(at + 1), + }); + } + } else if (c.startsWith('server:') && c.length > 7) { + entries.push({ kind: 'server', name: c.slice(7) }); + } else { + bad.push(c); + } + } + if (bad.length > 0) { + process.stderr.write( + chalk.red( + `${flag} entries must be tagged: ${bad.join(', ')}\n` + + ` plugin:@ — plugin-provided channel (allowlist enforced)\n` + + ` server: — manually configured MCP server\n`, + ), + ); + process.exit(1); + } + return entries; + }; + + const channelOpts = options as { + channels?: string[]; + dangerouslyLoadDevelopmentChannels?: string[]; + }; + const rawChannels = channelOpts.channels; + const rawDev = channelOpts.dangerouslyLoadDevelopmentChannels; + // Always parse + set. ChannelsNotice reads getAllowedChannels() and + // renders the appropriate branch (disabled/noAuth/policyBlocked/ + // listening) in the startup screen. gateChannelServer() enforces. + // --channels works in both interactive and print/SDK modes; dev-channels + // stays interactive-only (requires a confirmation dialog). + let channelEntries: ChannelEntry[] = []; + if (rawChannels && rawChannels.length > 0) { + channelEntries = parseChannelEntries(rawChannels, '--channels'); + setAllowedChannels(channelEntries); + } + if (!isNonInteractiveSession) { + if (rawDev && rawDev.length > 0) { + devChannels = parseChannelEntries(rawDev, '--dangerously-load-development-channels'); + } + } + // Flag-usage telemetry. Plugin identifiers are logged (same tier as + // tengu_plugin_installed — public-registry-style names); server-kind + // names are not (MCP-server-name tier, opt-in-only elsewhere). + // Per-server gate outcomes land in tengu_mcp_channel_gate once + // servers connect. Dev entries go through a confirmation dialog after + // this — dev_plugins captures what was typed, not what was accepted. + if (channelEntries.length > 0 || (devChannels?.length ?? 0) > 0) { + const joinPluginIds = (entries: ChannelEntry[]) => { + const ids = entries.flatMap(e => (e.kind === 'plugin' ? [`${e.name}@${e.marketplace}`] : [])); + return ids.length > 0 + ? (ids.sort().join(',') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) + : undefined; + }; + logEvent('tengu_mcp_channel_flags', { + channels_count: channelEntries.length, + dev_count: devChannels?.length ?? 0, + plugins: joinPluginIds(channelEntries), + dev_plugins: joinPluginIds(devChannels ?? []), + }); + } + } + + // SDK opt-in for SendUserMessage via --tools. All sessions require + // explicit opt-in; listing it in --tools signals intent. Runs BEFORE + // initializeToolPermissionContext so getToolsForDefaultPreset() sees + // the tool as enabled when computing the base-tools disallow filter. + // Conditional require avoids leaking the tool-name string into + // external builds. + if ((feature('KAIROS') || feature('KAIROS_BRIEF')) && baseTools.length > 0) { + /* eslint-disable @typescript-eslint/no-require-imports */ + const { BRIEF_TOOL_NAME, LEGACY_BRIEF_TOOL_NAME } = + require('@claude-code-best/builtin-tools/tools/BriefTool/prompt.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/prompt.js'); + const { isBriefEntitled } = + require('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js'); + /* eslint-enable @typescript-eslint/no-require-imports */ + const parsed = parseToolListFromCLI(baseTools); + if ((parsed.includes(BRIEF_TOOL_NAME) || parsed.includes(LEGACY_BRIEF_TOOL_NAME)) && isBriefEntitled()) { + setUserMsgOptIn(true); + } + } + + // This await replaces blocking existsSync/statSync calls that were already in + // the startup path. Wall-clock time is unchanged; we just yield to the event + // loop during the fs I/O instead of blocking it. See #19661. + const initResult = await initializeToolPermissionContext({ + allowedToolsCli: allowedTools, + disallowedToolsCli: disallowedTools, + baseToolsCli: baseTools, + permissionMode, + allowDangerouslySkipPermissions, + addDirs: addDir, + }); + let toolPermissionContext = initResult.toolPermissionContext; + const { warnings, dangerousPermissions, overlyBroadBashPermissions } = initResult; + + // Handle overly broad shell allow rules for ant users (Bash(*), PowerShell(*)) + if (process.env.USER_TYPE === 'ant' && overlyBroadBashPermissions.length > 0) { + for (const permission of overlyBroadBashPermissions) { + logForDebugging( + `Ignoring overly broad shell permission ${permission.ruleDisplay} from ${permission.sourceDisplay}`, + ); + } + toolPermissionContext = removeDangerousPermissions(toolPermissionContext, overlyBroadBashPermissions); + } + + if (feature('TRANSCRIPT_CLASSIFIER') && dangerousPermissions.length > 0) { + toolPermissionContext = stripDangerousPermissionsForAutoMode(toolPermissionContext); + } + + // Print any warnings from initialization + warnings.forEach(warning => { + console.error(warning); + }); + + // claude.ai config fetch: -p mode only (interactive uses useManageMCPConnections + // two-phase loading). Kicked off here to overlap with setup(); awaited + // before runHeadless so single-turn -p sees connectors. Skipped under + // enterprise/strict MCP to preserve policy boundaries. + const claudeaiConfigPromise: Promise> = + isNonInteractiveSession && + !strictMcpConfig && + !doesEnterpriseMcpConfigExist() && + // --bare / SIMPLE: skip claude.ai proxy servers (datadog, Gmail, + // Slack, BigQuery, PubMed — 6-14s each to connect). Scripted calls + // that need MCP pass --mcp-config explicitly. + !isBareMode() + ? fetchClaudeAIMcpConfigsIfEligible().then(configs => { + const { allowed, blocked } = filterMcpServersByPolicy(configs); + if (blocked.length > 0) { + process.stderr.write( + `Warning: claude.ai MCP ${plural(blocked.length, 'server')} blocked by enterprise policy: ${blocked.join(', ')}\n`, + ); + } + return allowed; + }) + : Promise.resolve({}); + + // Kick off MCP config loading early (safe - just reads files, no execution). + // Both interactive and -p use getClaudeCodeMcpConfigs (local file reads only). + // The local promise is awaited later (before prefetchAllMcpResources) to + // overlap config I/O with setup(), commands loading, and trust dialog. + logForDebugging('[STARTUP] Loading MCP configs...'); + const mcpConfigStart = Date.now(); + let mcpConfigResolvedMs: number | undefined; + // --bare skips auto-discovered MCP (.mcp.json, user settings, plugins) — + // only explicit --mcp-config works. dynamicMcpConfig is spread onto + // allMcpConfigs downstream so it survives this skip. + const mcpConfigPromise = ( + strictMcpConfig || isBareMode() + ? Promise.resolve({ + servers: {} as Record, + }) + : getClaudeCodeMcpConfigs(dynamicMcpConfig) + ).then(result => { + mcpConfigResolvedMs = Date.now() - mcpConfigStart; + return result; + }); + + // NOTE: We do NOT call prefetchAllMcpResources here - that's deferred until after trust dialog + + if (inputFormat && inputFormat !== 'text' && inputFormat !== 'stream-json') { + console.error(`Error: Invalid input format "${inputFormat}".`); + process.exit(1); + } + if (inputFormat === 'stream-json' && outputFormat !== 'stream-json') { + console.error(`Error: --input-format=stream-json requires output-format=stream-json.`); + process.exit(1); + } + + // Validate sdkUrl is only used with appropriate formats (formats are auto-set above) + if (sdkUrl) { + if (inputFormat !== 'stream-json' || outputFormat !== 'stream-json') { + console.error(`Error: --sdk-url requires both --input-format=stream-json and --output-format=stream-json.`); + process.exit(1); + } + } + + // Validate replayUserMessages is only used with stream-json formats + if (options.replayUserMessages) { + if (inputFormat !== 'stream-json' || outputFormat !== 'stream-json') { + console.error( + `Error: --replay-user-messages requires both --input-format=stream-json and --output-format=stream-json.`, + ); + process.exit(1); + } + } + + // Validate includePartialMessages is only used with print mode and stream-json output + if (effectiveIncludePartialMessages) { + if (!isNonInteractiveSession || outputFormat !== 'stream-json') { + writeToStderr(`Error: --include-partial-messages requires --print and --output-format=stream-json.`); + process.exit(1); + } + } + + // Validate --no-session-persistence is only used with print mode + if (options.sessionPersistence === false && !isNonInteractiveSession) { + writeToStderr(`Error: --no-session-persistence can only be used with --print mode.`); + process.exit(1); + } + + const effectivePrompt = prompt || ''; + let inputPrompt = await getInputPrompt(effectivePrompt, (inputFormat ?? 'text') as 'text' | 'stream-json'); + profileCheckpoint('action_after_input_prompt'); + + // Activate proactive mode BEFORE getTools() so SleepTool.isEnabled() + // (which returns isProactiveActive()) passes and Sleep is included. + // The later REPL-path maybeActivateProactive() calls are idempotent. + maybeActivateProactive(options); + + let tools = getTools(toolPermissionContext); + + // Apply coordinator mode tool filtering for headless path + // (mirrors useMergedTools.ts filtering for REPL/interactive path) + if (feature('COORDINATOR_MODE') && isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE)) { + const { applyCoordinatorToolFilter } = await import('./utils/toolPool.js'); + tools = applyCoordinatorToolFilter(tools); + } + + profileCheckpoint('action_tools_loaded'); + + let jsonSchema: ToolInputJSONSchema | undefined; + if (isSyntheticOutputToolEnabled({ isNonInteractiveSession }) && options.jsonSchema) { + jsonSchema = jsonParse(options.jsonSchema) as ToolInputJSONSchema; + } + + if (jsonSchema) { + const syntheticOutputResult = createSyntheticOutputTool(jsonSchema); + if ('tool' in syntheticOutputResult) { + // Add SyntheticOutputTool to the tools array AFTER getTools() filtering. + // This tool is excluded from normal filtering (see tools.ts) because it's + // an implementation detail for structured output, not a user-controlled tool. + tools = [...tools, syntheticOutputResult.tool]; + + logEvent('tengu_structured_output_enabled', { + schema_property_count: Object.keys((jsonSchema.properties as Record) || {}) + .length as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + has_required_fields: Boolean( + jsonSchema.required, + ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + } else { + logEvent('tengu_structured_output_failure', { + error: 'Invalid JSON schema' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + } + } + + // IMPORTANT: setup() must be called before any other code that depends on the cwd or worktree setup + profileCheckpoint('action_before_setup'); + logForDebugging('[STARTUP] Running setup()...'); + const setupStart = Date.now(); + const { setup } = await import('./setup.js'); + const messagingSocketPath = feature('UDS_INBOX') + ? (options as { messagingSocketPath?: string }).messagingSocketPath + : undefined; + // Parallelize setup() with commands+agents loading. setup()'s ~28ms is + // mostly startUdsMessaging (socket bind, ~20ms) — not disk-bound, so it + // doesn't contend with getCommands' file reads. Gated on !worktreeEnabled + // since --worktree makes setup() process.chdir() (setup.ts:203), and + // commands/agents need the post-chdir cwd. + const preSetupCwd = getCwd(); + // Register bundled skills/plugins before kicking getCommands() — they're + // pure in-memory array pushes (<1ms, zero I/O) that getBundledSkills() + // reads synchronously. Previously ran inside setup() after ~20ms of + // await points, so the parallel getCommands() memoized an empty list. + if (process.env.CLAUDE_CODE_ENTRYPOINT !== 'local-agent') { + initBuiltinPlugins(); + initBundledSkills(); + } + const setupPromise = setup( + preSetupCwd, + permissionMode, + allowDangerouslySkipPermissions, + worktreeEnabled, + worktreeName, + tmuxEnabled, + sessionId ? validateUuid(sessionId) : undefined, + worktreePRNumber, + messagingSocketPath, + ); + const commandsPromise = worktreeEnabled ? null : getCommands(preSetupCwd); + const agentDefsPromise = worktreeEnabled ? null : getAgentDefinitionsWithOverrides(preSetupCwd); + // Suppress transient unhandledRejection if these reject during the + // ~28ms setupPromise await before Promise.all joins them below. + commandsPromise?.catch(() => {}); + agentDefsPromise?.catch(() => {}); + await setupPromise; + logForDebugging(`[STARTUP] setup() completed in ${Date.now() - setupStart}ms`); + profileCheckpoint('action_after_setup'); + + // Replay user messages into stream-json only when the socket was + // explicitly requested. The auto-generated socket is passive — it + // lets tools inject if they want to, but turning it on by default + // shouldn't reshape stream-json for SDK consumers who never touch it. + // Callers who inject and also want those injections visible in the + // stream pass --messaging-socket-path explicitly (or --replay-user-messages). + let effectiveReplayUserMessages = !!options.replayUserMessages; + if (feature('UDS_INBOX')) { + if (!effectiveReplayUserMessages && outputFormat === 'stream-json') { + effectiveReplayUserMessages = !!(options as { messagingSocketPath?: string }).messagingSocketPath; + } + } + + if (getIsNonInteractiveSession()) { + // Apply full merged settings env now (including project-scoped + // .claude/settings.json PATH/GIT_DIR/GIT_WORK_TREE) so gitExe() and + // the git spawn below see it. Trust is implicit in -p mode; the + // docstring at managedEnv.ts:96-97 says this applies "potentially + // dangerous environment variables such as LD_PRELOAD, PATH" from all + // sources. The later call in the isNonInteractiveSession block below + // is idempotent (Object.assign, configureGlobalAgents ejects prior + // interceptor) and picks up any plugin-contributed env after plugin + // init. Project settings are already loaded here: + // applySafeConfigEnvironmentVariables in init() called + // getSettings_DEPRECATED at managedEnv.ts:86 which merges all enabled + // sources including projectSettings/localSettings. + applyConfigEnvironmentVariables(); + + // Spawn git status/log/branch now so the subprocess execution overlaps + // with the getCommands await below and startDeferredPrefetches. After + // setup() so cwd is final (setup.ts:254 may process.chdir(worktreePath) + // for --worktree) and after the applyConfigEnvironmentVariables above + // so PATH/GIT_DIR/GIT_WORK_TREE from all sources (trusted + project) + // are applied. getSystemContext is memoized; the + // prefetchSystemContextIfSafe call in startDeferredPrefetches becomes + // a cache hit. The microtask from await getIsGit() drains at the + // getCommands Promise.all await below. Trust is implicit in -p mode + // (same gate as prefetchSystemContextIfSafe). + void getSystemContext(); + // Kick getUserContext now too — its first await (fs.readFile in + // getMemoryFiles) yields naturally, so the CLAUDE.md directory walk + // runs during the ~280ms overlap window before the context + // Promise.all join in print.ts. The void getUserContext() in + // startDeferredPrefetches becomes a memoize cache-hit. + void getUserContext(); + // Kick ensureModelStringsInitialized now — for Bedrock this triggers + // a 100-200ms profile fetch that was awaited serially at + // print.ts:739. updateBedrockModelStrings is sequential()-wrapped so + // the await joins the in-flight fetch. Non-Bedrock is a sync + // early-return (zero-cost). + void ensureModelStringsInitialized(); + } + + // Apply --name: cache-only so no orphan file is created before the + // session ID is finalized by --continue/--resume. materializeSessionFile + // persists it on the first user message; REPL's useTerminalTitle reads it + // via getCurrentSessionTitle. + const sessionNameArg = options.name?.trim(); + if (sessionNameArg) { + cacheSessionTitle(sessionNameArg); + } + + // Ant model aliases (capybara-fast etc.) resolve via the + // tengu_ant_model_override GrowthBook flag. _CACHED_MAY_BE_STALE reads + // disk synchronously; disk is populated by a fire-and-forget write. On a + // cold cache, parseUserSpecifiedModel returns the unresolved alias, the + // API 404s, and -p exits before the async write lands — crashloop on + // fresh pods. Awaiting init here populates the in-memory payload map that + // _CACHED_MAY_BE_STALE now checks first. Gated so the warm path stays + // non-blocking: + // - explicit model via --model or ANTHROPIC_MODEL (both feed alias resolution) + // - no env override (which short-circuits _CACHED_MAY_BE_STALE before disk) + // - flag absent from disk (== null also catches pre-#22279 poisoned null) + const explicitModel = options.model || process.env.ANTHROPIC_MODEL; + if ( + process.env.USER_TYPE === 'ant' && + explicitModel && + explicitModel !== 'default' && + !hasGrowthBookEnvOverride('tengu_ant_model_override') && + getGlobalConfig().cachedGrowthBookFeatures?.['tengu_ant_model_override'] == null + ) { + await initializeGrowthBook(); + } + + // Special case the default model with the null keyword + // NOTE: Model resolution happens after setup() to ensure trust is established before AWS auth + const userSpecifiedModel = options.model === 'default' ? getDefaultMainLoopModel() : options.model; + const userSpecifiedFallbackModel = fallbackModel === 'default' ? getDefaultMainLoopModel() : fallbackModel; + + // Reuse preSetupCwd unless setup() chdir'd (worktreeEnabled). Saves a + // getCwd() syscall in the common path. + const currentCwd = worktreeEnabled ? getCwd() : preSetupCwd; + logForDebugging('[STARTUP] Loading commands and agents...'); + const commandsStart = Date.now(); + // Join the promises kicked before setup() (or start fresh if + // worktreeEnabled gated the early kick). Both memoized by cwd. + const [commands, agentDefinitionsResult] = await Promise.all([ + commandsPromise ?? getCommands(currentCwd), + agentDefsPromise ?? getAgentDefinitionsWithOverrides(currentCwd), + ]); + logForDebugging(`[STARTUP] Commands and agents loaded in ${Date.now() - commandsStart}ms`); + profileCheckpoint('action_commands_loaded'); + + // Parse CLI agents if provided via --agents flag + let cliAgents: typeof agentDefinitionsResult.activeAgents = []; + if (agentsJson) { + try { + const parsedAgents = safeParseJSON(agentsJson); + if (parsedAgents) { + cliAgents = parseAgentsFromJson(parsedAgents, 'flagSettings'); + } + } catch (error) { + logError(error); + } + } + + // Merge CLI agents with existing ones + const allAgents = [...agentDefinitionsResult.allAgents, ...cliAgents]; + const agentDefinitions = { + ...agentDefinitionsResult, + allAgents, + activeAgents: getActiveAgentsFromList(allAgents), + }; + + // Look up main thread agent from CLI flag or settings + const agentSetting = agentCli ?? getInitialSettings().agent; + let mainThreadAgentDefinition: (typeof agentDefinitions.activeAgents)[number] | undefined; + if (agentSetting) { + mainThreadAgentDefinition = agentDefinitions.activeAgents.find(agent => agent.agentType === agentSetting); + if (!mainThreadAgentDefinition) { + logForDebugging( + `Warning: agent "${agentSetting}" not found. ` + + `Available agents: ${agentDefinitions.activeAgents.map(a => a.agentType).join(', ')}. ` + + `Using default behavior.`, + ); + } + } + + // Store the main thread agent type in bootstrap state so hooks can access it + setMainThreadAgentType(mainThreadAgentDefinition?.agentType); + + // Log agent flag usage — only log agent name for built-in agents to avoid leaking custom agent names + if (mainThreadAgentDefinition) { + logEvent('tengu_agent_flag', { + agentType: isBuiltInAgent(mainThreadAgentDefinition) + ? (mainThreadAgentDefinition.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS) + : ('custom' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS), + ...(agentCli && { + source: 'cli' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }), + }); + } + + // Persist agent setting to session transcript for resume view display and restoration + if (mainThreadAgentDefinition?.agentType) { + saveAgentSetting(mainThreadAgentDefinition.agentType); + } + + // Apply the agent's system prompt for non-interactive sessions + // (interactive mode uses buildEffectiveSystemPrompt instead) + if ( + isNonInteractiveSession && + mainThreadAgentDefinition && + !systemPrompt && + !isBuiltInAgent(mainThreadAgentDefinition) + ) { + const agentSystemPrompt = mainThreadAgentDefinition.getSystemPrompt(); + if (agentSystemPrompt) { + systemPrompt = agentSystemPrompt; + } + } + + // initialPrompt goes first so its slash command (if any) is processed; + // user-provided text becomes trailing context. + // Only concatenate when inputPrompt is a string. When it's an + // AsyncIterable (SDK stream-json mode), template interpolation would + // call .toString() producing "[object Object]". The AsyncIterable case + // is handled in print.ts via structuredIO.prependUserMessage(). + if (mainThreadAgentDefinition?.initialPrompt) { + if (typeof inputPrompt === 'string') { + inputPrompt = inputPrompt + ? `${mainThreadAgentDefinition.initialPrompt}\n\n${inputPrompt}` + : mainThreadAgentDefinition.initialPrompt; + } else if (!inputPrompt) { + inputPrompt = mainThreadAgentDefinition.initialPrompt; + } + } + + // Compute effective model early so hooks can run in parallel with MCP + // If user didn't specify a model but agent has one, use the agent's model + let effectiveModel = userSpecifiedModel; + if (!effectiveModel && mainThreadAgentDefinition?.model && mainThreadAgentDefinition.model !== 'inherit') { + effectiveModel = parseUserSpecifiedModel(mainThreadAgentDefinition.model); + } + + setMainLoopModelOverride(effectiveModel); + + // Compute resolved model for hooks (use user-specified model at launch) + setInitialMainLoopModel(getUserSpecifiedModelSetting() || null); + const initialMainLoopModel = getInitialMainLoopModel(); + const resolvedInitialModel = parseUserSpecifiedModel(initialMainLoopModel ?? getDefaultMainLoopModel()); + + let advisorModel: string | undefined; + if (isAdvisorEnabled()) { + const advisorOption = canUserConfigureAdvisor() ? (options as { advisor?: string }).advisor : undefined; + if (advisorOption) { + logForDebugging(`[AdvisorTool] --advisor ${advisorOption}`); + if (!modelSupportsAdvisor(resolvedInitialModel)) { + process.stderr.write( + chalk.red(`Error: The model "${resolvedInitialModel}" does not support the advisor tool.\n`), + ); + process.exit(1); + } + const normalizedAdvisorModel = normalizeModelStringForAPI(parseUserSpecifiedModel(advisorOption)); + if (!isValidAdvisorModel(normalizedAdvisorModel)) { + process.stderr.write(chalk.red(`Error: The model "${advisorOption}" cannot be used as an advisor.\n`)); + process.exit(1); + } + } + advisorModel = canUserConfigureAdvisor() ? (advisorOption ?? getInitialAdvisorSetting()) : advisorOption; + if (advisorModel) { + logForDebugging(`[AdvisorTool] Advisor model: ${advisorModel}`); + } + } + + // For tmux teammates with --agent-type, append the custom agent's prompt + if ( + isAgentSwarmsEnabled() && + storedTeammateOpts?.agentId && + storedTeammateOpts?.agentName && + storedTeammateOpts?.teamName && + storedTeammateOpts?.agentType + ) { + // Look up the custom agent definition + const customAgent = agentDefinitions.activeAgents.find(a => a.agentType === storedTeammateOpts.agentType); + if (customAgent) { + // Get the prompt - need to handle both built-in and custom agents + let customPrompt: string | undefined; + if (customAgent.source === 'built-in') { + // Built-in agents have getSystemPrompt that takes toolUseContext + // We can't access full toolUseContext here, so skip for now + logForDebugging( + `[teammate] Built-in agent ${storedTeammateOpts.agentType} - skipping custom prompt (not supported)`, + ); + } else { + // Custom agents have getSystemPrompt that takes no args + customPrompt = customAgent.getSystemPrompt(); + } + + // Log agent memory loaded event for tmux teammates + if (customAgent.memory) { + logEvent('tengu_agent_memory_loaded', { + ...(process.env.USER_TYPE === 'ant' && { + agent_type: customAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }), + scope: customAgent.memory as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + source: 'teammate' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + } + + if (customPrompt) { + const customInstructions = `\n# Custom Agent Instructions\n${customPrompt}`; + appendSystemPrompt = appendSystemPrompt + ? `${appendSystemPrompt}\n\n${customInstructions}` + : customInstructions; + } + } else { + logForDebugging(`[teammate] Custom agent ${storedTeammateOpts.agentType} not found in available agents`); + } + } + + maybeActivateBrief(options); + // defaultView: 'chat' is a persisted opt-in — check entitlement and set + // userMsgOptIn so the tool + prompt section activate. Interactive-only: + // defaultView is a display preference; SDK sessions have no display, and + // the assistant installer writes defaultView:'chat' to settings.local.json + // which would otherwise leak into --print sessions in the same directory. + // Runs right after maybeActivateBrief() so all startup opt-in paths fire + // BEFORE any isBriefEnabled() read below (proactive prompt's + // briefVisibility). A persisted 'chat' after a GB kill-switch falls + // through (entitlement fails). + if ( + (feature('KAIROS') || feature('KAIROS_BRIEF')) && + !getIsNonInteractiveSession() && + !getUserMsgOptIn() && + getInitialSettings().defaultView === 'chat' + ) { + /* eslint-disable @typescript-eslint/no-require-imports */ + const { isBriefEntitled } = + require('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js'); + /* eslint-enable @typescript-eslint/no-require-imports */ + if (isBriefEntitled()) { + setUserMsgOptIn(true); + } + } + // Coordinator mode has its own system prompt and filters out Sleep, so + // the generic proactive prompt would tell it to call a tool it can't + // access and conflict with delegation instructions. + if ( + (feature('PROACTIVE') || feature('KAIROS')) && + ((options as { proactive?: boolean }).proactive || isEnvTruthy(process.env.CLAUDE_CODE_PROACTIVE)) && + !coordinatorModeModule?.isCoordinatorMode() + ) { + /* eslint-disable @typescript-eslint/no-require-imports */ + const briefVisibility = + feature('KAIROS') || feature('KAIROS_BRIEF') + ? ( + require('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') + ).isBriefEnabled() + ? 'Call SendUserMessage at checkpoints to mark where things stand.' + : 'The user will see any text you output.' + : 'The user will see any text you output.'; + /* eslint-enable @typescript-eslint/no-require-imports */ + const proactivePrompt = `\n# Proactive Mode\n\nYou are in proactive mode. Take initiative — explore, act, and make progress without waiting for instructions.\n\nStart by briefly greeting the user.\n\nYou will receive periodic prompts. These are check-ins. Do whatever seems most useful, or call Sleep if there's nothing to do. ${briefVisibility}`; + appendSystemPrompt = appendSystemPrompt ? `${appendSystemPrompt}\n\n${proactivePrompt}` : proactivePrompt; + } + + if (feature('KAIROS') && kairosEnabled && assistantModule) { + const assistantAddendum = assistantModule.getAssistantSystemPromptAddendum(); + appendSystemPrompt = appendSystemPrompt ? `${appendSystemPrompt}\n\n${assistantAddendum}` : assistantAddendum; + } + + // Ink root is only needed for interactive sessions — patchConsole in the + // Ink constructor would swallow console output in headless mode. + let root!: Root; + let getFpsMetrics!: () => FpsMetrics | undefined; + let stats!: StatsStore; + + // Show setup screens after commands are loaded + if (!isNonInteractiveSession) { + const ctx = getRenderContext(false); + getFpsMetrics = ctx.getFpsMetrics; + stats = ctx.stats; + // Install asciicast recorder before Ink mounts (ant-only, opt-in via CLAUDE_CODE_TERMINAL_RECORDING=1) + if (process.env.USER_TYPE === 'ant') { + installAsciicastRecorder(); + } + + const { createRoot } = await import('@anthropic/ink'); + root = await createRoot(ctx.renderOptions); + + // Log startup time now, before any blocking dialog renders. Logging + // from REPL's first render (the old location) included however long + // the user sat on trust/OAuth/onboarding/resume-picker — p99 was ~70s + // dominated by dialog-wait time, not code-path startup. + logEvent('tengu_timer', { + event: 'startup' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + durationMs: Math.round(process.uptime() * 1000), + }); + + logForDebugging('[STARTUP] Running showSetupScreens()...'); + const setupScreensStart = Date.now(); + const onboardingShown = await showSetupScreens( + root, + permissionMode, + allowDangerouslySkipPermissions, + commands, + enableClaudeInChrome, + devChannels, + ); + logForDebugging(`[STARTUP] showSetupScreens() completed in ${Date.now() - setupScreensStart}ms`); + + // Now that trust is established and GrowthBook has auth headers, + // resolve the --remote-control / --rc entitlement gate. + if (feature('BRIDGE_MODE') && remoteControlOption !== undefined) { + const { getBridgeDisabledReason } = await import('./bridge/bridgeEnabled.js'); + const disabledReason = await getBridgeDisabledReason(); + remoteControl = disabledReason === null; + if (disabledReason) { + process.stderr.write(chalk.yellow(`${disabledReason}\n--rc flag ignored.\n`)); + } + } + + // Check for pending agent memory snapshot updates (only for --agent mode, ant-only) + if ( + feature('AGENT_MEMORY_SNAPSHOT') && + mainThreadAgentDefinition && + isCustomAgent(mainThreadAgentDefinition) && + mainThreadAgentDefinition.memory && + mainThreadAgentDefinition.pendingSnapshotUpdate + ) { + const agentDef = mainThreadAgentDefinition; + const choice = await launchSnapshotUpdateDialog(root, { + agentType: agentDef.agentType, + scope: agentDef.memory!, + snapshotTimestamp: agentDef.pendingSnapshotUpdate!.snapshotTimestamp, + }); + if (choice === 'merge') { + const { buildMergePrompt } = await import('./components/agents/SnapshotUpdateDialog.js'); + const mergePrompt = buildMergePrompt(agentDef.agentType, agentDef.memory!); + inputPrompt = inputPrompt ? `${mergePrompt}\n\n${inputPrompt}` : mergePrompt; + } + agentDef.pendingSnapshotUpdate = undefined; + } + + // Skip executing /login if we just completed onboarding for it + if (onboardingShown && prompt?.trim().toLowerCase() === '/login') { + prompt = ''; + } + + if (onboardingShown) { + // Refresh auth-dependent services now that the user has logged in during onboarding. + // Keep in sync with the post-login logic in src/commands/login.tsx + void refreshRemoteManagedSettings(); + void refreshPolicyLimits(); + // Clear user data cache BEFORE GrowthBook refresh so it picks up fresh credentials + resetUserCache(); + // Refresh GrowthBook after login to get updated feature flags (e.g., for claude.ai MCPs) + refreshGrowthBookAfterAuthChange(); + // Clear any stale trusted device token then enroll for Remote Control. + // Both self-gate on tengu_sessions_elevated_auth_enforcement internally + // — enrollTrustedDevice() via checkGate_CACHED_OR_BLOCKING (awaits + // the GrowthBook reinit above), clearTrustedDeviceToken() via the + // sync cached check (acceptable since clear is idempotent). + void import('./bridge/trustedDevice.js').then(m => { + m.clearTrustedDeviceToken(); + return m.enrollTrustedDevice(); + }); + } + + // Validate that the active token's org matches forceLoginOrgUUID (if set + // in managed settings). Runs after onboarding so managed settings and + // login state are fully loaded. + const orgValidation = await validateForceLoginOrg(); + if (!orgValidation.valid) { + await exitWithError(root, (orgValidation as { valid: false; message: string }).message); + } + } + + // If gracefulShutdown was initiated (e.g., user rejected trust dialog), + // process.exitCode will be set. Skip all subsequent operations that could + // trigger code execution before the process exits (e.g. we don't want apiKeyHelper + // to run if trust was not established). + if (process.exitCode !== undefined) { + logForDebugging('Graceful shutdown initiated, skipping further initialization'); + return; + } + + // Initialize LSP manager AFTER trust is established (or in non-interactive mode + // where trust is implicit). This prevents plugin LSP servers from executing + // code in untrusted directories before user consent. + // Must be after inline plugins are set (if any) so --plugin-dir LSP servers are included. + initializeLspServerManager(); + + // Show settings validation errors after trust is established + // MCP config errors don't block settings from loading, so exclude them + if (!isNonInteractiveSession) { + const { errors } = getSettingsWithErrors(); + const nonMcpErrors = errors.filter(e => !e.mcpErrorMetadata); + if (nonMcpErrors.length > 0) { + await launchInvalidSettingsDialog(root, { + settingsErrors: nonMcpErrors, + onExit: () => gracefulShutdownSync(1), + }); + } + } + + // Check quota status, fast mode, passes eligibility, and bootstrap data + // after trust is established. These make API calls which could trigger + // apiKeyHelper execution. + // --bare / SIMPLE: skip — these are cache-warms for the REPL's + // first-turn responsiveness (quota, passes, fastMode, bootstrap data). Fast + // mode doesn't apply to the Agent SDK anyway (see getFastModeUnavailableReason). + const bgRefreshThrottleMs = getFeatureValue_CACHED_MAY_BE_STALE('tengu_cicada_nap_ms', 0); + const lastPrefetched = getGlobalConfig().startupPrefetchedAt ?? 0; + const skipStartupPrefetches = + isBareMode() || (bgRefreshThrottleMs > 0 && Date.now() - lastPrefetched < bgRefreshThrottleMs); + + if (!skipStartupPrefetches) { + const lastPrefetchedInfo = + lastPrefetched > 0 ? ` last ran ${Math.round((Date.now() - lastPrefetched) / 1000)}s ago` : ''; + logForDebugging(`Starting background startup prefetches${lastPrefetchedInfo}`); + + checkQuotaStatus().catch(error => logError(error)); + + // Fetch bootstrap data from the server and update all cache values. + void fetchBootstrapData(); + + // TODO: Consolidate other prefetches into a single bootstrap request. + void prefetchPassesEligibility(); + if (!getFeatureValue_CACHED_MAY_BE_STALE('tengu_miraculo_the_bard', false)) { + void prefetchFastModeStatus(); + } else { + // Kill switch skips the network call, not org-policy enforcement. + // Resolve from cache so orgStatus doesn't stay 'pending' (which + // getFastModeUnavailableReason treats as permissive). + resolveFastModeStatusFromCache(); + } + if (bgRefreshThrottleMs > 0) { + saveGlobalConfig(current => ({ + ...current, + startupPrefetchedAt: Date.now(), + })); + } + } else { + logForDebugging( + `Skipping startup prefetches, last ran ${Math.round((Date.now() - lastPrefetched) / 1000)}s ago`, + ); + // Resolve fast mode org status from cache (no network) + resolveFastModeStatusFromCache(); + } + + if (!isNonInteractiveSession) { + void refreshExampleCommands(); // Pre-fetch example commands (runs git log, no API call) + } + + // Resolve MCP configs (started early, overlaps with setup/trust dialog work) + const { servers: existingMcpConfigs } = await mcpConfigPromise; + logForDebugging( + `[STARTUP] MCP configs resolved in ${mcpConfigResolvedMs}ms (awaited at +${Date.now() - mcpConfigStart}ms)`, + ); + // CLI flag (--mcp-config) should override file-based configs, matching settings precedence + const allMcpConfigs = { + ...existingMcpConfigs, + ...dynamicMcpConfig, + }; + + // Separate SDK configs from regular MCP configs + const sdkMcpConfigs: Record = {}; + const regularMcpConfigs: Record = {}; + + for (const [name, config] of Object.entries(allMcpConfigs)) { + const typedConfig = config as ScopedMcpServerConfig | McpSdkServerConfig; + if (typedConfig.type === 'sdk') { + sdkMcpConfigs[name] = typedConfig as McpSdkServerConfig; + } else { + regularMcpConfigs[name] = typedConfig as ScopedMcpServerConfig; + } + } + + profileCheckpoint('action_mcp_configs_loaded'); + + // Prefetch MCP resources after trust dialog (this is where execution happens). + // Interactive mode only: print mode defers connects until headlessStore exists + // and pushes per-server (below), so ToolSearch's pending-client handling works + // and one slow server doesn't block the batch. + const localMcpPromise = isNonInteractiveSession + ? Promise.resolve({ clients: [], tools: [], commands: [] }) + : prefetchAllMcpResources(regularMcpConfigs); + const claudeaiMcpPromise = isNonInteractiveSession + ? Promise.resolve({ clients: [], tools: [], commands: [] }) + : claudeaiConfigPromise.then(configs => + Object.keys(configs).length > 0 + ? prefetchAllMcpResources(configs) + : { clients: [], tools: [], commands: [] }, + ); + // Merge with dedup by name: each prefetchAllMcpResources call independently + // adds helper tools (ListMcpResourcesTool, ReadMcpResourceTool) via + // local dedup flags, so merging two calls can yield duplicates. print.ts + // already uniqBy's the final tool pool, but dedup here keeps appState clean. + const mcpPromise = Promise.all([localMcpPromise, claudeaiMcpPromise]).then(([local, claudeai]) => ({ + clients: [...local.clients, ...claudeai.clients], + tools: uniqBy([...local.tools, ...claudeai.tools], 'name'), + commands: uniqBy([...local.commands, ...claudeai.commands], 'name'), + })); + + // Start hooks early so they run in parallel with MCP connections. + // Skip for initOnly/init/maintenance (handled separately), non-interactive + // (handled via setupTrigger), and resume/continue (conversationRecovery.ts + // fires 'resume' instead — without this guard, hooks fire TWICE on /resume + // and the second systemMessage clobbers the first. gh-30825) + const hooksPromise = + initOnly || init || maintenance || isNonInteractiveSession || options.continue || options.resume + ? null + : processSessionStartHooks('startup', { + agentType: mainThreadAgentDefinition?.agentType, + model: resolvedInitialModel, + }); + + // MCP never blocks REPL render OR turn 1 TTFT. useManageMCPConnections + // populates appState.mcp async as servers connect (connectToServer is + // memoized — the prefetch calls above and the hook converge on the same + // connections). getToolUseContext reads store.getState() fresh via + // computeTools(), so turn 1 sees whatever's connected by query time. + // Slow servers populate for turn 2+. Matches interactive-no-prompt + // behavior. Print mode: per-server push into headlessStore (below). + const hookMessages: Awaited> = []; + // Suppress transient unhandledRejection — the prefetch warms the + // memoized connectToServer cache but nobody awaits it in interactive. + mcpPromise.catch(() => {}); + + const mcpClients: Awaited['clients'] = []; + const mcpTools: Awaited['tools'] = []; + const mcpCommands: Awaited['commands'] = []; + + let thinkingEnabled = shouldEnableThinkingByDefault(); + let thinkingConfig: ThinkingConfig = thinkingEnabled !== false ? { type: 'adaptive' } : { type: 'disabled' }; + + if (options.thinking === 'adaptive' || options.thinking === 'enabled') { + thinkingEnabled = true; + thinkingConfig = { type: 'adaptive' }; + } else if (options.thinking === 'disabled') { + thinkingEnabled = false; + thinkingConfig = { type: 'disabled' }; + } else { + const maxThinkingTokens = process.env.MAX_THINKING_TOKENS + ? parseInt(process.env.MAX_THINKING_TOKENS, 10) + : options.maxThinkingTokens; + if (maxThinkingTokens !== undefined) { + if (maxThinkingTokens > 0) { + thinkingEnabled = true; + thinkingConfig = { + type: 'enabled', + budgetTokens: maxThinkingTokens, + }; + } else if (maxThinkingTokens === 0) { + thinkingEnabled = false; + thinkingConfig = { type: 'disabled' }; + } + } + } + + logForDiagnosticsNoPII('info', 'started', { + version: MACRO.VERSION, + is_native_binary: isInBundledMode(), + }); + + registerCleanup(async () => { + logForDiagnosticsNoPII('info', 'exited'); + }); + + void logTenguInit({ + hasInitialPrompt: Boolean(prompt), + hasStdin: Boolean(inputPrompt), + verbose, + debug, + debugToStderr, + print: print ?? false, + outputFormat: outputFormat ?? 'text', + inputFormat: inputFormat ?? 'text', + numAllowedTools: allowedTools.length, + numDisallowedTools: disallowedTools.length, + mcpClientCount: Object.keys(allMcpConfigs).length, + worktreeEnabled, + skipWebFetchPreflight: getInitialSettings().skipWebFetchPreflight, + githubActionInputs: process.env.GITHUB_ACTION_INPUTS, + dangerouslySkipPermissionsPassed: dangerouslySkipPermissions ?? false, + permissionMode, + modeIsBypass: permissionMode === 'bypassPermissions', + allowDangerouslySkipPermissionsPassed: allowDangerouslySkipPermissions, + systemPromptFlag: systemPrompt ? (options.systemPromptFile ? 'file' : 'flag') : undefined, + appendSystemPromptFlag: appendSystemPrompt ? (options.appendSystemPromptFile ? 'file' : 'flag') : undefined, + thinkingConfig, + assistantActivationPath: + feature('KAIROS') && kairosEnabled ? assistantModule?.getAssistantActivationPath() : undefined, + }); + + // Log context metrics once at initialization + void logContextMetrics(regularMcpConfigs, toolPermissionContext); + + void logPermissionContextForAnts(null, 'initialization'); + + logManagedSettings(); + + // Register PID file for concurrent-session detection (~/.claude/sessions/) + // and fire multi-clauding telemetry. Lives here (not init.ts) so only the + // REPL path registers — not subcommands like `claude doctor`. Chained: + // count must run after register's write completes or it misses our own file. + void registerSession().then(registered => { + if (!registered) return; + if (sessionNameArg) { + void updateSessionName(sessionNameArg); + } + void countConcurrentSessions().then(count => { + if (count >= 2) { + logEvent('tengu_concurrent_sessions', { + num_sessions: count, + }); + } + }); + }); + + // Initialize versioned plugins system (triggers V1→V2 migration if + // needed). Then run orphan GC, THEN warm the Grep/Glob exclusion cache. + // Sequencing matters: the warmup scans disk for .orphaned_at markers, + // so it must see the GC's Pass 1 (remove markers from reinstalled + // versions) and Pass 2 (stamp unmarked orphans) already applied. The + // warm also lands before autoupdate (fires on first submit in REPL) + // can orphan this session's active version underneath us. + // --bare / SIMPLE: skip plugin version sync + orphan cleanup. These + // are install/upgrade bookkeeping that scripted calls don't need — + // the next interactive session will reconcile. The await here was + // blocking -p on a marketplace round-trip. + if (isBareMode()) { + // skip — no-op + } else if (isNonInteractiveSession) { + // In headless mode, await to ensure plugin sync completes before CLI exits + await initializeVersionedPlugins(); + profileCheckpoint('action_after_plugins_init'); + void cleanupOrphanedPluginVersionsInBackground().then(() => getGlobExclusionsForPluginCache()); + } else { + // In interactive mode, fire-and-forget — this is purely bookkeeping + // that doesn't affect runtime behavior of the current session + void initializeVersionedPlugins().then(async () => { + profileCheckpoint('action_after_plugins_init'); + await cleanupOrphanedPluginVersionsInBackground(); + void getGlobExclusionsForPluginCache(); + }); + } + + const setupTrigger = initOnly || init ? 'init' : maintenance ? 'maintenance' : null; + if (initOnly) { + applyConfigEnvironmentVariables(); + await processSetupHooks('init', { forceSyncExecution: true }); + await processSessionStartHooks('startup', { + forceSyncExecution: true, + }); + gracefulShutdownSync(0); + return; + } + + // --print mode + if (isNonInteractiveSession) { + if (outputFormat === 'stream-json' || outputFormat === 'json') { + setHasFormattedOutput(true); + } + + // Apply full environment variables in print mode since trust dialog is bypassed + // This includes potentially dangerous environment variables from untrusted sources + // but print mode is considered trusted (as documented in help text) + applyConfigEnvironmentVariables(); + + // Initialize telemetry after env vars are applied so OTEL endpoint env vars and + // otelHeadersHelper (which requires trust to execute) are available. + initializeTelemetryAfterTrust(); + + // Kick SessionStart hooks now so the subprocess spawn overlaps with + // MCP connect + plugin init + print.ts import below. loadInitialMessages + // joins this at print.ts:4397. Guarded same as loadInitialMessages — + // continue/resume/teleport paths don't fire startup hooks (or fire them + // conditionally inside the resume branch, where this promise is + // undefined and the ?? fallback runs). Also skip when setupTrigger is + // set — those paths run setup hooks first (print.ts:544), and session + // start hooks must wait until setup completes. + const sessionStartHooksPromise = + options.continue || options.resume || teleport || setupTrigger + ? undefined + : processSessionStartHooks('startup'); + // Suppress transient unhandledRejection if this rejects before + // loadInitialMessages awaits it. Downstream await still observes the + // rejection — this just prevents the spurious global handler fire. + sessionStartHooksPromise?.catch(() => {}); + + profileCheckpoint('before_validateForceLoginOrg'); + // Validate org restriction for non-interactive sessions + const orgValidation = await validateForceLoginOrg(); + if (!orgValidation.valid) { + process.stderr.write((orgValidation as { valid: false; message: string }).message + '\n'); + process.exit(1); + } + + // Headless mode supports all prompt commands and some local commands + // If disableSlashCommands is true, return empty array + const commandsHeadless = disableSlashCommands + ? [] + : commands.filter( + command => + (command.type === 'prompt' && !command.disableNonInteractive) || + (command.type === 'local' && command.supportsNonInteractive), + ); + + const defaultState = getDefaultAppState(); + const headlessInitialState: AppState = { + ...defaultState, + mcp: { + ...defaultState.mcp, + clients: mcpClients, + commands: mcpCommands, + tools: mcpTools, + }, + toolPermissionContext, + effortValue: parseEffortValue(options.effort) ?? getInitialEffortSetting(), + ...(isFastModeEnabled() && { + fastMode: getInitialFastModeSetting(effectiveModel ?? null), + }), + ...(isAdvisorEnabled() && advisorModel && { advisorModel }), + // kairosEnabled gates the async fire-and-forget path in + // executeForkedSlashCommand (processSlashCommand.tsx:132) and + // AgentTool's shouldRunAsync. The REPL initialState sets this at + // ~3459; headless was defaulting to false, so the daemon child's + // scheduled tasks and Agent-tool calls ran synchronously — N + // overdue cron tasks on spawn = N serial subagent turns blocking + // user input. Computed at :1620, well before this branch. + ...(feature('KAIROS') ? { kairosEnabled } : {}), + }; + + // Init app state + const headlessStore = createStore(headlessInitialState, onChangeAppState); + + // Check if bypassPermissions should be disabled based on Statsig gate + // This runs in parallel to the code below, to avoid blocking the main loop. + if (toolPermissionContext.mode === 'bypassPermissions' || allowDangerouslySkipPermissions) { + void checkAndDisableBypassPermissions(toolPermissionContext); + } + + // Async check of auto mode gate — corrects state and disables auto if needed. + // Gated on TRANSCRIPT_CLASSIFIER (not USER_TYPE) so GrowthBook kill switch runs for external builds too. + if (feature('TRANSCRIPT_CLASSIFIER')) { + void verifyAutoModeGateAccess(toolPermissionContext, headlessStore.getState().fastMode).then( + ({ updateContext }) => { + headlessStore.setState(prev => { + const nextCtx = updateContext(prev.toolPermissionContext); + if (nextCtx === prev.toolPermissionContext) return prev; + return { ...prev, toolPermissionContext: nextCtx }; + }); + }, + ); + } + + // Set global state for session persistence + if (options.sessionPersistence === false) { + setSessionPersistenceDisabled(true); + } + + // Store SDK betas in global state for context window calculation + // Only store allowed betas (filters by allowlist and subscriber status) + setSdkBetas(filterAllowedSdkBetas(betas)); + + // Print-mode MCP: per-server incremental push into headlessStore. + // Mirrors useManageMCPConnections — push pending first (so ToolSearch's + // pending-check at ToolSearchTool.ts:334 sees them), then replace with + // connected/failed as each server settles. + const connectMcpBatch = (configs: Record, label: string): Promise => { + if (Object.keys(configs).length === 0) return Promise.resolve(); + headlessStore.setState(prev => ({ + ...prev, + mcp: { + ...prev.mcp, + clients: [ + ...prev.mcp.clients, + ...Object.entries(configs).map(([name, config]) => ({ + name, + type: 'pending' as const, + config, + })), + ], + }, + })); + return getMcpToolsCommandsAndResources(({ client, tools, commands }) => { + headlessStore.setState(prev => ({ + ...prev, + mcp: { + ...prev.mcp, + clients: prev.mcp.clients.some(c => c.name === client.name) + ? prev.mcp.clients.map(c => (c.name === client.name ? client : c)) + : [...prev.mcp.clients, client], + tools: uniqBy([...prev.mcp.tools, ...tools], 'name'), + commands: uniqBy([...prev.mcp.commands, ...commands], 'name'), + }, + })); + }, configs).catch(err => logForDebugging(`[MCP] ${label} connect error: ${err}`)); + }; + // Await all MCP configs — print mode is often single-turn, so + // "late-connecting servers visible next turn" doesn't help. SDK init + // message and turn-1 tool list both need configured MCP tools present. + // Zero-server case is free via the early return in connectMcpBatch. + // Connectors parallelize inside getMcpToolsCommandsAndResources + // (processBatched with Promise.all). claude.ai is awaited too — its + // fetch was kicked off early (line ~2558) so only residual time blocks + // here. --bare skips claude.ai entirely for perf-sensitive scripts. + profileCheckpoint('before_connectMcp'); + await connectMcpBatch(regularMcpConfigs, 'regular'); + profileCheckpoint('after_connectMcp'); + // Dedup: suppress plugin MCP servers that duplicate a claude.ai + // connector (connector wins), then connect claude.ai servers. + // Bounded wait — #23725 made this blocking so single-turn -p sees + // connectors, but with 40+ slow connectors tengu_startup_perf p99 + // climbed to 76s. If fetch+connect doesn't finish in time, proceed; + // the promise keeps running and updates headlessStore in the + // background so turn 2+ still sees connectors. + const CLAUDE_AI_MCP_TIMEOUT_MS = 5_000; + const claudeaiConnect = claudeaiConfigPromise.then(claudeaiConfigs => { + if (Object.keys(claudeaiConfigs).length > 0) { + const claudeaiSigs = new Set(); + for (const config of Object.values(claudeaiConfigs)) { + const sig = getMcpServerSignature(config); + if (sig) claudeaiSigs.add(sig); + } + const suppressed = new Set(); + for (const [name, config] of Object.entries(regularMcpConfigs)) { + if (!name.startsWith('plugin:')) continue; + const sig = getMcpServerSignature(config); + if (sig && claudeaiSigs.has(sig)) suppressed.add(name); + } + if (suppressed.size > 0) { + logForDebugging( + `[MCP] Lazy dedup: suppressing ${suppressed.size} plugin server(s) that duplicate claude.ai connectors: ${[...suppressed].join(', ')}`, + ); + // Disconnect before filtering from state. Only connected + // servers need cleanup — clearServerCache on a never-connected + // server triggers a real connect just to kill it (memoize + // cache-miss path, see useManageMCPConnections.ts:870). + for (const c of headlessStore.getState().mcp.clients) { + if (!suppressed.has(c.name) || c.type !== 'connected') continue; + c.client.onclose = undefined; + void clearServerCache(c.name, c.config).catch(() => {}); + } + headlessStore.setState(prev => { + let { clients, tools, commands, resources } = prev.mcp; + clients = clients.filter(c => !suppressed.has(c.name)); + tools = tools.filter(t => !t.mcpInfo || !suppressed.has(t.mcpInfo.serverName)); + for (const name of suppressed) { + commands = excludeCommandsByServer(commands, name); + resources = excludeResourcesByServer(resources, name); + } + return { + ...prev, + mcp: { + ...prev.mcp, + clients, + tools, + commands, + resources, + }, + }; + }); + } + } + // Suppress claude.ai connectors that duplicate an enabled + // manual server (URL-signature match). Plugin dedup above only + // handles `plugin:*` keys; this catches manual `.mcp.json` entries. + // plugin:* must be excluded here — step 1 already suppressed + // those (claude.ai wins); leaving them in suppresses the + // connector too, and neither survives (gh-39974). + const nonPluginConfigs = pickBy(regularMcpConfigs, (_, n) => !n.startsWith('plugin:')); + const { servers: dedupedClaudeAi } = dedupClaudeAiMcpServers(claudeaiConfigs, nonPluginConfigs); + return connectMcpBatch(dedupedClaudeAi, 'claudeai'); + }); + let claudeaiTimer: ReturnType | undefined; + const claudeaiTimedOut = await Promise.race([ + claudeaiConnect.then(() => false), + new Promise(resolve => { + claudeaiTimer = setTimeout(r => r(true), CLAUDE_AI_MCP_TIMEOUT_MS, resolve); + }), + ]); + if (claudeaiTimer) clearTimeout(claudeaiTimer); + if (claudeaiTimedOut) { + logForDebugging( + `[MCP] claude.ai connectors not ready after ${CLAUDE_AI_MCP_TIMEOUT_MS}ms — proceeding; background connection continues`, + ); + } + profileCheckpoint('after_connectMcp_claudeai'); + + // In headless mode, start deferred prefetches immediately (no user typing delay) + // --bare / SIMPLE: startDeferredPrefetches early-returns internally. + // backgroundHousekeeping (initExtractMemories, pruneShellSnapshots, + // cleanupOldMessageFiles) and sdkHeapDumpMonitor are all bookkeeping + // that scripted calls don't need — the next interactive session reconciles. + if (!isBareMode()) { + startDeferredPrefetches(); + void import('./utils/backgroundHousekeeping.js').then(m => m.startBackgroundHousekeeping()); + if (process.env.USER_TYPE === 'ant') { + void import('./utils/sdkHeapDumpMonitor.js').then(m => m.startSdkMemoryMonitor()); + } + } + + logSessionTelemetry(); + profileCheckpoint('before_print_import'); + const { runHeadless } = await import('src/cli/print.js'); + profileCheckpoint('after_print_import'); + void runHeadless( + inputPrompt, + () => headlessStore.getState(), + headlessStore.setState, + commandsHeadless, + tools, + sdkMcpConfigs, + agentDefinitions.activeAgents, + { + continue: options.continue, + resume: options.resume, + verbose: verbose, + outputFormat: outputFormat, + jsonSchema, + permissionPromptToolName: options.permissionPromptTool, + allowedTools, + thinkingConfig, + maxTurns: options.maxTurns, + maxBudgetUsd: options.maxBudgetUsd, + taskBudget: options.taskBudget ? { total: options.taskBudget } : undefined, + systemPrompt, + appendSystemPrompt, + userSpecifiedModel: effectiveModel, + fallbackModel: userSpecifiedFallbackModel, + teleport, + sdkUrl, + replayUserMessages: effectiveReplayUserMessages, + includePartialMessages: effectiveIncludePartialMessages, + forkSession: options.forkSession || false, + resumeSessionAt: options.resumeSessionAt || undefined, + rewindFiles: options.rewindFiles, + enableAuthStatus: options.enableAuthStatus, + agent: agentCli, + workload: options.workload, + setupTrigger: setupTrigger ?? undefined, + sessionStartHooksPromise, + }, + ); + return; + } + + // Log model config at startup + logEvent('tengu_startup_manual_model_config', { + cli_flag: options.model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + env_var: process.env.ANTHROPIC_MODEL as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + settings_file: (getInitialSettings() || {}).model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + subscriptionType: getSubscriptionType() as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + agent: agentSetting as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + + // Get deprecation warning for the initial model (resolvedInitialModel computed earlier for hooks parallelization) + const deprecationWarning = getModelDeprecationWarning(resolvedInitialModel); + + // Build initial notification queue + const initialNotifications: Array<{ + key: string; + text: string; + color?: 'warning'; + priority: 'high'; + }> = []; + if (permissionModeNotification) { + initialNotifications.push({ + key: 'permission-mode-notification', + text: permissionModeNotification, + priority: 'high', + }); + } + if (deprecationWarning) { + initialNotifications.push({ + key: 'model-deprecation-warning', + text: deprecationWarning, + color: 'warning', + priority: 'high', + }); + } + if (overlyBroadBashPermissions.length > 0) { + const displayList = uniq(overlyBroadBashPermissions.map(p => p.ruleDisplay)); + const displays = displayList.join(', '); + const sources = uniq(overlyBroadBashPermissions.map(p => p.sourceDisplay)).join(', '); + const n = displayList.length; + initialNotifications.push({ + key: 'overly-broad-bash-notification', + text: `${displays} allow ${plural(n, 'rule')} from ${sources} ${plural(n, 'was', 'were')} ignored \u2014 not available for Ants, please use auto-mode instead`, + color: 'warning', + priority: 'high', + }); + } + + const effectiveToolPermissionContext = { + ...toolPermissionContext, + mode: + isAgentSwarmsEnabled() && getTeammateUtils().isPlanModeRequired() + ? ('plan' as const) + : toolPermissionContext.mode, + }; + // All startup opt-in paths (--tools, --brief, defaultView) have fired + // above; initialIsBriefOnly just reads the resulting state. + const initialIsBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? getUserMsgOptIn() : false; + const fullRemoteControl = remoteControl || getRemoteControlAtStartup() || kairosEnabled; + let ccrMirrorEnabled = false; + if (feature('CCR_MIRROR') && !fullRemoteControl) { + /* eslint-disable @typescript-eslint/no-require-imports */ + const { isCcrMirrorEnabled } = + require('./bridge/bridgeEnabled.js') as typeof import('./bridge/bridgeEnabled.js'); + /* eslint-enable @typescript-eslint/no-require-imports */ + ccrMirrorEnabled = isCcrMirrorEnabled(); + } + + const initialState: AppState = { + settings: getInitialSettings(), + tasks: {}, + agentNameRegistry: new Map(), + verbose: verbose ?? getGlobalConfig().verbose ?? false, + mainLoopModel: initialMainLoopModel, + mainLoopModelForSession: null, + isBriefOnly: initialIsBriefOnly, + expandedView: getGlobalConfig().showSpinnerTree + ? 'teammates' + : getGlobalConfig().showExpandedTodos + ? 'tasks' + : 'none', + showTeammateMessagePreview: isAgentSwarmsEnabled() ? false : undefined, + selectedIPAgentIndex: -1, + coordinatorTaskIndex: -1, + viewSelectionMode: 'none', + footerSelection: null, + toolPermissionContext: effectiveToolPermissionContext, + agent: mainThreadAgentDefinition?.agentType, + agentDefinitions, + mcp: { + clients: [], + tools: [], + commands: [], + resources: {}, + pluginReconnectKey: 0, + }, + plugins: { + enabled: [], + disabled: [], + commands: [], + errors: [], + installationStatus: { + marketplaces: [], + plugins: [], + }, + needsRefresh: false, + }, + statusLineText: undefined, + kairosEnabled, + remoteSessionUrl: undefined, + remoteConnectionStatus: 'connecting', + remoteBackgroundTaskCount: 0, + replBridgeEnabled: fullRemoteControl || ccrMirrorEnabled, + replBridgeExplicit: remoteControl, + replBridgeOutboundOnly: ccrMirrorEnabled, + replBridgeConnected: false, + replBridgeSessionActive: false, + replBridgeReconnecting: false, + replBridgeConnectUrl: undefined, + replBridgeSessionUrl: undefined, + replBridgeEnvironmentId: undefined, + replBridgeSessionId: undefined, + replBridgeError: undefined, + replBridgeInitialName: remoteControlName, + showRemoteCallout: false, + notifications: { + current: null, + queue: initialNotifications, + }, + elicitation: { + queue: [], + }, + todos: {}, + remoteAgentTaskSuggestions: [], + fileHistory: { + snapshots: [], + trackedFiles: new Set(), + snapshotSequence: 0, + }, + attribution: createEmptyAttributionState(), + thinkingEnabled, + promptSuggestionEnabled: shouldEnablePromptSuggestion(), + sessionHooks: new Map(), + inbox: { + messages: [], + }, + promptSuggestion: { + text: null, + promptId: null, + shownAt: 0, + acceptedAt: 0, + generationRequestId: null, + }, + speculation: IDLE_SPECULATION_STATE, + speculationSessionTimeSavedMs: 0, + skillImprovement: { + suggestion: null, + }, + workerSandboxPermissions: { + queue: [], + selectedIndex: 0, + }, + pendingWorkerRequest: null, + pendingSandboxRequest: null, + authVersion: 0, + initialMessage: inputPrompt + ? { + message: createUserMessage({ + content: String(inputPrompt), + }), + } + : null, + effortValue: parseEffortValue(options.effort) ?? getInitialEffortSetting(), + activeOverlays: new Set(), + fastMode: getInitialFastModeSetting(resolvedInitialModel), + ...(isAdvisorEnabled() && advisorModel && { advisorModel }), + // Compute teamContext synchronously to avoid useEffect setState during render. + // KAIROS: assistantTeamContext takes precedence — set earlier in the + // KAIROS block so Agent(name: "foo") can spawn in-process teammates + // without TeamCreate. computeInitialTeamContext() is for tmux-spawned + // teammates reading their own identity, not the assistant-mode leader. + teamContext: (feature('KAIROS') + ? (assistantTeamContext ?? computeInitialTeamContext()) + : computeInitialTeamContext()) as AppState['teamContext'], + }; + + // Add CLI initial prompt to history + if (inputPrompt) { + addToHistory(String(inputPrompt)); + } + + const initialTools = mcpTools; + + // Increment numStartups synchronously — first-render readers like + // shouldShowEffortCallout (via useState initializer) need the updated + // value before setImmediate fires. Defer only telemetry. + saveGlobalConfig(current => ({ + ...current, + numStartups: (current.numStartups ?? 0) + 1, + })); + setImmediate(() => { + void logStartupTelemetry(); + logSessionTelemetry(); + }); + + // Set up per-turn session environment data uploader (ant-only build). + // Default-enabled for all ant users when working in an Anthropic-owned + // repo. Captures git/filesystem state (NOT transcripts) at each turn so + // environments can be recreated at any user message index. Gating: + // - Build-time: this import is stubbed in external builds. + // - Runtime: uploader checks github.com/anthropics/* remote + gcloud auth. + // - Safety: CLAUDE_CODE_DISABLE_SESSION_DATA_UPLOAD=1 bypasses (tests set this). + // Import is dynamic + async to avoid adding startup latency. + const sessionUploaderPromise = process.env.USER_TYPE === 'ant' ? import('./utils/sessionDataUploader.js') : null; + + // Defer session uploader resolution to the onTurnComplete callback to avoid + // adding a new top-level await in main.tsx (performance-critical path). + // The per-turn auth logic in sessionDataUploader.ts handles unauthenticated + // state gracefully (re-checks each turn, so auth recovery mid-session works). + const uploaderReady = sessionUploaderPromise + ? sessionUploaderPromise.then(mod => mod.createSessionTurnUploader()).catch(() => null) + : null; + + const sessionConfig = { + debug: debug || debugToStderr, + commands: [...commands, ...mcpCommands], + initialTools, + mcpClients, + autoConnectIdeFlag: ide, + mainThreadAgentDefinition, + disableSlashCommands, + dynamicMcpConfig, + strictMcpConfig, + systemPrompt, + appendSystemPrompt, + taskListId, + thinkingConfig, + ...(uploaderReady && { + onTurnComplete: (messages: MessageType[]) => { + void uploaderReady.then(uploader => (uploader as ((msgs: MessageType[]) => void) | null)?.(messages)); + }, + }), + }; + + // Shared context for processResumedConversation calls + const resumeContext = { + modeApi: coordinatorModeModule, + mainThreadAgentDefinition, + agentDefinitions, + currentCwd, + cliAgents, + initialState, + }; + + if (options.continue) { + // Continue the most recent conversation directly + let resumeSucceeded = false; + try { + const resumeStart = performance.now(); + + // Clear stale caches before resuming to ensure fresh file/skill discovery + const { clearSessionCaches } = await import('./commands/clear/caches.js'); + clearSessionCaches(); + + const result = await loadConversationForResume(undefined /* sessionId */, undefined /* sourceFile */); + if (!result) { + logEvent('tengu_continue', { + success: false, + }); + return await exitWithError(root, 'No conversation found to continue'); + } + + const loaded = await processResumedConversation( + result, + { + forkSession: !!options.forkSession, + includeAttribution: true, + transcriptPath: result.fullPath, + }, + resumeContext, + ); + + if (loaded.restoredAgentDef) { + mainThreadAgentDefinition = loaded.restoredAgentDef; + } + + maybeActivateProactive(options); + maybeActivateBrief(options); + + logEvent('tengu_continue', { + success: true, + resume_duration_ms: Math.round(performance.now() - resumeStart), + }); + resumeSucceeded = true; + + await launchRepl( + root, + { + getFpsMetrics, + stats, + initialState: loaded.initialState, + }, + { + ...sessionConfig, + mainThreadAgentDefinition: loaded.restoredAgentDef ?? mainThreadAgentDefinition, + initialMessages: loaded.messages, + initialFileHistorySnapshots: loaded.fileHistorySnapshots, + initialContentReplacements: loaded.contentReplacements, + initialAgentName: loaded.agentName, + initialAgentColor: loaded.agentColor, + }, + renderAndRun, + ); + } catch (error) { + if (!resumeSucceeded) { + logEvent('tengu_continue', { + success: false, + }); + } + logError(error); + process.exit(1); + } + } else if (feature('DIRECT_CONNECT') && _pendingConnect?.url) { + // `claude connect ` — full interactive TUI connected to a remote server + let directConnectConfig; + try { + const session = await createDirectConnectSession({ + serverUrl: _pendingConnect.url, + authToken: _pendingConnect.authToken, + cwd: getOriginalCwd(), + dangerouslySkipPermissions: _pendingConnect.dangerouslySkipPermissions, + }); + if (session.workDir) { + setOriginalCwd(session.workDir); + setCwdState(session.workDir); + } + setDirectConnectServerUrl(_pendingConnect.url); + directConnectConfig = session.config; + } catch (err) { + return await exitWithError(root, err instanceof DirectConnectError ? err.message : String(err), () => + gracefulShutdown(1), + ); + } + + const connectInfoMessage = createSystemMessage( + `Connected to server at ${_pendingConnect.url}\nSession: ${directConnectConfig.sessionId}`, + 'info', + ); + + await launchRepl( + root, + { getFpsMetrics, stats, initialState }, + { + debug: debug || debugToStderr, + commands, + initialTools: [], + initialMessages: [connectInfoMessage], + mcpClients: [], + autoConnectIdeFlag: ide, + mainThreadAgentDefinition, + disableSlashCommands, + directConnectConfig, + thinkingConfig, + }, + renderAndRun, + ); + return; + } else if (feature('SSH_REMOTE') && _pendingSSH?.host) { + // `claude ssh [dir]` — probe remote, deploy binary if needed, + // spawn ssh with unix-socket -R forward to a local auth proxy, hand + // the REPL an SSHSession. Tools run remotely, UI renders locally. + // `--local` skips probe/deploy/ssh and spawns the current binary + // directly with the same env — e2e test of the proxy/auth plumbing. + const { createSSHSession, createLocalSSHSession, SSHSessionError } = await import('./ssh/createSSHSession.js'); + let sshSession: import('./ssh/createSSHSession.js').SSHSession | undefined; + try { + if (_pendingSSH.local) { + process.stderr.write('Starting local ssh-proxy test session...\n'); + sshSession = await createLocalSSHSession({ + cwd: _pendingSSH.cwd, + permissionMode: _pendingSSH.permissionMode, + dangerouslySkipPermissions: _pendingSSH.dangerouslySkipPermissions, + }); + } else { + process.stderr.write(`Connecting to ${_pendingSSH.host}…\n`); + // In-place progress: \r + EL0 (erase to end of line). Final \n on + // success so the next message lands on a fresh line. No-op when + // stderr isn't a TTY (piped/redirected) — \r would just emit noise. + const isTTY = process.stderr.isTTY; + let hadProgress = false; + sshSession = await createSSHSession( + { + host: _pendingSSH.host, + cwd: _pendingSSH.cwd, + localVersion: MACRO.VERSION, + permissionMode: _pendingSSH.permissionMode, + dangerouslySkipPermissions: _pendingSSH.dangerouslySkipPermissions, + extraCliArgs: _pendingSSH.extraCliArgs, + }, + isTTY + ? { + onProgress: (msg: string) => { + hadProgress = true; + process.stderr.write(`\r ${msg}\x1b[K`); + }, + } + : {}, + ); + if (hadProgress) process.stderr.write('\n'); + } + setOriginalCwd(sshSession.remoteCwd); + setCwdState(sshSession.remoteCwd); + setDirectConnectServerUrl(_pendingSSH.local ? 'local' : _pendingSSH.host); + } catch (err) { + return await exitWithError(root, err instanceof SSHSessionError ? err.message : String(err), () => + gracefulShutdown(1), + ); + } + + const sshInfoMessage = createSystemMessage( + _pendingSSH.local + ? `Local ssh-proxy test session\ncwd: ${sshSession.remoteCwd}\nAuth: unix socket → local proxy` + : `SSH session to ${_pendingSSH.host}\nRemote cwd: ${sshSession.remoteCwd}\nAuth: unix socket -R → local proxy`, + 'info', + ); + + await launchRepl( + root, + { getFpsMetrics, stats, initialState }, + { + debug: debug || debugToStderr, + commands, + initialTools: [], + initialMessages: [sshInfoMessage], + mcpClients: [], + autoConnectIdeFlag: ide, + mainThreadAgentDefinition, + disableSlashCommands, + sshSession, + thinkingConfig, + }, + renderAndRun, + ); + return; + } else if ( + feature('KAIROS') && + _pendingAssistantChat && + (_pendingAssistantChat.sessionId || _pendingAssistantChat.discover) + ) { + // `claude assistant [sessionId]` — REPL as a pure viewer client + // of a remote assistant session. The agentic loop runs remotely; this + // process streams live events and POSTs messages. History is lazy- + // loaded by useAssistantHistory on scroll-up (no blocking fetch here). + const { discoverAssistantSessions } = await import('./assistant/sessionDiscovery.js'); + + let targetSessionId = _pendingAssistantChat.sessionId; + + // Discovery flow — list bridge environments, filter sessions + if (!targetSessionId) { + let sessions; + try { + sessions = await discoverAssistantSessions(); + } catch (e) { + return await exitWithError(root, `Failed to discover sessions: ${e instanceof Error ? e.message : e}`, () => + gracefulShutdown(1), + ); + } + if (sessions.length === 0) { + let installedDir: string | null; + try { + installedDir = await launchAssistantInstallWizard(root); + } catch (e) { + return await exitWithError( + root, + `Assistant installation failed: ${e instanceof Error ? e.message : e}`, + () => gracefulShutdown(1), + ); + } + if (installedDir === null) { + await gracefulShutdown(0); + process.exit(0); + } + // The daemon needs a few seconds to spin up its worker and + // establish a bridge session before discovery will find it. + return await exitWithMessage( + root, + `Assistant installed in ${installedDir}. The daemon is starting up — run \`claude assistant\` again in a few seconds to connect.`, + { + exitCode: 0, + beforeExit: () => gracefulShutdown(0), + }, + ); + } + if (sessions.length === 1) { + targetSessionId = sessions[0]!.id; + } else { + const picked = await launchAssistantSessionChooser(root, { + sessions, + }); + if (!picked) { + await gracefulShutdown(0); + process.exit(0); + } + targetSessionId = picked; + } + } + + // Auth — call prepareApiRequest() once for orgUUID, but use a + // getAccessToken closure for the token so reconnects get fresh tokens. + const { checkAndRefreshOAuthTokenIfNeeded, getClaudeAIOAuthTokens } = await import('./utils/auth.js'); + await checkAndRefreshOAuthTokenIfNeeded(); + let apiCreds; + try { + apiCreds = await prepareApiRequest(); + } catch (e) { + return await exitWithError(root, `Error: ${e instanceof Error ? e.message : 'Failed to authenticate'}`, () => + gracefulShutdown(1), + ); + } + const getAccessToken = (): string => getClaudeAIOAuthTokens()?.accessToken ?? apiCreds.accessToken; + + // Brief mode activation: setKairosActive(true) satisfies BOTH opt-in + // and entitlement for isBriefEnabled() (BriefTool.ts:124-132). + setKairosActive(true); + setUserMsgOptIn(true); + setIsRemoteMode(true); + + const remoteSessionConfig = createRemoteSessionConfig( + targetSessionId, + getAccessToken, + apiCreds.orgUUID, + /* hasInitialPrompt */ false, + /* viewerOnly */ true, + ); + + const infoMessage = createSystemMessage( + `Attached to assistant session ${targetSessionId.slice(0, 8)}…`, + 'info', + ); + + const assistantInitialState: AppState = { + ...initialState, + isBriefOnly: true, + kairosEnabled: false, + replBridgeEnabled: false, + }; + + const remoteCommands = filterCommandsForRemoteMode(commands); + await launchRepl( + root, + { + getFpsMetrics, + stats, + initialState: assistantInitialState, + }, + { + debug: debug || debugToStderr, + commands: remoteCommands, + initialTools: [], + initialMessages: [infoMessage], + mcpClients: [], + autoConnectIdeFlag: ide, + mainThreadAgentDefinition, + disableSlashCommands, + remoteSessionConfig, + thinkingConfig, + }, + renderAndRun, + ); + return; + } else if (options.resume || options.fromPr || teleport || remote !== null) { + // Handle resume flow - from file (ant-only), session ID, or interactive selector + + // Clear stale caches before resuming to ensure fresh file/skill discovery + const { clearSessionCaches } = await import('./commands/clear/caches.js'); + clearSessionCaches(); + + let messages: MessageType[] | null = null; + let processedResume: ProcessedResume | undefined; + + let maybeSessionId = validateUuid(options.resume); + let searchTerm: string | undefined; + // Store full LogOption when found by custom title (for cross-worktree resume) + let matchedLog: LogOption | null = null; + // PR filter for --from-pr flag + let filterByPr: boolean | number | string | undefined; + + // Handle --from-pr flag + if (options.fromPr) { + if (options.fromPr === true) { + // Show all sessions with linked PRs + filterByPr = true; + } else if (typeof options.fromPr === 'string') { + // Could be a PR number or URL + filterByPr = options.fromPr; + } + } + + // If resume value is not a UUID, try exact match by custom title first + if (options.resume && typeof options.resume === 'string' && !maybeSessionId) { + const trimmedValue = options.resume.trim(); + if (trimmedValue) { + const matches = await searchSessionsByCustomTitle(trimmedValue, { + exact: true, + }); + + if (matches.length === 1) { + // Exact match found - store full LogOption for cross-worktree resume + matchedLog = matches[0]!; + maybeSessionId = getSessionIdFromLog(matchedLog) ?? null; + } else { + // No match or multiple matches - use as search term for picker + searchTerm = trimmedValue; + } + } + } + + // --remote and --teleport both create/resume Claude Code Web (CCR) sessions. + // Remote Control (--rc) is a separate feature gated in initReplBridge.ts. + if (remote !== null || teleport) { + await waitForPolicyLimitsToLoad(); + if (!isPolicyAllowed('allow_remote_sessions')) { + return await exitWithError(root, "Error: Remote sessions are disabled by your organization's policy.", () => + gracefulShutdown(1), + ); + } + } + + if (remote !== null) { + // Create remote session (optionally with initial prompt) + const hasInitialPrompt = remote.length > 0; + + // Check if TUI mode is enabled - description is only optional in TUI mode + const isRemoteTuiEnabled = getFeatureValue_CACHED_MAY_BE_STALE('tengu_remote_backend', false); + if (!isRemoteTuiEnabled && !hasInitialPrompt) { + return await exitWithError( + root, + 'Error: --remote requires a description.\nUsage: claude --remote "your task description"', + () => gracefulShutdown(1), + ); + } + + logEvent('tengu_remote_create_session', { + has_initial_prompt: String(hasInitialPrompt) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + + // Pass current branch so CCR clones the repo at the right revision + const currentBranch = await getBranch(); + const createdSession = await teleportToRemoteWithErrorHandling( + root, + hasInitialPrompt ? remote : null, + new AbortController().signal, + currentBranch || undefined, + ); + if (!createdSession) { + logEvent('tengu_remote_create_session_error', { + error: 'unable_to_create_session' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + return await exitWithError(root, 'Error: Unable to create remote session', () => gracefulShutdown(1)); + } + logEvent('tengu_remote_create_session_success', { + session_id: createdSession.id as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + + // Check if new remote TUI mode is enabled via feature gate + if (!isRemoteTuiEnabled) { + // Original behavior: print session info and exit + process.stdout.write(`Created remote session: ${createdSession.title}\n`); + process.stdout.write(`View: ${getRemoteSessionUrl(createdSession.id)}?m=0\n`); + process.stdout.write(`Resume with: claude --teleport ${createdSession.id}\n`); + await gracefulShutdown(0); + process.exit(0); + } + + // New behavior: start local TUI with CCR engine + // Mark that we're in remote mode for command visibility + setIsRemoteMode(true); + switchSession(asSessionId(createdSession.id)); + + // Get OAuth credentials for remote session + let apiCreds: { accessToken: string; orgUUID: string }; + try { + apiCreds = await prepareApiRequest(); + } catch (error) { + logError(toError(error)); + return await exitWithError(root, `Error: ${errorMessage(error) || 'Failed to authenticate'}`, () => + gracefulShutdown(1), + ); + } + + // Create remote session config for the REPL + const { getClaudeAIOAuthTokens: getTokensForRemote } = await import('./utils/auth.js'); + const getAccessTokenForRemote = (): string => getTokensForRemote()?.accessToken ?? apiCreds.accessToken; + const remoteSessionConfig = createRemoteSessionConfig( + createdSession.id, + getAccessTokenForRemote, + apiCreds.orgUUID, + hasInitialPrompt, + ); + + // Add remote session info as initial system message + const remoteSessionUrl = `${getRemoteSessionUrl(createdSession.id)}?m=0`; + const remoteInfoMessage = createSystemMessage( + `/remote-control is active. Code in CLI or at ${remoteSessionUrl}`, + 'info', + ); + + // Create initial user message from the prompt if provided (CCR echoes it back but we ignore that) + const initialUserMessage = hasInitialPrompt ? createUserMessage({ content: remote }) : null; + + // Set remote session URL in app state for footer indicator + const remoteInitialState = { + ...initialState, + remoteSessionUrl, + }; + + // Pre-filter commands to only include remote-safe ones. + // CCR's init response may further refine the list (via handleRemoteInit in REPL). + const remoteCommands = filterCommandsForRemoteMode(commands); + await launchRepl( + root, + { + getFpsMetrics, + stats, + initialState: remoteInitialState, + }, + { + debug: debug || debugToStderr, + commands: remoteCommands, + initialTools: [], + initialMessages: initialUserMessage ? [remoteInfoMessage, initialUserMessage] : [remoteInfoMessage], + mcpClients: [], + autoConnectIdeFlag: ide, + mainThreadAgentDefinition, + disableSlashCommands, + remoteSessionConfig, + thinkingConfig, + }, + renderAndRun, + ); + return; + } else if (teleport) { + if (teleport === true || teleport === '') { + // Interactive mode: show task selector and handle resume + logEvent('tengu_teleport_interactive_mode', {}); + logForDebugging('selectAndResumeTeleportTask: Starting teleport flow...'); + const teleportResult = await launchTeleportResumeWrapper(root); + if (!teleportResult) { + // User cancelled or error occurred + await gracefulShutdown(0); + process.exit(0); + } + const { branchError } = await checkOutTeleportedSessionBranch(teleportResult.branch); + messages = processMessagesForTeleportResume(teleportResult.log, branchError); + } else if (typeof teleport === 'string') { + logEvent('tengu_teleport_resume_session', { + mode: 'direct' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + try { + // First, fetch session and validate repository before checking git state + const sessionData = await fetchSession(teleport); + const repoValidation = await validateSessionRepository(sessionData); + + // Handle repo mismatch or not in repo cases + if (repoValidation.status === 'mismatch' || repoValidation.status === 'not_in_repo') { + const sessionRepo = repoValidation.sessionRepo; + if (sessionRepo) { + // Check for known paths + const knownPaths = getKnownPathsForRepo(sessionRepo); + const existingPaths = await filterExistingPaths(knownPaths); + + if (existingPaths.length > 0) { + // Show directory switch dialog + const selectedPath = await launchTeleportRepoMismatchDialog(root, { + targetRepo: sessionRepo, + initialPaths: existingPaths, + }); + + if (selectedPath) { + // Change to the selected directory + process.chdir(selectedPath); + setCwd(selectedPath); + setOriginalCwd(selectedPath); + } else { + // User cancelled + await gracefulShutdown(0); + } + } else { + // No known paths - show original error + throw new TeleportOperationError( + `You must run claude --teleport ${teleport} from a checkout of ${sessionRepo}.`, + chalk.red( + `You must run claude --teleport ${teleport} from a checkout of ${chalk.bold(sessionRepo)}.\n`, + ), + ); + } + } + } else if (repoValidation.status === 'error') { + throw new TeleportOperationError( + repoValidation.errorMessage || 'Failed to validate session', + chalk.red(`Error: ${repoValidation.errorMessage || 'Failed to validate session'}\n`), + ); + } + + await validateGitState(); + + // Use progress UI for teleport + const { teleportWithProgress } = await import('./components/TeleportProgress.js'); + const result = await teleportWithProgress(root, teleport); + // Track teleported session for reliability logging + setTeleportedSessionInfo({ sessionId: teleport }); + messages = result.messages; + } catch (error) { + if (error instanceof TeleportOperationError) { + process.stderr.write(error.formattedMessage + '\n'); + } else { + logError(error); + process.stderr.write(chalk.red(`Error: ${errorMessage(error)}\n`)); + } + await gracefulShutdown(1); + } + } + } + if (process.env.USER_TYPE === 'ant') { + if (options.resume && typeof options.resume === 'string' && !maybeSessionId) { + // Check for ccshare URL (e.g. https://go/ccshare/boris-20260311-211036) + const { parseCcshareId, loadCcshare } = await import('./utils/ccshareResume.js'); + const ccshareId = parseCcshareId(options.resume); + if (ccshareId) { + try { + const resumeStart = performance.now(); + const logOption = await loadCcshare(ccshareId); + const result = await loadConversationForResume(logOption, undefined); + if (result) { + processedResume = await processResumedConversation( + result, + { + forkSession: true, + transcriptPath: result.fullPath, + }, + resumeContext, + ); + if (processedResume.restoredAgentDef) { + mainThreadAgentDefinition = processedResume.restoredAgentDef; + } + logEvent('tengu_session_resumed', { + entrypoint: 'ccshare' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + success: true, + resume_duration_ms: Math.round(performance.now() - resumeStart), + }); + } else { + logEvent('tengu_session_resumed', { + entrypoint: 'ccshare' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + success: false, + }); + } + } catch (error) { + logEvent('tengu_session_resumed', { + entrypoint: 'ccshare' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + success: false, + }); + logError(error); + await exitWithError(root, `Unable to resume from ccshare: ${errorMessage(error)}`, () => + gracefulShutdown(1), + ); + } + } else { + const resolvedPath = resolve(options.resume); + try { + const resumeStart = performance.now(); + let logOption; + try { + // Attempt to load as a transcript file; ENOENT falls through to session-ID handling + logOption = await loadTranscriptFromFile(resolvedPath); + } catch (error) { + if (!isENOENT(error)) throw error; + // ENOENT: not a file path — fall through to session-ID handling + } + if (logOption) { + const result = await loadConversationForResume(logOption, undefined /* sourceFile */); + if (result) { + processedResume = await processResumedConversation( + result, + { + forkSession: !!options.forkSession, + transcriptPath: result.fullPath, + }, + resumeContext, + ); + if (processedResume.restoredAgentDef) { + mainThreadAgentDefinition = processedResume.restoredAgentDef; + } + logEvent('tengu_session_resumed', { + entrypoint: 'file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + success: true, + resume_duration_ms: Math.round(performance.now() - resumeStart), + }); + } else { + logEvent('tengu_session_resumed', { + entrypoint: 'file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + success: false, + }); + } + } + } catch (error) { + logEvent('tengu_session_resumed', { + entrypoint: 'file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + success: false, + }); + logError(error); + await exitWithError(root, `Unable to load transcript from file: ${options.resume}`, () => + gracefulShutdown(1), + ); + } + } + } + } + + // If not loaded as a file, try as session ID + if (maybeSessionId) { + // Resume specific session by ID + const sessionId = maybeSessionId; + try { + const resumeStart = performance.now(); + // Use matchedLog if available (for cross-worktree resume by custom title) + // Otherwise fall back to sessionId string (for direct UUID resume) + const result = await loadConversationForResume(matchedLog ?? sessionId, undefined); + + if (!result) { + logEvent('tengu_session_resumed', { + entrypoint: 'cli_flag' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + success: false, + }); + return await exitWithError(root, `No conversation found with session ID: ${sessionId}`); + } + + const fullPath = matchedLog?.fullPath ?? result.fullPath; + processedResume = await processResumedConversation( + result, + { + forkSession: !!options.forkSession, + sessionIdOverride: sessionId, + transcriptPath: fullPath, + }, + resumeContext, + ); + + if (processedResume.restoredAgentDef) { + mainThreadAgentDefinition = processedResume.restoredAgentDef; + } + logEvent('tengu_session_resumed', { + entrypoint: 'cli_flag' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + success: true, + resume_duration_ms: Math.round(performance.now() - resumeStart), + }); + } catch (error) { + logEvent('tengu_session_resumed', { + entrypoint: 'cli_flag' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + success: false, + }); + logError(error); + await exitWithError(root, `Failed to resume session ${sessionId}`); + } + } + + // Await file downloads before rendering REPL (files must be available) + if (fileDownloadPromise) { + try { + const results = await fileDownloadPromise; + const failedCount = count(results, r => !r.success); + if (failedCount > 0) { + process.stderr.write( + chalk.yellow(`Warning: ${failedCount}/${results.length} file(s) failed to download.\n`), + ); + } + } catch (error) { + return await exitWithError(root, `Error downloading files: ${errorMessage(error)}`); + } + } + + // If we have a processed resume or teleport messages, render the REPL + const resumeData = + processedResume ?? + (Array.isArray(messages) + ? { + messages, + fileHistorySnapshots: undefined, + agentName: undefined, + agentColor: undefined as AgentColorName | undefined, + restoredAgentDef: mainThreadAgentDefinition, + initialState, + contentReplacements: undefined, + } + : undefined); + if (resumeData) { + maybeActivateProactive(options); + maybeActivateBrief(options); + + await launchRepl( + root, + { + getFpsMetrics, + stats, + initialState: resumeData.initialState, + }, + { + ...sessionConfig, + mainThreadAgentDefinition: resumeData.restoredAgentDef ?? mainThreadAgentDefinition, + initialMessages: resumeData.messages, + initialFileHistorySnapshots: resumeData.fileHistorySnapshots, + initialContentReplacements: resumeData.contentReplacements, + initialAgentName: resumeData.agentName, + initialAgentColor: resumeData.agentColor, + }, + renderAndRun, + ); + } else { + // Show interactive selector (includes same-repo worktrees) + // Note: ResumeConversation loads logs internally to ensure proper GC after selection + await launchResumeChooser(root, { getFpsMetrics, stats, initialState }, getWorktreePaths(getOriginalCwd()), { + ...sessionConfig, + initialSearchQuery: searchTerm, + forkSession: options.forkSession, + filterByPr, + }); + } + } else { + // Pass unresolved hooks promise to REPL so it can render immediately + // instead of blocking ~500ms waiting for SessionStart hooks to finish. + // REPL will inject hook messages when they resolve and await them before + // the first API call so the model always sees hook context. + const pendingHookMessages = hooksPromise && hookMessages.length === 0 ? hooksPromise : undefined; + + profileCheckpoint('action_after_hooks'); + maybeActivateProactive(options); + maybeActivateBrief(options); + // Persist the current mode for fresh sessions so future resumes know what mode was used + if (feature('COORDINATOR_MODE')) { + saveMode(coordinatorModeModule?.isCoordinatorMode() ? 'coordinator' : 'normal'); + } + + // If launched via a deep link, show a provenance banner so the user + // knows the session originated externally. Linux xdg-open and + // browsers with "always allow" set dispatch the link with no OS-level + // confirmation, so this is the only signal the user gets that the + // prompt — and the working directory / CLAUDE.md it implies — came + // from an external source rather than something they typed. + let deepLinkBanner: ReturnType | null = null; + if (feature('LODESTONE')) { + if (options.deepLinkOrigin) { + logEvent('tengu_deep_link_opened', { + has_prefill: Boolean(options.prefill), + has_repo: Boolean(options.deepLinkRepo), + }); + deepLinkBanner = createSystemMessage( + buildDeepLinkBanner({ + cwd: getCwd(), + prefillLength: options.prefill?.length, + repo: options.deepLinkRepo, + lastFetch: options.deepLinkLastFetch !== undefined ? new Date(options.deepLinkLastFetch) : undefined, + }), + 'warning', + ); + } else if (options.prefill) { + deepLinkBanner = createSystemMessage( + 'Launched with a pre-filled prompt — review it before pressing Enter.', + 'warning', + ); + } + } + const initialMessages = deepLinkBanner + ? [deepLinkBanner, ...hookMessages] + : hookMessages.length > 0 + ? hookMessages + : undefined; + + await launchRepl( + root, + { getFpsMetrics, stats, initialState }, + { + ...sessionConfig, + initialMessages, + pendingHookMessages, + }, + renderAndRun, + ); + } + }) + .version(`${MACRO.VERSION} (Claude Code)`, '-v, --version', 'Output the version number'); + + // Worktree flags + program.option('-w, --worktree [name]', 'Create a new git worktree for this session (optionally specify a name)'); + program.option( + '--tmux', + 'Create a tmux session for the worktree (requires --worktree). Uses iTerm2 native panes when available; use --tmux=classic for traditional tmux.', + ); + + if (canUserConfigureAdvisor()) { + program.addOption( + new Option( + '--advisor ', + 'Enable the server-side advisor tool with the specified model (alias or full ID).', + ).hideHelp(), + ); + } + + if (process.env.USER_TYPE === 'ant') { + program.addOption( + new Option('--delegate-permissions', '[ANT-ONLY] Alias for --permission-mode auto.').implies({ + permissionMode: 'auto', + }), + ); + program.addOption( + new Option( + '--dangerously-skip-permissions-with-classifiers', + '[ANT-ONLY] Deprecated alias for --permission-mode auto.', + ) + .hideHelp() + .implies({ permissionMode: 'auto' }), + ); + program.addOption( + new Option('--afk', '[ANT-ONLY] Deprecated alias for --permission-mode auto.') + .hideHelp() + .implies({ permissionMode: 'auto' }), + ); + program.addOption( + new Option( + '--tasks [id]', + '[ANT-ONLY] Tasks mode: watch for tasks and auto-process them. Optional id is used as both the task list ID and agent ID (defaults to "tasklist").', + ) + .argParser(String) + .hideHelp(), + ); + program.option('--agent-teams', '[ANT-ONLY] Force Claude to use multi-agent mode for solving problems', () => true); + } + + if (feature('TRANSCRIPT_CLASSIFIER')) { + program.addOption(new Option('--enable-auto-mode', 'Opt in to auto mode').hideHelp()); + } + + if (feature('PROACTIVE') || feature('KAIROS')) { + program.addOption(new Option('--proactive', 'Start in proactive autonomous mode')); + } + + if (feature('UDS_INBOX')) { + program.addOption( + new Option( + '--messaging-socket-path ', + 'Unix domain socket path for the UDS messaging server (defaults to a tmp path)', + ), + ); + } + + if (feature('KAIROS') || feature('KAIROS_BRIEF')) { + program.addOption(new Option('--brief', 'Enable SendUserMessage tool for agent-to-user communication')); + } + if (feature('KAIROS')) { + program.addOption(new Option('--assistant', 'Force assistant mode (Agent SDK daemon use)').hideHelp()); + } + if (feature('KAIROS') || feature('KAIROS_CHANNELS')) { + program.addOption( + new Option( + '--channels ', + 'MCP servers whose channel notifications (inbound push) should register this session. Space-separated server names.', + ).hideHelp(), + ); + program.addOption( + new Option( + '--dangerously-load-development-channels ', + 'Load channel servers not on the approved allowlist. For local channel development only. Shows a confirmation dialog at startup.', + ).hideHelp(), + ); + } + + // Teammate identity options (set by leader when spawning tmux teammates) + // These replace the CLAUDE_CODE_* environment variables + program.addOption(new Option('--agent-id ', 'Teammate agent ID').hideHelp()); + program.addOption(new Option('--agent-name ', 'Teammate display name').hideHelp()); + program.addOption(new Option('--team-name ', 'Team name for swarm coordination').hideHelp()); + program.addOption(new Option('--agent-color ', 'Teammate UI color').hideHelp()); + program.addOption(new Option('--plan-mode-required', 'Require plan mode before implementation').hideHelp()); + program.addOption(new Option('--parent-session-id ', 'Parent session ID for analytics correlation').hideHelp()); + program.addOption( + new Option('--teammate-mode ', 'How to spawn teammates: "tmux", "in-process", or "auto"') + .choices(['auto', 'tmux', 'in-process']) + .hideHelp(), + ); + program.addOption(new Option('--agent-type ', 'Custom agent type for this teammate').hideHelp()); + + // Enable SDK URL for all builds but hide from help + program.addOption( + new Option( + '--sdk-url ', + 'Use remote WebSocket endpoint for SDK I/O streaming (only with -p and stream-json format)', + ).hideHelp(), + ); + + // Enable teleport/remote flags for all builds but keep them undocumented until GA + program.addOption( + new Option('--teleport [session]', 'Resume a teleport session, optionally specify session ID').hideHelp(), + ); + program.addOption( + new Option('--remote [description]', 'Create a remote session with the given description').hideHelp(), + ); + if (feature('BRIDGE_MODE')) { + program.addOption( + new Option( + '--remote-control [name]', + 'Start an interactive session with Remote Control enabled (optionally named)', + ) + .argParser(value => value || true) + .hideHelp(), + ); + program.addOption( + new Option('--rc [name]', 'Alias for --remote-control').argParser(value => value || true).hideHelp(), + ); + } + + if (feature('HARD_FAIL')) { + program.addOption(new Option('--hard-fail', 'Crash on logError calls instead of silently logging').hideHelp()); + } + + profileCheckpoint('run_main_options_built'); + + // -p/--print mode: skip subcommand registration. The 52 subcommands + // (mcp, auth, plugin, skill, task, config, doctor, update, etc.) are + // never dispatched in print mode — commander routes the prompt to the + // default action. The subcommand registration path was measured at ~65ms + // on baseline — mostly the isBridgeEnabled() call (25ms settings Zod parse + // + 40ms sync keychain subprocess), both hidden by the try/catch that + // always returns false before enableConfigs(). cc:// URLs are rewritten to + // `open` at main() line ~851 BEFORE this runs, so argv check is safe here. + const isPrintMode = process.argv.includes('-p') || process.argv.includes('--print'); + const isCcUrl = process.argv.some(a => a.startsWith('cc://') || a.startsWith('cc+unix://')); + if (isPrintMode && !isCcUrl) { + profileCheckpoint('run_before_parse'); + await program.parseAsync(process.argv); + profileCheckpoint('run_after_parse'); + return program; + } + + // claude mcp + + const mcp = program + .command('mcp') + .description('Configure and manage MCP servers') + .configureHelp(createSortedHelpConfig()) + .enablePositionalOptions(); + + mcp + .command('serve') + .description(`Start the Claude Code MCP server`) + .option('-d, --debug', 'Enable debug mode', () => true) + .option('--verbose', 'Override verbose mode setting from config', () => true) + .action(async ({ debug, verbose }: { debug?: boolean; verbose?: boolean }) => { + const { mcpServeHandler } = await import('./cli/handlers/mcp.js'); + await mcpServeHandler({ debug, verbose }); + }); + + // Register the mcp add subcommand (extracted for testability) + registerMcpAddCommand(mcp); + + if (isXaaEnabled()) { + registerMcpXaaIdpCommand(mcp); + } + + mcp + .command('remove ') + .description('Remove an MCP server') + .option( + '-s, --scope ', + 'Configuration scope (local, user, or project) - if not specified, removes from whichever scope it exists in', + ) + .action(async (name: string, options: { scope?: string }) => { + const { mcpRemoveHandler } = await import('./cli/handlers/mcp.js'); + await mcpRemoveHandler(name, options); + }); + + mcp + .command('list') + .description( + 'List configured MCP servers. Note: The workspace trust dialog is skipped and stdio servers from .mcp.json are spawned for health checks. Only use this command in directories you trust.', + ) + .action(async () => { + const { mcpListHandler } = await import('./cli/handlers/mcp.js'); + await mcpListHandler(); + }); + + mcp + .command('get ') + .description( + 'Get details about an MCP server. Note: The workspace trust dialog is skipped and stdio servers from .mcp.json are spawned for health checks. Only use this command in directories you trust.', + ) + .action(async (name: string) => { + const { mcpGetHandler } = await import('./cli/handlers/mcp.js'); + await mcpGetHandler(name); + }); + + mcp + .command('add-json ') + .description('Add an MCP server (stdio or SSE) with a JSON string') + .option('-s, --scope ', 'Configuration scope (local, user, or project)', 'local') + .option('--client-secret', 'Prompt for OAuth client secret (or set MCP_CLIENT_SECRET env var)') + .action(async (name: string, json: string, options: { scope?: string; clientSecret?: true }) => { + const { mcpAddJsonHandler } = await import('./cli/handlers/mcp.js'); + await mcpAddJsonHandler(name, json, options); + }); + + mcp + .command('add-from-claude-desktop') + .description('Import MCP servers from Claude Desktop (Mac and WSL only)') + .option('-s, --scope ', 'Configuration scope (local, user, or project)', 'local') + .action(async (options: { scope?: string }) => { + const { mcpAddFromDesktopHandler } = await import('./cli/handlers/mcp.js'); + await mcpAddFromDesktopHandler(options); + }); + + mcp + .command('reset-project-choices') + .description('Reset all approved and rejected project-scoped (.mcp.json) servers within this project') + .action(async () => { + const { mcpResetChoicesHandler } = await import('./cli/handlers/mcp.js'); + await mcpResetChoicesHandler(); + }); + + // claude server + if (feature('DIRECT_CONNECT')) { + program + .command('server') + .description('Start a Claude Code session server') + .option('--port ', 'HTTP port', '0') + .option('--host ', 'Bind address', '0.0.0.0') + .option('--auth-token ', 'Bearer token for auth') + .option('--unix ', 'Listen on a unix domain socket') + .option('--workspace ', 'Default working directory for sessions that do not specify cwd') + .option('--idle-timeout ', 'Idle timeout for detached sessions in ms (0 = never expire)', '600000') + .option('--max-sessions ', 'Maximum concurrent sessions (0 = unlimited)', '32') + .action( + async (opts: { + port: string; + host: string; + authToken?: string; + unix?: string; + workspace?: string; + idleTimeout: string; + maxSessions: string; + }) => { + const { randomBytes } = await import('crypto'); + const { startServer } = await import('./server/server.js'); + const { SessionManager } = await import('./server/sessionManager.js'); + const { DangerousBackend } = await import('./server/backends/dangerousBackend.js'); + const { printBanner } = await import('./server/serverBanner.js'); + const { createServerLogger } = await import('./server/serverLog.js'); + const { writeServerLock, removeServerLock, probeRunningServer } = await import('./server/lockfile.js'); + + const existing = await probeRunningServer(); + if (existing) { + process.stderr.write(`A claude server is already running (pid ${existing.pid}) at ${existing.httpUrl}\n`); + process.exit(1); + } + + const authToken = opts.authToken ?? `sk-ant-cc-${randomBytes(16).toString('base64url')}`; + + const config = { + port: parseInt(opts.port, 10), + host: opts.host, + authToken, + unix: opts.unix, + workspace: opts.workspace, + idleTimeoutMs: parseInt(opts.idleTimeout, 10), + maxSessions: parseInt(opts.maxSessions, 10), + }; + + const backend = new DangerousBackend(); + const sessionManager = new SessionManager(backend, { + idleTimeoutMs: config.idleTimeoutMs, + maxSessions: config.maxSessions, + }); + const logger = createServerLogger(); + + const server = startServer(config, sessionManager, logger); + const actualPort = server.port ?? config.port; + printBanner(config, authToken, actualPort); + + await writeServerLock({ + pid: process.pid, + port: actualPort, + host: config.host, + httpUrl: config.unix ? `unix:${config.unix}` : `http://${config.host}:${actualPort}`, + startedAt: Date.now(), + }); + + let shuttingDown = false; + const shutdown = async () => { + if (shuttingDown) return; + shuttingDown = true; + // Stop accepting new connections before tearing down sessions. + server.stop(true); + await sessionManager.destroyAll(); + await removeServerLock(); + process.exit(0); + }; + process.once('SIGINT', () => void shutdown()); + process.once('SIGTERM', () => void shutdown()); + }, + ); + } + + // `claude ssh [dir]` — registered here only so --help shows it. + // The actual interactive flow is handled by early argv rewriting in main() + // (parallels the DIRECT_CONNECT/cc:// pattern above). If commander reaches + // this action it means the argv rewrite didn't fire (e.g. user ran + // `claude ssh` with no host) — just print usage. + if (feature('SSH_REMOTE')) { + program + .command('ssh [dir]') + .description( + 'Run Claude Code on a remote host over SSH. Deploys the binary and ' + + 'tunnels API auth back through your local machine — no remote setup needed.', + ) + .option('--permission-mode ', 'Permission mode for the remote session') + .option('--dangerously-skip-permissions', 'Skip all permission prompts on the remote (dangerous)') + .option( + '--local', + 'e2e test mode — spawn the child CLI locally (skip ssh/deploy). ' + + 'Exercises the auth proxy and unix-socket plumbing without a remote host.', + ) + .action(async () => { + // Argv rewriting in main() should have consumed `ssh ` before + // commander runs. Reaching here means host was missing or the + // rewrite predicate didn't match. + process.stderr.write( + 'Usage: claude ssh [dir]\n\n' + + "Runs Claude Code on a remote Linux host. You don't need to install\n" + + 'anything on the remote or run `claude auth login` there — the binary is\n' + + 'deployed over SSH and API auth tunnels back through your local machine.\n', + ); + process.exit(1); + }); + } + + // claude connect — subcommand only handles -p (headless) mode. + // Interactive mode (without -p) is handled by early argv rewriting in main() + // which redirects to the main command with full TUI support. + if (feature('DIRECT_CONNECT')) { + program + .command('open ') + .description('Connect to a Claude Code server (internal — use cc:// URLs)') + .option('-p, --print [prompt]', 'Print mode (headless)') + .option('--output-format ', 'Output format: text, json, stream-json', 'text') + .action( + async ( + ccUrl: string, + opts: { + print?: string | true; + outputFormat?: string; + }, + ) => { + const { parseConnectUrl } = await import('./server/parseConnectUrl.js'); + const { serverUrl, authToken } = parseConnectUrl(ccUrl); + + let connectConfig; + try { + const session = await createDirectConnectSession({ + serverUrl, + authToken, + cwd: getOriginalCwd(), + dangerouslySkipPermissions: _pendingConnect?.dangerouslySkipPermissions, + }); + if (session.workDir) { + setOriginalCwd(session.workDir); + setCwdState(session.workDir); + } + setDirectConnectServerUrl(serverUrl); + connectConfig = session.config; + } catch (err) { + console.error(err instanceof DirectConnectError ? err.message : String(err)); + process.exit(1); + } + + const { runConnectHeadless } = await import('./server/connectHeadless.js'); + + const prompt = typeof opts.print === 'string' ? opts.print : ''; + const interactive = opts.print === true; + await runConnectHeadless(connectConfig, prompt, opts.outputFormat, interactive); + }, + ); + } + + // claude auth + + const auth = program.command('auth').description('Manage authentication').configureHelp(createSortedHelpConfig()); + + auth + .command('login') + .description('Sign in to your Anthropic account') + .option('--email ', 'Pre-populate email address on the login page') + .option('--sso', 'Force SSO login flow') + .option('--console', 'Use Anthropic Console (API usage billing) instead of Claude subscription') + .option('--claudeai', 'Use Claude subscription (default)') + .action( + async ({ + email, + sso, + console: useConsole, + claudeai, + }: { + email?: string; + sso?: boolean; + console?: boolean; + claudeai?: boolean; + }) => { + const { authLogin } = await import('./cli/handlers/auth.js'); + await authLogin({ email, sso, console: useConsole, claudeai }); + }, + ); + + auth + .command('status') + .description('Show authentication status') + .option('--json', 'Output as JSON (default)') + .option('--text', 'Output as human-readable text') + .action(async (opts: { json?: boolean; text?: boolean }) => { + const { authStatus } = await import('./cli/handlers/auth.js'); + await authStatus(opts); + }); + + auth + .command('logout') + .description('Log out from your Anthropic account') + .action(async () => { + const { authLogout } = await import('./cli/handlers/auth.js'); + await authLogout(); + }); + + /** + * Helper function to handle marketplace command errors consistently. + * Logs the error and exits the process with status 1. + * @param error The error that occurred + * @param action Description of the action that failed + */ + // Hidden flag on all plugin/marketplace subcommands to target cowork_plugins. + const coworkOption = () => new Option('--cowork', 'Use cowork_plugins directory').hideHelp(); + + // Plugin validate command + const pluginCmd = program + .command('plugin') + .alias('plugins') + .description('Manage Claude Code plugins') + .configureHelp(createSortedHelpConfig()); + + pluginCmd + .command('validate ') + .description('Validate a plugin or marketplace manifest') + .addOption(coworkOption()) + .action(async (manifestPath: string, options: { cowork?: boolean }) => { + const { pluginValidateHandler } = await import('./cli/handlers/plugins.js'); + await pluginValidateHandler(manifestPath, options); + }); + + // Plugin list command + pluginCmd + .command('list') + .description('List installed plugins') + .option('--json', 'Output as JSON') + .option('--available', 'Include available plugins from marketplaces (requires --json)') + .addOption(coworkOption()) + .action(async (options: { json?: boolean; available?: boolean; cowork?: boolean }) => { + const { pluginListHandler } = await import('./cli/handlers/plugins.js'); + await pluginListHandler(options); + }); + + // Marketplace subcommands + const marketplaceCmd = pluginCmd + .command('marketplace') + .description('Manage Claude Code marketplaces') + .configureHelp(createSortedHelpConfig()); + + marketplaceCmd + .command('add ') + .description('Add a marketplace from a URL, path, or GitHub repo') + .addOption(coworkOption()) + .option( + '--sparse ', + 'Limit checkout to specific directories via git sparse-checkout (for monorepos). Example: --sparse .claude-plugin plugins', + ) + .option('--scope ', 'Where to declare the marketplace: user (default), project, or local') + .action( + async ( + source: string, + options: { + cowork?: boolean; + sparse?: string[]; + scope?: string; + }, + ) => { + const { marketplaceAddHandler } = await import('./cli/handlers/plugins.js'); + await marketplaceAddHandler(source, options); + }, + ); + + marketplaceCmd + .command('list') + .description('List all configured marketplaces') + .option('--json', 'Output as JSON') + .addOption(coworkOption()) + .action(async (options: { json?: boolean; cowork?: boolean }) => { + const { marketplaceListHandler } = await import('./cli/handlers/plugins.js'); + await marketplaceListHandler(options); + }); + + marketplaceCmd + .command('remove ') + .alias('rm') + .description('Remove a configured marketplace') + .addOption(coworkOption()) + .action(async (name: string, options: { cowork?: boolean }) => { + const { marketplaceRemoveHandler } = await import('./cli/handlers/plugins.js'); + await marketplaceRemoveHandler(name, options); + }); + + marketplaceCmd + .command('update [name]') + .description('Update marketplace(s) from their source - updates all if no name specified') + .addOption(coworkOption()) + .action(async (name: string | undefined, options: { cowork?: boolean }) => { + const { marketplaceUpdateHandler } = await import('./cli/handlers/plugins.js'); + await marketplaceUpdateHandler(name, options); + }); + + // Plugin install command + pluginCmd + .command('install ') + .alias('i') + .description('Install a plugin from available marketplaces (use plugin@marketplace for specific marketplace)') + .option('-s, --scope ', 'Installation scope: user, project, or local', 'user') + .addOption(coworkOption()) + .action(async (plugin: string, options: { scope?: string; cowork?: boolean }) => { + const { pluginInstallHandler } = await import('./cli/handlers/plugins.js'); + await pluginInstallHandler(plugin, options); + }); + + // Plugin uninstall command + pluginCmd + .command('uninstall ') + .alias('remove') + .alias('rm') + .description('Uninstall an installed plugin') + .option('-s, --scope ', 'Uninstall from scope: user, project, or local', 'user') + .option('--keep-data', "Preserve the plugin's persistent data directory (~/.claude/plugins/data/{id}/)") + .addOption(coworkOption()) + .action( + async ( + plugin: string, + options: { + scope?: string; + cowork?: boolean; + keepData?: boolean; + }, + ) => { + const { pluginUninstallHandler } = await import('./cli/handlers/plugins.js'); + await pluginUninstallHandler(plugin, options); + }, + ); + + // Plugin enable command + pluginCmd + .command('enable ') + .description('Enable a disabled plugin') + .option('-s, --scope ', `Installation scope: ${VALID_INSTALLABLE_SCOPES.join(', ')} (default: auto-detect)`) + .addOption(coworkOption()) + .action(async (plugin: string, options: { scope?: string; cowork?: boolean }) => { + const { pluginEnableHandler } = await import('./cli/handlers/plugins.js'); + await pluginEnableHandler(plugin, options); + }); + + // Plugin disable command + pluginCmd + .command('disable [plugin]') + .description('Disable an enabled plugin') + .option('-a, --all', 'Disable all enabled plugins') + .option('-s, --scope ', `Installation scope: ${VALID_INSTALLABLE_SCOPES.join(', ')} (default: auto-detect)`) + .addOption(coworkOption()) + .action(async (plugin: string | undefined, options: { scope?: string; cowork?: boolean; all?: boolean }) => { + const { pluginDisableHandler } = await import('./cli/handlers/plugins.js'); + await pluginDisableHandler(plugin, options); + }); + + // Plugin update command + pluginCmd + .command('update ') + .description('Update a plugin to the latest version (restart required to apply)') + .option('-s, --scope ', `Installation scope: ${VALID_UPDATE_SCOPES.join(', ')} (default: user)`) + .addOption(coworkOption()) + .action(async (plugin: string, options: { scope?: string; cowork?: boolean }) => { + const { pluginUpdateHandler } = await import('./cli/handlers/plugins.js'); + await pluginUpdateHandler(plugin, options); + }); + // END ANT-ONLY // Setup token command program .command('setup-token') - .description( - 'Set up a long-lived authentication token (requires Claude subscription)', - ) + .description('Set up a long-lived authentication token (requires Claude subscription)') .action(async () => { const [{ setupTokenHandler }, { createRoot }] = await Promise.all([ import('./cli/handlers/util.js'), import('@anthropic/ink'), - ]) - const root = await createRoot(getBaseRenderOptions(false)) - await setupTokenHandler(root) - }) + ]); + const root = await createRoot(getBaseRenderOptions(false)); + await setupTokenHandler(root); + }); - // Agents command - list configured agents - program - .command("agents") - .description("List configured agents") - .option( - "--setting-sources ", - "Comma-separated list of setting sources to load (user, project, local).", - ) - .action(async () => { - const { agentsHandler } = await import("./cli/handlers/agents.js"); - await agentsHandler(); - process.exit(0); - }); + // Agents command - list configured agents + program + .command('agents') + .description('List configured agents') + .option('--setting-sources ', 'Comma-separated list of setting sources to load (user, project, local).') + .action(async () => { + const { agentsHandler } = await import('./cli/handlers/agents.js'); + await agentsHandler(); + process.exit(0); + }); - if (feature("TRANSCRIPT_CLASSIFIER")) { - // Skip when tengu_auto_mode_config.enabled === 'disabled' (circuit breaker). - // Reads from disk cache — GrowthBook isn't initialized at registration time. - if (getAutoModeEnabledStateIfCached() !== "disabled") { - const autoModeCmd = program - .command("auto-mode") - .description("Inspect auto mode classifier configuration"); + if (feature('TRANSCRIPT_CLASSIFIER')) { + // Skip when tengu_auto_mode_config.enabled === 'disabled' (circuit breaker). + // Reads from disk cache — GrowthBook isn't initialized at registration time. + if (getAutoModeEnabledStateIfCached() !== 'disabled') { + const autoModeCmd = program.command('auto-mode').description('Inspect auto mode classifier configuration'); - autoModeCmd - .command("defaults") - .description( - "Print the default auto mode environment, allow, and deny rules as JSON", - ) - .action(async () => { - const { autoModeDefaultsHandler } = - await import("./cli/handlers/autoMode.js"); - autoModeDefaultsHandler(); - process.exit(0); - }); + autoModeCmd + .command('defaults') + .description('Print the default auto mode environment, allow, and deny rules as JSON') + .action(async () => { + const { autoModeDefaultsHandler } = await import('./cli/handlers/autoMode.js'); + autoModeDefaultsHandler(); + process.exit(0); + }); - autoModeCmd - .command("config") - .description( - "Print the effective auto mode config as JSON: your settings where set, defaults otherwise", - ) - .action(async () => { - const { autoModeConfigHandler } = - await import("./cli/handlers/autoMode.js"); - autoModeConfigHandler(); - process.exit(0); - }); + autoModeCmd + .command('config') + .description('Print the effective auto mode config as JSON: your settings where set, defaults otherwise') + .action(async () => { + const { autoModeConfigHandler } = await import('./cli/handlers/autoMode.js'); + autoModeConfigHandler(); + process.exit(0); + }); - autoModeCmd - .command("critique") - .description("Get AI feedback on your custom auto mode rules") - .option("--model ", "Override which model is used") - .action(async (options) => { - const { autoModeCritiqueHandler } = - await import("./cli/handlers/autoMode.js"); - await autoModeCritiqueHandler(options); - process.exit(); - }); - } - } + autoModeCmd + .command('critique') + .description('Get AI feedback on your custom auto mode rules') + .option('--model ', 'Override which model is used') + .action(async options => { + const { autoModeCritiqueHandler } = await import('./cli/handlers/autoMode.js'); + await autoModeCritiqueHandler(options); + process.exit(); + }); + } + } - // Remote Control command — connect local environment to claude.ai/code. - // The actual command is intercepted by the fast-path in cli.tsx before - // Commander.js runs, so this registration exists only for help output. - // Always hidden: isBridgeEnabled() at this point (before enableConfigs) - // would throw inside isClaudeAISubscriber → getGlobalConfig and return - // false via the try/catch — but not before paying ~65ms of side effects - // (25ms settings Zod parse + 40ms sync `security` keychain subprocess). - // The dynamic visibility never worked; the command was always hidden. - if (feature("BRIDGE_MODE")) { - program - .command("remote-control", { hidden: true }) - .alias("rc") - .description( - "Connect your local environment for remote-control sessions via claude.ai/code", - ) - .action(async () => { - // Unreachable — cli.tsx fast-path handles this command before main.tsx loads. - // If somehow reached, delegate to bridgeMain. - const { bridgeMain } = await import("./bridge/bridgeMain.js"); - await bridgeMain(process.argv.slice(3)); - }); - } + // Remote Control command — connect local environment to claude.ai/code. + // The actual command is intercepted by the fast-path in cli.tsx before + // Commander.js runs, so this registration exists only for help output. + // Always hidden: isBridgeEnabled() at this point (before enableConfigs) + // would throw inside isClaudeAISubscriber → getGlobalConfig and return + // false via the try/catch — but not before paying ~65ms of side effects + // (25ms settings Zod parse + 40ms sync `security` keychain subprocess). + // The dynamic visibility never worked; the command was always hidden. + if (feature('BRIDGE_MODE')) { + program + .command('remote-control', { hidden: true }) + .alias('rc') + .description('Connect your local environment for remote-control sessions via claude.ai/code') + .action(async () => { + // Unreachable — cli.tsx fast-path handles this command before main.tsx loads. + // If somehow reached, delegate to bridgeMain. + const { bridgeMain } = await import('./bridge/bridgeMain.js'); + await bridgeMain(process.argv.slice(3)); + }); + } - if (feature("KAIROS")) { - program - .command("assistant [sessionId]") - .description( - "Attach the REPL as a client to a running bridge session. Discovers sessions via API if no sessionId given.", - ) - .action(() => { - // Argv rewriting above should have consumed `assistant [id]` - // before commander runs. Reaching here means a root flag came first - // (e.g. `--debug assistant`) and the position-0 predicate - // didn't match. Print usage like the ssh stub does. - process.stderr.write( - "Usage: claude assistant [sessionId]\n\n" + - "Attach the REPL as a viewer client to a running bridge session.\n" + - "Omit sessionId to discover and pick from available sessions.\n", - ); - process.exit(1); - }); - } + if (feature('KAIROS')) { + program + .command('assistant [sessionId]') + .description( + 'Attach the REPL as a client to a running bridge session. Discovers sessions via API if no sessionId given.', + ) + .action(() => { + // Argv rewriting above should have consumed `assistant [id]` + // before commander runs. Reaching here means a root flag came first + // (e.g. `--debug assistant`) and the position-0 predicate + // didn't match. Print usage like the ssh stub does. + process.stderr.write( + 'Usage: claude assistant [sessionId]\n\n' + + 'Attach the REPL as a viewer client to a running bridge session.\n' + + 'Omit sessionId to discover and pick from available sessions.\n', + ); + process.exit(1); + }); + } // Doctor command - check installation health program @@ -6499,483 +5210,385 @@ async function run(): Promise { const [{ doctorHandler }, { createRoot }] = await Promise.all([ import('./cli/handlers/util.js'), import('@anthropic/ink'), - ]) - const root = await createRoot(getBaseRenderOptions(false)) - await doctorHandler(root) - }) + ]); + const root = await createRoot(getBaseRenderOptions(false)); + await doctorHandler(root); + }); + // claude up — run the project's CLAUDE.md "# claude up" setup instructions. + if (process.env.USER_TYPE === 'ant') { + program + .command('up') + .description( + '[ANT-ONLY] Initialize or upgrade the local dev environment using the "# claude up" section of the nearest CLAUDE.md', + ) + .action(async () => { + const { up } = await import('src/cli/up.js'); + await up(); + }); + } - // claude up — run the project's CLAUDE.md "# claude up" setup instructions. - if (process.env.USER_TYPE === "ant") { - program - .command("up") - .description( - '[ANT-ONLY] Initialize or upgrade the local dev environment using the "# claude up" section of the nearest CLAUDE.md', - ) - .action(async () => { - const { up } = await import("src/cli/up.js"); - await up(); - }); - } + // claude rollback (ant-only) + // Rolls back to previous releases + if (process.env.USER_TYPE === 'ant') { + program + .command('rollback [target]') + .description( + '[ANT-ONLY] Roll back to a previous release\n\nExamples:\n claude rollback Go 1 version back from current\n claude rollback 3 Go 3 versions back from current\n claude rollback 2.0.73-dev.20251217.t190658 Roll back to a specific version', + ) + .option('-l, --list', 'List recent published versions with ages') + .option('--dry-run', 'Show what would be installed without installing') + .option('--safe', 'Roll back to the server-pinned safe version (set by oncall during incidents)') + .action( + async ( + target?: string, + options?: { + list?: boolean; + dryRun?: boolean; + safe?: boolean; + }, + ) => { + const { rollback } = await import('src/cli/rollback.js'); + await rollback(target, options); + }, + ); + } - // claude rollback (ant-only) - // Rolls back to previous releases - if (process.env.USER_TYPE === "ant") { - program - .command("rollback [target]") - .description( - "[ANT-ONLY] Roll back to a previous release\n\nExamples:\n claude rollback Go 1 version back from current\n claude rollback 3 Go 3 versions back from current\n claude rollback 2.0.73-dev.20251217.t190658 Roll back to a specific version", - ) - .option("-l, --list", "List recent published versions with ages") - .option( - "--dry-run", - "Show what would be installed without installing", - ) - .option( - "--safe", - "Roll back to the server-pinned safe version (set by oncall during incidents)", - ) - .action( - async ( - target?: string, - options?: { - list?: boolean; - dryRun?: boolean; - safe?: boolean; - }, - ) => { - const { rollback } = await import("src/cli/rollback.js"); - await rollback(target, options); - }, - ); - } + // claude install + program + .command('install [target]') + .description( + 'Install Claude Code native build. Use [target] to specify version (stable, latest, or specific version)', + ) + .option('--force', 'Force installation even if already installed') + .action(async (target: string | undefined, options: { force?: boolean }) => { + const { installHandler } = await import('./cli/handlers/util.js'); + await installHandler(target, options); + }); - // claude install - program - .command("install [target]") - .description( - "Install Claude Code native build. Use [target] to specify version (stable, latest, or specific version)", - ) - .option("--force", "Force installation even if already installed") - .action( - async ( - target: string | undefined, - options: { force?: boolean }, - ) => { - const { installHandler } = - await import("./cli/handlers/util.js"); - await installHandler(target, options); - }, - ); + // ant-only commands + if (process.env.USER_TYPE === 'ant') { + const validateLogId = (value: string) => { + const maybeSessionId = validateUuid(value); + if (maybeSessionId) return maybeSessionId; + return Number(value); + }; + // claude log + program + .command('log') + .description('[ANT-ONLY] Manage conversation logs.') + .argument( + '[number|sessionId]', + 'A number (0, 1, 2, etc.) to display a specific log, or the sesssion ID (uuid) of a log', + validateLogId, + ) + .action(async (logId: string | number | undefined) => { + const { logHandler } = await import('./cli/handlers/ant.js'); + await logHandler(logId); + }); - // ant-only commands - if (process.env.USER_TYPE === "ant") { - const validateLogId = (value: string) => { - const maybeSessionId = validateUuid(value); - if (maybeSessionId) return maybeSessionId; - return Number(value); - }; - // claude log - program - .command("log") - .description("[ANT-ONLY] Manage conversation logs.") - .argument( - "[number|sessionId]", - "A number (0, 1, 2, etc.) to display a specific log, or the sesssion ID (uuid) of a log", - validateLogId, - ) - .action(async (logId: string | number | undefined) => { - const { logHandler } = await import("./cli/handlers/ant.js"); - await logHandler(logId); - }); + // claude error + program + .command('error') + .description( + '[ANT-ONLY] View error logs. Optionally provide a number (0, -1, -2, etc.) to display a specific log.', + ) + .argument('[number]', 'A number (0, 1, 2, etc.) to display a specific log', parseInt) + .action(async (number: number | undefined) => { + const { errorHandler } = await import('./cli/handlers/ant.js'); + await errorHandler(number); + }); - // claude error - program - .command("error") - .description( - "[ANT-ONLY] View error logs. Optionally provide a number (0, -1, -2, etc.) to display a specific log.", - ) - .argument( - "[number]", - "A number (0, 1, 2, etc.) to display a specific log", - parseInt, - ) - .action(async (number: number | undefined) => { - const { errorHandler } = await import("./cli/handlers/ant.js"); - await errorHandler(number); - }); - - // claude export - program - .command("export") - .description("[ANT-ONLY] Export a conversation to a text file.") - .usage(" ") - .argument( - "", - "Session ID, log index (0, 1, 2...), or path to a .json/.jsonl log file", - ) - .argument("", "Output file path for the exported text") - .addHelpText( - "after", - ` + // claude export + program + .command('export') + .description('[ANT-ONLY] Export a conversation to a text file.') + .usage(' ') + .argument('', 'Session ID, log index (0, 1, 2...), or path to a .json/.jsonl log file') + .argument('', 'Output file path for the exported text') + .addHelpText( + 'after', + ` Examples: $ claude export 0 conversation.txt Export conversation at log index 0 $ claude export conversation.txt Export conversation by session ID $ claude export input.json output.txt Render JSON log file to text $ claude export .jsonl output.txt Render JSONL session file to text`, - ) - .action(async (source: string, outputFile: string) => { - const { exportHandler } = await import("./cli/handlers/ant.js"); - await exportHandler(source, outputFile); - }); + ) + .action(async (source: string, outputFile: string) => { + const { exportHandler } = await import('./cli/handlers/ant.js'); + await exportHandler(source, outputFile); + }); - if (process.env.USER_TYPE === "ant") { - const taskCmd = program - .command("task") - .description("[ANT-ONLY] Manage task list tasks"); + if (process.env.USER_TYPE === 'ant') { + const taskCmd = program.command('task').description('[ANT-ONLY] Manage task list tasks'); - taskCmd - .command("create ") - .description("Create a new task") - .option("-d, --description ", "Task description") - .option( - "-l, --list ", - 'Task list ID (defaults to "tasklist")', - ) - .action( - async ( - subject: string, - opts: { description?: string; list?: string }, - ) => { - const { taskCreateHandler } = - await import("./cli/handlers/ant.js"); - await taskCreateHandler(subject, opts); - }, - ); + taskCmd + .command('create ') + .description('Create a new task') + .option('-d, --description ', 'Task description') + .option('-l, --list ', 'Task list ID (defaults to "tasklist")') + .action(async (subject: string, opts: { description?: string; list?: string }) => { + const { taskCreateHandler } = await import('./cli/handlers/ant.js'); + await taskCreateHandler(subject, opts); + }); - taskCmd - .command("list") - .description("List all tasks") - .option( - "-l, --list ", - 'Task list ID (defaults to "tasklist")', - ) - .option("--pending", "Show only pending tasks") - .option("--json", "Output as JSON") - .action( - async (opts: { - list?: string; - pending?: boolean; - json?: boolean; - }) => { - const { taskListHandler } = - await import("./cli/handlers/ant.js"); - await taskListHandler(opts); - }, - ); + taskCmd + .command('list') + .description('List all tasks') + .option('-l, --list ', 'Task list ID (defaults to "tasklist")') + .option('--pending', 'Show only pending tasks') + .option('--json', 'Output as JSON') + .action(async (opts: { list?: string; pending?: boolean; json?: boolean }) => { + const { taskListHandler } = await import('./cli/handlers/ant.js'); + await taskListHandler(opts); + }); - taskCmd - .command("get ") - .description("Get details of a task") - .option( - "-l, --list ", - 'Task list ID (defaults to "tasklist")', - ) - .action(async (id: string, opts: { list?: string }) => { - const { taskGetHandler } = - await import("./cli/handlers/ant.js"); - await taskGetHandler(id, opts); - }); + taskCmd + .command('get ') + .description('Get details of a task') + .option('-l, --list ', 'Task list ID (defaults to "tasklist")') + .action(async (id: string, opts: { list?: string }) => { + const { taskGetHandler } = await import('./cli/handlers/ant.js'); + await taskGetHandler(id, opts); + }); - taskCmd - .command("update ") - .description("Update a task") - .option( - "-l, --list ", - 'Task list ID (defaults to "tasklist")', - ) - .option( - "-s, --status ", - `Set status (${TASK_STATUSES.join(", ")})`, - ) - .option("--subject ", "Update subject") - .option("-d, --description ", "Update description") - .option("--owner ", "Set owner") - .option("--clear-owner", "Clear owner") - .action( - async ( - id: string, - opts: { - list?: string; - status?: string; - subject?: string; - description?: string; - owner?: string; - clearOwner?: boolean; - }, - ) => { - const { taskUpdateHandler } = - await import("./cli/handlers/ant.js"); - await taskUpdateHandler(id, opts); - }, - ); + taskCmd + .command('update ') + .description('Update a task') + .option('-l, --list ', 'Task list ID (defaults to "tasklist")') + .option('-s, --status ', `Set status (${TASK_STATUSES.join(', ')})`) + .option('--subject ', 'Update subject') + .option('-d, --description ', 'Update description') + .option('--owner ', 'Set owner') + .option('--clear-owner', 'Clear owner') + .action( + async ( + id: string, + opts: { + list?: string; + status?: string; + subject?: string; + description?: string; + owner?: string; + clearOwner?: boolean; + }, + ) => { + const { taskUpdateHandler } = await import('./cli/handlers/ant.js'); + await taskUpdateHandler(id, opts); + }, + ); - taskCmd - .command("dir") - .description("Show the tasks directory path") - .option( - "-l, --list ", - 'Task list ID (defaults to "tasklist")', - ) - .action(async (opts: { list?: string }) => { - const { taskDirHandler } = - await import("./cli/handlers/ant.js"); - await taskDirHandler(opts); - }); - } + taskCmd + .command('dir') + .description('Show the tasks directory path') + .option('-l, --list ', 'Task list ID (defaults to "tasklist")') + .action(async (opts: { list?: string }) => { + const { taskDirHandler } = await import('./cli/handlers/ant.js'); + await taskDirHandler(opts); + }); + } - // claude completion - program - .command("completion ", { hidden: true }) - .description( - "Generate shell completion script (bash, zsh, or fish)", - ) - .option( - "--output ", - "Write completion script directly to a file instead of stdout", - ) - .action(async (shell: string, opts: { output?: string }) => { - const { completionHandler } = - await import("./cli/handlers/ant.js"); - await completionHandler(shell, opts, program); - }); - } + // claude completion + program + .command('completion ', { hidden: true }) + .description('Generate shell completion script (bash, zsh, or fish)') + .option('--output ', 'Write completion script directly to a file instead of stdout') + .action(async (shell: string, opts: { output?: string }) => { + const { completionHandler } = await import('./cli/handlers/ant.js'); + await completionHandler(shell, opts, program); + }); + } - profileCheckpoint("run_before_parse"); - await program.parseAsync(process.argv); - profileCheckpoint("run_after_parse"); + profileCheckpoint('run_before_parse'); + await program.parseAsync(process.argv); + profileCheckpoint('run_after_parse'); - // Record final checkpoint for total_time calculation - profileCheckpoint("main_after_run"); + // Record final checkpoint for total_time calculation + profileCheckpoint('main_after_run'); - // Log startup perf to Statsig (sampled) and output detailed report if enabled - profileReport(); + // Log startup perf to Statsig (sampled) and output detailed report if enabled + profileReport(); - return program; + return program; } async function logTenguInit({ - hasInitialPrompt, - hasStdin, - verbose, - debug, - debugToStderr, - print, - outputFormat, - inputFormat, - numAllowedTools, - numDisallowedTools, - mcpClientCount, - worktreeEnabled, - skipWebFetchPreflight, - githubActionInputs, - dangerouslySkipPermissionsPassed, - permissionMode, - modeIsBypass, - allowDangerouslySkipPermissionsPassed, - systemPromptFlag, - appendSystemPromptFlag, - thinkingConfig, - assistantActivationPath, + hasInitialPrompt, + hasStdin, + verbose, + debug, + debugToStderr, + print, + outputFormat, + inputFormat, + numAllowedTools, + numDisallowedTools, + mcpClientCount, + worktreeEnabled, + skipWebFetchPreflight, + githubActionInputs, + dangerouslySkipPermissionsPassed, + permissionMode, + modeIsBypass, + allowDangerouslySkipPermissionsPassed, + systemPromptFlag, + appendSystemPromptFlag, + thinkingConfig, + assistantActivationPath, }: { - hasInitialPrompt: boolean; - hasStdin: boolean; - verbose: boolean; - debug: boolean; - debugToStderr: boolean; - print: boolean; - outputFormat: string; - inputFormat: string; - numAllowedTools: number; - numDisallowedTools: number; - mcpClientCount: number; - worktreeEnabled: boolean; - skipWebFetchPreflight: boolean | undefined; - githubActionInputs: string | undefined; - dangerouslySkipPermissionsPassed: boolean; - permissionMode: string; - modeIsBypass: boolean; - allowDangerouslySkipPermissionsPassed: boolean; - systemPromptFlag: "file" | "flag" | undefined; - appendSystemPromptFlag: "file" | "flag" | undefined; - thinkingConfig: ThinkingConfig; - assistantActivationPath: string | undefined; + hasInitialPrompt: boolean; + hasStdin: boolean; + verbose: boolean; + debug: boolean; + debugToStderr: boolean; + print: boolean; + outputFormat: string; + inputFormat: string; + numAllowedTools: number; + numDisallowedTools: number; + mcpClientCount: number; + worktreeEnabled: boolean; + skipWebFetchPreflight: boolean | undefined; + githubActionInputs: string | undefined; + dangerouslySkipPermissionsPassed: boolean; + permissionMode: string; + modeIsBypass: boolean; + allowDangerouslySkipPermissionsPassed: boolean; + systemPromptFlag: 'file' | 'flag' | undefined; + appendSystemPromptFlag: 'file' | 'flag' | undefined; + thinkingConfig: ThinkingConfig; + assistantActivationPath: string | undefined; }): Promise { - try { - logEvent("tengu_init", { - entrypoint: - "claude" as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - hasInitialPrompt, - hasStdin, - verbose, - debug, - debugToStderr, - print, - outputFormat: - outputFormat as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - inputFormat: - inputFormat as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - numAllowedTools, - numDisallowedTools, - mcpClientCount, - worktree: worktreeEnabled, - skipWebFetchPreflight, - ...(githubActionInputs && { - githubActionInputs: - githubActionInputs as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }), - dangerouslySkipPermissionsPassed, - permissionMode: - permissionMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - modeIsBypass, - inProtectedNamespace: isInProtectedNamespace(), - allowDangerouslySkipPermissionsPassed, - thinkingType: - thinkingConfig.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - ...(systemPromptFlag && { - systemPromptFlag: - systemPromptFlag as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }), - ...(appendSystemPromptFlag && { - appendSystemPromptFlag: - appendSystemPromptFlag as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }), - is_simple: isBareMode() || undefined, - is_coordinator: - feature("COORDINATOR_MODE") && - coordinatorModeModule?.isCoordinatorMode() - ? true - : undefined, - ...(assistantActivationPath && { - assistantActivationPath: - assistantActivationPath as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }), - autoUpdatesChannel: (getInitialSettings().autoUpdatesChannel ?? - "latest") as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - ...(process.env.USER_TYPE === "ant" - ? (() => { - const cwd = getCwd(); - const gitRoot = findGitRoot(cwd); - const rp = gitRoot - ? relative(gitRoot, cwd) || "." - : undefined; - return rp - ? { - relativeProjectPath: - rp as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - } - : {}; - })() - : {}), - }); - } catch (error) { - logError(error); - } + try { + logEvent('tengu_init', { + entrypoint: 'claude' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + hasInitialPrompt, + hasStdin, + verbose, + debug, + debugToStderr, + print, + outputFormat: outputFormat as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + inputFormat: inputFormat as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + numAllowedTools, + numDisallowedTools, + mcpClientCount, + worktree: worktreeEnabled, + skipWebFetchPreflight, + ...(githubActionInputs && { + githubActionInputs: githubActionInputs as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }), + dangerouslySkipPermissionsPassed, + permissionMode: permissionMode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + modeIsBypass, + inProtectedNamespace: isInProtectedNamespace(), + allowDangerouslySkipPermissionsPassed, + thinkingType: thinkingConfig.type as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + ...(systemPromptFlag && { + systemPromptFlag: systemPromptFlag as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }), + ...(appendSystemPromptFlag && { + appendSystemPromptFlag: appendSystemPromptFlag as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }), + is_simple: isBareMode() || undefined, + is_coordinator: feature('COORDINATOR_MODE') && coordinatorModeModule?.isCoordinatorMode() ? true : undefined, + ...(assistantActivationPath && { + assistantActivationPath: assistantActivationPath as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }), + autoUpdatesChannel: (getInitialSettings().autoUpdatesChannel ?? + 'latest') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + ...(process.env.USER_TYPE === 'ant' + ? (() => { + const cwd = getCwd(); + const gitRoot = findGitRoot(cwd); + const rp = gitRoot ? relative(gitRoot, cwd) || '.' : undefined; + return rp + ? { + relativeProjectPath: rp as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + } + : {}; + })() + : {}), + }); + } catch (error) { + logError(error); + } } function maybeActivateProactive(options: unknown): void { - if ( - (feature("PROACTIVE") || feature("KAIROS")) && - ((options as { proactive?: boolean }).proactive || - isEnvTruthy(process.env.CLAUDE_CODE_PROACTIVE)) - ) { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const proactiveModule = require("./proactive/index.js"); - if (!proactiveModule.isProactiveActive()) { - proactiveModule.activateProactive("command"); - } - } + if ( + (feature('PROACTIVE') || feature('KAIROS')) && + ((options as { proactive?: boolean }).proactive || isEnvTruthy(process.env.CLAUDE_CODE_PROACTIVE)) + ) { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const proactiveModule = require('./proactive/index.js'); + if (!proactiveModule.isProactiveActive()) { + proactiveModule.activateProactive('command'); + } + } } function maybeActivateBrief(options: unknown): void { - if (!(feature("KAIROS") || feature("KAIROS_BRIEF"))) return; - const briefFlag = (options as { brief?: boolean }).brief; - const briefEnv = isEnvTruthy(process.env.CLAUDE_CODE_BRIEF); - if (!briefFlag && !briefEnv) return; - // --brief / CLAUDE_CODE_BRIEF are explicit opt-ins: check entitlement, - // then set userMsgOptIn to activate the tool + prompt section. The env - // var also grants entitlement (isBriefEntitled() reads it), so setting - // CLAUDE_CODE_BRIEF=1 alone force-enables for dev/testing — no GB gate - // needed. initialIsBriefOnly reads getUserMsgOptIn() directly. - // Conditional require: static import would leak the tool name string - // into external builds via BriefTool.ts → prompt.ts. - /* eslint-disable @typescript-eslint/no-require-imports */ - const { isBriefEntitled } = - require("@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js") as typeof import("@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js"); - /* eslint-enable @typescript-eslint/no-require-imports */ - const entitled = isBriefEntitled(); - if (entitled) { - setUserMsgOptIn(true); - } - // Fire unconditionally once intent is seen: enabled=false captures the - // "user tried but was gated" failure mode in Datadog. - logEvent("tengu_brief_mode_enabled", { - enabled: entitled, - gated: !entitled, - source: (briefEnv - ? "env" - : "flag") as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }); + if (!(feature('KAIROS') || feature('KAIROS_BRIEF'))) return; + const briefFlag = (options as { brief?: boolean }).brief; + const briefEnv = isEnvTruthy(process.env.CLAUDE_CODE_BRIEF); + if (!briefFlag && !briefEnv) return; + // --brief / CLAUDE_CODE_BRIEF are explicit opt-ins: check entitlement, + // then set userMsgOptIn to activate the tool + prompt section. The env + // var also grants entitlement (isBriefEntitled() reads it), so setting + // CLAUDE_CODE_BRIEF=1 alone force-enables for dev/testing — no GB gate + // needed. initialIsBriefOnly reads getUserMsgOptIn() directly. + // Conditional require: static import would leak the tool name string + // into external builds via BriefTool.ts → prompt.ts. + /* eslint-disable @typescript-eslint/no-require-imports */ + const { isBriefEntitled } = + require('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js') as typeof import('@claude-code-best/builtin-tools/tools/BriefTool/BriefTool.js'); + /* eslint-enable @typescript-eslint/no-require-imports */ + const entitled = isBriefEntitled(); + if (entitled) { + setUserMsgOptIn(true); + } + // Fire unconditionally once intent is seen: enabled=false captures the + // "user tried but was gated" failure mode in Datadog. + logEvent('tengu_brief_mode_enabled', { + enabled: entitled, + gated: !entitled, + source: (briefEnv ? 'env' : 'flag') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); } function resetCursor() { - const terminal = process.stderr.isTTY - ? process.stderr - : process.stdout.isTTY - ? process.stdout - : undefined; - terminal?.write(SHOW_CURSOR); + const terminal = process.stderr.isTTY ? process.stderr : process.stdout.isTTY ? process.stdout : undefined; + terminal?.write(SHOW_CURSOR); } type TeammateOptions = { - agentId?: string; - agentName?: string; - teamName?: string; - agentColor?: string; - planModeRequired?: boolean; - parentSessionId?: string; - teammateMode?: "auto" | "tmux" | "in-process"; - agentType?: string; + agentId?: string; + agentName?: string; + teamName?: string; + agentColor?: string; + planModeRequired?: boolean; + parentSessionId?: string; + teammateMode?: 'auto' | 'tmux' | 'in-process'; + agentType?: string; }; function extractTeammateOptions(options: unknown): TeammateOptions { - if (typeof options !== "object" || options === null) { - return {}; - } - const opts = options as Record; - const teammateMode = opts.teammateMode; - return { - agentId: typeof opts.agentId === "string" ? opts.agentId : undefined, - agentName: - typeof opts.agentName === "string" ? opts.agentName : undefined, - teamName: typeof opts.teamName === "string" ? opts.teamName : undefined, - agentColor: - typeof opts.agentColor === "string" ? opts.agentColor : undefined, - planModeRequired: - typeof opts.planModeRequired === "boolean" - ? opts.planModeRequired - : undefined, - parentSessionId: - typeof opts.parentSessionId === "string" - ? opts.parentSessionId - : undefined, - teammateMode: - teammateMode === "auto" || - teammateMode === "tmux" || - teammateMode === "in-process" - ? teammateMode - : undefined, - agentType: - typeof opts.agentType === "string" ? opts.agentType : undefined, - }; + if (typeof options !== 'object' || options === null) { + return {}; + } + const opts = options as Record; + const teammateMode = opts.teammateMode; + return { + agentId: typeof opts.agentId === 'string' ? opts.agentId : undefined, + agentName: typeof opts.agentName === 'string' ? opts.agentName : undefined, + teamName: typeof opts.teamName === 'string' ? opts.teamName : undefined, + agentColor: typeof opts.agentColor === 'string' ? opts.agentColor : undefined, + planModeRequired: typeof opts.planModeRequired === 'boolean' ? opts.planModeRequired : undefined, + parentSessionId: typeof opts.parentSessionId === 'string' ? opts.parentSessionId : undefined, + teammateMode: + teammateMode === 'auto' || teammateMode === 'tmux' || teammateMode === 'in-process' ? teammateMode : undefined, + agentType: typeof opts.agentType === 'string' ? opts.agentType : undefined, + }; } diff --git a/src/memdir/memoryShapeTelemetry.ts b/src/memdir/memoryShapeTelemetry.ts index 60c31ac8d..9c58d0e02 100644 --- a/src/memdir/memoryShapeTelemetry.ts +++ b/src/memdir/memoryShapeTelemetry.ts @@ -1,7 +1,15 @@ // Auto-generated stub — replace with real implementation -import type { MemoryHeader } from './memoryScan.js'; -import type { MemoryScope } from '../utils/memoryFileDetection.js'; +import type { MemoryHeader } from './memoryScan.js' +import type { MemoryScope } from '../utils/memoryFileDetection.js' -export {}; -export const logMemoryRecallShape: (memories: MemoryHeader[], selected: MemoryHeader[]) => void = (() => {}); -export const logMemoryWriteShape: (toolName: string, toolInput: Record, filePath: string, scope: MemoryScope) => void = (() => {}); +export {} +export const logMemoryRecallShape: ( + memories: MemoryHeader[], + selected: MemoryHeader[], +) => void = () => {} +export const logMemoryWriteShape: ( + toolName: string, + toolInput: Record, + filePath: string, + scope: MemoryScope, +) => void = () => {} diff --git a/src/migrations/src/services/analytics/index.ts b/src/migrations/src/services/analytics/index.ts index 60402f927..c095b5a65 100644 --- a/src/migrations/src/services/analytics/index.ts +++ b/src/migrations/src/services/analytics/index.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type logEvent = any; +export type logEvent = any diff --git a/src/moreright/useMoreRight.tsx b/src/moreright/useMoreRight.tsx index fb605d9e3..14d58e77a 100644 --- a/src/moreright/useMoreRight.tsx +++ b/src/moreright/useMoreRight.tsx @@ -5,22 +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 - onTurnComplete: (all: M[], aborted: boolean) => Promise - render: () => null + onBeforeQuery: (input: string, all: M[], n: number) => Promise; + onTurnComplete: (all: M[], aborted: boolean) => Promise; + render: () => null; } { return { onBeforeQuery: async () => true, onTurnComplete: async () => {}, render: () => null, - } + }; } diff --git a/src/native-ts/file-index/index.ts b/src/native-ts/file-index/index.ts index 7eb9f4fa1..b407ce286 100644 --- a/src/native-ts/file-index/index.ts +++ b/src/native-ts/file-index/index.ts @@ -48,6 +48,7 @@ export class FileIndex { private topLevelCache: SearchResult[] | null = null // During async build, tracks how many paths have bitmap/lowerPath filled. // search() uses this to search the ready prefix while build continues. + // biome-ignore lint/correctness/noUnusedPrivateClassMembers: used via destructuring in search() private readyCount = 0 /** @@ -205,7 +206,7 @@ export class FileIndex { const { paths, lowerPaths, charBits, pathLens, readyCount } = this - outer: for (let i = 0; i < readyCount; i++) { + for (let i = 0; i < readyCount; i++) { // O(1) bitmap reject: path must contain every letter in the needle if ((charBits[i]! & needleBitmap) !== needleBitmap) continue @@ -241,7 +242,7 @@ export class FileIndex { prevCode === 45 || // - prevCode === 95 || // _ prevCode === 46 || // . - prevCode === 32 // space + prevCode === 32 // space ) { startPositions[startCount++] = bp } @@ -260,7 +261,10 @@ export class FileIndex { let matched = true for (let j = 1; j < nLen; j++) { const pos = haystack.indexOf(needleChars[j]!, prev + 1) - if (pos === -1) { matched = false; break } + if (pos === -1) { + matched = false + break + } posBuf[j] = pos const gap = pos - prev - 1 if (gap === 0) consecBonus += BONUS_CONSECUTIVE diff --git a/src/native-ts/yoga-layout/index.ts b/src/native-ts/yoga-layout/index.ts index 49b9602be..35e57cf8c 100644 --- a/src/native-ts/yoga-layout/index.ts +++ b/src/native-ts/yoga-layout/index.ts @@ -111,6 +111,7 @@ function isDefined(n: number): boolean { // NaN-safe equality for layout-cache input comparison function sameFloat(a: number, b: number): boolean { + // biome-ignore lint/suspicious/noSelfCompare: intentional NaN detection (a !== a is true only for NaN) return a === b || (a !== a && b !== b) } @@ -2372,12 +2373,14 @@ function boundAxis( if (v > maxV.value) v = maxV.value } else if (maxU === 2) { const m = (maxV.value * owner) / 100 + // biome-ignore lint/suspicious/noSelfCompare: intentional NaN guard (m === m is false only for NaN) if (m === m && v > m) v = m } if (minU === 1) { if (v < minV.value) v = minV.value } else if (minU === 2) { const m = (minV.value * owner) / 100 + // biome-ignore lint/suspicious/noSelfCompare: intentional NaN guard (m === m is false only for NaN) if (m === m && v < m) v = m } return v diff --git a/src/query.ts b/src/query.ts index 8bfca6111..a83d68ecb 100644 --- a/src/query.ts +++ b/src/query.ts @@ -111,7 +111,11 @@ import { } from './bootstrap/state.js' import { createBudgetTracker, checkTokenBudget } from './query/tokenBudget.js' import { count } from './utils/array.js' -import { createTrace, endTrace, isLangfuseEnabled } from './services/langfuse/index.js' +import { + createTrace, + endTrace, + isLangfuseEnabled, +} from './services/langfuse/index.js' import { getAPIProvider } from './utils/model/providers.js' /* eslint-disable @typescript-eslint/no-require-imports */ @@ -129,7 +133,11 @@ function* yieldMissingToolResultBlocks( ) { for (const assistantMessage of assistantMessages) { // Extract all tool use blocks from this assistant message - const toolUseBlocks = (Array.isArray(assistantMessage.message?.content) ? assistantMessage.message.content : []).filter( + const toolUseBlocks = ( + Array.isArray(assistantMessage.message?.content) + ? assistantMessage.message.content + : [] + ).filter( (content: { type: string }) => content.type === 'tool_use', ) as ToolUseBlock[] @@ -235,8 +243,9 @@ export async function* query( // When called as a sub-agent, langfuseTrace is already set by runAgent() // — reuse it instead of creating an independent trace. const ownsTrace = !params.toolUseContext.langfuseTrace - const langfuseTrace = params.toolUseContext.langfuseTrace - ?? (isLangfuseEnabled() + const langfuseTrace = + params.toolUseContext.langfuseTrace ?? + (isLangfuseEnabled() ? createTrace({ sessionId: getSessionId(), model: params.toolUseContext.options.mainLoopModel, @@ -274,7 +283,6 @@ export async function* query( for (const uuid of consumedCommandUuids) { notifyCommandLifecycle(uuid, 'completed') } - // biome-ignore lint/style/noNonNullAssertion: terminal is always assigned when queryLoop returns normally return terminal! } @@ -328,7 +336,7 @@ async function* queryLoop( // multiple compacts: each subtracts the final context at that compact's // trigger point. Loop-local (not on State) to avoid touching the 7 continue // sites. - let taskBudgetRemaining: number | undefined = undefined + let taskBudgetRemaining: number | undefined // Snapshot immutable env/statsig/session state once at entry. See QueryConfig // for what's included and why feature() gates are intentionally excluded. @@ -788,7 +796,14 @@ async function* queryLoop( let yieldMessage: typeof message = message if (message.type === 'assistant') { const assistantMsg = message as AssistantMessage - const contentArr = Array.isArray(assistantMsg.message?.content) ? assistantMsg.message.content as unknown as Array<{ type: string; input?: unknown; name?: string; [key: string]: unknown }> : [] + const contentArr = Array.isArray(assistantMsg.message?.content) + ? (assistantMsg.message.content as unknown as Array<{ + type: string + input?: unknown + name?: string + [key: string]: unknown + }>) + : [] let clonedContent: typeof contentArr | undefined for (let i = 0; i < contentArr.length; i++) { const block = contentArr[i]! @@ -824,7 +839,10 @@ async function* queryLoop( if (clonedContent) { yieldMessage = { ...message, - message: { ...(assistantMsg.message ?? {}), content: clonedContent }, + message: { + ...(assistantMsg.message ?? {}), + content: clonedContent, + }, } as typeof message } } @@ -870,7 +888,11 @@ async function* queryLoop( const assistantMessage = message as AssistantMessage assistantMessages.push(assistantMessage) - const msgToolUseBlocks = (Array.isArray(assistantMessage.message?.content) ? assistantMessage.message.content : []).filter( + const msgToolUseBlocks = ( + Array.isArray(assistantMessage.message?.content) + ? assistantMessage.message.content + : [] + ).filter( (content: { type: string }) => content.type === 'tool_use', ) as ToolUseBlock[] if (msgToolUseBlocks.length > 0) { @@ -1003,7 +1025,10 @@ async function* queryLoop( logEvent('tengu_query_error', { assistantMessages: assistantMessages.length, toolUses: assistantMessages.flatMap(_ => - (Array.isArray(_.message?.content) ? _.message.content as Array<{ type: string }> : []).filter(content => content.type === 'tool_use'), + (Array.isArray(_.message?.content) + ? (_.message.content as Array<{ type: string }>) + : [] + ).filter(content => content.type === 'tool_use'), ).length, queryChainId: queryChainIdForAnalytics, @@ -1406,7 +1431,6 @@ async function* queryLoop( queryCheckpoint('query_tool_execution_start') - if (streamingToolExecutor) { logEvent('tengu_streaming_tool_execution_used', { tool_count: toolUseBlocks.length, @@ -1466,9 +1490,14 @@ async function* queryLoop( const lastAssistantMessage = assistantMessages.at(-1) let lastAssistantText: string | undefined if (lastAssistantMessage) { - const textBlocks = (Array.isArray(lastAssistantMessage.message?.content) ? lastAssistantMessage.message.content as Array<{ type: string; text?: string }> : []).filter( - block => block.type === 'text', - ) + const textBlocks = ( + Array.isArray(lastAssistantMessage.message?.content) + ? (lastAssistantMessage.message.content as Array<{ + type: string + text?: string + }>) + : [] + ).filter(block => block.type === 'text') if (textBlocks.length > 0) { const lastTextBlock = textBlocks.at(-1) if (lastTextBlock && 'text' in lastTextBlock) { @@ -1657,7 +1686,6 @@ async function* queryLoop( pendingMemoryPrefetch.consumedOnIteration = turnCount - 1 } - // Inject prefetched skill discovery. collectSkillDiscoveryPrefetch emits // hidden_by_main_turn — true when the prefetch resolved before this point // (should be >98% at AKI@250ms / Haiku@573ms vs turn durations of 2-30s). diff --git a/src/query/stopHooks.ts b/src/query/stopHooks.ts index 73aa62df6..a3b354c9a 100644 --- a/src/query/stopHooks.ts +++ b/src/query/stopHooks.ts @@ -156,7 +156,9 @@ export async function* handleStopHooks( // but before gracefulShutdownSync (see drainPendingExtraction). void extractMemoriesModule!.executeExtractMemories( stopHookContext, - toolUseContext.appendSystemMessage as ((msg: import('../types/message.js').SystemMessage) => void) | undefined, + toolUseContext.appendSystemMessage as + | ((msg: import('../types/message.js').SystemMessage) => void) + | undefined, ) } if (!toolUseContext.agentId && !poorMode) { @@ -231,7 +233,8 @@ export async function* handleStopHooks( ) { if (attachment.type === 'hook_non_blocking_error') { hookErrors.push( - (attachment.stderr as string) || `Exit code ${attachment.exitCode}`, + (attachment.stderr as string) || + `Exit code ${attachment.exitCode}`, ) // Non-blocking errors always have output hasOutput = true diff --git a/src/query/transitions.ts b/src/query/transitions.ts index f8fe51551..38c269eda 100644 --- a/src/query/transitions.ts +++ b/src/query/transitions.ts @@ -1,3 +1,3 @@ // Auto-generated stub — replace with real implementation -export type Terminal = any; -export type Continue = any; +export type Terminal = any +export type Continue = any diff --git a/src/remote/sdkMessageAdapter.ts b/src/remote/sdkMessageAdapter.ts index 60390adfb..bcde76233 100644 --- a/src/remote/sdkMessageAdapter.ts +++ b/src/remote/sdkMessageAdapter.ts @@ -172,7 +172,10 @@ export function convertSDKMessage( ): ConvertedMessage { switch (msg.type) { case 'assistant': - return { type: 'message', message: convertAssistantMessage(msg as SDKAssistantMessage) } + return { + type: 'message', + message: convertAssistantMessage(msg as SDKAssistantMessage), + } case 'user': { const userMsg = msg as SDKUserMessage @@ -217,13 +220,19 @@ export function convertSDKMessage( } case 'stream_event': - return { type: 'stream_event', event: convertStreamEvent(msg as SDKPartialAssistantMessage) } + return { + type: 'stream_event', + event: convertStreamEvent(msg as SDKPartialAssistantMessage), + } case 'result': // Only show result messages for errors. Success results are noise // in multi-turn sessions (isLoading=false is sufficient signal). if ((msg as SDKResultMessage).subtype !== 'success') { - return { type: 'message', message: convertResultMessage(msg as SDKResultMessage) } + return { + type: 'message', + message: convertResultMessage(msg as SDKResultMessage), + } } return { type: 'ignored' } @@ -241,7 +250,9 @@ export function convertSDKMessage( if (sysMsg.subtype === 'compact_boundary') { return { type: 'message', - message: convertCompactBoundaryMessage(msg as SDKCompactBoundaryMessage), + message: convertCompactBoundaryMessage( + msg as SDKCompactBoundaryMessage, + ), } } // hook_response and other subtypes @@ -252,7 +263,10 @@ export function convertSDKMessage( } case 'tool_progress': - return { type: 'message', message: convertToolProgressMessage(msg as SDKToolProgressMessage) } + return { + type: 'message', + message: convertToolProgressMessage(msg as SDKToolProgressMessage), + } case 'auth_status': // Auth status is handled separately, not converted to a display message diff --git a/src/replLauncher.tsx b/src/replLauncher.tsx index 91550a3bd..0d27afe12 100644 --- a/src/replLauncher.tsx +++ b/src/replLauncher.tsx @@ -1,15 +1,15 @@ -import React from 'react' -import type { StatsStore } from './context/stats.js' -import type { Root } from '@anthropic/ink' -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 '@anthropic/ink'; +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 -} + getFpsMetrics: () => FpsMetrics | undefined; + stats?: StatsStore; + initialState: AppState; +}; export async function launchRepl( root: Root, @@ -17,12 +17,12 @@ export async function launchRepl( replProps: REPLProps, renderAndRun: (root: Root, element: React.ReactNode) => Promise, ): Promise { - const { App } = await import('./components/App.js') - const { REPL } = await import('./screens/REPL.js') + const { App } = await import('./components/App.js'); + const { REPL } = await import('./screens/REPL.js'); await renderAndRun( root, , - ) + ); } diff --git a/src/schemas/hooks.ts b/src/schemas/hooks.ts index 280bcb1c3..1ccc0dc6f 100644 --- a/src/schemas/hooks.ts +++ b/src/schemas/hooks.ts @@ -103,12 +103,10 @@ function buildHookSchemas() { .positive() .optional() .describe('Timeout in seconds for this specific request'), - headers: z - .record(z.string(), z.string()) - .optional() - .describe( - 'Additional headers to include in the request. Values may reference environment variables using $VAR_NAME or ${VAR_NAME} syntax (e.g., "Authorization": "Bearer $MY_TOKEN"). Only variables listed in allowedEnvVars will be interpolated.', - ), + headers: z.record(z.string(), z.string()).optional().describe( + // biome-ignore lint/suspicious/noTemplateCurlyInString: ${VAR_NAME} is documentation for the config syntax, not a JS template literal + 'Additional headers to include in the request. Values may reference environment variables using $VAR_NAME or ${VAR_NAME} syntax (e.g., "Authorization": "Bearer $MY_TOKEN"). Only variables listed in allowedEnvVars will be interpolated.', + ), allowedEnvVars: z .array(z.string()) .optional() diff --git a/src/schemas/src/entrypoints/agentSdkTypes.ts b/src/schemas/src/entrypoints/agentSdkTypes.ts index 264ee1cdd..ef42308dc 100644 --- a/src/schemas/src/entrypoints/agentSdkTypes.ts +++ b/src/schemas/src/entrypoints/agentSdkTypes.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type HOOK_EVENTS = any; -export type HookEvent = any; +export type HOOK_EVENTS = any +export type HookEvent = any diff --git a/src/screens/Doctor.tsx b/src/screens/Doctor.tsx index 6ba73f2a4..e6a9f8760 100644 --- a/src/screens/Doctor.tsx +++ b/src/screens/Doctor.tsx @@ -1,140 +1,104 @@ -import figures from 'figures' -import { join } from 'path' -import React, { - Suspense, - use, - useCallback, - useEffect, - useMemo, - useState, -} from 'react' -import { KeybindingWarnings } from 'src/components/KeybindingWarnings.js' -import { McpParsingWarnings } from 'src/components/mcp/McpParsingWarnings.js' -import { getModelMaxOutputTokens } from 'src/utils/context.js' -import { getClaudeConfigHomeDir } from 'src/utils/envUtils.js' -import type { SettingSource } from 'src/utils/settings/constants.js' -import { getOriginalCwd } from '../bootstrap/state.js' -import type { CommandResultDisplay } from '../commands.js' -import { Pane } from '@anthropic/ink' -import { PressEnterToContinue } from '../components/PressEnterToContinue.js' -import { SandboxDoctorSection } from '../components/sandbox/SandboxDoctorSection.js' -import { ValidationErrorsList } from '../components/ValidationErrorsList.js' -import { useSettingsErrors } from '../hooks/notifs/useSettingsErrors.js' -import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js' -import { Box, Text } from '@anthropic/ink' -import { useKeybindings } from '../keybindings/useKeybinding.js' -import { useAppState } from '../state/AppState.js' -import { getPluginErrorMessage } from '../types/plugin.js' -import { - getGcsDistTags, - getNpmDistTags, - type NpmDistTags, -} from '../utils/autoUpdater.js' -import { - type ContextWarnings, - checkContextWarnings, -} from '../utils/doctorContextWarnings.js' -import { - type DiagnosticInfo, - getDoctorDiagnostic, -} from '../utils/doctorDiagnostic.js' -import { validateBoundedIntEnvVar } from '../utils/envValidation.js' -import { pathExists } from '../utils/file.js' +import figures from 'figures'; +import { join } from 'path'; +import React, { Suspense, use, useCallback, useEffect, useMemo, useState } from 'react'; +import { KeybindingWarnings } from 'src/components/KeybindingWarnings.js'; +import { McpParsingWarnings } from 'src/components/mcp/McpParsingWarnings.js'; +import { getModelMaxOutputTokens } from 'src/utils/context.js'; +import { getClaudeConfigHomeDir } from 'src/utils/envUtils.js'; +import type { SettingSource } from 'src/utils/settings/constants.js'; +import { getOriginalCwd } from '../bootstrap/state.js'; +import type { CommandResultDisplay } from '../commands.js'; +import { Pane } from '@anthropic/ink'; +import { PressEnterToContinue } from '../components/PressEnterToContinue.js'; +import { SandboxDoctorSection } from '../components/sandbox/SandboxDoctorSection.js'; +import { ValidationErrorsList } from '../components/ValidationErrorsList.js'; +import { useSettingsErrors } from '../hooks/notifs/useSettingsErrors.js'; +import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js'; +import { Box, Text } from '@anthropic/ink'; +import { useKeybindings } from '../keybindings/useKeybinding.js'; +import { useAppState } from '../state/AppState.js'; +import { getPluginErrorMessage } from '../types/plugin.js'; +import { getGcsDistTags, getNpmDistTags, type NpmDistTags } from '../utils/autoUpdater.js'; +import { type ContextWarnings, checkContextWarnings } from '../utils/doctorContextWarnings.js'; +import { type DiagnosticInfo, getDoctorDiagnostic } from '../utils/doctorDiagnostic.js'; +import { validateBoundedIntEnvVar } from '../utils/envValidation.js'; +import { pathExists } from '../utils/file.js'; import { cleanupStaleLocks, getAllLockInfo, isPidBasedLockingEnabled, type LockInfo, -} from '../utils/nativeInstaller/pidLock.js' -import { getInitialSettings } from '../utils/settings/settings.js' -import { - BASH_MAX_OUTPUT_DEFAULT, - BASH_MAX_OUTPUT_UPPER_LIMIT, -} from '../utils/shell/outputLimits.js' -import { - TASK_MAX_OUTPUT_DEFAULT, - TASK_MAX_OUTPUT_UPPER_LIMIT, -} from '../utils/task/outputFormatting.js' -import { getXDGStateHome } from '../utils/xdg.js' +} from '../utils/nativeInstaller/pidLock.js'; +import { getInitialSettings } from '../utils/settings/settings.js'; +import { BASH_MAX_OUTPUT_DEFAULT, BASH_MAX_OUTPUT_UPPER_LIMIT } from '../utils/shell/outputLimits.js'; +import { TASK_MAX_OUTPUT_DEFAULT, TASK_MAX_OUTPUT_UPPER_LIMIT } from '../utils/task/outputFormatting.js'; +import { getXDGStateHome } from '../utils/xdg.js'; type Props = { - onDone: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void -} + onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void; +}; type AgentInfo = { activeAgents: Array<{ - agentType: string - source: SettingSource | 'built-in' | 'plugin' - }> - userAgentsDir: string - projectAgentsDir: string - userDirExists: boolean - projectDirExists: boolean - failedFiles?: Array<{ path: string; error: string }> -} + agentType: string; + source: SettingSource | 'built-in' | 'plugin'; + }>; + userAgentsDir: string; + projectAgentsDir: string; + userDirExists: boolean; + projectDirExists: boolean; + failedFiles?: Array<{ path: string; error: string }>; +}; type VersionLockInfo = { - enabled: boolean - locks: LockInfo[] - locksDir: string - staleLocksCleaned: number -} + enabled: boolean; + locks: LockInfo[]; + locksDir: string; + staleLocksCleaned: number; +}; -function DistTagsDisplay({ - promise, -}: { - promise: Promise -}): React.ReactNode { - const distTags = use(promise) +function DistTagsDisplay({ promise }: { promise: Promise }): React.ReactNode { + const distTags = use(promise); if (!distTags.latest) { - return └ Failed to fetch versions + return └ Failed to fetch versions; } return ( <> {distTags.stable && └ Stable version: {distTags.stable}} └ Latest version: {distTags.latest} - ) + ); } export function Doctor({ onDone }: Props): React.ReactNode { - const agentDefinitions = useAppState(s => s.agentDefinitions) - const mcpTools = useAppState(s => s.mcp.tools) - const toolPermissionContext = useAppState(s => s.toolPermissionContext) - const pluginsErrors = useAppState(s => s.plugins.errors) - useExitOnCtrlCDWithKeybindings() + const agentDefinitions = useAppState(s => s.agentDefinitions); + const mcpTools = useAppState(s => s.mcp.tools); + const toolPermissionContext = useAppState(s => s.toolPermissionContext); + const pluginsErrors = useAppState(s => s.plugins.errors); + useExitOnCtrlCDWithKeybindings(); const tools = useMemo(() => { - return mcpTools || [] - }, [mcpTools]) + return mcpTools || []; + }, [mcpTools]); - const [diagnostic, setDiagnostic] = useState(null) - const [agentInfo, setAgentInfo] = useState(null) - const [contextWarnings, setContextWarnings] = - useState(null) - const [versionLockInfo, setVersionLockInfo] = - useState(null) - const validationErrors = useSettingsErrors() + const [diagnostic, setDiagnostic] = useState(null); + const [agentInfo, setAgentInfo] = useState(null); + const [contextWarnings, setContextWarnings] = useState(null); + const [versionLockInfo, setVersionLockInfo] = useState(null); + const validationErrors = useSettingsErrors(); // Create promise once for dist-tags fetch (depends on diagnostic) const distTagsPromise = useMemo( () => getDoctorDiagnostic().then(diag => { - const fetchDistTags = - diag.installationType === 'native' ? getGcsDistTags : getNpmDistTags - return fetchDistTags().catch(() => ({ latest: null, stable: null })) + const fetchDistTags = diag.installationType === 'native' ? getGcsDistTags : getNpmDistTags; + return fetchDistTags().catch(() => ({ latest: null, stable: null })); }), [], - ) - const autoUpdatesChannel = - getInitialSettings()?.autoUpdatesChannel ?? 'latest' + ); + const autoUpdatesChannel = getInitialSettings()?.autoUpdatesChannel ?? 'latest'; - const errorsExcludingMcp = validationErrors.filter( - error => error.mcpErrorMetadata === undefined, - ) + const errorsExcludingMcp = validationErrors.filter(error => error.mcpErrorMetadata === undefined); const envValidationErrors = useMemo(() => { const envVars = [ @@ -153,34 +117,29 @@ export function Doctor({ onDone }: Props): React.ReactNode { // Check for values against the latest supported model ...getModelMaxOutputTokens('claude-opus-4-6'), }, - ] + ]; return envVars .map(v => { - const value = process.env[v.name] - const result = validateBoundedIntEnvVar( - v.name, - value, - v.default, - v.upperLimit, - ) - return { name: v.name, ...result } + const value = process.env[v.name]; + const result = validateBoundedIntEnvVar(v.name, value, v.default, v.upperLimit); + return { name: v.name, ...result }; }) - .filter(v => v.status !== 'valid') - }, []) + .filter(v => v.status !== 'valid'); + }, []); useEffect(() => { - void getDoctorDiagnostic().then(setDiagnostic) + void getDoctorDiagnostic().then(setDiagnostic); void (async () => { - const userAgentsDir = join(getClaudeConfigHomeDir(), 'agents') - const projectAgentsDir = join(getOriginalCwd(), '.claude', 'agents') + const userAgentsDir = join(getClaudeConfigHomeDir(), 'agents'); + const projectAgentsDir = join(getOriginalCwd(), '.claude', 'agents'); - const { activeAgents, allAgents, failedFiles } = agentDefinitions + const { activeAgents, allAgents, failedFiles } = agentDefinitions; const [userDirExists, projectDirExists] = await Promise.all([ pathExists(userAgentsDir), pathExists(projectAgentsDir), - ]) + ]); const agentInfoData = { activeAgents: activeAgents.map(a => ({ @@ -192,8 +151,8 @@ export function Doctor({ onDone }: Props): React.ReactNode { userDirExists, projectDirExists, failedFiles, - } - setAgentInfo(agentInfoData) + }; + setAgentInfo(agentInfoData); const warnings = await checkContextWarnings( tools, @@ -203,34 +162,34 @@ export function Doctor({ onDone }: Props): React.ReactNode { failedFiles, }, async () => toolPermissionContext, - ) - setContextWarnings(warnings) + ); + setContextWarnings(warnings); // Fetch version lock info if PID-based locking is enabled if (isPidBasedLockingEnabled()) { - const locksDir = join(getXDGStateHome(), 'claude', 'locks') - const staleLocksCleaned = cleanupStaleLocks(locksDir) - const locks = getAllLockInfo(locksDir) + const locksDir = join(getXDGStateHome(), 'claude', 'locks'); + const staleLocksCleaned = cleanupStaleLocks(locksDir); + const locks = getAllLockInfo(locksDir); setVersionLockInfo({ enabled: true, locks, locksDir, staleLocksCleaned, - }) + }); } else { setVersionLockInfo({ enabled: false, locks: [], locksDir: '', staleLocksCleaned: 0, - }) + }); } - })() - }, [toolPermissionContext, tools, agentDefinitions]) + })(); + }, [toolPermissionContext, tools, agentDefinitions]); const handleDismiss = useCallback(() => { - onDone('Claude Code diagnostics dismissed', { display: 'system' }) - }, [onDone]) + onDone('Claude Code diagnostics dismissed', { display: 'system' }); + }, [onDone]); // Handle dismiss via keybindings (Enter, Escape, or Ctrl+C) useKeybindings( @@ -239,7 +198,7 @@ export function Doctor({ onDone }: Props): React.ReactNode { 'confirm:no': handleDismiss, }, { context: 'Confirmation' }, - ) + ); // Loading state if (!diagnostic) { @@ -247,7 +206,7 @@ export function Doctor({ onDone }: Props): React.ReactNode { Checking installation status… - ) + ); } // Format the diagnostic output according to spec @@ -256,12 +215,9 @@ export function Doctor({ onDone }: Props): React.ReactNode { Diagnostics - └ Currently running: {diagnostic.installationType} ( - {diagnostic.version}) + └ Currently running: {diagnostic.installationType} ({diagnostic.version}) - {diagnostic.packageManager && ( - └ Package manager: {diagnostic.packageManager} - )} + {diagnostic.packageManager && └ Package manager: {diagnostic.packageManager}} └ Path: {diagnostic.installationPath} └ Invoked: {diagnostic.invokedBinary} └ Config install method: {diagnostic.configInstallMethod} @@ -279,9 +235,7 @@ export function Doctor({ onDone }: Props): React.ReactNode { {diagnostic.recommendation && ( <> - - Recommendation: {diagnostic.recommendation.split('\n')[0]} - + Recommendation: {diagnostic.recommendation.split('\n')[0]} {diagnostic.recommendation.split('\n')[1]} )} @@ -324,17 +278,9 @@ export function Doctor({ onDone }: Props): React.ReactNode { {/* Updates section */} Updates - - └ Auto-updates:{' '} - {diagnostic.packageManager - ? 'Managed by package manager' - : diagnostic.autoUpdates} - + └ Auto-updates: {diagnostic.packageManager ? 'Managed by package manager' : diagnostic.autoUpdates} {diagnostic.hasUpdatePermissions !== null && ( - - └ Update permissions:{' '} - {diagnostic.hasUpdatePermissions ? 'Yes' : 'No (requires sudo)'} - + └ Update permissions: {diagnostic.hasUpdatePermissions ? 'Yes' : 'No (requires sudo)'} )} └ Auto-update channel: {autoUpdatesChannel} @@ -355,11 +301,7 @@ export function Doctor({ onDone }: Props): React.ReactNode { {envValidationErrors.map((validation, i) => ( └ {validation.name}:{' '} - - {validation.message} - + {validation.message} ))} @@ -370,9 +312,7 @@ export function Doctor({ onDone }: Props): React.ReactNode { Version Locks {versionLockInfo.staleLocksCleaned > 0 && ( - - └ Cleaned {versionLockInfo.staleLocksCleaned} stale lock(s) - + └ Cleaned {versionLockInfo.staleLocksCleaned} stale lock(s) )} {versionLockInfo.locks.length === 0 ? ( └ No active version locks @@ -380,11 +320,7 @@ export function Doctor({ onDone }: Props): React.ReactNode { versionLockInfo.locks.map((lock, i) => ( └ {lock.version}: PID {lock.pid}{' '} - {lock.isProcessRunning ? ( - (running) - ) : ( - (stale) - )} + {lock.isProcessRunning ? (running) : (stale)} )) )} @@ -396,9 +332,7 @@ export function Doctor({ onDone }: Props): React.ReactNode { Agent Parse Errors - - └ Failed to parse {agentInfo.failedFiles.length} agent file(s): - + └ Failed to parse {agentInfo.failedFiles.length} agent file(s): {agentInfo.failedFiles.map((file, i) => ( {' '}└ {file.path}: {file.error} @@ -413,14 +347,11 @@ export function Doctor({ onDone }: Props): React.ReactNode { Plugin Errors - - └ {pluginsErrors.length} plugin error(s) detected: - + └ {pluginsErrors.length} plugin error(s) detected: {pluginsErrors.map((error, i) => ( {' '}└ {error.source || 'unknown'} - {'plugin' in error && error.plugin ? ` [${error.plugin}]` : ''}:{' '} - {getPluginErrorMessage(error)} + {'plugin' in error && error.plugin ? ` [${error.plugin}]` : ''}: {getPluginErrorMessage(error)} ))} @@ -435,8 +366,7 @@ export function Doctor({ onDone }: Props): React.ReactNode { └{' '} - {figures.warning}{' '} - {contextWarnings.unreachableRulesWarning.message} + {figures.warning} {contextWarnings.unreachableRulesWarning.message} {contextWarnings.unreachableRulesWarning.details.map((detail, i) => ( @@ -449,9 +379,7 @@ export function Doctor({ onDone }: Props): React.ReactNode { {/* Context Usage Warnings */} {contextWarnings && - (contextWarnings.claudeMdWarning || - contextWarnings.agentWarning || - contextWarnings.mcpWarning) && ( + (contextWarnings.claudeMdWarning || contextWarnings.agentWarning || contextWarnings.mcpWarning) && ( Context Usage Warnings @@ -512,5 +440,5 @@ export function Doctor({ onDone }: Props): React.ReactNode { - ) + ); } diff --git a/src/screens/ResumeConversation.tsx b/src/screens/ResumeConversation.tsx index f1a5ea83e..fe07e46a1 100644 --- a/src/screens/ResumeConversation.tsx +++ b/src/screens/ResumeConversation.tsx @@ -1,43 +1,40 @@ -import { feature } from 'bun:bundle' -import { dirname } from 'path' -import React from 'react' -import { useTerminalSize } from 'src/hooks/useTerminalSize.js' -import { getOriginalCwd, switchSession } from '../bootstrap/state.js' -import type { Command } from '../commands.js' -import { LogSelector } from '../components/LogSelector.js' -import { Spinner } from '../components/Spinner.js' -import { restoreCostStateForSession } from '../cost-tracker.js' -import { setClipboard } from '@anthropic/ink' -import { Box, Text } from '@anthropic/ink' -import { useKeybinding } from '../keybindings/useKeybinding.js' +import { feature } from 'bun:bundle'; +import { dirname } from 'path'; +import React from 'react'; +import { useTerminalSize } from 'src/hooks/useTerminalSize.js'; +import { getOriginalCwd, switchSession } from '../bootstrap/state.js'; +import type { Command } from '../commands.js'; +import { LogSelector } from '../components/LogSelector.js'; +import { Spinner } from '../components/Spinner.js'; +import { restoreCostStateForSession } from '../cost-tracker.js'; +import { setClipboard } from '@anthropic/ink'; +import { Box, Text } from '@anthropic/ink'; +import { useKeybinding } from '../keybindings/useKeybinding.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from '../services/analytics/index.js' -import type { - MCPServerConnection, - ScopedMcpServerConfig, -} from '../services/mcp/types.js' -import { useAppState, useSetAppState } from '../state/AppState.js' -import type { Tool } from '../Tool.js' -import type { AgentColorName } from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js' -import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js' -import { asSessionId } from '../types/ids.js' -import type { LogOption } from '../types/logs.js' -import type { Message } from '../types/message.js' -import { agenticSessionSearch } from '../utils/agenticSessionSearch.js' -import { renameRecordingForSession } from '../utils/asciicast.js' -import { updateSessionName } from '../utils/concurrentSessions.js' -import { loadConversationForResume } from '../utils/conversationRecovery.js' -import { checkCrossProjectResume } from '../utils/crossProjectResume.js' -import type { FileHistorySnapshot } from '../utils/fileHistory.js' -import { logError } from '../utils/log.js' -import { createSystemMessage } from '../utils/messages.js' +} from '../services/analytics/index.js'; +import type { MCPServerConnection, ScopedMcpServerConfig } from '../services/mcp/types.js'; +import { useAppState, useSetAppState } from '../state/AppState.js'; +import type { Tool } from '../Tool.js'; +import type { AgentColorName } from '@claude-code-best/builtin-tools/tools/AgentTool/agentColorManager.js'; +import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'; +import { asSessionId } from '../types/ids.js'; +import type { LogOption } from '../types/logs.js'; +import type { Message } from '../types/message.js'; +import { agenticSessionSearch } from '../utils/agenticSessionSearch.js'; +import { renameRecordingForSession } from '../utils/asciicast.js'; +import { updateSessionName } from '../utils/concurrentSessions.js'; +import { loadConversationForResume } from '../utils/conversationRecovery.js'; +import { checkCrossProjectResume } from '../utils/crossProjectResume.js'; +import type { FileHistorySnapshot } from '../utils/fileHistory.js'; +import { logError } from '../utils/log.js'; +import { createSystemMessage } from '../utils/messages.js'; import { computeStandaloneAgentContext, restoreAgentFromSession, restoreWorktreeForResume, -} from '../utils/sessionRestore.js' +} from '../utils/sessionRestore.js'; import { adoptResumedSessionFile, enrichLogs, @@ -48,43 +45,43 @@ import { resetSessionFilePointer, restoreSessionMetadata, type SessionLogResult, -} from '../utils/sessionStorage.js' -import type { ThinkingConfig } from '../utils/thinking.js' -import type { ContentReplacementRecord } from '../utils/toolResultStorage.js' -import { REPL } from './REPL.js' +} from '../utils/sessionStorage.js'; +import type { ThinkingConfig } from '../utils/thinking.js'; +import type { ContentReplacementRecord } from '../utils/toolResultStorage.js'; +import { REPL } from './REPL.js'; function parsePrIdentifier(value: string): number | null { - const directNumber = parseInt(value, 10) + const directNumber = parseInt(value, 10); if (!isNaN(directNumber) && directNumber > 0) { - return directNumber + return directNumber; } - const urlMatch = value.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/) + const urlMatch = value.match(/github\.com\/[^/]+\/[^/]+\/pull\/(\d+)/); if (urlMatch?.[1]) { - return parseInt(urlMatch[1], 10) + return parseInt(urlMatch[1], 10); } - return null + return null; } type Props = { - commands: Command[] - worktreePaths: string[] - initialTools: Tool[] - mcpClients?: MCPServerConnection[] - dynamicMcpConfig?: Record - debug: boolean - mainThreadAgentDefinition?: AgentDefinition - autoConnectIdeFlag?: boolean - strictMcpConfig?: boolean - systemPrompt?: string - appendSystemPrompt?: string - initialSearchQuery?: string - disableSlashCommands?: boolean - forkSession?: boolean - taskListId?: string - filterByPr?: boolean | number | string - thinkingConfig: ThinkingConfig - onTurnComplete?: (messages: Message[]) => void | Promise -} + commands: Command[]; + worktreePaths: string[]; + initialTools: Tool[]; + mcpClients?: MCPServerConnection[]; + dynamicMcpConfig?: Record; + debug: boolean; + mainThreadAgentDefinition?: AgentDefinition; + autoConnectIdeFlag?: boolean; + strictMcpConfig?: boolean; + systemPrompt?: string; + appendSystemPrompt?: string; + initialSearchQuery?: string; + disableSlashCommands?: boolean; + forkSession?: boolean; + taskListId?: string; + filterByPr?: boolean | number | string; + thinkingConfig: ThinkingConfig; + onTurnComplete?: (messages: Message[]) => void | Promise; +}; export function ResumeConversation({ commands, @@ -106,155 +103,147 @@ export function ResumeConversation({ thinkingConfig, onTurnComplete, }: Props): React.ReactNode { - const { rows } = useTerminalSize() - const agentDefinitions = useAppState(s => s.agentDefinitions) - const setAppState = useSetAppState() - const [logs, setLogs] = React.useState([]) - const [loading, setLoading] = React.useState(true) - const [resuming, setResuming] = React.useState(false) - const [showAllProjects, setShowAllProjects] = React.useState(false) + const { rows } = useTerminalSize(); + const agentDefinitions = useAppState(s => s.agentDefinitions); + const setAppState = useSetAppState(); + const [logs, setLogs] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [resuming, setResuming] = React.useState(false); + const [showAllProjects, setShowAllProjects] = React.useState(false); const [resumeData, setResumeData] = React.useState<{ - messages: Message[] - fileHistorySnapshots?: FileHistorySnapshot[] - contentReplacements?: ContentReplacementRecord[] - agentName?: string - agentColor?: AgentColorName - mainThreadAgentDefinition?: AgentDefinition - } | null>(null) - const [crossProjectCommand, setCrossProjectCommand] = React.useState< - string | null - >(null) - const sessionLogResultRef = React.useRef(null) + messages: Message[]; + fileHistorySnapshots?: FileHistorySnapshot[]; + contentReplacements?: ContentReplacementRecord[]; + agentName?: string; + agentColor?: AgentColorName; + mainThreadAgentDefinition?: AgentDefinition; + } | null>(null); + const [crossProjectCommand, setCrossProjectCommand] = React.useState(null); + const sessionLogResultRef = React.useRef(null); // Mirror of logs.length so loadMoreLogs can compute value indices outside // the setLogs updater (keeping it pure per React's contract). - const logCountRef = React.useRef(0) + const logCountRef = React.useRef(0); const filteredLogs = React.useMemo(() => { - let result = logs.filter(l => !l.isSidechain) + let result = logs.filter(l => !l.isSidechain); if (filterByPr !== undefined) { if (filterByPr === true) { - result = result.filter(l => l.prNumber !== undefined) + result = result.filter(l => l.prNumber !== undefined); } else if (typeof filterByPr === 'number') { - result = result.filter(l => l.prNumber === filterByPr) + result = result.filter(l => l.prNumber === filterByPr); } else if (typeof filterByPr === 'string') { - const prNumber = parsePrIdentifier(filterByPr) + const prNumber = parsePrIdentifier(filterByPr); if (prNumber !== null) { - result = result.filter(l => l.prNumber === prNumber) + result = result.filter(l => l.prNumber === prNumber); } } } - return result - }, [logs, filterByPr]) - const isResumeWithRenameEnabled = isCustomTitleEnabled() + return result; + }, [logs, filterByPr]); + const isResumeWithRenameEnabled = isCustomTitleEnabled(); React.useEffect(() => { loadSameRepoMessageLogsProgressive(worktreePaths) .then(result => { - sessionLogResultRef.current = result - logCountRef.current = result.logs.length - setLogs(result.logs) - setLoading(false) + sessionLogResultRef.current = result; + logCountRef.current = result.logs.length; + setLogs(result.logs); + setLoading(false); }) .catch(error => { - logError(error) - setLoading(false) - }) - }, [worktreePaths]) + logError(error); + setLoading(false); + }); + }, [worktreePaths]); const loadMoreLogs = React.useCallback((count: number) => { - const ref = sessionLogResultRef.current - if (!ref || ref.nextIndex >= ref.allStatLogs.length) return + const ref = sessionLogResultRef.current; + if (!ref || ref.nextIndex >= ref.allStatLogs.length) return; void enrichLogs(ref.allStatLogs, ref.nextIndex, count).then(result => { - ref.nextIndex = result.nextIndex + ref.nextIndex = result.nextIndex; if (result.logs.length > 0) { // enrichLogs returns fresh unshared objects — safe to mutate in place. // Offset comes from logCountRef so the setLogs updater stays pure. - const offset = logCountRef.current + const offset = logCountRef.current; result.logs.forEach((log, i) => { - log.value = offset + i - }) - setLogs(prev => prev.concat(result.logs)) - logCountRef.current += result.logs.length + log.value = offset + i; + }); + setLogs(prev => prev.concat(result.logs)); + logCountRef.current += result.logs.length; } else if (ref.nextIndex < ref.allStatLogs.length) { - loadMoreLogs(count) + loadMoreLogs(count); } - }) - }, []) + }); + }, []); const loadLogs = React.useCallback( (allProjects: boolean) => { - setLoading(true) + setLoading(true); const promise = allProjects ? loadAllProjectsMessageLogsProgressive() - : loadSameRepoMessageLogsProgressive(worktreePaths) + : loadSameRepoMessageLogsProgressive(worktreePaths); promise .then(result => { - sessionLogResultRef.current = result - logCountRef.current = result.logs.length - setLogs(result.logs) + sessionLogResultRef.current = result; + logCountRef.current = result.logs.length; + setLogs(result.logs); }) .catch(error => { - logError(error) + logError(error); }) .finally(() => { - setLoading(false) - }) + setLoading(false); + }); }, [worktreePaths], - ) + ); const handleToggleAllProjects = React.useCallback(() => { - const newValue = !showAllProjects - setShowAllProjects(newValue) - loadLogs(newValue) - }, [showAllProjects, loadLogs]) + const newValue = !showAllProjects; + setShowAllProjects(newValue); + loadLogs(newValue); + }, [showAllProjects, loadLogs]); function onCancel() { // eslint-disable-next-line custom-rules/no-process-exit - process.exit(1) + process.exit(1); } async function onSelect(log: LogOption) { - setResuming(true) - const resumeStart = performance.now() + setResuming(true); + const resumeStart = performance.now(); - const crossProjectCheck = checkCrossProjectResume( - log, - showAllProjects, - worktreePaths, - ) + const crossProjectCheck = checkCrossProjectResume(log, showAllProjects, worktreePaths); if (crossProjectCheck.isCrossProject) { if (!crossProjectCheck.isSameRepoWorktree) { - const cmd = (crossProjectCheck as { command: string }).command - const raw = await setClipboard(cmd) - if (raw) process.stdout.write(raw) - setCrossProjectCommand(cmd) - return + const cmd = (crossProjectCheck as { command: string }).command; + const raw = await setClipboard(cmd); + if (raw) process.stdout.write(raw); + setCrossProjectCommand(cmd); + return; } } try { - const result = await loadConversationForResume(log, undefined) + const result = await loadConversationForResume(log, undefined); if (!result) { - throw new Error('Failed to load conversation') + throw new Error('Failed to load conversation'); } if (feature('COORDINATOR_MODE')) { /* eslint-disable @typescript-eslint/no-require-imports */ const coordinatorModule = - require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js') + require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js'); /* eslint-enable @typescript-eslint/no-require-imports */ - const warning = coordinatorModule.matchSessionMode(result.mode) + const warning = coordinatorModule.matchSessionMode(result.mode); if (warning) { /* eslint-disable @typescript-eslint/no-require-imports */ const { getAgentDefinitionsWithOverrides, getActiveAgentsFromList } = - require('@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js') as typeof import('@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js') + require('@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js') as typeof import('@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'); /* eslint-enable @typescript-eslint/no-require-imports */ - getAgentDefinitionsWithOverrides.cache.clear?.() - const freshAgentDefs = await getAgentDefinitionsWithOverrides( - getOriginalCwd(), - ) + getAgentDefinitionsWithOverrides.cache.clear?.(); + const freshAgentDefs = await getAgentDefinitionsWithOverrides(getOriginalCwd()); setAppState(prev => ({ ...prev, agentDefinitions: { @@ -262,101 +251,86 @@ export function ResumeConversation({ allAgents: freshAgentDefs.allAgents, activeAgents: getActiveAgentsFromList(freshAgentDefs.allAgents), }, - })) - result.messages.push(createSystemMessage(warning, 'warning')) + })); + result.messages.push(createSystemMessage(warning, 'warning')); } } if (result.sessionId && !forkSession) { - switchSession( - asSessionId(result.sessionId), - log.fullPath ? dirname(log.fullPath) : null, - ) - await renameRecordingForSession() - await resetSessionFilePointer() - restoreCostStateForSession(result.sessionId) + switchSession(asSessionId(result.sessionId), log.fullPath ? dirname(log.fullPath) : null); + await renameRecordingForSession(); + await resetSessionFilePointer(); + restoreCostStateForSession(result.sessionId); } else if (forkSession && result.contentReplacements?.length) { - await recordContentReplacement(result.contentReplacements) + await recordContentReplacement(result.contentReplacements); } const { agentDefinition: resolvedAgentDef } = restoreAgentFromSession( result.agentSetting, mainThreadAgentDefinition, agentDefinitions, - ) - setAppState(prev => ({ ...prev, agent: resolvedAgentDef?.agentType })) + ); + setAppState(prev => ({ ...prev, agent: resolvedAgentDef?.agentType })); if (feature('COORDINATOR_MODE')) { /* eslint-disable @typescript-eslint/no-require-imports */ - const { saveMode } = require('../utils/sessionStorage.js') + const { saveMode } = require('../utils/sessionStorage.js'); const { isCoordinatorMode } = - require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js') + require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js'); /* eslint-enable @typescript-eslint/no-require-imports */ - saveMode(isCoordinatorMode() ? 'coordinator' : 'normal') + saveMode(isCoordinatorMode() ? 'coordinator' : 'normal'); } - const standaloneAgentContext = computeStandaloneAgentContext( - result.agentName, - result.agentColor, - ) + const standaloneAgentContext = computeStandaloneAgentContext(result.agentName, result.agentColor); if (standaloneAgentContext) { - setAppState(prev => ({ ...prev, standaloneAgentContext })) + setAppState(prev => ({ ...prev, standaloneAgentContext })); } - void updateSessionName(result.agentName) + void updateSessionName(result.agentName); - restoreSessionMetadata( - forkSession ? { ...result, worktreeSession: undefined } : result, - ) + restoreSessionMetadata(forkSession ? { ...result, worktreeSession: undefined } : result); if (!forkSession) { - restoreWorktreeForResume(result.worktreeSession) + restoreWorktreeForResume(result.worktreeSession); if (result.sessionId) { - adoptResumedSessionFile() + adoptResumedSessionFile(); } } if (feature('CONTEXT_COLLAPSE')) { /* eslint-disable @typescript-eslint/no-require-imports */ - ;( + ( require('../services/contextCollapse/persist.js') as typeof import('../services/contextCollapse/persist.js') - ).restoreFromEntries( - result.contextCollapseCommits ?? [], - result.contextCollapseSnapshot, - ) + ).restoreFromEntries(result.contextCollapseCommits ?? [], result.contextCollapseSnapshot); /* eslint-enable @typescript-eslint/no-require-imports */ } logEvent('tengu_session_resumed', { - entrypoint: - 'picker' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + entrypoint: 'picker' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, success: true, resume_duration_ms: Math.round(performance.now() - resumeStart), - }) + }); - setLogs([]) + setLogs([]); setResumeData({ messages: result.messages, fileHistorySnapshots: result.fileHistorySnapshots, contentReplacements: result.contentReplacements, agentName: result.agentName, - agentColor: (result.agentColor === 'default' - ? undefined - : result.agentColor) as AgentColorName | undefined, + agentColor: (result.agentColor === 'default' ? undefined : result.agentColor) as AgentColorName | undefined, mainThreadAgentDefinition: resolvedAgentDef, - }) + }); } catch (e) { logEvent('tengu_session_resumed', { - entrypoint: - 'picker' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + entrypoint: 'picker' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, success: false, - }) - logError(e as Error) - throw e + }); + logError(e as Error); + throw e; } } if (crossProjectCommand) { - return + return ; } if (resumeData) { @@ -382,7 +356,7 @@ export function ResumeConversation({ thinkingConfig={thinkingConfig} onTurnComplete={onTurnComplete} /> - ) + ); } if (loading) { @@ -391,7 +365,7 @@ export function ResumeConversation({ Loading conversations… - ) + ); } if (resuming) { @@ -400,11 +374,11 @@ export function ResumeConversation({ Resuming conversation… - ) + ); } if (filteredLogs.length === 0) { - return + return ; } return ( @@ -413,16 +387,14 @@ export function ResumeConversation({ maxHeight={rows} onCancel={onCancel} onSelect={onSelect} - onLogsChanged={ - isResumeWithRenameEnabled ? () => loadLogs(showAllProjects) : undefined - } + onLogsChanged={isResumeWithRenameEnabled ? () => loadLogs(showAllProjects) : undefined} onLoadMore={loadMoreLogs} initialSearchQuery={initialSearchQuery} showAllProjects={showAllProjects} onToggleAllProjects={handleToggleAllProjects} onAgenticSearch={agenticSessionSearch} /> - ) + ); } function NoConversationsMessage(): React.ReactNode { @@ -430,31 +402,27 @@ function NoConversationsMessage(): React.ReactNode { 'app:interrupt', () => { // eslint-disable-next-line custom-rules/no-process-exit - process.exit(1) + process.exit(1); }, { context: 'Global' }, - ) + ); return ( No conversations found to resume. Press Ctrl+C to exit and start a new conversation. - ) + ); } -function CrossProjectMessage({ - command, -}: { - command: string -}): React.ReactNode { +function CrossProjectMessage({ command }: { command: string }): React.ReactNode { React.useEffect(() => { const timeout = setTimeout(() => { // eslint-disable-next-line custom-rules/no-process-exit - process.exit(0) - }, 100) - return () => clearTimeout(timeout) - }, []) + process.exit(0); + }, 100); + return () => clearTimeout(timeout); + }, []); return ( @@ -465,5 +433,5 @@ function CrossProjectMessage({ (Command copied to clipboard) - ) + ); } diff --git a/src/screens/src/cli/structuredIO.ts b/src/screens/src/cli/structuredIO.ts index 8e536b8a2..60bbe3e13 100644 --- a/src/screens/src/cli/structuredIO.ts +++ b/src/screens/src/cli/structuredIO.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type SANDBOX_NETWORK_ACCESS_TOOL_NAME = any; +export type SANDBOX_NETWORK_ACCESS_TOOL_NAME = any diff --git a/src/screens/src/components/AutoModeOptInDialog.ts b/src/screens/src/components/AutoModeOptInDialog.ts index f441f7e57..7a27e8afd 100644 --- a/src/screens/src/components/AutoModeOptInDialog.ts +++ b/src/screens/src/components/AutoModeOptInDialog.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type AUTO_MODE_DESCRIPTION = any; +export type AUTO_MODE_DESCRIPTION = any diff --git a/src/screens/src/components/ClaudeCodeHint/PluginHintMenu.ts b/src/screens/src/components/ClaudeCodeHint/PluginHintMenu.ts index 2369289c7..108a6b750 100644 --- a/src/screens/src/components/ClaudeCodeHint/PluginHintMenu.ts +++ b/src/screens/src/components/ClaudeCodeHint/PluginHintMenu.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type PluginHintMenu = any; +export type PluginHintMenu = any diff --git a/src/screens/src/components/DesktopUpsell/DesktopUpsellStartup.ts b/src/screens/src/components/DesktopUpsell/DesktopUpsellStartup.ts index fcb5dc73d..75df655b9 100644 --- a/src/screens/src/components/DesktopUpsell/DesktopUpsellStartup.ts +++ b/src/screens/src/components/DesktopUpsell/DesktopUpsellStartup.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type DesktopUpsellStartup = any; -export type shouldShowDesktopUpsellStartup = any; +export type DesktopUpsellStartup = any +export type shouldShowDesktopUpsellStartup = any diff --git a/src/screens/src/components/FeedbackSurvey/FeedbackSurvey.ts b/src/screens/src/components/FeedbackSurvey/FeedbackSurvey.ts index 2466dd075..d32558baf 100644 --- a/src/screens/src/components/FeedbackSurvey/FeedbackSurvey.ts +++ b/src/screens/src/components/FeedbackSurvey/FeedbackSurvey.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type FeedbackSurvey = any; +export type FeedbackSurvey = any diff --git a/src/screens/src/components/FeedbackSurvey/useFeedbackSurvey.ts b/src/screens/src/components/FeedbackSurvey/useFeedbackSurvey.ts index dcff53ce8..b62a45abd 100644 --- a/src/screens/src/components/FeedbackSurvey/useFeedbackSurvey.ts +++ b/src/screens/src/components/FeedbackSurvey/useFeedbackSurvey.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useFeedbackSurvey = any; +export type useFeedbackSurvey = any diff --git a/src/screens/src/components/FeedbackSurvey/useMemorySurvey.ts b/src/screens/src/components/FeedbackSurvey/useMemorySurvey.ts index f7e0029db..c85f1225a 100644 --- a/src/screens/src/components/FeedbackSurvey/useMemorySurvey.ts +++ b/src/screens/src/components/FeedbackSurvey/useMemorySurvey.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useMemorySurvey = any; +export type useMemorySurvey = any diff --git a/src/screens/src/components/FeedbackSurvey/usePostCompactSurvey.ts b/src/screens/src/components/FeedbackSurvey/usePostCompactSurvey.ts index e81d32694..bda93aba6 100644 --- a/src/screens/src/components/FeedbackSurvey/usePostCompactSurvey.ts +++ b/src/screens/src/components/FeedbackSurvey/usePostCompactSurvey.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type usePostCompactSurvey = any; +export type usePostCompactSurvey = any diff --git a/src/screens/src/components/KeybindingWarnings.ts b/src/screens/src/components/KeybindingWarnings.ts index e57e1f78f..da43c3765 100644 --- a/src/screens/src/components/KeybindingWarnings.ts +++ b/src/screens/src/components/KeybindingWarnings.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type KeybindingWarnings = any; +export type KeybindingWarnings = any diff --git a/src/screens/src/components/LspRecommendation/LspRecommendationMenu.ts b/src/screens/src/components/LspRecommendation/LspRecommendationMenu.ts index 0628cdcb0..e9f82e807 100644 --- a/src/screens/src/components/LspRecommendation/LspRecommendationMenu.ts +++ b/src/screens/src/components/LspRecommendation/LspRecommendationMenu.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type LspRecommendationMenu = any; +export type LspRecommendationMenu = any diff --git a/src/screens/src/components/SandboxViolationExpandedView.ts b/src/screens/src/components/SandboxViolationExpandedView.ts index 4af5947d8..2f06a9ff2 100644 --- a/src/screens/src/components/SandboxViolationExpandedView.ts +++ b/src/screens/src/components/SandboxViolationExpandedView.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type SandboxViolationExpandedView = any; +export type SandboxViolationExpandedView = any diff --git a/src/screens/src/components/mcp/McpParsingWarnings.ts b/src/screens/src/components/mcp/McpParsingWarnings.ts index ab05516ed..7c9e52187 100644 --- a/src/screens/src/components/mcp/McpParsingWarnings.ts +++ b/src/screens/src/components/mcp/McpParsingWarnings.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type McpParsingWarnings = any; +export type McpParsingWarnings = any diff --git a/src/screens/src/components/messages/UserTextMessage.ts b/src/screens/src/components/messages/UserTextMessage.ts index 40106edbb..4cc0ef57f 100644 --- a/src/screens/src/components/messages/UserTextMessage.ts +++ b/src/screens/src/components/messages/UserTextMessage.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type UserTextMessage = any; +export type UserTextMessage = any diff --git a/src/screens/src/components/permissions/SandboxPermissionRequest.ts b/src/screens/src/components/permissions/SandboxPermissionRequest.ts index bd5e216a4..db1549ab5 100644 --- a/src/screens/src/components/permissions/SandboxPermissionRequest.ts +++ b/src/screens/src/components/permissions/SandboxPermissionRequest.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type SandboxPermissionRequest = any; +export type SandboxPermissionRequest = any diff --git a/src/screens/src/hooks/notifs/useAutoModeUnavailableNotification.ts b/src/screens/src/hooks/notifs/useAutoModeUnavailableNotification.ts index b240eb3f5..77fa126ca 100644 --- a/src/screens/src/hooks/notifs/useAutoModeUnavailableNotification.ts +++ b/src/screens/src/hooks/notifs/useAutoModeUnavailableNotification.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useAutoModeUnavailableNotification = any; +export type useAutoModeUnavailableNotification = any diff --git a/src/screens/src/hooks/notifs/useCanSwitchToExistingSubscription.ts b/src/screens/src/hooks/notifs/useCanSwitchToExistingSubscription.ts index 7a4814cec..ad32d76e2 100644 --- a/src/screens/src/hooks/notifs/useCanSwitchToExistingSubscription.ts +++ b/src/screens/src/hooks/notifs/useCanSwitchToExistingSubscription.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useCanSwitchToExistingSubscription = any; +export type useCanSwitchToExistingSubscription = any diff --git a/src/screens/src/hooks/notifs/useDeprecationWarningNotification.ts b/src/screens/src/hooks/notifs/useDeprecationWarningNotification.ts index c919ed0d7..57bc25bd4 100644 --- a/src/screens/src/hooks/notifs/useDeprecationWarningNotification.ts +++ b/src/screens/src/hooks/notifs/useDeprecationWarningNotification.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useDeprecationWarningNotification = any; +export type useDeprecationWarningNotification = any diff --git a/src/screens/src/hooks/notifs/useFastModeNotification.ts b/src/screens/src/hooks/notifs/useFastModeNotification.ts index 2d8192243..007d69963 100644 --- a/src/screens/src/hooks/notifs/useFastModeNotification.ts +++ b/src/screens/src/hooks/notifs/useFastModeNotification.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useFastModeNotification = any; +export type useFastModeNotification = any diff --git a/src/screens/src/hooks/notifs/useIDEStatusIndicator.ts b/src/screens/src/hooks/notifs/useIDEStatusIndicator.ts index c85b1a312..87f9ad8f7 100644 --- a/src/screens/src/hooks/notifs/useIDEStatusIndicator.ts +++ b/src/screens/src/hooks/notifs/useIDEStatusIndicator.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useIDEStatusIndicator = any; +export type useIDEStatusIndicator = any diff --git a/src/screens/src/hooks/notifs/useInstallMessages.ts b/src/screens/src/hooks/notifs/useInstallMessages.ts index 033331f33..e3408ad6b 100644 --- a/src/screens/src/hooks/notifs/useInstallMessages.ts +++ b/src/screens/src/hooks/notifs/useInstallMessages.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useInstallMessages = any; +export type useInstallMessages = any diff --git a/src/screens/src/hooks/notifs/useLspInitializationNotification.ts b/src/screens/src/hooks/notifs/useLspInitializationNotification.ts index 67239f66d..1c480f5f8 100644 --- a/src/screens/src/hooks/notifs/useLspInitializationNotification.ts +++ b/src/screens/src/hooks/notifs/useLspInitializationNotification.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useLspInitializationNotification = any; +export type useLspInitializationNotification = any diff --git a/src/screens/src/hooks/notifs/useMcpConnectivityStatus.ts b/src/screens/src/hooks/notifs/useMcpConnectivityStatus.ts index 7abc0f54f..12516b95d 100644 --- a/src/screens/src/hooks/notifs/useMcpConnectivityStatus.ts +++ b/src/screens/src/hooks/notifs/useMcpConnectivityStatus.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useMcpConnectivityStatus = any; +export type useMcpConnectivityStatus = any diff --git a/src/screens/src/hooks/notifs/useModelMigrationNotifications.ts b/src/screens/src/hooks/notifs/useModelMigrationNotifications.ts index 2dbde4b2f..644ffb397 100644 --- a/src/screens/src/hooks/notifs/useModelMigrationNotifications.ts +++ b/src/screens/src/hooks/notifs/useModelMigrationNotifications.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useModelMigrationNotifications = any; +export type useModelMigrationNotifications = any diff --git a/src/screens/src/hooks/notifs/useNpmDeprecationNotification.ts b/src/screens/src/hooks/notifs/useNpmDeprecationNotification.ts index ee5cd8011..c7bf0c326 100644 --- a/src/screens/src/hooks/notifs/useNpmDeprecationNotification.ts +++ b/src/screens/src/hooks/notifs/useNpmDeprecationNotification.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useNpmDeprecationNotification = any; +export type useNpmDeprecationNotification = any diff --git a/src/screens/src/hooks/notifs/usePluginAutoupdateNotification.ts b/src/screens/src/hooks/notifs/usePluginAutoupdateNotification.ts index 020e8df25..6b728e49d 100644 --- a/src/screens/src/hooks/notifs/usePluginAutoupdateNotification.ts +++ b/src/screens/src/hooks/notifs/usePluginAutoupdateNotification.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type usePluginAutoupdateNotification = any; +export type usePluginAutoupdateNotification = any diff --git a/src/screens/src/hooks/notifs/usePluginInstallationStatus.ts b/src/screens/src/hooks/notifs/usePluginInstallationStatus.ts index c20954050..750e740d3 100644 --- a/src/screens/src/hooks/notifs/usePluginInstallationStatus.ts +++ b/src/screens/src/hooks/notifs/usePluginInstallationStatus.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type usePluginInstallationStatus = any; +export type usePluginInstallationStatus = any diff --git a/src/screens/src/hooks/notifs/useRateLimitWarningNotification.ts b/src/screens/src/hooks/notifs/useRateLimitWarningNotification.ts index 81d3a769c..4d00fa8f5 100644 --- a/src/screens/src/hooks/notifs/useRateLimitWarningNotification.ts +++ b/src/screens/src/hooks/notifs/useRateLimitWarningNotification.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useRateLimitWarningNotification = any; +export type useRateLimitWarningNotification = any diff --git a/src/screens/src/hooks/notifs/useSettingsErrors.ts b/src/screens/src/hooks/notifs/useSettingsErrors.ts index 0724b24d6..448f5e11b 100644 --- a/src/screens/src/hooks/notifs/useSettingsErrors.ts +++ b/src/screens/src/hooks/notifs/useSettingsErrors.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useSettingsErrors = any; +export type useSettingsErrors = any diff --git a/src/screens/src/hooks/notifs/useTeammateShutdownNotification.ts b/src/screens/src/hooks/notifs/useTeammateShutdownNotification.ts index 9fd3f8a7f..4cc16aee8 100644 --- a/src/screens/src/hooks/notifs/useTeammateShutdownNotification.ts +++ b/src/screens/src/hooks/notifs/useTeammateShutdownNotification.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useTeammateLifecycleNotification = any; +export type useTeammateLifecycleNotification = any diff --git a/src/screens/src/hooks/useAwaySummary.ts b/src/screens/src/hooks/useAwaySummary.ts index 4455dec96..acb7a2f88 100644 --- a/src/screens/src/hooks/useAwaySummary.ts +++ b/src/screens/src/hooks/useAwaySummary.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useAwaySummary = any; +export type useAwaySummary = any diff --git a/src/screens/src/hooks/useChromeExtensionNotification.ts b/src/screens/src/hooks/useChromeExtensionNotification.ts index c97ee1f82..24d3c3328 100644 --- a/src/screens/src/hooks/useChromeExtensionNotification.ts +++ b/src/screens/src/hooks/useChromeExtensionNotification.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useChromeExtensionNotification = any; +export type useChromeExtensionNotification = any diff --git a/src/screens/src/hooks/useClaudeCodeHintRecommendation.ts b/src/screens/src/hooks/useClaudeCodeHintRecommendation.ts index 83701c8a8..03b11dc1a 100644 --- a/src/screens/src/hooks/useClaudeCodeHintRecommendation.ts +++ b/src/screens/src/hooks/useClaudeCodeHintRecommendation.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useClaudeCodeHintRecommendation = any; +export type useClaudeCodeHintRecommendation = any diff --git a/src/screens/src/hooks/useFileHistorySnapshotInit.ts b/src/screens/src/hooks/useFileHistorySnapshotInit.ts index 8d14981c0..4cfb771a8 100644 --- a/src/screens/src/hooks/useFileHistorySnapshotInit.ts +++ b/src/screens/src/hooks/useFileHistorySnapshotInit.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useFileHistorySnapshotInit = any; +export type useFileHistorySnapshotInit = any diff --git a/src/screens/src/hooks/useLspPluginRecommendation.ts b/src/screens/src/hooks/useLspPluginRecommendation.ts index c6d24fab8..32c42e317 100644 --- a/src/screens/src/hooks/useLspPluginRecommendation.ts +++ b/src/screens/src/hooks/useLspPluginRecommendation.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useLspPluginRecommendation = any; +export type useLspPluginRecommendation = any diff --git a/src/screens/src/hooks/useOfficialMarketplaceNotification.ts b/src/screens/src/hooks/useOfficialMarketplaceNotification.ts index 95d10a32d..2824b36ba 100644 --- a/src/screens/src/hooks/useOfficialMarketplaceNotification.ts +++ b/src/screens/src/hooks/useOfficialMarketplaceNotification.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useOfficialMarketplaceNotification = any; +export type useOfficialMarketplaceNotification = any diff --git a/src/screens/src/hooks/usePromptsFromClaudeInChrome.ts b/src/screens/src/hooks/usePromptsFromClaudeInChrome.ts index ca8e71f05..946bc6f4a 100644 --- a/src/screens/src/hooks/usePromptsFromClaudeInChrome.ts +++ b/src/screens/src/hooks/usePromptsFromClaudeInChrome.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type usePromptsFromClaudeInChrome = any; +export type usePromptsFromClaudeInChrome = any diff --git a/src/screens/src/hooks/useTerminalSize.ts b/src/screens/src/hooks/useTerminalSize.ts index 4a0ef3ea3..fdaf2e999 100644 --- a/src/screens/src/hooks/useTerminalSize.ts +++ b/src/screens/src/hooks/useTerminalSize.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useTerminalSize = any; +export type useTerminalSize = any diff --git a/src/screens/src/services/analytics/growthbook.ts b/src/screens/src/services/analytics/growthbook.ts index e380906ea..7967fd3ee 100644 --- a/src/screens/src/services/analytics/growthbook.ts +++ b/src/screens/src/services/analytics/growthbook.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getFeatureValue_CACHED_MAY_BE_STALE = any; +export type getFeatureValue_CACHED_MAY_BE_STALE = any diff --git a/src/screens/src/services/analytics/index.ts b/src/screens/src/services/analytics/index.ts index ce0a9a827..eca4493cf 100644 --- a/src/screens/src/services/analytics/index.ts +++ b/src/screens/src/services/analytics/index.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type logEvent = any; -export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any; +export type logEvent = any +export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any diff --git a/src/screens/src/services/mcp/MCPConnectionManager.ts b/src/screens/src/services/mcp/MCPConnectionManager.ts index 2a0ec4e7f..7cde40817 100644 --- a/src/screens/src/services/mcp/MCPConnectionManager.ts +++ b/src/screens/src/services/mcp/MCPConnectionManager.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type MCPConnectionManager = any; +export type MCPConnectionManager = any diff --git a/src/screens/src/services/tips/tipScheduler.ts b/src/screens/src/services/tips/tipScheduler.ts index 813f4de4e..88a1e3e06 100644 --- a/src/screens/src/services/tips/tipScheduler.ts +++ b/src/screens/src/services/tips/tipScheduler.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type getTipToShowOnSpinner = any; -export type recordShownTip = any; +export type getTipToShowOnSpinner = any +export type recordShownTip = any diff --git a/src/screens/src/utils/context.ts b/src/screens/src/utils/context.ts index 03d405458..5fcf08c4b 100644 --- a/src/screens/src/utils/context.ts +++ b/src/screens/src/utils/context.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getModelMaxOutputTokens = any; +export type getModelMaxOutputTokens = any diff --git a/src/screens/src/utils/envUtils.ts b/src/screens/src/utils/envUtils.ts index ef637d0cf..33260a183 100644 --- a/src/screens/src/utils/envUtils.ts +++ b/src/screens/src/utils/envUtils.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getClaudeConfigHomeDir = any; +export type getClaudeConfigHomeDir = any diff --git a/src/screens/src/utils/permissions/bypassPermissionsKillswitch.ts b/src/screens/src/utils/permissions/bypassPermissionsKillswitch.ts index cd1c15aa3..d1725db82 100644 --- a/src/screens/src/utils/permissions/bypassPermissionsKillswitch.ts +++ b/src/screens/src/utils/permissions/bypassPermissionsKillswitch.ts @@ -1,5 +1,5 @@ // Auto-generated type stub — replace with real implementation -export type checkAndDisableBypassPermissionsIfNeeded = any; -export type checkAndDisableAutoModeIfNeeded = any; -export type useKickOffCheckAndDisableBypassPermissionsIfNeeded = any; -export type useKickOffCheckAndDisableAutoModeIfNeeded = any; +export type checkAndDisableBypassPermissionsIfNeeded = any +export type checkAndDisableAutoModeIfNeeded = any +export type useKickOffCheckAndDisableBypassPermissionsIfNeeded = any +export type useKickOffCheckAndDisableAutoModeIfNeeded = any diff --git a/src/screens/src/utils/plugins/performStartupChecks.ts b/src/screens/src/utils/plugins/performStartupChecks.ts index e555a9f1f..9c917733b 100644 --- a/src/screens/src/utils/plugins/performStartupChecks.ts +++ b/src/screens/src/utils/plugins/performStartupChecks.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type performStartupChecks = any; +export type performStartupChecks = any diff --git a/src/screens/src/utils/sandbox/sandbox-adapter.ts b/src/screens/src/utils/sandbox/sandbox-adapter.ts index edebe2640..e9f663b72 100644 --- a/src/screens/src/utils/sandbox/sandbox-adapter.ts +++ b/src/screens/src/utils/sandbox/sandbox-adapter.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type SandboxManager = any; +export type SandboxManager = any diff --git a/src/screens/src/utils/settings/constants.ts b/src/screens/src/utils/settings/constants.ts index b82138d6a..24eb36c76 100644 --- a/src/screens/src/utils/settings/constants.ts +++ b/src/screens/src/utils/settings/constants.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type SettingSource = any; +export type SettingSource = any diff --git a/src/screens/src/utils/theme.ts b/src/screens/src/utils/theme.ts index c6999a678..833b24799 100644 --- a/src/screens/src/utils/theme.ts +++ b/src/screens/src/utils/theme.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type Theme = any; +export type Theme = any diff --git a/src/self-hosted-runner/main.ts b/src/self-hosted-runner/main.ts index 09139d298..acec32b91 100644 --- a/src/self-hosted-runner/main.ts +++ b/src/self-hosted-runner/main.ts @@ -1,3 +1,4 @@ // Auto-generated stub — replace with real implementation -export {}; -export const selfHostedRunnerMain: (args: string[]) => Promise = () => Promise.resolve(); +export {} +export const selfHostedRunnerMain: (args: string[]) => Promise = () => + Promise.resolve() diff --git a/src/server/backends/dangerousBackend.ts b/src/server/backends/dangerousBackend.ts index 8bb43e5eb..9bc5a12b7 100644 --- a/src/server/backends/dangerousBackend.ts +++ b/src/server/backends/dangerousBackend.ts @@ -1,3 +1,5 @@ // Auto-generated stub — replace with real implementation -export {}; -export const DangerousBackend: new (...args: unknown[]) => Record = class {} as never; +export {} +export const DangerousBackend: new ( + ...args: unknown[] +) => Record = class {} as never diff --git a/src/server/connectHeadless.ts b/src/server/connectHeadless.ts index 110ea4747..8e693fccb 100644 --- a/src/server/connectHeadless.ts +++ b/src/server/connectHeadless.ts @@ -1,3 +1,4 @@ // Auto-generated stub — replace with real implementation -export {}; -export const runConnectHeadless: (...args: unknown[]) => Promise = () => Promise.resolve(); +export {} +export const runConnectHeadless: (...args: unknown[]) => Promise = () => + Promise.resolve() diff --git a/src/server/lockfile.ts b/src/server/lockfile.ts index 7efc3e804..d4507c804 100644 --- a/src/server/lockfile.ts +++ b/src/server/lockfile.ts @@ -8,6 +8,8 @@ export interface ServerLockInfo { startedAt: number } -export const writeServerLock: (info: ServerLockInfo) => Promise = (async () => {}); -export const removeServerLock: () => Promise = (async () => {}); -export const probeRunningServer: () => Promise = (async () => null); +export const writeServerLock: (info: ServerLockInfo) => Promise = + async () => {} +export const removeServerLock: () => Promise = async () => {} +export const probeRunningServer: () => Promise = + async () => null diff --git a/src/server/parseConnectUrl.ts b/src/server/parseConnectUrl.ts index f60ad4ecf..c86326b2e 100644 --- a/src/server/parseConnectUrl.ts +++ b/src/server/parseConnectUrl.ts @@ -1,3 +1,7 @@ // Auto-generated stub — replace with real implementation -export {}; -export const parseConnectUrl: (url: string) => { serverUrl: string; authToken: string; [key: string]: unknown } = () => ({ serverUrl: '', authToken: '' }); +export {} +export const parseConnectUrl: (url: string) => { + serverUrl: string + authToken: string + [key: string]: unknown +} = () => ({ serverUrl: '', authToken: '' }) diff --git a/src/server/server.ts b/src/server/server.ts index 94a377148..a8f554098 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -1,3 +1,6 @@ // Auto-generated stub — replace with real implementation -export {}; -export const startServer: (...args: unknown[]) => { port?: number; stop: (closeActiveConnections: boolean) => void } = () => ({ stop() {} }); +export {} +export const startServer: (...args: unknown[]) => { + port?: number + stop: (closeActiveConnections: boolean) => void +} = () => ({ stop() {} }) diff --git a/src/server/serverBanner.ts b/src/server/serverBanner.ts index b91b6b484..b06d386cc 100644 --- a/src/server/serverBanner.ts +++ b/src/server/serverBanner.ts @@ -1,3 +1,3 @@ // Auto-generated stub — replace with real implementation -export {}; -export const printBanner: (...args: unknown[]) => void = () => {}; +export {} +export const printBanner: (...args: unknown[]) => void = () => {} diff --git a/src/server/serverLog.ts b/src/server/serverLog.ts index e89c00070..605f47eec 100644 --- a/src/server/serverLog.ts +++ b/src/server/serverLog.ts @@ -1,3 +1,3 @@ // Auto-generated stub — replace with real implementation -export {}; -export const createServerLogger: () => Record = () => ({}); +export {} +export const createServerLogger: () => Record = () => ({}) diff --git a/src/server/sessionManager.ts b/src/server/sessionManager.ts index 5684d78f8..9f893092a 100644 --- a/src/server/sessionManager.ts +++ b/src/server/sessionManager.ts @@ -1,3 +1,7 @@ // Auto-generated stub — replace with real implementation -export {}; -export const SessionManager: new (...args: unknown[]) => { destroyAll(): Promise; [key: string]: unknown } = class { async destroyAll() {} } as never; +export {} +export const SessionManager: new ( + ...args: unknown[] +) => { destroyAll(): Promise; [key: string]: unknown } = class { + async destroyAll() {} +} as never diff --git a/src/services/AgentSummary/agentSummary.ts b/src/services/AgentSummary/agentSummary.ts index 50146b3c7..de3ee749b 100644 --- a/src/services/AgentSummary/agentSummary.ts +++ b/src/services/AgentSummary/agentSummary.ts @@ -136,7 +136,9 @@ export function startAgentSummarization( ) continue } - const contentArr = Array.isArray(msg.message!.content) ? msg.message!.content : [] + const contentArr = Array.isArray(msg.message!.content) + ? msg.message!.content + : [] const textBlock = contentArr.find(b => b.type === 'text') if (textBlock?.type === 'text' && textBlock.text.trim()) { const summaryText = textBlock.text.trim() diff --git a/src/services/MagicDocs/prompts.ts b/src/services/MagicDocs/prompts.ts index 8b926a151..5e549404d 100644 --- a/src/services/MagicDocs/prompts.ts +++ b/src/services/MagicDocs/prompts.ts @@ -86,9 +86,7 @@ function substituteVariables( // (replacer fn treats $ literally), and (2) double-substitution when user // content happens to contain {{varName}} matching a later variable. return template.replace(/\{\{(\w+)\}\}/g, (match, key: string) => - Object.prototype.hasOwnProperty.call(variables, key) - ? variables[key]! - : match, + Object.hasOwn(variables, key) ? variables[key]! : match, ) } diff --git a/src/services/PromptSuggestion/promptSuggestion.ts b/src/services/PromptSuggestion/promptSuggestion.ts index c54df2a40..ee158ac92 100644 --- a/src/services/PromptSuggestion/promptSuggestion.ts +++ b/src/services/PromptSuggestion/promptSuggestion.ts @@ -249,7 +249,9 @@ export function getParentCacheSuppressReason( // The fork re-processes the parent's output (never cached) plus its own prompt. const outputTokens = usage!.output_tokens ?? 0 - return (inputTokens as number) + (cacheWriteTokens as number) + (outputTokens as number) > + return (inputTokens as number) + + (cacheWriteTokens as number) + + (outputTokens as number) > MAX_PARENT_UNCACHED_TOKENS ? 'cache_cold' : null @@ -339,7 +341,9 @@ export async function generateSuggestion( for (const msg of result.messages) { if (msg.type !== 'assistant') continue - const contentArr = Array.isArray(msg.message!.content) ? msg.message!.content as Array<{ type: string; text?: string }> : [] + const contentArr = Array.isArray(msg.message!.content) + ? (msg.message!.content as Array<{ type: string; text?: string }>) + : [] const textBlock = contentArr.find(b => b.type === 'text') if (textBlock?.type === 'text' && typeof textBlock.text === 'string') { const suggestion = textBlock.text.trim() @@ -349,7 +353,7 @@ export async function generateSuggestion( } } - return { suggestion: null as (string | null), generationRequestId } + return { suggestion: null as string | null, generationRequestId } } export function shouldFilterSuggestion( diff --git a/src/services/PromptSuggestion/speculation.ts b/src/services/PromptSuggestion/speculation.ts index 9835d4d86..6e7b0a469 100644 --- a/src/services/PromptSuggestion/speculation.ts +++ b/src/services/PromptSuggestion/speculation.ts @@ -197,7 +197,9 @@ function getBoundaryDetail( function isUserMessageWithArrayContent( m: Message, ): m is Message & { message: { content: unknown[] } } { - return m.type === 'user' && 'message' in m && Array.isArray(m.message?.content) + return ( + m.type === 'user' && 'message' in m && Array.isArray(m.message?.content) + ) } export function prepareMessagesForInjection(messages: Message[]): Message[] { @@ -254,7 +256,8 @@ export function prepareMessagesForInjection(messages: Message[]): Message[] { return messages .map(msg => { - if (!('message' in msg) || !Array.isArray(msg.message?.content)) return msg + if (!('message' in msg) || !Array.isArray(msg.message?.content)) + return msg const content = msg.message!.content.filter(keep) if (content.length === msg.message!.content.length) return msg if (content.length === 0) return null diff --git a/src/services/SessionMemory/prompts.ts b/src/services/SessionMemory/prompts.ts index e220736f9..dc889cbe6 100644 --- a/src/services/SessionMemory/prompts.ts +++ b/src/services/SessionMemory/prompts.ts @@ -206,9 +206,7 @@ function substituteVariables( // (replacer fn treats $ literally), and (2) double-substitution when user // content happens to contain {{varName}} matching a later variable. return template.replace(/\{\{(\w+)\}\}/g, (match, key: string) => - Object.prototype.hasOwnProperty.call(variables, key) - ? variables[key]! - : match, + Object.hasOwn(variables, key) ? variables[key]! : match, ) } diff --git a/src/services/analytics/datadog.ts b/src/services/analytics/datadog.ts index 60bc5a7f7..f456b6458 100644 --- a/src/services/analytics/datadog.ts +++ b/src/services/analytics/datadog.ts @@ -16,10 +16,8 @@ import { getEventMetadata } from './metadata.js' * DATADOG_LOGS_ENDPOINT=https://http-intake.logs.datadoghq.com/api/v2/logs * DATADOG_API_KEY= */ -const DATADOG_LOGS_ENDPOINT = - process.env.DATADOG_LOGS_ENDPOINT ?? '' -const DATADOG_CLIENT_TOKEN = - process.env.DATADOG_API_KEY ?? '' +const DATADOG_LOGS_ENDPOINT = process.env.DATADOG_LOGS_ENDPOINT ?? '' +const DATADOG_CLIENT_TOKEN = process.env.DATADOG_API_KEY ?? '' const DEFAULT_FLUSH_INTERVAL_MS = 15000 const MAX_BATCH_SIZE = 100 const NETWORK_TIMEOUT_MS = 5000 diff --git a/src/services/analytics/firstPartyEventLogger.ts b/src/services/analytics/firstPartyEventLogger.ts index e3a501d74..b54c43e12 100644 --- a/src/services/analytics/firstPartyEventLogger.ts +++ b/src/services/analytics/firstPartyEventLogger.ts @@ -331,6 +331,7 @@ export function initialize1PEventLogging(): void { parseInt( process.env.OTEL_LOGS_EXPORT_INTERVAL || DEFAULT_LOGS_EXPORT_INTERVAL_MS.toString(), + 10, ) const maxExportBatchSize = diff --git a/src/services/analytics/firstPartyEventLoggingExporter.ts b/src/services/analytics/firstPartyEventLoggingExporter.ts index 99c559114..b0cf489c1 100644 --- a/src/services/analytics/firstPartyEventLoggingExporter.ts +++ b/src/services/analytics/firstPartyEventLoggingExporter.ts @@ -673,7 +673,9 @@ export class FirstPartyEventLoggingExporter implements LogRecordExporter { (attributes.event_name as string) || (log.body as string) || 'unknown' // Extract metadata objects directly (no JSON parsing needed) - const coreMetadata = attributes.core_metadata as unknown as EventMetadata | undefined + const coreMetadata = attributes.core_metadata as unknown as + | EventMetadata + | undefined const userMetadata = attributes.user_metadata as CoreUserData const eventMetadata = (attributes.event_metadata || {}) as Record< string, diff --git a/src/services/analytics/metadata.ts b/src/services/analytics/metadata.ts index b83e96aa3..7d0a7e124 100644 --- a/src/services/analytics/metadata.ts +++ b/src/services/analytics/metadata.ts @@ -742,7 +742,6 @@ export async function getEventMetadata( return metadata } - /** * Core event metadata for 1P event logging (snake_case format). */ diff --git a/src/services/analytics/sink.ts b/src/services/analytics/sink.ts index a7b702127..76e3b2c55 100644 --- a/src/services/analytics/sink.ts +++ b/src/services/analytics/sink.ts @@ -20,7 +20,7 @@ type LogEventMetadata = { [key: string]: boolean | number | undefined } const DATADOG_GATE_NAME = 'tengu_log_datadog_events' // Module-level gate state - starts undefined, initialized during startup -let isDatadogGateEnabled: boolean | undefined = undefined +let isDatadogGateEnabled: boolean | undefined /** * Check if Datadog tracking is enabled. diff --git a/src/services/analytics/src/utils/user.ts b/src/services/analytics/src/utils/user.ts index be2aa4592..99181f2d7 100644 --- a/src/services/analytics/src/utils/user.ts +++ b/src/services/analytics/src/utils/user.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type CoreUserData = any; +export type CoreUserData = any diff --git a/src/services/api/claude.ts b/src/services/api/claude.ts index 8b3c0e622..7c4d78ba3 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -230,7 +230,10 @@ import { getInitializationStatus } from '../lsp/manager.js' import { isToolFromMcpServer } from '../mcp/utils.js' import { recordLLMObservation } from '../langfuse/index.js' import type { LangfuseSpan } from '../langfuse/index.js' -import { convertMessagesToLangfuse, convertOutputToLangfuse } from '../langfuse/convert.js' +import { + convertMessagesToLangfuse, + convertOutputToLangfuse, +} from '../langfuse/convert.js' import { withStreamingVCR, withVCR } from '../vcr.js' import { CLIENT_REQUEST_ID_HEADER, getAnthropicClient } from './client.js' import { @@ -442,7 +445,7 @@ function configureEffortParams( betas.push(EFFORT_BETA_HEADER) } else if (typeof effortValue === 'string') { // Send string effort level as is - outputConfig.effort = effortValue as "high" | "medium" | "low" | "max" + outputConfig.effort = effortValue as 'high' | 'medium' | 'low' | 'max' betas.push(EFFORT_BETA_HEADER) } else if (process.env.USER_TYPE === 'ant') { // Numeric effort override - ant-only (uses anthropic_internal) @@ -541,7 +544,6 @@ export async function verifyApiKey( }), async anthropic => { const messages: MessageParam[] = [{ role: 'user', content: 'test' }] - // biome-ignore lint/plugin: API key verification is intentionally a minimal direct call await anthropic.beta.messages.create({ model, max_tokens: 1, @@ -616,7 +618,8 @@ export function userMessageToMessageParam( role: 'user', content: (Array.isArray(message.message!.content) ? [...message.message!.content] - : message.message!.content) as import('@anthropic-ai/sdk/resources/beta/messages/messages.js').BetaContentBlockParam[], + : message.message! + .content) as import('@anthropic-ai/sdk/resources/beta/messages/messages.js').BetaContentBlockParam[], } } @@ -667,7 +670,9 @@ export function assistantMessageToMessageParam( content: typeof message.message!.content === 'string' ? message.message!.content - : message.message!.content!.map(stripGeminiProviderMetadata) as BetaContentBlockParam[], + : (message.message!.content!.map( + stripGeminiProviderMetadata, + ) as BetaContentBlockParam[]), } } @@ -682,10 +687,8 @@ function stripGeminiProviderMetadata( } const obj = contentBlock as unknown as Record - const { - _geminiThoughtSignature: _unusedGeminiThoughtSignature, - ...rest - } = obj + const { _geminiThoughtSignature: _unusedGeminiThoughtSignature, ...rest } = + obj return rest as unknown as T } @@ -878,7 +881,6 @@ export async function* executeNonStreamingRequest( ) try { - // biome-ignore lint/plugin: non-streaming API call return await anthropic.beta.messages.create( { ...adjustedParams, @@ -1337,7 +1339,13 @@ async function* queryModel( // media stripping) but before Anthropic-specific logic (betas, thinking, caching). if (getAPIProvider() === 'openai') { const { queryModelOpenAI } = await import('./openai/index.js') - yield* queryModelOpenAI(messagesForAPI, systemPrompt, filteredTools, signal, options) + yield* queryModelOpenAI( + messagesForAPI, + systemPrompt, + filteredTools, + signal, + options, + ) return } @@ -1356,7 +1364,13 @@ async function* queryModel( if (getAPIProvider() === 'grok') { const { queryModelGrok } = await import('./grok/index.js') - yield* queryModelGrok(messagesForAPI, systemPrompt, filteredTools, signal, options) + yield* queryModelGrok( + messagesForAPI, + systemPrompt, + filteredTools, + signal, + options, + ) return } @@ -1552,11 +1566,11 @@ async function* queryModel( let start = Date.now() let attemptNumber = 0 const attemptStartTimes: number[] = [] - let stream: Stream | undefined = undefined - let streamRequestId: string | null | undefined = undefined - let clientRequestId: string | undefined = undefined + let stream: Stream | undefined + let streamRequestId: string | null | undefined + let clientRequestId: string | undefined // eslint-disable-next-line eslint-plugin-n/no-unsupported-features/node-builtins -- Response is available in Node 18+ and is used by the SDK - let streamResponse: Response | undefined = undefined + let streamResponse: Response | undefined // Release all stream resources to prevent native memory leaks. // The Response object holds native TLS/socket buffers that live outside the @@ -1642,7 +1656,7 @@ async function* queryModel( const hasThinking = thinkingConfig.type !== 'disabled' && !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_THINKING) - let thinking: BetaMessageStreamParams['thinking'] | undefined = undefined + let thinking: BetaMessageStreamParams['thinking'] | undefined // IMPORTANT: Do not change the adaptive-vs-budget thinking selection below // without notifying the model launch DRI and research. This is a sensitive @@ -1806,7 +1820,7 @@ async function* queryModel( const newMessages: AssistantMessage[] = [] let ttftMs = 0 - let partialMessage: BetaMessage | undefined = undefined + let partialMessage: BetaMessage | undefined const contentBlocks: (BetaContentBlock | ConnectorTextBlock)[] = [] let usage: NonNullableUsage = EMPTY_USAGE let costUSD = 0 @@ -1814,8 +1828,8 @@ async function* queryModel( let didFallBackToNonStreaming = false let fallbackMessage: AssistantMessage | undefined let maxOutputTokens = 0 - let responseHeaders: globalThis.Headers | undefined = undefined - let research: unknown = undefined + let responseHeaders: globalThis.Headers | undefined + let research: unknown let isFastModeRequest = isFastMode // Keep separate state as it may change if falling back let isAdvisorInProgress = false @@ -1864,7 +1878,6 @@ async function* queryModel( // Use raw stream instead of BetaMessageStream to avoid O(n²) partial JSON parsing // BetaMessageStream calls partialParse() on every input_json_delta, which we don't need // since we handle tool input accumulation ourselves - // biome-ignore lint/plugin: main conversation loop handles attribution separately const result = await anthropic.beta.messages .create( { ...params, stream: true }, @@ -2124,7 +2137,8 @@ async function* queryModel( }) throw new Error('Content block is not a connector_text block') } - ;(contentBlock as { connector_text: string }).connector_text += delta.connector_text + ;(contentBlock as { connector_text: string }).connector_text += + delta.connector_text } else { switch (delta.type) { case 'citations_delta': @@ -2203,7 +2217,8 @@ async function* queryModel( }) throw new Error('Content block is not a thinking block') } - ;(contentBlock as { thinking: string }).thinking += delta.thinking + ;(contentBlock as { thinking: string }).thinking += + delta.thinking break } } @@ -2294,7 +2309,10 @@ async function* queryModel( } // Update cost - const costUSDForPart = calculateUSDCost(resolvedModel, usage as unknown as BetaUsage) + const costUSDForPart = calculateUSDCost( + resolvedModel, + usage as unknown as BetaUsage, + ) costUSD += addToTotalSessionCost( costUSDForPart, usage as unknown as BetaUsage, @@ -2864,10 +2882,14 @@ async function* queryModel( // message_delta handler before any yield. Fallback pushes to newMessages // then yields, so tracking must be here to survive .return() at the yield. if (fallbackMessage) { - const fallbackUsage = fallbackMessage.message.usage as BetaMessageDeltaUsage + const fallbackUsage = fallbackMessage.message + .usage as BetaMessageDeltaUsage usage = updateUsage(EMPTY_USAGE, fallbackUsage) stopReason = fallbackMessage.message.stop_reason as BetaStopReason - const fallbackCost = calculateUSDCost(resolvedModel, fallbackUsage as unknown as BetaUsage) + const fallbackCost = calculateUSDCost( + resolvedModel, + fallbackUsage as unknown as BetaUsage, + ) costUSD += addToTotalSessionCost( fallbackCost, fallbackUsage as unknown as BetaUsage, @@ -2921,7 +2943,9 @@ async function* queryModel( void options.getToolPermissionContext().then(permissionContext => { logAPISuccessAndDuration({ model: - (newMessages[0]?.message.model as string | undefined) ?? partialMessage?.model ?? options.model, + (newMessages[0]?.message.model as string | undefined) ?? + partialMessage?.model ?? + options.model, preNormalizedModel: options.model, usage, start, diff --git a/src/services/api/client.ts b/src/services/api/client.ts index 166eaadf3..9f862b7ec 100644 --- a/src/services/api/client.ts +++ b/src/services/api/client.ts @@ -73,14 +73,10 @@ import { function createStderrLogger(): ClientOptions['logger'] { return { error: (msg, ...args) => - // biome-ignore lint/suspicious/noConsole:: intentional console output -- SDK logger must use console console.error('[Anthropic SDK ERROR]', msg, ...args), - // biome-ignore lint/suspicious/noConsole:: intentional console output -- SDK logger must use console warn: (msg, ...args) => console.error('[Anthropic SDK WARN]', msg, ...args), - // biome-ignore lint/suspicious/noConsole:: intentional console output -- SDK logger must use console info: (msg, ...args) => console.error('[Anthropic SDK INFO]', msg, ...args), debug: (msg, ...args) => - // biome-ignore lint/suspicious/noConsole:: intentional console output -- SDK logger must use console console.error('[Anthropic SDK DEBUG]', msg, ...args), } } diff --git a/src/services/api/filesApi.ts b/src/services/api/filesApi.ts index 71b896bee..94096fd71 100644 --- a/src/services/api/filesApi.ts +++ b/src/services/api/filesApi.ts @@ -113,7 +113,7 @@ async function retryWithBackoff( ) if (attempt < MAX_RETRIES) { - const delayMs = BASE_DELAY_MS * Math.pow(2, attempt - 1) + const delayMs = BASE_DELAY_MS * 2 ** (attempt - 1) logDebug(`Retrying ${operation} in ${delayMs}ms...`) await sleep(delayMs) } diff --git a/src/services/api/gemini/__tests__/convertMessages.test.ts b/src/services/api/gemini/__tests__/convertMessages.test.ts index 63a9cf60a..20b208ffa 100644 --- a/src/services/api/gemini/__tests__/convertMessages.test.ts +++ b/src/services/api/gemini/__tests__/convertMessages.test.ts @@ -23,10 +23,9 @@ function makeAssistantMsg(content: string | any[]): AssistantMessage { describe('anthropicMessagesToGemini', () => { test('converts system prompt to systemInstruction', () => { - const result = anthropicMessagesToGemini( - [makeUserMsg('hello')], - ['You are helpful.'] as any, - ) + const result = anthropicMessagesToGemini([makeUserMsg('hello')], [ + 'You are helpful.', + ] as any) expect(result.systemInstruction).toEqual({ parts: [{ text: 'You are helpful.' }], @@ -202,17 +201,19 @@ describe('anthropicMessagesToGemini', () => { test('converts base64 image to inlineData', () => { const result = anthropicMessagesToGemini( - [makeUserMsg([ - { type: 'text', text: 'describe this' }, - { - type: 'image', - source: { - type: 'base64', - media_type: 'image/png', - data: 'iVBORw0KGgo=', + [ + makeUserMsg([ + { type: 'text', text: 'describe this' }, + { + type: 'image', + source: { + type: 'base64', + media_type: 'image/png', + data: 'iVBORw0KGgo=', + }, }, - }, - ])], + ]), + ], [] as any, ) expect(result.contents).toEqual([ @@ -228,15 +229,17 @@ describe('anthropicMessagesToGemini', () => { test('converts url image to text fallback', () => { const result = anthropicMessagesToGemini( - [makeUserMsg([ - { - type: 'image', - source: { - type: 'url', - url: 'https://example.com/img.png', + [ + makeUserMsg([ + { + type: 'image', + source: { + type: 'url', + url: 'https://example.com/img.png', + }, }, - }, - ])], + ]), + ], [] as any, ) expect(result.contents).toEqual([ @@ -249,15 +252,17 @@ describe('anthropicMessagesToGemini', () => { test('defaults to image/png when media_type is missing', () => { const result = anthropicMessagesToGemini( - [makeUserMsg([ - { - type: 'image', - source: { - type: 'base64', - data: 'ABC123', + [ + makeUserMsg([ + { + type: 'image', + source: { + type: 'base64', + data: 'ABC123', + }, }, - }, - ])], + ]), + ], [] as any, ) expect(result.contents[0].parts[0]).toEqual({ diff --git a/src/services/api/gemini/__tests__/convertTools.test.ts b/src/services/api/gemini/__tests__/convertTools.test.ts index 999f362cd..8aae1c20f 100644 --- a/src/services/api/gemini/__tests__/convertTools.test.ts +++ b/src/services/api/gemini/__tests__/convertTools.test.ts @@ -120,11 +120,11 @@ describe('anthropicToolChoiceToGemini', () => { }) test('maps explicit tool choice', () => { - expect( - anthropicToolChoiceToGemini({ type: 'tool', name: 'bash' }), - ).toEqual({ - mode: 'ANY', - allowedFunctionNames: ['bash'], - }) + expect(anthropicToolChoiceToGemini({ type: 'tool', name: 'bash' })).toEqual( + { + mode: 'ANY', + allowedFunctionNames: ['bash'], + }, + ) }) }) diff --git a/src/services/api/gemini/__tests__/streamAdapter.test.ts b/src/services/api/gemini/__tests__/streamAdapter.test.ts index d7b42229f..2ac8836d8 100644 --- a/src/services/api/gemini/__tests__/streamAdapter.test.ts +++ b/src/services/api/gemini/__tests__/streamAdapter.test.ts @@ -57,7 +57,8 @@ describe('adaptGeminiStreamToAnthropic', () => { const textDeltas = events.filter( event => - event.type === 'content_block_delta' && event.delta.type === 'text_delta', + event.type === 'content_block_delta' && + event.delta.type === 'text_delta', ) expect(events[0].type).toBe('message_start') @@ -92,7 +93,9 @@ describe('adaptGeminiStreamToAnthropic', () => { }, ]) - const blockStart = events.find(event => event.type === 'content_block_start') + const blockStart = events.find( + event => event.type === 'content_block_start', + ) expect(blockStart.content_block.type).toBe('thinking') const signatureDelta = events.find( @@ -125,7 +128,9 @@ describe('adaptGeminiStreamToAnthropic', () => { }, ]) - const blockStart = events.find(event => event.type === 'content_block_start') + const blockStart = events.find( + event => event.type === 'content_block_start', + ) expect(blockStart.content_block.type).toBe('tool_use') expect(blockStart.content_block.name).toBe('bash') diff --git a/src/services/api/gemini/convertMessages.ts b/src/services/api/gemini/convertMessages.ts index 0bdf22223..081964778 100644 --- a/src/services/api/gemini/convertMessages.ts +++ b/src/services/api/gemini/convertMessages.ts @@ -84,7 +84,10 @@ function convertInternalUserMessage( return { role: 'user', parts: content.flatMap(block => - convertUserContentBlockToGeminiParts(block as unknown as string | Record, toolNamesById), + convertUserContentBlockToGeminiParts( + block as unknown as string | Record, + toolNamesById, + ), ), } } @@ -106,7 +109,8 @@ function convertUserContentBlockToGeminiParts( return [ { functionResponse: { - name: toolNamesById.get(toolResult.tool_use_id) ?? toolResult.tool_use_id, + name: + toolNamesById.get(toolResult.tool_use_id) ?? toolResult.tool_use_id, response: toolResultToResponseObject(toolResult), }, }, @@ -161,7 +165,9 @@ function convertInternalAssistantMessage(msg: AssistantMessage): GeminiContent { parts.push( ...createTextGeminiParts( block.text, - getGeminiThoughtSignature(block as unknown as Record), + getGeminiThoughtSignature( + block as unknown as Record, + ), ), ) continue @@ -185,8 +191,12 @@ function convertInternalAssistantMessage(msg: AssistantMessage): GeminiContent { name: toolUse.name, args: normalizeToolUseInput(toolUse.input), }, - ...(getGeminiThoughtSignature(block as unknown as Record) && { - thoughtSignature: getGeminiThoughtSignature(block as unknown as Record), + ...(getGeminiThoughtSignature( + block as unknown as Record, + ) && { + thoughtSignature: getGeminiThoughtSignature( + block as unknown as Record, + ), }), }) } @@ -246,12 +256,10 @@ function toolResultToResponseObject( block: BetaToolResultBlockParam, ): Record { const result = normalizeToolResultContent(block.content) - if ( - result && - typeof result === 'object' && - !Array.isArray(result) - ) { - return block.is_error ? { ...(result as Record), is_error: true } : result as Record + if (result && typeof result === 'object' && !Array.isArray(result)) { + return block.is_error + ? { ...(result as Record), is_error: true } + : (result as Record) } return { @@ -290,7 +298,9 @@ function normalizeToolResultContent(content: unknown): unknown { return content ?? '' } -function getGeminiThoughtSignature(block: Record): string | undefined { +function getGeminiThoughtSignature( + block: Record, +): string | undefined { const signature = block[GEMINI_THOUGHT_SIGNATURE_FIELD] return typeof signature === 'string' && signature.length > 0 ? signature diff --git a/src/services/api/gemini/convertTools.ts b/src/services/api/gemini/convertTools.ts index 7f6fc82c5..0473174c6 100644 --- a/src/services/api/gemini/convertTools.ts +++ b/src/services/api/gemini/convertTools.ts @@ -1,8 +1,5 @@ import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' -import type { - GeminiFunctionCallingConfig, - GeminiTool, -} from './types.js' +import type { GeminiFunctionCallingConfig, GeminiTool } from './types.js' const GEMINI_JSON_SCHEMA_TYPES = new Set([ 'string', @@ -34,7 +31,9 @@ function normalizeGeminiJsonSchemaType( return undefined } -function inferGeminiJsonSchemaTypeFromValue(value: unknown): string | undefined { +function inferGeminiJsonSchemaTypeFromValue( + value: unknown, +): string | undefined { if (value === null) return 'null' if (Array.isArray(value)) return 'array' if (typeof value === 'string') return 'string' @@ -97,9 +96,7 @@ function sanitizeGeminiJsonSchemaArray( return sanitized.length > 0 ? sanitized : undefined } -function sanitizeGeminiJsonSchema( - schema: unknown, -): Record { +function sanitizeGeminiJsonSchema(schema: unknown): Record { if (!schema || typeof schema !== 'object' || Array.isArray(schema)) { return {} } @@ -236,17 +233,20 @@ export function anthropicToolsToGemini(tools: BetaToolUnion[]): GeminiTool[] { const functionDeclarations = tools .filter(tool => { const toolType = (tool as unknown as { type?: string }).type - return tool.type === 'custom' || !('type' in tool) || toolType !== 'server' + return ( + tool.type === 'custom' || !('type' in tool) || toolType !== 'server' + ) }) .map(tool => { const anyTool = tool as unknown as Record const name = (anyTool.name as string) || '' const description = (anyTool.description as string) || '' - const inputSchema = - (anyTool.input_schema as Record | undefined) ?? { - type: 'object', - properties: {}, - } + const inputSchema = (anyTool.input_schema as + | Record + | undefined) ?? { + type: 'object', + properties: {}, + } return { name, @@ -255,9 +255,7 @@ export function anthropicToolsToGemini(tools: BetaToolUnion[]): GeminiTool[] { } }) - return functionDeclarations.length > 0 - ? [{ functionDeclarations }] - : [] + return functionDeclarations.length > 0 ? [{ functionDeclarations }] : [] } export function anthropicToolChoiceToGemini( diff --git a/src/services/api/gemini/index.ts b/src/services/api/gemini/index.ts index 1b887878e..c0d70103f 100644 --- a/src/services/api/gemini/index.ts +++ b/src/services/api/gemini/index.ts @@ -107,7 +107,7 @@ export async function* queryModelGemini( const adaptedStream = adaptGeminiStreamToAnthropic(stream, geminiModel) const contentBlocks: Record = {} - let partialMessage: any = undefined + let partialMessage: any let ttftMs = 0 const start = Date.now() @@ -187,7 +187,9 @@ export async function* queryModelGemini( yield createAssistantAPIErrorMessage({ content: `API Error: ${errorMessage}`, apiError: 'api_error', - error: (error instanceof Error ? error : new Error(String(error))) as unknown as SDKAssistantMessageError, + error: (error instanceof Error + ? error + : new Error(String(error))) as unknown as SDKAssistantMessageError, }) } } diff --git a/src/services/api/gemini/streamAdapter.ts b/src/services/api/gemini/streamAdapter.ts index d40980e04..9095b6da0 100644 --- a/src/services/api/gemini/streamAdapter.ts +++ b/src/services/api/gemini/streamAdapter.ts @@ -10,9 +10,8 @@ export async function* adaptGeminiStreamToAnthropic( let started = false let stopped = false let nextContentIndex = 0 - let openTextLikeBlock: - | { index: number; type: 'text' | 'thinking' } - | null = null + let openTextLikeBlock: { index: number; type: 'text' | 'thinking' } | null = + null let sawToolUse = false let finishReason: string | undefined let inputTokens = 0 @@ -85,7 +84,10 @@ export async function* adaptGeminiStreamToAnthropic( } as BetaRawMessageStreamEvent } - if (part.functionCall.args && Object.keys(part.functionCall.args).length > 0) { + if ( + part.functionCall.args && + Object.keys(part.functionCall.args).length > 0 + ) { yield { type: 'content_block_delta', index: toolIndex, @@ -213,9 +215,7 @@ export async function* adaptGeminiStreamToAnthropic( } } -function getTextLikeBlockType( - part: GeminiPart, -): 'text' | 'thinking' | null { +function getTextLikeBlockType(part: GeminiPart): 'text' | 'thinking' | null { if (typeof part.text !== 'string') { return null } diff --git a/src/services/api/grok/__tests__/modelMapping.test.ts b/src/services/api/grok/__tests__/modelMapping.test.ts index 168f236fa..84253dac4 100644 --- a/src/services/api/grok/__tests__/modelMapping.test.ts +++ b/src/services/api/grok/__tests__/modelMapping.test.ts @@ -33,11 +33,14 @@ describe('resolveGrokModel', () => { }) test('maps haiku models to grok-3-mini-fast', () => { - expect(resolveGrokModel('claude-haiku-4-5-20251001')).toBe('grok-3-mini-fast') + expect(resolveGrokModel('claude-haiku-4-5-20251001')).toBe( + 'grok-3-mini-fast', + ) }) test('GROK_MODEL_MAP overrides family mapping', () => { - process.env.GROK_MODEL_MAP = '{"opus":"grok-4","sonnet":"grok-3","haiku":"grok-mini"}' + process.env.GROK_MODEL_MAP = + '{"opus":"grok-4","sonnet":"grok-3","haiku":"grok-mini"}' expect(resolveGrokModel('claude-opus-4-6')).toBe('grok-4') expect(resolveGrokModel('claude-sonnet-4-6')).toBe('grok-3') expect(resolveGrokModel('claude-haiku-4-5-20251001')).toBe('grok-mini') @@ -62,6 +65,8 @@ describe('resolveGrokModel', () => { }) test('falls back to family default for unlisted model', () => { - expect(resolveGrokModel('claude-opus-99-20300101')).toBe('grok-4.20-reasoning') + expect(resolveGrokModel('claude-opus-99-20300101')).toBe( + 'grok-4.20-reasoning', + ) }) }) diff --git a/src/services/api/grok/index.ts b/src/services/api/grok/index.ts index 3198e85f6..26bc511e9 100644 --- a/src/services/api/grok/index.ts +++ b/src/services/api/grok/index.ts @@ -1,6 +1,11 @@ import type { BetaToolUnion } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' import type { SystemPrompt } from '../../../utils/systemPromptType.js' -import type { Message, StreamEvent, SystemAPIErrorMessage, AssistantMessage } from '../../../types/message.js' +import type { + Message, + StreamEvent, + SystemAPIErrorMessage, + AssistantMessage, +} from '../../../types/message.js' import type { Tools } from '../../../Tool.js' import type { ChatCompletionChunk, @@ -8,7 +13,10 @@ import type { } from 'openai/resources/chat/completions/completions.mjs' import { getGrokClient } from './client.js' import { anthropicMessagesToOpenAI } from '../openai/convertMessages.js' -import { anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI } from '../openai/convertTools.js' +import { + anthropicToolsToOpenAI, + anthropicToolChoiceToOpenAI, +} from '../openai/convertTools.js' import { adaptOpenAIStreamToAnthropic } from '../openai/streamAdapter.js' import { resolveGrokModel } from './modelMapping.js' import { normalizeMessagesForAPI } from '../../../utils/messages.js' @@ -57,11 +65,16 @@ export async function* queryModelGrok( const standardTools = toolSchemas.filter( (t): t is BetaToolUnion & { type: string } => { const anyT = t as unknown as Record - return anyT.type !== 'advisor_20260301' && anyT.type !== 'computer_20250124' + return ( + anyT.type !== 'advisor_20260301' && anyT.type !== 'computer_20250124' + ) }, ) - const openaiMessages = anthropicMessagesToOpenAI(messagesForAPI, systemPrompt) + const openaiMessages = anthropicMessagesToOpenAI( + messagesForAPI, + systemPrompt, + ) const openaiTools = anthropicToolsToOpenAI(standardTools) const openaiToolChoice = anthropicToolChoiceToOpenAI(options.toolChoice) @@ -71,7 +84,9 @@ export async function* queryModelGrok( source: options.querySource, }) - logForDebugging(`[Grok] Calling model=${grokModel}, messages=${openaiMessages.length}, tools=${openaiTools.length}`) + logForDebugging( + `[Grok] Calling model=${grokModel}, messages=${openaiMessages.length}, tools=${openaiTools.length}`, + ) const stream = await client.chat.completions.create( { @@ -92,10 +107,13 @@ export async function* queryModelGrok( }, ) - const adaptedStream = adaptOpenAIStreamToAnthropic(stream as AsyncIterable, grokModel) + const adaptedStream = adaptOpenAIStreamToAnthropic( + stream as AsyncIterable, + grokModel, + ) const contentBlocks: Record = {} - let partialMessage: any = undefined + let partialMessage: any let usage = { input_tokens: 0, output_tokens: 0, @@ -111,7 +129,7 @@ export async function* queryModelGrok( partialMessage = (event as any).message ttftMs = Date.now() - start if ((event as any).message?.usage) { - usage = { ...usage, ...((event as any).message.usage) } + usage = { ...usage, ...(event as any).message.usage } } break } @@ -174,7 +192,10 @@ export async function* queryModelGrok( break } - if (event.type === 'message_stop' && usage.input_tokens + usage.output_tokens > 0) { + if ( + event.type === 'message_stop' && + usage.input_tokens + usage.output_tokens > 0 + ) { const costUSD = calculateUSDCost(grokModel, usage as any) addToTotalSessionCost(costUSD, usage as any, options.model) } @@ -191,7 +212,9 @@ export async function* queryModelGrok( yield createAssistantAPIErrorMessage({ content: `API Error: ${errorMessage}`, apiError: 'api_error', - error: (error instanceof Error ? error : new Error(String(error))) as unknown as SDKAssistantMessageError, + error: (error instanceof Error + ? error + : new Error(String(error))) as unknown as SDKAssistantMessageError, }) } } diff --git a/src/services/api/logging.ts b/src/services/api/logging.ts index 821ce688a..c68708952 100644 --- a/src/services/api/logging.ts +++ b/src/services/api/logging.ts @@ -377,7 +377,7 @@ export function logAPIError({ // Pass the span to correctly match responses to requests when beta tracing is enabled endLLMRequestSpan(llmSpan, { success: false, - statusCode: status ? parseInt(status) : undefined, + statusCode: status ? parseInt(status, 10) : undefined, error: errStr, attempt, }) @@ -656,7 +656,9 @@ export function logAPISuccessAndDuration({ let connectorCount = 0 for (const msg of newMessages) { - const contentArr = Array.isArray(msg.message.content) ? msg.message.content : [] + const contentArr = Array.isArray(msg.message.content) + ? msg.message.content + : [] for (const block of contentArr) { if (typeof block === 'string') continue if (block.type === 'text') { @@ -664,14 +666,19 @@ export function logAPISuccessAndDuration({ } else if (feature('CONNECTOR_TEXT') && isConnectorTextBlock(block)) { connectorCount++ } else if (block.type === 'thinking') { - thinkingLen += (block as { type: 'thinking'; thinking: string }).thinking.length + thinkingLen += (block as { type: 'thinking'; thinking: string }) + .thinking.length } else if ( block.type === 'tool_use' || block.type === 'server_tool_use' || (block.type as string) === 'mcp_tool_use' ) { - const inputLen = jsonStringify((block as { input: unknown }).input).length - const sanitizedName = sanitizeToolNameForAnalytics((block as { name: string }).name) + const inputLen = jsonStringify( + (block as { input: unknown }).input, + ).length + const sanitizedName = sanitizeToolNameForAnalytics( + (block as { name: string }).name, + ) toolLengths[sanitizedName] = (toolLengths[sanitizedName] ?? 0) + inputLen hasToolUse = true diff --git a/src/services/api/openai/__tests__/convertMessages.test.ts b/src/services/api/openai/__tests__/convertMessages.test.ts index 39811c7c8..e838af11f 100644 --- a/src/services/api/openai/__tests__/convertMessages.test.ts +++ b/src/services/api/openai/__tests__/convertMessages.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from 'bun:test' import { anthropicMessagesToOpenAI } from '../convertMessages.js' -import type { UserMessage, AssistantMessage } from '../../../../types/message.js' +import type { + UserMessage, + AssistantMessage, +} from '../../../../types/message.js' // Helpers to create internal-format messages function makeUserMsg(content: string | any[]): UserMessage { @@ -21,26 +24,22 @@ function makeAssistantMsg(content: string | any[]): AssistantMessage { describe('anthropicMessagesToOpenAI', () => { test('converts system prompt to system message', () => { - const result = anthropicMessagesToOpenAI( - [makeUserMsg('hello')], - ['You are helpful.'] as any, - ) + const result = anthropicMessagesToOpenAI([makeUserMsg('hello')], [ + 'You are helpful.', + ] as any) expect(result[0]).toEqual({ role: 'system', content: 'You are helpful.' }) }) test('joins multiple system prompt strings', () => { - const result = anthropicMessagesToOpenAI( - [makeUserMsg('hi')], - ['Part 1', 'Part 2'] as any, - ) + const result = anthropicMessagesToOpenAI([makeUserMsg('hi')], [ + 'Part 1', + 'Part 2', + ] as any) expect(result[0]).toEqual({ role: 'system', content: 'Part 1\n\nPart 2' }) }) test('skips empty system prompt', () => { - const result = anthropicMessagesToOpenAI( - [makeUserMsg('hi')], - [] as any, - ) + const result = anthropicMessagesToOpenAI([makeUserMsg('hi')], [] as any) expect(result[0].role).toBe('user') }) @@ -54,10 +53,12 @@ describe('anthropicMessagesToOpenAI', () => { test('converts user message with content array', () => { const result = anthropicMessagesToOpenAI( - [makeUserMsg([ - { type: 'text', text: 'line 1' }, - { type: 'text', text: 'line 2' }, - ])], + [ + makeUserMsg([ + { type: 'text', text: 'line 1' }, + { type: 'text', text: 'line 2' }, + ]), + ], [] as any, ) expect(result).toEqual([{ role: 'user', content: 'line 1\nline 2' }]) @@ -73,52 +74,64 @@ describe('anthropicMessagesToOpenAI', () => { test('converts assistant message with tool_use', () => { const result = anthropicMessagesToOpenAI( - [makeAssistantMsg([ - { type: 'text', text: 'Let me help.' }, - { - type: 'tool_use' as const, - id: 'toolu_123', - name: 'bash', - input: { command: 'ls' }, - }, - ])], + [ + makeAssistantMsg([ + { type: 'text', text: 'Let me help.' }, + { + type: 'tool_use' as const, + id: 'toolu_123', + name: 'bash', + input: { command: 'ls' }, + }, + ]), + ], [] as any, ) - expect(result).toEqual([{ - role: 'assistant', - content: 'Let me help.', - tool_calls: [{ - id: 'toolu_123', - type: 'function', - function: { name: 'bash', arguments: '{"command":"ls"}' }, - }], - }]) + expect(result).toEqual([ + { + role: 'assistant', + content: 'Let me help.', + tool_calls: [ + { + id: 'toolu_123', + type: 'function', + function: { name: 'bash', arguments: '{"command":"ls"}' }, + }, + ], + }, + ]) }) test('converts tool_result to tool message', () => { const result = anthropicMessagesToOpenAI( - [makeUserMsg([ - { - type: 'tool_result' as const, - tool_use_id: 'toolu_123', - content: 'file1.txt\nfile2.txt', - }, - ])], + [ + makeUserMsg([ + { + type: 'tool_result' as const, + tool_use_id: 'toolu_123', + content: 'file1.txt\nfile2.txt', + }, + ]), + ], [] as any, ) - expect(result).toEqual([{ - role: 'tool', - tool_call_id: 'toolu_123', - content: 'file1.txt\nfile2.txt', - }]) + expect(result).toEqual([ + { + role: 'tool', + tool_call_id: 'toolu_123', + content: 'file1.txt\nfile2.txt', + }, + ]) }) test('strips thinking blocks', () => { const result = anthropicMessagesToOpenAI( - [makeAssistantMsg([ - { type: 'thinking' as const, thinking: 'internal thoughts...' }, - { type: 'text', text: 'visible response' }, - ])], + [ + makeAssistantMsg([ + { type: 'thinking' as const, thinking: 'internal thoughts...' }, + { type: 'text', text: 'visible response' }, + ]), + ], [] as any, ) expect(result).toEqual([{ role: 'assistant', content: 'visible response' }]) @@ -157,91 +170,105 @@ describe('anthropicMessagesToOpenAI', () => { test('converts base64 image to image_url', () => { const result = anthropicMessagesToOpenAI( - [makeUserMsg([ - { type: 'text', text: 'what is this?' }, - { - type: 'image' as const, - source: { - type: 'base64', - media_type: 'image/png', - data: 'iVBORw0KGgo=', + [ + makeUserMsg([ + { type: 'text', text: 'what is this?' }, + { + type: 'image' as const, + source: { + type: 'base64', + media_type: 'image/png', + data: 'iVBORw0KGgo=', + }, }, - }, - ])], + ]), + ], [] as any, ) - expect(result).toEqual([{ - role: 'user', - content: [ - { type: 'text', text: 'what is this?' }, - { - type: 'image_url', - image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' }, - }, - ], - }]) + expect(result).toEqual([ + { + role: 'user', + content: [ + { type: 'text', text: 'what is this?' }, + { + type: 'image_url', + image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' }, + }, + ], + }, + ]) }) test('converts url image to image_url', () => { const result = anthropicMessagesToOpenAI( - [makeUserMsg([ - { - type: 'image' as const, - source: { - type: 'url', - url: 'https://example.com/img.png', + [ + makeUserMsg([ + { + type: 'image' as const, + source: { + type: 'url', + url: 'https://example.com/img.png', + }, }, - }, - ])], + ]), + ], [] as any, ) - expect(result).toEqual([{ - role: 'user', - content: [ - { - type: 'image_url', - image_url: { url: 'https://example.com/img.png' }, - }, - ], - }]) + expect(result).toEqual([ + { + role: 'user', + content: [ + { + type: 'image_url', + image_url: { url: 'https://example.com/img.png' }, + }, + ], + }, + ]) }) test('converts image-only message without text', () => { const result = anthropicMessagesToOpenAI( - [makeUserMsg([ - { - type: 'image' as const, - source: { - type: 'base64', - media_type: 'image/jpeg', - data: '/9j/4AAQ', + [ + makeUserMsg([ + { + type: 'image' as const, + source: { + type: 'base64', + media_type: 'image/jpeg', + data: '/9j/4AAQ', + }, }, - }, - ])], + ]), + ], [] as any, ) - expect(result).toEqual([{ - role: 'user', - content: [ - { - type: 'image_url', - image_url: { url: 'data:image/jpeg;base64,/9j/4AAQ' }, - }, - ], - }]) + expect(result).toEqual([ + { + role: 'user', + content: [ + { + type: 'image_url', + image_url: { url: 'data:image/jpeg;base64,/9j/4AAQ' }, + }, + ], + }, + ]) }) test('defaults to image/png when media_type is missing', () => { const result = anthropicMessagesToOpenAI( - [makeUserMsg([ - { - type: 'image' as const, - source: { - type: 'base64', - data: 'ABC123', + [ + makeUserMsg([ + { + type: 'image' as const, + source: { + type: 'base64', + data: 'ABC123', + }, }, - }, - ])], + ]), + ], [] as any, ) expect((result[0].content as any[])[0].image_url.url).toBe( @@ -253,10 +280,16 @@ describe('anthropicMessagesToOpenAI', () => { describe('DeepSeek thinking mode (enableThinking)', () => { test('preserves thinking block as reasoning_content when enabled', () => { const result = anthropicMessagesToOpenAI( - [makeUserMsg('question'), makeAssistantMsg([ - { type: 'thinking' as const, thinking: 'Let me reason about this...' }, - { type: 'text', text: 'The answer is 42.' }, - ])], + [ + makeUserMsg('question'), + makeAssistantMsg([ + { + type: 'thinking' as const, + thinking: 'Let me reason about this...', + }, + { type: 'text', text: 'The answer is 42.' }, + ]), + ], [] as any, { enableThinking: true }, ) @@ -271,10 +304,12 @@ describe('DeepSeek thinking mode (enableThinking)', () => { test('drops thinking block when enableThinking is false (default)', () => { const result = anthropicMessagesToOpenAI( - [makeAssistantMsg([ - { type: 'thinking' as const, thinking: 'internal thoughts...' }, - { type: 'text', text: 'visible response' }, - ])], + [ + makeAssistantMsg([ + { type: 'thinking' as const, thinking: 'internal thoughts...' }, + { type: 'text', text: 'visible response' }, + ]), + ], [] as any, ) const assistant = result[0] as any @@ -287,7 +322,10 @@ describe('DeepSeek thinking mode (enableThinking)', () => { [ makeUserMsg('what is the weather?'), makeAssistantMsg([ - { type: 'thinking' as const, thinking: 'I need to call the weather tool.' }, + { + type: 'thinking' as const, + thinking: 'I need to call the weather tool.', + }, { type: 'text', text: '' }, { type: 'tool_use' as const, @@ -403,18 +441,27 @@ describe('DeepSeek thinking mode (enableThinking)', () => { const assistants = result.filter(m => m.role === 'assistant') expect(assistants.length).toBe(3) // All iterations within the same turn preserve reasoning - expect((assistants[0] as any).reasoning_content).toBe('I need the date first.') - expect((assistants[1] as any).reasoning_content).toBe('Now I can get the weather.') - expect((assistants[2] as any).reasoning_content).toBe('I have the info now.') + expect((assistants[0] as any).reasoning_content).toBe( + 'I need the date first.', + ) + expect((assistants[1] as any).reasoning_content).toBe( + 'Now I can get the weather.', + ) + expect((assistants[2] as any).reasoning_content).toBe( + 'I have the info now.', + ) }) test('handles multiple thinking blocks in single assistant message', () => { const result = anthropicMessagesToOpenAI( - [makeUserMsg('question'), makeAssistantMsg([ - { type: 'thinking' as const, thinking: 'First thought.' }, - { type: 'thinking' as const, thinking: 'Second thought.' }, - { type: 'text', text: 'Final answer.' }, - ])], + [ + makeUserMsg('question'), + makeAssistantMsg([ + { type: 'thinking' as const, thinking: 'First thought.' }, + { type: 'thinking' as const, thinking: 'Second thought.' }, + { type: 'text', text: 'Final answer.' }, + ]), + ], [] as any, { enableThinking: true }, ) @@ -424,10 +471,13 @@ describe('DeepSeek thinking mode (enableThinking)', () => { test('skips empty thinking blocks', () => { const result = anthropicMessagesToOpenAI( - [makeUserMsg('question'), makeAssistantMsg([ - { type: 'thinking' as const, thinking: '' }, - { type: 'text', text: 'Answer.' }, - ])], + [ + makeUserMsg('question'), + makeAssistantMsg([ + { type: 'thinking' as const, thinking: '' }, + { type: 'text', text: 'Answer.' }, + ]), + ], [] as any, { enableThinking: true }, ) @@ -437,15 +487,18 @@ describe('DeepSeek thinking mode (enableThinking)', () => { test('sets content to null when only thinking and tool_calls present', () => { const result = anthropicMessagesToOpenAI( - [makeUserMsg('question'), makeAssistantMsg([ - { type: 'thinking' as const, thinking: 'Reasoning only.' }, - { - type: 'tool_use' as const, - id: 'toolu_001', - name: 'bash', - input: { command: 'ls' }, - }, - ])], + [ + makeUserMsg('question'), + makeAssistantMsg([ + { type: 'thinking' as const, thinking: 'Reasoning only.' }, + { + type: 'tool_use' as const, + id: 'toolu_001', + name: 'bash', + input: { command: 'ls' }, + }, + ]), + ], [] as any, { enableThinking: true }, ) diff --git a/src/services/api/openai/__tests__/convertTools.test.ts b/src/services/api/openai/__tests__/convertTools.test.ts index 0c51a0b2c..22888d0ec 100644 --- a/src/services/api/openai/__tests__/convertTools.test.ts +++ b/src/services/api/openai/__tests__/convertTools.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from 'bun:test' -import { anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI } from '../convertTools.js' +import { + anthropicToolsToOpenAI, + anthropicToolChoiceToOpenAI, +} from '../convertTools.js' describe('anthropicToolsToOpenAI', () => { test('converts basic tool', () => { @@ -18,25 +21,29 @@ describe('anthropicToolsToOpenAI', () => { const result = anthropicToolsToOpenAI(tools as any) - expect(result).toEqual([{ - type: 'function', - function: { - name: 'bash', - description: 'Run a bash command', - parameters: { - type: 'object', - properties: { command: { type: 'string' } }, - required: ['command'], + expect(result).toEqual([ + { + type: 'function', + function: { + name: 'bash', + description: 'Run a bash command', + parameters: { + type: 'object', + properties: { command: { type: 'string' } }, + required: ['command'], + }, }, }, - }]) + ]) }) test('uses empty schema when input_schema missing', () => { const tools = [{ type: 'custom', name: 'noop', description: 'no-op' }] const result = anthropicToolsToOpenAI(tools as any) - expect((result[0] as { function: { parameters: unknown } }).function.parameters).toEqual({ type: 'object', properties: {} }) + expect( + (result[0] as { function: { parameters: unknown } }).function.parameters, + ).toEqual({ type: 'object', properties: {} }) }) test('strips Anthropic-specific fields', () => { @@ -76,7 +83,8 @@ describe('anthropicToolsToOpenAI', () => { }, ] const result = anthropicToolsToOpenAI(tools as any) - const props = (result[0] as { function: { parameters: any } }).function.parameters as any + const props = (result[0] as { function: { parameters: any } }).function + .parameters as any expect(props.properties.mode).toEqual({ enum: ['read'] }) expect(props.properties.mode.const).toBeUndefined() expect(props.properties.name).toEqual({ type: 'string' }) @@ -110,8 +118,11 @@ describe('anthropicToolsToOpenAI', () => { }, ] const result = anthropicToolsToOpenAI(tools as any) - const params = (result[0] as { function: { parameters: any } }).function.parameters as any - expect(params.properties.outer.properties.inner).toEqual({ enum: ['fixed'] }) + const params = (result[0] as { function: { parameters: any } }).function + .parameters as any + expect(params.properties.outer.properties.inner).toEqual({ + enum: ['fixed'], + }) expect(params.definitions.MyType.properties.field).toEqual({ enum: [42] }) }) @@ -125,18 +136,17 @@ describe('anthropicToolsToOpenAI', () => { type: 'object', properties: { val: { - anyOf: [ - { const: 'a' }, - { const: 'b' }, - { type: 'string' }, - ], + anyOf: [{ const: 'a' }, { const: 'b' }, { type: 'string' }], }, }, }, }, ] const result = anthropicToolsToOpenAI(tools as any) - const anyOf = ((result[0] as { function: { parameters: any } }).function.parameters as any).properties.val.anyOf + const anyOf = ( + (result[0] as { function: { parameters: any } }).function + .parameters as any + ).properties.val.anyOf expect(anyOf[0]).toEqual({ enum: ['a'] }) expect(anyOf[1]).toEqual({ enum: ['b'] }) expect(anyOf[2]).toEqual({ type: 'string' }) diff --git a/src/services/api/openai/__tests__/queryModelOpenAI.isolated.ts b/src/services/api/openai/__tests__/queryModelOpenAI.isolated.ts index 9af151c56..86ccc5d5d 100644 --- a/src/services/api/openai/__tests__/queryModelOpenAI.isolated.ts +++ b/src/services/api/openai/__tests__/queryModelOpenAI.isolated.ts @@ -15,12 +15,17 @@ */ import { describe, expect, test, mock, beforeEach, afterEach } from 'bun:test' import type { BetaRawMessageStreamEvent } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' -import type { AssistantMessage, StreamEvent } from '../../../../types/message.js' +import type { + AssistantMessage, + StreamEvent, +} from '../../../../types/message.js' // ─── helpers ───────────────────────────────────────────────────────────────── /** Build a minimal message_start event */ -function makeMessageStart(overrides: Record = {}): BetaRawMessageStreamEvent { +function makeMessageStart( + overrides: Record = {}, +): BetaRawMessageStreamEvent { return { type: 'message_start', message: { @@ -31,36 +36,67 @@ function makeMessageStart(overrides: Record = {}): BetaRawMessageSt model: 'test-model', stop_reason: null, stop_sequence: null, - usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 }, + usage: { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, ...overrides, }, } as any } /** Build a content_block_start event for the given block type */ -function makeContentBlockStart(index: number, type: 'text' | 'tool_use' | 'thinking', extra: Record = {}): BetaRawMessageStreamEvent { +function makeContentBlockStart( + index: number, + type: 'text' | 'tool_use' | 'thinking', + extra: Record = {}, +): BetaRawMessageStreamEvent { const block = type === 'text' ? { type: 'text', text: '' } : type === 'tool_use' ? { type: 'tool_use', id: 'toolu_test', name: 'bash', input: {} } : { type: 'thinking', thinking: '', signature: '' } - return { type: 'content_block_start', index, content_block: { ...block, ...extra } } as any + return { + type: 'content_block_start', + index, + content_block: { ...block, ...extra }, + } as any } /** Build a text_delta content_block_delta event */ function makeTextDelta(index: number, text: string): BetaRawMessageStreamEvent { - return { type: 'content_block_delta', index, delta: { type: 'text_delta', text } } as any + return { + type: 'content_block_delta', + index, + delta: { type: 'text_delta', text }, + } as any } /** Build an input_json_delta content_block_delta event */ -function makeInputJsonDelta(index: number, json: string): BetaRawMessageStreamEvent { - return { type: 'content_block_delta', index, delta: { type: 'input_json_delta', partial_json: json } } as any +function makeInputJsonDelta( + index: number, + json: string, +): BetaRawMessageStreamEvent { + return { + type: 'content_block_delta', + index, + delta: { type: 'input_json_delta', partial_json: json }, + } as any } /** Build a thinking_delta content_block_delta event */ -function makeThinkingDelta(index: number, thinking: string): BetaRawMessageStreamEvent { - return { type: 'content_block_delta', index, delta: { type: 'thinking_delta', thinking } } as any +function makeThinkingDelta( + index: number, + thinking: string, +): BetaRawMessageStreamEvent { + return { + type: 'content_block_delta', + index, + delta: { type: 'thinking_delta', thinking }, + } as any } /** Build a content_block_stop event */ @@ -69,7 +105,10 @@ function makeContentBlockStop(index: number): BetaRawMessageStreamEvent { } /** Build a message_delta event with stop_reason and output_tokens */ -function makeMessageDelta(stopReason: string, outputTokens: number): BetaRawMessageStreamEvent { +function makeMessageDelta( + stopReason: string, + outputTokens: number, +): BetaRawMessageStreamEvent { return { type: 'message_delta', delta: { stop_reason: stopReason, stop_sequence: null }, @@ -175,7 +214,8 @@ mock.module('../client.js', () => ({ })) mock.module('../streamAdapter.js', () => ({ - adaptOpenAIStreamToAnthropic: (_stream: any, _model: string) => eventStream(_nextEvents), + adaptOpenAIStreamToAnthropic: (_stream: any, _model: string) => + eventStream(_nextEvents), })) mock.module('../modelMapping.js', () => ({ @@ -202,7 +242,10 @@ mock.module('../../../../utils/context.js', () => ({ getModelMaxOutputTokens: () => ({ upperLimit: 8192, default: 8192 }), getContextWindowForModel: () => 200_000, getSonnet1mExpTreatmentEnabled: () => false, - calculateContextPercentages: () => ({ usedPercent: 0, remainingPercent: 100 }), + calculateContextPercentages: () => ({ + usedPercent: 0, + remainingPercent: 100, + }), getMaxThinkingTokensForModel: () => 0, })) @@ -211,7 +254,10 @@ mock.module('../../../../utils/messages.js', () => ({ normalizeContentFromAPI: (blocks: any[]) => blocks, createAssistantAPIErrorMessage: (opts: any) => ({ type: 'assistant', - message: { content: [{ type: 'text', text: opts.content }], apiError: opts.apiError }, + message: { + content: [{ type: 'text', text: opts.content }], + apiError: opts.apiError, + }, uuid: 'error-uuid', timestamp: new Date().toISOString(), }), @@ -349,7 +395,14 @@ describe('queryModelOpenAI — usage accumulation', () => { // The spread in the message_delta handler must override all zeros from message_start, // including cache_read_input_tokens which was previously missing from message_delta. _nextEvents = [ - makeMessageStart({ usage: { input_tokens: 0, output_tokens: 0, cache_creation_input_tokens: 0, cache_read_input_tokens: 0 } }), + makeMessageStart({ + usage: { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }), makeContentBlockStart(0, 'text'), makeTextDelta(0, 'response'), makeContentBlockStop(0), @@ -357,7 +410,12 @@ describe('queryModelOpenAI — usage accumulation', () => { { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, - usage: { input_tokens: 30011, output_tokens: 190, cache_read_input_tokens: 19904, cache_creation_input_tokens: 0 }, + usage: { + input_tokens: 30011, + output_tokens: 190, + cache_read_input_tokens: 19904, + cache_creation_input_tokens: 0, + }, } as any, makeMessageStop(), ] diff --git a/src/services/api/openai/__tests__/streamAdapter.test.ts b/src/services/api/openai/__tests__/streamAdapter.test.ts index db6b3015c..807e1dcda 100644 --- a/src/services/api/openai/__tests__/streamAdapter.test.ts +++ b/src/services/api/openai/__tests__/streamAdapter.test.ts @@ -10,7 +10,10 @@ import { tmpdir } from 'os' // We copy the source to a unique temp path so the import bypasses bun's // module mock cache completely. const _testDir = dirname(fileURLToPath(import.meta.url)) -const _realSource = readFileSync(join(_testDir, '..', 'streamAdapter.ts'), 'utf-8') +const _realSource = readFileSync( + join(_testDir, '..', 'streamAdapter.ts'), + 'utf-8', +) const _tempDir = join(tmpdir(), `stream-adapter-test-${Date.now()}`) mkdirSync(_tempDir, { recursive: true }) const _tempFile = join(_tempDir, 'streamAdapter.ts') @@ -18,7 +21,9 @@ writeFileSync(_tempFile, _realSource, 'utf-8') const { adaptOpenAIStreamToAnthropic } = await import(_tempFile) /** Helper to create a mock async iterable from chunk array */ -function mockStream(chunks: ChatCompletionChunk[]): AsyncIterable { +function mockStream( + chunks: ChatCompletionChunk[], +): AsyncIterable { return { [Symbol.asyncIterator]() { let i = 0 @@ -33,7 +38,9 @@ function mockStream(chunks: ChatCompletionChunk[]): AsyncIterable & any = {}): ChatCompletionChunk { +function makeChunk( + overrides: Partial & any = {}, +): ChatCompletionChunk { return { id: 'chatcmpl-test', object: 'chat.completion.chunk', @@ -52,7 +59,10 @@ async function collectEvents(chunks: ChatCompletionChunk[]) { ).href const { adaptOpenAIStreamToAnthropic } = await import(realModuleUrl) const events: any[] = [] - for await (const event of adaptOpenAIStreamToAnthropic(mockStream(chunks), 'gpt-4o')) { + for await (const event of adaptOpenAIStreamToAnthropic( + mockStream(chunks), + 'gpt-4o', + )) { events.push(event) } return events @@ -62,25 +72,31 @@ describe('adaptOpenAIStreamToAnthropic', () => { test('emits message_start on first chunk', async () => { const events = await collectEvents([ makeChunk({ - choices: [{ - index: 0, - delta: { role: 'assistant', content: '' }, - finish_reason: null, - }], + choices: [ + { + index: 0, + delta: { role: 'assistant', content: '' }, + finish_reason: null, + }, + ], }), makeChunk({ - choices: [{ - index: 0, - delta: { content: 'hello' }, - finish_reason: null, - }], + choices: [ + { + index: 0, + delta: { content: 'hello' }, + finish_reason: null, + }, + ], }), makeChunk({ - choices: [{ - index: 0, - delta: {}, - finish_reason: 'stop', - }], + choices: [ + { + index: 0, + delta: {}, + finish_reason: 'stop', + }, + ], usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, }), ]) @@ -93,10 +109,14 @@ describe('adaptOpenAIStreamToAnthropic', () => { test('converts text content stream', async () => { const events = await collectEvents([ makeChunk({ - choices: [{ index: 0, delta: { content: 'Hello' }, finish_reason: null }], + choices: [ + { index: 0, delta: { content: 'Hello' }, finish_reason: null }, + ], }), makeChunk({ - choices: [{ index: 0, delta: { content: ' world' }, finish_reason: null }], + choices: [ + { index: 0, delta: { content: ' world' }, finish_reason: null }, + ], }), makeChunk({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], @@ -111,7 +131,9 @@ describe('adaptOpenAIStreamToAnthropic', () => { expect(types).toContain('message_delta') expect(types).toContain('message_stop') - const textDeltas = events.filter(e => e.type === 'content_block_delta') as any[] + const textDeltas = events.filter( + e => e.type === 'content_block_delta', + ) as any[] expect(textDeltas[0].delta.text).toBe('Hello') expect(textDeltas[1].delta.text).toBe(' world') }) @@ -119,42 +141,54 @@ describe('adaptOpenAIStreamToAnthropic', () => { test('converts tool_calls stream', async () => { const events = await collectEvents([ makeChunk({ - choices: [{ - index: 0, - delta: { - tool_calls: [{ - index: 0, - id: 'call_abc', - type: 'function', - function: { name: 'bash', arguments: '' }, - }], + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: 'call_abc', + type: 'function', + function: { name: 'bash', arguments: '' }, + }, + ], + }, + finish_reason: null, }, - finish_reason: null, - }], + ], }), makeChunk({ - choices: [{ - index: 0, - delta: { - tool_calls: [{ - index: 0, - function: { arguments: '{"comm' }, - }], + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + function: { arguments: '{"comm' }, + }, + ], + }, + finish_reason: null, }, - finish_reason: null, - }], + ], }), makeChunk({ - choices: [{ - index: 0, - delta: { - tool_calls: [{ - index: 0, - function: { arguments: 'and":"ls"}' }, - }], + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + function: { arguments: 'and":"ls"}' }, + }, + ], + }, + finish_reason: null, }, - finish_reason: null, - }], + ], }), makeChunk({ choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], @@ -166,7 +200,8 @@ describe('adaptOpenAIStreamToAnthropic', () => { expect(blockStart.content_block.name).toBe('bash') const jsonDeltas = events.filter( - e => e.type === 'content_block_delta' && e.delta.type === 'input_json_delta', + e => + e.type === 'content_block_delta' && e.delta.type === 'input_json_delta', ) as any[] const fullArgs = jsonDeltas.map(d => d.delta.partial_json).join('') expect(fullArgs).toBe('{"command":"ls"}') @@ -191,13 +226,21 @@ describe('adaptOpenAIStreamToAnthropic', () => { // return finish_reason "stop" when they actually made tool calls. const events = await collectEvents([ makeChunk({ - choices: [{ - index: 0, - delta: { - tool_calls: [{ index: 0, id: 'call_1', function: { name: 'bash', arguments: '{"cmd":"ls"}' } }], + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: 'call_1', + function: { name: 'bash', arguments: '{"cmd":"ls"}' }, + }, + ], + }, + finish_reason: null, }, - finish_reason: null, - }], + ], }), makeChunk({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], @@ -211,13 +254,21 @@ describe('adaptOpenAIStreamToAnthropic', () => { test('maps finish_reason tool_calls to tool_use', async () => { const events = await collectEvents([ makeChunk({ - choices: [{ - index: 0, - delta: { - tool_calls: [{ index: 0, id: 'call_1', function: { name: 'bash', arguments: '{}' } }], + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: 'call_1', + function: { name: 'bash', arguments: '{}' }, + }, + ], + }, + finish_reason: null, }, - finish_reason: null, - }], + ], }), makeChunk({ choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], @@ -231,7 +282,9 @@ describe('adaptOpenAIStreamToAnthropic', () => { test('maps finish_reason length to max_tokens', async () => { const events = await collectEvents([ makeChunk({ - choices: [{ index: 0, delta: { content: 'truncated' }, finish_reason: null }], + choices: [ + { index: 0, delta: { content: 'truncated' }, finish_reason: null }, + ], }), makeChunk({ choices: [{ index: 0, delta: {}, finish_reason: 'length' }], @@ -245,23 +298,35 @@ describe('adaptOpenAIStreamToAnthropic', () => { test('handles mixed text and tool_calls', async () => { const events = await collectEvents([ makeChunk({ - choices: [{ index: 0, delta: { content: 'Thinking...' }, finish_reason: null }], + choices: [ + { index: 0, delta: { content: 'Thinking...' }, finish_reason: null }, + ], }), makeChunk({ - choices: [{ - index: 0, - delta: { - tool_calls: [{ index: 0, id: 'call_1', function: { name: 'grep', arguments: '{"p":"test"}' } }], + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: 'call_1', + function: { name: 'grep', arguments: '{"p":"test"}' }, + }, + ], + }, + finish_reason: null, }, - finish_reason: null, - }], + ], }), makeChunk({ choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], }), ]) - const blockStarts = events.filter(e => e.type === 'content_block_start') as any[] + const blockStarts = events.filter( + e => e.type === 'content_block_start', + ) as any[] expect(blockStarts.length).toBe(2) expect(blockStarts[0].content_block.type).toBe('text') expect(blockStarts[1].content_block.type).toBe('tool_use') @@ -272,18 +337,22 @@ describe('thinking support (reasoning_content)', () => { test('converts reasoning_content to thinking block', async () => { const events = await collectEvents([ makeChunk({ - choices: [{ - index: 0, - delta: { reasoning_content: 'Let me analyze this...' }, - finish_reason: null, - }], + choices: [ + { + index: 0, + delta: { reasoning_content: 'Let me analyze this...' }, + finish_reason: null, + }, + ], }), makeChunk({ - choices: [{ - index: 0, - delta: { reasoning_content: ' step by step.' }, - finish_reason: null, - }], + choices: [ + { + index: 0, + delta: { reasoning_content: ' step by step.' }, + finish_reason: null, + }, + ], }), makeChunk({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], @@ -297,7 +366,8 @@ describe('thinking support (reasoning_content)', () => { // Should have thinking_delta events const thinkingDeltas = events.filter( - e => e.type === 'content_block_delta' && e.delta.type === 'thinking_delta', + e => + e.type === 'content_block_delta' && e.delta.type === 'thinking_delta', ) as any[] expect(thinkingDeltas.length).toBe(2) expect(thinkingDeltas[0].delta.thinking).toBe('Let me analyze this...') @@ -307,18 +377,22 @@ describe('thinking support (reasoning_content)', () => { test('converts reasoning then content (DeepSeek-style)', async () => { const events = await collectEvents([ makeChunk({ - choices: [{ - index: 0, - delta: { reasoning_content: 'Thinking about the answer...' }, - finish_reason: null, - }], + choices: [ + { + index: 0, + delta: { reasoning_content: 'Thinking about the answer...' }, + finish_reason: null, + }, + ], }), makeChunk({ - choices: [{ - index: 0, - delta: { content: 'Here is my answer.' }, - finish_reason: null, - }], + choices: [ + { + index: 0, + delta: { content: 'Here is my answer.' }, + finish_reason: null, + }, + ], }), makeChunk({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], @@ -326,13 +400,17 @@ describe('thinking support (reasoning_content)', () => { ]) // Should have two content blocks: thinking + text - const blockStarts = events.filter(e => e.type === 'content_block_start') as any[] + const blockStarts = events.filter( + e => e.type === 'content_block_start', + ) as any[] expect(blockStarts.length).toBe(2) expect(blockStarts[0].content_block.type).toBe('thinking') expect(blockStarts[1].content_block.type).toBe('text') // Thinking block should be closed before text block starts - const blockStops = events.filter(e => e.type === 'content_block_stop') as any[] + const blockStops = events.filter( + e => e.type === 'content_block_stop', + ) as any[] expect(blockStops[0].index).toBe(0) // thinking block closed at index 0 expect(blockStarts[1].index).toBe(1) // text block starts at index 1 @@ -346,27 +424,39 @@ describe('thinking support (reasoning_content)', () => { test('handles reasoning then tool_calls', async () => { const events = await collectEvents([ makeChunk({ - choices: [{ - index: 0, - delta: { reasoning_content: 'I need to run a command.' }, - finish_reason: null, - }], + choices: [ + { + index: 0, + delta: { reasoning_content: 'I need to run a command.' }, + finish_reason: null, + }, + ], }), makeChunk({ - choices: [{ - index: 0, - delta: { - tool_calls: [{ index: 0, id: 'call_1', function: { name: 'bash', arguments: '{"c":"ls"}' } }], + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: 'call_1', + function: { name: 'bash', arguments: '{"c":"ls"}' }, + }, + ], + }, + finish_reason: null, }, - finish_reason: null, - }], + ], }), makeChunk({ choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], }), ]) - const blockStarts = events.filter(e => e.type === 'content_block_start') as any[] + const blockStarts = events.filter( + e => e.type === 'content_block_start', + ) as any[] expect(blockStarts.length).toBe(2) expect(blockStarts[0].content_block.type).toBe('thinking') expect(blockStarts[1].content_block.type).toBe('tool_use') @@ -375,25 +465,31 @@ describe('thinking support (reasoning_content)', () => { test('thinking block index is 0, text block index is 1', async () => { const events = await collectEvents([ makeChunk({ - choices: [{ - index: 0, - delta: { reasoning_content: 'reason' }, - finish_reason: null, - }], + choices: [ + { + index: 0, + delta: { reasoning_content: 'reason' }, + finish_reason: null, + }, + ], }), makeChunk({ - choices: [{ - index: 0, - delta: { content: 'answer' }, - finish_reason: null, - }], + choices: [ + { + index: 0, + delta: { content: 'answer' }, + finish_reason: null, + }, + ], }), makeChunk({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], }), ]) - const blockStarts = events.filter(e => e.type === 'content_block_start') as any[] + const blockStarts = events.filter( + e => e.type === 'content_block_start', + ) as any[] expect(blockStarts[0].index).toBe(0) expect(blockStarts[1].index).toBe(1) }) @@ -403,11 +499,13 @@ describe('prompt caching support', () => { test('maps cached_tokens to cache_read_input_tokens', async () => { const events = await collectEvents([ makeChunk({ - choices: [{ - index: 0, - delta: { content: 'hi' }, - finish_reason: null, - }], + choices: [ + { + index: 0, + delta: { content: 'hi' }, + finish_reason: null, + }, + ], usage: { prompt_tokens: 1000, completion_tokens: 0, @@ -483,7 +581,9 @@ describe('prompt caching support', () => { // emitted before the trailing chunk and always has input_tokens=0. const events = await collectEvents([ makeChunk({ - choices: [{ index: 0, delta: { content: 'hello' }, finish_reason: null }], + choices: [ + { index: 0, delta: { content: 'hello' }, finish_reason: null }, + ], }), // finish_reason chunk — usage not yet available makeChunk({ @@ -513,14 +613,20 @@ describe('prompt caching support', () => { // the autocompact threshold (~33k), so compaction never fires. const events = await collectEvents([ makeChunk({ - choices: [{ index: 0, delta: { content: 'answer' }, finish_reason: null }], + choices: [ + { index: 0, delta: { content: 'answer' }, finish_reason: null }, + ], }), makeChunk({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], }), makeChunk({ choices: [], - usage: { prompt_tokens: 800, completion_tokens: 200, total_tokens: 1000 }, + usage: { + prompt_tokens: 800, + completion_tokens: 200, + total_tokens: 1000, + }, }), ]) @@ -534,13 +640,21 @@ describe('prompt caching support', () => { // when the model made tool calls and usage arrives in a trailing chunk. const events = await collectEvents([ makeChunk({ - choices: [{ - index: 0, - delta: { - tool_calls: [{ index: 0, id: 'call_x', function: { name: 'bash', arguments: '{"cmd":"ls"}' } }], + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: 'call_x', + function: { name: 'bash', arguments: '{"cmd":"ls"}' }, + }, + ], + }, + finish_reason: null, }, - finish_reason: null, - }], + ], }), makeChunk({ choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }], @@ -560,9 +674,14 @@ describe('prompt caching support', () => { test('message_delta always comes before message_stop', async () => { // Verifies event ordering is preserved after deferring to post-loop emission. const events = await collectEvents([ - makeChunk({ choices: [{ index: 0, delta: { content: 'x' }, finish_reason: null }] }), + makeChunk({ + choices: [{ index: 0, delta: { content: 'x' }, finish_reason: null }], + }), makeChunk({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] }), - makeChunk({ choices: [], usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 } }), + makeChunk({ + choices: [], + usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 }, + }), ]) const types = events.map(e => e.type) @@ -581,7 +700,9 @@ describe('prompt caching support', () => { // queryModelOpenAI's spread — even though cachedTokens was captured internally. const events = await collectEvents([ makeChunk({ - choices: [{ index: 0, delta: { content: 'answer' }, finish_reason: null }], + choices: [ + { index: 0, delta: { content: 'answer' }, finish_reason: null }, + ], }), makeChunk({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], @@ -658,7 +779,9 @@ describe('prompt caching support', () => { // Some endpoints send usage in the finish_reason chunk instead of a trailing chunk. const events = await collectEvents([ makeChunk({ - choices: [{ index: 0, delta: { content: 'result' }, finish_reason: null }], + choices: [ + { index: 0, delta: { content: 'result' }, finish_reason: null }, + ], }), makeChunk({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], diff --git a/src/services/api/openai/__tests__/thinking.test.ts b/src/services/api/openai/__tests__/thinking.test.ts index 48d754bb5..a2e8dceb8 100644 --- a/src/services/api/openai/__tests__/thinking.test.ts +++ b/src/services/api/openai/__tests__/thinking.test.ts @@ -81,7 +81,9 @@ describe('isOpenAIThinkingEnabled', () => { }) test('returns true when model name is namespaced for deepseek-reasoner', () => { - expect(isOpenAIThinkingEnabled('TokenService/deepseek-reasoner')).toBe(true) + expect(isOpenAIThinkingEnabled('TokenService/deepseek-reasoner')).toBe( + true, + ) }) test('returns true when model name is "deepseek-v3.2"', () => { @@ -172,14 +174,20 @@ describe('buildOpenAIRequestBody — thinking params', () => { }) test('does NOT include thinking params when disabled', () => { - const body = buildOpenAIRequestBody({ ...baseParams, enableThinking: false }) + const body = buildOpenAIRequestBody({ + ...baseParams, + enableThinking: false, + }) expect(body.thinking).toBeUndefined() expect(body.enable_thinking).toBeUndefined() expect(body.chat_template_kwargs).toBeUndefined() }) test('always includes stream and stream_options', () => { - const body = buildOpenAIRequestBody({ ...baseParams, enableThinking: false }) + const body = buildOpenAIRequestBody({ + ...baseParams, + enableThinking: false, + }) expect(body.stream).toBe(true) expect(body.stream_options).toEqual({ include_usage: true }) }) @@ -203,7 +211,10 @@ describe('buildOpenAIRequestBody — thinking params', () => { }) test('excludes temperature when thinking is off and no override', () => { - const body = buildOpenAIRequestBody({ ...baseParams, enableThinking: false }) + const body = buildOpenAIRequestBody({ + ...baseParams, + enableThinking: false, + }) expect(body.temperature).toBeUndefined() }) @@ -219,8 +230,11 @@ describe('buildOpenAIRequestBody — thinking params', () => { }) test('excludes tools when empty', () => { - const body = buildOpenAIRequestBody({ ...baseParams, enableThinking: false }) + const body = buildOpenAIRequestBody({ + ...baseParams, + enableThinking: false, + }) expect(body.tools).toBeUndefined() expect(body.tool_choice).toBeUndefined() }) -}) \ No newline at end of file +}) diff --git a/src/services/api/openai/client.ts b/src/services/api/openai/client.ts index 62a37dfbc..1d262d43e 100644 --- a/src/services/api/openai/client.ts +++ b/src/services/api/openai/client.ts @@ -29,8 +29,12 @@ export function getOpenAIClient(options?: { maxRetries: options?.maxRetries ?? 0, timeout: parseInt(process.env.API_TIMEOUT_MS || String(600 * 1000), 10), dangerouslyAllowBrowser: true, - ...(process.env.OPENAI_ORG_ID && { organization: process.env.OPENAI_ORG_ID }), - ...(process.env.OPENAI_PROJECT_ID && { project: process.env.OPENAI_PROJECT_ID }), + ...(process.env.OPENAI_ORG_ID && { + organization: process.env.OPENAI_ORG_ID, + }), + ...(process.env.OPENAI_PROJECT_ID && { + project: process.env.OPENAI_PROJECT_ID, + }), fetchOptions: getProxyFetchOptions({ forAnthropicAPI: false }), ...(options?.fetchOverride && { fetch: options.fetchOverride }), }) diff --git a/src/services/api/openai/convertMessages.ts b/src/services/api/openai/convertMessages.ts index b525874ae..413bd4fd1 100644 --- a/src/services/api/openai/convertMessages.ts +++ b/src/services/api/openai/convertMessages.ts @@ -62,16 +62,18 @@ export function anthropicMessagesToOpenAI( // A user message starts a new turn if it contains any non-tool_result content // (text, image, or other media). Tool results alone do NOT start a new turn // because they are continuations of the previous assistant tool call. - const startsNewUserTurn = typeof content === 'string' - ? content.length > 0 - : Array.isArray(content) && content.some( - (b: any) => - typeof b === 'string' || - (b && - typeof b === 'object' && - 'type' in b && - b.type !== 'tool_result'), - ) + const startsNewUserTurn = + typeof content === 'string' + ? content.length > 0 + : Array.isArray(content) && + content.some( + (b: any) => + typeof b === 'string' || + (b && + typeof b === 'object' && + 'type' in b && + b.type !== 'tool_result'), + ) if (startsNewUserTurn) { turnBoundaries.add(i) } @@ -88,7 +90,8 @@ export function anthropicMessagesToOpenAI( case 'assistant': // Preserve reasoning_content unless we're before a turn boundary // (i.e., from a previous user Q&A round) - const preserveReasoning = enableThinking && !isBeforeAnyTurnBoundary(i, turnBoundaries) + const preserveReasoning = + enableThinking && !isBeforeAnyTurnBoundary(i, turnBoundaries) result.push(...convertInternalAssistantMessage(msg, preserveReasoning)) break default: @@ -101,9 +104,7 @@ export function anthropicMessagesToOpenAI( function systemPromptToText(systemPrompt: SystemPrompt): string { if (!systemPrompt || systemPrompt.length === 0) return '' - return systemPrompt - .filter(Boolean) - .join('\n\n') + return systemPrompt.filter(Boolean).join('\n\n') } /** @@ -131,7 +132,8 @@ function convertInternalUserMessage( } else if (Array.isArray(content)) { const textParts: string[] = [] const toolResults: BetaToolResultBlockParam[] = [] - const imageParts: Array<{ type: 'image_url'; image_url: { url: string } }> = [] + const imageParts: Array<{ type: 'image_url'; image_url: { url: string } }> = + [] for (const block of content) { if (typeof block === 'string') { @@ -141,7 +143,9 @@ function convertInternalUserMessage( } else if (block.type === 'tool_result') { toolResults.push(block as BetaToolResultBlockParam) } else if (block.type === 'image') { - const imagePart = convertImageBlockToOpenAI(block as unknown as Record) + const imagePart = convertImageBlockToOpenAI( + block as unknown as Record, + ) if (imagePart) { imageParts.push(imagePart) } @@ -159,7 +163,10 @@ function convertInternalUserMessage( // 如果有图片,构建多模态 content 数组 if (imageParts.length > 0) { - const multiContent: Array<{ type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string } }> = [] + const multiContent: Array< + | { type: 'text'; text: string } + | { type: 'image_url'; image_url: { url: string } } + > = [] if (textParts.length > 0) { multiContent.push({ type: 'text', text: textParts.join('\n') }) } @@ -230,7 +237,9 @@ function convertInternalAssistantMessage( } const textParts: string[] = [] - const toolCalls: NonNullable = [] + const toolCalls: NonNullable< + ChatCompletionAssistantMessageParam['tool_calls'] + > = [] const reasoningParts: string[] = [] for (const block of content) { @@ -251,7 +260,8 @@ function convertInternalAssistantMessage( }) } else if (block.type === 'thinking' && preserveReasoning) { // DeepSeek thinking mode: preserve reasoning_content for tool call iterations - const thinkingText = (block as unknown as Record).thinking + const thinkingText = (block as unknown as Record) + .thinking if (typeof thinkingText === 'string' && thinkingText) { reasoningParts.push(thinkingText) } @@ -263,7 +273,9 @@ function convertInternalAssistantMessage( role: 'assistant', content: textParts.length > 0 ? textParts.join('\n') : null, ...(toolCalls.length > 0 && { tool_calls: toolCalls }), - ...(reasoningParts.length > 0 && { reasoning_content: reasoningParts.join('\n') }), + ...(reasoningParts.length > 0 && { + reasoning_content: reasoningParts.join('\n'), + }), } return [result] diff --git a/src/services/api/openai/convertTools.ts b/src/services/api/openai/convertTools.ts index bace8208b..fbf53009d 100644 --- a/src/services/api/openai/convertTools.ts +++ b/src/services/api/openai/convertTools.ts @@ -16,21 +16,27 @@ export function anthropicToolsToOpenAI( .filter(tool => { // Only convert standard tools (skip server tools like computer_use, etc.) const toolType = (tool as unknown as { type?: string }).type - return tool.type === 'custom' || !('type' in tool) || toolType !== 'server' + return ( + tool.type === 'custom' || !('type' in tool) || toolType !== 'server' + ) }) .map(tool => { // Handle the various tool shapes from Anthropic SDK const anyTool = tool as unknown as Record const name = (anyTool.name as string) || '' const description = (anyTool.description as string) || '' - const inputSchema = anyTool.input_schema as Record | undefined + const inputSchema = anyTool.input_schema as + | Record + | undefined return { type: 'function' as const, function: { name, description, - parameters: sanitizeJsonSchema(inputSchema || { type: 'object', properties: {} }), + parameters: sanitizeJsonSchema( + inputSchema || { type: 'object', properties: {} }, + ), }, } satisfies ChatCompletionTool }) @@ -43,7 +49,9 @@ export function anthropicToolsToOpenAI( * support the `const` keyword in JSON Schema. Convert it to `enum` with a * single-element array, which is semantically equivalent. */ -function sanitizeJsonSchema(schema: Record): Record { +function sanitizeJsonSchema( + schema: Record, +): Record { if (!schema || typeof schema !== 'object') return schema const result = { ...schema } @@ -55,20 +63,37 @@ function sanitizeJsonSchema(schema: Record): Record = {} for (const [k, v] of Object.entries(nested as Record)) { - sanitized[k] = v && typeof v === 'object' ? sanitizeJsonSchema(v as Record) : v + sanitized[k] = + v && typeof v === 'object' + ? sanitizeJsonSchema(v as Record) + : v } result[key] = sanitized } } // Recursively process single-schema keys - const singleKeys = ['items', 'additionalProperties', 'not', 'if', 'then', 'else', 'contains', 'propertyNames'] as const + const singleKeys = [ + 'items', + 'additionalProperties', + 'not', + 'if', + 'then', + 'else', + 'contains', + 'propertyNames', + ] as const for (const key of singleKeys) { const nested = result[key] if (nested && typeof nested === 'object' && !Array.isArray(nested)) { @@ -82,7 +107,9 @@ function sanitizeJsonSchema(schema: Record): Record - item && typeof item === 'object' ? sanitizeJsonSchema(item as Record) : item + item && typeof item === 'object' + ? sanitizeJsonSchema(item as Record) + : item, ) } } diff --git a/src/services/api/openai/index.ts b/src/services/api/openai/index.ts index 040907006..f10efeac5 100644 --- a/src/services/api/openai/index.ts +++ b/src/services/api/openai/index.ts @@ -68,7 +68,10 @@ export function isOpenAIThinkingEnabled(model: string): boolean { if (isEnvTruthy(process.env.OPENAI_ENABLE_THINKING)) return true // Auto-detect from model name (deepseek-reasoner and DeepSeek-V3.2 support thinking mode) const modelLower = model.toLowerCase() - return modelLower.includes('deepseek-reasoner') || modelLower.includes('deepseek-v3.2') + return ( + modelLower.includes('deepseek-reasoner') || + modelLower.includes('deepseek-v3.2') + ) } /** @@ -96,7 +99,15 @@ export function buildOpenAIRequestBody(params: { enable_thinking?: boolean chat_template_kwargs?: { thinking: boolean } } { - const { model, messages, tools, toolChoice, enableThinking, maxTokens, temperatureOverride } = params + const { + model, + messages, + tools, + toolChoice, + enableThinking, + maxTokens, + temperatureOverride, + } = params return { model, messages, @@ -118,9 +129,10 @@ export function buildOpenAIRequestBody(params: { }), // Only send temperature when thinking mode is off (DeepSeek ignores it anyway, // but other providers may respect it) - ...(!enableThinking && temperatureOverride !== undefined && { - temperature: temperatureOverride, - }), + ...(!enableThinking && + temperatureOverride !== undefined && { + temperature: temperatureOverride, + }), } } @@ -134,11 +146,24 @@ function assembleFinalAssistantOutputs(params: { contentBlocks: Record tools: Tools agentId: string | undefined - usage: { input_tokens: number; output_tokens: number; cache_creation_input_tokens: number; cache_read_input_tokens: number } + usage: { + input_tokens: number + output_tokens: number + cache_creation_input_tokens: number + cache_read_input_tokens: number + } stopReason: string | null maxTokens: number }): (AssistantMessage | SystemAPIErrorMessage)[] { - const { partialMessage, contentBlocks, tools, agentId, usage, stopReason, maxTokens } = params + const { + partialMessage, + contentBlocks, + tools, + agentId, + usage, + stopReason, + maxTokens, + } = params const outputs: (AssistantMessage | SystemAPIErrorMessage)[] = [] const allBlocks = Object.keys(contentBlocks) @@ -150,7 +175,11 @@ function assembleFinalAssistantOutputs(params: { outputs.push({ message: { ...partialMessage, - content: normalizeContentFromAPI(allBlocks, tools, agentId as AgentId | undefined), + content: normalizeContentFromAPI( + allBlocks, + tools, + agentId as AgentId | undefined, + ), usage, stop_reason: stopReason, stop_sequence: null, @@ -163,12 +192,15 @@ function assembleFinalAssistantOutputs(params: { } if (stopReason === 'max_tokens') { - outputs.push(createAssistantAPIErrorMessage({ - content: `Output truncated: response exceeded the ${maxTokens} token limit. ` + - `Set CLAUDE_CODE_MAX_OUTPUT_TOKENS to override.`, - apiError: 'max_output_tokens', - error: 'max_output_tokens', - })) + outputs.push( + createAssistantAPIErrorMessage({ + content: + `Output truncated: response exceeded the ${maxTokens} token limit. ` + + `Set CLAUDE_CODE_MAX_OUTPUT_TOKENS to override.`, + apiError: 'max_output_tokens', + error: 'max_output_tokens', + }), + ) } return outputs @@ -256,9 +288,13 @@ export async function* queryModelOpenAI( // 8. Convert messages and tools to OpenAI format const enableThinking = isOpenAIThinkingEnabled(openaiModel) - const openaiMessages = anthropicMessagesToOpenAI(messagesForAPI, systemPrompt, { - enableThinking, - }) + const openaiMessages = anthropicMessagesToOpenAI( + messagesForAPI, + systemPrompt, + { + enableThinking, + }, + ) const openaiTools = anthropicToolsToOpenAI(standardTools) const openaiToolChoice = anthropicToolChoiceToOpenAI(options.toolChoice) @@ -310,10 +346,7 @@ export async function* queryModelOpenAI( maxTokens, temperatureOverride: options.temperatureOverride, }) - const stream = await client.chat.completions.create( - requestBody, - { signal }, - ) + const stream = await client.chat.completions.create(requestBody, { signal }) // 12. Convert OpenAI stream to Anthropic events, then process into // AssistantMessage + StreamEvent (matching the Anthropic path behavior) @@ -395,8 +428,13 @@ export async function* queryModelOpenAI( // here and injected so tokenCountWithEstimation() can read it. if (partialMessage) { for (const output of assembleFinalAssistantOutputs({ - partialMessage, contentBlocks, tools, agentId: options.agentId, - usage, stopReason, maxTokens, + partialMessage, + contentBlocks, + tools, + agentId: options.agentId, + usage, + stopReason, + maxTokens, })) { yield output } @@ -424,8 +462,13 @@ export async function* queryModelOpenAI( // Safety: if stream ended without message_stop, assemble and yield whatever we have if (partialMessage) { for (const output of assembleFinalAssistantOutputs({ - partialMessage, contentBlocks, tools, agentId: options.agentId, - usage, stopReason, maxTokens, + partialMessage, + contentBlocks, + tools, + agentId: options.agentId, + usage, + stopReason, + maxTokens, })) { yield output } @@ -436,7 +479,9 @@ export async function* queryModelOpenAI( yield createAssistantAPIErrorMessage({ content: `API Error: ${errorMessage}`, apiError: 'api_error', - error: (error instanceof Error ? error : new Error(String(error))) as unknown as SDKAssistantMessageError, + error: (error instanceof Error + ? error + : new Error(String(error))) as unknown as SDKAssistantMessageError, }) } } diff --git a/src/services/api/openai/streamAdapter.ts b/src/services/api/openai/streamAdapter.ts index 70f7161ff..51c8068ec 100644 --- a/src/services/api/openai/streamAdapter.ts +++ b/src/services/api/openai/streamAdapter.ts @@ -42,7 +42,10 @@ export async function* adaptOpenAIStreamToAnthropic( let currentContentIndex = -1 // Track tool_use blocks: tool_calls index → { contentIndex, id, name, arguments } - const toolBlocks = new Map() + const toolBlocks = new Map< + number, + { contentIndex: number; id: string; name: string; arguments: string } + >() // Track thinking block state let thinkingBlockOpen = false @@ -212,7 +215,8 @@ export async function* adaptOpenAIStreamToAnthropic( // Start new tool_use block currentContentIndex++ - const toolId = tc.id || `toolu_${randomUUID().replace(/-/g, '').slice(0, 24)}` + const toolId = + tc.id || `toolu_${randomUUID().replace(/-/g, '').slice(0, 24)}` const toolName = tc.function?.name || '' toolBlocks.set(tcIndex, { diff --git a/src/services/api/promptCacheBreakDetection.ts b/src/services/api/promptCacheBreakDetection.ts index 06307214f..e6f2410bc 100644 --- a/src/services/api/promptCacheBreakDetection.ts +++ b/src/services/api/promptCacheBreakDetection.ts @@ -459,7 +459,8 @@ export async function checkResponseForCacheBreak( // assistant message timestamp in the messages array (before the current response) const lastAssistantMessage = messages.findLast(m => m.type === 'assistant') const timeSinceLastAssistantMsg = lastAssistantMessage - ? Date.now() - new Date(lastAssistantMessage.timestamp as string | number).getTime() + ? Date.now() - + new Date(lastAssistantMessage.timestamp as string | number).getTime() : null // Skip the first call — no previous value to compare against diff --git a/src/services/api/sessionIngress.ts b/src/services/api/sessionIngress.ts index ae24a56cf..ed0576bb7 100644 --- a/src/services/api/sessionIngress.ts +++ b/src/services/api/sessionIngress.ts @@ -175,7 +175,7 @@ async function appendSessionLogImpl( return false } - const delayMs = Math.min(BASE_DELAY_MS * Math.pow(2, attempt - 1), 8000) + const delayMs = Math.min(BASE_DELAY_MS * 2 ** (attempt - 1), 8000) logForDebugging( `Remote persistence attempt ${attempt}/${MAX_RETRIES} failed, retrying in ${delayMs}ms…`, ) diff --git a/src/services/api/src/Tool.ts b/src/services/api/src/Tool.ts index 63577b373..d6cf5b985 100644 --- a/src/services/api/src/Tool.ts +++ b/src/services/api/src/Tool.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type QueryChainTracking = any; +export type QueryChainTracking = any diff --git a/src/services/api/src/bootstrap/state.ts b/src/services/api/src/bootstrap/state.ts index 24331fe0d..03c909720 100644 --- a/src/services/api/src/bootstrap/state.ts +++ b/src/services/api/src/bootstrap/state.ts @@ -1,22 +1,22 @@ // Auto-generated type stub — replace with real implementation -export type getSessionId = any; -export type getAfkModeHeaderLatched = any; -export type getCacheEditingHeaderLatched = any; -export type getFastModeHeaderLatched = any; -export type getLastApiCompletionTimestamp = any; -export type getPromptCache1hAllowlist = any; -export type getPromptCache1hEligible = any; -export type getThinkingClearLatched = any; -export type setAfkModeHeaderLatched = any; -export type setCacheEditingHeaderLatched = any; -export type setFastModeHeaderLatched = any; -export type setLastMainRequestId = any; -export type setPromptCache1hAllowlist = any; -export type setPromptCache1hEligible = any; -export type setThinkingClearLatched = any; -export type addToTotalDurationState = any; -export type consumePostCompaction = any; -export type getIsNonInteractiveSession = any; -export type getTeleportedSessionInfo = any; -export type markFirstTeleportMessageLogged = any; -export type setLastApiCompletionTimestamp = any; +export type getSessionId = any +export type getAfkModeHeaderLatched = any +export type getCacheEditingHeaderLatched = any +export type getFastModeHeaderLatched = any +export type getLastApiCompletionTimestamp = any +export type getPromptCache1hAllowlist = any +export type getPromptCache1hEligible = any +export type getThinkingClearLatched = any +export type setAfkModeHeaderLatched = any +export type setCacheEditingHeaderLatched = any +export type setFastModeHeaderLatched = any +export type setLastMainRequestId = any +export type setPromptCache1hAllowlist = any +export type setPromptCache1hEligible = any +export type setThinkingClearLatched = any +export type addToTotalDurationState = any +export type consumePostCompaction = any +export type getIsNonInteractiveSession = any +export type getTeleportedSessionInfo = any +export type markFirstTeleportMessageLogged = any +export type setLastApiCompletionTimestamp = any diff --git a/src/services/api/src/constants/betas.ts b/src/services/api/src/constants/betas.ts index fd08b7176..5f9bd089b 100644 --- a/src/services/api/src/constants/betas.ts +++ b/src/services/api/src/constants/betas.ts @@ -1,10 +1,10 @@ // Auto-generated type stub — replace with real implementation -export type AFK_MODE_BETA_HEADER = any; -export type CONTEXT_1M_BETA_HEADER = any; -export type CONTEXT_MANAGEMENT_BETA_HEADER = any; -export type EFFORT_BETA_HEADER = any; -export type FAST_MODE_BETA_HEADER = any; -export type PROMPT_CACHING_SCOPE_BETA_HEADER = any; -export type REDACT_THINKING_BETA_HEADER = any; -export type STRUCTURED_OUTPUTS_BETA_HEADER = any; -export type TASK_BUDGETS_BETA_HEADER = any; +export type AFK_MODE_BETA_HEADER = any +export type CONTEXT_1M_BETA_HEADER = any +export type CONTEXT_MANAGEMENT_BETA_HEADER = any +export type EFFORT_BETA_HEADER = any +export type FAST_MODE_BETA_HEADER = any +export type PROMPT_CACHING_SCOPE_BETA_HEADER = any +export type REDACT_THINKING_BETA_HEADER = any +export type STRUCTURED_OUTPUTS_BETA_HEADER = any +export type TASK_BUDGETS_BETA_HEADER = any diff --git a/src/services/api/src/constants/querySource.ts b/src/services/api/src/constants/querySource.ts index 61a17200d..09b2d0921 100644 --- a/src/services/api/src/constants/querySource.ts +++ b/src/services/api/src/constants/querySource.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type QuerySource = any; +export type QuerySource = any diff --git a/src/services/api/src/context/notifications.ts b/src/services/api/src/context/notifications.ts index c212e68b7..22164fdb3 100644 --- a/src/services/api/src/context/notifications.ts +++ b/src/services/api/src/context/notifications.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type Notification = any; +export type Notification = any diff --git a/src/services/api/src/cost-tracker.ts b/src/services/api/src/cost-tracker.ts index 3f76a9113..dea78c7af 100644 --- a/src/services/api/src/cost-tracker.ts +++ b/src/services/api/src/cost-tracker.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type addToTotalSessionCost = any; +export type addToTotalSessionCost = any diff --git a/src/services/api/src/entrypoints/agentSdkTypes.ts b/src/services/api/src/entrypoints/agentSdkTypes.ts index 0a85ba1ac..2c31d79aa 100644 --- a/src/services/api/src/entrypoints/agentSdkTypes.ts +++ b/src/services/api/src/entrypoints/agentSdkTypes.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type SDKAssistantMessageError = any; +export type SDKAssistantMessageError = any diff --git a/src/services/api/src/services/analytics/growthbook.ts b/src/services/api/src/services/analytics/growthbook.ts index e380906ea..7967fd3ee 100644 --- a/src/services/api/src/services/analytics/growthbook.ts +++ b/src/services/api/src/services/analytics/growthbook.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getFeatureValue_CACHED_MAY_BE_STALE = any; +export type getFeatureValue_CACHED_MAY_BE_STALE = any diff --git a/src/services/api/src/services/analytics/index.ts b/src/services/api/src/services/analytics/index.ts index 142e7b6f5..2e0f796da 100644 --- a/src/services/api/src/services/analytics/index.ts +++ b/src/services/api/src/services/analytics/index.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any; -export type logEvent = any; +export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any +export type logEvent = any diff --git a/src/services/api/src/types/connectorText.ts b/src/services/api/src/types/connectorText.ts index 6af50eb27..ff7f27861 100644 --- a/src/services/api/src/types/connectorText.ts +++ b/src/services/api/src/types/connectorText.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type isConnectorTextBlock = (block: unknown) => boolean; +export type isConnectorTextBlock = (block: unknown) => boolean diff --git a/src/services/api/src/types/ids.ts b/src/services/api/src/types/ids.ts index c8c60ebe5..93fc5f899 100644 --- a/src/services/api/src/types/ids.ts +++ b/src/services/api/src/types/ids.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type AgentId = any; +export type AgentId = any diff --git a/src/services/api/src/types/message.ts b/src/services/api/src/types/message.ts index c420ee31b..e46a6f93d 100644 --- a/src/services/api/src/types/message.ts +++ b/src/services/api/src/types/message.ts @@ -1,5 +1,5 @@ // Auto-generated type stub — replace with real implementation -export type Message = any; -export type AssistantMessage = any; -export type UserMessage = any; -export type SystemAPIErrorMessage = any; +export type Message = any +export type AssistantMessage = any +export type UserMessage = any +export type SystemAPIErrorMessage = any diff --git a/src/services/api/src/utils/advisor.ts b/src/services/api/src/utils/advisor.ts index 57cbac38c..963080e31 100644 --- a/src/services/api/src/utils/advisor.ts +++ b/src/services/api/src/utils/advisor.ts @@ -1,6 +1,6 @@ // Auto-generated type stub — replace with real implementation -export type ADVISOR_TOOL_INSTRUCTIONS = any; -export type getExperimentAdvisorModels = any; -export type isAdvisorEnabled = any; -export type isValidAdvisorModel = any; -export type modelSupportsAdvisor = any; +export type ADVISOR_TOOL_INSTRUCTIONS = any +export type getExperimentAdvisorModels = any +export type isAdvisorEnabled = any +export type isValidAdvisorModel = any +export type modelSupportsAdvisor = any diff --git a/src/services/api/src/utils/agentContext.ts b/src/services/api/src/utils/agentContext.ts index 92f1d4946..7625c79ba 100644 --- a/src/services/api/src/utils/agentContext.ts +++ b/src/services/api/src/utils/agentContext.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getAgentContext = any; +export type getAgentContext = any diff --git a/src/services/api/src/utils/auth.ts b/src/services/api/src/utils/auth.ts index ee66093d8..2d75731d2 100644 --- a/src/services/api/src/utils/auth.ts +++ b/src/services/api/src/utils/auth.ts @@ -1,12 +1,12 @@ // Auto-generated type stub — replace with real implementation -export type getAnthropicApiKeyWithSource = any; -export type getClaudeAIOAuthTokens = any; -export type getOauthAccountInfo = any; -export type isClaudeAISubscriber = any; -export type checkAndRefreshOAuthTokenIfNeeded = any; -export type getAnthropicApiKey = any; -export type getApiKeyFromApiKeyHelper = any; -export type refreshAndGetAwsCredentials = any; -export type refreshGcpCredentialsIfNeeded = any; -export type isConsumerSubscriber = any; -export type hasProfileScope = any; +export type getAnthropicApiKeyWithSource = any +export type getClaudeAIOAuthTokens = any +export type getOauthAccountInfo = any +export type isClaudeAISubscriber = any +export type checkAndRefreshOAuthTokenIfNeeded = any +export type getAnthropicApiKey = any +export type getApiKeyFromApiKeyHelper = any +export type refreshAndGetAwsCredentials = any +export type refreshGcpCredentialsIfNeeded = any +export type isConsumerSubscriber = any +export type hasProfileScope = any diff --git a/src/services/api/src/utils/aws.ts b/src/services/api/src/utils/aws.ts index 1929aaf98..43956e4fa 100644 --- a/src/services/api/src/utils/aws.ts +++ b/src/services/api/src/utils/aws.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type isAwsCredentialsProviderError = any; +export type isAwsCredentialsProviderError = any diff --git a/src/services/api/src/utils/betas.ts b/src/services/api/src/utils/betas.ts index 20d09935e..26dcfc68e 100644 --- a/src/services/api/src/utils/betas.ts +++ b/src/services/api/src/utils/betas.ts @@ -1,5 +1,5 @@ // Auto-generated type stub — replace with real implementation -export type getToolSearchBetaHeader = any; -export type modelSupportsStructuredOutputs = any; -export type shouldIncludeFirstPartyOnlyBetas = any; -export type shouldUseGlobalCacheScope = any; +export type getToolSearchBetaHeader = any +export type modelSupportsStructuredOutputs = any +export type shouldIncludeFirstPartyOnlyBetas = any +export type shouldUseGlobalCacheScope = any diff --git a/src/services/api/src/utils/claudeInChrome/common.ts b/src/services/api/src/utils/claudeInChrome/common.ts index 26f787dab..c31c3dc82 100644 --- a/src/services/api/src/utils/claudeInChrome/common.ts +++ b/src/services/api/src/utils/claudeInChrome/common.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type CLAUDE_IN_CHROME_MCP_SERVER_NAME = any; +export type CLAUDE_IN_CHROME_MCP_SERVER_NAME = any diff --git a/src/services/api/src/utils/claudeInChrome/prompt.ts b/src/services/api/src/utils/claudeInChrome/prompt.ts index 5029b1cc6..fdd67785b 100644 --- a/src/services/api/src/utils/claudeInChrome/prompt.ts +++ b/src/services/api/src/utils/claudeInChrome/prompt.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type CHROME_TOOL_SEARCH_INSTRUCTIONS = any; +export type CHROME_TOOL_SEARCH_INSTRUCTIONS = any diff --git a/src/services/api/src/utils/context.ts b/src/services/api/src/utils/context.ts index 3840f431a..589418478 100644 --- a/src/services/api/src/utils/context.ts +++ b/src/services/api/src/utils/context.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getMaxThinkingTokensForModel = any; +export type getMaxThinkingTokensForModel = any diff --git a/src/services/api/src/utils/debug.ts b/src/services/api/src/utils/debug.ts index c64d5960c..5a0f1d515 100644 --- a/src/services/api/src/utils/debug.ts +++ b/src/services/api/src/utils/debug.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type logForDebugging = any; +export type logForDebugging = any diff --git a/src/services/api/src/utils/diagLogs.ts b/src/services/api/src/utils/diagLogs.ts index b016581ff..c9614ae3b 100644 --- a/src/services/api/src/utils/diagLogs.ts +++ b/src/services/api/src/utils/diagLogs.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type logForDiagnosticsNoPII = any; +export type logForDiagnosticsNoPII = any diff --git a/src/services/api/src/utils/effort.ts b/src/services/api/src/utils/effort.ts index c3acecb56..085af3cd9 100644 --- a/src/services/api/src/utils/effort.ts +++ b/src/services/api/src/utils/effort.ts @@ -1,4 +1,4 @@ // Auto-generated type stub — replace with real implementation -export type EffortValue = 'low' | 'medium' | 'high' | 'max' | number; -export type modelSupportsEffort = (model: string) => boolean; -export type EffortLevel = 'low' | 'medium' | 'high' | 'max'; +export type EffortValue = 'low' | 'medium' | 'high' | 'max' | number +export type modelSupportsEffort = (model: string) => boolean +export type EffortLevel = 'low' | 'medium' | 'high' | 'max' diff --git a/src/services/api/src/utils/fastMode.ts b/src/services/api/src/utils/fastMode.ts index 1228a5815..07576ac89 100644 --- a/src/services/api/src/utils/fastMode.ts +++ b/src/services/api/src/utils/fastMode.ts @@ -1,5 +1,5 @@ // Auto-generated type stub — replace with real implementation -export type isFastModeAvailable = any; -export type isFastModeCooldown = any; -export type isFastModeEnabled = any; -export type isFastModeSupportedByModel = any; +export type isFastModeAvailable = any +export type isFastModeCooldown = any +export type isFastModeEnabled = any +export type isFastModeSupportedByModel = any diff --git a/src/services/api/src/utils/generators.ts b/src/services/api/src/utils/generators.ts index 5a7707488..8ff432161 100644 --- a/src/services/api/src/utils/generators.ts +++ b/src/services/api/src/utils/generators.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type returnValue = any; +export type returnValue = any diff --git a/src/services/api/src/utils/gracefulShutdown.ts b/src/services/api/src/utils/gracefulShutdown.ts index 28329ba76..e6caf3522 100644 --- a/src/services/api/src/utils/gracefulShutdown.ts +++ b/src/services/api/src/utils/gracefulShutdown.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type gracefulShutdown = any; +export type gracefulShutdown = any diff --git a/src/services/api/src/utils/hash.ts b/src/services/api/src/utils/hash.ts index 6aaf94a67..387aa74a0 100644 --- a/src/services/api/src/utils/hash.ts +++ b/src/services/api/src/utils/hash.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type djb2Hash = any; +export type djb2Hash = any diff --git a/src/services/api/src/utils/headlessProfiler.ts b/src/services/api/src/utils/headlessProfiler.ts index 123f78a48..c16a0e523 100644 --- a/src/services/api/src/utils/headlessProfiler.ts +++ b/src/services/api/src/utils/headlessProfiler.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type headlessProfilerCheckpoint = any; +export type headlessProfilerCheckpoint = any diff --git a/src/services/api/src/utils/http.ts b/src/services/api/src/utils/http.ts index 22100b2eb..f5f93c618 100644 --- a/src/services/api/src/utils/http.ts +++ b/src/services/api/src/utils/http.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getUserAgent = any; +export type getUserAgent = any diff --git a/src/services/api/src/utils/log.ts b/src/services/api/src/utils/log.ts index cf30e90da..30df9cbcb 100644 --- a/src/services/api/src/utils/log.ts +++ b/src/services/api/src/utils/log.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type logError = any; +export type logError = any diff --git a/src/services/api/src/utils/mcpInstructionsDelta.ts b/src/services/api/src/utils/mcpInstructionsDelta.ts index 5da03fccc..7b4c4070d 100644 --- a/src/services/api/src/utils/mcpInstructionsDelta.ts +++ b/src/services/api/src/utils/mcpInstructionsDelta.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type isMcpInstructionsDeltaEnabled = any; +export type isMcpInstructionsDeltaEnabled = any diff --git a/src/services/api/src/utils/messages.ts b/src/services/api/src/utils/messages.ts index 27c84668c..09fbc7dd9 100644 --- a/src/services/api/src/utils/messages.ts +++ b/src/services/api/src/utils/messages.ts @@ -1,4 +1,4 @@ // Auto-generated type stub — replace with real implementation -export type createAssistantAPIErrorMessage = any; -export type NO_RESPONSE_REQUESTED = any; -export type createSystemAPIErrorMessage = any; +export type createAssistantAPIErrorMessage = any +export type NO_RESPONSE_REQUESTED = any +export type createSystemAPIErrorMessage = any diff --git a/src/services/api/src/utils/model/model.ts b/src/services/api/src/utils/model/model.ts index 401fe0248..2974ce161 100644 --- a/src/services/api/src/utils/model/model.ts +++ b/src/services/api/src/utils/model/model.ts @@ -1,4 +1,4 @@ // Auto-generated type stub — replace with real implementation -export type getDefaultMainLoopModelSetting = any; -export type isNonCustomOpusModel = any; -export type getSmallFastModel = any; +export type getDefaultMainLoopModelSetting = any +export type isNonCustomOpusModel = any +export type getSmallFastModel = any diff --git a/src/services/api/src/utils/model/modelStrings.ts b/src/services/api/src/utils/model/modelStrings.ts index 1265ece0e..a58abdf93 100644 --- a/src/services/api/src/utils/model/modelStrings.ts +++ b/src/services/api/src/utils/model/modelStrings.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getModelStrings = any; +export type getModelStrings = any diff --git a/src/services/api/src/utils/model/providers.ts b/src/services/api/src/utils/model/providers.ts index f8e248dfa..0d6d4841a 100644 --- a/src/services/api/src/utils/model/providers.ts +++ b/src/services/api/src/utils/model/providers.ts @@ -1,4 +1,4 @@ // Auto-generated type stub — replace with real implementation -export type getAPIProvider = any; -export type getAPIProviderForStatsig = any; -export type isFirstPartyAnthropicBaseUrl = any; +export type getAPIProvider = any +export type getAPIProviderForStatsig = any +export type isFirstPartyAnthropicBaseUrl = any diff --git a/src/services/api/src/utils/modelCost.ts b/src/services/api/src/utils/modelCost.ts index a37f5df38..dffb6f217 100644 --- a/src/services/api/src/utils/modelCost.ts +++ b/src/services/api/src/utils/modelCost.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type calculateUSDCost = any; +export type calculateUSDCost = any diff --git a/src/services/api/src/utils/permissions/PermissionMode.ts b/src/services/api/src/utils/permissions/PermissionMode.ts index 1bc6199f9..799935c26 100644 --- a/src/services/api/src/utils/permissions/PermissionMode.ts +++ b/src/services/api/src/utils/permissions/PermissionMode.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type PermissionMode = any; +export type PermissionMode = any diff --git a/src/services/api/src/utils/permissions/filesystem.ts b/src/services/api/src/utils/permissions/filesystem.ts index ef39cc7f8..43512f2ec 100644 --- a/src/services/api/src/utils/permissions/filesystem.ts +++ b/src/services/api/src/utils/permissions/filesystem.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getClaudeTempDir = any; +export type getClaudeTempDir = any diff --git a/src/services/api/src/utils/privacyLevel.ts b/src/services/api/src/utils/privacyLevel.ts index 9d1c3506f..cd0906bc0 100644 --- a/src/services/api/src/utils/privacyLevel.ts +++ b/src/services/api/src/utils/privacyLevel.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type isEssentialTrafficOnly = any; +export type isEssentialTrafficOnly = any diff --git a/src/services/api/src/utils/process.ts b/src/services/api/src/utils/process.ts index 1085e5d63..f9dfb47b3 100644 --- a/src/services/api/src/utils/process.ts +++ b/src/services/api/src/utils/process.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type writeToStderr = any; +export type writeToStderr = any diff --git a/src/services/api/src/utils/proxy.ts b/src/services/api/src/utils/proxy.ts index e93b33d2d..c6017d936 100644 --- a/src/services/api/src/utils/proxy.ts +++ b/src/services/api/src/utils/proxy.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getProxyFetchOptions = any; +export type getProxyFetchOptions = any diff --git a/src/services/api/src/utils/queryProfiler.ts b/src/services/api/src/utils/queryProfiler.ts index 1283fcc9e..d2f1fa133 100644 --- a/src/services/api/src/utils/queryProfiler.ts +++ b/src/services/api/src/utils/queryProfiler.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type endQueryProfile = any; -export type queryCheckpoint = any; +export type endQueryProfile = any +export type queryCheckpoint = any diff --git a/src/services/api/src/utils/slowOperations.ts b/src/services/api/src/utils/slowOperations.ts index b888efc24..72ec74769 100644 --- a/src/services/api/src/utils/slowOperations.ts +++ b/src/services/api/src/utils/slowOperations.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type jsonStringify = any; +export type jsonStringify = any diff --git a/src/services/api/src/utils/telemetry/events.ts b/src/services/api/src/utils/telemetry/events.ts index 4ae001883..4a7e479c7 100644 --- a/src/services/api/src/utils/telemetry/events.ts +++ b/src/services/api/src/utils/telemetry/events.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type logOTelEvent = any; +export type logOTelEvent = any diff --git a/src/services/api/src/utils/telemetry/sessionTracing.ts b/src/services/api/src/utils/telemetry/sessionTracing.ts index 094d0f999..5ae799b55 100644 --- a/src/services/api/src/utils/telemetry/sessionTracing.ts +++ b/src/services/api/src/utils/telemetry/sessionTracing.ts @@ -1,4 +1,4 @@ // Auto-generated type stub — replace with real implementation -export type endLLMRequestSpan = any; -export type isBetaTracingEnabled = any; -export type Span = any; +export type endLLMRequestSpan = any +export type isBetaTracingEnabled = any +export type Span = any diff --git a/src/services/api/src/utils/thinking.ts b/src/services/api/src/utils/thinking.ts index ea79bd179..ccff6b6f9 100644 --- a/src/services/api/src/utils/thinking.ts +++ b/src/services/api/src/utils/thinking.ts @@ -1,4 +1,4 @@ // Auto-generated type stub — replace with real implementation -export type modelSupportsAdaptiveThinking = any; -export type modelSupportsThinking = any; -export type ThinkingConfig = any; +export type modelSupportsAdaptiveThinking = any +export type modelSupportsThinking = any +export type ThinkingConfig = any diff --git a/src/services/api/src/utils/toolSearch.ts b/src/services/api/src/utils/toolSearch.ts index 3df7e9955..fb679f84e 100644 --- a/src/services/api/src/utils/toolSearch.ts +++ b/src/services/api/src/utils/toolSearch.ts @@ -1,4 +1,4 @@ // Auto-generated type stub — replace with real implementation -export type extractDiscoveredToolNames = any; -export type isDeferredToolsDeltaEnabled = any; -export type isToolSearchEnabled = any; +export type extractDiscoveredToolNames = any +export type isDeferredToolsDeltaEnabled = any +export type isToolSearchEnabled = any diff --git a/src/services/api/withRetry.ts b/src/services/api/withRetry.ts index 5ec9ad080..3e844d744 100644 --- a/src/services/api/withRetry.ts +++ b/src/services/api/withRetry.ts @@ -539,10 +539,7 @@ export function getRetryDelay( } } - const baseDelay = Math.min( - BASE_DELAY_MS * Math.pow(2, attempt - 1), - maxDelayMs, - ) + const baseDelay = Math.min(BASE_DELAY_MS * 2 ** (attempt - 1), maxDelayMs) const jitter = Math.random() * 0.25 * baseDelay return baseDelay + jitter } diff --git a/src/services/claudeAiLimits.ts b/src/services/claudeAiLimits.ts index 979f4f72d..178f13613 100644 --- a/src/services/claudeAiLimits.ts +++ b/src/services/claudeAiLimits.ts @@ -205,7 +205,6 @@ async function makeTestQuery() { }) const messages: MessageParam[] = [{ role: 'user', content: 'quota' }] const betas = getModelBetas(model) - // biome-ignore lint/plugin: quota check needs raw response access via asResponse() return anthropic.beta.messages .create({ model, diff --git a/src/services/compact/__tests__/grouping.test.ts b/src/services/compact/__tests__/grouping.test.ts index fac5aa56d..973589d8f 100644 --- a/src/services/compact/__tests__/grouping.test.ts +++ b/src/services/compact/__tests__/grouping.test.ts @@ -1,121 +1,115 @@ -import { describe, expect, test } from "bun:test"; -import { groupMessagesByApiRound } from "../grouping"; +import { describe, expect, test } from 'bun:test' +import { groupMessagesByApiRound } from '../grouping' -function makeMsg(type: "user" | "assistant" | "system", id: string): any { +function makeMsg(type: 'user' | 'assistant' | 'system', id: string): any { return { type, message: { id, content: `${type}-${id}` }, - }; + } } -describe("groupMessagesByApiRound", () => { +describe('groupMessagesByApiRound', () => { // Boundary fires when: assistant msg with NEW id AND current group has items - test("splits before first assistant if user messages precede it", () => { - const messages = [makeMsg("user", "u1"), makeMsg("assistant", "a1")]; - const groups = groupMessagesByApiRound(messages); + test('splits before first assistant if user messages precede it', () => { + const messages = [makeMsg('user', 'u1'), makeMsg('assistant', 'a1')] + const groups = groupMessagesByApiRound(messages) // user msgs form group 1, assistant starts group 2 - expect(groups).toHaveLength(2); - expect(groups[0]).toHaveLength(1); - expect(groups[1]).toHaveLength(1); - }); + expect(groups).toHaveLength(2) + expect(groups[0]).toHaveLength(1) + expect(groups[1]).toHaveLength(1) + }) - test("single assistant message forms one group", () => { - const messages = [makeMsg("assistant", "a1")]; - const groups = groupMessagesByApiRound(messages); - expect(groups).toHaveLength(1); - }); + test('single assistant message forms one group', () => { + const messages = [makeMsg('assistant', 'a1')] + const groups = groupMessagesByApiRound(messages) + expect(groups).toHaveLength(1) + }) - test("splits at new assistant message ID", () => { + test('splits at new assistant message ID', () => { const messages = [ - makeMsg("user", "u1"), - makeMsg("assistant", "a1"), - makeMsg("assistant", "a2"), - ]; - const groups = groupMessagesByApiRound(messages); - expect(groups).toHaveLength(3); - }); + makeMsg('user', 'u1'), + makeMsg('assistant', 'a1'), + makeMsg('assistant', 'a2'), + ] + const groups = groupMessagesByApiRound(messages) + expect(groups).toHaveLength(3) + }) - test("keeps same-ID assistant messages in same group (streaming chunks)", () => { + test('keeps same-ID assistant messages in same group (streaming chunks)', () => { const messages = [ - makeMsg("assistant", "a1"), - makeMsg("assistant", "a1"), - makeMsg("assistant", "a1"), - ]; - const groups = groupMessagesByApiRound(messages); - expect(groups).toHaveLength(1); - expect(groups[0]).toHaveLength(3); - }); + makeMsg('assistant', 'a1'), + makeMsg('assistant', 'a1'), + makeMsg('assistant', 'a1'), + ] + const groups = groupMessagesByApiRound(messages) + expect(groups).toHaveLength(1) + expect(groups[0]).toHaveLength(3) + }) - test("returns empty array for empty input", () => { - expect(groupMessagesByApiRound([])).toEqual([]); - }); + test('returns empty array for empty input', () => { + expect(groupMessagesByApiRound([])).toEqual([]) + }) - test("handles all user messages (no assistant)", () => { - const messages = [makeMsg("user", "u1"), makeMsg("user", "u2")]; - const groups = groupMessagesByApiRound(messages); - expect(groups).toHaveLength(1); - }); + test('handles all user messages (no assistant)', () => { + const messages = [makeMsg('user', 'u1'), makeMsg('user', 'u2')] + const groups = groupMessagesByApiRound(messages) + expect(groups).toHaveLength(1) + }) - test("three API rounds produce correct groups", () => { + test('three API rounds produce correct groups', () => { const messages = [ - makeMsg("user", "u1"), - makeMsg("assistant", "a1"), - makeMsg("user", "u2"), - makeMsg("assistant", "a2"), - makeMsg("user", "u3"), - makeMsg("assistant", "a3"), - ]; - const groups = groupMessagesByApiRound(messages); + makeMsg('user', 'u1'), + makeMsg('assistant', 'a1'), + makeMsg('user', 'u2'), + makeMsg('assistant', 'a2'), + makeMsg('user', 'u3'), + makeMsg('assistant', 'a3'), + ] + const groups = groupMessagesByApiRound(messages) // [u1], [a1, u2], [a2, u3], [a3] = 4 groups - expect(groups).toHaveLength(4); - }); + expect(groups).toHaveLength(4) + }) - test("consecutive user messages stay in same group", () => { - const messages = [makeMsg("user", "u1"), makeMsg("user", "u2")]; - expect(groupMessagesByApiRound(messages)).toHaveLength(1); - }); + test('consecutive user messages stay in same group', () => { + const messages = [makeMsg('user', 'u1'), makeMsg('user', 'u2')] + expect(groupMessagesByApiRound(messages)).toHaveLength(1) + }) - test("does not produce empty groups", () => { - const messages = [ - makeMsg("assistant", "a1"), - makeMsg("assistant", "a2"), - ]; - const groups = groupMessagesByApiRound(messages); + test('does not produce empty groups', () => { + const messages = [makeMsg('assistant', 'a1'), makeMsg('assistant', 'a2')] + const groups = groupMessagesByApiRound(messages) for (const group of groups) { - expect(group.length).toBeGreaterThan(0); + expect(group.length).toBeGreaterThan(0) } - }); + }) - test("handles single message", () => { - expect(groupMessagesByApiRound([makeMsg("user", "u1")])).toHaveLength(1); - }); + test('handles single message', () => { + expect(groupMessagesByApiRound([makeMsg('user', 'u1')])).toHaveLength(1) + }) - test("preserves message order within groups", () => { - const messages = [makeMsg("assistant", "a1"), makeMsg("user", "u2")]; - const groups = groupMessagesByApiRound(messages); - expect(groups[0]![0]!.message!.id).toBe("a1"); - expect(groups[0]![1]!.message!.id).toBe("u2"); - }); + test('preserves message order within groups', () => { + const messages = [makeMsg('assistant', 'a1'), makeMsg('user', 'u2')] + const groups = groupMessagesByApiRound(messages) + expect(groups[0]![0]!.message!.id).toBe('a1') + expect(groups[0]![1]!.message!.id).toBe('u2') + }) - test("handles system messages", () => { - const messages = [ - makeMsg("system", "s1"), - makeMsg("assistant", "a1"), - ]; + test('handles system messages', () => { + const messages = [makeMsg('system', 's1'), makeMsg('assistant', 'a1')] // system msg is non-assistant, goes to current. Then assistant a1 is new ID // and current has items, so split. - const groups = groupMessagesByApiRound(messages); - expect(groups).toHaveLength(2); - }); + const groups = groupMessagesByApiRound(messages) + expect(groups).toHaveLength(2) + }) - test("tool_result after assistant stays in same round", () => { + test('tool_result after assistant stays in same round', () => { const messages = [ - makeMsg("assistant", "a1"), - makeMsg("user", "tool_result_1"), - makeMsg("assistant", "a1"), // same ID = no new boundary - ]; - const groups = groupMessagesByApiRound(messages); - expect(groups).toHaveLength(1); - expect(groups[0]).toHaveLength(3); - }); -}); + makeMsg('assistant', 'a1'), + makeMsg('user', 'tool_result_1'), + makeMsg('assistant', 'a1'), // same ID = no new boundary + ] + const groups = groupMessagesByApiRound(messages) + expect(groups).toHaveLength(1) + expect(groups[0]).toHaveLength(3) + }) +}) diff --git a/src/services/compact/__tests__/prompt.test.ts b/src/services/compact/__tests__/prompt.test.ts index dbed89847..3a758adc3 100644 --- a/src/services/compact/__tests__/prompt.test.ts +++ b/src/services/compact/__tests__/prompt.test.ts @@ -1,77 +1,80 @@ -import { mock, describe, expect, test } from "bun:test"; +import { mock, describe, expect, test } from 'bun:test' -mock.module("bun:bundle", () => ({ feature: () => false })); +mock.module('bun:bundle', () => ({ feature: () => false })) -const { formatCompactSummary } = await import("../prompt"); +const { formatCompactSummary } = await import('../prompt') -describe("formatCompactSummary", () => { - test("strips ... block", () => { - const input = "my thought process\nthe summary"; - const result = formatCompactSummary(input); - expect(result).not.toContain(""); - expect(result).not.toContain("my thought process"); - }); +describe('formatCompactSummary', () => { + test('strips ... block', () => { + const input = + 'my thought process\nthe summary' + const result = formatCompactSummary(input) + expect(result).not.toContain('') + expect(result).not.toContain('my thought process') + }) test("replaces ... with 'Summary:\\n' prefix", () => { - const input = "key points here"; - const result = formatCompactSummary(input); - expect(result).toContain("Summary:"); - expect(result).toContain("key points here"); - expect(result).not.toContain(""); - }); + const input = 'key points here' + const result = formatCompactSummary(input) + expect(result).toContain('Summary:') + expect(result).toContain('key points here') + expect(result).not.toContain('') + }) - test("handles analysis + summary together", () => { - const input = "thinkingresult"; - const result = formatCompactSummary(input); - expect(result).not.toContain("thinking"); - expect(result).toContain("result"); - }); + test('handles analysis + summary together', () => { + const input = 'thinkingresult' + const result = formatCompactSummary(input) + expect(result).not.toContain('thinking') + expect(result).toContain('result') + }) - test("handles summary without analysis", () => { - const input = "just the summary"; - const result = formatCompactSummary(input); - expect(result).toContain("just the summary"); - }); + test('handles summary without analysis', () => { + const input = 'just the summary' + const result = formatCompactSummary(input) + expect(result).toContain('just the summary') + }) - test("handles analysis without summary", () => { - const input = "just analysisand some text"; - const result = formatCompactSummary(input); - expect(result).not.toContain("just analysis"); - expect(result).toContain("and some text"); - }); + test('handles analysis without summary', () => { + const input = 'just analysisand some text' + const result = formatCompactSummary(input) + expect(result).not.toContain('just analysis') + expect(result).toContain('and some text') + }) - test("collapses multiple newlines to double", () => { - const input = "hello\n\n\n\nworld"; - const result = formatCompactSummary(input); - expect(result).not.toMatch(/\n{3,}/); - }); + test('collapses multiple newlines to double', () => { + const input = 'hello\n\n\n\nworld' + const result = formatCompactSummary(input) + expect(result).not.toMatch(/\n{3,}/) + }) - test("trims leading/trailing whitespace", () => { - const input = " \n hello \n "; - const result = formatCompactSummary(input); - expect(result).toBe("hello"); - }); + test('trims leading/trailing whitespace', () => { + const input = ' \n hello \n ' + const result = formatCompactSummary(input) + expect(result).toBe('hello') + }) - test("handles empty string", () => { - expect(formatCompactSummary("")).toBe(""); - }); + test('handles empty string', () => { + expect(formatCompactSummary('')).toBe('') + }) - test("handles plain text without tags", () => { - const input = "just plain text"; - expect(formatCompactSummary(input)).toBe("just plain text"); - }); + test('handles plain text without tags', () => { + const input = 'just plain text' + expect(formatCompactSummary(input)).toBe('just plain text') + }) - test("handles multiline analysis content", () => { - const input = "\nline1\nline2\nline3\nok"; - const result = formatCompactSummary(input); - expect(result).not.toContain("line1"); - expect(result).toContain("ok"); - }); + test('handles multiline analysis content', () => { + const input = + '\nline1\nline2\nline3\nok' + const result = formatCompactSummary(input) + expect(result).not.toContain('line1') + expect(result).toContain('ok') + }) - test("preserves content between analysis and summary", () => { - const input = "thoughtsmiddle textfinal"; - const result = formatCompactSummary(input); - expect(result).toContain("middle text"); - expect(result).toContain("final"); - }); -}); + test('preserves content between analysis and summary', () => { + const input = + 'thoughtsmiddle textfinal' + const result = formatCompactSummary(input) + expect(result).toContain('middle text') + expect(result).toContain('final') + }) +}) diff --git a/src/services/compact/apiMicrocompact.ts b/src/services/compact/apiMicrocompact.ts index 44b292dac..8ffe2c234 100644 --- a/src/services/compact/apiMicrocompact.ts +++ b/src/services/compact/apiMicrocompact.ts @@ -103,10 +103,10 @@ export function getAPIContextManagement(options?: { if (useClearToolResults) { const triggerThreshold = process.env.API_MAX_INPUT_TOKENS - ? parseInt(process.env.API_MAX_INPUT_TOKENS) + ? parseInt(process.env.API_MAX_INPUT_TOKENS, 10) : DEFAULT_MAX_INPUT_TOKENS const keepTarget = process.env.API_TARGET_INPUT_TOKENS - ? parseInt(process.env.API_TARGET_INPUT_TOKENS) + ? parseInt(process.env.API_TARGET_INPUT_TOKENS, 10) : DEFAULT_TARGET_INPUT_TOKENS const strategy: ContextEditStrategy = { @@ -127,10 +127,10 @@ export function getAPIContextManagement(options?: { if (useClearToolUses) { const triggerThreshold = process.env.API_MAX_INPUT_TOKENS - ? parseInt(process.env.API_MAX_INPUT_TOKENS) + ? parseInt(process.env.API_MAX_INPUT_TOKENS, 10) : DEFAULT_MAX_INPUT_TOKENS const keepTarget = process.env.API_TARGET_INPUT_TOKENS - ? parseInt(process.env.API_TARGET_INPUT_TOKENS) + ? parseInt(process.env.API_TARGET_INPUT_TOKENS, 10) : DEFAULT_TARGET_INPUT_TOKENS const strategy: ContextEditStrategy = { diff --git a/src/services/compact/cachedMCConfig.ts b/src/services/compact/cachedMCConfig.ts index 67d72648c..3279fcc07 100644 --- a/src/services/compact/cachedMCConfig.ts +++ b/src/services/compact/cachedMCConfig.ts @@ -1,3 +1,8 @@ // Auto-generated stub — replace with real implementation -export {}; -export const getCachedMCConfig: () => { enabled?: boolean; systemPromptSuggestSummaries?: boolean; supportedModels?: string[]; [key: string]: unknown } = () => ({}); +export {} +export const getCachedMCConfig: () => { + enabled?: boolean + systemPromptSuggestSummaries?: boolean + supportedModels?: string[] + [key: string]: unknown +} = () => ({}) diff --git a/src/services/compact/cachedMicrocompact.ts b/src/services/compact/cachedMicrocompact.ts index 471ad8dfe..81824709c 100644 --- a/src/services/compact/cachedMicrocompact.ts +++ b/src/services/compact/cachedMicrocompact.ts @@ -1,5 +1,5 @@ // Auto-generated stub — replace with real implementation -export {}; +export {} export type CachedMCState = { registeredTools: Set @@ -19,19 +19,33 @@ export type PinnedCacheEdits = { block: CacheEditsBlock } -export const isCachedMicrocompactEnabled: () => boolean = () => false; -export const isModelSupportedForCacheEditing: (model: string) => boolean = () => false; -export const getCachedMCConfig: () => { triggerThreshold: number; keepRecent: number } = () => ({ triggerThreshold: 0, keepRecent: 0 }); +export const isCachedMicrocompactEnabled: () => boolean = () => false +export const isModelSupportedForCacheEditing: (model: string) => boolean = () => + false +export const getCachedMCConfig: () => { + triggerThreshold: number + keepRecent: number +} = () => ({ triggerThreshold: 0, keepRecent: 0 }) export const createCachedMCState: () => CachedMCState = () => ({ registeredTools: new Set(), toolOrder: [], deletedRefs: new Set(), pinnedEdits: [], toolsSentToAPI: false, -}); -export const markToolsSentToAPI: (state: CachedMCState) => void = () => {}; -export const resetCachedMCState: (state: CachedMCState) => void = () => {}; -export const registerToolResult: (state: CachedMCState, toolId: string) => void = () => {}; -export const registerToolMessage: (state: CachedMCState, groupIds: string[]) => void = () => {}; -export const getToolResultsToDelete: (state: CachedMCState) => string[] = () => []; -export const createCacheEditsBlock: (state: CachedMCState, toolIds: string[]) => CacheEditsBlock | null = () => null; +}) +export const markToolsSentToAPI: (state: CachedMCState) => void = () => {} +export const resetCachedMCState: (state: CachedMCState) => void = () => {} +export const registerToolResult: ( + state: CachedMCState, + toolId: string, +) => void = () => {} +export const registerToolMessage: ( + state: CachedMCState, + groupIds: string[], +) => void = () => {} +export const getToolResultsToDelete: (state: CachedMCState) => string[] = + () => [] +export const createCacheEditsBlock: ( + state: CachedMCState, + toolIds: string[], +) => CacheEditsBlock | null = () => null diff --git a/src/services/compact/compact.ts b/src/services/compact/compact.ts index f46194ffb..efc892712 100644 --- a/src/services/compact/compact.ts +++ b/src/services/compact/compact.ts @@ -267,7 +267,9 @@ export function truncateHeadForPTLRetry( let acc = 0 dropCount = 0 for (const g of groups) { - acc += roughTokenCountEstimationForMessages(g as Parameters[0]) + acc += roughTokenCountEstimationForMessages( + g as Parameters[0], + ) dropCount++ if (acc >= tokenGap) break } @@ -762,7 +764,7 @@ export async function compactConversation( context.setStreamMode?.('requesting') context.setResponseLength?.(() => 0) context.onCompactProgress?.({ type: 'compact_end' }) - context.setSDKStatus?.("" as SDKStatus) + context.setSDKStatus?.('' as SDKStatus) } } @@ -1105,7 +1107,7 @@ export async function partialCompactConversation( context.setStreamMode?.('requesting') context.setResponseLength?.(() => 0) context.onCompactProgress?.({ type: 'compact_end' }) - context.setSDKStatus?.("" as SDKStatus) + context.setSDKStatus?.('' as SDKStatus) } } @@ -1332,8 +1334,18 @@ async function streamCompactSummary({ let next = await streamIter.next() while (!next.done) { - const event = next.value as StreamEvent | AssistantMessage | SystemAPIErrorMessage - const streamEvent = event as { type: string; event: { type: string; content_block: { type: string }; delta: { type: string; text: string } } } + const event = next.value as + | StreamEvent + | AssistantMessage + | SystemAPIErrorMessage + const streamEvent = event as { + type: string + event: { + type: string + content_block: { type: string } + delta: { type: string; text: string } + } + } if ( !hasStartedStreaming && diff --git a/src/services/compact/microCompact.ts b/src/services/compact/microCompact.ts index 015991a4c..b392253e0 100644 --- a/src/services/compact/microCompact.ts +++ b/src/services/compact/microCompact.ts @@ -436,7 +436,9 @@ export function evaluateTimeBasedTrigger( return null } const gapMinutes = - (Date.now() - new Date(lastAssistant.timestamp as string | number).getTime()) / 60_000 + (Date.now() - + new Date(lastAssistant.timestamp as string | number).getTime()) / + 60_000 if (!Number.isFinite(gapMinutes) || gapMinutes < config.gapThresholdMinutes) { return null } diff --git a/src/services/compact/reactiveCompact.ts b/src/services/compact/reactiveCompact.ts index 67c872cda..2a124c728 100644 --- a/src/services/compact/reactiveCompact.ts +++ b/src/services/compact/reactiveCompact.ts @@ -1,22 +1,25 @@ // Auto-generated stub — replace with real implementation -export {}; +export {} -import type { Message } from 'src/types/message'; -import type { CompactionResult } from './compact.js'; +import type { Message } from 'src/types/message' +import type { CompactionResult } from './compact.js' -export const isReactiveOnlyMode: () => boolean = () => false; +export const isReactiveOnlyMode: () => boolean = () => false export const reactiveCompactOnPromptTooLong: ( messages: Message[], cacheSafeParams: Record, options: { customInstructions?: string; trigger?: string }, -) => Promise<{ ok: boolean; reason?: string; result?: CompactionResult }> = async () => ({ ok: false }); -export const isReactiveCompactEnabled: () => boolean = () => false; -export const isWithheldPromptTooLong: (message: Message) => boolean = () => false; -export const isWithheldMediaSizeError: (message: Message) => boolean = () => false; +) => Promise<{ ok: boolean; reason?: string; result?: CompactionResult }> = + async () => ({ ok: false }) +export const isReactiveCompactEnabled: () => boolean = () => false +export const isWithheldPromptTooLong: (message: Message) => boolean = () => + false +export const isWithheldMediaSizeError: (message: Message) => boolean = () => + false export const tryReactiveCompact: (params: { - hasAttempted: boolean; - querySource: string; - aborted: boolean; - messages: Message[]; - cacheSafeParams: Record; -}) => Promise = async () => null; + hasAttempted: boolean + querySource: string + aborted: boolean + messages: Message[] + cacheSafeParams: Record +}) => Promise = async () => null diff --git a/src/services/compact/sessionMemoryCompact.ts b/src/services/compact/sessionMemoryCompact.ts index fae482c40..6704e0a9a 100644 --- a/src/services/compact/sessionMemoryCompact.ts +++ b/src/services/compact/sessionMemoryCompact.ts @@ -135,7 +135,9 @@ async function initSessionMemoryCompactConfig(): Promise { export function hasTextBlocks(message: Message): boolean { if (message.type === 'assistant') { const content = message.message!.content - return Array.isArray(content) && content.some(block => block.type === 'text') + return ( + Array.isArray(content) && content.some(block => block.type === 'text') + ) } if (message.type === 'user') { const content = message.message!.content diff --git a/src/services/compact/snipCompact.ts b/src/services/compact/snipCompact.ts index ecd72176e..6ea79fd64 100644 --- a/src/services/compact/snipCompact.ts +++ b/src/services/compact/snipCompact.ts @@ -1,17 +1,22 @@ // Auto-generated stub — replace with real implementation -export {}; +export {} -import type { Message } from 'src/types/message'; +import type { Message } from 'src/types/message' -export const isSnipMarkerMessage: (message: Message) => boolean = () => false; +export const isSnipMarkerMessage: (message: Message) => boolean = () => false export const snipCompactIfNeeded: ( messages: Message[], options?: { force?: boolean }, -) => { messages: Message[]; executed: boolean; tokensFreed: number; boundaryMessage?: Message } = (messages) => ({ +) => { + messages: Message[] + executed: boolean + tokensFreed: number + boundaryMessage?: Message +} = messages => ({ messages, executed: false, tokensFreed: 0, -}); -export const isSnipRuntimeEnabled: () => boolean = () => false; -export const shouldNudgeForSnips: (messages: Message[]) => boolean = () => false; -export const SNIP_NUDGE_TEXT: string = ''; +}) +export const isSnipRuntimeEnabled: () => boolean = () => false +export const shouldNudgeForSnips: (messages: Message[]) => boolean = () => false +export const SNIP_NUDGE_TEXT: string = '' diff --git a/src/services/compact/snipProjection.ts b/src/services/compact/snipProjection.ts index 80efe381a..63b60ef5b 100644 --- a/src/services/compact/snipProjection.ts +++ b/src/services/compact/snipProjection.ts @@ -1,7 +1,8 @@ // Auto-generated stub — replace with real implementation -export {}; +export {} -import type { Message } from 'src/types/message'; +import type { Message } from 'src/types/message' -export const isSnipBoundaryMessage: (message: Message) => boolean = () => false; -export const projectSnippedView: (messages: Message[]) => Message[] = (messages) => messages; +export const isSnipBoundaryMessage: (message: Message) => boolean = () => false +export const projectSnippedView: (messages: Message[]) => Message[] = + messages => messages diff --git a/src/services/compact/src/bootstrap/state.ts b/src/services/compact/src/bootstrap/state.ts index a860c549e..9d8e08961 100644 --- a/src/services/compact/src/bootstrap/state.ts +++ b/src/services/compact/src/bootstrap/state.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type markPostCompaction = any; +export type markPostCompaction = any diff --git a/src/services/compact/src/tools/FileEditTool/constants.ts b/src/services/compact/src/tools/FileEditTool/constants.ts index b455c0655..f851a8bcc 100644 --- a/src/services/compact/src/tools/FileEditTool/constants.ts +++ b/src/services/compact/src/tools/FileEditTool/constants.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type FILE_EDIT_TOOL_NAME = any; +export type FILE_EDIT_TOOL_NAME = any diff --git a/src/services/compact/src/tools/FileReadTool/prompt.ts b/src/services/compact/src/tools/FileReadTool/prompt.ts index fac6439fc..e8c6709b3 100644 --- a/src/services/compact/src/tools/FileReadTool/prompt.ts +++ b/src/services/compact/src/tools/FileReadTool/prompt.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type FILE_READ_TOOL_NAME = any; +export type FILE_READ_TOOL_NAME = any diff --git a/src/services/compact/src/tools/FileWriteTool/prompt.ts b/src/services/compact/src/tools/FileWriteTool/prompt.ts index e69299d74..45cc15c49 100644 --- a/src/services/compact/src/tools/FileWriteTool/prompt.ts +++ b/src/services/compact/src/tools/FileWriteTool/prompt.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type FILE_WRITE_TOOL_NAME = any; +export type FILE_WRITE_TOOL_NAME = any diff --git a/src/services/compact/src/tools/GlobTool/prompt.ts b/src/services/compact/src/tools/GlobTool/prompt.ts index 060caf29c..5ff2b16bb 100644 --- a/src/services/compact/src/tools/GlobTool/prompt.ts +++ b/src/services/compact/src/tools/GlobTool/prompt.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type GLOB_TOOL_NAME = any; +export type GLOB_TOOL_NAME = any diff --git a/src/services/compact/src/tools/GrepTool/prompt.ts b/src/services/compact/src/tools/GrepTool/prompt.ts index 08b8a8d29..4645d4c52 100644 --- a/src/services/compact/src/tools/GrepTool/prompt.ts +++ b/src/services/compact/src/tools/GrepTool/prompt.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type GREP_TOOL_NAME = any; +export type GREP_TOOL_NAME = any diff --git a/src/services/compact/src/tools/NotebookEditTool/constants.ts b/src/services/compact/src/tools/NotebookEditTool/constants.ts index 6c6c94bad..3c1d7a0d2 100644 --- a/src/services/compact/src/tools/NotebookEditTool/constants.ts +++ b/src/services/compact/src/tools/NotebookEditTool/constants.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type NOTEBOOK_EDIT_TOOL_NAME = any; +export type NOTEBOOK_EDIT_TOOL_NAME = any diff --git a/src/services/compact/src/tools/WebFetchTool/prompt.ts b/src/services/compact/src/tools/WebFetchTool/prompt.ts index 63b342a25..83e9643c5 100644 --- a/src/services/compact/src/tools/WebFetchTool/prompt.ts +++ b/src/services/compact/src/tools/WebFetchTool/prompt.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type WEB_FETCH_TOOL_NAME = any; +export type WEB_FETCH_TOOL_NAME = any diff --git a/src/services/compact/src/tools/WebSearchTool/prompt.ts b/src/services/compact/src/tools/WebSearchTool/prompt.ts index 38871a0ba..3d3f02b32 100644 --- a/src/services/compact/src/tools/WebSearchTool/prompt.ts +++ b/src/services/compact/src/tools/WebSearchTool/prompt.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type WEB_SEARCH_TOOL_NAME = any; +export type WEB_SEARCH_TOOL_NAME = any diff --git a/src/services/compact/src/utils/shell/shellToolUtils.ts b/src/services/compact/src/utils/shell/shellToolUtils.ts index c89fe2ada..c5f7e0226 100644 --- a/src/services/compact/src/utils/shell/shellToolUtils.ts +++ b/src/services/compact/src/utils/shell/shellToolUtils.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type SHELL_TOOL_NAMES = any; +export type SHELL_TOOL_NAMES = any diff --git a/src/services/contextCollapse/index.ts b/src/services/contextCollapse/index.ts index 09fb3c501..43df12635 100644 --- a/src/services/contextCollapse/index.ts +++ b/src/services/contextCollapse/index.ts @@ -27,7 +27,7 @@ export interface DrainResult { messages: Message[] } -export const getStats: () => ContextCollapseStats = (() => ({ +export const getStats: () => ContextCollapseStats = () => ({ collapsedSpans: 0, collapsedMessages: 0, stagedSpans: 0, @@ -38,29 +38,30 @@ export const getStats: () => ContextCollapseStats = (() => ({ emptySpawnWarningEmitted: false, totalEmptySpawns: 0, }, -})); +}) -export const isContextCollapseEnabled: () => boolean = (() => false); +export const isContextCollapseEnabled: () => boolean = () => false -export const subscribe: (callback: () => void) => () => void = ((_callback: () => void) => () => {}); +export const subscribe: (callback: () => void) => () => void = + (_callback: () => void) => () => {} export const applyCollapsesIfNeeded: ( messages: Message[], toolUseContext: ToolUseContext, querySource: QuerySource, -) => Promise = (async (messages: Message[]) => ({ messages })); +) => Promise = async (messages: Message[]) => ({ messages }) export const isWithheldPromptTooLong: ( message: Message, isPromptTooLongMessage: (msg: Message) => boolean, querySource: QuerySource, -) => boolean = (() => false); +) => boolean = () => false export const recoverFromOverflow: ( messages: Message[], querySource: QuerySource, -) => DrainResult = ((messages: Message[]) => ({ committed: 0, messages })); +) => DrainResult = (messages: Message[]) => ({ committed: 0, messages }) -export const resetContextCollapse: () => void = (() => {}); +export const resetContextCollapse: () => void = () => {} -export const initContextCollapse: () => void = (() => {}); +export const initContextCollapse: () => void = () => {} diff --git a/src/services/contextCollapse/operations.ts b/src/services/contextCollapse/operations.ts index 731e30c40..1715657dd 100644 --- a/src/services/contextCollapse/operations.ts +++ b/src/services/contextCollapse/operations.ts @@ -1,4 +1,5 @@ // Auto-generated stub — replace with real implementation -export {}; -import type { Message } from 'src/types/message.js'; -export const projectView: (messages: Message[]) => Message[] = (messages) => messages; +export {} +import type { Message } from 'src/types/message.js' +export const projectView: (messages: Message[]) => Message[] = messages => + messages diff --git a/src/services/contextCollapse/persist.ts b/src/services/contextCollapse/persist.ts index b89c2b8e9..4150a0150 100644 --- a/src/services/contextCollapse/persist.ts +++ b/src/services/contextCollapse/persist.ts @@ -1,3 +1,3 @@ // Auto-generated stub — replace with real implementation -export {}; -export const restoreFromEntries: (...args: unknown[]) => void = () => {}; +export {} +export const restoreFromEntries: (...args: unknown[]) => void = () => {} diff --git a/src/services/extractMemories/extractMemories.ts b/src/services/extractMemories/extractMemories.ts index bb2ae1103..51e532439 100644 --- a/src/services/extractMemories/extractMemories.ts +++ b/src/services/extractMemories/extractMemories.ts @@ -272,9 +272,7 @@ function extractWrittenPaths(agentMessages: Message[]): string[] { // Initialization & Closure-scoped State // ============================================================================ -type AppendSystemMessageFn = ( - msg: SystemMessage, -) => void +type AppendSystemMessageFn = (msg: SystemMessage) => void /** The active extractor function, set by initExtractMemories(). */ let extractor: diff --git a/src/services/langfuse/client.ts b/src/services/langfuse/client.ts index 89037c607..256cd8af8 100644 --- a/src/services/langfuse/client.ts +++ b/src/services/langfuse/client.ts @@ -37,7 +37,11 @@ export function initLangfuse(): boolean { mask: maskFn, environment: process.env.LANGFUSE_TRACING_ENVIRONMENT ?? 'development', release: MACRO.VERSION, - exportMode: (process.env.LANGFUSE_EXPORT_MODE as 'batched' | 'immediate' | undefined) ?? 'batched', + exportMode: + (process.env.LANGFUSE_EXPORT_MODE as + | 'batched' + | 'immediate' + | undefined) ?? 'batched', timeout: parseInt(process.env.LANGFUSE_TIMEOUT ?? '5', 10), }) diff --git a/src/services/langfuse/convert.ts b/src/services/langfuse/convert.ts index c07de5c94..5658afc21 100644 --- a/src/services/langfuse/convert.ts +++ b/src/services/langfuse/convert.ts @@ -6,7 +6,11 @@ * output: { role: 'assistant', content: string | part[] } */ -import type { Message, AssistantMessage, UserMessage } from 'src/types/message.js' +import type { + Message, + AssistantMessage, + UserMessage, +} from 'src/types/message.js' type LangfuseContentPart = | { type: 'text'; text: string } @@ -33,9 +37,17 @@ function normalizeContent(content: unknown): string | LangfuseContentPart[] { if (type === 'text') { parts.push({ type: 'text', text: String(b.text ?? '') }) } else if (type === 'thinking' || type === 'redacted_thinking') { - parts.push({ type: 'thinking', thinking: String(b.thinking ?? '[redacted]') }) + parts.push({ + type: 'thinking', + thinking: String(b.thinking ?? '[redacted]'), + }) } else if (type === 'tool_use') { - parts.push({ type: 'tool_use', id: String(b.id ?? ''), name: String(b.name ?? ''), input: b.input }) + parts.push({ + type: 'tool_use', + id: String(b.id ?? ''), + name: String(b.name ?? ''), + input: b.input, + }) } else if (type === 'tool_result') { const resultContent = Array.isArray(b.content) ? (b.content as Record[]) @@ -47,22 +59,40 @@ function normalizeContent(content: unknown): string | LangfuseContentPart[] { }) .join('\n') : String(b.content ?? '') - parts.push({ type: 'tool_result', tool_use_id: String(b.tool_use_id ?? ''), content: resultContent }) + parts.push({ + type: 'tool_result', + tool_use_id: String(b.tool_use_id ?? ''), + content: resultContent, + }) } else if (type === 'image') { parts.push({ type: 'text', text: '[image]' }) } else if (type === 'document') { - const name = (b.source as Record | undefined)?.filename - ?? (b.title as string | undefined) - ?? 'document' + const name = + (b.source as Record | undefined)?.filename ?? + (b.title as string | undefined) ?? + 'document' parts.push({ type: 'text', text: `[document: ${name}]` }) - } else if (type === 'server_tool_use' || type === 'web_search_tool_result' || type === 'tool_search_tool_result') { + } else if ( + type === 'server_tool_use' || + type === 'web_search_tool_result' || + type === 'tool_search_tool_result' + ) { // server-side tool blocks — keep name/id, drop raw content - parts.push({ type: type, id: String(b.id ?? ''), name: String(b.name ?? type) }) + parts.push({ + type: type, + id: String(b.id ?? ''), + name: String(b.name ?? type), + }) } else { // unknown block: keep type + scalar fields only, drop any binary/large payloads const safe: Record = { type: type ?? 'unknown' } for (const [k, v] of Object.entries(b)) { - if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') safe[k] = v + if ( + typeof v === 'string' || + typeof v === 'number' || + typeof v === 'boolean' + ) + safe[k] = v } parts.push(safe as LangfuseContentPart) } @@ -108,7 +138,10 @@ export function convertOutputToLangfuse( if (messages.length === 0) return null if (messages.length === 1) { const msg = messages[0]! - return { role: 'assistant', content: normalizeContent(msg.message?.content) } + return { + role: 'assistant', + content: normalizeContent(msg.message?.content), + } } return messages.map(msg => ({ role: 'assistant' as const, diff --git a/src/services/langfuse/index.ts b/src/services/langfuse/index.ts index 6d044fa5a..999f7403a 100644 --- a/src/services/langfuse/index.ts +++ b/src/services/langfuse/index.ts @@ -1,4 +1,21 @@ -export { initLangfuse, shutdownLangfuse, isLangfuseEnabled, getLangfuseProcessor } from './client.js' -export { createTrace, createSubagentTrace, recordLLMObservation, recordToolObservation, endTrace, createToolBatchSpan, endToolBatchSpan } from './tracing.js' +export { + initLangfuse, + shutdownLangfuse, + isLangfuseEnabled, + getLangfuseProcessor, +} from './client.js' +export { + createTrace, + createSubagentTrace, + recordLLMObservation, + recordToolObservation, + endTrace, + createToolBatchSpan, + endToolBatchSpan, +} from './tracing.js' export type { LangfuseSpan } from './tracing.js' -export { sanitizeToolInput, sanitizeToolOutput, sanitizeGlobal } from './sanitize.js' +export { + sanitizeToolInput, + sanitizeToolOutput, + sanitizeGlobal, +} from './sanitize.js' diff --git a/src/services/langfuse/sanitize.ts b/src/services/langfuse/sanitize.ts index 9a077b4e3..01b3999af 100644 --- a/src/services/langfuse/sanitize.ts +++ b/src/services/langfuse/sanitize.ts @@ -1,5 +1,9 @@ const MAX_OUTPUT_LENGTH = 500 -const REDACTED_FILE_TOOLS = new Set(['FileReadTool', 'FileWriteTool', 'FileEditTool']) +const REDACTED_FILE_TOOLS = new Set([ + 'FileReadTool', + 'FileWriteTool', + 'FileEditTool', +]) const REDACTED_SHELL_TOOLS = new Set(['BashTool', 'PowerShellTool']) const SENSITIVE_OUTPUT_TOOLS = new Set(['ConfigTool', 'MCPTool']) @@ -8,7 +12,8 @@ const HOME_DIR_PATTERN = new RegExp( 'g', ) -const SENSITIVE_KEY_PATTERN = /(?:api_?key|token|secret|password|credential|auth_header)/i +const SENSITIVE_KEY_PATTERN = + /(?:api_?key|token|secret|password|credential|auth_header)/i export function sanitizeGlobal(data: unknown): unknown { if (typeof data === 'string') { diff --git a/src/services/langfuse/tracing.ts b/src/services/langfuse/tracing.ts index 1e06d8ae4..ea211535c 100644 --- a/src/services/langfuse/tracing.ts +++ b/src/services/langfuse/tracing.ts @@ -1,5 +1,9 @@ import { startObservation, LangfuseOtelSpanAttributes } from '@langfuse/tracing' -import type { LangfuseSpan, LangfuseGeneration, LangfuseAgent } from '@langfuse/tracing' +import type { + LangfuseSpan, + LangfuseGeneration, + LangfuseAgent, +} from '@langfuse/tracing' import { isLangfuseEnabled } from './client.js' import { sanitizeToolInput, sanitizeToolOutput } from './sanitize.js' import { logForDebugging } from 'src/utils/debug.js' @@ -12,7 +16,12 @@ type RootTrace = LangfuseAgent & { _sessionId?: string; _userId?: string } /** Resolve the user ID for Langfuse traces: explicit param > env var > email > deviceId */ function resolveLangfuseUserId(username?: string): string | undefined { - return username ?? process.env.LANGFUSE_USER_ID ?? getCoreUserData().email ?? getCoreUserData().deviceId + return ( + username ?? + process.env.LANGFUSE_USER_ID ?? + getCoreUserData().email ?? + getCoreUserData().deviceId + ) } export function createTrace(params: { @@ -26,21 +35,33 @@ export function createTrace(params: { }): LangfuseSpan | null { if (!isLangfuseEnabled()) return null try { - const traceName = params.name ?? (params.querySource ? `agent-run:${params.querySource}` : 'agent-run') - const rootSpan = startObservation(traceName, { - input: params.input, - metadata: { - provider: params.provider, - model: params.model, - agentType: 'main', - ...(params.querySource && { querySource: params.querySource }), + const traceName = + params.name ?? + (params.querySource ? `agent-run:${params.querySource}` : 'agent-run') + const rootSpan = startObservation( + traceName, + { + input: params.input, + metadata: { + provider: params.provider, + model: params.model, + agentType: 'main', + ...(params.querySource && { querySource: params.querySource }), + }, }, - }, { asType: 'agent' }) as RootTrace - rootSpan.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_SESSION_ID, params.sessionId) + { asType: 'agent' }, + ) as RootTrace + rootSpan.otelSpan.setAttribute( + LangfuseOtelSpanAttributes.TRACE_SESSION_ID, + params.sessionId, + ) rootSpan._sessionId = params.sessionId const userId = resolveLangfuseUserId(params.username) if (userId) { - rootSpan.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_USER_ID, userId) + rootSpan.otelSpan.setAttribute( + LangfuseOtelSpanAttributes.TRACE_USER_ID, + userId, + ) rootSpan._userId = userId } logForDebugging(`[langfuse] Trace created: ${rootSpan.id}`) @@ -81,7 +102,8 @@ export function recordLLMObservation( ): void { if (!rootSpan || !isLangfuseEnabled()) return try { - const genName = PROVIDER_GENERATION_NAMES[params.provider] ?? `Chat${params.provider}` + const genName = + PROVIDER_GENERATION_NAMES[params.provider] ?? `Chat${params.provider}` // Use the global startObservation directly instead of rootSpan.startObservation(). // The instance method only forwards asType to the global function and drops startTime, @@ -95,7 +117,9 @@ export function recordLLMObservation( provider: params.provider, model: params.model, }, - ...(params.completionStartTime && { completionStartTime: params.completionStartTime }), + ...(params.completionStartTime && { + completionStartTime: params.completionStartTime, + }), }, { asType: 'generation', @@ -107,11 +131,17 @@ export function recordLLMObservation( // Propagate session ID and user ID to generation span so Langfuse links it correctly const sessionId = (rootSpan as unknown as RootTrace)._sessionId if (sessionId) { - gen.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_SESSION_ID, sessionId) + gen.otelSpan.setAttribute( + LangfuseOtelSpanAttributes.TRACE_SESSION_ID, + sessionId, + ) } const userId = (rootSpan as unknown as RootTrace)._userId if (userId) { - gen.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_USER_ID, userId) + gen.otelSpan.setAttribute( + LangfuseOtelSpanAttributes.TRACE_USER_ID, + userId, + ) } // Anthropic splits input into uncached + cache_read + cache_creation. @@ -131,7 +161,9 @@ export function recordLLMObservation( gen.end(params.endTime) logForDebugging(`[langfuse] LLM observation recorded: ${gen.id}`) } catch (e) { - logForDebugging(`[langfuse] recordLLMObservation failed: ${e}`, { level: 'error' }) + logForDebugging(`[langfuse] recordLLMObservation failed: ${e}`, { + level: 'error', + }) } } @@ -172,11 +204,17 @@ export function recordToolObservation( // Propagate session ID and user ID to tool span so Langfuse links it correctly const sessionId = (rootSpan as unknown as RootTrace)._sessionId if (sessionId) { - toolObs.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_SESSION_ID, sessionId) + toolObs.otelSpan.setAttribute( + LangfuseOtelSpanAttributes.TRACE_SESSION_ID, + sessionId, + ) } const userId = (rootSpan as unknown as RootTrace)._userId if (userId) { - toolObs.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_USER_ID, userId) + toolObs.otelSpan.setAttribute( + LangfuseOtelSpanAttributes.TRACE_USER_ID, + userId, + ) } toolObs.update({ @@ -185,9 +223,13 @@ export function recordToolObservation( }) toolObs.end() - logForDebugging(`[langfuse] Tool observation recorded: ${params.toolName} (${toolObs.id})`) + logForDebugging( + `[langfuse] Tool observation recorded: ${params.toolName} (${toolObs.id})`, + ) } catch (e) { - logForDebugging(`[langfuse] recordToolObservation failed: ${e}`, { level: 'error' }) + logForDebugging(`[langfuse] recordToolObservation failed: ${e}`, { + level: 'error', + }) } } @@ -219,17 +261,27 @@ export function createToolBatchSpan( const sessionId = (rootSpan as unknown as RootTrace)._sessionId if (sessionId) { - batchSpan.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_SESSION_ID, sessionId) + batchSpan.otelSpan.setAttribute( + LangfuseOtelSpanAttributes.TRACE_SESSION_ID, + sessionId, + ) } const userId = (rootSpan as unknown as RootTrace)._userId if (userId) { - batchSpan.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_USER_ID, userId) + batchSpan.otelSpan.setAttribute( + LangfuseOtelSpanAttributes.TRACE_USER_ID, + userId, + ) } - logForDebugging(`[langfuse] Tool batch span created: ${batchSpan.id} (tools=${params.toolNames.join(',')})`) + logForDebugging( + `[langfuse] Tool batch span created: ${batchSpan.id} (tools=${params.toolNames.join(',')})`, + ) return batchSpan } catch (e) { - logForDebugging(`[langfuse] createToolBatchSpan failed: ${e}`, { level: 'error' }) + logForDebugging(`[langfuse] createToolBatchSpan failed: ${e}`, { + level: 'error', + }) return null } } @@ -240,7 +292,9 @@ export function endToolBatchSpan(batchSpan: LangfuseSpan | null): void { batchSpan.end() logForDebugging(`[langfuse] Tool batch span ended: ${batchSpan.id}`) } catch (e) { - logForDebugging(`[langfuse] endToolBatchSpan failed: ${e}`, { level: 'error' }) + logForDebugging(`[langfuse] endToolBatchSpan failed: ${e}`, { + level: 'error', + }) } } @@ -255,26 +309,40 @@ export function createSubagentTrace(params: { }): LangfuseSpan | null { if (!isLangfuseEnabled()) return null try { - const rootSpan = startObservation(`agent:${params.agentType}`, { - input: params.input, - metadata: { - provider: params.provider, - model: params.model, - agentType: params.agentType, - agentId: params.agentId, + const rootSpan = startObservation( + `agent:${params.agentType}`, + { + input: params.input, + metadata: { + provider: params.provider, + model: params.model, + agentType: params.agentType, + agentId: params.agentId, + }, }, - }, { asType: 'agent' }) as RootTrace - rootSpan.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_SESSION_ID, params.sessionId) + { asType: 'agent' }, + ) as RootTrace + rootSpan.otelSpan.setAttribute( + LangfuseOtelSpanAttributes.TRACE_SESSION_ID, + params.sessionId, + ) rootSpan._sessionId = params.sessionId const userId = resolveLangfuseUserId(params.username) if (userId) { - rootSpan.otelSpan.setAttribute(LangfuseOtelSpanAttributes.TRACE_USER_ID, userId) + rootSpan.otelSpan.setAttribute( + LangfuseOtelSpanAttributes.TRACE_USER_ID, + userId, + ) rootSpan._userId = userId } - logForDebugging(`[langfuse] Sub-agent trace created: ${rootSpan.id} (type=${params.agentType})`) + logForDebugging( + `[langfuse] Sub-agent trace created: ${rootSpan.id} (type=${params.agentType})`, + ) return rootSpan as unknown as LangfuseSpan } catch (e) { - logForDebugging(`[langfuse] createSubagentTrace failed: ${e}`, { level: 'error' }) + logForDebugging(`[langfuse] createSubagentTrace failed: ${e}`, { + level: 'error', + }) return null } } @@ -292,7 +360,9 @@ export function endTrace( else if (status === 'error') updatePayload.level = 'ERROR' if (Object.keys(updatePayload).length > 0) rootSpan.update(updatePayload) rootSpan.end() - logForDebugging(`[langfuse] Trace ended: ${rootSpan.id}${status ? ` (${status})` : ''}`) + logForDebugging( + `[langfuse] Trace ended: ${rootSpan.id}${status ? ` (${status})` : ''}`, + ) } catch (e) { logForDebugging(`[langfuse] endTrace failed: ${e}`, { level: 'error' }) } diff --git a/src/services/lsp/LSPServerInstance.ts b/src/services/lsp/LSPServerInstance.ts index b5994a5f6..75d386e32 100644 --- a/src/services/lsp/LSPServerInstance.ts +++ b/src/services/lsp/LSPServerInstance.ts @@ -387,7 +387,7 @@ export function createLSPServerInstance( isContentModifiedError && attempt < MAX_RETRIES_FOR_TRANSIENT_ERRORS ) { - const delay = RETRY_BASE_DELAY_MS * Math.pow(2, attempt) + const delay = RETRY_BASE_DELAY_MS * 2 ** attempt logForDebugging( `LSP request '${method}' to '${name}' got ContentModified error, ` + `retrying in ${delay}ms (attempt ${attempt + 1}/${MAX_RETRIES_FOR_TRANSIENT_ERRORS})…`, diff --git a/src/services/lsp/types.ts b/src/services/lsp/types.ts index 1dfa79379..c99756713 100644 --- a/src/services/lsp/types.ts +++ b/src/services/lsp/types.ts @@ -1,4 +1,4 @@ // Auto-generated stub — replace with real implementation -export type LspServerConfig = any; -export type ScopedLspServerConfig = any; -export type LspServerState = any; +export type LspServerConfig = any +export type ScopedLspServerConfig = any +export type LspServerState = any diff --git a/src/services/mcp/MCPConnectionManager.tsx b/src/services/mcp/MCPConnectionManager.tsx index 46c56b689..1b42a21f1 100644 --- a/src/services/mcp/MCPConnectionManager.tsx +++ b/src/services/mcp/MCPConnectionManager.tsx @@ -1,54 +1,41 @@ -import React, { - createContext, - type ReactNode, - useContext, - useMemo, -} from 'react' -import type { Command } from '../../commands.js' -import type { Tool } from '../../Tool.js' -import type { - MCPServerConnection, - ScopedMcpServerConfig, - ServerResource, -} from './types.js' -import { useManageMCPConnections } from './useManageMCPConnections.js' +import React, { createContext, type ReactNode, useContext, useMemo } from 'react'; +import type { Command } from '../../commands.js'; +import type { Tool } from '../../Tool.js'; +import type { MCPServerConnection, ScopedMcpServerConfig, ServerResource } from './types.js'; +import { useManageMCPConnections } from './useManageMCPConnections.js'; interface MCPConnectionContextValue { reconnectMcpServer: (serverName: string) => Promise<{ - client: MCPServerConnection - tools: Tool[] - commands: Command[] - resources?: ServerResource[] - }> - toggleMcpServer: (serverName: string) => Promise + client: MCPServerConnection; + tools: Tool[]; + commands: Command[]; + resources?: ServerResource[]; + }>; + toggleMcpServer: (serverName: string) => Promise; } -const MCPConnectionContext = createContext( - null, -) +const MCPConnectionContext = createContext(null); export function useMcpReconnect() { - const context = useContext(MCPConnectionContext) + const context = useContext(MCPConnectionContext); if (!context) { - throw new Error('useMcpReconnect must be used within MCPConnectionManager') + throw new Error('useMcpReconnect must be used within MCPConnectionManager'); } - return context.reconnectMcpServer + return context.reconnectMcpServer; } export function useMcpToggleEnabled() { - const context = useContext(MCPConnectionContext) + const context = useContext(MCPConnectionContext); if (!context) { - throw new Error( - 'useMcpToggleEnabled must be used within MCPConnectionManager', - ) + throw new Error('useMcpToggleEnabled must be used within MCPConnectionManager'); } - return context.toggleMcpServer + return context.toggleMcpServer; } interface MCPConnectionManagerProps { - children: ReactNode - dynamicMcpConfig: Record | undefined - isStrictMcpConfig: boolean + children: ReactNode; + dynamicMcpConfig: Record | undefined; + isStrictMcpConfig: boolean; } // TODO (ollie): We may be able to get rid of this context by putting these function on app state @@ -57,18 +44,8 @@ export function MCPConnectionManager({ dynamicMcpConfig, isStrictMcpConfig, }: MCPConnectionManagerProps): React.ReactNode { - const { reconnectMcpServer, toggleMcpServer } = useManageMCPConnections( - dynamicMcpConfig, - isStrictMcpConfig, - ) - const value = useMemo( - () => ({ reconnectMcpServer, toggleMcpServer }), - [reconnectMcpServer, toggleMcpServer], - ) + const { reconnectMcpServer, toggleMcpServer } = useManageMCPConnections(dynamicMcpConfig, isStrictMcpConfig); + const value = useMemo(() => ({ reconnectMcpServer, toggleMcpServer }), [reconnectMcpServer, toggleMcpServer]); - return ( - - {children} - - ) + return {children}; } diff --git a/src/services/mcp/__tests__/channelNotification.test.ts b/src/services/mcp/__tests__/channelNotification.test.ts index 1e0e968f1..43011a9b5 100644 --- a/src/services/mcp/__tests__/channelNotification.test.ts +++ b/src/services/mcp/__tests__/channelNotification.test.ts @@ -1,10 +1,10 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test } from 'bun:test' // findChannelEntry extracted from ../channelNotification.ts (line 161) // Copied to avoid heavy import chain type ChannelEntry = { - kind: "server" | "plugin" + kind: 'server' | 'plugin' name: string } @@ -12,58 +12,58 @@ function findChannelEntry( serverName: string, channels: readonly ChannelEntry[], ): ChannelEntry | undefined { - const parts = serverName.split(":") + const parts = serverName.split(':') return channels.find(c => - c.kind === "server" + c.kind === 'server' ? serverName === c.name - : parts[0] === "plugin" && parts[1] === c.name, + : parts[0] === 'plugin' && parts[1] === c.name, ) } -describe("findChannelEntry", () => { - test("finds server entry by exact name match", () => { - const channels = [{ kind: "server" as const, name: "my-server" }] - expect(findChannelEntry("my-server", channels)).toBeDefined() - expect(findChannelEntry("my-server", channels)!.name).toBe("my-server") +describe('findChannelEntry', () => { + test('finds server entry by exact name match', () => { + const channels = [{ kind: 'server' as const, name: 'my-server' }] + expect(findChannelEntry('my-server', channels)).toBeDefined() + expect(findChannelEntry('my-server', channels)!.name).toBe('my-server') }) - test("finds plugin entry by matching second segment", () => { - const channels = [{ kind: "plugin" as const, name: "slack" }] - expect(findChannelEntry("plugin:slack:tg", channels)).toBeDefined() + test('finds plugin entry by matching second segment', () => { + const channels = [{ kind: 'plugin' as const, name: 'slack' }] + expect(findChannelEntry('plugin:slack:tg', channels)).toBeDefined() }) - test("returns undefined for no match", () => { - const channels = [{ kind: "server" as const, name: "other" }] - expect(findChannelEntry("my-server", channels)).toBeUndefined() + test('returns undefined for no match', () => { + const channels = [{ kind: 'server' as const, name: 'other' }] + expect(findChannelEntry('my-server', channels)).toBeUndefined() }) - test("handles empty channels array", () => { - expect(findChannelEntry("my-server", [])).toBeUndefined() + test('handles empty channels array', () => { + expect(findChannelEntry('my-server', [])).toBeUndefined() }) - test("handles server name without colon", () => { - const channels = [{ kind: "server" as const, name: "simple" }] - expect(findChannelEntry("simple", channels)).toBeDefined() + test('handles server name without colon', () => { + const channels = [{ kind: 'server' as const, name: 'simple' }] + expect(findChannelEntry('simple', channels)).toBeDefined() }) test("handles 'plugin:name' format correctly", () => { - const channels = [{ kind: "plugin" as const, name: "slack" }] - expect(findChannelEntry("plugin:slack:tg", channels)).toBeDefined() - expect(findChannelEntry("plugin:discord:tg", channels)).toBeUndefined() + const channels = [{ kind: 'plugin' as const, name: 'slack' }] + expect(findChannelEntry('plugin:slack:tg', channels)).toBeDefined() + expect(findChannelEntry('plugin:discord:tg', channels)).toBeUndefined() }) - test("prefers exact match (server kind) over partial match", () => { + test('prefers exact match (server kind) over partial match', () => { const channels = [ - { kind: "server" as const, name: "plugin:slack" }, - { kind: "plugin" as const, name: "slack" }, + { kind: 'server' as const, name: 'plugin:slack' }, + { kind: 'plugin' as const, name: 'slack' }, ] - const result = findChannelEntry("plugin:slack", channels) + const result = findChannelEntry('plugin:slack', channels) expect(result).toBeDefined() - expect(result!.kind).toBe("server") + expect(result!.kind).toBe('server') }) - test("plugin kind does not match bare name", () => { - const channels = [{ kind: "plugin" as const, name: "slack" }] - expect(findChannelEntry("slack", channels)).toBeUndefined() + test('plugin kind does not match bare name', () => { + const channels = [{ kind: 'plugin' as const, name: 'slack' }] + expect(findChannelEntry('slack', channels)).toBeUndefined() }) }) diff --git a/src/services/mcp/__tests__/channelPermissions.test.ts b/src/services/mcp/__tests__/channelPermissions.test.ts index dc19af315..28c789848 100644 --- a/src/services/mcp/__tests__/channelPermissions.test.ts +++ b/src/services/mcp/__tests__/channelPermissions.test.ts @@ -1,165 +1,165 @@ -import { mock, describe, expect, test } from "bun:test"; +import { mock, describe, expect, test } from 'bun:test' -mock.module("src/utils/slowOperations.js", () => ({ +mock.module('src/utils/slowOperations.js', () => ({ jsonStringify: (v: unknown) => JSON.stringify(v), -})); -mock.module("src/services/analytics/growthbook.js", () => ({ +})) +mock.module('src/services/analytics/growthbook.js', () => ({ getFeatureValue_CACHED_MAY_BE_STALE: () => false, -})); +})) const { shortRequestId, truncateForPreview, PERMISSION_REPLY_RE, createChannelPermissionCallbacks, -} = await import("../channelPermissions"); +} = await import('../channelPermissions') -describe("shortRequestId", () => { - test("returns 5-char string from tool use ID", () => { - const result = shortRequestId("toolu_abc123"); - expect(result).toHaveLength(5); - }); +describe('shortRequestId', () => { + test('returns 5-char string from tool use ID', () => { + const result = shortRequestId('toolu_abc123') + expect(result).toHaveLength(5) + }) - test("is deterministic (same input = same output)", () => { - const a = shortRequestId("toolu_abc123"); - const b = shortRequestId("toolu_abc123"); - expect(a).toBe(b); - }); + test('is deterministic (same input = same output)', () => { + const a = shortRequestId('toolu_abc123') + const b = shortRequestId('toolu_abc123') + expect(a).toBe(b) + }) - test("different inputs produce different outputs", () => { - const a = shortRequestId("toolu_aaa"); - const b = shortRequestId("toolu_bbb"); - expect(a).not.toBe(b); - }); + test('different inputs produce different outputs', () => { + const a = shortRequestId('toolu_aaa') + const b = shortRequestId('toolu_bbb') + expect(a).not.toBe(b) + }) test("result contains only valid letters (no 'l')", () => { - const validChars = new Set("abcdefghijkmnopqrstuvwxyz"); + const validChars = new Set('abcdefghijkmnopqrstuvwxyz') for (let i = 0; i < 50; i++) { - const result = shortRequestId(`toolu_${i}`); + const result = shortRequestId(`toolu_${i}`) for (const ch of result) { - expect(validChars.has(ch)).toBe(true); + expect(validChars.has(ch)).toBe(true) } } - }); + }) - test("handles empty string", () => { - const result = shortRequestId(""); - expect(result).toHaveLength(5); - }); -}); + test('handles empty string', () => { + const result = shortRequestId('') + expect(result).toHaveLength(5) + }) +}) -describe("truncateForPreview", () => { - test("returns JSON string for object input", () => { - const result = truncateForPreview({ key: "value" }); - expect(result).toBe('{"key":"value"}'); - }); +describe('truncateForPreview', () => { + test('returns JSON string for object input', () => { + const result = truncateForPreview({ key: 'value' }) + expect(result).toBe('{"key":"value"}') + }) - test("truncates to <=200 chars with ellipsis when input is long", () => { - const longObj = { data: "x".repeat(300) }; - const result = truncateForPreview(longObj); - expect(result.length).toBeLessThanOrEqual(203); // 200 + '…' - expect(result.endsWith("…")).toBe(true); - }); + test('truncates to <=200 chars with ellipsis when input is long', () => { + const longObj = { data: 'x'.repeat(300) } + const result = truncateForPreview(longObj) + expect(result.length).toBeLessThanOrEqual(203) // 200 + '…' + expect(result.endsWith('…')).toBe(true) + }) - test("returns short input unchanged", () => { - const result = truncateForPreview({ a: 1 }); - expect(result).toBe('{"a":1}'); - expect(result.endsWith("…")).toBe(false); - }); + test('returns short input unchanged', () => { + const result = truncateForPreview({ a: 1 }) + expect(result).toBe('{"a":1}') + expect(result.endsWith('…')).toBe(false) + }) - test("handles string input", () => { - const result = truncateForPreview("hello"); - expect(result).toBe('"hello"'); - }); + test('handles string input', () => { + const result = truncateForPreview('hello') + expect(result).toBe('"hello"') + }) - test("handles null input", () => { - const result = truncateForPreview(null); - expect(result).toBe("null"); - }); + test('handles null input', () => { + const result = truncateForPreview(null) + expect(result).toBe('null') + }) - test("handles undefined input", () => { - const result = truncateForPreview(undefined); + test('handles undefined input', () => { + const result = truncateForPreview(undefined) // JSON.stringify(undefined) returns undefined, then .length throws → catch returns '(unserializable)' - expect(result).toBe("(unserializable)"); - }); -}); + expect(result).toBe('(unserializable)') + }) +}) -describe("PERMISSION_REPLY_RE", () => { +describe('PERMISSION_REPLY_RE', () => { test("matches 'y abcde'", () => { - expect(PERMISSION_REPLY_RE.test("y abcde")).toBe(true); - }); + expect(PERMISSION_REPLY_RE.test('y abcde')).toBe(true) + }) test("matches 'yes abcde'", () => { - expect(PERMISSION_REPLY_RE.test("yes abcde")).toBe(true); - }); + expect(PERMISSION_REPLY_RE.test('yes abcde')).toBe(true) + }) test("matches 'n abcde'", () => { - expect(PERMISSION_REPLY_RE.test("n abcde")).toBe(true); - }); + expect(PERMISSION_REPLY_RE.test('n abcde')).toBe(true) + }) test("matches 'no abcde'", () => { - expect(PERMISSION_REPLY_RE.test("no abcde")).toBe(true); - }); + expect(PERMISSION_REPLY_RE.test('no abcde')).toBe(true) + }) - test("is case-insensitive", () => { - expect(PERMISSION_REPLY_RE.test("Y abcde")).toBe(true); - expect(PERMISSION_REPLY_RE.test("YES abcde")).toBe(true); - }); + test('is case-insensitive', () => { + expect(PERMISSION_REPLY_RE.test('Y abcde')).toBe(true) + expect(PERMISSION_REPLY_RE.test('YES abcde')).toBe(true) + }) - test("does not match without ID", () => { - expect(PERMISSION_REPLY_RE.test("yes")).toBe(false); - }); + test('does not match without ID', () => { + expect(PERMISSION_REPLY_RE.test('yes')).toBe(false) + }) - test("captures the ID from reply", () => { - const match = "y abcde".match(PERMISSION_REPLY_RE); - expect(match?.[2]).toBe("abcde"); - }); -}); + test('captures the ID from reply', () => { + const match = 'y abcde'.match(PERMISSION_REPLY_RE) + expect(match?.[2]).toBe('abcde') + }) +}) -describe("createChannelPermissionCallbacks", () => { - test("resolve returns false for unknown request ID", () => { - const cb = createChannelPermissionCallbacks(); - expect(cb.resolve("unknown-id", "allow", "server")).toBe(false); - }); +describe('createChannelPermissionCallbacks', () => { + test('resolve returns false for unknown request ID', () => { + const cb = createChannelPermissionCallbacks() + expect(cb.resolve('unknown-id', 'allow', 'server')).toBe(false) + }) - test("onResponse + resolve triggers handler", () => { - const cb = createChannelPermissionCallbacks(); - let received: any = null; - cb.onResponse("test-id", (response) => { - received = response; - }); - expect(cb.resolve("test-id", "allow", "test-server")).toBe(true); + test('onResponse + resolve triggers handler', () => { + const cb = createChannelPermissionCallbacks() + let received: any = null + cb.onResponse('test-id', response => { + received = response + }) + expect(cb.resolve('test-id', 'allow', 'test-server')).toBe(true) expect(received).toEqual({ - behavior: "allow", - fromServer: "test-server", - }); - }); + behavior: 'allow', + fromServer: 'test-server', + }) + }) - test("onResponse unsubscribe prevents resolve", () => { - const cb = createChannelPermissionCallbacks(); - let called = false; - const unsub = cb.onResponse("test-id", () => { - called = true; - }); - unsub(); - expect(cb.resolve("test-id", "allow", "server")).toBe(false); - expect(called).toBe(false); - }); + test('onResponse unsubscribe prevents resolve', () => { + const cb = createChannelPermissionCallbacks() + let called = false + const unsub = cb.onResponse('test-id', () => { + called = true + }) + unsub() + expect(cb.resolve('test-id', 'allow', 'server')).toBe(false) + expect(called).toBe(false) + }) - test("duplicate resolve returns false (already consumed)", () => { - const cb = createChannelPermissionCallbacks(); - cb.onResponse("test-id", () => {}); - expect(cb.resolve("test-id", "allow", "server")).toBe(true); - expect(cb.resolve("test-id", "allow", "server")).toBe(false); - }); + test('duplicate resolve returns false (already consumed)', () => { + const cb = createChannelPermissionCallbacks() + cb.onResponse('test-id', () => {}) + expect(cb.resolve('test-id', 'allow', 'server')).toBe(true) + expect(cb.resolve('test-id', 'allow', 'server')).toBe(false) + }) - test("is case-insensitive for request IDs", () => { - const cb = createChannelPermissionCallbacks(); - let received: any = null; - cb.onResponse("ABC", (response) => { - received = response; - }); - expect(cb.resolve("abc", "deny", "server")).toBe(true); - expect(received?.behavior).toBe("deny"); - }); -}); + test('is case-insensitive for request IDs', () => { + const cb = createChannelPermissionCallbacks() + let received: any = null + cb.onResponse('ABC', response => { + received = response + }) + expect(cb.resolve('abc', 'deny', 'server')).toBe(true) + expect(received?.behavior).toBe('deny') + }) +}) diff --git a/src/services/mcp/__tests__/envExpansion.test.ts b/src/services/mcp/__tests__/envExpansion.test.ts index fe2032f2e..0f33378ab 100644 --- a/src/services/mcp/__tests__/envExpansion.test.ts +++ b/src/services/mcp/__tests__/envExpansion.test.ts @@ -1,139 +1,153 @@ -import { describe, expect, test, beforeEach, afterEach } from "bun:test"; -import { expandEnvVarsInString } from "../envExpansion"; +import { describe, expect, test, beforeEach, afterEach } from 'bun:test' +import { expandEnvVarsInString } from '../envExpansion' -describe("expandEnvVarsInString", () => { +describe('expandEnvVarsInString', () => { // Save and restore env vars touched by tests - const savedEnv: Record = {}; + const savedEnv: Record = {} const trackedKeys = [ - "TEST_HOME", - "MISSING", - "TEST_A", - "TEST_B", - "TEST_EMPTY", - "TEST_X", - "VAR", - "TEST_FOUND", - ]; + 'TEST_HOME', + 'MISSING', + 'TEST_A', + 'TEST_B', + 'TEST_EMPTY', + 'TEST_X', + 'VAR', + 'TEST_FOUND', + ] beforeEach(() => { for (const key of trackedKeys) { - savedEnv[key] = process.env[key]; + savedEnv[key] = process.env[key] } - }); + }) afterEach(() => { for (const key of trackedKeys) { if (savedEnv[key] === undefined) { - delete process.env[key]; + delete process.env[key] } else { - process.env[key] = savedEnv[key]; + process.env[key] = savedEnv[key] } } - }); + }) - test("expands a single env var that exists", () => { - process.env.TEST_HOME = "/home/user"; - const result = expandEnvVarsInString("${TEST_HOME}"); - expect(result.expanded).toBe("/home/user"); - expect(result.missingVars).toEqual([]); - }); + test('expands a single env var that exists', () => { + process.env.TEST_HOME = '/home/user' + // biome-ignore lint/suspicious/noTemplateCurlyInString: ${} is test input for env var expansion, not a JS template literal + const result = expandEnvVarsInString('${TEST_HOME}') + expect(result.expanded).toBe('/home/user') + expect(result.missingVars).toEqual([]) + }) - test("returns original placeholder and tracks missing var when not found", () => { - delete process.env.MISSING; - const result = expandEnvVarsInString("${MISSING}"); - expect(result.expanded).toBe("${MISSING}"); - expect(result.missingVars).toEqual(["MISSING"]); - }); + test('returns original placeholder and tracks missing var when not found', () => { + delete process.env.MISSING + // biome-ignore lint/suspicious/noTemplateCurlyInString: ${} is test input for env var expansion, not a JS template literal + const result = expandEnvVarsInString('${MISSING}') + // biome-ignore lint/suspicious/noTemplateCurlyInString: ${} is test input for env var expansion, not a JS template literal + expect(result.expanded).toBe('${MISSING}') + expect(result.missingVars).toEqual(['MISSING']) + }) - test("uses default value when var is missing and default is provided", () => { - delete process.env.MISSING; - const result = expandEnvVarsInString("${MISSING:-fallback}"); - expect(result.expanded).toBe("fallback"); - expect(result.missingVars).toEqual([]); - }); + test('uses default value when var is missing and default is provided', () => { + delete process.env.MISSING + // biome-ignore lint/suspicious/noTemplateCurlyInString: ${} is test input for env var expansion, not a JS template literal + const result = expandEnvVarsInString('${MISSING:-fallback}') + expect(result.expanded).toBe('fallback') + expect(result.missingVars).toEqual([]) + }) - test("expands multiple vars", () => { - process.env.TEST_A = "hello"; - process.env.TEST_B = "world"; - const result = expandEnvVarsInString("${TEST_A}/${TEST_B}"); - expect(result.expanded).toBe("hello/world"); - expect(result.missingVars).toEqual([]); - }); + test('expands multiple vars', () => { + process.env.TEST_A = 'hello' + process.env.TEST_B = 'world' + // biome-ignore lint/suspicious/noTemplateCurlyInString: ${} is test input for env var expansion, not a JS template literal + const result = expandEnvVarsInString('${TEST_A}/${TEST_B}') + expect(result.expanded).toBe('hello/world') + expect(result.missingVars).toEqual([]) + }) - test("handles mix of found and missing vars", () => { - process.env.TEST_FOUND = "yes"; - delete process.env.MISSING; - const result = expandEnvVarsInString("${TEST_FOUND}-${MISSING}"); - expect(result.expanded).toBe("yes-${MISSING}"); - expect(result.missingVars).toEqual(["MISSING"]); - }); + test('handles mix of found and missing vars', () => { + process.env.TEST_FOUND = 'yes' + delete process.env.MISSING + // biome-ignore lint/suspicious/noTemplateCurlyInString: ${} is test input for env var expansion, not a JS template literal + const result = expandEnvVarsInString('${TEST_FOUND}-${MISSING}') + // biome-ignore lint/suspicious/noTemplateCurlyInString: ${} is test input for env var expansion, not a JS template literal + expect(result.expanded).toBe('yes-${MISSING}') + expect(result.missingVars).toEqual(['MISSING']) + }) - test("returns plain string unchanged with empty missingVars", () => { - const result = expandEnvVarsInString("plain string"); - expect(result.expanded).toBe("plain string"); - expect(result.missingVars).toEqual([]); - }); + test('returns plain string unchanged with empty missingVars', () => { + const result = expandEnvVarsInString('plain string') + expect(result.expanded).toBe('plain string') + expect(result.missingVars).toEqual([]) + }) - test("expands empty env var value", () => { - process.env.TEST_EMPTY = ""; - const result = expandEnvVarsInString("${TEST_EMPTY}"); - expect(result.expanded).toBe(""); - expect(result.missingVars).toEqual([]); - }); + test('expands empty env var value', () => { + process.env.TEST_EMPTY = '' + // biome-ignore lint/suspicious/noTemplateCurlyInString: ${} is test input for env var expansion, not a JS template literal + const result = expandEnvVarsInString('${TEST_EMPTY}') + expect(result.expanded).toBe('') + expect(result.missingVars).toEqual([]) + }) - test("prefers env var value over default when var exists", () => { - process.env.TEST_X = "real"; - const result = expandEnvVarsInString("${TEST_X:-default}"); - expect(result.expanded).toBe("real"); - expect(result.missingVars).toEqual([]); - }); + test('prefers env var value over default when var exists', () => { + process.env.TEST_X = 'real' + // biome-ignore lint/suspicious/noTemplateCurlyInString: ${} is test input for env var expansion, not a JS template literal + const result = expandEnvVarsInString('${TEST_X:-default}') + expect(result.expanded).toBe('real') + expect(result.missingVars).toEqual([]) + }) - test("handles default value containing colons", () => { + test('handles default value containing colons', () => { // split(':-', 2) means only the first :- is the delimiter - delete process.env.TEST_X; - const result = expandEnvVarsInString("${TEST_X:-value:-with:-colons}"); + delete process.env.TEST_X + // biome-ignore lint/suspicious/noTemplateCurlyInString: ${} is test input for env var expansion, not a JS template literal + const result = expandEnvVarsInString('${TEST_X:-value:-with:-colons}') // The default is "value" because split(':-', 2) gives ["TEST_X", "value"] // Wait -- actually split(':-', 2) on "TEST_X:-value:-with:-colons" gives: // ["TEST_X", "value"] because limit=2 stops at 2 pieces - expect(result.expanded).toBe("value"); - expect(result.missingVars).toEqual([]); - }); + expect(result.expanded).toBe('value') + expect(result.missingVars).toEqual([]) + }) - test("handles nested-looking syntax as literal (not supported)", () => { + test('handles nested-looking syntax as literal (not supported)', () => { // ${${VAR}} - the regex [^}]+ matches "${VAR" (up to first }) // so varName would be "${VAR" which won't be found in env - delete process.env.VAR; - const result = expandEnvVarsInString("${${VAR}}"); + delete process.env.VAR + // biome-ignore lint/suspicious/noTemplateCurlyInString: ${} is test input for env var expansion, not a JS template literal + const result = expandEnvVarsInString('${${VAR}}') // The regex \$\{([^}]+)\} matches "${${VAR}" with capture "${VAR" // That env var won't exist, so it stays as "${${VAR}" + remaining "}" - expect(result.missingVars).toEqual(["${VAR"]); - expect(result.expanded).toBe("${${VAR}}"); - }); + expect(result.missingVars).toEqual(['${VAR']) + // biome-ignore lint/suspicious/noTemplateCurlyInString: ${} is test input for env var expansion, not a JS template literal + expect(result.expanded).toBe('${${VAR}}') + }) - test("handles empty string input", () => { - const result = expandEnvVarsInString(""); - expect(result.expanded).toBe(""); - expect(result.missingVars).toEqual([]); - }); + test('handles empty string input', () => { + const result = expandEnvVarsInString('') + expect(result.expanded).toBe('') + expect(result.missingVars).toEqual([]) + }) - test("handles var surrounded by text", () => { - process.env.TEST_A = "middle"; - const result = expandEnvVarsInString("before-${TEST_A}-after"); - expect(result.expanded).toBe("before-middle-after"); - expect(result.missingVars).toEqual([]); - }); + test('handles var surrounded by text', () => { + process.env.TEST_A = 'middle' + // biome-ignore lint/suspicious/noTemplateCurlyInString: ${} is test input for env var expansion, not a JS template literal + const result = expandEnvVarsInString('before-${TEST_A}-after') + expect(result.expanded).toBe('before-middle-after') + expect(result.missingVars).toEqual([]) + }) - test("handles default value that is empty string", () => { - delete process.env.MISSING; - const result = expandEnvVarsInString("${MISSING:-}"); - expect(result.expanded).toBe(""); - expect(result.missingVars).toEqual([]); - }); + test('handles default value that is empty string', () => { + delete process.env.MISSING + // biome-ignore lint/suspicious/noTemplateCurlyInString: ${} is test input for env var expansion, not a JS template literal + const result = expandEnvVarsInString('${MISSING:-}') + expect(result.expanded).toBe('') + expect(result.missingVars).toEqual([]) + }) - test("does not expand $VAR without braces", () => { - process.env.TEST_A = "value"; - const result = expandEnvVarsInString("$TEST_A"); - expect(result.expanded).toBe("$TEST_A"); - expect(result.missingVars).toEqual([]); - }); -}); + test('does not expand $VAR without braces', () => { + process.env.TEST_A = 'value' + const result = expandEnvVarsInString('$TEST_A') + expect(result.expanded).toBe('$TEST_A') + expect(result.missingVars).toEqual([]) + }) +}) diff --git a/src/services/mcp/__tests__/filterUtils.test.ts b/src/services/mcp/__tests__/filterUtils.test.ts index eecd8d8dc..61e37aafe 100644 --- a/src/services/mcp/__tests__/filterUtils.test.ts +++ b/src/services/mcp/__tests__/filterUtils.test.ts @@ -1,11 +1,11 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test } from 'bun:test' // parseHeaders is a pure function from ../utils.ts (line 325) // Copied here to avoid triggering the heavy import chain of utils.ts function parseHeaders(headerArray: string[]): Record { const headers: Record = {} for (const header of headerArray) { - const colonIndex = header.indexOf(":") + const colonIndex = header.indexOf(':') if (colonIndex === -1) { throw new Error( `Invalid header format: "${header}". Expected format: "Header-Name: value"`, @@ -23,43 +23,43 @@ function parseHeaders(headerArray: string[]): Record { return headers } -describe("parseHeaders", () => { +describe('parseHeaders', () => { test("parses 'Key: Value' format", () => { - expect(parseHeaders(["Content-Type: application/json"])).toEqual({ - "Content-Type": "application/json", - }); - }); + expect(parseHeaders(['Content-Type: application/json'])).toEqual({ + 'Content-Type': 'application/json', + }) + }) - test("parses multiple headers", () => { - expect(parseHeaders(["Key1: val1", "Key2: val2"])).toEqual({ - Key1: "val1", - Key2: "val2", - }); - }); + test('parses multiple headers', () => { + expect(parseHeaders(['Key1: val1', 'Key2: val2'])).toEqual({ + Key1: 'val1', + Key2: 'val2', + }) + }) - test("trims whitespace around key and value", () => { - expect(parseHeaders([" Key : Value "])).toEqual({ Key: "Value" }); - }); + test('trims whitespace around key and value', () => { + expect(parseHeaders([' Key : Value '])).toEqual({ Key: 'Value' }) + }) - test("throws on missing colon", () => { - expect(() => parseHeaders(["no colon here"])).toThrow(); - }); + test('throws on missing colon', () => { + expect(() => parseHeaders(['no colon here'])).toThrow() + }) - test("throws on empty key", () => { - expect(() => parseHeaders([": value"])).toThrow(); - }); + test('throws on empty key', () => { + expect(() => parseHeaders([': value'])).toThrow() + }) - test("handles value with colons (like URLs)", () => { - expect(parseHeaders(["url: http://example.com:8080"])).toEqual({ - url: "http://example.com:8080", - }); - }); + test('handles value with colons (like URLs)', () => { + expect(parseHeaders(['url: http://example.com:8080'])).toEqual({ + url: 'http://example.com:8080', + }) + }) - test("returns empty object for empty array", () => { - expect(parseHeaders([])).toEqual({}); - }); + test('returns empty object for empty array', () => { + expect(parseHeaders([])).toEqual({}) + }) - test("handles duplicate keys (last wins)", () => { - expect(parseHeaders(["K: v1", "K: v2"])).toEqual({ K: "v2" }); - }); -}); + test('handles duplicate keys (last wins)', () => { + expect(parseHeaders(['K: v1', 'K: v2'])).toEqual({ K: 'v2' }) + }) +}) diff --git a/src/services/mcp/__tests__/mcpStringUtils.test.ts b/src/services/mcp/__tests__/mcpStringUtils.test.ts index 0b8d22bdf..3d2212558 100644 --- a/src/services/mcp/__tests__/mcpStringUtils.test.ts +++ b/src/services/mcp/__tests__/mcpStringUtils.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test } from 'bun:test' import { mcpInfoFromString, buildMcpToolName, @@ -6,135 +6,133 @@ import { getMcpDisplayName, getToolNameForPermissionCheck, extractMcpToolDisplayName, -} from "../mcpStringUtils"; +} from '../mcpStringUtils' // ─── mcpInfoFromString ───────────────────────────────────────────────── -describe("mcpInfoFromString", () => { - test("parses standard mcp tool name", () => { - const result = mcpInfoFromString("mcp__github__list_issues"); - expect(result).toEqual({ serverName: "github", toolName: "list_issues" }); - }); +describe('mcpInfoFromString', () => { + test('parses standard mcp tool name', () => { + const result = mcpInfoFromString('mcp__github__list_issues') + expect(result).toEqual({ serverName: 'github', toolName: 'list_issues' }) + }) - test("returns null for non-mcp string", () => { - expect(mcpInfoFromString("Bash")).toBeNull(); - expect(mcpInfoFromString("grep__pattern")).toBeNull(); - }); + test('returns null for non-mcp string', () => { + expect(mcpInfoFromString('Bash')).toBeNull() + expect(mcpInfoFromString('grep__pattern')).toBeNull() + }) - test("returns null when no server name", () => { - expect(mcpInfoFromString("mcp__")).toBeNull(); - }); + test('returns null when no server name', () => { + expect(mcpInfoFromString('mcp__')).toBeNull() + }) - test("handles server name only (no tool)", () => { - const result = mcpInfoFromString("mcp__server"); - expect(result).toEqual({ serverName: "server", toolName: undefined }); - }); + test('handles server name only (no tool)', () => { + const result = mcpInfoFromString('mcp__server') + expect(result).toEqual({ serverName: 'server', toolName: undefined }) + }) - test("preserves double underscores in tool name", () => { - const result = mcpInfoFromString("mcp__server__tool__with__underscores"); + test('preserves double underscores in tool name', () => { + const result = mcpInfoFromString('mcp__server__tool__with__underscores') expect(result).toEqual({ - serverName: "server", - toolName: "tool__with__underscores", - }); - }); + serverName: 'server', + toolName: 'tool__with__underscores', + }) + }) - test("returns null for empty string", () => { - expect(mcpInfoFromString("")).toBeNull(); - }); -}); + test('returns null for empty string', () => { + expect(mcpInfoFromString('')).toBeNull() + }) +}) // ─── getMcpPrefix ────────────────────────────────────────────────────── -describe("getMcpPrefix", () => { - test("creates prefix from server name", () => { - expect(getMcpPrefix("github")).toBe("mcp__github__"); - }); +describe('getMcpPrefix', () => { + test('creates prefix from server name', () => { + expect(getMcpPrefix('github')).toBe('mcp__github__') + }) - test("normalizes server name with special chars", () => { - expect(getMcpPrefix("my-server")).toBe("mcp__my-server__"); - }); + test('normalizes server name with special chars', () => { + expect(getMcpPrefix('my-server')).toBe('mcp__my-server__') + }) - test("normalizes dots to underscores", () => { - expect(getMcpPrefix("my.server")).toBe("mcp__my_server__"); - }); -}); + test('normalizes dots to underscores', () => { + expect(getMcpPrefix('my.server')).toBe('mcp__my_server__') + }) +}) // ─── buildMcpToolName ────────────────────────────────────────────────── -describe("buildMcpToolName", () => { - test("builds fully qualified name", () => { - expect(buildMcpToolName("github", "list_issues")).toBe( - "mcp__github__list_issues" - ); - }); +describe('buildMcpToolName', () => { + test('builds fully qualified name', () => { + expect(buildMcpToolName('github', 'list_issues')).toBe( + 'mcp__github__list_issues', + ) + }) - test("normalizes both server and tool names", () => { - expect(buildMcpToolName("my.server", "my.tool")).toBe( - "mcp__my_server__my_tool" - ); - }); -}); + test('normalizes both server and tool names', () => { + expect(buildMcpToolName('my.server', 'my.tool')).toBe( + 'mcp__my_server__my_tool', + ) + }) +}) // ─── getMcpDisplayName ───────────────────────────────────────────────── -describe("getMcpDisplayName", () => { - test("strips mcp prefix from full name", () => { - expect(getMcpDisplayName("mcp__github__list_issues", "github")).toBe( - "list_issues" - ); - }); +describe('getMcpDisplayName', () => { + test('strips mcp prefix from full name', () => { + expect(getMcpDisplayName('mcp__github__list_issues', 'github')).toBe( + 'list_issues', + ) + }) test("returns full name if prefix doesn't match", () => { - expect(getMcpDisplayName("mcp__other__tool", "github")).toBe( - "mcp__other__tool" - ); - }); -}); + expect(getMcpDisplayName('mcp__other__tool', 'github')).toBe( + 'mcp__other__tool', + ) + }) +}) // ─── getToolNameForPermissionCheck ───────────────────────────────────── -describe("getToolNameForPermissionCheck", () => { - test("returns built MCP name for MCP tools", () => { +describe('getToolNameForPermissionCheck', () => { + test('returns built MCP name for MCP tools', () => { const tool = { - name: "list_issues", - mcpInfo: { serverName: "github", toolName: "list_issues" }, - }; - expect(getToolNameForPermissionCheck(tool)).toBe( - "mcp__github__list_issues" - ); - }); + name: 'list_issues', + mcpInfo: { serverName: 'github', toolName: 'list_issues' }, + } + expect(getToolNameForPermissionCheck(tool)).toBe('mcp__github__list_issues') + }) - test("returns tool name for non-MCP tools", () => { - const tool = { name: "Bash" }; - expect(getToolNameForPermissionCheck(tool)).toBe("Bash"); - }); + test('returns tool name for non-MCP tools', () => { + const tool = { name: 'Bash' } + expect(getToolNameForPermissionCheck(tool)).toBe('Bash') + }) - test("returns tool name when mcpInfo is undefined", () => { - const tool = { name: "Write", mcpInfo: undefined }; - expect(getToolNameForPermissionCheck(tool)).toBe("Write"); - }); -}); + test('returns tool name when mcpInfo is undefined', () => { + const tool = { name: 'Write', mcpInfo: undefined } + expect(getToolNameForPermissionCheck(tool)).toBe('Write') + }) +}) // ─── extractMcpToolDisplayName ───────────────────────────────────────── -describe("extractMcpToolDisplayName", () => { - test("extracts display name from full user-facing name", () => { +describe('extractMcpToolDisplayName', () => { + test('extracts display name from full user-facing name', () => { expect( - extractMcpToolDisplayName("github - Add comment to issue (MCP)") - ).toBe("Add comment to issue"); - }); + extractMcpToolDisplayName('github - Add comment to issue (MCP)'), + ).toBe('Add comment to issue') + }) - test("removes (MCP) suffix only", () => { - expect(extractMcpToolDisplayName("simple-tool (MCP)")).toBe("simple-tool"); - }); + test('removes (MCP) suffix only', () => { + expect(extractMcpToolDisplayName('simple-tool (MCP)')).toBe('simple-tool') + }) - test("handles name without (MCP) suffix", () => { - expect(extractMcpToolDisplayName("github - List issues")).toBe( - "List issues" - ); - }); + test('handles name without (MCP) suffix', () => { + expect(extractMcpToolDisplayName('github - List issues')).toBe( + 'List issues', + ) + }) - test("handles name without dash separator", () => { - expect(extractMcpToolDisplayName("just-a-name")).toBe("just-a-name"); - }); -}); + test('handles name without dash separator', () => { + expect(extractMcpToolDisplayName('just-a-name')).toBe('just-a-name') + }) +}) diff --git a/src/services/mcp/__tests__/normalization.test.ts b/src/services/mcp/__tests__/normalization.test.ts index 9b3b6991b..49cc34557 100644 --- a/src/services/mcp/__tests__/normalization.test.ts +++ b/src/services/mcp/__tests__/normalization.test.ts @@ -1,59 +1,59 @@ -import { describe, expect, test } from "bun:test"; -import { normalizeNameForMCP } from "../normalization"; +import { describe, expect, test } from 'bun:test' +import { normalizeNameForMCP } from '../normalization' -describe("normalizeNameForMCP", () => { - test("returns simple valid name unchanged", () => { - expect(normalizeNameForMCP("my-server")).toBe("my-server"); - }); +describe('normalizeNameForMCP', () => { + test('returns simple valid name unchanged', () => { + expect(normalizeNameForMCP('my-server')).toBe('my-server') + }) - test("replaces dots with underscores", () => { - expect(normalizeNameForMCP("my.server.name")).toBe("my_server_name"); - }); + test('replaces dots with underscores', () => { + expect(normalizeNameForMCP('my.server.name')).toBe('my_server_name') + }) - test("replaces spaces with underscores", () => { - expect(normalizeNameForMCP("my server")).toBe("my_server"); - }); + test('replaces spaces with underscores', () => { + expect(normalizeNameForMCP('my server')).toBe('my_server') + }) - test("replaces special characters with underscores", () => { - expect(normalizeNameForMCP("server@v2!")).toBe("server_v2_"); - }); + test('replaces special characters with underscores', () => { + expect(normalizeNameForMCP('server@v2!')).toBe('server_v2_') + }) - test("returns already valid name unchanged", () => { - expect(normalizeNameForMCP("valid_name-123")).toBe("valid_name-123"); - }); + test('returns already valid name unchanged', () => { + expect(normalizeNameForMCP('valid_name-123')).toBe('valid_name-123') + }) - test("returns empty string for empty input", () => { - expect(normalizeNameForMCP("")).toBe(""); - }); + test('returns empty string for empty input', () => { + expect(normalizeNameForMCP('')).toBe('') + }) - test("handles claude.ai prefix: collapses consecutive underscores and strips edges", () => { + test('handles claude.ai prefix: collapses consecutive underscores and strips edges', () => { // "claude.ai My Server" -> replace invalid -> "claude_ai_My_Server" // starts with "claude.ai " so collapse + strip -> "claude_ai_My_Server" - expect(normalizeNameForMCP("claude.ai My Server")).toBe( - "claude_ai_My_Server" - ); - }); + expect(normalizeNameForMCP('claude.ai My Server')).toBe( + 'claude_ai_My_Server', + ) + }) - test("handles claude.ai prefix with consecutive invalid chars", () => { + test('handles claude.ai prefix with consecutive invalid chars', () => { // "claude.ai ...test..." -> replace invalid -> "claude_ai____test___" // collapse consecutive _ -> "claude_ai_test_" // strip leading/trailing _ -> "claude_ai_test" - expect(normalizeNameForMCP("claude.ai ...test...")).toBe("claude_ai_test"); - }); + expect(normalizeNameForMCP('claude.ai ...test...')).toBe('claude_ai_test') + }) - test("non-claude.ai name preserves consecutive underscores", () => { + test('non-claude.ai name preserves consecutive underscores', () => { // "a..b" -> "a__b", no claude.ai prefix so no collapse - expect(normalizeNameForMCP("a..b")).toBe("a__b"); - }); + expect(normalizeNameForMCP('a..b')).toBe('a__b') + }) - test("non-claude.ai name preserves trailing underscores", () => { - expect(normalizeNameForMCP("name!")).toBe("name_"); - }); + test('non-claude.ai name preserves trailing underscores', () => { + expect(normalizeNameForMCP('name!')).toBe('name_') + }) - test("handles claude.ai prefix that results in only underscores", () => { + test('handles claude.ai prefix that results in only underscores', () => { // "claude.ai ..." -> replace invalid -> "claude_ai____" // collapse -> "claude_ai_" // strip trailing -> "claude_ai" - expect(normalizeNameForMCP("claude.ai ...")).toBe("claude_ai"); - }); -}); + expect(normalizeNameForMCP('claude.ai ...')).toBe('claude_ai') + }) +}) diff --git a/src/services/mcp/__tests__/officialRegistry.test.ts b/src/services/mcp/__tests__/officialRegistry.test.ts index ffb4b94c9..468d17049 100644 --- a/src/services/mcp/__tests__/officialRegistry.test.ts +++ b/src/services/mcp/__tests__/officialRegistry.test.ts @@ -1,45 +1,45 @@ -import { mock, describe, expect, test, afterEach } from "bun:test"; +import { mock, describe, expect, test, afterEach } from 'bun:test' -mock.module("axios", () => ({ +mock.module('axios', () => ({ default: { get: async () => ({ data: { servers: [] } }) }, -})); -mock.module("src/utils/debug.js", () => ({ +})) +mock.module('src/utils/debug.js', () => ({ logForDebugging: () => {}, -})); -mock.module("src/utils/errors.js", () => ({ +})) +mock.module('src/utils/errors.js', () => ({ errorMessage: (e: any) => String(e), -})); +})) const { isOfficialMcpUrl, resetOfficialMcpUrlsForTesting } = await import( - "../officialRegistry" -); + '../officialRegistry' +) -describe("isOfficialMcpUrl", () => { +describe('isOfficialMcpUrl', () => { afterEach(() => { - resetOfficialMcpUrlsForTesting(); - }); + resetOfficialMcpUrlsForTesting() + }) - test("returns false when registry not loaded (initial state)", () => { - resetOfficialMcpUrlsForTesting(); - expect(isOfficialMcpUrl("https://example.com")).toBe(false); - }); + test('returns false when registry not loaded (initial state)', () => { + resetOfficialMcpUrlsForTesting() + expect(isOfficialMcpUrl('https://example.com')).toBe(false) + }) - test("returns false for non-registered URL", () => { - expect(isOfficialMcpUrl("https://random-server.com/mcp")).toBe(false); - }); + test('returns false for non-registered URL', () => { + expect(isOfficialMcpUrl('https://random-server.com/mcp')).toBe(false) + }) - test("returns false for empty string", () => { - expect(isOfficialMcpUrl("")).toBe(false); - }); -}); + test('returns false for empty string', () => { + expect(isOfficialMcpUrl('')).toBe(false) + }) +}) -describe("resetOfficialMcpUrlsForTesting", () => { - test("can be called without error", () => { - expect(() => resetOfficialMcpUrlsForTesting()).not.toThrow(); - }); +describe('resetOfficialMcpUrlsForTesting', () => { + test('can be called without error', () => { + expect(() => resetOfficialMcpUrlsForTesting()).not.toThrow() + }) - test("clears state so subsequent lookups return false", () => { - resetOfficialMcpUrlsForTesting(); - expect(isOfficialMcpUrl("https://anything.com")).toBe(false); - }); -}); + test('clears state so subsequent lookups return false', () => { + resetOfficialMcpUrlsForTesting() + expect(isOfficialMcpUrl('https://anything.com')).toBe(false) + }) +}) diff --git a/src/services/mcp/adapter/analytics.ts b/src/services/mcp/adapter/analytics.ts index 6b3bf46c1..c30b7b572 100644 --- a/src/services/mcp/adapter/analytics.ts +++ b/src/services/mcp/adapter/analytics.ts @@ -12,7 +12,13 @@ import { export function createMcpAnalytics(): AnalyticsSink { return { trackEvent(event: string, metadata: Record) { - logEvent(event, metadata as Record) + logEvent( + event, + metadata as Record< + string, + AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS + >, + ) }, } } diff --git a/src/services/mcp/adapter/imageProcessor.ts b/src/services/mcp/adapter/imageProcessor.ts index f8670d64a..616823360 100644 --- a/src/services/mcp/adapter/imageProcessor.ts +++ b/src/services/mcp/adapter/imageProcessor.ts @@ -9,7 +9,11 @@ import { maybeResizeAndDownsampleImageBuffer } from '../../../utils/imageResizer export function createMcpImageProcessor(): ImageProcessor { return { async resizeAndDownsample(buffer: Buffer) { - const result = await maybeResizeAndDownsampleImageBuffer(buffer, buffer.length, 'png') + const result = await maybeResizeAndDownsampleImageBuffer( + buffer, + buffer.length, + 'png', + ) return result.buffer }, } diff --git a/src/services/mcp/adapter/storage.ts b/src/services/mcp/adapter/storage.ts index 8ba2f2526..e3455640a 100644 --- a/src/services/mcp/adapter/storage.ts +++ b/src/services/mcp/adapter/storage.ts @@ -2,7 +2,10 @@ import type { ContentStorage } from '@claude-code-best/mcp-client' import { persistBinaryContent } from '../../../utils/mcpOutputStorage.js' -import { persistToolResult, isPersistError } from '../../../utils/toolResultStorage.js' +import { + persistToolResult, + isPersistError, +} from '../../../utils/toolResultStorage.js' /** * Creates a ContentStorage implementation using the host's binary persistence. @@ -10,7 +13,11 @@ import { persistToolResult, isPersistError } from '../../../utils/toolResultStor export function createMcpStorage(): ContentStorage { return { async persistBinaryContent(data: Buffer, ext: string) { - const result = await persistBinaryContent(data, ext, `mcp-adapter-${Date.now()}`) + const result = await persistBinaryContent( + data, + ext, + `mcp-adapter-${Date.now()}`, + ) if ('error' in result) { throw new Error(result.error) } diff --git a/src/services/mcp/auth.ts b/src/services/mcp/auth.ts index 61c946364..72b741061 100644 --- a/src/services/mcp/auth.ts +++ b/src/services/mcp/auth.ts @@ -2346,7 +2346,7 @@ export class ClaudeAuthProvider implements OAuthClientProvider { return undefined } - const delayMs = 1000 * Math.pow(2, attempt - 1) // 1s, 2s, 4s + const delayMs = 1000 * 2 ** (attempt - 1) // 1s, 2s, 4s logMCPDebug( this.serverName, `Token refresh failed, retrying in ${delayMs}ms (attempt ${attempt}/${MAX_ATTEMPTS})`, diff --git a/src/services/mcp/client.ts b/src/services/mcp/client.ts index 00576954d..82e46b6e7 100644 --- a/src/services/mcp/client.ts +++ b/src/services/mcp/client.ts @@ -51,7 +51,10 @@ import { toolMatchesName, } from '../../Tool.js' import { ListMcpResourcesTool } from '@claude-code-best/builtin-tools/tools/ListMcpResourcesTool/ListMcpResourcesTool.js' -import { type MCPProgress, MCPTool } from '@claude-code-best/builtin-tools/tools/MCPTool/MCPTool.js' +import { + type MCPProgress, + MCPTool, +} from '@claude-code-best/builtin-tools/tools/MCPTool/MCPTool.js' import { createMcpAuthTool } from '@claude-code-best/builtin-tools/tools/McpAuthTool/McpAuthTool.js' import { ReadMcpResourceTool } from '@claude-code-best/builtin-tools/tools/ReadMcpResourceTool/ReadMcpResourceTool.js' import { createAbortController } from '../../utils/abortController.js' @@ -903,7 +906,8 @@ export const connectToServer = memoize( ) logMCPDebug(name, `claude.ai proxy transport created successfully`) } else if ( - ((serverRef as ScopedMcpServerConfig).type === 'stdio' || !(serverRef as ScopedMcpServerConfig).type) && + ((serverRef as ScopedMcpServerConfig).type === 'stdio' || + !(serverRef as ScopedMcpServerConfig).type) && isClaudeInChromeMCPServer(name) ) { // Run the Chrome MCP server in-process to avoid spawning a ~325 MB subprocess @@ -916,7 +920,9 @@ export const connectToServer = memoize( const { createLinkedTransportPair } = await import( './InProcessTransport.js' ) - const context = createChromeContext((serverRef as McpStdioServerConfig).env) + const context = createChromeContext( + (serverRef as McpStdioServerConfig).env, + ) inProcessServer = createClaudeForChromeMcpServer(context) const [clientTransport, serverTransport] = createLinkedTransportPair() await inProcessServer.connect(serverTransport) @@ -924,7 +930,8 @@ export const connectToServer = memoize( logMCPDebug(name, `In-process Chrome MCP server started`) } else if ( feature('CHICAGO_MCP') && - ((serverRef as ScopedMcpServerConfig).type === 'stdio' || !(serverRef as ScopedMcpServerConfig).type) && + ((serverRef as ScopedMcpServerConfig).type === 'stdio' || + !(serverRef as ScopedMcpServerConfig).type) && isComputerUseMCPServer!(name) ) { // Run the Computer Use MCP server in-process — same rationale as @@ -941,7 +948,10 @@ export const connectToServer = memoize( await inProcessServer.connect(serverTransport) transport = clientTransport logMCPDebug(name, `In-process Computer Use MCP server started`) - } else if ((serverRef as ScopedMcpServerConfig).type === 'stdio' || !(serverRef as ScopedMcpServerConfig).type) { + } else if ( + (serverRef as ScopedMcpServerConfig).type === 'stdio' || + !(serverRef as ScopedMcpServerConfig).type + ) { const stdioRef = serverRef as McpStdioServerConfig const finalCommand = process.env.CLAUDE_CODE_SHELL_PREFIX || stdioRef.command @@ -958,7 +968,9 @@ export const connectToServer = memoize( stderr: 'pipe', // prevents error output from the MCP server from printing to the UI }) } else { - throw new Error(`Unsupported server type: ${(serverRef as ScopedMcpServerConfig).type}`) + throw new Error( + `Unsupported server type: ${(serverRef as ScopedMcpServerConfig).type}`, + ) } // Set up stderr logging for stdio transport before connecting in case there are any stderr @@ -1444,6 +1456,7 @@ export const connectToServer = memoize( } // Wait for graceful shutdown with rapid escalation (total 500ms to keep CLI responsive) + // biome-ignore lint/suspicious/noAsyncPromiseExecutor: async needed for sequential await inside executor await new Promise(async resolve => { let resolved = false @@ -3246,8 +3259,14 @@ async function callMCPTool({ } function extractToolUseId(message: AssistantMessage): string | undefined { - const firstBlock = (message.message.content as ContentBlockParam[] | undefined)?.[0] - if (!firstBlock || typeof firstBlock === 'string' || firstBlock.type !== 'tool_use') { + const firstBlock = ( + message.message.content as ContentBlockParam[] | undefined + )?.[0] + if ( + !firstBlock || + typeof firstBlock === 'string' || + firstBlock.type !== 'tool_use' + ) { return undefined } return firstBlock.id diff --git a/src/services/mcp/config.ts b/src/services/mcp/config.ts index dc0300dc8..e627c2840 100644 --- a/src/services/mcp/config.ts +++ b/src/services/mcp/config.ts @@ -1351,7 +1351,7 @@ export function parseMcpConfig(params: { if ( getPlatform() === 'windows' && (!configToCheck.type || configToCheck.type === 'stdio') && - ('command' in configToCheck) && + 'command' in configToCheck && (configToCheck.command === 'npx' || configToCheck.command.endsWith('\\npx') || configToCheck.command.endsWith('/npx')) diff --git a/src/services/mcp/oauthPort.ts b/src/services/mcp/oauthPort.ts index 8cf8ce66e..595b9b1a6 100644 --- a/src/services/mcp/oauthPort.ts +++ b/src/services/mcp/oauthPort.ts @@ -56,10 +56,7 @@ export async function findAvailablePort(): Promise { }) }) return port - } catch { - // Port in use, try another random port - continue - } + } catch {} } // If random selection failed, try the fallback port diff --git a/src/services/mcp/officialRegistry.ts b/src/services/mcp/officialRegistry.ts index 3e26292a3..b5c973013 100644 --- a/src/services/mcp/officialRegistry.ts +++ b/src/services/mcp/officialRegistry.ts @@ -14,7 +14,7 @@ type RegistryResponse = { // URLs stripped of query string and trailing slash — matches the normalization // done by getLoggingSafeMcpBaseUrl so direct Set.has() lookup works. -let officialUrls: Set | undefined = undefined +let officialUrls: Set | undefined function normalizeUrl(url: string): string | undefined { try { diff --git a/src/services/mcp/src/constants/oauth.ts b/src/services/mcp/src/constants/oauth.ts index a1b08933a..ae2abf9b1 100644 --- a/src/services/mcp/src/constants/oauth.ts +++ b/src/services/mcp/src/constants/oauth.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getOauthConfig = any; +export type getOauthConfig = any diff --git a/src/services/mcp/src/services/analytics/index.ts b/src/services/mcp/src/services/analytics/index.ts index 142e7b6f5..2e0f796da 100644 --- a/src/services/mcp/src/services/analytics/index.ts +++ b/src/services/mcp/src/services/analytics/index.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any; -export type logEvent = any; +export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any +export type logEvent = any diff --git a/src/services/mcp/src/services/mcp/config.ts b/src/services/mcp/src/services/mcp/config.ts index 109c9d3cc..3f4037185 100644 --- a/src/services/mcp/src/services/mcp/config.ts +++ b/src/services/mcp/src/services/mcp/config.ts @@ -1,7 +1,7 @@ // Auto-generated type stub — replace with real implementation -export type dedupClaudeAiMcpServers = any; -export type doesEnterpriseMcpConfigExist = any; -export type filterMcpServersByPolicy = any; -export type getClaudeCodeMcpConfigs = any; -export type isMcpServerDisabled = any; -export type setMcpServerEnabled = any; +export type dedupClaudeAiMcpServers = any +export type doesEnterpriseMcpConfigExist = any +export type filterMcpServersByPolicy = any +export type getClaudeCodeMcpConfigs = any +export type isMcpServerDisabled = any +export type setMcpServerEnabled = any diff --git a/src/services/mcp/src/state/AppState.ts b/src/services/mcp/src/state/AppState.ts index caf2928ae..fec2e89be 100644 --- a/src/services/mcp/src/state/AppState.ts +++ b/src/services/mcp/src/state/AppState.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type AppState = any; +export type AppState = any diff --git a/src/services/mcp/src/types/message.ts b/src/services/mcp/src/types/message.ts index a689141e4..de5e47b85 100644 --- a/src/services/mcp/src/types/message.ts +++ b/src/services/mcp/src/types/message.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type AssistantMessage = any; +export type AssistantMessage = any diff --git a/src/services/mcp/src/types/plugin.ts b/src/services/mcp/src/types/plugin.ts index 5129b6880..577391cf4 100644 --- a/src/services/mcp/src/types/plugin.ts +++ b/src/services/mcp/src/types/plugin.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type PluginError = any; +export type PluginError = any diff --git a/src/services/mcp/src/utils/auth.ts b/src/services/mcp/src/utils/auth.ts index 118c05735..2e9fa96c1 100644 --- a/src/services/mcp/src/utils/auth.ts +++ b/src/services/mcp/src/utils/auth.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getClaudeAIOAuthTokens = any; +export type getClaudeAIOAuthTokens = any diff --git a/src/services/mcp/src/utils/config.ts b/src/services/mcp/src/utils/config.ts index 7cf15deca..b3f5e0f8c 100644 --- a/src/services/mcp/src/utils/config.ts +++ b/src/services/mcp/src/utils/config.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type getGlobalConfig = any; -export type saveGlobalConfig = any; +export type getGlobalConfig = any +export type saveGlobalConfig = any diff --git a/src/services/mcp/src/utils/debug.ts b/src/services/mcp/src/utils/debug.ts index c64d5960c..5a0f1d515 100644 --- a/src/services/mcp/src/utils/debug.ts +++ b/src/services/mcp/src/utils/debug.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type logForDebugging = any; +export type logForDebugging = any diff --git a/src/services/mcp/src/utils/envUtils.ts b/src/services/mcp/src/utils/envUtils.ts index f6fa62ed7..85de6b353 100644 --- a/src/services/mcp/src/utils/envUtils.ts +++ b/src/services/mcp/src/utils/envUtils.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type isEnvDefinedFalsy = any; +export type isEnvDefinedFalsy = any diff --git a/src/services/mcp/src/utils/platform.ts b/src/services/mcp/src/utils/platform.ts index b6686f812..c7486cc77 100644 --- a/src/services/mcp/src/utils/platform.ts +++ b/src/services/mcp/src/utils/platform.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getPlatform = any; +export type getPlatform = any diff --git a/src/services/mcp/useManageMCPConnections.ts b/src/services/mcp/useManageMCPConnections.ts index 80b82d688..4b9d93e8a 100644 --- a/src/services/mcp/useManageMCPConnections.ts +++ b/src/services/mcp/useManageMCPConnections.ts @@ -445,7 +445,7 @@ export function useManageMCPConnections( // Schedule next retry with exponential backoff const backoffMs = Math.min( - INITIAL_BACKOFF_MS * Math.pow(2, attempt - 1), + INITIAL_BACKOFF_MS * 2 ** (attempt - 1), MAX_BACKOFF_MS, ) logMCPDebug( diff --git a/src/services/mcpServerApproval.tsx b/src/services/mcpServerApproval.tsx index 78be862f3..92b76bff6 100644 --- a/src/services/mcpServerApproval.tsx +++ b/src/services/mcpServerApproval.tsx @@ -1,11 +1,11 @@ -import React from 'react' -import { MCPServerApprovalDialog } from '../components/MCPServerApprovalDialog.js' -import { MCPServerMultiselectDialog } from '../components/MCPServerMultiselectDialog.js' -import type { Root } from '@anthropic/ink' -import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js' -import { AppStateProvider } from '../state/AppState.js' -import { getMcpConfigsByScope } from './mcp/config.js' -import { getProjectMcpServerStatus } from './mcp/utils.js' +import React from 'react'; +import { MCPServerApprovalDialog } from '../components/MCPServerApprovalDialog.js'; +import { MCPServerMultiselectDialog } from '../components/MCPServerMultiselectDialog.js'; +import type { Root } from '@anthropic/ink'; +import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js'; +import { AppStateProvider } from '../state/AppState.js'; +import { getMcpConfigsByScope } from './mcp/config.js'; +import { getProjectMcpServerStatus } from './mcp/utils.js'; /** * Show MCP server approval dialogs for pending project servers. @@ -13,37 +13,34 @@ import { getProjectMcpServerStatus } from './mcp/utils.js' * from main.tsx instead of creating a separate one). */ export async function handleMcpjsonServerApprovals(root: Root): Promise { - const { servers: projectServers } = getMcpConfigsByScope('project') + const { servers: projectServers } = getMcpConfigsByScope('project'); const pendingServers = Object.keys(projectServers).filter( serverName => getProjectMcpServerStatus(serverName) === 'pending', - ) + ); if (pendingServers.length === 0) { - return + return; } await new Promise(resolve => { - const done = (): void => void resolve() + const done = (): void => void resolve(); if (pendingServers.length === 1 && pendingServers[0] !== undefined) { - const serverName = pendingServers[0] + const serverName = pendingServers[0]; root.render( , - ) + ); } else { root.render( - + , - ) + ); } - }) + }); } diff --git a/src/services/notifier.ts b/src/services/notifier.ts index 1eb8f99db..adf90bc09 100644 --- a/src/services/notifier.ts +++ b/src/services/notifier.ts @@ -136,7 +136,9 @@ async function isAppleTerminalBellDisabled(): Promise { // Lazy-load plist (~280KB with xmlbuilder+@xmldom) — only hit on // Apple_Terminal with auto-channel, which is a small fraction of users. const plist = await import('plist') - const parsed: Record = plist.parse(defaultsOutput.stdout) as any + const parsed: Record = plist.parse( + defaultsOutput.stdout, + ) as any const windowSettings = parsed?.['Window Settings'] as | Record | undefined diff --git a/src/services/oauth/src/constants/oauth.ts b/src/services/oauth/src/constants/oauth.ts index 870b6dd12..3b9a8acf2 100644 --- a/src/services/oauth/src/constants/oauth.ts +++ b/src/services/oauth/src/constants/oauth.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type getOauthConfig = any; -export type OAUTH_BETA_HEADER = any; +export type getOauthConfig = any +export type OAUTH_BETA_HEADER = any diff --git a/src/services/oauth/src/services/analytics/index.ts b/src/services/oauth/src/services/analytics/index.ts index ce0a9a827..eca4493cf 100644 --- a/src/services/oauth/src/services/analytics/index.ts +++ b/src/services/oauth/src/services/analytics/index.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type logEvent = any; -export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any; +export type logEvent = any +export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any diff --git a/src/services/oauth/src/services/oauth/types.ts b/src/services/oauth/src/services/oauth/types.ts index bc5c3d5cc..a425c54cd 100644 --- a/src/services/oauth/src/services/oauth/types.ts +++ b/src/services/oauth/src/services/oauth/types.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type OAuthProfileResponse = any; +export type OAuthProfileResponse = any diff --git a/src/services/oauth/src/utils/auth.ts b/src/services/oauth/src/utils/auth.ts index 2a02cad70..828a8702e 100644 --- a/src/services/oauth/src/utils/auth.ts +++ b/src/services/oauth/src/utils/auth.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getAnthropicApiKey = any; +export type getAnthropicApiKey = any diff --git a/src/services/oauth/src/utils/config.ts b/src/services/oauth/src/utils/config.ts index 00cdd1299..2c8c5f10c 100644 --- a/src/services/oauth/src/utils/config.ts +++ b/src/services/oauth/src/utils/config.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getGlobalConfig = any; +export type getGlobalConfig = any diff --git a/src/services/oauth/src/utils/log.ts b/src/services/oauth/src/utils/log.ts index cf30e90da..30df9cbcb 100644 --- a/src/services/oauth/src/utils/log.ts +++ b/src/services/oauth/src/utils/log.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type logError = any; +export type logError = any diff --git a/src/services/oauth/types.ts b/src/services/oauth/types.ts index b6b4785e2..4e14b12d9 100644 --- a/src/services/oauth/types.ts +++ b/src/services/oauth/types.ts @@ -1,12 +1,12 @@ // Auto-generated stub — replace with real implementation -export type BillingType = any; -export type ReferralEligibilityResponse = any; -export type OAuthTokens = any; -export type SubscriptionType = any; -export type ReferralRedemptionsResponse = any; -export type ReferrerRewardInfo = any; -export type OAuthProfileResponse = any; -export type OAuthTokenExchangeResponse = any; -export type RateLimitTier = any; -export type UserRolesResponse = any; -export type ReferralCampaign = any; +export type BillingType = any +export type ReferralEligibilityResponse = any +export type OAuthTokens = any +export type SubscriptionType = any +export type ReferralRedemptionsResponse = any +export type ReferrerRewardInfo = any +export type OAuthProfileResponse = any +export type OAuthTokenExchangeResponse = any +export type RateLimitTier = any +export type UserRolesResponse = any +export type ReferralCampaign = any diff --git a/src/services/plugins/pluginCliCommands.ts b/src/services/plugins/pluginCliCommands.ts index 514a4143e..917658768 100644 --- a/src/services/plugins/pluginCliCommands.ts +++ b/src/services/plugins/pluginCliCommands.ts @@ -61,7 +61,6 @@ function handlePluginCommandError( : command === 'disable-all' ? 'disable all plugins' : `${command} plugins` - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error( `${figures.cross} Failed to ${operation}: ${errorMessage(error)}`, ) @@ -105,7 +104,6 @@ export async function installPlugin( scope: InstallableScope = 'user', ): Promise { try { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`Installing plugin "${plugin}"...`) const result = await installPluginOp(plugin, scope) @@ -114,7 +112,6 @@ export async function installPlugin( throw new Error(result.message) } - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`${figures.tick} ${result.message}`) // _PROTO_* routes to PII-tagged plugin_name/marketplace_name BQ columns. @@ -162,7 +159,6 @@ export async function uninstallPlugin( throw new Error(result.message) } - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`${figures.tick} ${result.message}`) const { name, marketplace } = parsePluginIdentifier( @@ -203,7 +199,6 @@ export async function enablePlugin( throw new Error(result.message) } - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`${figures.tick} ${result.message}`) const { name, marketplace } = parsePluginIdentifier( @@ -244,7 +239,6 @@ export async function disablePlugin( throw new Error(result.message) } - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`${figures.tick} ${result.message}`) const { name, marketplace } = parsePluginIdentifier( @@ -280,7 +274,6 @@ export async function disableAllPlugins(): Promise { throw new Error(result.message) } - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log(`${figures.tick} ${result.message}`) logEvent('tengu_plugin_disabled_all_cli', {}) diff --git a/src/services/plugins/pluginOperations.ts b/src/services/plugins/pluginOperations.ts index 4d524ed68..c2cd8c7ff 100644 --- a/src/services/plugins/pluginOperations.ts +++ b/src/services/plugins/pluginOperations.ts @@ -353,7 +353,6 @@ export async function installPluginOp( } } catch (error) { logError(toError(error)) - continue } } } diff --git a/src/services/remoteManagedSettings/securityCheck.tsx b/src/services/remoteManagedSettings/securityCheck.tsx index f0d9b1ca7..ea8281d61 100644 --- a/src/services/remoteManagedSettings/securityCheck.tsx +++ b/src/services/remoteManagedSettings/securityCheck.tsx @@ -1,20 +1,20 @@ -import React from 'react' -import { getIsInteractive } from '../../bootstrap/state.js' -import { ManagedSettingsSecurityDialog } from '../../components/ManagedSettingsSecurityDialog/ManagedSettingsSecurityDialog.js' +import React from 'react'; +import { getIsInteractive } from '../../bootstrap/state.js'; +import { ManagedSettingsSecurityDialog } from '../../components/ManagedSettingsSecurityDialog/ManagedSettingsSecurityDialog.js'; import { extractDangerousSettings, hasDangerousSettings, hasDangerousSettingsChanged, -} from '../../components/ManagedSettingsSecurityDialog/utils.js' -import { wrappedRender as render } from '@anthropic/ink' -import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js' -import { AppStateProvider } from '../../state/AppState.js' -import { gracefulShutdownSync } from '../../utils/gracefulShutdown.js' -import { getBaseRenderOptions } from '../../utils/renderOptions.js' -import type { SettingsJson } from '../../utils/settings/types.js' -import { logEvent } from '../analytics/index.js' +} from '../../components/ManagedSettingsSecurityDialog/utils.js'; +import { wrappedRender as render } from '@anthropic/ink'; +import { KeybindingSetup } from '../../keybindings/KeybindingProviderSetup.js'; +import { AppStateProvider } from '../../state/AppState.js'; +import { gracefulShutdownSync } from '../../utils/gracefulShutdown.js'; +import { getBaseRenderOptions } from '../../utils/renderOptions.js'; +import type { SettingsJson } from '../../utils/settings/types.js'; +import { logEvent } from '../analytics/index.js'; -export type SecurityCheckResult = 'approved' | 'rejected' | 'no_check_needed' +export type SecurityCheckResult = 'approved' | 'rejected' | 'no_check_needed'; /** * Check if new remote managed settings contain dangerous settings that require user approval. @@ -29,25 +29,22 @@ export async function checkManagedSettingsSecurity( newSettings: SettingsJson | null, ): Promise { // If new settings don't have dangerous settings, no check needed - if ( - !newSettings || - !hasDangerousSettings(extractDangerousSettings(newSettings)) - ) { - return 'no_check_needed' + if (!newSettings || !hasDangerousSettings(extractDangerousSettings(newSettings))) { + return 'no_check_needed'; } // If dangerous settings haven't changed, no check needed if (!hasDangerousSettingsChanged(cachedSettings, newSettings)) { - return 'no_check_needed' + return 'no_check_needed'; } // Skip dialog in non-interactive mode (consistent with trust dialog behavior) if (!getIsInteractive()) { - return 'no_check_needed' + return 'no_check_needed'; } // Log that dialog is being shown - logEvent('tengu_managed_settings_security_dialog_shown', {}) + logEvent('tengu_managed_settings_security_dialog_shown', {}); // Show blocking dialog return new Promise(resolve => { @@ -58,34 +55,32 @@ export async function checkManagedSettingsSecurity( { - logEvent('tengu_managed_settings_security_dialog_accepted', {}) - unmount() - void resolve('approved') + logEvent('tengu_managed_settings_security_dialog_accepted', {}); + unmount(); + void resolve('approved'); }} onReject={() => { - logEvent('tengu_managed_settings_security_dialog_rejected', {}) - unmount() - void resolve('rejected') + logEvent('tengu_managed_settings_security_dialog_rejected', {}); + unmount(); + void resolve('rejected'); }} /> , getBaseRenderOptions(false), - ) - })() - }) + ); + })(); + }); } /** * Handle the security check result by exiting if rejected * Returns true if we should continue, false if we should stop */ -export function handleSecurityCheckResult( - result: SecurityCheckResult, -): boolean { +export function handleSecurityCheckResult(result: SecurityCheckResult): boolean { if (result === 'rejected') { - gracefulShutdownSync(1) - return false + gracefulShutdownSync(1); + return false; } - return true + return true; } diff --git a/src/services/sessionTranscript/sessionTranscript.ts b/src/services/sessionTranscript/sessionTranscript.ts index 3b3b4e7b0..ca26f41e6 100644 --- a/src/services/sessionTranscript/sessionTranscript.ts +++ b/src/services/sessionTranscript/sessionTranscript.ts @@ -1,6 +1,10 @@ // Auto-generated stub — replace with real implementation -import type { Message } from '../../types/message.js'; +import type { Message } from '../../types/message.js' -export {}; -export const writeSessionTranscriptSegment: (messages: Message[]) => void = (() => {}); -export const flushOnDateChange: (messages: Message[], currentDate: string) => void = (() => {}); +export {} +export const writeSessionTranscriptSegment: (messages: Message[]) => void = + () => {} +export const flushOnDateChange: ( + messages: Message[], + currentDate: string, +) => void = () => {} diff --git a/src/services/skillSearch/featureCheck.ts b/src/services/skillSearch/featureCheck.ts index ff8950f4b..bbdd58ef1 100644 --- a/src/services/skillSearch/featureCheck.ts +++ b/src/services/skillSearch/featureCheck.ts @@ -1,3 +1,3 @@ // Auto-generated stub — replace with real implementation -export {}; -export const isSkillSearchEnabled: () => boolean = () => false; +export {} +export const isSkillSearchEnabled: () => boolean = () => false diff --git a/src/services/skillSearch/localSearch.ts b/src/services/skillSearch/localSearch.ts index f8139d653..1f3d71693 100644 --- a/src/services/skillSearch/localSearch.ts +++ b/src/services/skillSearch/localSearch.ts @@ -1,3 +1,3 @@ // Auto-generated stub — replace with real implementation -export {}; -export const clearSkillIndexCache: () => void = () => {}; +export {} +export const clearSkillIndexCache: () => void = () => {} diff --git a/src/services/skillSearch/prefetch.ts b/src/services/skillSearch/prefetch.ts index 50c8729ec..7a86a13d9 100644 --- a/src/services/skillSearch/prefetch.ts +++ b/src/services/skillSearch/prefetch.ts @@ -7,12 +7,12 @@ export const startSkillDiscoveryPrefetch: ( input: string | null, messages: Message[], toolUseContext: ToolUseContext, -) => Promise = (async () => []); +) => Promise = async () => [] export const collectSkillDiscoveryPrefetch: ( pending: Promise, -) => Promise = (async (pending) => pending); +) => Promise = async pending => pending export const getTurnZeroSkillDiscovery: ( input: string, messages: Message[], context: ToolUseContext, -) => Promise = (async () => null); +) => Promise = async () => null diff --git a/src/services/skillSearch/remoteSkillLoader.ts b/src/services/skillSearch/remoteSkillLoader.ts index db251d4ff..7bb16f9a5 100644 --- a/src/services/skillSearch/remoteSkillLoader.ts +++ b/src/services/skillSearch/remoteSkillLoader.ts @@ -1,17 +1,20 @@ // Auto-generated stub — replace with real implementation -export function loadRemoteSkill(_slug: string, _url: string): Promise<{ - cacheHit: boolean; - latencyMs: number; - skillPath: string; - content: string; - fileCount?: number; - totalBytes?: number; - fetchMethod?: string; +export function loadRemoteSkill( + _slug: string, + _url: string, +): Promise<{ + cacheHit: boolean + latencyMs: number + skillPath: string + content: string + fileCount?: number + totalBytes?: number + fetchMethod?: string }> { return Promise.resolve({ cacheHit: false, latencyMs: 0, skillPath: '', content: '', - }); + }) } diff --git a/src/services/skillSearch/remoteSkillState.ts b/src/services/skillSearch/remoteSkillState.ts index af2f1999d..36b628ce7 100644 --- a/src/services/skillSearch/remoteSkillState.ts +++ b/src/services/skillSearch/remoteSkillState.ts @@ -1,3 +1,9 @@ // Auto-generated stub — replace with real implementation -export function stripCanonicalPrefix(_name: string): string | null { return null; } -export function getDiscoveredRemoteSkill(_slug: string): { url: string } | undefined { return undefined; } +export function stripCanonicalPrefix(_name: string): string | null { + return null +} +export function getDiscoveredRemoteSkill( + _slug: string, +): { url: string } | undefined { + return undefined +} diff --git a/src/services/skillSearch/signals.ts b/src/services/skillSearch/signals.ts index 0b89faefe..4d927bfd9 100644 --- a/src/services/skillSearch/signals.ts +++ b/src/services/skillSearch/signals.ts @@ -1,2 +1,2 @@ // Auto-generated stub — replace with real implementation -export type DiscoverySignal = any; +export type DiscoverySignal = any diff --git a/src/services/skillSearch/telemetry.ts b/src/services/skillSearch/telemetry.ts index ce2f85b22..7c2ba1fcc 100644 --- a/src/services/skillSearch/telemetry.ts +++ b/src/services/skillSearch/telemetry.ts @@ -1,11 +1,11 @@ // Auto-generated stub — replace with real implementation export function logRemoteSkillLoaded(_data: { - slug: string; - cacheHit: boolean; - latencyMs: number; - urlScheme: string; - error?: string; - fileCount?: number; - totalBytes?: number; - fetchMethod?: string; + slug: string + cacheHit: boolean + latencyMs: number + urlScheme: string + error?: string + fileCount?: number + totalBytes?: number + fetchMethod?: string }): void {} diff --git a/src/services/src/cost-tracker.ts b/src/services/src/cost-tracker.ts index 3f76a9113..dea78c7af 100644 --- a/src/services/src/cost-tracker.ts +++ b/src/services/src/cost-tracker.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type addToTotalSessionCost = any; +export type addToTotalSessionCost = any diff --git a/src/services/src/utils/log.ts b/src/services/src/utils/log.ts index cf30e90da..30df9cbcb 100644 --- a/src/services/src/utils/log.ts +++ b/src/services/src/utils/log.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type logError = any; +export type logError = any diff --git a/src/services/src/utils/model/providers.ts b/src/services/src/utils/model/providers.ts index df87a41b4..1379140e8 100644 --- a/src/services/src/utils/model/providers.ts +++ b/src/services/src/utils/model/providers.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getAPIProvider = any; +export type getAPIProvider = any diff --git a/src/services/src/utils/modelCost.ts b/src/services/src/utils/modelCost.ts index a37f5df38..dffb6f217 100644 --- a/src/services/src/utils/modelCost.ts +++ b/src/services/src/utils/modelCost.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type calculateUSDCost = any; +export type calculateUSDCost = any diff --git a/src/services/tips/src/utils/debug.ts b/src/services/tips/src/utils/debug.ts index c64d5960c..5a0f1d515 100644 --- a/src/services/tips/src/utils/debug.ts +++ b/src/services/tips/src/utils/debug.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type logForDebugging = any; +export type logForDebugging = any diff --git a/src/services/tips/src/utils/fileHistory.ts b/src/services/tips/src/utils/fileHistory.ts index 5c33161bd..741941819 100644 --- a/src/services/tips/src/utils/fileHistory.ts +++ b/src/services/tips/src/utils/fileHistory.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type fileHistoryEnabled = any; +export type fileHistoryEnabled = any diff --git a/src/services/tips/src/utils/settings/settings.ts b/src/services/tips/src/utils/settings/settings.ts index bdf4efb89..1d3a3064d 100644 --- a/src/services/tips/src/utils/settings/settings.ts +++ b/src/services/tips/src/utils/settings/settings.ts @@ -1,4 +1,4 @@ // Auto-generated type stub — replace with real implementation -export type getInitialSettings = any; -export type getSettings_DEPRECATED = any; -export type getSettingsForSource = any; +export type getInitialSettings = any +export type getSettings_DEPRECATED = any +export type getSettingsForSource = any diff --git a/src/services/tips/types.ts b/src/services/tips/types.ts index 35071c25f..cc2ea2b85 100644 --- a/src/services/tips/types.ts +++ b/src/services/tips/types.ts @@ -1,3 +1,3 @@ // Auto-generated stub — replace with real implementation -export type Tip = any; -export type TipContext = any; +export type Tip = any +export type TipContext = any diff --git a/src/services/tokenEstimation.ts b/src/services/tokenEstimation.ts index c59d53a3a..57ec7c8d0 100644 --- a/src/services/tokenEstimation.ts +++ b/src/services/tokenEstimation.ts @@ -308,7 +308,6 @@ export async function countTokensViaHaikuFallback( ? betas.filter(b => VERTEX_COUNT_TOKENS_ALLOWED_BETAS.has(b)) : betas - // biome-ignore lint/plugin: token counting needs specialized parameters (thinking, betas) that sideQuery doesn't support const response = await anthropic.beta.messages.create({ model: normalizeModelStringForAPI(model), max_tokens: containsThinking ? TOKEN_COUNT_MAX_TOKENS : 1, diff --git a/src/services/toolUseSummary/toolUseSummaryGenerator.ts b/src/services/toolUseSummary/toolUseSummaryGenerator.ts index 52c421944..cf138fbdd 100644 --- a/src/services/toolUseSummary/toolUseSummaryGenerator.ts +++ b/src/services/toolUseSummary/toolUseSummaryGenerator.ts @@ -80,7 +80,9 @@ export async function generateToolUseSummary({ }, }) - const summary = (Array.isArray(response.message.content) ? response.message.content : []) + const summary = ( + Array.isArray(response.message.content) ? response.message.content : [] + ) .filter(block => block.type === 'text') .map(block => (block.type === 'text' ? block.text : '')) .join('') diff --git a/src/services/tools/StreamingToolExecutor.ts b/src/services/tools/StreamingToolExecutor.ts index b924fdd91..f82ba6583 100644 --- a/src/services/tools/StreamingToolExecutor.ts +++ b/src/services/tools/StreamingToolExecutor.ts @@ -80,7 +80,10 @@ export class StreamingToolExecutor { { toolNames: [block.name], batchIndex: 0 }, ) if (this.turnSpan) { - this.toolUseContext = { ...this.toolUseContext, langfuseBatchSpan: this.turnSpan } + this.toolUseContext = { + ...this.toolUseContext, + langfuseBatchSpan: this.turnSpan, + } } } const toolDefinition = findToolByName(this.toolDefinitions, block.name) diff --git a/src/services/tools/src/services/analytics/index.ts b/src/services/tools/src/services/analytics/index.ts index 142e7b6f5..2e0f796da 100644 --- a/src/services/tools/src/services/analytics/index.ts +++ b/src/services/tools/src/services/analytics/index.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any; -export type logEvent = any; +export type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS = any +export type logEvent = any diff --git a/src/services/tools/src/services/analytics/metadata.ts b/src/services/tools/src/services/analytics/metadata.ts index e702ce446..4e63821fa 100644 --- a/src/services/tools/src/services/analytics/metadata.ts +++ b/src/services/tools/src/services/analytics/metadata.ts @@ -1,9 +1,9 @@ // Auto-generated type stub — replace with real implementation -export type sanitizeToolNameForAnalytics = any; -export type extractMcpToolDetails = any; -export type extractSkillName = any; -export type extractToolInputForTelemetry = any; -export type getFileExtensionForAnalytics = any; -export type getFileExtensionsFromBashCommand = any; -export type isToolDetailsLoggingEnabled = any; -export type mcpToolDetailsForAnalytics = any; +export type sanitizeToolNameForAnalytics = any +export type extractMcpToolDetails = any +export type extractSkillName = any +export type extractToolInputForTelemetry = any +export type getFileExtensionForAnalytics = any +export type getFileExtensionsFromBashCommand = any +export type isToolDetailsLoggingEnabled = any +export type mcpToolDetailsForAnalytics = any diff --git a/src/services/tools/src/utils/messages.ts b/src/services/tools/src/utils/messages.ts index edf1650fc..48f7d686f 100644 --- a/src/services/tools/src/utils/messages.ts +++ b/src/services/tools/src/utils/messages.ts @@ -1,4 +1,4 @@ // Auto-generated type stub — replace with real implementation -export type createUserMessage = any; -export type REJECT_MESSAGE = any; -export type withMemoryCorrectionHint = any; +export type createUserMessage = any +export type REJECT_MESSAGE = any +export type withMemoryCorrectionHint = any diff --git a/src/services/tools/toolHooks.ts b/src/services/tools/toolHooks.ts index c1e6f702c..e5b84d2f8 100644 --- a/src/services/tools/toolHooks.ts +++ b/src/services/tools/toolHooks.ts @@ -99,7 +99,11 @@ export async function* runPostToolUseHooks( result.message.attachment!.type === 'hook_blocking_error' ) ) { - yield { message: result.message as AttachmentMessage | ProgressMessage } + yield { + message: result.message as + | AttachmentMessage + | ProgressMessage, + } } if (result.blockingError) { @@ -251,7 +255,11 @@ export async function* runPostToolUseFailureHooks( result.message.attachment!.type === 'hook_blocking_error' ) ) { - yield { message: result.message as AttachmentMessage | ProgressMessage } + yield { + message: result.message as + | AttachmentMessage + | ProgressMessage, + } } if (result.blockingError) { @@ -476,7 +484,14 @@ export async function* runPreToolUseHooks( )) { try { if (result.message) { - yield { type: 'message', message: { message: result.message as AttachmentMessage | ProgressMessage } } + yield { + type: 'message', + message: { + message: result.message as + | AttachmentMessage + | ProgressMessage, + }, + } } if (result.blockingError) { const denialMessage = getPreToolHookBlockingMessage( diff --git a/src/services/tools/toolOrchestration.ts b/src/services/tools/toolOrchestration.ts index 9e5d52449..0bef73c28 100644 --- a/src/services/tools/toolOrchestration.ts +++ b/src/services/tools/toolOrchestration.ts @@ -24,12 +24,13 @@ export async function* runTools( toolUseContext: ToolUseContext, ): AsyncGenerator { // Wrap all tool calls in this turn under a single Langfuse turn span - const turnSpan = toolUseMessages.length > 0 - ? createToolBatchSpan(toolUseContext.langfuseTrace ?? null, { - toolNames: toolUseMessages.map(b => b.name), - batchIndex: 0, - }) - : null + const turnSpan = + toolUseMessages.length > 0 + ? createToolBatchSpan(toolUseContext.langfuseTrace ?? null, { + toolNames: toolUseMessages.map(b => b.name), + batchIndex: 0, + }) + : null const contextWithTurn = turnSpan ? { ...toolUseContext, langfuseBatchSpan: turnSpan } : toolUseContext @@ -143,10 +144,12 @@ async function* runToolsSerially( ) for await (const update of runToolUse( toolUse, - assistantMessages.find(_ => - Array.isArray(_.message.content) && _.message.content.some( - _ => _.type === 'tool_use' && _.id === toolUse.id, - ), + assistantMessages.find( + _ => + Array.isArray(_.message.content) && + _.message.content.some( + _ => _.type === 'tool_use' && _.id === toolUse.id, + ), )!, canUseTool, currentContext, @@ -176,10 +179,12 @@ async function* runToolsConcurrently( ) yield* runToolUse( toolUse, - assistantMessages.find(_ => - Array.isArray(_.message.content) && _.message.content.some( - _ => _.type === 'tool_use' && _.id === toolUse.id, - ), + assistantMessages.find( + _ => + Array.isArray(_.message.content) && + _.message.content.some( + _ => _.type === 'tool_use' && _.id === toolUse.id, + ), )!, canUseTool, toolUseContext, diff --git a/src/services/vcr.ts b/src/services/vcr.ts index 8ee060c1d..068b9e0ad 100644 --- a/src/services/vcr.ts +++ b/src/services/vcr.ts @@ -1,4 +1,7 @@ -import type { BetaContentBlock, BetaUsage } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' +import type { + BetaContentBlock, + BetaUsage, +} from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' import { createHash, randomUUID, type UUID } from 'crypto' import { mkdir, readFile, writeFile } from 'fs/promises' import isPlainObject from 'lodash-es/isPlainObject.js' diff --git a/src/setup.ts b/src/setup.ts index 985e8577a..c256b1972 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -68,8 +68,7 @@ export async function setup( // Check for Node.js version < 18 const nodeVersion = process.version.match(/^v(\d+)\./)?.[1] - if (!nodeVersion || parseInt(nodeVersion) < 18) { - // biome-ignore lint/suspicious/noConsole:: intentional console output + if (!nodeVersion || parseInt(nodeVersion, 10) < 18) { console.error( chalk.bold.red( 'Error: Claude Code requires Node.js version 18 or higher.', @@ -117,14 +116,12 @@ export async function setup( if (isAgentSwarmsEnabled()) { const restoredIterm2Backup = await checkAndRestoreITerm2Backup() if (restoredIterm2Backup.status === 'restored') { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log( chalk.yellow( 'Detected an interrupted iTerm2 setup. Your original settings have been restored. You may need to restart iTerm2 for the changes to take effect.', ), ) } else if (restoredIterm2Backup.status === 'failed') { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error( chalk.red( `Failed to restore iTerm2 settings. Please manually restore your original settings with: defaults import com.googlecode.iterm2 ${restoredIterm2Backup.backupPath}.`, @@ -137,14 +134,12 @@ export async function setup( try { const restoredTerminalBackup = await checkAndRestoreTerminalBackup() if (restoredTerminalBackup.status === 'restored') { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log( chalk.yellow( 'Detected an interrupted Terminal.app setup. Your original settings have been restored. You may need to restart Terminal.app for the changes to take effect.', ), ) } else if (restoredTerminalBackup.status === 'failed') { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error( chalk.red( `Failed to restore Terminal.app settings. Please manually restore your original settings with: defaults import com.apple.Terminal ${restoredTerminalBackup.backupPath}.`, @@ -252,14 +247,12 @@ export async function setup( worktreeSession.worktreePath, ) if (tmuxResult.created) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.log( chalk.green( `Created tmux session: ${chalk.bold(tmuxSessionName)}\nTo attach: ${chalk.bold(`tmux attach -t ${tmuxSessionName}`)}`, ), ) } else { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error( chalk.yellow( `Warning: Failed to create tmux session: ${tmuxResult.error}`, @@ -406,7 +399,6 @@ export async function setup( process.env.IS_SANDBOX !== '1' && !isEnvTruthy(process.env.CLAUDE_CODE_BUBBLEWRAP) ) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error( `--dangerously-skip-permissions cannot be used with root/sudo privileges for security reasons`, ) @@ -432,7 +424,6 @@ export async function setup( const isSandbox = process.env.IS_SANDBOX === '1' const isSandboxed = isDocker || isBubblewrap || isSandbox if (!isSandboxed || hasInternet) { - // biome-ignore lint/suspicious/noConsole:: intentional console output console.error( `--dangerously-skip-permissions can only be used in Docker/sandbox containers with no internet access but got Docker: ${isDocker}, Bubblewrap: ${isBubblewrap}, IS_SANDBOX: ${isSandbox}, hasInternet: ${hasInternet}`, ) diff --git a/src/skills/bundled/loremIpsum.ts b/src/skills/bundled/loremIpsum.ts index 053306c6f..de4c1a023 100644 --- a/src/skills/bundled/loremIpsum.ts +++ b/src/skills/bundled/loremIpsum.ts @@ -243,7 +243,7 @@ export function registerLoremIpsumSkill(): void { argumentHint: '[token_count]', userInvocable: true, async getPromptForCommand(args) { - const parsed = parseInt(args) + const parsed = parseInt(args, 10) if (args && (isNaN(parsed) || parsed <= 0)) { return [ diff --git a/src/skills/bundled/src/tools/AgentTool/built-in/claudeCodeGuideAgent.ts b/src/skills/bundled/src/tools/AgentTool/built-in/claudeCodeGuideAgent.ts index d9e0c872d..797432180 100644 --- a/src/skills/bundled/src/tools/AgentTool/built-in/claudeCodeGuideAgent.ts +++ b/src/skills/bundled/src/tools/AgentTool/built-in/claudeCodeGuideAgent.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type CLAUDE_CODE_GUIDE_AGENT_TYPE = any; +export type CLAUDE_CODE_GUIDE_AGENT_TYPE = any diff --git a/src/skills/bundled/src/utils/claudeInChrome/setup.ts b/src/skills/bundled/src/utils/claudeInChrome/setup.ts index 0f25ac9b8..e6ea53646 100644 --- a/src/skills/bundled/src/utils/claudeInChrome/setup.ts +++ b/src/skills/bundled/src/utils/claudeInChrome/setup.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type shouldAutoEnableClaudeInChrome = any; +export type shouldAutoEnableClaudeInChrome = any diff --git a/src/skills/bundled/src/utils/settings/settings.ts b/src/skills/bundled/src/utils/settings/settings.ts index c9d1262e3..9146102cb 100644 --- a/src/skills/bundled/src/utils/settings/settings.ts +++ b/src/skills/bundled/src/utils/settings/settings.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getSettingsFilePathForSource = any; +export type getSettingsFilePathForSource = any diff --git a/src/ssh/SSHSessionManager.ts b/src/ssh/SSHSessionManager.ts index 6a2faaefa..752b605aa 100644 --- a/src/ssh/SSHSessionManager.ts +++ b/src/ssh/SSHSessionManager.ts @@ -5,7 +5,10 @@ import type { RemoteMessageContent } from '../utils/teleport/api.js' export interface SSHSessionManagerOptions { onMessage: (sdkMessage: SDKMessage) => void - onPermissionRequest: (request: SSHPermissionRequest, requestId: string) => void + onPermissionRequest: ( + request: SSHPermissionRequest, + requestId: string, + ) => void onConnected: () => void onReconnecting: (attempt: number, max: number) => void onDisconnected: () => void @@ -26,5 +29,8 @@ export interface SSHSessionManager { disconnect(): void sendMessage(content: RemoteMessageContent): Promise sendInterrupt(): void - respondToPermissionRequest(requestId: string, response: { behavior: string; message?: string; updatedInput?: unknown }): void + respondToPermissionRequest( + requestId: string, + response: { behavior: string; message?: string; updatedInput?: unknown }, + ): void } diff --git a/src/ssh/createSSHSession.ts b/src/ssh/createSSHSession.ts index 1db14a1f3..7f2526cb1 100644 --- a/src/ssh/createSSHSession.ts +++ b/src/ssh/createSSHSession.ts @@ -1,6 +1,9 @@ // Auto-generated stub — replace with real implementation import type { Subprocess } from 'bun' -import type { SSHSessionManager, SSHSessionManagerOptions } from './SSHSessionManager.js' +import type { + SSHSessionManager, + SSHSessionManagerOptions, +} from './SSHSessionManager.js' export interface SSHAuthProxy { stop(): void @@ -21,9 +24,14 @@ export class SSHSessionError extends Error { } } -export const createSSHSession: (...args: unknown[]) => Promise = (async () => { - throw new SSHSessionError('SSH sessions are not supported in this build') -}); -export const createLocalSSHSession: (...args: unknown[]) => Promise = (async () => { - throw new SSHSessionError('Local SSH sessions are not supported in this build') -}); +export const createSSHSession: (...args: unknown[]) => Promise = + async () => { + throw new SSHSessionError('SSH sessions are not supported in this build') + } +export const createLocalSSHSession: ( + ...args: unknown[] +) => Promise = async () => { + throw new SSHSessionError( + 'Local SSH sessions are not supported in this build', + ) +} diff --git a/src/state/AppState.tsx b/src/state/AppState.tsx index 783170cc3..41119c108 100644 --- a/src/state/AppState.tsx +++ b/src/state/AppState.tsx @@ -1,35 +1,24 @@ -import { feature } from 'bun:bundle' -import React, { - useContext, - useEffect, - useEffectEvent, - useState, - useSyncExternalStore, -} from 'react' -import { MailboxProvider } from '../context/mailbox.js' -import { useSettingsChange } from '../hooks/useSettingsChange.js' -import { logForDebugging } from '../utils/debug.js' +import { feature } from 'bun:bundle'; +import React, { useContext, useEffect, useEffectEvent, useState, useSyncExternalStore } from 'react'; +import { MailboxProvider } from '../context/mailbox.js'; +import { useSettingsChange } from '../hooks/useSettingsChange.js'; +import { logForDebugging } from '../utils/debug.js'; import { createDisabledBypassPermissionsContext, isBypassPermissionsModeDisabled, -} from '../utils/permissions/permissionSetup.js' -import { applySettingsChange } from '../utils/settings/applySettingsChange.js' -import type { SettingSource } from '../utils/settings/constants.js' -import { createStore } from './store.js' +} from '../utils/permissions/permissionSetup.js'; +import { applySettingsChange } from '../utils/settings/applySettingsChange.js'; +import type { SettingSource } from '../utils/settings/constants.js'; +import { createStore } from './store.js'; // DCE: voice context is ant-only. External builds get a passthrough. /* eslint-disable @typescript-eslint/no-require-imports */ -const VoiceProvider: (props: { children: React.ReactNode }) => React.ReactNode = - feature('VOICE_MODE') - ? require('../context/voice.js').VoiceProvider - : ({ children }) => children +const VoiceProvider: (props: { children: React.ReactNode }) => React.ReactNode = feature('VOICE_MODE') + ? require('../context/voice.js').VoiceProvider + : ({ children }) => children; /* eslint-enable @typescript-eslint/no-require-imports */ -import { - type AppState, - type AppStateStore, - getDefaultAppState, -} from './AppStateStore.js' +import { type AppState, type AppStateStore, getDefaultAppState } from './AppStateStore.js'; // TODO: Remove these re-exports once all callers import directly from // ./AppStateStore.js. Kept for back-compat during migration so .ts callers @@ -42,40 +31,29 @@ export { IDLE_SPECULATION_STATE, type SpeculationResult, type SpeculationState, -} from './AppStateStore.js' +} from './AppStateStore.js'; -export const AppStoreContext = React.createContext(null) +export const AppStoreContext = React.createContext(null); type Props = { - children: React.ReactNode - initialState?: AppState - onChangeAppState?: (args: { newState: AppState; oldState: AppState }) => void -} + children: React.ReactNode; + initialState?: AppState; + onChangeAppState?: (args: { newState: AppState; oldState: AppState }) => void; +}; -const HasAppStateContext = React.createContext(false) +const HasAppStateContext = React.createContext(false); -export function AppStateProvider({ - children, - initialState, - onChangeAppState, -}: Props): React.ReactNode { +export function AppStateProvider({ children, initialState, onChangeAppState }: Props): React.ReactNode { // Don't allow nested AppStateProviders. - const hasAppStateContext = useContext(HasAppStateContext) + const hasAppStateContext = useContext(HasAppStateContext); if (hasAppStateContext) { - throw new Error( - 'AppStateProvider can not be nested within another AppStateProvider', - ) + throw new Error('AppStateProvider can not be nested within another AppStateProvider'); } // Store is created once and never changes -- stable context value means // the provider never triggers re-renders. Consumers subscribe to slices // via useSyncExternalStore in useAppState(selector). - const [store] = useState(() => - createStore( - initialState ?? getDefaultAppState(), - onChangeAppState, - ), - ) + const [store] = useState(() => createStore(initialState ?? getDefaultAppState(), onChangeAppState)); // Check on mount if bypass mode should be disabled // This handles the race condition where remote settings load BEFORE this component mounts, @@ -83,31 +61,21 @@ export function AppStateProvider({ // On subsequent sessions, the cached remote-settings.json is read during initial setup, // but on the first session the remote fetch may complete before React mounts. useEffect(() => { - const { toolPermissionContext } = store.getState() - if ( - toolPermissionContext.isBypassPermissionsModeAvailable && - isBypassPermissionsModeDisabled() - ) { - logForDebugging( - 'Disabling bypass permissions mode on mount (remote settings loaded before mount)', - ) + const { toolPermissionContext } = store.getState(); + if (toolPermissionContext.isBypassPermissionsModeAvailable && isBypassPermissionsModeDisabled()) { + logForDebugging('Disabling bypass permissions mode on mount (remote settings loaded before mount)'); store.setState(prev => ({ ...prev, - toolPermissionContext: createDisabledBypassPermissionsContext( - prev.toolPermissionContext, - ), - })) + toolPermissionContext: createDisabledBypassPermissionsContext(prev.toolPermissionContext), + })); } - // biome-ignore lint/correctness/useExhaustiveDependencies: intentional mount-only effect - }, []) + }, []); // Listen for external settings changes and sync to AppState. // This ensures file watcher changes propagate through the app -- // shared with the headless/SDK path via applySettingsChange. - const onSettingsChange = useEffectEvent((source: SettingSource) => - applySettingsChange(source, store.setState), - ) - useSettingsChange(onSettingsChange) + const onSettingsChange = useEffectEvent((source: SettingSource) => applySettingsChange(source, store.setState)); + useSettingsChange(onSettingsChange); return ( @@ -117,18 +85,16 @@ export function AppStateProvider({ - ) + ); } function useAppStore(): AppStateStore { // eslint-disable-next-line react-hooks/rules-of-hooks - const store = useContext(AppStoreContext) + const store = useContext(AppStoreContext); if (!store) { - throw new ReferenceError( - 'useAppState/useSetAppState cannot be called outside of an ', - ) + throw new ReferenceError('useAppState/useSetAppState cannot be called outside of an '); } - return store + return store; } /** @@ -148,22 +114,22 @@ function useAppStore(): AppStateStore { * ``` */ export function useAppState(selector: (state: AppState) => T): T { - const store = useAppStore() + const store = useAppStore(); const get = () => { - const state = store.getState() - const selected = selector(state) + const state = store.getState(); + const selected = selector(state); if (process.env.USER_TYPE === 'ant' && state === selected) { throw new Error( `Your selector in \`useAppState(${selector.toString()})\` returned the original state, which is not allowed. You must instead return a property for optimised rendering.`, - ) + ); } - return selected - } + return selected; + }; - return useSyncExternalStore(store.subscribe, get, get) + return useSyncExternalStore(store.subscribe, get, get); } /** @@ -171,30 +137,26 @@ export function useAppState(selector: (state: AppState) => T): T { * Returns a stable reference that never changes -- components using only * this hook will never re-render from state changes. */ -export function useSetAppState(): ( - updater: (prev: AppState) => AppState, -) => void { - return useAppStore().setState +export function useSetAppState(): (updater: (prev: AppState) => AppState) => void { + return useAppStore().setState; } /** * Get the store directly (for passing getState/setState to non-React code). */ export function useAppStateStore(): AppStateStore { - return useAppStore() + return useAppStore(); } -const NOOP_SUBSCRIBE = () => () => {} +const NOOP_SUBSCRIBE = () => () => {}; /** * Safe version of useAppState that returns undefined if called outside of AppStateProvider. * Useful for components that may be rendered in contexts where AppStateProvider isn't available. */ -export function useAppStateMaybeOutsideOfProvider( - selector: (state: AppState) => T, -): T | undefined { - const store = useContext(AppStoreContext) +export function useAppStateMaybeOutsideOfProvider(selector: (state: AppState) => T): T | undefined { + const store = useContext(AppStoreContext); return useSyncExternalStore(store ? store.subscribe : NOOP_SUBSCRIBE, () => store ? selector(store.getState()) : undefined, - ) + ); } diff --git a/src/state/__tests__/store.test.ts b/src/state/__tests__/store.test.ts index 31c376555..aefb7cdc5 100644 --- a/src/state/__tests__/store.test.ts +++ b/src/state/__tests__/store.test.ts @@ -1,112 +1,125 @@ -import { describe, expect, test } from "bun:test"; -import { createStore } from "../store"; +import { describe, expect, test } from 'bun:test' +import { createStore } from '../store' -describe("createStore", () => { - test("returns object with getState, setState, subscribe", () => { - const store = createStore({ count: 0 }); - expect(typeof store.getState).toBe("function"); - expect(typeof store.setState).toBe("function"); - expect(typeof store.subscribe).toBe("function"); - }); +describe('createStore', () => { + test('returns object with getState, setState, subscribe', () => { + const store = createStore({ count: 0 }) + expect(typeof store.getState).toBe('function') + expect(typeof store.setState).toBe('function') + expect(typeof store.subscribe).toBe('function') + }) - test("getState returns initial state", () => { - const store = createStore({ count: 0, name: "test" }); - expect(store.getState()).toEqual({ count: 0, name: "test" }); - }); + test('getState returns initial state', () => { + const store = createStore({ count: 0, name: 'test' }) + expect(store.getState()).toEqual({ count: 0, name: 'test' }) + }) - test("setState updates state via updater function", () => { - const store = createStore({ count: 0 }); - store.setState(prev => ({ count: prev.count + 1 })); - expect(store.getState().count).toBe(1); - }); + test('setState updates state via updater function', () => { + const store = createStore({ count: 0 }) + store.setState(prev => ({ count: prev.count + 1 })) + expect(store.getState().count).toBe(1) + }) - test("setState does not notify when state unchanged (Object.is)", () => { - const store = createStore({ count: 0 }); - let notified = false; - store.subscribe(() => { notified = true; }); - store.setState(prev => prev); - expect(notified).toBe(false); - }); + test('setState does not notify when state unchanged (Object.is)', () => { + const store = createStore({ count: 0 }) + let notified = false + store.subscribe(() => { + notified = true + }) + store.setState(prev => prev) + expect(notified).toBe(false) + }) - test("setState notifies subscribers on change", () => { - const store = createStore({ count: 0 }); - let notified = false; - store.subscribe(() => { notified = true; }); - store.setState(prev => ({ count: prev.count + 1 })); - expect(notified).toBe(true); - }); + test('setState notifies subscribers on change', () => { + const store = createStore({ count: 0 }) + let notified = false + store.subscribe(() => { + notified = true + }) + store.setState(prev => ({ count: prev.count + 1 })) + expect(notified).toBe(true) + }) - test("subscribe returns unsubscribe function", () => { - const store = createStore({ count: 0 }); - const unsub = store.subscribe(() => {}); - expect(typeof unsub).toBe("function"); - }); + test('subscribe returns unsubscribe function', () => { + const store = createStore({ count: 0 }) + const unsub = store.subscribe(() => {}) + expect(typeof unsub).toBe('function') + }) - test("unsubscribe stops notifications", () => { - const store = createStore({ count: 0 }); - let count = 0; - const unsub = store.subscribe(() => { count++; }); - store.setState(prev => ({ count: prev.count + 1 })); - unsub(); - store.setState(prev => ({ count: prev.count + 1 })); - expect(count).toBe(1); - }); + test('unsubscribe stops notifications', () => { + const store = createStore({ count: 0 }) + let count = 0 + const unsub = store.subscribe(() => { + count++ + }) + store.setState(prev => ({ count: prev.count + 1 })) + unsub() + store.setState(prev => ({ count: prev.count + 1 })) + expect(count).toBe(1) + }) - test("multiple subscribers all get notified", () => { - const store = createStore({ count: 0 }); - let a = 0, b = 0; - store.subscribe(() => { a++; }); - store.subscribe(() => { b++; }); - store.setState(prev => ({ count: prev.count + 1 })); - expect(a).toBe(1); - expect(b).toBe(1); - }); + test('multiple subscribers all get notified', () => { + const store = createStore({ count: 0 }) + let a = 0, + b = 0 + store.subscribe(() => { + a++ + }) + store.subscribe(() => { + b++ + }) + store.setState(prev => ({ count: prev.count + 1 })) + expect(a).toBe(1) + expect(b).toBe(1) + }) - test("onChange callback is called on state change", () => { - let captured: any = null; + test('onChange callback is called on state change', () => { + let captured: any = null const store = createStore({ count: 0 }, ({ newState, oldState }) => { - captured = { newState, oldState }; - }); - store.setState(prev => ({ count: prev.count + 5 })); - expect(captured).not.toBeNull(); - expect(captured.oldState.count).toBe(0); - expect(captured.newState.count).toBe(5); - }); + captured = { newState, oldState } + }) + store.setState(prev => ({ count: prev.count + 5 })) + expect(captured).not.toBeNull() + expect(captured.oldState.count).toBe(0) + expect(captured.newState.count).toBe(5) + }) - test("onChange is not called when state unchanged", () => { - let called = false; - const store = createStore({ count: 0 }, () => { called = true; }); - store.setState(prev => prev); - expect(called).toBe(false); - }); + test('onChange is not called when state unchanged', () => { + let called = false + const store = createStore({ count: 0 }, () => { + called = true + }) + store.setState(prev => prev) + expect(called).toBe(false) + }) - test("works with complex state objects", () => { - const store = createStore({ items: [] as number[], name: "test" }); - store.setState(prev => ({ ...prev, items: [1, 2, 3] })); - expect(store.getState().items).toEqual([1, 2, 3]); - expect(store.getState().name).toBe("test"); - }); + test('works with complex state objects', () => { + const store = createStore({ items: [] as number[], name: 'test' }) + store.setState(prev => ({ ...prev, items: [1, 2, 3] })) + expect(store.getState().items).toEqual([1, 2, 3]) + expect(store.getState().name).toBe('test') + }) - test("works with primitive state", () => { - const store = createStore(0); - store.setState(() => 42); - expect(store.getState()).toBe(42); - }); + test('works with primitive state', () => { + const store = createStore(0) + store.setState(() => 42) + expect(store.getState()).toBe(42) + }) - test("updater receives previous state", () => { - const store = createStore({ value: 10 }); + test('updater receives previous state', () => { + const store = createStore({ value: 10 }) store.setState(prev => { - expect(prev.value).toBe(10); - return { value: prev.value * 2 }; - }); - expect(store.getState().value).toBe(20); - }); + expect(prev.value).toBe(10) + return { value: prev.value * 2 } + }) + expect(store.getState().value).toBe(20) + }) - test("sequential setState calls produce final state", () => { - const store = createStore({ count: 0 }); - store.setState(prev => ({ count: prev.count + 1 })); - store.setState(prev => ({ count: prev.count + 1 })); - store.setState(prev => ({ count: prev.count + 1 })); - expect(store.getState().count).toBe(3); - }); -}); + test('sequential setState calls produce final state', () => { + const store = createStore({ count: 0 }) + store.setState(prev => ({ count: prev.count + 1 })) + store.setState(prev => ({ count: prev.count + 1 })) + store.setState(prev => ({ count: prev.count + 1 })) + expect(store.getState().count).toBe(3) + }) +}) diff --git a/src/state/src/context/notifications.ts b/src/state/src/context/notifications.ts index c212e68b7..22164fdb3 100644 --- a/src/state/src/context/notifications.ts +++ b/src/state/src/context/notifications.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type Notification = any; +export type Notification = any diff --git a/src/state/src/utils/todo/types.ts b/src/state/src/utils/todo/types.ts index 273f5ec20..36cd96132 100644 --- a/src/state/src/utils/todo/types.ts +++ b/src/state/src/utils/todo/types.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type TodoList = any; +export type TodoList = any diff --git a/src/tasks/LocalAgentTask/LocalAgentTask.tsx b/src/tasks/LocalAgentTask/LocalAgentTask.tsx index 3b65cfb82..f458d0b5a 100644 --- a/src/tasks/LocalAgentTask/LocalAgentTask.tsx +++ b/src/tasks/LocalAgentTask/LocalAgentTask.tsx @@ -1,5 +1,5 @@ -import type { BetaUsage } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs' -import { getSdkAgentProgressSummariesEnabled } from '../../bootstrap/state.js' +import type { BetaUsage } from '@anthropic-ai/sdk/resources/beta/messages/messages.mjs'; +import { getSdkAgentProgressSummariesEnabled } from '../../bootstrap/state.js'; import { OUTPUT_FILE_TAG, STATUS_TAG, @@ -10,69 +10,58 @@ import { WORKTREE_BRANCH_TAG, WORKTREE_PATH_TAG, WORKTREE_TAG, -} from '../../constants/xml.js' -import { abortSpeculation } from '../../services/PromptSuggestion/speculation.js' -import type { AppState } from '../../state/AppState.js' -import type { SetAppState, Task, TaskStateBase } from '../../Task.js' -import { createTaskStateBase } from '../../Task.js' -import type { Tools } from '../../Tool.js' -import { findToolByName } from '../../Tool.js' -import type { AgentToolResult } from '@claude-code-best/builtin-tools/tools/AgentTool/agentToolUtils.js' -import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js' -import { SYNTHETIC_OUTPUT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SyntheticOutputTool/SyntheticOutputTool.js' -import { asAgentId } from '../../types/ids.js' -import type { Message } from '../../types/message.js' -import { - createAbortController, - createChildAbortController, -} from '../../utils/abortController.js' -import { registerCleanup } from '../../utils/cleanupRegistry.js' -import { getToolSearchOrReadInfo } from '../../utils/collapseReadSearch.js' -import { enqueuePendingNotification } from '../../utils/messageQueueManager.js' -import { getAgentTranscriptPath } from '../../utils/sessionStorage.js' -import { - evictTaskOutput, - getTaskOutputPath, - initTaskOutputAsSymlink, -} from '../../utils/task/diskOutput.js' -import { - PANEL_GRACE_MS, - registerTask, - updateTaskState, -} from '../../utils/task/framework.js' -import { emitTaskProgress } from '../../utils/task/sdkProgress.js' -import type { TaskState } from '../types.js' +} from '../../constants/xml.js'; +import { abortSpeculation } from '../../services/PromptSuggestion/speculation.js'; +import type { AppState } from '../../state/AppState.js'; +import type { SetAppState, Task, TaskStateBase } from '../../Task.js'; +import { createTaskStateBase } from '../../Task.js'; +import type { Tools } from '../../Tool.js'; +import { findToolByName } from '../../Tool.js'; +import type { AgentToolResult } from '@claude-code-best/builtin-tools/tools/AgentTool/agentToolUtils.js'; +import type { AgentDefinition } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'; +import { SYNTHETIC_OUTPUT_TOOL_NAME } from '@claude-code-best/builtin-tools/tools/SyntheticOutputTool/SyntheticOutputTool.js'; +import { asAgentId } from '../../types/ids.js'; +import type { Message } from '../../types/message.js'; +import { createAbortController, createChildAbortController } from '../../utils/abortController.js'; +import { registerCleanup } from '../../utils/cleanupRegistry.js'; +import { getToolSearchOrReadInfo } from '../../utils/collapseReadSearch.js'; +import { enqueuePendingNotification } from '../../utils/messageQueueManager.js'; +import { getAgentTranscriptPath } from '../../utils/sessionStorage.js'; +import { evictTaskOutput, getTaskOutputPath, initTaskOutputAsSymlink } from '../../utils/task/diskOutput.js'; +import { PANEL_GRACE_MS, registerTask, updateTaskState } from '../../utils/task/framework.js'; +import { emitTaskProgress } from '../../utils/task/sdkProgress.js'; +import type { TaskState } from '../types.js'; export type ToolActivity = { - toolName: string - input: Record + toolName: string; + input: Record; /** Pre-computed activity description from the tool, e.g. "Reading src/foo.ts" */ - activityDescription?: string + activityDescription?: string; /** Pre-computed: true if this is a search operation (Grep, Glob, etc.) */ - isSearch?: boolean + isSearch?: boolean; /** Pre-computed: true if this is a read operation (Read, cat, etc.) */ - isRead?: boolean -} + isRead?: boolean; +}; export type AgentProgress = { - toolUseCount: number - tokenCount: number - lastActivity?: ToolActivity - recentActivities?: ToolActivity[] - summary?: string -} + toolUseCount: number; + tokenCount: number; + lastActivity?: ToolActivity; + recentActivities?: ToolActivity[]; + summary?: string; +}; -const MAX_RECENT_ACTIVITIES = 5 +const MAX_RECENT_ACTIVITIES = 5; export type ProgressTracker = { - toolUseCount: number + toolUseCount: number; // Track input and output separately to avoid double-counting. // input_tokens in Claude API is cumulative per turn (includes all previous context), // so we keep the latest value. output_tokens is per-turn, so we sum those. - latestInputTokens: number - cumulativeOutputTokens: number - recentActivities: ToolActivity[] -} + latestInputTokens: number; + cumulativeOutputTokens: number; + recentActivities: ToolActivity[]; +}; export function createProgressTracker(): ProgressTracker { return { @@ -80,11 +69,11 @@ export function createProgressTracker(): ProgressTracker { latestInputTokens: 0, cumulativeOutputTokens: 0, recentActivities: [], - } + }; } export function getTokenCountFromTracker(tracker: ProgressTracker): number { - return tracker.latestInputTokens + tracker.cumulativeOutputTokens + return tracker.latestInputTokens + tracker.cumulativeOutputTokens; } /** @@ -92,10 +81,7 @@ export function getTokenCountFromTracker(tracker: ProgressTracker): number { * for a given tool name and input. Used to pre-compute descriptions * from Tool.getActivityDescription() at recording time. */ -export type ActivityDescriptionResolver = ( - toolName: string, - input: Record, -) => string | undefined +export type ActivityDescriptionResolver = (toolName: string, input: Record) => string | undefined; export function updateProgressFromMessage( tracker: ProgressTracker, @@ -104,39 +90,32 @@ export function updateProgressFromMessage( tools?: Tools, ): void { if (message.type !== 'assistant') { - return + return; } - const usage = message.message!.usage as BetaUsage + const usage = message.message!.usage as BetaUsage; // Keep latest input (it's cumulative in the API), sum outputs tracker.latestInputTokens = - (usage.input_tokens as number) + - (usage.cache_creation_input_tokens ?? 0) + - (usage.cache_read_input_tokens ?? 0) - tracker.cumulativeOutputTokens += usage.output_tokens as number + (usage.input_tokens as number) + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0); + tracker.cumulativeOutputTokens += usage.output_tokens as number; for (const content of (message.message!.content ?? []) as Array<{ type: string; name?: string; input?: unknown }>) { if (content.type === 'tool_use') { - tracker.toolUseCount++ + tracker.toolUseCount++; // Omit StructuredOutput from preview - it's an internal tool if (content.name !== SYNTHETIC_OUTPUT_TOOL_NAME) { - const input = content.input as Record - const classification = tools - ? getToolSearchOrReadInfo(content.name!, input, tools) - : undefined + const input = content.input as Record; + const classification = tools ? getToolSearchOrReadInfo(content.name!, input, tools) : undefined; tracker.recentActivities.push({ toolName: content.name!, input, - activityDescription: resolveActivityDescription?.( - content.name!, - input, - ), + activityDescription: resolveActivityDescription?.(content.name!, input), isSearch: classification?.isSearch, isRead: classification?.isRead, - }) + }); } } } while (tracker.recentActivities.length > MAX_RECENT_ACTIVITIES) { - tracker.recentActivities.shift() + tracker.recentActivities.shift(); } } @@ -145,67 +124,58 @@ export function getProgressUpdate(tracker: ProgressTracker): AgentProgress { toolUseCount: tracker.toolUseCount, tokenCount: getTokenCountFromTracker(tracker), lastActivity: - tracker.recentActivities.length > 0 - ? tracker.recentActivities[tracker.recentActivities.length - 1] - : undefined, + tracker.recentActivities.length > 0 ? tracker.recentActivities[tracker.recentActivities.length - 1] : undefined, recentActivities: [...tracker.recentActivities], - } + }; } /** * Creates an ActivityDescriptionResolver from a tools list. * Looks up the tool by name and calls getActivityDescription if available. */ -export function createActivityDescriptionResolver( - tools: Tools, -): ActivityDescriptionResolver { +export function createActivityDescriptionResolver(tools: Tools): ActivityDescriptionResolver { return (toolName, input) => { - const tool = findToolByName(tools, toolName) - return tool?.getActivityDescription?.(input) ?? undefined - } + const tool = findToolByName(tools, toolName); + return tool?.getActivityDescription?.(input) ?? undefined; + }; } export type LocalAgentTaskState = TaskStateBase & { - type: 'local_agent' - agentId: string - prompt: string - selectedAgent?: AgentDefinition - agentType: string - model?: string - abortController?: AbortController - unregisterCleanup?: () => void - error?: string - result?: AgentToolResult - progress?: AgentProgress - retrieved: boolean - messages?: Message[] + type: 'local_agent'; + agentId: string; + prompt: string; + selectedAgent?: AgentDefinition; + agentType: string; + model?: string; + abortController?: AbortController; + unregisterCleanup?: () => void; + error?: string; + result?: AgentToolResult; + progress?: AgentProgress; + retrieved: boolean; + messages?: Message[]; // Track what we last reported for computing deltas - lastReportedToolCount: number - lastReportedTokenCount: number + lastReportedToolCount: number; + lastReportedTokenCount: number; // Whether the task has been backgrounded (false = foreground running, true = backgrounded) - isBackgrounded: boolean + isBackgrounded: boolean; // Messages queued mid-turn via SendMessage, drained at tool-round boundaries - pendingMessages: string[] + pendingMessages: string[]; // UI is holding this task: blocks eviction, enables stream-append, triggers // disk bootstrap. Set by enterTeammateView. Separate from viewingAgentTaskId // (which is "what am I LOOKING at") — retain is "what am I HOLDING." - retain: boolean + retain: boolean; // Bootstrap has read the sidechain JSONL and UUID-merged into messages. // One-shot per retain cycle; stream appends from there. - diskLoaded: boolean + diskLoaded: boolean; // Panel visibility deadline. undefined = no deadline (running or retained); // timestamp = hide + GC-eligible after this time. Set at terminal transition // and on unselect; cleared on retain. - evictAfter?: number -} + evictAfter?: number; +}; export function isLocalAgentTask(task: unknown): task is LocalAgentTaskState { - return ( - typeof task === 'object' && - task !== null && - 'type' in task && - task.type === 'local_agent' - ) + return typeof task === 'object' && task !== null && 'type' in task && task.type === 'local_agent'; } /** @@ -215,7 +185,7 @@ export function isLocalAgentTask(task: unknown): task is LocalAgentTaskState { * the gate changes, change it here. */ export function isPanelAgentTask(t: unknown): t is LocalAgentTaskState { - return isLocalAgentTask(t) && t.agentType !== 'main-session' + return isLocalAgentTask(t) && t.agentType !== 'main-session'; } export function queuePendingMessage( @@ -226,7 +196,7 @@ export function queuePendingMessage( updateTaskState(taskId, setAppState, task => ({ ...task, pendingMessages: [...task.pendingMessages, msg], - })) + })); } /** @@ -243,7 +213,7 @@ export function appendMessageToLocalAgent( updateTaskState(taskId, setAppState, task => ({ ...task, messages: [...(task.messages ?? []), message], - })) + })); } export function drainPendingMessages( @@ -251,16 +221,16 @@ export function drainPendingMessages( getAppState: () => AppState, setAppState: (f: (prev: AppState) => AppState) => void, ): string[] { - const task = getAppState().tasks[taskId] + const task = getAppState().tasks[taskId]; if (!isLocalAgentTask(task) || task.pendingMessages.length === 0) { - return [] + return []; } - const drained = task.pendingMessages + const drained = task.pendingMessages; updateTaskState(taskId, setAppState, t => ({ ...t, pendingMessages: [], - })) - return drained + })); + return drained; } /** @@ -278,72 +248,70 @@ export function enqueueAgentNotification({ worktreePath, worktreeBranch, }: { - taskId: string - description: string - status: 'completed' | 'failed' | 'killed' - error?: string - setAppState: SetAppState - finalMessage?: string + taskId: string; + description: string; + status: 'completed' | 'failed' | 'killed'; + error?: string; + setAppState: SetAppState; + finalMessage?: string; usage?: { - totalTokens: number - toolUses: number - durationMs: number - } - toolUseId?: string - worktreePath?: string - worktreeBranch?: string + totalTokens: number; + toolUses: number; + durationMs: number; + }; + toolUseId?: string; + worktreePath?: string; + worktreeBranch?: string; }): void { // Atomically check and set notified flag to prevent duplicate notifications. // If the task was already marked as notified (e.g., by TaskStopTool), skip // enqueueing to avoid sending redundant messages to the model. - let shouldEnqueue = false + let shouldEnqueue = false; updateTaskState(taskId, setAppState, task => { if (task.notified) { - return task + return task; } - shouldEnqueue = true + shouldEnqueue = true; return { ...task, notified: true, - } - }) + }; + }); if (!shouldEnqueue) { - return + return; } // Abort any active speculation — background task state changed, so speculated // results may reference stale task output. The prompt suggestion text is // preserved; only the pre-computed response is discarded. - abortSpeculation(setAppState) + abortSpeculation(setAppState); const summary = status === 'completed' ? `Agent "${description}" completed` : status === 'failed' ? `Agent "${description}" failed: ${error || 'Unknown error'}` - : `Agent "${description}" was stopped` + : `Agent "${description}" was stopped`; - const outputPath = getTaskOutputPath(taskId) - const toolUseIdLine = toolUseId - ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}` - : '' - const resultSection = finalMessage ? `\n${finalMessage}` : '' + const outputPath = getTaskOutputPath(taskId); + const toolUseIdLine = toolUseId ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}` : ''; + const resultSection = finalMessage ? `\n${finalMessage}` : ''; const usageSection = usage ? `\n${usage.totalTokens}${usage.toolUses}${usage.durationMs}` - : '' + : ''; const worktreeSection = worktreePath ? `\n<${WORKTREE_TAG}><${WORKTREE_PATH_TAG}>${worktreePath}${worktreeBranch ? `<${WORKTREE_BRANCH_TAG}>${worktreeBranch}` : ''}` - : '' + : ''; const message = `<${TASK_NOTIFICATION_TAG}> <${TASK_ID_TAG}>${taskId}${toolUseIdLine} <${OUTPUT_FILE_TAG}>${outputPath} <${STATUS_TAG}>${status} <${SUMMARY_TAG}>${summary}${resultSection}${usageSection}${worktreeSection} -` +`; - enqueuePendingNotification({ value: message, mode: 'task-notification' }) + enqueuePendingNotification({ value: message, mode: 'task-notification' }); } /** @@ -357,22 +325,22 @@ export const LocalAgentTask: Task = { type: 'local_agent', async kill(taskId, setAppState) { - killAsyncAgent(taskId, setAppState) + killAsyncAgent(taskId, setAppState); }, -} +}; /** * Kill an agent task. No-op if already killed/completed. */ export function killAsyncAgent(taskId: string, setAppState: SetAppState): void { - let killed = false + let killed = false; updateTaskState(taskId, setAppState, task => { if (task.status !== 'running') { - return task + return task; } - killed = true - task.abortController?.abort() - task.unregisterCleanup?.() + killed = true; + task.abortController?.abort(); + task.unregisterCleanup?.(); return { ...task, status: 'killed', @@ -381,10 +349,10 @@ export function killAsyncAgent(taskId: string, setAppState: SetAppState): void { abortController: undefined, unregisterCleanup: undefined, selectedAgent: undefined, - } - }) + }; + }); if (killed) { - void evictTaskOutput(taskId) + void evictTaskOutput(taskId); } } @@ -392,13 +360,10 @@ export function killAsyncAgent(taskId: string, setAppState: SetAppState): void { * Kill all running agent tasks. * Used by ESC cancellation in coordinator mode to stop all subagents. */ -export function killAllRunningAgentTasks( - tasks: Record, - setAppState: SetAppState, -): void { +export function killAllRunningAgentTasks(tasks: Record, setAppState: SetAppState): void { for (const [taskId, task] of Object.entries(tasks)) { if (task.type === 'local_agent' && task.status === 'running') { - killAsyncAgent(taskId, setAppState) + killAsyncAgent(taskId, setAppState); } } } @@ -408,19 +373,16 @@ export function killAllRunningAgentTasks( * Used by chat:killAgents bulk kill to suppress per-agent async notifications * when a single aggregate message is sent instead. */ -export function markAgentsNotified( - taskId: string, - setAppState: SetAppState, -): void { +export function markAgentsNotified(taskId: string, setAppState: SetAppState): void { updateTaskState(taskId, setAppState, task => { if (task.notified) { - return task + return task; } return { ...task, notified: true, - } - }) + }; + }); } /** @@ -428,45 +390,35 @@ export function markAgentsNotified( * Preserves the existing summary field so that background summarization * results are not clobbered by progress updates from assistant messages. */ -export function updateAgentProgress( - taskId: string, - progress: AgentProgress, - setAppState: SetAppState, -): void { +export function updateAgentProgress(taskId: string, progress: AgentProgress, setAppState: SetAppState): void { updateTaskState(taskId, setAppState, task => { if (task.status !== 'running') { - return task + return task; } - const existingSummary = task.progress?.summary + const existingSummary = task.progress?.summary; return { ...task, - progress: existingSummary - ? { ...progress, summary: existingSummary } - : progress, - } - }) + progress: existingSummary ? { ...progress, summary: existingSummary } : progress, + }; + }); } /** * Update the background summary for an agent task. * Called by the periodic summarization service to store a 1-2 sentence progress summary. */ -export function updateAgentSummary( - taskId: string, - summary: string, - setAppState: SetAppState, -): void { +export function updateAgentSummary(taskId: string, summary: string, setAppState: SetAppState): void { let captured: { - tokenCount: number - toolUseCount: number - startTime: number - toolUseId: string | undefined - } | null = null + tokenCount: number; + toolUseCount: number; + startTime: number; + toolUseId: string | undefined; + } | null = null; updateTaskState(taskId, setAppState, task => { if (task.status !== 'running') { - return task + return task; } captured = { @@ -474,7 +426,7 @@ export function updateAgentSummary( toolUseCount: task.progress?.toolUseCount ?? 0, startTime: task.startTime, toolUseId: task.toolUseId, - } + }; return { ...task, @@ -484,14 +436,14 @@ export function updateAgentSummary( tokenCount: task.progress?.tokenCount ?? 0, summary, }, - } - }) + }; + }); // Emit summary to SDK consumers (e.g. VS Code subagent panel). No-op in TUI. // Gate on the SDK option so coordinator-mode sessions without the flag don't // leak summary events to consumers who didn't opt in. if (captured && getSdkAgentProgressSummariesEnabled()) { - const { tokenCount, toolUseCount, startTime, toolUseId } = captured + const { tokenCount, toolUseCount, startTime, toolUseId } = captured; emitTaskProgress({ taskId, toolUseId, @@ -500,24 +452,21 @@ export function updateAgentSummary( totalTokens: tokenCount, toolUses: toolUseCount, summary, - }) + }); } } /** * Complete an agent task with result. */ -export function completeAgentTask( - result: AgentToolResult, - setAppState: SetAppState, -): void { - const taskId = result.agentId +export function completeAgentTask(result: AgentToolResult, setAppState: SetAppState): void { + const taskId = result.agentId; updateTaskState(taskId, setAppState, task => { if (task.status !== 'running') { - return task + return task; } - task.unregisterCleanup?.() + task.unregisterCleanup?.(); return { ...task, @@ -528,26 +477,22 @@ export function completeAgentTask( abortController: undefined, unregisterCleanup: undefined, selectedAgent: undefined, - } - }) - void evictTaskOutput(taskId) + }; + }); + void evictTaskOutput(taskId); // Note: Notification is sent by AgentTool via enqueueAgentNotification } /** * Fail an agent task with error. */ -export function failAgentTask( - taskId: string, - error: string, - setAppState: SetAppState, -): void { +export function failAgentTask(taskId: string, error: string, setAppState: SetAppState): void { updateTaskState(taskId, setAppState, task => { if (task.status !== 'running') { - return task + return task; } - task.unregisterCleanup?.() + task.unregisterCleanup?.(); return { ...task, @@ -558,9 +503,9 @@ export function failAgentTask( abortController: undefined, unregisterCleanup: undefined, selectedAgent: undefined, - } - }) - void evictTaskOutput(taskId) + }; + }); + void evictTaskOutput(taskId); // Note: Notification is sent by AgentTool via enqueueAgentNotification } @@ -581,23 +526,20 @@ export function registerAsyncAgent({ parentAbortController, toolUseId, }: { - agentId: string - description: string - prompt: string - selectedAgent: AgentDefinition - setAppState: SetAppState - parentAbortController?: AbortController - toolUseId?: string + agentId: string; + description: string; + prompt: string; + selectedAgent: AgentDefinition; + setAppState: SetAppState; + parentAbortController?: AbortController; + toolUseId?: string; }): LocalAgentTaskState { - void initTaskOutputAsSymlink( - agentId, - getAgentTranscriptPath(asAgentId(agentId)), - ) + void initTaskOutputAsSymlink(agentId, getAgentTranscriptPath(asAgentId(agentId))); // Create abort controller - if parent provided, create child that auto-aborts with parent const abortController = parentAbortController ? createChildAbortController(parentAbortController) - : createAbortController() + : createAbortController(); const taskState: LocalAgentTaskState = { ...createTaskStateBase(agentId, 'local_agent', description, toolUseId), @@ -615,24 +557,24 @@ export function registerAsyncAgent({ pendingMessages: [], retain: false, diskLoaded: false, - } + }; // Register cleanup handler const unregisterCleanup = registerCleanup(async () => { - killAsyncAgent(agentId, setAppState) - }) + killAsyncAgent(agentId, setAppState); + }); - taskState.unregisterCleanup = unregisterCleanup + taskState.unregisterCleanup = unregisterCleanup; // Register task in AppState - registerTask(taskState, setAppState) + registerTask(taskState, setAppState); - return taskState + return taskState; } // Map of taskId -> resolve function for background signals // When backgroundAgentTask is called, it resolves the corresponding promise -const backgroundSignalResolvers = new Map void>() +const backgroundSignalResolvers = new Map void>(); /** * Register a foreground agent task that could be backgrounded later. @@ -648,28 +590,25 @@ export function registerAgentForeground({ autoBackgroundMs, toolUseId, }: { - agentId: string - description: string - prompt: string - selectedAgent: AgentDefinition - setAppState: SetAppState - autoBackgroundMs?: number - toolUseId?: string + agentId: string; + description: string; + prompt: string; + selectedAgent: AgentDefinition; + setAppState: SetAppState; + autoBackgroundMs?: number; + toolUseId?: string; }): { - taskId: string - backgroundSignal: Promise - cancelAutoBackground?: () => void + taskId: string; + backgroundSignal: Promise; + cancelAutoBackground?: () => void; } { - void initTaskOutputAsSymlink( - agentId, - getAgentTranscriptPath(asAgentId(agentId)), - ) + void initTaskOutputAsSymlink(agentId, getAgentTranscriptPath(asAgentId(agentId))); - const abortController = createAbortController() + const abortController = createAbortController(); const unregisterCleanup = registerCleanup(async () => { - killAsyncAgent(agentId, setAppState) - }) + killAsyncAgent(agentId, setAppState); + }); const taskState: LocalAgentTaskState = { ...createTaskStateBase(agentId, 'local_agent', description, toolUseId), @@ -688,27 +627,27 @@ export function registerAgentForeground({ pendingMessages: [], retain: false, diskLoaded: false, - } + }; // Create background signal promise - let resolveBackgroundSignal: () => void + let resolveBackgroundSignal: () => void; const backgroundSignal = new Promise(resolve => { - resolveBackgroundSignal = resolve - }) - backgroundSignalResolvers.set(agentId, resolveBackgroundSignal!) + resolveBackgroundSignal = resolve; + }); + backgroundSignalResolvers.set(agentId, resolveBackgroundSignal!); - registerTask(taskState, setAppState) + registerTask(taskState, setAppState); // Auto-background after timeout if configured - let cancelAutoBackground: (() => void) | undefined + let cancelAutoBackground: (() => void) | undefined; if (autoBackgroundMs !== undefined && autoBackgroundMs > 0) { const timer = setTimeout( (setAppState, agentId) => { // Mark task as backgrounded and resolve the signal setAppState(prev => { - const prevTask = prev.tasks[agentId] + const prevTask = prev.tasks[agentId]; if (!isLocalAgentTask(prevTask) || prevTask.isBackgrounded) { - return prev + return prev; } return { ...prev, @@ -716,44 +655,40 @@ export function registerAgentForeground({ ...prev.tasks, [agentId]: { ...prevTask, isBackgrounded: true }, }, - } - }) - const resolver = backgroundSignalResolvers.get(agentId) + }; + }); + const resolver = backgroundSignalResolvers.get(agentId); if (resolver) { - resolver() - backgroundSignalResolvers.delete(agentId) + resolver(); + backgroundSignalResolvers.delete(agentId); } }, autoBackgroundMs, setAppState, agentId, - ) - cancelAutoBackground = () => clearTimeout(timer) + ); + cancelAutoBackground = () => clearTimeout(timer); } - return { taskId: agentId, backgroundSignal, cancelAutoBackground } + return { taskId: agentId, backgroundSignal, cancelAutoBackground }; } /** * Background a specific foreground agent task. * @returns true if backgrounded successfully, false otherwise */ -export function backgroundAgentTask( - taskId: string, - getAppState: () => AppState, - setAppState: SetAppState, -): boolean { - const state = getAppState() - const task = state.tasks[taskId] +export function backgroundAgentTask(taskId: string, getAppState: () => AppState, setAppState: SetAppState): boolean { + const state = getAppState(); + const task = state.tasks[taskId]; if (!isLocalAgentTask(task) || task.isBackgrounded) { - return false + return false; } // Update state to mark as backgrounded setAppState(prev => { - const prevTask = prev.tasks[taskId] + const prevTask = prev.tasks[taskId]; if (!isLocalAgentTask(prevTask)) { - return prev + return prev; } return { ...prev, @@ -761,45 +696,42 @@ export function backgroundAgentTask( ...prev.tasks, [taskId]: { ...prevTask, isBackgrounded: true }, }, - } - }) + }; + }); // Resolve the background signal to interrupt the agent loop - const resolver = backgroundSignalResolvers.get(taskId) + const resolver = backgroundSignalResolvers.get(taskId); if (resolver) { - resolver() - backgroundSignalResolvers.delete(taskId) + resolver(); + backgroundSignalResolvers.delete(taskId); } - return true + return true; } /** * Unregister a foreground agent task when the agent completes without being backgrounded. */ -export function unregisterAgentForeground( - taskId: string, - setAppState: SetAppState, -): void { +export function unregisterAgentForeground(taskId: string, setAppState: SetAppState): void { // Clean up the background signal resolver - backgroundSignalResolvers.delete(taskId) + backgroundSignalResolvers.delete(taskId); - let cleanupFn: (() => void) | undefined + let cleanupFn: (() => void) | undefined; setAppState(prev => { - const task = prev.tasks[taskId] + const task = prev.tasks[taskId]; // Only remove if it's a foreground task (not backgrounded) if (!isLocalAgentTask(task) || task.isBackgrounded) { - return prev + return prev; } // Capture cleanup function to call outside of updater - cleanupFn = task.unregisterCleanup + cleanupFn = task.unregisterCleanup; - const { [taskId]: removed, ...rest } = prev.tasks - return { ...prev, tasks: rest } - }) + const { [taskId]: removed, ...rest } = prev.tasks; + return { ...prev, tasks: rest }; + }); // Call cleanup outside of the state updater (avoid side effects in updater) - cleanupFn?.() + cleanupFn?.(); } diff --git a/src/tasks/LocalMainSessionTask.ts b/src/tasks/LocalMainSessionTask.ts index 27398d515..ac87e0586 100644 --- a/src/tasks/LocalMainSessionTask.ts +++ b/src/tasks/LocalMainSessionTask.ts @@ -210,7 +210,10 @@ export function completeMainSessionTask( // Set notified so evictTerminalTask/generateTaskAttachments eviction // guards pass; the backgrounded path sets this inside // enqueueMainSessionNotification's check-and-set. - updateTaskState(taskId, setAppState, task => ({ ...task, notified: true })) + updateTaskState(taskId, setAppState, task => ({ + ...task, + notified: true, + })) emitTaskTerminatedSdk(taskId, success ? 'completed' : 'failed', { toolUseId, summary: 'Background session', @@ -388,10 +391,14 @@ export function startBackgroundSession({ // Aborted mid-stream — completeMainSessionTask won't be reached. // chat:killAgents path already marked notified + emitted; stopTask path did not. let alreadyNotified = false - updateTaskState(taskId, setAppState, task => { - alreadyNotified = task.notified === true - return alreadyNotified ? task : { ...task, notified: true } - }) + updateTaskState( + taskId, + setAppState, + task => { + alreadyNotified = task.notified === true + return alreadyNotified ? task : { ...task, notified: true } + }, + ) if (!alreadyNotified) { emitTaskTerminatedSdk(taskId, 'stopped', { summary: description, @@ -420,7 +427,12 @@ export function startBackgroundSession({ lastRecordedUuid = msg.uuid if (msg.type === 'assistant') { - const contentBlocks = (msg.message?.content ?? []) as Array<{ type: string; text?: string; name?: string; input?: unknown }> + const contentBlocks = (msg.message?.content ?? []) as Array<{ + type: string + text?: string + name?: string + input?: unknown + }> for (const block of contentBlocks) { if (block.type === 'text') { tokenCount += roughTokenCountEstimation(block.text ?? '') diff --git a/src/tasks/LocalShellTask/LocalShellTask.tsx b/src/tasks/LocalShellTask/LocalShellTask.tsx index 22810bff1..dd8b2138a 100644 --- a/src/tasks/LocalShellTask/LocalShellTask.tsx +++ b/src/tasks/LocalShellTask/LocalShellTask.tsx @@ -1,5 +1,5 @@ -import { feature } from 'bun:bundle' -import { stat } from 'fs/promises' +import { feature } from 'bun:bundle'; +import { stat } from 'fs/promises'; import { OUTPUT_FILE_TAG, STATUS_TAG, @@ -7,47 +7,31 @@ import { TASK_ID_TAG, TASK_NOTIFICATION_TAG, TOOL_USE_ID_TAG, -} from '../../constants/xml.js' -import { abortSpeculation } from '../../services/PromptSuggestion/speculation.js' -import type { AppState } from '../../state/AppState.js' -import type { - LocalShellSpawnInput, - SetAppState, - Task, - TaskContext, - TaskHandle, -} from '../../Task.js' -import { createTaskStateBase } from '../../Task.js' -import type { AgentId } from '../../types/ids.js' -import { registerCleanup } from '../../utils/cleanupRegistry.js' -import { tailFile } from '../../utils/fsOperations.js' -import { logError } from '../../utils/log.js' -import { enqueuePendingNotification } from '../../utils/messageQueueManager.js' -import type { ShellCommand } from '../../utils/ShellCommand.js' -import { - evictTaskOutput, - getTaskOutputPath, -} from '../../utils/task/diskOutput.js' -import { registerTask, updateTaskState } from '../../utils/task/framework.js' -import { escapeXml } from '../../utils/xml.js' -import { - backgroundAgentTask, - isLocalAgentTask, -} from '../LocalAgentTask/LocalAgentTask.js' -import { isMainSessionTask } from '../LocalMainSessionTask.js' -import { - type BashTaskKind, - isLocalShellTask, - type LocalShellTaskState, -} from './guards.js' -import { killTask } from './killShellTasks.js' +} from '../../constants/xml.js'; +import { abortSpeculation } from '../../services/PromptSuggestion/speculation.js'; +import type { AppState } from '../../state/AppState.js'; +import type { LocalShellSpawnInput, SetAppState, Task, TaskContext, TaskHandle } from '../../Task.js'; +import { createTaskStateBase } from '../../Task.js'; +import type { AgentId } from '../../types/ids.js'; +import { registerCleanup } from '../../utils/cleanupRegistry.js'; +import { tailFile } from '../../utils/fsOperations.js'; +import { logError } from '../../utils/log.js'; +import { enqueuePendingNotification } from '../../utils/messageQueueManager.js'; +import type { ShellCommand } from '../../utils/ShellCommand.js'; +import { evictTaskOutput, getTaskOutputPath } from '../../utils/task/diskOutput.js'; +import { registerTask, updateTaskState } from '../../utils/task/framework.js'; +import { escapeXml } from '../../utils/xml.js'; +import { backgroundAgentTask, isLocalAgentTask } from '../LocalAgentTask/LocalAgentTask.js'; +import { isMainSessionTask } from '../LocalMainSessionTask.js'; +import { type BashTaskKind, isLocalShellTask, type LocalShellTaskState } from './guards.js'; +import { killTask } from './killShellTasks.js'; /** Prefix that identifies a LocalShellTask summary to the UI collapse transform. */ -export const BACKGROUND_BASH_SUMMARY_PREFIX = 'Background command ' +export const BACKGROUND_BASH_SUMMARY_PREFIX = 'Background command '; -const STALL_CHECK_INTERVAL_MS = 5_000 -const STALL_THRESHOLD_MS = 45_000 -const STALL_TAIL_BYTES = 1024 +const STALL_CHECK_INTERVAL_MS = 5_000; +const STALL_THRESHOLD_MS = 45_000; +const STALL_TAIL_BYTES = 1024; // Last-line patterns that suggest a command is blocked waiting for keyboard // input. Used to gate the stall notification — we stay silent on commands that @@ -61,11 +45,11 @@ const PROMPT_PATTERNS = [ /Press (any key|Enter)/i, /Continue\?/i, /Overwrite\?/i, -] +]; export function looksLikePrompt(tail: string): boolean { - const lastLine = tail.trimEnd().split('\n').pop() ?? '' - return PROMPT_PATTERNS.some(p => p.test(lastLine)) + const lastLine = tail.trimEnd().split('\n').pop() ?? ''; + return PROMPT_PATTERNS.some(p => p.test(lastLine)); } // Output-side analog of peekForStdinData (utils/process.ts): fire a one-shot @@ -77,38 +61,36 @@ function startStallWatchdog( toolUseId?: string, agentId?: AgentId, ): () => void { - if (kind === 'monitor') return () => {} - const outputPath = getTaskOutputPath(taskId) - let lastSize = 0 - let lastGrowth = Date.now() - let cancelled = false + if (kind === 'monitor') return () => {}; + const outputPath = getTaskOutputPath(taskId); + let lastSize = 0; + let lastGrowth = Date.now(); + let cancelled = false; const timer = setInterval(() => { void stat(outputPath).then( s => { if (s.size > lastSize) { - lastSize = s.size - lastGrowth = Date.now() - return + lastSize = s.size; + lastGrowth = Date.now(); + return; } - if (Date.now() - lastGrowth < STALL_THRESHOLD_MS) return + if (Date.now() - lastGrowth < STALL_THRESHOLD_MS) return; void tailFile(outputPath, STALL_TAIL_BYTES).then( ({ content }) => { - if (cancelled) return + if (cancelled) return; if (!looksLikePrompt(content)) { // Not a prompt — keep watching. Reset so the next check is // 45s out instead of re-reading the tail on every tick. - lastGrowth = Date.now() - return + lastGrowth = Date.now(); + return; } // Latch before the async-boundary-visible side effects so an // overlapping tick's callback sees cancelled=true and bails. - cancelled = true - clearInterval(timer) - const toolUseIdLine = toolUseId - ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}` - : '' - const summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" appears to be waiting for interactive input` + cancelled = true; + clearInterval(timer); + const toolUseIdLine = toolUseId ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}` : ''; + const summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" appears to be waiting for interactive input`; // No tag — print.ts treats as a terminal // signal and an unknown value falls through to 'completed', // falsely closing the task for SDK consumers. Statusless @@ -121,26 +103,26 @@ function startStallWatchdog( Last output: ${content.trimEnd()} -The command is likely blocked on an interactive prompt. Kill this task and re-run with piped input (e.g., \`echo y | command\`) or a non-interactive flag if one exists.` +The command is likely blocked on an interactive prompt. Kill this task and re-run with piped input (e.g., \`echo y | command\`) or a non-interactive flag if one exists.`; enqueuePendingNotification({ value: message, mode: 'task-notification', priority: 'next', agentId, - }) + }); }, () => {}, - ) + ); }, () => {}, // File may not exist yet - ) - }, STALL_CHECK_INTERVAL_MS) - timer.unref() + ); + }, STALL_CHECK_INTERVAL_MS); + timer.unref(); return () => { - cancelled = true - clearInterval(timer) - } + cancelled = true; + clearInterval(timer); + }; } function enqueueShellNotification( @@ -156,25 +138,25 @@ function enqueueShellNotification( // Atomically check and set notified flag to prevent duplicate notifications. // If the task was already marked as notified (e.g., by TaskStopTool), skip // enqueueing to avoid sending redundant messages to the model. - let shouldEnqueue = false + let shouldEnqueue = false; updateTaskState(taskId, setAppState, task => { if (task.notified) { - return task + return task; } - shouldEnqueue = true - return { ...task, notified: true } - }) + shouldEnqueue = true; + return { ...task, notified: true }; + }); if (!shouldEnqueue) { - return + return; } // Abort any active speculation — background task state changed, so speculated // results may reference stale task output. The prompt suggestion text is // preserved; only the pre-computed response is discarded. - abortSpeculation(setAppState) + abortSpeculation(setAppState); - let summary: string + let summary: string; if (feature('MONITOR_TOOL') && kind === 'monitor') { // Monitor is streaming-only (post-#22764) — the script exiting means // the stream ended, not "condition met". Distinct from the bash prefix @@ -182,70 +164,68 @@ function enqueueShellNotification( // completed" collapse. switch (status) { case 'completed': - summary = `Monitor "${description}" stream ended` - break + summary = `Monitor "${description}" stream ended`; + break; case 'failed': - summary = `Monitor "${description}" script failed${exitCode !== undefined ? ` (exit ${exitCode})` : ''}` - break + summary = `Monitor "${description}" script failed${exitCode !== undefined ? ` (exit ${exitCode})` : ''}`; + break; case 'killed': - summary = `Monitor "${description}" stopped` - break + summary = `Monitor "${description}" stopped`; + break; } } else { switch (status) { case 'completed': - summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" completed${exitCode !== undefined ? ` (exit code ${exitCode})` : ''}` - break + summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" completed${exitCode !== undefined ? ` (exit code ${exitCode})` : ''}`; + break; case 'failed': - summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" failed${exitCode !== undefined ? ` with exit code ${exitCode}` : ''}` - break + summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" failed${exitCode !== undefined ? ` with exit code ${exitCode}` : ''}`; + break; case 'killed': - summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" was stopped` - break + summary = `${BACKGROUND_BASH_SUMMARY_PREFIX}"${description}" was stopped`; + break; } } - const outputPath = getTaskOutputPath(taskId) - const toolUseIdLine = toolUseId - ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}` - : '' + const outputPath = getTaskOutputPath(taskId); + const toolUseIdLine = toolUseId ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}` : ''; const message = `<${TASK_NOTIFICATION_TAG}> <${TASK_ID_TAG}>${taskId}${toolUseIdLine} <${OUTPUT_FILE_TAG}>${outputPath} <${STATUS_TAG}>${status} <${SUMMARY_TAG}>${escapeXml(summary)} -` +`; enqueuePendingNotification({ value: message, mode: 'task-notification', priority: feature('MONITOR_TOOL') ? 'next' : 'later', agentId, - }) + }); } export const LocalShellTask: Task = { name: 'LocalShellTask', type: 'local_bash', async kill(taskId, setAppState) { - killTask(taskId, setAppState) + killTask(taskId, setAppState); }, -} +}; export async function spawnShellTask( input: LocalShellSpawnInput & { shellCommand: ShellCommand }, context: TaskContext, ): Promise { - const { command, description, shellCommand, toolUseId, agentId, kind } = input - const { setAppState } = context + const { command, description, shellCommand, toolUseId, agentId, kind } = input; + const { setAppState } = context; // TaskOutput owns the data — use its taskId so disk writes are consistent - const { taskOutput } = shellCommand - const taskId = taskOutput.taskId + const { taskOutput } = shellCommand; + const taskId = taskOutput.taskId; const unregisterCleanup = registerCleanup(async () => { - killTask(taskId, setAppState) - }) + killTask(taskId, setAppState); + }); const taskState: LocalShellTaskState = { ...createTaskStateBase(taskId, 'local_bash', description, toolUseId), @@ -259,31 +239,25 @@ export async function spawnShellTask( isBackgrounded: true, agentId, kind, - } + }; - registerTask(taskState, setAppState) + registerTask(taskState, setAppState); // Data flows through TaskOutput automatically — no stream listeners needed. // Just transition to backgrounded state so the process keeps running. - shellCommand.background(taskId) + shellCommand.background(taskId); - const cancelStallWatchdog = startStallWatchdog( - taskId, - description, - kind, - toolUseId, - agentId, - ) + const cancelStallWatchdog = startStallWatchdog(taskId, description, kind, toolUseId, agentId); void shellCommand.result.then(async result => { - cancelStallWatchdog() - await flushAndCleanup(shellCommand) - let wasKilled = false + cancelStallWatchdog(); + await flushAndCleanup(shellCommand); + let wasKilled = false; updateTaskState(taskId, setAppState, task => { if (task.status === 'killed') { - wasKilled = true - return task + wasKilled = true; + return task; } return { @@ -293,8 +267,8 @@ export async function spawnShellTask( shellCommand: null, unregisterCleanup: undefined, endTime: Date.now(), - } - }) + }; + }); enqueueShellNotification( taskId, @@ -305,17 +279,17 @@ export async function spawnShellTask( toolUseId, kind, agentId, - ) + ); - void evictTaskOutput(taskId) - }) + void evictTaskOutput(taskId); + }); return { taskId, cleanup: () => { - unregisterCleanup() + unregisterCleanup(); }, - } + }; } /** @@ -328,13 +302,13 @@ export function registerForeground( setAppState: SetAppState, toolUseId?: string, ): string { - const { command, description, shellCommand, agentId } = input + const { command, description, shellCommand, agentId } = input; - const taskId = shellCommand.taskOutput.taskId + const taskId = shellCommand.taskOutput.taskId; const unregisterCleanup = registerCleanup(async () => { - killTask(taskId, setAppState) - }) + killTask(taskId, setAppState); + }); const taskState: LocalShellTaskState = { ...createTaskStateBase(taskId, 'local_bash', description, toolUseId), @@ -347,41 +321,37 @@ export function registerForeground( lastReportedTotalLines: 0, isBackgrounded: false, // Not yet backgrounded - running in foreground agentId, - } + }; - registerTask(taskState, setAppState) - return taskId + registerTask(taskState, setAppState); + return taskId; } /** * Background a specific foreground task. * @returns true if backgrounded successfully, false otherwise */ -function backgroundTask( - taskId: string, - getAppState: () => AppState, - setAppState: SetAppState, -): boolean { +function backgroundTask(taskId: string, getAppState: () => AppState, setAppState: SetAppState): boolean { // Step 1: Get the task and shell command from current state - const state = getAppState() - const task = state.tasks[taskId] + const state = getAppState(); + const task = state.tasks[taskId]; if (!isLocalShellTask(task) || task.isBackgrounded || !task.shellCommand) { - return false + return false; } - const shellCommand = task.shellCommand - const description = task.description - const { toolUseId, kind, agentId } = task + const shellCommand = task.shellCommand; + const description = task.description; + const { toolUseId, kind, agentId } = task; // Transition to backgrounded — TaskOutput continues receiving data automatically if (!shellCommand.background(taskId)) { - return false + return false; } setAppState(prev => { - const prevTask = prev.tasks[taskId] + const prevTask = prev.tasks[taskId]; if (!isLocalShellTask(prevTask) || prevTask.isBackgrounded) { - return prev + return prev; } return { ...prev, @@ -389,32 +359,26 @@ function backgroundTask( ...prev.tasks, [taskId]: { ...prevTask, isBackgrounded: true }, }, - } - }) + }; + }); - const cancelStallWatchdog = startStallWatchdog( - taskId, - description, - kind, - toolUseId, - agentId, - ) + const cancelStallWatchdog = startStallWatchdog(taskId, description, kind, toolUseId, agentId); // Set up result handler void shellCommand.result.then(async result => { - cancelStallWatchdog() - await flushAndCleanup(shellCommand) - let wasKilled = false - let cleanupFn: (() => void) | undefined + cancelStallWatchdog(); + await flushAndCleanup(shellCommand); + let wasKilled = false; + let cleanupFn: (() => void) | undefined; updateTaskState(taskId, setAppState, t => { if (t.status === 'killed') { - wasKilled = true - return t + wasKilled = true; + return t; } // Capture cleanup function to call outside of updater - cleanupFn = t.unregisterCleanup + cleanupFn = t.unregisterCleanup; return { ...t, @@ -423,41 +387,23 @@ function backgroundTask( shellCommand: null, unregisterCleanup: undefined, endTime: Date.now(), - } - }) + }; + }); // Call cleanup outside of the state updater (avoid side effects in updater) - cleanupFn?.() + cleanupFn?.(); if (wasKilled) { - enqueueShellNotification( - taskId, - description, - 'killed', - result.code, - setAppState, - toolUseId, - kind, - agentId, - ) + enqueueShellNotification(taskId, description, 'killed', result.code, setAppState, toolUseId, kind, agentId); } else { - const finalStatus = result.code === 0 ? 'completed' : 'failed' - enqueueShellNotification( - taskId, - description, - finalStatus, - result.code, - setAppState, - toolUseId, - kind, - agentId, - ) + const finalStatus = result.code === 0 ? 'completed' : 'failed'; + enqueueShellNotification(taskId, description, finalStatus, result.code, setAppState, toolUseId, kind, agentId); } - void evictTaskOutput(taskId) - }) + void evictTaskOutput(taskId); + }); - return true + return true; } /** @@ -471,42 +417,35 @@ function backgroundTask( export function hasForegroundTasks(state: AppState): boolean { return Object.values(state.tasks).some(task => { if (isLocalShellTask(task) && !task.isBackgrounded && task.shellCommand) { - return true + return true; } // Exclude main session tasks - they display in the main view, not as foreground tasks - if ( - isLocalAgentTask(task) && - !task.isBackgrounded && - !isMainSessionTask(task) - ) { - return true + if (isLocalAgentTask(task) && !task.isBackgrounded && !isMainSessionTask(task)) { + return true; } - return false - }) + return false; + }); } -export function backgroundAll( - getAppState: () => AppState, - setAppState: SetAppState, -): void { - const state = getAppState() +export function backgroundAll(getAppState: () => AppState, setAppState: SetAppState): void { + const state = getAppState(); // Background all foreground bash tasks const foregroundBashTaskIds = Object.keys(state.tasks).filter(id => { - const task = state.tasks[id] - return isLocalShellTask(task) && !task.isBackgrounded && task.shellCommand - }) + const task = state.tasks[id]; + return isLocalShellTask(task) && !task.isBackgrounded && task.shellCommand; + }); for (const taskId of foregroundBashTaskIds) { - backgroundTask(taskId, getAppState, setAppState) + backgroundTask(taskId, getAppState, setAppState); } // Background all foreground agent tasks const foregroundAgentTaskIds = Object.keys(state.tasks).filter(id => { - const task = state.tasks[id] - return isLocalAgentTask(task) && !task.isBackgrounded - }) + const task = state.tasks[id]; + return isLocalAgentTask(task) && !task.isBackgrounded; + }); for (const taskId of foregroundAgentTaskIds) { - backgroundAgentTask(taskId, getAppState, setAppState) + backgroundAgentTask(taskId, getAppState, setAppState); } } @@ -526,46 +465,40 @@ export function backgroundExistingForegroundTask( toolUseId?: string, ): boolean { if (!shellCommand.background(taskId)) { - return false + return false; } - let agentId: AgentId | undefined + let agentId: AgentId | undefined; setAppState(prev => { - const prevTask = prev.tasks[taskId] + const prevTask = prev.tasks[taskId]; if (!isLocalShellTask(prevTask) || prevTask.isBackgrounded) { - return prev + return prev; } - agentId = prevTask.agentId + agentId = prevTask.agentId; return { ...prev, tasks: { ...prev.tasks, [taskId]: { ...prevTask, isBackgrounded: true }, }, - } - }) + }; + }); - const cancelStallWatchdog = startStallWatchdog( - taskId, - description, - undefined, - toolUseId, - agentId, - ) + const cancelStallWatchdog = startStallWatchdog(taskId, description, undefined, toolUseId, agentId); // Set up result handler (mirrors backgroundTask's handler) void shellCommand.result.then(async result => { - cancelStallWatchdog() - await flushAndCleanup(shellCommand) - let wasKilled = false - let cleanupFn: (() => void) | undefined + cancelStallWatchdog(); + await flushAndCleanup(shellCommand); + let wasKilled = false; + let cleanupFn: (() => void) | undefined; updateTaskState(taskId, setAppState, t => { if (t.status === 'killed') { - wasKilled = true - return t + wasKilled = true; + return t; } - cleanupFn = t.unregisterCleanup + cleanupFn = t.unregisterCleanup; return { ...t, status: result.code === 0 ? 'completed' : 'failed', @@ -573,31 +506,18 @@ export function backgroundExistingForegroundTask( shellCommand: null, unregisterCleanup: undefined, endTime: Date.now(), - } - }) + }; + }); - cleanupFn?.() + cleanupFn?.(); - const finalStatus = wasKilled - ? 'killed' - : result.code === 0 - ? 'completed' - : 'failed' - enqueueShellNotification( - taskId, - description, - finalStatus, - result.code, - setAppState, - toolUseId, - undefined, - agentId, - ) + const finalStatus = wasKilled ? 'killed' : result.code === 0 ? 'completed' : 'failed'; + enqueueShellNotification(taskId, description, finalStatus, result.code, setAppState, toolUseId, undefined, agentId); - void evictTaskOutput(taskId) - }) + void evictTaskOutput(taskId); + }); - return true + return true; } /** @@ -605,47 +525,39 @@ export function backgroundExistingForegroundTask( * Used when backgrounding raced with completion — the tool result already * carries the full output, so the would be redundant. */ -export function markTaskNotified( - taskId: string, - setAppState: SetAppState, -): void { - updateTaskState(taskId, setAppState, t => - t.notified ? t : { ...t, notified: true }, - ) +export function markTaskNotified(taskId: string, setAppState: SetAppState): void { + updateTaskState(taskId, setAppState, t => (t.notified ? t : { ...t, notified: true })); } /** * Unregister a foreground task when the command completes without being backgrounded. */ -export function unregisterForeground( - taskId: string, - setAppState: SetAppState, -): void { - let cleanupFn: (() => void) | undefined +export function unregisterForeground(taskId: string, setAppState: SetAppState): void { + let cleanupFn: (() => void) | undefined; setAppState(prev => { - const task = prev.tasks[taskId] + const task = prev.tasks[taskId]; // Only remove if it's a foreground task (not backgrounded) if (!isLocalShellTask(task) || task.isBackgrounded) { - return prev + return prev; } // Capture cleanup function to call outside of updater - cleanupFn = task.unregisterCleanup + cleanupFn = task.unregisterCleanup; - const { [taskId]: removed, ...rest } = prev.tasks - return { ...prev, tasks: rest } - }) + const { [taskId]: removed, ...rest } = prev.tasks; + return { ...prev, tasks: rest }; + }); // Call cleanup outside of the state updater (avoid side effects in updater) - cleanupFn?.() + cleanupFn?.(); } async function flushAndCleanup(shellCommand: ShellCommand): Promise { try { - await shellCommand.taskOutput.flush() - shellCommand.cleanup() + await shellCommand.taskOutput.flush(); + shellCommand.cleanup(); } catch (error) { - logError(error) + logError(error); } } diff --git a/src/tasks/LocalWorkflowTask/LocalWorkflowTask.ts b/src/tasks/LocalWorkflowTask/LocalWorkflowTask.ts index 3d7fb78c5..b6755ba80 100644 --- a/src/tasks/LocalWorkflowTask/LocalWorkflowTask.ts +++ b/src/tasks/LocalWorkflowTask/LocalWorkflowTask.ts @@ -62,7 +62,12 @@ export function registerLocalWorkflowTask( ): string { const id = generateTaskId('local_workflow') const task: LocalWorkflowTaskState = { - ...createTaskStateBase(id, 'local_workflow', opts.description, opts.toolUseId), + ...createTaskStateBase( + id, + 'local_workflow', + opts.description, + opts.toolUseId, + ), type: 'local_workflow', status: 'running', workflowName: opts.workflowName, diff --git a/src/tasks/MonitorMcpTask/MonitorMcpTask.ts b/src/tasks/MonitorMcpTask/MonitorMcpTask.ts index 56cea6831..49d5541ba 100644 --- a/src/tasks/MonitorMcpTask/MonitorMcpTask.ts +++ b/src/tasks/MonitorMcpTask/MonitorMcpTask.ts @@ -87,10 +87,7 @@ export function failMonitorMcpTask( })) } -export function killMonitorMcp( - taskId: string, - setAppState: SetAppState, -): void { +export function killMonitorMcp(taskId: string, setAppState: SetAppState): void { updateTaskState(taskId, setAppState, task => { if (task.status !== 'running') return task task.abortController?.abort() diff --git a/src/tasks/RemoteAgentTask/RemoteAgentTask.tsx b/src/tasks/RemoteAgentTask/RemoteAgentTask.tsx index 0d9fc1057..641c1c461 100644 --- a/src/tasks/RemoteAgentTask/RemoteAgentTask.tsx +++ b/src/tasks/RemoteAgentTask/RemoteAgentTask.tsx @@ -1,5 +1,5 @@ -import type { ToolUseBlock } from '@anthropic-ai/sdk/resources' -import { getRemoteSessionUrl } from '../../constants/product.js' +import type { ToolUseBlock } from '@anthropic-ai/sdk/resources'; +import { getRemoteSessionUrl } from '../../constants/product.js'; import { OUTPUT_FILE_TAG, REMOTE_REVIEW_PROGRESS_TAG, @@ -11,111 +11,89 @@ import { TASK_TYPE_TAG, TOOL_USE_ID_TAG, ULTRAPLAN_TAG, -} from '../../constants/xml.js' -import type { - SDKAssistantMessage, - SDKMessage, -} from '../../entrypoints/agentSdkTypes.js' -import type { MessageContent } from '../../types/message.js' -import type { - SetAppState, - Task, - TaskContext, - TaskStateBase, -} from '../../Task.js' -import { createTaskStateBase, generateTaskId } from '../../Task.js' -import { TodoWriteTool } from '@claude-code-best/builtin-tools/tools/TodoWriteTool/TodoWriteTool.js' +} from '../../constants/xml.js'; +import type { SDKAssistantMessage, SDKMessage } from '../../entrypoints/agentSdkTypes.js'; +import type { MessageContent } from '../../types/message.js'; +import type { SetAppState, Task, TaskContext, TaskStateBase } from '../../Task.js'; +import { createTaskStateBase, generateTaskId } from '../../Task.js'; +import { TodoWriteTool } from '@claude-code-best/builtin-tools/tools/TodoWriteTool/TodoWriteTool.js'; import { type BackgroundRemoteSessionPrecondition, checkBackgroundRemoteSessionEligibility, -} from '../../utils/background/remote/remoteSession.js' -export type { BackgroundRemoteSessionPrecondition } -import { logForDebugging } from '../../utils/debug.js' -import { logError } from '../../utils/log.js' -import { enqueuePendingNotification } from '../../utils/messageQueueManager.js' -import { extractTag, extractTextContent } from '../../utils/messages.js' -import { emitTaskTerminatedSdk } from '../../utils/sdkEventQueue.js' +} from '../../utils/background/remote/remoteSession.js'; +export type { BackgroundRemoteSessionPrecondition }; +import { logForDebugging } from '../../utils/debug.js'; +import { logError } from '../../utils/log.js'; +import { enqueuePendingNotification } from '../../utils/messageQueueManager.js'; +import { extractTag, extractTextContent } from '../../utils/messages.js'; +import { emitTaskTerminatedSdk } from '../../utils/sdkEventQueue.js'; import { deleteRemoteAgentMetadata, listRemoteAgentMetadata, type RemoteAgentMetadata, writeRemoteAgentMetadata, -} from '../../utils/sessionStorage.js' -import { jsonStringify } from '../../utils/slowOperations.js' -import { - appendTaskOutput, - evictTaskOutput, - getTaskOutputPath, - initTaskOutput, -} from '../../utils/task/diskOutput.js' -import { registerTask, updateTaskState } from '../../utils/task/framework.js' -import { fetchSession } from '../../utils/teleport/api.js' -import { - archiveRemoteSession, - pollRemoteSessionEvents, -} from '../../utils/teleport.js' -import type { TodoList } from '../../utils/todo/types.js' -import type { UltraplanPhase } from '../../utils/ultraplan/ccrSession.js' +} from '../../utils/sessionStorage.js'; +import { jsonStringify } from '../../utils/slowOperations.js'; +import { appendTaskOutput, evictTaskOutput, getTaskOutputPath, initTaskOutput } from '../../utils/task/diskOutput.js'; +import { registerTask, updateTaskState } from '../../utils/task/framework.js'; +import { fetchSession } from '../../utils/teleport/api.js'; +import { archiveRemoteSession, pollRemoteSessionEvents } from '../../utils/teleport.js'; +import type { TodoList } from '../../utils/todo/types.js'; +import type { UltraplanPhase } from '../../utils/ultraplan/ccrSession.js'; export type RemoteAgentTaskState = TaskStateBase & { - type: 'remote_agent' - remoteTaskType: RemoteTaskType + type: 'remote_agent'; + remoteTaskType: RemoteTaskType; /** Task-specific metadata (PR number, repo, etc.). */ - remoteTaskMetadata?: RemoteTaskMetadata - sessionId: string // Original session ID for API calls - command: string - title: string - todoList: TodoList - log: SDKMessage[] + remoteTaskMetadata?: RemoteTaskMetadata; + sessionId: string; // Original session ID for API calls + command: string; + title: string; + todoList: TodoList; + log: SDKMessage[]; /** * Long-running agent that will not be marked as complete after the first `result`. */ - isLongRunning?: boolean + isLongRunning?: boolean; /** * When the local poller started watching this task (at spawn or on restore). * Review timeout clocks from here so a restore doesn't immediately time out * a task spawned >30min ago. */ - pollStartedAt: number + pollStartedAt: number; /** True when this task was created by a teleported /ultrareview command. */ - isRemoteReview?: boolean + isRemoteReview?: boolean; /** Parsed from the orchestrator's heartbeat echoes. */ reviewProgress?: { - stage?: 'finding' | 'verifying' | 'synthesizing' - bugsFound: number - bugsVerified: number - bugsRefuted: number - } - isUltraplan?: boolean + stage?: 'finding' | 'verifying' | 'synthesizing'; + bugsFound: number; + bugsVerified: number; + bugsRefuted: number; + }; + isUltraplan?: boolean; /** * Scanner-derived pill state. Undefined = running. `needs_input` when the * remote asked a clarifying question and is idle; `plan_ready` when * ExitPlanMode is awaiting browser approval. Surfaced in the pill badge * and detail dialog status line. */ - ultraplanPhase?: Exclude -} + ultraplanPhase?: Exclude; +}; -const REMOTE_TASK_TYPES = [ - 'remote-agent', - 'ultraplan', - 'ultrareview', - 'autofix-pr', - 'background-pr', -] as const -export type RemoteTaskType = (typeof REMOTE_TASK_TYPES)[number] +const REMOTE_TASK_TYPES = ['remote-agent', 'ultraplan', 'ultrareview', 'autofix-pr', 'background-pr'] as const; +export type RemoteTaskType = (typeof REMOTE_TASK_TYPES)[number]; function isRemoteTaskType(v: string | undefined): v is RemoteTaskType { - return (REMOTE_TASK_TYPES as readonly string[]).includes(v ?? '') + return (REMOTE_TASK_TYPES as readonly string[]).includes(v ?? ''); } export type AutofixPrRemoteTaskMetadata = { - owner: string - repo: string - prNumber: number -} + owner: string; + repo: string; + prNumber: number; +}; -export type RemoteTaskMetadata = AutofixPrRemoteTaskMetadata +export type RemoteTaskMetadata = AutofixPrRemoteTaskMetadata; /** * Called on every poll tick for tasks with a matching remoteTaskType. Return a @@ -124,35 +102,27 @@ export type RemoteTaskMetadata = AutofixPrRemoteTaskMetadata */ export type RemoteTaskCompletionChecker = ( remoteTaskMetadata: RemoteTaskMetadata | undefined, -) => Promise +) => Promise; -const completionCheckers = new Map< - RemoteTaskType, - RemoteTaskCompletionChecker ->() +const completionCheckers = new Map(); /** * Register a completion checker for a remote task type. Invoked on every poll * tick; survives --resume via the sidecar's remoteTaskType + remoteTaskMetadata. */ -export function registerCompletionChecker( - remoteTaskType: RemoteTaskType, - checker: RemoteTaskCompletionChecker, -): void { - completionCheckers.set(remoteTaskType, checker) +export function registerCompletionChecker(remoteTaskType: RemoteTaskType, checker: RemoteTaskCompletionChecker): void { + completionCheckers.set(remoteTaskType, checker); } /** * Persist a remote-agent metadata entry to the session sidecar. * Fire-and-forget — persistence failures must not block task registration. */ -async function persistRemoteAgentMetadata( - meta: RemoteAgentMetadata, -): Promise { +async function persistRemoteAgentMetadata(meta: RemoteAgentMetadata): Promise { try { - await writeRemoteAgentMetadata(meta.taskId, meta) + await writeRemoteAgentMetadata(meta.taskId, meta); } catch (e) { - logForDebugging(`persistRemoteAgentMetadata failed: ${String(e)}`) + logForDebugging(`persistRemoteAgentMetadata failed: ${String(e)}`); } } @@ -163,21 +133,21 @@ async function persistRemoteAgentMetadata( */ async function removeRemoteAgentMetadata(taskId: string): Promise { try { - await deleteRemoteAgentMetadata(taskId) + await deleteRemoteAgentMetadata(taskId); } catch (e) { - logForDebugging(`removeRemoteAgentMetadata failed: ${String(e)}`) + logForDebugging(`removeRemoteAgentMetadata failed: ${String(e)}`); } } // Precondition error result export type RemoteAgentPreconditionResult = | { - eligible: true + eligible: true; } | { - eligible: false - errors: BackgroundRemoteSessionPrecondition[] - } + eligible: false; + errors: BackgroundRemoteSessionPrecondition[]; + }; /** * Check eligibility for creating a remote agent session. @@ -185,34 +155,32 @@ export type RemoteAgentPreconditionResult = export async function checkRemoteAgentEligibility({ skipBundle = false, }: { - skipBundle?: boolean + skipBundle?: boolean; } = {}): Promise { - const errors = await checkBackgroundRemoteSessionEligibility({ skipBundle }) + const errors = await checkBackgroundRemoteSessionEligibility({ skipBundle }); if (errors.length > 0) { - return { eligible: false, errors } + return { eligible: false, errors }; } - return { eligible: true } + return { eligible: true }; } /** * Format precondition error for display. */ -export function formatPreconditionError( - error: BackgroundRemoteSessionPrecondition, -): string { +export function formatPreconditionError(error: BackgroundRemoteSessionPrecondition): string { switch (error.type) { case 'not_logged_in': - return 'Please run /login and sign in with your Claude.ai account (not Console).' + return 'Please run /login and sign in with your Claude.ai account (not Console).'; case 'no_remote_environment': - return 'No cloud environment available. Set one up at https://claude.ai/code/onboarding?magic=env-setup' + return 'No cloud environment available. Set one up at https://claude.ai/code/onboarding?magic=env-setup'; case 'not_in_git_repo': - return 'Background tasks require a git repository. Initialize git or run from a git repository.' + return 'Background tasks require a git repository. Initialize git or run from a git repository.'; case 'no_git_remote': - return 'Background tasks require a GitHub remote. Add one with `git remote add origin REPO_URL`.' + return 'Background tasks require a GitHub remote. Add one with `git remote add origin REPO_URL`.'; case 'github_app_not_installed': - return 'The Claude GitHub app must be installed on this repository first.\nhttps://github.com/apps/claude/installations/new' + return 'The Claude GitHub app must be installed on this repository first.\nhttps://github.com/apps/claude/installations/new'; case 'policy_blocked': - return "Remote sessions are disabled by your organization's policy. Contact your organization admin to enable them." + return "Remote sessions are disabled by your organization's policy. Contact your organization admin to enable them."; } } @@ -227,29 +195,22 @@ function enqueueRemoteNotification( toolUseId?: string, ): void { // Atomically check and set notified flag to prevent duplicate notifications. - if (!markTaskNotified(taskId, setAppState)) return + if (!markTaskNotified(taskId, setAppState)) return; - const statusText = - status === 'completed' - ? 'completed successfully' - : status === 'failed' - ? 'failed' - : 'was stopped' + const statusText = status === 'completed' ? 'completed successfully' : status === 'failed' ? 'failed' : 'was stopped'; - const toolUseIdLine = toolUseId - ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}` - : '' + const toolUseIdLine = toolUseId ? `\n<${TOOL_USE_ID_TAG}>${toolUseId}` : ''; - const outputPath = getTaskOutputPath(taskId) + const outputPath = getTaskOutputPath(taskId); const message = `<${TASK_NOTIFICATION_TAG}> <${TASK_ID_TAG}>${taskId}${toolUseIdLine} <${TASK_TYPE_TAG}>remote_agent <${OUTPUT_FILE_TAG}>${outputPath} <${STATUS_TAG}>${status} <${SUMMARY_TAG}>Remote task "${title}" ${statusText} -` +`; - enqueuePendingNotification({ value: message, mode: 'task-notification' }) + enqueuePendingNotification({ value: message, mode: 'task-notification' }); } /** @@ -257,15 +218,15 @@ function enqueueRemoteNotification( * flag (caller should enqueue), false if already notified (caller should skip). */ function markTaskNotified(taskId: string, setAppState: SetAppState): boolean { - let shouldEnqueue = false + let shouldEnqueue = false; updateTaskState(taskId, setAppState, task => { if (task.notified) { - return task + return task; } - shouldEnqueue = true - return { ...task, notified: true } - }) - return shouldEnqueue + shouldEnqueue = true; + return { ...task, notified: true }; + }); + return shouldEnqueue; } /** @@ -275,18 +236,18 @@ function markTaskNotified(taskId: string, setAppState: SetAppState): boolean { export function extractPlanFromLog(log: SDKMessage[]): string | null { // Walk backwards through assistant messages to find content for (let i = log.length - 1; i >= 0; i--) { - const msg = log[i] as SDKAssistantMessage - if (msg?.type !== 'assistant') continue - const content = msg.message?.content as MessageContent | undefined - if (!content) continue + const msg = log[i] as SDKAssistantMessage; + if (msg?.type !== 'assistant') continue; + const content = msg.message?.content as MessageContent | undefined; + if (!content) continue; const fullText = extractTextContent( typeof content === 'string' ? [{ type: 'text' as const, text: content }] : content, '\n', - ) - const plan = extractTag(fullText, ULTRAPLAN_TAG) - if (plan?.trim()) return plan.trim() + ); + const plan = extractTag(fullText, ULTRAPLAN_TAG); + if (plan?.trim()) return plan.trim(); } - return null + return null; } /** @@ -300,18 +261,18 @@ export function enqueueUltraplanFailureNotification( reason: string, setAppState: SetAppState, ): void { - if (!markTaskNotified(taskId, setAppState)) return + if (!markTaskNotified(taskId, setAppState)) return; - const sessionUrl = getRemoteTaskSessionUrl(sessionId) + const sessionUrl = getRemoteTaskSessionUrl(sessionId); const message = `<${TASK_NOTIFICATION_TAG}> <${TASK_ID_TAG}>${taskId} <${TASK_TYPE_TAG}>remote_agent <${STATUS_TAG}>failed <${SUMMARY_TAG}>Ultraplan failed: ${reason} -The remote Ultraplan session did not produce a plan (${reason}). Inspect the session at ${sessionUrl} and tell the user to retry locally with plan mode.` +The remote Ultraplan session did not produce a plan (${reason}). Inspect the session at ${sessionUrl} and tell the user to retry locally with plan mode.`; - enqueuePendingNotification({ value: message, mode: 'task-notification' }) + enqueuePendingNotification({ value: message, mode: 'task-notification' }); } /** @@ -329,61 +290,54 @@ The remote Ultraplan session did not produce a plan (${reason}). Inspect the ses */ function extractReviewFromLog(log: SDKMessage[]): string | null { for (let i = log.length - 1; i >= 0; i--) { - const msg = log[i] + const msg = log[i]; // The final echo before hook exit may land in either the last // hook_progress or the terminal hook_response depending on buffering; // both have flat stdout. - if ( - msg?.type === 'system' && - (msg.subtype === 'hook_progress' || msg.subtype === 'hook_response') - ) { - const tagged = extractTag(msg.stdout as string, REMOTE_REVIEW_TAG) - if (tagged?.trim()) return tagged.trim() + if (msg?.type === 'system' && (msg.subtype === 'hook_progress' || msg.subtype === 'hook_response')) { + const tagged = extractTag(msg.stdout as string, REMOTE_REVIEW_TAG); + if (tagged?.trim()) return tagged.trim(); } } for (let i = log.length - 1; i >= 0; i--) { - const msg = log[i] - if (msg?.type !== 'assistant') continue - const content = (msg as SDKAssistantMessage).message?.content as MessageContent | undefined - if (!content) continue + const msg = log[i]; + if (msg?.type !== 'assistant') continue; + const content = (msg as SDKAssistantMessage).message?.content as MessageContent | undefined; + if (!content) continue; const fullText = extractTextContent( typeof content === 'string' ? [{ type: 'text' as const, text: content }] : content, '\n', - ) - const tagged = extractTag(fullText, REMOTE_REVIEW_TAG) - if (tagged?.trim()) return tagged.trim() + ); + const tagged = extractTag(fullText, REMOTE_REVIEW_TAG); + if (tagged?.trim()) return tagged.trim(); } // Hook-stdout concat fallback: a single echo should land in one event, but // large JSON payloads can flush across two if the pipe buffer fills // mid-write. Per-message scan above misses a tag split across events. const hookStdout = log - .filter( - msg => - msg.type === 'system' && - (msg.subtype === 'hook_progress' || msg.subtype === 'hook_response'), - ) + .filter(msg => msg.type === 'system' && (msg.subtype === 'hook_progress' || msg.subtype === 'hook_response')) .map(msg => msg.stdout as string) - .join('') - const hookTagged = extractTag(hookStdout, REMOTE_REVIEW_TAG) - if (hookTagged?.trim()) return hookTagged.trim() + .join(''); + const hookTagged = extractTag(hookStdout, REMOTE_REVIEW_TAG); + if (hookTagged?.trim()) return hookTagged.trim(); // Fallback: concatenate all assistant text in chronological order. const allText = log .filter((msg): msg is SDKAssistantMessage => msg.type === 'assistant') .map(msg => { - const content = msg.message?.content as MessageContent | undefined - if (!content) return '' + const content = msg.message?.content as MessageContent | undefined; + if (!content) return ''; return extractTextContent( typeof content === 'string' ? [{ type: 'text' as const, text: content }] : content, '\n', - ) + ); }) .join('\n') - .trim() + .trim(); - return allText || null + return allText || null; } /** @@ -399,43 +353,36 @@ function extractReviewFromLog(log: SDKMessage[]): string | null { function extractReviewTagFromLog(log: SDKMessage[]): string | null { // hook_progress / hook_response per-message scan (bughunter path) for (let i = log.length - 1; i >= 0; i--) { - const msg = log[i] - if ( - msg?.type === 'system' && - (msg.subtype === 'hook_progress' || msg.subtype === 'hook_response') - ) { - const tagged = extractTag(msg.stdout as string, REMOTE_REVIEW_TAG) - if (tagged?.trim()) return tagged.trim() + const msg = log[i]; + if (msg?.type === 'system' && (msg.subtype === 'hook_progress' || msg.subtype === 'hook_response')) { + const tagged = extractTag(msg.stdout as string, REMOTE_REVIEW_TAG); + if (tagged?.trim()) return tagged.trim(); } } // assistant text per-message scan (prompt mode) for (let i = log.length - 1; i >= 0; i--) { - const msg = log[i] - if (msg?.type !== 'assistant') continue - const content = (msg as SDKAssistantMessage).message?.content as MessageContent | undefined - if (!content) continue + const msg = log[i]; + if (msg?.type !== 'assistant') continue; + const content = (msg as SDKAssistantMessage).message?.content as MessageContent | undefined; + if (!content) continue; const fullText = extractTextContent( typeof content === 'string' ? [{ type: 'text' as const, text: content }] : content, '\n', - ) - const tagged = extractTag(fullText, REMOTE_REVIEW_TAG) - if (tagged?.trim()) return tagged.trim() + ); + const tagged = extractTag(fullText, REMOTE_REVIEW_TAG); + if (tagged?.trim()) return tagged.trim(); } // Hook-stdout concat fallback for split tags const hookStdout = log - .filter( - msg => - msg.type === 'system' && - (msg.subtype === 'hook_progress' || msg.subtype === 'hook_response'), - ) + .filter(msg => msg.type === 'system' && (msg.subtype === 'hook_progress' || msg.subtype === 'hook_response')) .map(msg => msg.stdout as string) - .join('') - const hookTagged = extractTag(hookStdout, REMOTE_REVIEW_TAG) - if (hookTagged?.trim()) return hookTagged.trim() + .join(''); + const hookTagged = extractTag(hookStdout, REMOTE_REVIEW_TAG); + if (hookTagged?.trim()) return hookTagged.trim(); - return null + return null; } /** @@ -444,12 +391,8 @@ function extractReviewTagFromLog(log: SDKMessage[]): string | null { * turn — no file indirection, no mode change. Session is kept alive so the * claude.ai URL stays a durable record the user can revisit; TTL handles cleanup. */ -function enqueueRemoteReviewNotification( - taskId: string, - reviewContent: string, - setAppState: SetAppState, -): void { - if (!markTaskNotified(taskId, setAppState)) return +function enqueueRemoteReviewNotification(taskId: string, reviewContent: string, setAppState: SetAppState): void { + if (!markTaskNotified(taskId, setAppState)) return; const message = `<${TASK_NOTIFICATION_TAG}> <${TASK_ID_TAG}>${taskId} @@ -459,20 +402,16 @@ function enqueueRemoteReviewNotification( The remote review produced the following findings: -${reviewContent}` +${reviewContent}`; - enqueuePendingNotification({ value: message, mode: 'task-notification' }) + enqueuePendingNotification({ value: message, mode: 'task-notification' }); } /** * Enqueue a remote-review failure notification. */ -function enqueueRemoteReviewFailureNotification( - taskId: string, - reason: string, - setAppState: SetAppState, -): void { - if (!markTaskNotified(taskId, setAppState)) return +function enqueueRemoteReviewFailureNotification(taskId: string, reason: string, setAppState: SetAppState): void { + if (!markTaskNotified(taskId, setAppState)) return; const message = `<${TASK_NOTIFICATION_TAG}> <${TASK_ID_TAG}>${taskId} @@ -480,9 +419,9 @@ function enqueueRemoteReviewFailureNotification( <${STATUS_TAG}>failed <${SUMMARY_TAG}>Remote review failed: ${reason} -Remote review did not produce output (${reason}). Tell the user to retry /ultrareview, or use /review for a local review instead.` +Remote review did not produce output (${reason}). Tell the user to retry /ultrareview, or use /review for a local review instead.`; - enqueuePendingNotification({ value: message, mode: 'task-notification' }) + enqueuePendingNotification({ value: message, mode: 'task-notification' }); } /** @@ -492,30 +431,33 @@ function extractTodoListFromLog(log: SDKMessage[]): TodoList { const todoListMessage = log.findLast( (msg): msg is SDKAssistantMessage => msg.type === 'assistant' && - (Array.isArray((msg as SDKAssistantMessage).message?.content)) && + Array.isArray((msg as SDKAssistantMessage).message?.content) && (((msg as SDKAssistantMessage).message?.content ?? []) as Array<{ type: string; name?: string }>).some( block => block.type === 'tool_use' && block.name === TodoWriteTool.name, ), - ) + ); if (!todoListMessage) { - return [] + return []; } - const contentBlocks = (todoListMessage.message?.content ?? []) as Array<{ type: string; name?: string; input?: unknown }> + const contentBlocks = (todoListMessage.message?.content ?? []) as Array<{ + type: string; + name?: string; + input?: unknown; + }>; const input = contentBlocks.find( - (block): block is ToolUseBlock => - block.type === 'tool_use' && block.name === TodoWriteTool.name, - )?.input + (block): block is ToolUseBlock => block.type === 'tool_use' && block.name === TodoWriteTool.name, + )?.input; if (!input) { - return [] + return []; } - const parsedInput = TodoWriteTool.inputSchema.safeParse(input) + const parsedInput = TodoWriteTool.inputSchema.safeParse(input); if (!parsedInput.success) { - return [] + return []; } - return parsedInput.data.todos + return parsedInput.data.todos; } /** @@ -524,19 +466,19 @@ function extractTodoListFromLog(log: SDKMessage[]): TodoList { * Callers remain responsible for custom pre-registration logic (git dialogs, transcript upload, teleport options). */ export function registerRemoteAgentTask(options: { - remoteTaskType: RemoteTaskType - session: { id: string; title: string } - command: string - context: TaskContext - toolUseId?: string - isRemoteReview?: boolean - isUltraplan?: boolean - isLongRunning?: boolean - remoteTaskMetadata?: RemoteTaskMetadata + remoteTaskType: RemoteTaskType; + session: { id: string; title: string }; + command: string; + context: TaskContext; + toolUseId?: string; + isRemoteReview?: boolean; + isUltraplan?: boolean; + isLongRunning?: boolean; + remoteTaskMetadata?: RemoteTaskMetadata; }): { - taskId: string - sessionId: string - cleanup: () => void + taskId: string; + sessionId: string; + cleanup: () => void; } { const { remoteTaskType, @@ -548,13 +490,13 @@ export function registerRemoteAgentTask(options: { isUltraplan, isLongRunning, remoteTaskMetadata, - } = options - const taskId = generateTaskId('remote_agent') + } = options; + const taskId = generateTaskId('remote_agent'); // Create the output file before registering the task. // RemoteAgentTask uses appendTaskOutput() (not TaskOutput), so // the file must exist for readers before any output arrives. - void initTaskOutput(taskId) + void initTaskOutput(taskId); const taskState: RemoteAgentTaskState = { ...createTaskStateBase(taskId, 'remote_agent', session.title, toolUseId), @@ -571,9 +513,9 @@ export function registerRemoteAgentTask(options: { isLongRunning, pollStartedAt: Date.now(), remoteTaskMetadata, - } + }; - registerTask(taskState, context.setAppState) + registerTask(taskState, context.setAppState); // Persist identity to the session sidecar so --resume can reconnect to // still-running remote sessions. Status is not stored — it's fetched @@ -590,19 +532,19 @@ export function registerRemoteAgentTask(options: { isRemoteReview, isLongRunning, remoteTaskMetadata, - }) + }); // Ultraplan lifecycle is owned by startDetachedPoll in ultraplan.tsx. Generic // polling still runs so session.log populates for the detail view's progress // counts; the result-lookup guard below prevents early completion. // TODO(#23985): fold ExitPlanModeScanner into this poller, drop startDetachedPoll. - const stopPolling = startRemoteSessionPolling(taskId, context) + const stopPolling = startRemoteSessionPolling(taskId, context); return { taskId, sessionId: session.id, cleanup: stopPolling, - } + }; } /** @@ -614,27 +556,23 @@ export function registerRemoteAgentTask(options: { * removed. Must run after switchSession() so getSessionId() points at the * resumed session's sidecar directory. */ -export async function restoreRemoteAgentTasks( - context: TaskContext, -): Promise { +export async function restoreRemoteAgentTasks(context: TaskContext): Promise { try { - await restoreRemoteAgentTasksImpl(context) + await restoreRemoteAgentTasksImpl(context); } catch (e) { - logForDebugging(`restoreRemoteAgentTasks failed: ${String(e)}`) + logForDebugging(`restoreRemoteAgentTasks failed: ${String(e)}`); } } -async function restoreRemoteAgentTasksImpl( - context: TaskContext, -): Promise { - const persisted = await listRemoteAgentMetadata() - if (persisted.length === 0) return +async function restoreRemoteAgentTasksImpl(context: TaskContext): Promise { + const persisted = await listRemoteAgentMetadata(); + if (persisted.length === 0) return; for (const meta of persisted) { - let remoteStatus: string + let remoteStatus: string; try { - const session = await fetchSession(meta.sessionId) - remoteStatus = session.session_status + const session = await fetchSession(meta.sessionId); + remoteStatus = session.session_status; } catch (e) { // Only 404 means the CCR session is truly gone. Auth errors (401, // missing OAuth token) are recoverable via /login — the remote @@ -642,35 +580,24 @@ async function restoreRemoteAgentTasksImpl( // 4xx (validateStatus treats <500 as success), so isTransientNetworkError // can't distinguish them; match the 404 message instead. if (e instanceof Error && e.message.startsWith('Session not found:')) { - logForDebugging( - `restoreRemoteAgentTasks: dropping ${meta.taskId} (404: ${String(e)})`, - ) - void removeRemoteAgentMetadata(meta.taskId) + logForDebugging(`restoreRemoteAgentTasks: dropping ${meta.taskId} (404: ${String(e)})`); + void removeRemoteAgentMetadata(meta.taskId); } else { - logForDebugging( - `restoreRemoteAgentTasks: skipping ${meta.taskId} (recoverable: ${String(e)})`, - ) + logForDebugging(`restoreRemoteAgentTasks: skipping ${meta.taskId} (recoverable: ${String(e)})`); } - continue + continue; } if (remoteStatus === 'archived') { // Session ended while the local client was offline. Don't resurrect. - void removeRemoteAgentMetadata(meta.taskId) - continue + void removeRemoteAgentMetadata(meta.taskId); + continue; } const taskState: RemoteAgentTaskState = { - ...createTaskStateBase( - meta.taskId, - 'remote_agent', - meta.title, - meta.toolUseId, - ), + ...createTaskStateBase(meta.taskId, 'remote_agent', meta.title, meta.toolUseId), type: 'remote_agent', - remoteTaskType: isRemoteTaskType(meta.remoteTaskType) - ? meta.remoteTaskType - : 'remote-agent', + remoteTaskType: isRemoteTaskType(meta.remoteTaskType) ? meta.remoteTaskType : 'remote-agent', status: 'running', sessionId: meta.sessionId, command: meta.command, @@ -682,14 +609,12 @@ async function restoreRemoteAgentTasksImpl( isLongRunning: meta.isLongRunning, startTime: meta.spawnedAt, pollStartedAt: Date.now(), - remoteTaskMetadata: meta.remoteTaskMetadata as - | RemoteTaskMetadata - | undefined, - } + remoteTaskMetadata: meta.remoteTaskMetadata as RemoteTaskMetadata | undefined, + }; - registerTask(taskState, context.setAppState) - void initTaskOutput(meta.taskId) - startRemoteSessionPolling(meta.taskId, context) + registerTask(taskState, context.setAppState); + void initTaskOutput(meta.taskId); + startRemoteSessionPolling(meta.taskId, context); } } @@ -697,104 +622,79 @@ async function restoreRemoteAgentTasksImpl( * Start polling for remote session updates. * Returns a cleanup function to stop polling. */ -function startRemoteSessionPolling( - taskId: string, - context: TaskContext, -): () => void { - let isRunning = true - const POLL_INTERVAL_MS = 1000 - const REMOTE_REVIEW_TIMEOUT_MS = 30 * 60 * 1000 +function startRemoteSessionPolling(taskId: string, context: TaskContext): () => void { + let isRunning = true; + const POLL_INTERVAL_MS = 1000; + const REMOTE_REVIEW_TIMEOUT_MS = 30 * 60 * 1000; // Remote sessions flip to 'idle' between tool turns. With 100+ rapid // turns, a 1s poll WILL catch a transient idle mid-run. Require stable // idle (no log growth for N consecutive polls) before believing it. - const STABLE_IDLE_POLLS = 5 - let consecutiveIdlePolls = 0 - let lastEventId: string | null = null - let accumulatedLog: SDKMessage[] = [] + const STABLE_IDLE_POLLS = 5; + let consecutiveIdlePolls = 0; + let lastEventId: string | null = null; + let accumulatedLog: SDKMessage[] = []; // Cached across ticks so we don't re-scan the full log. Tag appears once // at end of run; scanning only the delta (response.newEvents) is O(new). - let cachedReviewContent: string | null = null + let cachedReviewContent: string | null = null; const poll = async (): Promise => { - if (!isRunning) return + if (!isRunning) return; try { - const appState = context.getAppState() - const task = appState.tasks?.[taskId] as RemoteAgentTaskState | undefined + const appState = context.getAppState(); + const task = appState.tasks?.[taskId] as RemoteAgentTaskState | undefined; if (!task || task.status !== 'running') { // Task was killed externally (TaskStopTool) or already terminal. // Session left alive so the claude.ai URL stays valid — the run_hunt.sh // post_stage() calls land as assistant events there, and the user may // want to revisit them after closing the terminal. TTL reaps it. - return + return; } - const response = await pollRemoteSessionEvents( - task.sessionId, - lastEventId, - ) - lastEventId = response.lastEventId - const logGrew = response.newEvents.length > 0 + const response = await pollRemoteSessionEvents(task.sessionId, lastEventId); + lastEventId = response.lastEventId; + const logGrew = response.newEvents.length > 0; if (logGrew) { - accumulatedLog = [...accumulatedLog, ...response.newEvents] + accumulatedLog = [...accumulatedLog, ...response.newEvents]; const deltaText = response.newEvents .map(msg => { if (msg.type === 'assistant') { - const content = (msg as SDKAssistantMessage).message?.content - if (!content || typeof content === 'string') return '' + const content = (msg as SDKAssistantMessage).message?.content; + if (!content || typeof content === 'string') return ''; return (content as Array<{ type: string; text?: string }>) .filter(block => block.type === 'text') .map(block => ('text' in block ? block.text : '')) - .join('\n') + .join('\n'); } - return jsonStringify(msg) + return jsonStringify(msg); }) - .join('\n') + .join('\n'); if (deltaText) { - appendTaskOutput(taskId, deltaText + '\n') + appendTaskOutput(taskId, deltaText + '\n'); } } if (response.sessionStatus === 'archived') { updateTaskState(taskId, context.setAppState, t => - t.status === 'running' - ? { ...t, status: 'completed', endTime: Date.now() } - : t, - ) - enqueueRemoteNotification( - taskId, - task.title, - 'completed', - context.setAppState, - task.toolUseId, - ) - void evictTaskOutput(taskId) - void removeRemoteAgentMetadata(taskId) - return + t.status === 'running' ? { ...t, status: 'completed', endTime: Date.now() } : t, + ); + enqueueRemoteNotification(taskId, task.title, 'completed', context.setAppState, task.toolUseId); + void evictTaskOutput(taskId); + void removeRemoteAgentMetadata(taskId); + return; } - const checker = completionCheckers.get(task.remoteTaskType) + const checker = completionCheckers.get(task.remoteTaskType); if (checker) { - const completionResult = await checker(task.remoteTaskMetadata) + const completionResult = await checker(task.remoteTaskMetadata); if (completionResult !== null) { - updateTaskState( - taskId, - context.setAppState, - t => - t.status === 'running' - ? { ...t, status: 'completed', endTime: Date.now() } - : t, - ) - enqueueRemoteNotification( - taskId, - completionResult, - 'completed', - context.setAppState, - task.toolUseId, - ) - void evictTaskOutput(taskId) - void removeRemoteAgentMetadata(taskId) - return + updateTaskState(taskId, context.setAppState, t => + t.status === 'running' ? { ...t, status: 'completed', endTime: Date.now() } : t, + ); + enqueueRemoteNotification(taskId, completionResult, 'completed', context.setAppState, task.toolUseId); + void evictTaskOutput(taskId); + void removeRemoteAgentMetadata(taskId); + return; } } @@ -803,9 +703,7 @@ function startRemoteSessionPolling( // Long-running monitors (autofix-pr) emit result per notification cycle, // so the same skip applies. const result = - task.isUltraplan || task.isLongRunning - ? undefined - : accumulatedLog.findLast(msg => msg.type === 'result') + task.isUltraplan || task.isLongRunning ? undefined : accumulatedLog.findLast(msg => msg.type === 'result'); // For remote-review: in hook_progress stdout is the // bughunter path's completion signal. Scan only the delta to stay O(new); @@ -815,41 +713,36 @@ function startRemoteSessionPolling( // nothing. Require STABLE_IDLE_POLLS consecutive idle polls with no log // growth. if (task.isRemoteReview && logGrew && cachedReviewContent === null) { - cachedReviewContent = extractReviewTagFromLog(response.newEvents) + cachedReviewContent = extractReviewTagFromLog(response.newEvents); } // Parse live progress counts from the orchestrator's heartbeat echoes. // hook_progress stdout is cumulative (every echo since hook start), so // each event contains all progress tags. Grab the LAST occurrence — // extractTag returns the first match which would always be the earliest // value (0/0). - let newProgress: RemoteAgentTaskState['reviewProgress'] + let newProgress: RemoteAgentTaskState['reviewProgress']; if (task.isRemoteReview && logGrew) { - const open = `<${REMOTE_REVIEW_PROGRESS_TAG}>` - const close = `` + const open = `<${REMOTE_REVIEW_PROGRESS_TAG}>`; + const close = ``; for (const ev of response.newEvents) { - if ( - ev.type === 'system' && - (ev.subtype === 'hook_progress' || ev.subtype === 'hook_response') - ) { - const s = ev.stdout as string - const closeAt = s.lastIndexOf(close) - const openAt = closeAt === -1 ? -1 : s.lastIndexOf(open, closeAt) + if (ev.type === 'system' && (ev.subtype === 'hook_progress' || ev.subtype === 'hook_response')) { + const s = ev.stdout as string; + const closeAt = s.lastIndexOf(close); + const openAt = closeAt === -1 ? -1 : s.lastIndexOf(open, closeAt); if (openAt !== -1 && closeAt > openAt) { try { - const p = JSON.parse( - s.slice(openAt + open.length, closeAt), - ) as { - stage?: 'finding' | 'verifying' | 'synthesizing' - bugs_found?: number - bugs_verified?: number - bugs_refuted?: number - } + const p = JSON.parse(s.slice(openAt + open.length, closeAt)) as { + stage?: 'finding' | 'verifying' | 'synthesizing'; + bugs_found?: number; + bugs_verified?: number; + bugs_refuted?: number; + }; newProgress = { stage: p.stage, bugsFound: p.bugs_found ?? 0, bugsVerified: p.bugs_verified ?? 0, bugsRefuted: p.bugs_refuted ?? 0, - } + }; } catch { // ignore malformed progress } @@ -865,15 +758,14 @@ function startRemoteSessionPolling( msg.type === 'assistant' || (task.isRemoteReview && msg.type === 'system' && - (msg.subtype === 'hook_progress' || - msg.subtype === 'hook_response')), - ) + (msg.subtype === 'hook_progress' || msg.subtype === 'hook_response')), + ); if (response.sessionStatus === 'idle' && !logGrew && hasAnyOutput) { - consecutiveIdlePolls++ + consecutiveIdlePolls++; } else { - consecutiveIdlePolls = 0 + consecutiveIdlePolls = 0; } - const stableIdle = consecutiveIdlePolls >= STABLE_IDLE_POLLS + const stableIdle = consecutiveIdlePolls >= STABLE_IDLE_POLLS; // stableIdle is a prompt-mode completion signal (Claude stops writing // → session idles → done). In bughunter mode the session is "idle" the // entire time the SessionStart hook runs; the previous guard checked @@ -891,21 +783,14 @@ function startRemoteSessionPolling( const hasSessionStartHook = accumulatedLog.some( m => m.type === 'system' && - (m.subtype === 'hook_started' || - m.subtype === 'hook_progress' || - m.subtype === 'hook_response') && + (m.subtype === 'hook_started' || m.subtype === 'hook_progress' || m.subtype === 'hook_response') && (m as { hook_event?: string }).hook_event === 'SessionStart', - ) - const hasAssistantEvents = accumulatedLog.some( - m => m.type === 'assistant', - ) + ); + const hasAssistantEvents = accumulatedLog.some(m => m.type === 'assistant'); const sessionDone = task.isRemoteReview && - (cachedReviewContent !== null || - (!hasSessionStartHook && stableIdle && hasAssistantEvents)) - const reviewTimedOut = - task.isRemoteReview && - Date.now() - task.pollStartedAt > REMOTE_REVIEW_TIMEOUT_MS + (cachedReviewContent !== null || (!hasSessionStartHook && stableIdle && hasAssistantEvents)); + const reviewTimedOut = task.isRemoteReview && Date.now() - task.pollStartedAt > REMOTE_REVIEW_TIMEOUT_MS; const newStatus = result ? result.subtype === 'success' ? ('completed' as const) @@ -914,53 +799,44 @@ function startRemoteSessionPolling( ? ('completed' as const) : accumulatedLog.length > 0 ? ('running' as const) - : ('starting' as const) + : ('starting' as const); // Update task state. Guard against terminal states — if stopTask raced // while pollRemoteSessionEvents was in-flight (status set to 'killed', // notified set to true), bail without overwriting status or proceeding to // side effects (notification, permission-mode flip). - let raceTerminated = false - updateTaskState( - taskId, - context.setAppState, - prevTask => { - if (prevTask.status !== 'running') { - raceTerminated = true - return prevTask - } - // No log growth and status unchanged → nothing to report. Return - // same ref so updateTaskState skips the spread and 18 s.tasks - // subscribers (REPL, Spinner, PromptInput, ...) don't re-render. - // newProgress only arrives via log growth (heartbeat echo is a - // hook_progress event), so !logGrew already covers no-update. - const statusUnchanged = - newStatus === 'running' || newStatus === 'starting' - if (!logGrew && statusUnchanged) { - return prevTask - } - return { - ...prevTask, - status: newStatus === 'starting' ? 'running' : newStatus, - log: accumulatedLog, - // Only re-scan for TodoWrite when log grew — log is append-only, - // so no growth means no new tool_use blocks. Avoids findLast + - // some + find + safeParse every second when idle. - todoList: logGrew - ? extractTodoListFromLog(accumulatedLog) - : prevTask.todoList, - reviewProgress: newProgress ?? prevTask.reviewProgress, - endTime: - result || sessionDone || reviewTimedOut ? Date.now() : undefined, - } - }, - ) - if (raceTerminated) return + let raceTerminated = false; + updateTaskState(taskId, context.setAppState, prevTask => { + if (prevTask.status !== 'running') { + raceTerminated = true; + return prevTask; + } + // No log growth and status unchanged → nothing to report. Return + // same ref so updateTaskState skips the spread and 18 s.tasks + // subscribers (REPL, Spinner, PromptInput, ...) don't re-render. + // newProgress only arrives via log growth (heartbeat echo is a + // hook_progress event), so !logGrew already covers no-update. + const statusUnchanged = newStatus === 'running' || newStatus === 'starting'; + if (!logGrew && statusUnchanged) { + return prevTask; + } + return { + ...prevTask, + status: newStatus === 'starting' ? 'running' : newStatus, + log: accumulatedLog, + // Only re-scan for TodoWrite when log grew — log is append-only, + // so no growth means no new tool_use blocks. Avoids findLast + + // some + find + safeParse every second when idle. + todoList: logGrew ? extractTodoListFromLog(accumulatedLog) : prevTask.todoList, + reviewProgress: newProgress ?? prevTask.reviewProgress, + endTime: result || sessionDone || reviewTimedOut ? Date.now() : undefined, + }; + }); + if (raceTerminated) return; // Send notification if task completed or timed out if (result || sessionDone || reviewTimedOut) { - const finalStatus = - result && result.subtype !== 'success' ? 'failed' : 'completed' + const finalStatus = result && result.subtype !== 'success' ? 'failed' : 'completed'; // For remote-review tasks: inject the review text directly into the // message queue. No mode change, no file indirection — the local model @@ -972,63 +848,46 @@ function startRemoteSessionPolling( // cachedReviewContent hit the tag in the delta scan. Full-log scan // catches the stableIdle path where the tag arrived in an earlier // tick but the delta scan wasn't wired yet (first poll after resume). - const reviewContent = - cachedReviewContent ?? extractReviewFromLog(accumulatedLog) + const reviewContent = cachedReviewContent ?? extractReviewFromLog(accumulatedLog); if (reviewContent && finalStatus === 'completed') { - enqueueRemoteReviewNotification( - taskId, - reviewContent, - context.setAppState, - ) - void evictTaskOutput(taskId) - void removeRemoteAgentMetadata(taskId) - return // Stop polling + enqueueRemoteReviewNotification(taskId, reviewContent, context.setAppState); + void evictTaskOutput(taskId); + void removeRemoteAgentMetadata(taskId); + return; // Stop polling } // No output or remote error — mark failed with a review-specific message. updateTaskState(taskId, context.setAppState, t => ({ ...t, status: 'failed', - })) + })); const reason = result && result.subtype !== 'success' ? 'remote session returned an error' : reviewTimedOut && !sessionDone ? 'remote session exceeded 30 minutes' - : 'no review output — orchestrator may have exited early' - enqueueRemoteReviewFailureNotification( - taskId, - reason, - context.setAppState, - ) - void evictTaskOutput(taskId) - void removeRemoteAgentMetadata(taskId) - return // Stop polling + : 'no review output — orchestrator may have exited early'; + enqueueRemoteReviewFailureNotification(taskId, reason, context.setAppState); + void evictTaskOutput(taskId); + void removeRemoteAgentMetadata(taskId); + return; // Stop polling } - enqueueRemoteNotification( - taskId, - task.title, - finalStatus, - context.setAppState, - task.toolUseId, - ) - void evictTaskOutput(taskId) - void removeRemoteAgentMetadata(taskId) - return // Stop polling + enqueueRemoteNotification(taskId, task.title, finalStatus, context.setAppState, task.toolUseId); + void evictTaskOutput(taskId); + void removeRemoteAgentMetadata(taskId); + return; // Stop polling } } catch (error) { - logError(error) + logError(error); // Reset so an API error doesn't let non-consecutive idle polls accumulate. - consecutiveIdlePolls = 0 + consecutiveIdlePolls = 0; // Check review timeout even when the API call fails — without this, // persistent API errors skip the timeout check and poll forever. try { - const appState = context.getAppState() - const task = appState.tasks?.[taskId] as - | RemoteAgentTaskState - | undefined + const appState = context.getAppState(); + const task = appState.tasks?.[taskId] as RemoteAgentTaskState | undefined; if ( task?.isRemoteReview && task.status === 'running' && @@ -1038,15 +897,11 @@ function startRemoteSessionPolling( ...t, status: 'failed', endTime: Date.now(), - })) - enqueueRemoteReviewFailureNotification( - taskId, - 'remote session exceeded 30 minutes', - context.setAppState, - ) - void evictTaskOutput(taskId) - void removeRemoteAgentMetadata(taskId) - return // Stop polling + })); + enqueueRemoteReviewFailureNotification(taskId, 'remote session exceeded 30 minutes', context.setAppState); + void evictTaskOutput(taskId); + void removeRemoteAgentMetadata(taskId); + return; // Stop polling } } catch { // Best effort — if getAppState fails, continue polling @@ -1055,17 +910,17 @@ function startRemoteSessionPolling( // Continue polling if (isRunning) { - setTimeout(poll, POLL_INTERVAL_MS) + setTimeout(poll, POLL_INTERVAL_MS); } - } + }; // Start polling - void poll() + void poll(); // Return cleanup function return () => { - isRunning = false - } + isRunning = false; + }; } /** @@ -1079,25 +934,25 @@ export const RemoteAgentTask: Task = { name: 'RemoteAgentTask', type: 'remote_agent', async kill(taskId, setAppState) { - let toolUseId: string | undefined - let description: string | undefined - let sessionId: string | undefined - let killed = false + let toolUseId: string | undefined; + let description: string | undefined; + let sessionId: string | undefined; + let killed = false; updateTaskState(taskId, setAppState, task => { if (task.status !== 'running') { - return task + return task; } - toolUseId = task.toolUseId - description = task.description - sessionId = task.sessionId - killed = true + toolUseId = task.toolUseId; + description = task.description; + sessionId = task.sessionId; + killed = true; return { ...task, status: 'killed', notified: true, endTime: Date.now(), - } - }) + }; + }); // Close the task_started bookend for SDK consumers. The poll loop's // early-return when status!=='running' won't emit a notification. @@ -1105,26 +960,24 @@ export const RemoteAgentTask: Task = { emitTaskTerminatedSdk(taskId, 'stopped', { toolUseId, summary: description, - }) + }); // Archive the remote session so it stops consuming cloud resources. if (sessionId) { void archiveRemoteSession(sessionId).catch(e => logForDebugging(`RemoteAgentTask archive failed: ${String(e)}`), - ) + ); } } - void evictTaskOutput(taskId) - void removeRemoteAgentMetadata(taskId) - logForDebugging( - `RemoteAgentTask ${taskId} killed, archiving session ${sessionId ?? 'unknown'}`, - ) + void evictTaskOutput(taskId); + void removeRemoteAgentMetadata(taskId); + logForDebugging(`RemoteAgentTask ${taskId} killed, archiving session ${sessionId ?? 'unknown'}`); }, -} +}; /** * Get the session URL for a remote task. */ export function getRemoteTaskSessionUrl(sessionId: string): string { - return getRemoteSessionUrl(sessionId, process.env.SESSION_INGRESS_URL) + return getRemoteSessionUrl(sessionId, process.env.SESSION_INGRESS_URL); } diff --git a/src/tools.ts b/src/tools.ts index 9c956ff65..3417ca1b6 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -15,7 +15,8 @@ import { BriefTool } from '@claude-code-best/builtin-tools/tools/BriefTool/Brief /* eslint-disable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */ const REPLTool = process.env.USER_TYPE === 'ant' - ? require('@claude-code-best/builtin-tools/tools/REPLTool/REPLTool.js').REPLTool + ? require('@claude-code-best/builtin-tools/tools/REPLTool/REPLTool.js') + .REPLTool : null const SuggestBackgroundPRTool = process.env.USER_TYPE === 'ant' @@ -24,21 +25,28 @@ const SuggestBackgroundPRTool = : null const SleepTool = feature('PROACTIVE') || feature('KAIROS') - ? require('@claude-code-best/builtin-tools/tools/SleepTool/SleepTool.js').SleepTool + ? require('@claude-code-best/builtin-tools/tools/SleepTool/SleepTool.js') + .SleepTool : null const cronTools = [ - require('@claude-code-best/builtin-tools/tools/ScheduleCronTool/CronCreateTool.js').CronCreateTool, - require('@claude-code-best/builtin-tools/tools/ScheduleCronTool/CronDeleteTool.js').CronDeleteTool, - require('@claude-code-best/builtin-tools/tools/ScheduleCronTool/CronListTool.js').CronListTool, + require('@claude-code-best/builtin-tools/tools/ScheduleCronTool/CronCreateTool.js') + .CronCreateTool, + require('@claude-code-best/builtin-tools/tools/ScheduleCronTool/CronDeleteTool.js') + .CronDeleteTool, + require('@claude-code-best/builtin-tools/tools/ScheduleCronTool/CronListTool.js') + .CronListTool, ] const RemoteTriggerTool = feature('AGENT_TRIGGERS_REMOTE') - ? require('@claude-code-best/builtin-tools/tools/RemoteTriggerTool/RemoteTriggerTool.js').RemoteTriggerTool + ? require('@claude-code-best/builtin-tools/tools/RemoteTriggerTool/RemoteTriggerTool.js') + .RemoteTriggerTool : null const MonitorTool = feature('MONITOR_TOOL') - ? require('@claude-code-best/builtin-tools/tools/MonitorTool/MonitorTool.js').MonitorTool + ? require('@claude-code-best/builtin-tools/tools/MonitorTool/MonitorTool.js') + .MonitorTool : null const SendUserFileTool = feature('KAIROS') - ? require('@claude-code-best/builtin-tools/tools/SendUserFileTool/SendUserFileTool.js').SendUserFileTool + ? require('@claude-code-best/builtin-tools/tools/SendUserFileTool/SendUserFileTool.js') + .SendUserFileTool : null const PushNotificationTool = feature('KAIROS') || feature('KAIROS_PUSH_NOTIFICATION') @@ -46,7 +54,8 @@ const PushNotificationTool = .PushNotificationTool : null const SubscribePRTool = feature('KAIROS_GITHUB_WEBHOOKS') - ? require('@claude-code-best/builtin-tools/tools/SubscribePRTool/SubscribePRTool.js').SubscribePRTool + ? require('@claude-code-best/builtin-tools/tools/SubscribePRTool/SubscribePRTool.js') + .SubscribePRTool : null /* eslint-enable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */ import { TaskOutputTool } from '@claude-code-best/builtin-tools/tools/TaskOutputTool/TaskOutputTool.js' @@ -103,35 +112,41 @@ import { feature } from 'bun:bundle' // Dead code elimination: conditional import for OVERFLOW_TEST_TOOL /* eslint-disable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */ const OverflowTestTool = feature('OVERFLOW_TEST_TOOL') - ? require('@claude-code-best/builtin-tools/tools/OverflowTestTool/OverflowTestTool.js').OverflowTestTool + ? require('@claude-code-best/builtin-tools/tools/OverflowTestTool/OverflowTestTool.js') + .OverflowTestTool : null const CtxInspectTool = feature('CONTEXT_COLLAPSE') - ? require('@claude-code-best/builtin-tools/tools/CtxInspectTool/CtxInspectTool.js').CtxInspectTool + ? require('@claude-code-best/builtin-tools/tools/CtxInspectTool/CtxInspectTool.js') + .CtxInspectTool : null const TerminalCaptureTool = feature('TERMINAL_PANEL') ? require('@claude-code-best/builtin-tools/tools/TerminalCaptureTool/TerminalCaptureTool.js') .TerminalCaptureTool : null const WebBrowserTool = feature('WEB_BROWSER_TOOL') - ? require('@claude-code-best/builtin-tools/tools/WebBrowserTool/WebBrowserTool.js').WebBrowserTool + ? require('@claude-code-best/builtin-tools/tools/WebBrowserTool/WebBrowserTool.js') + .WebBrowserTool : null const coordinatorModeModule = feature('COORDINATOR_MODE') ? (require('./coordinator/coordinatorMode.js') as typeof import('./coordinator/coordinatorMode.js')) : null const SnipTool = feature('HISTORY_SNIP') - ? require('@claude-code-best/builtin-tools/tools/SnipTool/SnipTool.js').SnipTool + ? require('@claude-code-best/builtin-tools/tools/SnipTool/SnipTool.js') + .SnipTool : null const ReviewArtifactTool = feature('REVIEW_ARTIFACT') ? require('@claude-code-best/builtin-tools/tools/ReviewArtifactTool/ReviewArtifactTool.js') .ReviewArtifactTool : null const ListPeersTool = feature('UDS_INBOX') - ? require('@claude-code-best/builtin-tools/tools/ListPeersTool/ListPeersTool.js').ListPeersTool + ? require('@claude-code-best/builtin-tools/tools/ListPeersTool/ListPeersTool.js') + .ListPeersTool : null const WorkflowTool = feature('WORKFLOW_SCRIPTS') ? (() => { require('@claude-code-best/builtin-tools/tools/WorkflowTool/bundled/index.js').initBundledWorkflows() - return require('@claude-code-best/builtin-tools/tools/WorkflowTool/WorkflowTool.js').WorkflowTool + return require('@claude-code-best/builtin-tools/tools/WorkflowTool/WorkflowTool.js') + .WorkflowTool })() : null /* eslint-enable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */ diff --git a/src/types/connectorText.ts b/src/types/connectorText.ts index a9aa0853d..b6ffca496 100644 --- a/src/types/connectorText.ts +++ b/src/types/connectorText.ts @@ -1,4 +1,19 @@ // Auto-generated stub — replace with real implementation -export type ConnectorTextBlock = { type: string; connector_text: string; signature?: string; [key: string]: unknown }; -export type ConnectorTextDelta = { type: string; connector_text: string; text?: string; thinking?: string; signature?: string; [key: string]: unknown }; -export const isConnectorTextBlock: (block: unknown) => block is ConnectorTextBlock = (_block): _block is ConnectorTextBlock => false; +export type ConnectorTextBlock = { + type: string + connector_text: string + signature?: string + [key: string]: unknown +} +export type ConnectorTextDelta = { + type: string + connector_text: string + text?: string + thinking?: string + signature?: string + [key: string]: unknown +} +export const isConnectorTextBlock: ( + block: unknown, +) => block is ConnectorTextBlock = (_block): _block is ConnectorTextBlock => + false diff --git a/src/types/fileSuggestion.ts b/src/types/fileSuggestion.ts index c92a18b96..7a384688a 100644 --- a/src/types/fileSuggestion.ts +++ b/src/types/fileSuggestion.ts @@ -1,2 +1,2 @@ // Auto-generated stub — replace with real implementation -export type FileSuggestionCommandInput = any; +export type FileSuggestionCommandInput = any diff --git a/src/types/global.d.ts b/src/types/global.d.ts index df6cc7bd2..539126983 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -21,7 +21,9 @@ declare namespace MACRO { // These are referenced inside `MACRO(() => ...)` or `false && ...` blocks. // Model resolution (internal) -declare function resolveAntModel(model: string): import('../utils/model/antModels.js').AntModel | undefined +declare function resolveAntModel( + model: string, +): import('../utils/model/antModels.js').AntModel | undefined declare function getAntModels(): import('../utils/model/antModels.js').AntModel[] declare function getAntModelOverrideConfig(): { defaultSystemPromptSuffix?: string @@ -31,7 +33,13 @@ declare function getAntModelOverrideConfig(): { // Companion reactions handled by src/buddy/companionReact.ts (direct import) // Metrics (internal) -type ApiMetricEntry = { ttftMs: number; firstTokenTime: number; lastTokenTime: number; responseLengthBaseline: number; endResponseLength: number } +type ApiMetricEntry = { + ttftMs: number + firstTokenTime: number + lastTokenTime: number + responseLengthBaseline: number + endResponseLength: number +} declare const apiMetricsRef: React.RefObject | null declare function computeTtftText(metrics: ApiMetricEntry[]): string @@ -53,7 +61,10 @@ declare const HOOK_TIMING_DISPLAY_THRESHOLD_MS: number declare type T = unknown // Tungsten (internal) -declare function TungstenPill(props?: { key?: string; selected?: boolean }): JSX.Element | null +declare function TungstenPill(props?: { + key?: string + selected?: boolean +}): JSX.Element | null // ============================================================================ // Build-time constants BUILD_TARGET/BUILD_ENV/INTERFACE_TYPE — removed (zero runtime usage) diff --git a/src/types/ink-elements.d.ts b/src/types/ink-elements.d.ts index f98f08e53..86542b155 100644 --- a/src/types/ink-elements.d.ts +++ b/src/types/ink-elements.d.ts @@ -1,45 +1,52 @@ // Type declarations for custom Ink JSX elements // Note: The detailed prop types are defined in ink-jsx.d.ts via React module augmentation. // This file provides the global JSX namespace fallback declarations. -import type { ReactNode, Ref } from 'react'; -import type { ClickEvent, FocusEvent, KeyboardEvent, Styles, TextStyles, DOMElement } from '@anthropic/ink'; +import type { ReactNode, Ref } from 'react' +import type { + ClickEvent, + FocusEvent, + KeyboardEvent, + Styles, + TextStyles, + DOMElement, +} from '@anthropic/ink' declare global { namespace JSX { interface IntrinsicElements { 'ink-box': { - ref?: Ref; - tabIndex?: number; - autoFocus?: boolean; - onClick?: (event: ClickEvent) => void; - onFocus?: (event: FocusEvent) => void; - onFocusCapture?: (event: FocusEvent) => void; - onBlur?: (event: FocusEvent) => void; - onBlurCapture?: (event: FocusEvent) => void; - onMouseEnter?: () => void; - onMouseLeave?: () => void; - onKeyDown?: (event: KeyboardEvent) => void; - onKeyDownCapture?: (event: KeyboardEvent) => void; - style?: Styles; - stickyScroll?: boolean; - children?: ReactNode; - }; + ref?: Ref + tabIndex?: number + autoFocus?: boolean + onClick?: (event: ClickEvent) => void + onFocus?: (event: FocusEvent) => void + onFocusCapture?: (event: FocusEvent) => void + onBlur?: (event: FocusEvent) => void + onBlurCapture?: (event: FocusEvent) => void + onMouseEnter?: () => void + onMouseLeave?: () => void + onKeyDown?: (event: KeyboardEvent) => void + onKeyDownCapture?: (event: KeyboardEvent) => void + style?: Styles + stickyScroll?: boolean + children?: ReactNode + } 'ink-text': { - style?: Styles; - textStyles?: TextStyles; - children?: ReactNode; - }; + style?: Styles + textStyles?: TextStyles + children?: ReactNode + } 'ink-link': { - href?: string; - children?: ReactNode; - }; + href?: string + children?: ReactNode + } 'ink-raw-ansi': { - rawText?: string; - rawWidth?: number; - rawHeight?: number; - }; + rawText?: string + rawWidth?: number + rawHeight?: number + } } } } -export {}; +export {} diff --git a/src/types/ink-jsx.d.ts b/src/types/ink-jsx.d.ts index 2c8911c71..7b4c6168b 100644 --- a/src/types/ink-jsx.d.ts +++ b/src/types/ink-jsx.d.ts @@ -8,43 +8,50 @@ * This file must be a module (have an import/export) for `declare module` * augmentation to work correctly. */ -import type { ReactNode, Ref } from 'react'; -import type { ClickEvent, FocusEvent, KeyboardEvent, Styles, TextStyles, DOMElement } from '@anthropic/ink'; +import type { ReactNode, Ref } from 'react' +import type { + ClickEvent, + FocusEvent, + KeyboardEvent, + Styles, + TextStyles, + DOMElement, +} from '@anthropic/ink' declare module 'react' { namespace JSX { interface IntrinsicElements { 'ink-box': { - ref?: Ref; - tabIndex?: number; - autoFocus?: boolean; - onClick?: (event: ClickEvent) => void; - onFocus?: (event: FocusEvent) => void; - onFocusCapture?: (event: FocusEvent) => void; - onBlur?: (event: FocusEvent) => void; - onBlurCapture?: (event: FocusEvent) => void; - onMouseEnter?: () => void; - onMouseLeave?: () => void; - onKeyDown?: (event: KeyboardEvent) => void; - onKeyDownCapture?: (event: KeyboardEvent) => void; - style?: Styles; - stickyScroll?: boolean; - children?: ReactNode; - }; + ref?: Ref + tabIndex?: number + autoFocus?: boolean + onClick?: (event: ClickEvent) => void + onFocus?: (event: FocusEvent) => void + onFocusCapture?: (event: FocusEvent) => void + onBlur?: (event: FocusEvent) => void + onBlurCapture?: (event: FocusEvent) => void + onMouseEnter?: () => void + onMouseLeave?: () => void + onKeyDown?: (event: KeyboardEvent) => void + onKeyDownCapture?: (event: KeyboardEvent) => void + style?: Styles + stickyScroll?: boolean + children?: ReactNode + } 'ink-text': { - style?: Styles; - textStyles?: TextStyles; - children?: ReactNode; - }; + style?: Styles + textStyles?: TextStyles + children?: ReactNode + } 'ink-link': { - href?: string; - children?: ReactNode; - }; + href?: string + children?: ReactNode + } 'ink-raw-ansi': { - rawText?: string; - rawWidth?: number; - rawHeight?: number; - }; + rawText?: string + rawWidth?: number + rawHeight?: number + } } } } diff --git a/src/types/internal-modules.d.ts b/src/types/internal-modules.d.ts index 6f94d93e9..7d2606df9 100644 --- a/src/types/internal-modules.d.ts +++ b/src/types/internal-modules.d.ts @@ -7,25 +7,44 @@ // ============================================================================ // bun:bundle — compile-time macros // ============================================================================ -declare module "bun:bundle" { - export function feature(name: string): boolean; +declare module 'bun:bundle' { + export function feature(name: string): boolean } -declare module "bun:ffi" { - export function dlopen>(path: string, symbols: T): { symbols: { [K in keyof T]: (...args: unknown[]) => unknown }; close(): void }; +declare module 'bun:ffi' { + export function dlopen< + T extends Record, + >( + path: string, + symbols: T, + ): { + symbols: { [K in keyof T]: (...args: unknown[]) => unknown } + close(): void + } } // Third-party modules without @types packages declare module 'bidi-js' { - function getEmbeddingLevels(text: string, defaultDirection?: string): { paragraphLevel: number; levels: Uint8Array } - function getReorderSegments(text: string, embeddingLevels: { paragraphLevel: number; levels: Uint8Array }, start?: number, end?: number): [number, number][] + function getEmbeddingLevels( + text: string, + defaultDirection?: string, + ): { paragraphLevel: number; levels: Uint8Array } + function getReorderSegments( + text: string, + embeddingLevels: { paragraphLevel: number; levels: Uint8Array }, + start?: number, + end?: number, + ): [number, number][] function getVisualOrder(reorderSegments: [number, number][]): number[] export { getEmbeddingLevels, getReorderSegments, getVisualOrder } export default { getEmbeddingLevels, getReorderSegments, getVisualOrder } } declare module 'asciichart' { - function plot(series: number[] | number[][], config?: Record): string + function plot( + series: number[] | number[][], + config?: Record, + ): string export { plot } export default { plot } } diff --git a/src/types/message.ts b/src/types/message.ts index 567bae475..b88b5e820 100644 --- a/src/types/message.ts +++ b/src/types/message.ts @@ -16,7 +16,14 @@ import type { * Individual message subtypes (UserMessage, AssistantMessage, etc.) extend * this with narrower `type` literals and additional fields. */ -export type MessageType = 'user' | 'assistant' | 'system' | 'attachment' | 'progress' | 'grouped_tool_use' | 'collapsed_read_search' +export type MessageType = + | 'user' + | 'assistant' + | 'system' + | 'attachment' + | 'progress' + | 'grouped_tool_use' + | 'collapsed_read_search' /** A single content element inside message.content arrays. */ export type ContentItem = ContentBlockParam | ContentBlock @@ -37,7 +44,14 @@ export type Message = { isCompactSummary?: boolean toolUseResult?: unknown isVisibleInTranscriptOnly?: boolean - attachment?: { type: string; toolUseID?: string; [key: string]: unknown; addedNames: string[]; addedLines: string[]; removedNames: string[] } + attachment?: { + type: string + toolUseID?: string + [key: string]: unknown + addedNames: string[] + addedLines: string[] + removedNames: string[] + } message?: { role?: string id?: string @@ -52,8 +66,12 @@ export type AssistantMessage = Message & { type: 'assistant' message: NonNullable } -export type AttachmentMessage = Message & { type: 'attachment'; attachment: T } -export type ProgressMessage = Message & { type: 'progress'; data: T } +export type AttachmentMessage = + Message & { type: 'attachment'; attachment: T } +export type ProgressMessage = Message & { + type: 'progress' + data: T +} export type SystemLocalCommandMessage = Message & { type: 'system' } export type SystemMessage = Message & { type: 'system' } export type UserMessage = Message & { @@ -126,7 +144,14 @@ export type RenderableMessage = | AssistantMessage | UserMessage | (Message & { type: 'system' }) - | (Message & { type: 'attachment'; attachment: { type: string; memories?: { path: string; content: string; mtimeMs: number }[]; [key: string]: unknown } }) + | (Message & { + type: 'attachment' + attachment: { + type: string + memories?: { path: string; content: string; mtimeMs: number }[] + [key: string]: unknown + } + }) | (Message & { type: 'progress' }) | GroupedToolUseMessage | CollapsedReadSearchGroup diff --git a/src/types/messageQueueTypes.ts b/src/types/messageQueueTypes.ts index 6543963b2..63d7a1569 100644 --- a/src/types/messageQueueTypes.ts +++ b/src/types/messageQueueTypes.ts @@ -7,4 +7,4 @@ export type QueueOperationMessage = { content?: string [key: string]: unknown } -export type QueueOperation = 'enqueue' | 'dequeue' | 'remove' | string; +export type QueueOperation = 'enqueue' | 'dequeue' | 'remove' | string diff --git a/src/types/notebook.ts b/src/types/notebook.ts index 3ac9b2a02..012b8fd2d 100644 --- a/src/types/notebook.ts +++ b/src/types/notebook.ts @@ -1,8 +1,8 @@ // Auto-generated stub — replace with real implementation -export type NotebookCell = any; -export type NotebookContent = any; -export type NotebookCellOutput = any; -export type NotebookCellSource = any; -export type NotebookCellSourceOutput = any; -export type NotebookOutputImage = any; -export type NotebookCellType = any; +export type NotebookCell = any +export type NotebookContent = any +export type NotebookCellOutput = any +export type NotebookCellSource = any +export type NotebookCellSourceOutput = any +export type NotebookOutputImage = any +export type NotebookCellType = any diff --git a/src/types/sdk-stubs.d.ts b/src/types/sdk-stubs.d.ts index 6633a5eb1..272d6f85c 100644 --- a/src/types/sdk-stubs.d.ts +++ b/src/types/sdk-stubs.d.ts @@ -9,7 +9,7 @@ // ============================================================================ // coreTypes.generated.js — Generated from coreSchemas.ts Zod schemas // ============================================================================ -declare module "*/sdk/coreTypes.generated.js" { +declare module '*/sdk/coreTypes.generated.js' { // Usage & Model export type ModelUsage = { inputTokens: number @@ -32,20 +32,20 @@ declare module "*/sdk/coreTypes.generated.js" { export type McpServerConfigForProcessTransport = { command: string args: string[] - type?: "stdio" + type?: 'stdio' env?: Record } & { scope: string; pluginSource?: string } export type McpServerStatus = { name: string - status: "connected" | "disconnected" | "error" + status: 'connected' | 'disconnected' | 'error' [key: string]: unknown } // Permissions export type PermissionMode = string export type PermissionResult = - | { behavior: "allow" } - | { behavior: "deny"; message?: string } + | { behavior: 'allow' } + | { behavior: 'deny'; message?: string } export type PermissionUpdate = { path: string permission: string @@ -91,21 +91,56 @@ declare module "*/sdk/coreTypes.generated.js" { // SDK Message types export type SDKMessage = { type: string; [key: string]: unknown } - export type SDKUserMessage = { type: "user"; content: unknown; uuid: string; [key: string]: unknown } + export type SDKUserMessage = { + type: 'user' + content: unknown + uuid: string + [key: string]: unknown + } export type SDKUserMessageReplay = SDKUserMessage - export type SDKAssistantMessage = { type: "assistant"; content: unknown; [key: string]: unknown } - export type SDKAssistantErrorMessage = { type: "assistant_error"; error: unknown; [key: string]: unknown } - export type SDKAssistantMessageError = 'authentication_failed' | 'billing_error' | 'rate_limit' | 'invalid_request' | 'server_error' | 'unknown' | 'max_output_tokens' - export type SDKPartialAssistantMessage = { type: "partial_assistant"; [key: string]: unknown } - export type SDKResultMessage = { type: "result"; [key: string]: unknown } - export type SDKResultSuccess = { type: "result_success"; [key: string]: unknown } - export type SDKSystemMessage = { type: "system"; [key: string]: unknown } - export type SDKStatusMessage = { type: "status"; [key: string]: unknown } - export type SDKToolProgressMessage = { type: "tool_progress"; [key: string]: unknown } - export type SDKCompactBoundaryMessage = { type: "compact_boundary"; [key: string]: unknown } - export type SDKPermissionDenial = { type: "permission_denial"; [key: string]: unknown } - export type SDKRateLimitInfo = { type: "rate_limit"; [key: string]: unknown } - export type SDKStatus = "active" | "idle" | "error" | string + export type SDKAssistantMessage = { + type: 'assistant' + content: unknown + [key: string]: unknown + } + export type SDKAssistantErrorMessage = { + type: 'assistant_error' + error: unknown + [key: string]: unknown + } + export type SDKAssistantMessageError = + | 'authentication_failed' + | 'billing_error' + | 'rate_limit' + | 'invalid_request' + | 'server_error' + | 'unknown' + | 'max_output_tokens' + export type SDKPartialAssistantMessage = { + type: 'partial_assistant' + [key: string]: unknown + } + export type SDKResultMessage = { type: 'result'; [key: string]: unknown } + export type SDKResultSuccess = { + type: 'result_success' + [key: string]: unknown + } + export type SDKSystemMessage = { type: 'system'; [key: string]: unknown } + export type SDKStatusMessage = { type: 'status'; [key: string]: unknown } + export type SDKToolProgressMessage = { + type: 'tool_progress' + [key: string]: unknown + } + export type SDKCompactBoundaryMessage = { + type: 'compact_boundary' + [key: string]: unknown + } + export type SDKPermissionDenial = { + type: 'permission_denial' + [key: string]: unknown + } + export type SDKRateLimitInfo = { type: 'rate_limit'; [key: string]: unknown } + export type SDKStatus = 'active' | 'idle' | 'error' | string export type SDKSessionInfo = { sessionId: string summary?: string diff --git a/src/types/src/entrypoints/agentSdkTypes.ts b/src/types/src/entrypoints/agentSdkTypes.ts index d06fee6e9..9e22e8af6 100644 --- a/src/types/src/entrypoints/agentSdkTypes.ts +++ b/src/types/src/entrypoints/agentSdkTypes.ts @@ -1,8 +1,8 @@ // Auto-generated type stub — replace with real implementation -export type HookEvent = any; -export type HOOK_EVENTS = any; -export type HookInput = any; -export type PermissionUpdate = any; -export type HookJSONOutput = any; -export type AsyncHookJSONOutput = any; -export type SyncHookJSONOutput = any; +export type HookEvent = any +export type HOOK_EVENTS = any +export type HookInput = any +export type PermissionUpdate = any +export type HookJSONOutput = any +export type AsyncHookJSONOutput = any +export type SyncHookJSONOutput = any diff --git a/src/types/src/types/message.ts b/src/types/src/types/message.ts index 585895d8e..5febc8ae0 100644 --- a/src/types/src/types/message.ts +++ b/src/types/src/types/message.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type Message = any; +export type Message = any diff --git a/src/types/src/utils/fileHistory.ts b/src/types/src/utils/fileHistory.ts index f1dbe566a..1a0a69b20 100644 --- a/src/types/src/utils/fileHistory.ts +++ b/src/types/src/utils/fileHistory.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type FileHistorySnapshot = any; +export type FileHistorySnapshot = any diff --git a/src/types/src/utils/permissions/PermissionResult.ts b/src/types/src/utils/permissions/PermissionResult.ts index 4ffc39c69..454f0e1ff 100644 --- a/src/types/src/utils/permissions/PermissionResult.ts +++ b/src/types/src/utils/permissions/PermissionResult.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type PermissionResult = any; +export type PermissionResult = any diff --git a/src/types/src/utils/permissions/PermissionRule.ts b/src/types/src/utils/permissions/PermissionRule.ts index 2b6b7b27a..67b1e38ef 100644 --- a/src/types/src/utils/permissions/PermissionRule.ts +++ b/src/types/src/utils/permissions/PermissionRule.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type permissionBehaviorSchema = any; +export type permissionBehaviorSchema = any diff --git a/src/types/src/utils/permissions/PermissionUpdateSchema.ts b/src/types/src/utils/permissions/PermissionUpdateSchema.ts index a56a548e6..408c8896a 100644 --- a/src/types/src/utils/permissions/PermissionUpdateSchema.ts +++ b/src/types/src/utils/permissions/PermissionUpdateSchema.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type permissionUpdateSchema = any; +export type permissionUpdateSchema = any diff --git a/src/types/src/utils/toolResultStorage.ts b/src/types/src/utils/toolResultStorage.ts index 2229baa6d..e1f51f493 100644 --- a/src/types/src/utils/toolResultStorage.ts +++ b/src/types/src/utils/toolResultStorage.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type ContentReplacementRecord = any; +export type ContentReplacementRecord = any diff --git a/src/types/statusLine.ts b/src/types/statusLine.ts index 059480875..4467e526b 100644 --- a/src/types/statusLine.ts +++ b/src/types/statusLine.ts @@ -1,2 +1,2 @@ // Auto-generated stub — replace with real implementation -export type StatusLineCommandInput = any; +export type StatusLineCommandInput = any diff --git a/src/types/tools.ts b/src/types/tools.ts index 218d782f6..b022c61da 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -1,12 +1,12 @@ // Auto-generated stub — replace with real implementation -export type AgentToolProgress = any; -export type BashProgress = any; -export type MCPProgress = any; -export type REPLToolProgress = any; -export type SkillToolProgress = any; -export type TaskOutputProgress = any; -export type ToolProgressData = any; -export type WebSearchProgress = any; -export type ShellProgress = any; -export type PowerShellProgress = any; -export type SdkWorkflowProgress = any; +export type AgentToolProgress = any +export type BashProgress = any +export type MCPProgress = any +export type REPLToolProgress = any +export type SkillToolProgress = any +export type TaskOutputProgress = any +export type ToolProgressData = any +export type WebSearchProgress = any +export type ShellProgress = any +export type PowerShellProgress = any +export type SdkWorkflowProgress = any diff --git a/src/types/utils.ts b/src/types/utils.ts index 208e5dd8a..66f5cbb18 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -1,3 +1,3 @@ // Auto-generated stub — replace with real implementation -export type DeepImmutable = T; -export type Permutations = T[]; +export type DeepImmutable = T +export type Permutations = T[] diff --git a/src/utils/__tests__/CircularBuffer.test.ts b/src/utils/__tests__/CircularBuffer.test.ts index 96d5b6ec3..07866dcd2 100644 --- a/src/utils/__tests__/CircularBuffer.test.ts +++ b/src/utils/__tests__/CircularBuffer.test.ts @@ -1,102 +1,102 @@ -import { describe, expect, test } from "bun:test"; -import { CircularBuffer } from "../CircularBuffer"; +import { describe, expect, test } from 'bun:test' +import { CircularBuffer } from '../CircularBuffer' -describe("CircularBuffer", () => { - test("starts empty", () => { - const buf = new CircularBuffer(5); - expect(buf.length()).toBe(0); - expect(buf.toArray()).toEqual([]); - }); +describe('CircularBuffer', () => { + test('starts empty', () => { + const buf = new CircularBuffer(5) + expect(buf.length()).toBe(0) + expect(buf.toArray()).toEqual([]) + }) - test("adds items up to capacity", () => { - const buf = new CircularBuffer(3); - buf.add(1); - buf.add(2); - buf.add(3); - expect(buf.length()).toBe(3); - expect(buf.toArray()).toEqual([1, 2, 3]); - }); + test('adds items up to capacity', () => { + const buf = new CircularBuffer(3) + buf.add(1) + buf.add(2) + buf.add(3) + expect(buf.length()).toBe(3) + expect(buf.toArray()).toEqual([1, 2, 3]) + }) - test("evicts oldest when full", () => { - const buf = new CircularBuffer(3); - buf.add(1); - buf.add(2); - buf.add(3); - buf.add(4); - expect(buf.length()).toBe(3); - expect(buf.toArray()).toEqual([2, 3, 4]); - }); + test('evicts oldest when full', () => { + const buf = new CircularBuffer(3) + buf.add(1) + buf.add(2) + buf.add(3) + buf.add(4) + expect(buf.length()).toBe(3) + expect(buf.toArray()).toEqual([2, 3, 4]) + }) - test("evicts multiple oldest items", () => { - const buf = new CircularBuffer(2); - buf.add(1); - buf.add(2); - buf.add(3); - buf.add(4); - buf.add(5); - expect(buf.toArray()).toEqual([4, 5]); - }); + test('evicts multiple oldest items', () => { + const buf = new CircularBuffer(2) + buf.add(1) + buf.add(2) + buf.add(3) + buf.add(4) + buf.add(5) + expect(buf.toArray()).toEqual([4, 5]) + }) - test("addAll adds multiple items", () => { - const buf = new CircularBuffer(5); - buf.addAll([1, 2, 3]); - expect(buf.toArray()).toEqual([1, 2, 3]); - }); + test('addAll adds multiple items', () => { + const buf = new CircularBuffer(5) + buf.addAll([1, 2, 3]) + expect(buf.toArray()).toEqual([1, 2, 3]) + }) - test("addAll with overflow", () => { - const buf = new CircularBuffer(3); - buf.addAll([1, 2, 3, 4, 5]); - expect(buf.toArray()).toEqual([3, 4, 5]); - }); + test('addAll with overflow', () => { + const buf = new CircularBuffer(3) + buf.addAll([1, 2, 3, 4, 5]) + expect(buf.toArray()).toEqual([3, 4, 5]) + }) - test("getRecent returns last N items", () => { - const buf = new CircularBuffer(5); - buf.addAll([1, 2, 3, 4, 5]); - expect(buf.getRecent(3)).toEqual([3, 4, 5]); - }); + test('getRecent returns last N items', () => { + const buf = new CircularBuffer(5) + buf.addAll([1, 2, 3, 4, 5]) + expect(buf.getRecent(3)).toEqual([3, 4, 5]) + }) - test("getRecent returns fewer when not enough items", () => { - const buf = new CircularBuffer(5); - buf.add(1); - buf.add(2); - expect(buf.getRecent(5)).toEqual([1, 2]); - }); + test('getRecent returns fewer when not enough items', () => { + const buf = new CircularBuffer(5) + buf.add(1) + buf.add(2) + expect(buf.getRecent(5)).toEqual([1, 2]) + }) - test("getRecent works after wraparound", () => { - const buf = new CircularBuffer(3); - buf.addAll([1, 2, 3, 4, 5]); - expect(buf.getRecent(2)).toEqual([4, 5]); - }); + test('getRecent works after wraparound', () => { + const buf = new CircularBuffer(3) + buf.addAll([1, 2, 3, 4, 5]) + expect(buf.getRecent(2)).toEqual([4, 5]) + }) - test("clear resets buffer", () => { - const buf = new CircularBuffer(5); - buf.addAll([1, 2, 3]); - buf.clear(); - expect(buf.length()).toBe(0); - expect(buf.toArray()).toEqual([]); - }); + test('clear resets buffer', () => { + const buf = new CircularBuffer(5) + buf.addAll([1, 2, 3]) + buf.clear() + expect(buf.length()).toBe(0) + expect(buf.toArray()).toEqual([]) + }) - test("works with string type", () => { - const buf = new CircularBuffer(2); - buf.add("a"); - buf.add("b"); - buf.add("c"); - expect(buf.toArray()).toEqual(["b", "c"]); - }); + test('works with string type', () => { + const buf = new CircularBuffer(2) + buf.add('a') + buf.add('b') + buf.add('c') + expect(buf.toArray()).toEqual(['b', 'c']) + }) - test("capacity=1 keeps only the most recent item", () => { - const buf = new CircularBuffer(1); - buf.add(10); - expect(buf.toArray()).toEqual([10]); - buf.add(20); - expect(buf.toArray()).toEqual([20]); - buf.add(30); - expect(buf.toArray()).toEqual([30]); - expect(buf.getRecent(1)).toEqual([30]); - }); + test('capacity=1 keeps only the most recent item', () => { + const buf = new CircularBuffer(1) + buf.add(10) + expect(buf.toArray()).toEqual([10]) + buf.add(20) + expect(buf.toArray()).toEqual([20]) + buf.add(30) + expect(buf.toArray()).toEqual([30]) + expect(buf.getRecent(1)).toEqual([30]) + }) - test("getRecent on empty buffer returns empty array", () => { - const buf = new CircularBuffer(5); - expect(buf.getRecent(3)).toEqual([]); - }); -}); + test('getRecent on empty buffer returns empty array', () => { + const buf = new CircularBuffer(5) + expect(buf.getRecent(3)).toEqual([]) + }) +}) diff --git a/src/utils/__tests__/abortController.test.ts b/src/utils/__tests__/abortController.test.ts index 1f18ada41..f3665c089 100644 --- a/src/utils/__tests__/abortController.test.ts +++ b/src/utils/__tests__/abortController.test.ts @@ -1,106 +1,106 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test } from 'bun:test' import { createAbortController, createChildAbortController, -} from "../abortController"; +} from '../abortController' -describe("createAbortController", () => { - test("returns an AbortController that is not aborted", () => { - const controller = createAbortController(); - expect(controller.signal.aborted).toBe(false); - }); +describe('createAbortController', () => { + test('returns an AbortController that is not aborted', () => { + const controller = createAbortController() + expect(controller.signal.aborted).toBe(false) + }) - test("aborting the controller sets signal.aborted", () => { - const controller = createAbortController(); - controller.abort(); - expect(controller.signal.aborted).toBe(true); - }); + test('aborting the controller sets signal.aborted', () => { + const controller = createAbortController() + controller.abort() + expect(controller.signal.aborted).toBe(true) + }) - test("abort reason is propagated", () => { - const controller = createAbortController(); - controller.abort("custom reason"); - expect(controller.signal.reason).toBe("custom reason"); - }); + test('abort reason is propagated', () => { + const controller = createAbortController() + controller.abort('custom reason') + expect(controller.signal.reason).toBe('custom reason') + }) - test("accepts custom maxListeners without error", () => { - const controller = createAbortController(100); - expect(controller.signal.aborted).toBe(false); - }); -}); + test('accepts custom maxListeners without error', () => { + const controller = createAbortController(100) + expect(controller.signal.aborted).toBe(false) + }) +}) -describe("createChildAbortController", () => { - test("child is not aborted initially", () => { - const parent = createAbortController(); - const child = createChildAbortController(parent); - expect(child.signal.aborted).toBe(false); - expect(parent.signal.aborted).toBe(false); - }); +describe('createChildAbortController', () => { + test('child is not aborted initially', () => { + const parent = createAbortController() + const child = createChildAbortController(parent) + expect(child.signal.aborted).toBe(false) + expect(parent.signal.aborted).toBe(false) + }) - test("parent abort propagates to child", () => { - const parent = createAbortController(); - const child = createChildAbortController(parent); - parent.abort("parent reason"); - expect(child.signal.aborted).toBe(true); - expect(child.signal.reason).toBe("parent reason"); - }); + test('parent abort propagates to child', () => { + const parent = createAbortController() + const child = createChildAbortController(parent) + parent.abort('parent reason') + expect(child.signal.aborted).toBe(true) + expect(child.signal.reason).toBe('parent reason') + }) - test("child abort does NOT propagate to parent", () => { - const parent = createAbortController(); - const child = createChildAbortController(parent); - child.abort("child reason"); - expect(child.signal.aborted).toBe(true); - expect(parent.signal.aborted).toBe(false); - }); + test('child abort does NOT propagate to parent', () => { + const parent = createAbortController() + const child = createChildAbortController(parent) + child.abort('child reason') + expect(child.signal.aborted).toBe(true) + expect(parent.signal.aborted).toBe(false) + }) - test("already-aborted parent immediately aborts child", () => { - const parent = createAbortController(); - parent.abort("pre-abort"); - const child = createChildAbortController(parent); - expect(child.signal.aborted).toBe(true); - expect(child.signal.reason).toBe("pre-abort"); - }); + test('already-aborted parent immediately aborts child', () => { + const parent = createAbortController() + parent.abort('pre-abort') + const child = createChildAbortController(parent) + expect(child.signal.aborted).toBe(true) + expect(child.signal.reason).toBe('pre-abort') + }) - test("multiple children are independent", () => { - const parent = createAbortController(); - const child1 = createChildAbortController(parent); - const child2 = createChildAbortController(parent); - child1.abort("child1"); - expect(child1.signal.aborted).toBe(true); - expect(child2.signal.aborted).toBe(false); + test('multiple children are independent', () => { + const parent = createAbortController() + const child1 = createChildAbortController(parent) + const child2 = createChildAbortController(parent) + child1.abort('child1') + expect(child1.signal.aborted).toBe(true) + expect(child2.signal.aborted).toBe(false) // Aborting child1 did not affect child2 or parent - expect(parent.signal.aborted).toBe(false); - }); + expect(parent.signal.aborted).toBe(false) + }) - test("parent abort propagates to all children", () => { - const parent = createAbortController(); - const child1 = createChildAbortController(parent); - const child2 = createChildAbortController(parent); - parent.abort("all go down"); - expect(child1.signal.aborted).toBe(true); - expect(child2.signal.aborted).toBe(true); - }); + test('parent abort propagates to all children', () => { + const parent = createAbortController() + const child1 = createChildAbortController(parent) + const child2 = createChildAbortController(parent) + parent.abort('all go down') + expect(child1.signal.aborted).toBe(true) + expect(child2.signal.aborted).toBe(true) + }) - test("grandchild abort propagation", () => { - const grandparent = createAbortController(); - const parent = createChildAbortController(grandparent); - const child = createChildAbortController(parent); - grandparent.abort("chain"); - expect(parent.signal.aborted).toBe(true); - expect(child.signal.aborted).toBe(true); - }); + test('grandchild abort propagation', () => { + const grandparent = createAbortController() + const parent = createChildAbortController(grandparent) + const child = createChildAbortController(parent) + grandparent.abort('chain') + expect(parent.signal.aborted).toBe(true) + expect(child.signal.aborted).toBe(true) + }) - test("child abort then parent abort — child stays aborted with original reason", () => { - const parent = createAbortController(); - const child = createChildAbortController(parent); - child.abort("child first"); - parent.abort("parent later"); - expect(child.signal.reason).toBe("child first"); - expect(parent.signal.reason).toBe("parent later"); - }); + test('child abort then parent abort — child stays aborted with original reason', () => { + const parent = createAbortController() + const child = createChildAbortController(parent) + child.abort('child first') + parent.abort('parent later') + expect(child.signal.reason).toBe('child first') + expect(parent.signal.reason).toBe('parent later') + }) - test("accepts custom maxListeners for child", () => { - const parent = createAbortController(); - const child = createChildAbortController(parent, 200); - expect(child.signal.aborted).toBe(false); - }); -}); + test('accepts custom maxListeners for child', () => { + const parent = createAbortController() + const child = createChildAbortController(parent, 200) + expect(child.signal.aborted).toBe(false) + }) +}) diff --git a/src/utils/__tests__/argumentSubstitution.test.ts b/src/utils/__tests__/argumentSubstitution.test.ts index 75b0b54bd..74c7040e9 100644 --- a/src/utils/__tests__/argumentSubstitution.test.ts +++ b/src/utils/__tests__/argumentSubstitution.test.ts @@ -1,145 +1,141 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test } from 'bun:test' import { parseArguments, parseArgumentNames, generateProgressiveArgumentHint, substituteArguments, -} from "../argumentSubstitution"; +} from '../argumentSubstitution' // ─── parseArguments ───────────────────────────────────────────────────── -describe("parseArguments", () => { - test("splits simple arguments", () => { - expect(parseArguments("foo bar baz")).toEqual(["foo", "bar", "baz"]); - }); +describe('parseArguments', () => { + test('splits simple arguments', () => { + expect(parseArguments('foo bar baz')).toEqual(['foo', 'bar', 'baz']) + }) - test("handles quoted strings", () => { + test('handles quoted strings', () => { expect(parseArguments('foo "hello world" baz')).toEqual([ - "foo", - "hello world", - "baz", - ]); - }); + 'foo', + 'hello world', + 'baz', + ]) + }) - test("handles single-quoted strings", () => { + test('handles single-quoted strings', () => { expect(parseArguments("foo 'hello world' baz")).toEqual([ - "foo", - "hello world", - "baz", - ]); - }); + 'foo', + 'hello world', + 'baz', + ]) + }) - test("handles escaped quotes inside quoted strings", () => { + test('handles escaped quotes inside quoted strings', () => { expect(parseArguments('foo "hello \\"world\\"" baz')).toEqual([ - "foo", + 'foo', 'hello "world"', - "baz", - ]); - }); + 'baz', + ]) + }) - test("returns empty for empty string", () => { - expect(parseArguments("")).toEqual([]); - }); + test('returns empty for empty string', () => { + expect(parseArguments('')).toEqual([]) + }) - test("returns empty for whitespace only", () => { - expect(parseArguments(" ")).toEqual([]); - }); -}); + test('returns empty for whitespace only', () => { + expect(parseArguments(' ')).toEqual([]) + }) +}) // ─── parseArgumentNames ───────────────────────────────────────────────── -describe("parseArgumentNames", () => { - test("parses space-separated string", () => { - expect(parseArgumentNames("foo bar baz")).toEqual(["foo", "bar", "baz"]); - }); +describe('parseArgumentNames', () => { + test('parses space-separated string', () => { + expect(parseArgumentNames('foo bar baz')).toEqual(['foo', 'bar', 'baz']) + }) - test("accepts array input", () => { - expect(parseArgumentNames(["foo", "bar"])).toEqual(["foo", "bar"]); - }); + test('accepts array input', () => { + expect(parseArgumentNames(['foo', 'bar'])).toEqual(['foo', 'bar']) + }) - test("filters out numeric-only names", () => { - expect(parseArgumentNames("foo 123 bar")).toEqual(["foo", "bar"]); - }); + test('filters out numeric-only names', () => { + expect(parseArgumentNames('foo 123 bar')).toEqual(['foo', 'bar']) + }) - test("filters out empty strings", () => { - expect(parseArgumentNames(["foo", "", "bar"])).toEqual(["foo", "bar"]); - }); + test('filters out empty strings', () => { + expect(parseArgumentNames(['foo', '', 'bar'])).toEqual(['foo', 'bar']) + }) - test("returns empty for undefined", () => { - expect(parseArgumentNames(undefined)).toEqual([]); - }); -}); + test('returns empty for undefined', () => { + expect(parseArgumentNames(undefined)).toEqual([]) + }) +}) // ─── generateProgressiveArgumentHint ──────────────────────────────────── -describe("generateProgressiveArgumentHint", () => { - test("shows remaining arguments", () => { - expect(generateProgressiveArgumentHint(["a", "b", "c"], ["x"])).toBe( - "[b] [c]" - ); - }); +describe('generateProgressiveArgumentHint', () => { + test('shows remaining arguments', () => { + expect(generateProgressiveArgumentHint(['a', 'b', 'c'], ['x'])).toBe( + '[b] [c]', + ) + }) - test("returns undefined when all filled", () => { - expect( - generateProgressiveArgumentHint(["a"], ["x"]) - ).toBeUndefined(); - }); + test('returns undefined when all filled', () => { + expect(generateProgressiveArgumentHint(['a'], ['x'])).toBeUndefined() + }) - test("shows all when none typed", () => { - expect(generateProgressiveArgumentHint(["a", "b"], [])).toBe("[a] [b]"); - }); -}); + test('shows all when none typed', () => { + expect(generateProgressiveArgumentHint(['a', 'b'], [])).toBe('[a] [b]') + }) +}) // ─── substituteArguments ──────────────────────────────────────────────── -describe("substituteArguments", () => { - test("replaces $ARGUMENTS with full args", () => { - expect(substituteArguments("run $ARGUMENTS", "foo bar")).toBe( - "run foo bar" - ); - }); +describe('substituteArguments', () => { + test('replaces $ARGUMENTS with full args', () => { + expect(substituteArguments('run $ARGUMENTS', 'foo bar')).toBe('run foo bar') + }) - test("replaces indexed $ARGUMENTS[0]", () => { - expect(substituteArguments("run $ARGUMENTS[0]", "foo bar")).toBe("run foo"); - }); + test('replaces indexed $ARGUMENTS[0]', () => { + expect(substituteArguments('run $ARGUMENTS[0]', 'foo bar')).toBe('run foo') + }) - test("replaces shorthand $0, $1", () => { - expect(substituteArguments("$0 and $1", "hello world")).toBe( - "hello and world" - ); - }); + test('replaces shorthand $0, $1', () => { + expect(substituteArguments('$0 and $1', 'hello world')).toBe( + 'hello and world', + ) + }) - test("replaces out-of-range index with empty string", () => { - expect(substituteArguments("$5", "hello world")).toBe(""); - }); + test('replaces out-of-range index with empty string', () => { + expect(substituteArguments('$5', 'hello world')).toBe('') + }) - test("reuses same placeholder multiple times", () => { - expect(substituteArguments("cmd $0 $1 $0", "alpha beta")).toBe( - "cmd alpha beta alpha" - ); - }); + test('reuses same placeholder multiple times', () => { + expect(substituteArguments('cmd $0 $1 $0', 'alpha beta')).toBe( + 'cmd alpha beta alpha', + ) + }) - test("replaces named arguments", () => { - expect( - substituteArguments("file: $name", "test.txt", true, ["name"]) - ).toBe("file: test.txt"); - }); + test('replaces named arguments', () => { + expect(substituteArguments('file: $name', 'test.txt', true, ['name'])).toBe( + 'file: test.txt', + ) + }) - test("returns content unchanged for undefined args", () => { - expect(substituteArguments("hello", undefined)).toBe("hello"); - }); + test('returns content unchanged for undefined args', () => { + expect(substituteArguments('hello', undefined)).toBe('hello') + }) - test("appends ARGUMENTS when no placeholder found", () => { - expect(substituteArguments("run this", "extra")).toBe( - "run this\n\nARGUMENTS: extra" - ); - }); + test('appends ARGUMENTS when no placeholder found', () => { + expect(substituteArguments('run this', 'extra')).toBe( + 'run this\n\nARGUMENTS: extra', + ) + }) - test("does not append when appendIfNoPlaceholder is false", () => { - expect(substituteArguments("run this", "extra", false)).toBe("run this"); - }); + test('does not append when appendIfNoPlaceholder is false', () => { + expect(substituteArguments('run this', 'extra', false)).toBe('run this') + }) - test("does not append for empty args string", () => { - expect(substituteArguments("run this", "")).toBe("run this"); - }); -}); + test('does not append for empty args string', () => { + expect(substituteArguments('run this', '')).toBe('run this') + }) +}) diff --git a/src/utils/__tests__/array.test.ts b/src/utils/__tests__/array.test.ts index 16f45bde9..e5d8735c6 100644 --- a/src/utils/__tests__/array.test.ts +++ b/src/utils/__tests__/array.test.ts @@ -1,58 +1,58 @@ -import { describe, expect, test } from "bun:test"; -import { count, intersperse, uniq } from "../array"; +import { describe, expect, test } from 'bun:test' +import { count, intersperse, uniq } from '../array' -describe("intersperse", () => { - test("inserts separator between elements", () => { - const result = intersperse([1, 2, 3], () => 0); - expect(result).toEqual([1, 0, 2, 0, 3]); - }); +describe('intersperse', () => { + test('inserts separator between elements', () => { + const result = intersperse([1, 2, 3], () => 0) + expect(result).toEqual([1, 0, 2, 0, 3]) + }) - test("returns empty array for empty input", () => { - expect(intersperse([], () => 0)).toEqual([]); - }); + test('returns empty array for empty input', () => { + expect(intersperse([], () => 0)).toEqual([]) + }) - test("returns single element without separator", () => { - expect(intersperse([1], () => 0)).toEqual([1]); - }); + test('returns single element without separator', () => { + expect(intersperse([1], () => 0)).toEqual([1]) + }) - test("passes index to separator function", () => { - const result = intersperse(["a", "b", "c"], (i) => `sep-${i}`); - expect(result).toEqual(["a", "sep-1", "b", "sep-2", "c"]); - }); -}); + test('passes index to separator function', () => { + const result = intersperse(['a', 'b', 'c'], i => `sep-${i}`) + expect(result).toEqual(['a', 'sep-1', 'b', 'sep-2', 'c']) + }) +}) -describe("count", () => { - test("counts matching elements", () => { - expect(count([1, 2, 3, 4, 5], (x) => x > 3)).toBe(2); - }); +describe('count', () => { + test('counts matching elements', () => { + expect(count([1, 2, 3, 4, 5], x => x > 3)).toBe(2) + }) - test("returns 0 for empty array", () => { - expect(count([], () => true)).toBe(0); - }); + test('returns 0 for empty array', () => { + expect(count([], () => true)).toBe(0) + }) - test("returns 0 when nothing matches", () => { - expect(count([1, 2, 3], (x) => x > 10)).toBe(0); - }); + test('returns 0 when nothing matches', () => { + expect(count([1, 2, 3], x => x > 10)).toBe(0) + }) - test("counts all when everything matches", () => { - expect(count([1, 2, 3], () => true)).toBe(3); - }); -}); + test('counts all when everything matches', () => { + expect(count([1, 2, 3], () => true)).toBe(3) + }) +}) -describe("uniq", () => { - test("removes duplicates", () => { - expect(uniq([1, 2, 2, 3, 3, 3])).toEqual([1, 2, 3]); - }); +describe('uniq', () => { + test('removes duplicates', () => { + expect(uniq([1, 2, 2, 3, 3, 3])).toEqual([1, 2, 3]) + }) - test("preserves order of first occurrence", () => { - expect(uniq([3, 1, 2, 1, 3])).toEqual([3, 1, 2]); - }); + test('preserves order of first occurrence', () => { + expect(uniq([3, 1, 2, 1, 3])).toEqual([3, 1, 2]) + }) - test("handles empty array", () => { - expect(uniq([])).toEqual([]); - }); + test('handles empty array', () => { + expect(uniq([])).toEqual([]) + }) - test("works with strings", () => { - expect(uniq(["a", "b", "a"])).toEqual(["a", "b"]); - }); -}); + test('works with strings', () => { + expect(uniq(['a', 'b', 'a'])).toEqual(['a', 'b']) + }) +}) diff --git a/src/utils/__tests__/bufferedWriter.test.ts b/src/utils/__tests__/bufferedWriter.test.ts index d5d6ab35f..80039938b 100644 --- a/src/utils/__tests__/bufferedWriter.test.ts +++ b/src/utils/__tests__/bufferedWriter.test.ts @@ -1,117 +1,117 @@ -import { describe, expect, test } from "bun:test"; -import { createBufferedWriter } from "../bufferedWriter"; +import { describe, expect, test } from 'bun:test' +import { createBufferedWriter } from '../bufferedWriter' -describe("createBufferedWriter", () => { - test("immediateMode calls writeFn directly", () => { - const written: string[] = []; +describe('createBufferedWriter', () => { + test('immediateMode calls writeFn directly', () => { + const written: string[] = [] const writer = createBufferedWriter({ - writeFn: (c) => written.push(c), + writeFn: c => written.push(c), immediateMode: true, - }); - writer.write("a"); - writer.write("b"); - expect(written).toEqual(["a", "b"]); - }); + }) + writer.write('a') + writer.write('b') + expect(written).toEqual(['a', 'b']) + }) - test("buffered mode accumulates until flush", () => { - const written: string[] = []; + test('buffered mode accumulates until flush', () => { + const written: string[] = [] const writer = createBufferedWriter({ - writeFn: (c) => written.push(c), - }); - writer.write("hello "); - writer.write("world"); - expect(written).toEqual([]); - writer.flush(); - expect(written).toEqual(["hello world"]); - }); + writeFn: c => written.push(c), + }) + writer.write('hello ') + writer.write('world') + expect(written).toEqual([]) + writer.flush() + expect(written).toEqual(['hello world']) + }) - test("flush with empty buffer does not call writeFn", () => { - const written: string[] = []; + test('flush with empty buffer does not call writeFn', () => { + const written: string[] = [] const writer = createBufferedWriter({ - writeFn: (c) => written.push(c), - }); - writer.flush(); - expect(written).toEqual([]); - }); + writeFn: c => written.push(c), + }) + writer.flush() + expect(written).toEqual([]) + }) - test("flush clears the buffer", () => { - const written: string[] = []; + test('flush clears the buffer', () => { + const written: string[] = [] const writer = createBufferedWriter({ - writeFn: (c) => written.push(c), - }); - writer.write("data"); - writer.flush(); - writer.flush(); // second flush should be no-op - expect(written).toEqual(["data"]); - }); + writeFn: c => written.push(c), + }) + writer.write('data') + writer.flush() + writer.flush() // second flush should be no-op + expect(written).toEqual(['data']) + }) - test("overflow triggers deferred flush when maxBufferSize reached", () => { - const written: string[] = []; + test('overflow triggers deferred flush when maxBufferSize reached', () => { + const written: string[] = [] const writer = createBufferedWriter({ - writeFn: (c) => written.push(c), + writeFn: c => written.push(c), maxBufferSize: 2, - }); - writer.write("a"); - writer.write("b"); + }) + writer.write('a') + writer.write('b') // 2 writes = maxBufferSize, triggers flushDeferred via setImmediate - expect(written).toEqual([]); - }); + expect(written).toEqual([]) + }) - test("overflow triggers deferred flush when maxBufferBytes reached", () => { - const written: string[] = []; + test('overflow triggers deferred flush when maxBufferBytes reached', () => { + const written: string[] = [] const writer = createBufferedWriter({ - writeFn: (c) => written.push(c), + writeFn: c => written.push(c), maxBufferBytes: 5, - }); - writer.write("abc"); - writer.write("def"); + }) + writer.write('abc') + writer.write('def') // total 6 bytes > 5, triggers flushDeferred - expect(written).toEqual([]); - }); + expect(written).toEqual([]) + }) - test("dispose flushes remaining buffer", () => { - const written: string[] = []; + test('dispose flushes remaining buffer', () => { + const written: string[] = [] const writer = createBufferedWriter({ - writeFn: (c) => written.push(c), - }); - writer.write("final"); - writer.dispose(); - expect(written).toEqual(["final"]); - }); + writeFn: c => written.push(c), + }) + writer.write('final') + writer.dispose() + expect(written).toEqual(['final']) + }) - test("dispose flushes pending overflow", () => { - const written: string[] = []; + test('dispose flushes pending overflow', () => { + const written: string[] = [] const writer = createBufferedWriter({ - writeFn: (c) => written.push(c), + writeFn: c => written.push(c), maxBufferSize: 1, - }); - writer.write("overflow-data"); + }) + writer.write('overflow-data') // overflow triggered but deferred; dispose should flush it synchronously - writer.dispose(); - expect(written).toEqual(["overflow-data"]); - }); + writer.dispose() + expect(written).toEqual(['overflow-data']) + }) - test("coalesced overflow — multiple overflows merge before write", () => { - const written: string[] = []; + test('coalesced overflow — multiple overflows merge before write', () => { + const written: string[] = [] const writer = createBufferedWriter({ - writeFn: (c) => written.push(c), + writeFn: c => written.push(c), maxBufferSize: 1, - }); - writer.write("a"); // triggers first overflow (deferred) - writer.write("b"); // pendingOverflow exists, coalesces - writer.dispose(); // flushes coalesced overflow - expect(written).toEqual(["ab"]); - }); + }) + writer.write('a') // triggers first overflow (deferred) + writer.write('b') // pendingOverflow exists, coalesces + writer.dispose() // flushes coalesced overflow + expect(written).toEqual(['ab']) + }) - test("multiple flushes produce concatenated writes", () => { - const written: string[] = []; + test('multiple flushes produce concatenated writes', () => { + const written: string[] = [] const writer = createBufferedWriter({ - writeFn: (c) => written.push(c), - }); - writer.write("batch1"); - writer.flush(); - writer.write("batch2"); - writer.flush(); - expect(written).toEqual(["batch1", "batch2"]); - }); -}); + writeFn: c => written.push(c), + }) + writer.write('batch1') + writer.flush() + writer.write('batch2') + writer.flush() + expect(written).toEqual(['batch1', 'batch2']) + }) +}) diff --git a/src/utils/__tests__/claudemd.test.ts b/src/utils/__tests__/claudemd.test.ts index b514332c9..2d2edae21 100644 --- a/src/utils/__tests__/claudemd.test.ts +++ b/src/utils/__tests__/claudemd.test.ts @@ -1,148 +1,148 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test } from 'bun:test' import { stripHtmlComments, isMemoryFilePath, getLargeMemoryFiles, MAX_MEMORY_CHARACTER_COUNT, type MemoryFileInfo, -} from "../claudemd"; +} from '../claudemd' -function mockMemoryFile(overrides: Partial = {}): MemoryFileInfo { +function mockMemoryFile( + overrides: Partial = {}, +): MemoryFileInfo { return { - path: "/project/CLAUDE.md", - type: "Project", - content: "test content", + path: '/project/CLAUDE.md', + type: 'Project', + content: 'test content', ...overrides, - }; + } } -describe("stripHtmlComments", () => { - test("strips block-level HTML comments (own line)", () => { +describe('stripHtmlComments', () => { + test('strips block-level HTML comments (own line)', () => { // CommonMark type-2 HTML blocks: comment must start at beginning of line - const result = stripHtmlComments("text\n\nmore"); - expect(result.content).not.toContain("block comment"); - expect(result.stripped).toBe(true); - }); + const result = stripHtmlComments('text\n\nmore') + expect(result.content).not.toContain('block comment') + expect(result.stripped).toBe(true) + }) - test("returns stripped: false when no comments", () => { - const result = stripHtmlComments("no comments here"); - expect(result.stripped).toBe(false); - expect(result.content).toBe("no comments here"); - }); + test('returns stripped: false when no comments', () => { + const result = stripHtmlComments('no comments here') + expect(result.stripped).toBe(false) + expect(result.content).toBe('no comments here') + }) - test("returns stripped: true when block comments exist", () => { - const result = stripHtmlComments("hello\n\nend"); - expect(result.stripped).toBe(true); - }); + test('returns stripped: true when block comments exist', () => { + const result = stripHtmlComments('hello\n\nend') + expect(result.stripped).toBe(true) + }) - test("handles empty string", () => { - const result = stripHtmlComments(""); - expect(result.content).toBe(""); - expect(result.stripped).toBe(false); - }); + test('handles empty string', () => { + const result = stripHtmlComments('') + expect(result.content).toBe('') + expect(result.stripped).toBe(false) + }) - test("handles multiple block comments", () => { - const result = stripHtmlComments( - "a\n\nb\n\nc" - ); - expect(result.content).not.toContain("c1"); - expect(result.content).not.toContain("c2"); - expect(result.stripped).toBe(true); - }); + test('handles multiple block comments', () => { + const result = stripHtmlComments('a\n\nb\n\nc') + expect(result.content).not.toContain('c1') + expect(result.content).not.toContain('c2') + expect(result.stripped).toBe(true) + }) - test("preserves code block content", () => { - const input = "text\n```html\n\n```\nmore"; - const result = stripHtmlComments(input); - expect(result.content).toContain(""); - }); + test('preserves code block content', () => { + const input = 'text\n```html\n\n```\nmore' + const result = stripHtmlComments(input) + expect(result.content).toContain('') + }) - test("preserves inline comments within paragraphs", () => { + test('preserves inline comments within paragraphs', () => { // Inline comments are NOT stripped (CommonMark paragraph semantics) - const result = stripHtmlComments("text more"); - expect(result.content).toContain(""); - expect(result.stripped).toBe(false); - }); + const result = stripHtmlComments('text more') + expect(result.content).toContain('') + expect(result.stripped).toBe(false) + }) - test("leaves unclosed HTML comment unchanged", () => { - const result = stripHtmlComments("some text"); - expect(result.content).toContain("some text"); - expect(result.content).not.toContain("some text') + expect(result.content).toContain('some text') + expect(result.content).not.toContain('