diff --git a/CLAUDE.md b/CLAUDE.md index c3ada0762..4cd5556ff 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -180,7 +180,7 @@ Feature flag `VOICE_MODE`,dev/build 默认启用。Push-to-Talk 语音输入 - **`src/services/api/openai/`** — client、消息/工具转换、流适配、模型映射 - **`src/utils/model/providers.ts`** — 添加 `'openai'` provider 类型(最高优先级) -关键环境变量:`CLAUDE_CODE_USE_OPENAI`、`OPENAI_API_KEY`、`OPENAI_BASE_URL`、`OPENAI_MODEL`、`OPENAI_MODEL_MAP`。详见 `docs/plans/openai-compatibility.md`。 +关键环境变量:`CLAUDE_CODE_USE_OPENAI`、`OPENAI_API_KEY`、`OPENAI_BASE_URL`、`OPENAI_MODEL`、`OPENAI_DEFAULT_OPUS_MODEL`、`OPENAI_DEFAULT_SONNET_MODEL`、`OPENAI_DEFAULT_HAIKU_MODEL`。详见 `docs/plans/openai-compatibility.md`。 ### Key Type Files diff --git a/docs/plans/openai-compatibility.md b/docs/plans/openai-compatibility.md index 68fa9f158..a32213fce 100644 --- a/docs/plans/openai-compatibility.md +++ b/docs/plans/openai-compatibility.md @@ -14,7 +14,9 @@ claude-code 支持通过 OpenAI Chat Completions API(`/v1/chat/completions`) | `OPENAI_API_KEY` | 是 | API key(Ollama 等可设为任意值) | | `OPENAI_BASE_URL` | 推荐 | 端点 URL(如 `http://localhost:11434/v1`) | | `OPENAI_MODEL` | 可选 | 覆盖所有请求的模型名(跳过映射) | -| `OPENAI_MODEL_MAP` | 可选 | JSON 映射,如 `{"claude-sonnet-4-6":"gpt-4o"}` | +| `OPENAI_DEFAULT_OPUS_MODEL` | 可选 | 覆盖 opus 家族对应的模型(如 `o3`, `o3-mini`, `o1-pro`) | +| `OPENAI_DEFAULT_SONNET_MODEL` | 可选 | 覆盖 sonnet 家族对应的模型(如 `gpt-4o`, `gpt-4.1`) | +| `OPENAI_DEFAULT_HAIKU_MODEL` | 可选 | 覆盖 haiku 家族对应的模型(如 `gpt-4o-mini`, `gpt-4.0-mini`) | | `OPENAI_ORG_ID` | 可选 | Organization ID | | `OPENAI_PROJECT_ID` | 可选 | Project ID | @@ -85,9 +87,10 @@ queryModel() [claude.ts] `resolveOpenAIModel()` 的解析顺序: 1. `OPENAI_MODEL` 环境变量 → 直接使用,覆盖所有 -2. `OPENAI_MODEL_MAP` JSON 查表 → 自定义映射 -3. 内置默认映射(见下表) -4. 以上都不匹配 → 原名透传 +2. `OPENAI_DEFAULT_{FAMILY}_MODEL` 变量(如 `OPENAI_DEFAULT_SONNET_MODEL`)→ 按模型家族覆盖 +3. `ANTHROPIC_DEFAULT_{FAMILY}_MODEL` 变量(向后兼容) +4. 内置默认映射(见下表) +5. 以上都不匹配 → 原名透传 ### 内置模型映射 diff --git a/src/QueryEngine.ts b/src/QueryEngine.ts index 1166151d1..e6be79f45 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 './tools/AgentTool/loadAgentsDir.js' import { SYNTHETIC_OUTPUT_TOOL_NAME } from './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, diff --git a/src/assistant/AssistantSessionChooser.ts b/src/assistant/AssistantSessionChooser.ts index e61ba6ced..382f5b1b0 100644 --- a/src/assistant/AssistantSessionChooser.ts +++ b/src/assistant/AssistantSessionChooser.ts @@ -1,3 +1,4 @@ // Auto-generated stub — replace with real implementation -export {}; -export const AssistantSessionChooser: (props: Record) => null = () => null; +export {} +export const AssistantSessionChooser: (props: Record) => null = + () => null diff --git a/src/assistant/gate.ts b/src/assistant/gate.ts index c08265c2d..9e3222722 100644 --- a/src/assistant/gate.ts +++ b/src/assistant/gate.ts @@ -1,3 +1,4 @@ // Auto-generated stub — replace with real implementation -export {}; -export const isKairosEnabled: () => Promise = () => Promise.resolve(false); +export {} +export const isKairosEnabled: () => Promise = () => + Promise.resolve(false) diff --git a/src/assistant/index.ts b/src/assistant/index.ts index 3e23f69d9..5b75255ad 100644 --- a/src/assistant/index.ts +++ b/src/assistant/index.ts @@ -1,8 +1,9 @@ // Auto-generated stub — replace with real implementation -export {}; -export const isAssistantMode: () => boolean = () => false; -export const initializeAssistantTeam: () => Promise = async () => {}; -export const markAssistantForced: () => void = () => {}; -export const isAssistantForced: () => boolean = () => false; -export const getAssistantSystemPromptAddendum: () => string = () => ''; -export const getAssistantActivationPath: () => string | undefined = () => undefined; +export {} +export const isAssistantMode: () => boolean = () => false +export const initializeAssistantTeam: () => Promise = async () => {} +export const markAssistantForced: () => void = () => {} +export const isAssistantForced: () => boolean = () => false +export const getAssistantSystemPromptAddendum: () => string = () => '' +export const getAssistantActivationPath: () => string | undefined = () => + undefined diff --git a/src/assistant/sessionDiscovery.ts b/src/assistant/sessionDiscovery.ts index 424564c1c..e28e6eda0 100644 --- a/src/assistant/sessionDiscovery.ts +++ b/src/assistant/sessionDiscovery.ts @@ -1,3 +1,4 @@ // Auto-generated stub — replace with real implementation -export type AssistantSession = { id: string; [key: string]: unknown }; -export const discoverAssistantSessions: () => Promise = () => Promise.resolve([]); +export type AssistantSession = { id: string; [key: string]: unknown } +export const discoverAssistantSessions: () => Promise = + () => Promise.resolve([]) 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 e160e16f9..9601b0b95 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/bridgeMessaging.ts b/src/bridge/bridgeMessaging.ts index f5d37f779..464776ac6 100644 --- a/src/bridge/bridgeMessaging.ts +++ b/src/bridge/bridgeMessaging.ts @@ -103,7 +103,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') { @@ -265,7 +266,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', 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/remoteBridgeCore.ts b/src/bridge/remoteBridgeCore.ts index d1886cc83..90f60653a 100644 --- a/src/bridge/remoteBridgeCore.ts +++ b/src/bridge/remoteBridgeCore.ts @@ -829,7 +829,10 @@ export async function initEnvLessBridgeCore( return } const event = { ...request, session_id: sessionId } - if ((request as { request?: { subtype?: string } }).request?.subtype === 'can_use_tool') { + if ( + (request as { request?: { subtype?: string } }).request?.subtype === + 'can_use_tool' + ) { transport.reportState('requires_action') } void transport.write(event) diff --git a/src/bridge/replBridge.ts b/src/bridge/replBridge.ts index c19cb0811..13f9a2bf7 100644 --- a/src/bridge/replBridge.ts +++ b/src/bridge/replBridge.ts @@ -438,7 +438,6 @@ export async function initBridgeCore( // re-created after a connection loss. let currentSessionId: string - if (reusedPriorSession && prior) { currentSessionId = prior.sessionId logForDebugging( @@ -826,7 +825,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. 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 d8c7ae473..1da9c7d92 100644 --- a/src/buddy/CompanionSprite.tsx +++ b/src/buddy/CompanionSprite.tsx @@ -1,51 +1,51 @@ -import { feature } from 'bun:bundle' -import figures from 'figures' -import React, { useEffect, useRef, useState } from 'react' -import { useTerminalSize } from '../hooks/useTerminalSize.js' -import { stringWidth } from '../ink/stringWidth.js' -import { Box, Text } from '../ink.js' -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 { stringWidth } from '../ink/stringWidth.js'; +import { Box, Text } from '../ink.js'; +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({ @@ -54,40 +54,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 ( @@ -97,18 +86,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 @@ -116,89 +105,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 ( @@ -211,44 +184,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 @@ -256,31 +219,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 @@ -289,19 +241,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 @@ -309,33 +256,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 62d61f4cf..d4d2e4a63 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 '../ink.js' -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 '../ink.js'; +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 709e7df9e..d5d451fb9 100644 --- a/src/cli/bg.ts +++ b/src/cli/bg.ts @@ -1,7 +1,12 @@ // Auto-generated stub — replace with real implementation -export {}; -export const psHandler: (args: string[]) => Promise = (async () => {}) as (args: string[]) => Promise; -export const logsHandler: (sessionId: string | undefined) => Promise = (async () => {}) as (sessionId: string | undefined) => Promise; -export const attachHandler: (sessionId: string | undefined) => Promise = (async () => {}) as (sessionId: string | undefined) => Promise; -export const killHandler: (sessionId: string | undefined) => Promise = (async () => {}) as (sessionId: string | undefined) => Promise; -export const handleBgFlag: (args: string[]) => Promise = (async () => {}) as (args: string[]) => Promise; +export {} +export const psHandler: (args: string[]) => Promise = + (async () => {}) as (args: string[]) => Promise +export const logsHandler: (sessionId: string | undefined) => Promise = + (async () => {}) as (sessionId: string | undefined) => Promise +export const attachHandler: (sessionId: string | undefined) => Promise = + (async () => {}) as (sessionId: string | undefined) => Promise +export const killHandler: (sessionId: string | undefined) => Promise = + (async () => {}) as (sessionId: string | undefined) => Promise +export const handleBgFlag: (args: string[]) => Promise = + (async () => {}) as (args: string[]) => Promise diff --git a/src/cli/handlers/ant.ts b/src/cli/handlers/ant.ts index 74e53359f..413819df7 100644 --- a/src/cli/handlers/ant.ts +++ b/src/cli/handlers/ant.ts @@ -1,13 +1,70 @@ // Auto-generated stub — replace with real implementation -import type { Command } from '@commander-js/extra-typings'; +import type { Command } from '@commander-js/extra-typings' -export {}; -export const logHandler: (logId: string | number | undefined) => Promise = (async () => {}) as (logId: string | number | undefined) => Promise; -export const errorHandler: (num: number | undefined) => Promise = (async () => {}) as (num: number | undefined) => Promise; -export const exportHandler: (source: string, outputFile: string) => Promise = (async () => {}) as (source: string, outputFile: string) => Promise; -export const taskCreateHandler: (subject: string, opts: { description?: string; list?: string }) => Promise = (async () => {}) as (subject: string, opts: { description?: string; list?: string }) => Promise; -export const taskListHandler: (opts: { list?: string; pending?: boolean; json?: boolean }) => Promise = (async () => {}) as (opts: { list?: string; pending?: boolean; json?: boolean }) => Promise; -export const taskGetHandler: (id: string, opts: { list?: string }) => Promise = (async () => {}) as (id: string, opts: { list?: string }) => Promise; -export const taskUpdateHandler: (id: string, opts: { list?: string; status?: string; subject?: string; description?: string; owner?: string; clearOwner?: boolean }) => Promise = (async () => {}) as (id: string, opts: { list?: string; status?: string; subject?: string; description?: string; owner?: string; clearOwner?: boolean }) => Promise; -export const taskDirHandler: (opts: { list?: string }) => Promise = (async () => {}) as (opts: { list?: string }) => Promise; -export const completionHandler: (shell: string, opts: { output?: string }, program: Command) => Promise = (async () => {}) as (shell: string, opts: { output?: string }, program: Command) => Promise; +export {} +export const logHandler: (logId: string | number | undefined) => Promise = + (async () => {}) as (logId: string | number | undefined) => Promise +export const errorHandler: (num: number | undefined) => Promise = + (async () => {}) as (num: number | undefined) => Promise +export const exportHandler: ( + source: string, + outputFile: string, +) => Promise = (async () => {}) as ( + source: string, + outputFile: string, +) => Promise +export const taskCreateHandler: ( + subject: string, + opts: { description?: string; list?: string }, +) => Promise = (async () => {}) as ( + subject: string, + opts: { description?: string; list?: string }, +) => Promise +export const taskListHandler: (opts: { + list?: string + pending?: boolean + json?: boolean +}) => Promise = (async () => {}) as (opts: { + list?: string + pending?: boolean + json?: boolean +}) => Promise +export const taskGetHandler: ( + id: string, + opts: { list?: string }, +) => Promise = (async () => {}) as ( + id: string, + opts: { list?: string }, +) => Promise +export const taskUpdateHandler: ( + id: string, + opts: { + list?: string + status?: string + subject?: string + description?: string + owner?: string + clearOwner?: boolean + }, +) => Promise = (async () => {}) as ( + id: string, + opts: { + list?: string + status?: string + subject?: string + description?: string + owner?: string + clearOwner?: boolean + }, +) => Promise +export const taskDirHandler: (opts: { list?: string }) => Promise = + (async () => {}) as (opts: { list?: string }) => Promise +export const completionHandler: ( + shell: string, + opts: { output?: string }, + program: Command, +) => Promise = (async () => {}) as ( + shell: string, + opts: { output?: string }, + program: Command, +) => Promise 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 134918c75..fc181f9c5 100644 --- a/src/cli/handlers/mcp.tsx +++ b/src/cli/handlers/mcp.tsx @@ -3,203 +3,165 @@ * 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 { render } from '../../ink.js' -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 { render } from '../../ink.js'; +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' - -async function checkMcpServerHealth( - name: string, - server: ScopedMcpServerConfig, -): Promise { +} 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 { 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, - }) - - await removeMcpConfig(name, scope) - cleanupSecureStorage() - process.stdout.write(`Removed MCP server ${name} from ${scope} config\n`) - cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`) + 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)}`); } // 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, - }) - - await removeMcpConfig(name, scope) - cleanupSecureStorage() - process.stdout.write( - `Removed MCP server "${name}" from ${scope} config\n`, - ) - cliOk(`File modified: ${describeMcpConfigFilePath(scope)}`) + 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)}`); } 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,126 +170,122 @@ 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 args = Array.isArray(server.args) ? server.args : [] + const args = Array.isArray(server.args) ? server.args : []; // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(`${name}: ${server.command} ${args.join(' ')} - ${status}`) + console.log(`${name}: ${server.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}:`) + console.log(`${name}:`); // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` Scope: ${getScopeLabel(server.scope)}`) + console.log(` Scope: ${getScopeLabel(server.scope)}`); // Check server health - const status = await checkMcpServerHealth(name, server) + const status = await checkMcpServerHealth(name, server); // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` Status: ${status}`) + 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`) + console.log(` Type: sse`); // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` URL: ${server.url}`) + 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}`) + if (server.oauth.callbackPort) parts.push(`callback_port ${server.oauth.callbackPort}`); // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` OAuth: ${parts.join(', ')}`) + console.log(` OAuth: ${parts.join(', ')}`); } } else if (server.type === 'http') { // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` Type: http`) + console.log(` Type: http`); // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` URL: ${server.url}`) + 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}`) + if (server.oauth.callbackPort) parts.push(`callback_port ${server.oauth.callbackPort}`); // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` OAuth: ${parts.join(', ')}`) + console.log(` OAuth: ${parts.join(', ')}`); } } else if (server.type === 'stdio') { // biome-ignore lint/suspicious/noConsole:: intentional console output - console.log(` Type: stdio`) + 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 : [] + 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(` 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) @@ -337,8 +295,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 = @@ -352,15 +310,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 && @@ -371,53 +329,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, - }) - - const { readClaudeDesktopMcpServers } = await import( - '../../utils/claudeDesktop.js' - ) - const servers = await readClaudeDesktopMcpServers() + 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(); 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( @@ -427,29 +370,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/templateJobs.ts b/src/cli/handlers/templateJobs.ts index aefb23217..68b8c66b3 100644 --- a/src/cli/handlers/templateJobs.ts +++ b/src/cli/handlers/templateJobs.ts @@ -1,3 +1,4 @@ // Auto-generated stub — replace with real implementation -export {}; -export const templatesMain: (args: string[]) => Promise = () => Promise.resolve(); +export {} +export const templatesMain: (args: string[]) => Promise = () => + Promise.resolve() diff --git a/src/cli/handlers/util.tsx b/src/cli/handlers/util.tsx index c86b31737..08cdf186e 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 '../../ink.js' -import { Box, Text } from '../../ink.js' -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 '../../ink.js'; +import { Box, Text } from '../../ink.js'; +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/print.ts b/src/cli/print.ts index 644753119..b0e425e32 100644 --- a/src/cli/print.ts +++ b/src/cli/print.ts @@ -362,9 +362,12 @@ const proactiveModule = feature('PROACTIVE') || feature('KAIROS') ? (require('../proactive/index.js') as typeof import('../proactive/index.js')) : null -const cronSchedulerModule = require('../utils/cronScheduler.js') as typeof import('../utils/cronScheduler.js') -const cronJitterConfigModule = require('../utils/cronJitterConfig.js') as typeof import('../utils/cronJitterConfig.js') -const cronGate = require('../tools/ScheduleCronTool/prompt.js') as typeof import('../tools/ScheduleCronTool/prompt.js') +const cronSchedulerModule = + require('../utils/cronScheduler.js') as typeof import('../utils/cronScheduler.js') +const cronJitterConfigModule = + require('../utils/cronJitterConfig.js') as typeof import('../utils/cronJitterConfig.js') +const cronGate = + require('../tools/ScheduleCronTool/prompt.js') as typeof import('../tools/ScheduleCronTool/prompt.js') const extractMemoriesModule = feature('EXTRACT_MEMORIES') ? (require('../services/extractMemories/extractMemories.js') as typeof import('../services/extractMemories/extractMemories.js')) : null @@ -1642,7 +1645,10 @@ function runHeadlessStreaming( connection.config.type === 'stdio' || connection.config.type === undefined ) { - const stdioConfig = connection.config as { command: string; args: string[] } + const stdioConfig = connection.config as { + command: string + args: string[] + } config = { type: 'stdio' as const, command: stdioConfig.command, @@ -1804,7 +1810,8 @@ function runHeadlessStreaming( } for (const [name, config] of Object.entries(sdkMcpConfigs)) { if (config.type === 'sdk' && !(name in supportedConfigs)) { - supportedConfigs[name] = config as unknown as McpServerConfigForProcessTransport + supportedConfigs[name] = + config as unknown as McpServerConfigForProcessTransport } } const { response, sdkServersChanged } = @@ -2253,7 +2260,9 @@ function runHeadlessStreaming( if (feature('FILE_PERSISTENCE') && turnStartTime !== undefined) { void executeFilePersistence( - { turnStartTime } as import('src/utils/filePersistence/types.js').TurnStartTime, + { + turnStartTime, + } as import('src/utils/filePersistence/types.js').TurnStartTime, abortController.signal, result => { output.enqueue({ @@ -2699,9 +2708,7 @@ function runHeadlessStreaming( // the end of run() picks up the queued command. let cronScheduler: import('../utils/cronScheduler.js').CronScheduler | null = null - if ( - cronGate.isKairosCronEnabled() - ) { + if (cronGate.isKairosCronEnabled()) { cronScheduler = cronSchedulerModule.createCronScheduler({ onFire: prompt => { if (inputClosed) return @@ -4431,7 +4438,10 @@ async function handleInitializeRequest( const accountInfo = getAccountInformation() if (request.hooks) { const hooks: Partial> = {} - for (const [event, matchers] of Object.entries(request.hooks) as [string, Array<{ hookCallbackIds: string[]; timeout?: number; matcher?: string }>][]) { + for (const [event, matchers] of Object.entries(request.hooks) as [ + string, + Array<{ hookCallbackIds: string[]; timeout?: number; matcher?: string }>, + ][]) { hooks[event as HookEvent] = matchers.map(matcher => { const callbacks = matcher.hookCallbackIds.map(callbackId => { return structuredIO.createHookCallback(callbackId, matcher.timeout) @@ -4521,7 +4531,11 @@ async function handleRewindFiles( dryRun: boolean, ): Promise { if (!fileHistoryEnabled()) { - return { canRewind: false, error: 'File rewinding is not enabled.', filesChanged: [] } + return { + canRewind: false, + error: 'File rewinding is not enabled.', + filesChanged: [], + } } if (!fileHistoryCanRestore(appState.fileHistory, userMessageId)) { return { @@ -4826,7 +4840,10 @@ function reregisterChannelHandlerAfterReconnect( value: wrapChannelMessage(connection.name, content, meta), priority: 'next', isMeta: true, - origin: { kind: 'channel', server: connection.name } as unknown as string, + origin: { + kind: 'channel', + server: connection.name, + } as unknown as string, skipSlashCommands: true, }) }, @@ -5250,13 +5267,21 @@ export async function handleOrphanedPermissionResponse({ onEnqueued?: () => void handledToolUseIds: Set }): Promise { - const responseInner = message.response as { subtype?: string; response?: Record; request_id?: string } | undefined + const responseInner = message.response as + | { + subtype?: string + response?: Record + request_id?: string + } + | undefined if ( responseInner?.subtype === 'success' && responseInner.response?.toolUseID && typeof responseInner.response.toolUseID === 'string' ) { - const permissionResult = responseInner.response as PermissionResult & { toolUseID?: string } + const permissionResult = responseInner.response as PermissionResult & { + toolUseID?: string + } const toolUseID = permissionResult.toolUseID if (!toolUseID) { return false diff --git a/src/cli/rollback.ts b/src/cli/rollback.ts index c93ab4124..1052b0368 100644 --- a/src/cli/rollback.ts +++ b/src/cli/rollback.ts @@ -1,2 +1,5 @@ // Auto-generated stub -export async function rollback(target?: string, options?: { list?: boolean; dryRun?: boolean; safe?: boolean }): Promise {} +export async function rollback( + target?: string, + options?: { list?: boolean; dryRun?: boolean; safe?: boolean }, +): Promise {} 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 d5cedfa2d..1c5e65601 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) @@ -408,7 +417,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(message.response.request_id) @@ -490,7 +500,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 = () => { @@ -822,7 +835,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/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/ccrClient.ts b/src/cli/transports/ccrClient.ts index 3e40ae1bb..8fcd6f3bd 100644 --- a/src/cli/transports/ccrClient.ts +++ b/src/cli/transports/ccrClient.ts @@ -427,7 +427,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, 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.ts b/src/commands.ts index 8cae75452..495cbd210 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -150,6 +150,7 @@ import sandboxToggle from './commands/sandbox-toggle/index.js' import chrome from './commands/chrome/index.js' import stickers from './commands/stickers/index.js' import advisor from './commands/advisor.js' +import provider from './commands/provider.js' import { logError } from './utils/log.js' import { toError } from './utils/errors.js' import { logForDebugging } from './utils/debug.js' @@ -258,6 +259,7 @@ export const INTERNAL_ONLY_COMMANDS = [ const COMMANDS = memoize((): Command[] => [ addDir, advisor, + provider, agents, branch, btw, diff --git a/src/commands/add-dir/add-dir.tsx b/src/commands/add-dir/add-dir.tsx index cfb6c6687..7ae6b806f 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 '../../ink.js' -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 '../../ink.js'; +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/assistant/assistant.ts b/src/commands/assistant/assistant.ts index 80a04ca62..c7d715e7f 100644 --- a/src/commands/assistant/assistant.ts +++ b/src/commands/assistant/assistant.ts @@ -1,11 +1,12 @@ // Auto-generated stub — replace with real implementation -import type React from 'react'; +import type React from 'react' -export {}; +export {} export const NewInstallWizard: React.FC<{ - defaultDir: string; - onInstalled: (dir: string) => void; - onCancel: () => void; - onError: (message: string) => void; -}> = (() => null); -export const computeDefaultInstallDir: () => Promise = (() => Promise.resolve('')); + defaultDir: string + onInstalled: (dir: string) => void + onCancel: () => void + onError: (message: string) => void +}> = () => null +export const computeDefaultInstallDir: () => Promise = () => + Promise.resolve('') diff --git a/src/commands/branch/branch.ts b/src/commands/branch/branch.ts index a4742bf92..f58e156fa 100644 --- a/src/commands/branch/branch.ts +++ b/src/commands/branch/branch.ts @@ -240,7 +240,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 33a681202..18ad71927 100644 --- a/src/commands/bridge/bridge.tsx +++ b/src/commands/bridge/bridge.tsx @@ -1,40 +1,30 @@ -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 } from '../../components/design-system/Dialog.js' -import { ListItem } from '../../components/design-system/ListItem.js' -import { shouldShowRemoteCallout } from '../../components/RemoteCallout.js' -import { useRegisterOverlay } from '../../context/overlayContext.js' -import { Box, Text } from '../../ink.js' -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 } from '../../components/design-system/Dialog.js'; +import { ListItem } from '../../components/design-system/ListItem.js'; +import { shouldShowRemoteCallout } from '../../components/RemoteCallout.js'; +import { useRegisterOverlay } from '../../context/overlayContext.js'; +import { Box, Text } from '../../ink.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 { 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. @@ -49,11 +39,11 @@ 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(() => { @@ -61,23 +51,22 @@ function BridgeToggle({ onDone, name }: Props): React.ReactNode { // 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. @@ -85,48 +74,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; } /** @@ -134,22 +122,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', @@ -157,55 +145,53 @@ function BridgeDisconnectDialog({ onDone }: Props): React.ReactNode { small: true, }) .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 ( @@ -235,7 +221,7 @@ function BridgeDisconnectDialog({ onDone }: Props): React.ReactNode { Enter to select · Esc to continue - ) + ); } /** @@ -246,43 +232,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( @@ -290,6 +272,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 28a83946b..f05ca5d02 100644 --- a/src/commands/btw/btw.tsx +++ b/src/commands/btw/btw.tsx @@ -1,121 +1,97 @@ -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 ScrollBox, { - type ScrollBoxHandle, -} from '../../ink/components/ScrollBox.js' -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' -import { Box, Text } from '../../ink.js' -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 ScrollBox, { type ScrollBoxHandle } from '../../ink/components/ScrollBox.js'; +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; +import { Box, Text } from '../../ink.js'; +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 -} - -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()) + question: string; + context: ProcessUserInputContext; + onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void; +}; + +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()); // 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{' '} @@ -139,13 +115,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 )} - ) + ); } /** @@ -164,20 +139,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, @@ -185,25 +156,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( @@ -211,19 +177,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/chrome/chrome.tsx b/src/commands/chrome/chrome.tsx index 3fd0dbca3..db5839e9c 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 '../../components/design-system/Dialog.js' -import { Box, Text } from '../../ink.js' -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' - -type MenuAction = - | 'install-extension' - | 'reconnect' - | 'manage-permissions' - | 'toggle-default' +import React, { useState } from 'react'; +import { type OptionWithDescription, Select } from '../../components/CustomSelect/select.js'; +import { Dialog } from '../../components/design-system/Dialog.js'; +import { Box, Text } from '../../ink.js'; +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'; + +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 || ("external" !== 'ant' && !isClaudeAISubscriber) + const isDisabled = isWSL || ('external' !== '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.} - {"external" !== 'ant' && !isClaudeAISubscriber && ( - - Claude in Chrome requires a claude.ai subscription. - + {'external' !== 'ant' && !isClaudeAISubscriber && ( + 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/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 d5196de20..3d21fcbf9 100644 --- a/src/commands/copy/copy.tsx +++ b/src/commands/copy/copy.tsx @@ -1,44 +1,44 @@ -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 } from '../../components/design-system/Byline.js' -import { KeyboardShortcutHint } from '../../components/design-system/KeyboardShortcutHint.js' -import { Pane } from '../../components/design-system/Pane.js' -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' -import { stringWidth } from '../../ink/stringWidth.js' -import { setClipboard } from '../../ink/termio/osc.js' -import { Box, Text } from '../../ink.js' -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 } from '../../components/design-system/Byline.js'; +import { KeyboardShortcutHint } from '../../components/design-system/KeyboardShortcutHint.js'; +import { Pane } from '../../components/design-system/Pane.js'; +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; +import { stringWidth } from '../../ink/stringWidth.js'; +import { setClipboard } from '../../ink/termio/osc.js'; +import { Box, Text } from '../../ink.js'; +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; } /** @@ -47,95 +47,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[] = [ { @@ -144,109 +129,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' }); }} /> @@ -258,56 +233,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/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 a959a909a..42c0d9e2e 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 '../../components/design-system/Dialog.js' -import { FastIcon, getFastIconString } from '../../components/FastIcon.js' -import { Box, Link, Text } from '../../ink.js' -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 '../../components/design-system/Dialog.js'; +import { FastIcon, getFastIconString } from '../../components/FastIcon.js'; +import { Box, Link, Text } from '../../ink.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 { - 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/fork/index.ts b/src/commands/fork/index.ts index 29ae6094c..a5251bd27 100644 --- a/src/commands/fork/index.ts +++ b/src/commands/fork/index.ts @@ -1,3 +1,3 @@ // Auto-generated stub — replace with real implementation -const _default: Record = {}; -export default _default; +const _default: Record = {} +export default _default 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 f22c16e6a..a9224b004 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 '../../components/design-system/Dialog.js' +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 '../../components/design-system/Dialog.js'; import { IdeAutoConnectDialog, IdeDisableAutoConnectDialog, shouldShowAutoConnectDialog, shouldShowDisableAutoConnectDialog, -} from '../../components/IdeAutoConnectDialog.js' -import { Box, Text } from '../../ink.js' -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 '../../ink.js'; +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))); } }, [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)); + 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 3e9acbe41..ac33b0a95 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 diff --git a/src/commands/install-github-app/ApiKeyStep.tsx b/src/commands/install-github-app/ApiKeyStep.tsx index 3b88a94f3..cae96c1f1 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 '../../ink.js' -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 '../../ink.js'; +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 b00b682c2..5e44a25b8 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 '../../ink.js' -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 '../../ink.js'; +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 16f4d7e8a..d19d8f652 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 '../../ink.js' +import React from 'react'; +import { Text } from '../../ink.js'; 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 b0d4c63b0..43f289c0a 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 '../../ink.js' -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 '../../ink.js'; +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 1861571ed..1e85a6f25 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 '../../ink.js' -import type { Workflow } from './types.js' +import React from 'react'; +import { Box, Text } from '../../ink.js'; +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 a8333f395..053d37123 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 '../../ink.js' +import React from 'react'; +import { GITHUB_ACTION_SETUP_DOCS_URL } from '../../constants/github-app.js'; +import { Box, Text } from '../../ink.js'; 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 645edb742..f4065769e 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 '../../ink.js' +import React from 'react'; +import { Select } from 'src/components/CustomSelect/index.js'; +import { Box, Text } from '../../ink.js'; 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 98a699945..1b11c7967 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 '../../ink.js' -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 '../../ink.js'; +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 b8fd96a49..57eb1373f 100644 --- a/src/commands/install-github-app/OAuthFlowStep.tsx +++ b/src/commands/install-github-app/OAuthFlowStep.tsx @@ -1,22 +1,22 @@ -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 '../../components/design-system/KeyboardShortcutHint.js' -import { Spinner } from '../../components/Spinner.js' -import TextInput from '../../components/TextInput.js' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' -import { setClipboard } from '../../ink/termio/osc.js' -import { Box, Link, Text } from '../../ink.js' -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 '../../components/design-system/KeyboardShortcutHint.js'; +import { Spinner } from '../../components/Spinner.js'; +import TextInput from '../../components/TextInput.js'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; +import { setClipboard } from '../../ink/termio/osc.js'; +import { Box, Link, Text } from '../../ink.js'; +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 = @@ -25,139 +25,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) + const timer2 = setTimeout(onSuccess, 1000, accessToken); + timersRef.current.add(timer2); }, 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(() => { @@ -165,46 +158,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 { @@ -215,7 +203,7 @@ export function OAuthFlowStep({ Starting authentication… - ) + ); case 'waiting_for_login': return ( @@ -223,9 +211,7 @@ export function OAuthFlowStep({ {!showPastePrompt && ( - - Opening browser to sign in with your Claude account… - + Opening browser to sign in with your Claude account… )} @@ -235,9 +221,7 @@ export function OAuthFlowStep({ - handleSubmitCode(value, oauthStatus.url) - } + onSubmit={(value: string) => handleSubmitCode(value, oauthStatus.url)} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} columns={textInputColumns} @@ -245,7 +229,7 @@ export function OAuthFlowStep({ )} - ) + ); case 'processing': return ( @@ -253,52 +237,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' && ( @@ -307,21 +281,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!) ) : ( @@ -339,5 +309,5 @@ export function OAuthFlowStep({ {renderStatusMessage()} - ) + ); } diff --git a/src/commands/install-github-app/SuccessStep.tsx b/src/commands/install-github-app/SuccessStep.tsx index a04b98ac7..ce15f04a2 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 '../../ink.js' +import React from 'react'; +import { Box, Text } from '../../ink.js'; 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 122cdeac3..846d78066 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 '../../ink.js' -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 '../../ink.js'; +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 3a78ae106..8e367a098 100644 --- a/src/commands/install-github-app/install-github-app.tsx +++ b/src/commands/install-github-app/install-github-app.tsx @@ -1,33 +1,33 @@ -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 } from '../../ink/events/keyboard-event.js' -import { Box } from '../../ink.js' -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 } from '../../ink/events/keyboard-event.js'; +import { Box } from '../../ink.js'; +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', @@ -45,54 +45,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', @@ -102,19 +98,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) { @@ -132,18 +128,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, @@ -152,14 +148,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) => { @@ -167,7 +163,7 @@ function InstallGitHubApp(props: { ...prev, step: 'creating', currentWorkflowInstallStep: 0, - })) + })); try { await setupGitHubActions( @@ -178,7 +174,7 @@ function InstallGitHubApp(props: { setState(prev => ({ ...prev, currentWorkflowInstallStep: prev.currentWorkflowInstallStep + 1, - })) + })); }, state.workflowAction === 'skip', state.selectedWorkflows, @@ -188,22 +184,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', @@ -216,12 +208,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, @@ -229,7 +220,7 @@ function InstallGitHubApp(props: { error: errorMessage, errorReason: 'GitHub Actions setup failed', errorInstructions: [], - })) + })); } } }, @@ -242,42 +233,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 }; } } @@ -287,9 +268,9 @@ function InstallGitHubApp(props: { `repos/${repoName}/contents/.github/workflows/claude.yml`, '--jq', '.sha', - ]) + ]); - return checkFileResult.code === 0 + return checkFileResult.code === 0; } async function checkExistingSecret() { @@ -300,20 +281,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) { @@ -322,11 +303,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 { @@ -337,11 +318,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' })); } } } @@ -350,33 +331,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$/, '') || ''; } } @@ -384,14 +360,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({ @@ -403,7 +376,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', @@ -413,81 +386,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) @@ -495,7 +464,7 @@ function InstallGitHubApp(props: { ...prev, apiKeyOrOAuthToken: apiKeyToUse, useExistingKey: state.selectedApiKeyOption === 'existing', - })) + })); // Check if ANTHROPIC_API_KEY secret already exists const checkSecretsResult = await execFileNoThrow('gh', [ @@ -505,132 +474,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' @@ -638,16 +607,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 ( @@ -720,17 +675,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-github-app/types.ts b/src/commands/install-github-app/types.ts index 8caa15694..e6ad071f2 100644 --- a/src/commands/install-github-app/types.ts +++ b/src/commands/install-github-app/types.ts @@ -1,4 +1,4 @@ // Auto-generated stub — replace with real implementation -export type Workflow = any; -export type State = any; -export type Warning = any; +export type Workflow = any +export type State = any +export type Warning = any diff --git a/src/commands/install.tsx b/src/commands/install.tsx index 15eddd575..83c9b00cc 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 '../components/design-system/StatusIcon.js' -import { Box, render, Text } from '../ink.js' -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 '../components/design-system/StatusIcon.js'; +import { Box, render, Text } from '../ink.js'; +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/login/login.tsx b/src/commands/login/login.tsx index 912ad61f9..0c5612c09 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 '../../components/design-system/Dialog.js' -import { useMainLoopModel } from '../../hooks/useMainLoopModel.js' -import { Text } from '../../ink.js' -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 '../../components/design-system/Dialog.js'; +import { useMainLoopModel } from '../../hooks/useMainLoopModel.js'; +import { Text } from '../../ink.js'; +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 b8eb13b87..ec56f329c 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 '../../ink.js' -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 '../../ink.js'; +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 945b34a60..a36674d2e 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 '../../components/design-system/Dialog.js' -import { MemoryFileSelector } from '../../components/memory/MemoryFileSelector.js' -import { getRelativeMemoryPath } from '../../components/memory/MemoryUpdateNotification.js' -import { Box, Link, Text } from '../../ink.js' -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 '../../components/design-system/Dialog.js'; +import { MemoryFileSelector } from '../../components/memory/MemoryFileSelector.js'; +import { getRelativeMemoryPath } from '../../components/memory/MemoryUpdateNotification.js'; +import { Box, Link, Text } from '../../ink.js'; +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 0467919bb..494cc8a78 100644 --- a/src/commands/mobile/mobile.tsx +++ b/src/commands/mobile/mobile.tsx @@ -1,17 +1,17 @@ -import { toString as qrToString } from 'qrcode' -import * as React from 'react' -import { useCallback, useEffect, useState } from 'react' -import { Pane } from '../../components/design-system/Pane.js' -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' -import { Box, Text } from '../../ink.js' -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 '../../components/design-system/Pane.js'; +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; +import { Box, Text } from '../../ink.js'; +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: { @@ -20,17 +20,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(() => { @@ -44,42 +44,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) => ( @@ -95,10 +90,7 @@ function MobileQRCode({ onDone }: Props): React.ReactNode { iOS {' / '} - + Android @@ -107,11 +99,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..8ddf81659 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 = undefined; 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 = undefined; 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/index.ts b/src/commands/peers/index.ts index 29ae6094c..a5251bd27 100644 --- a/src/commands/peers/index.ts +++ b/src/commands/peers/index.ts @@ -1,3 +1,3 @@ // Auto-generated stub — replace with real implementation -const _default: Record = {}; -export default _default; +const _default: Record = {} +export default _default 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 8c3f328a7..0aff0565c 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 '../../ink.js' -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 '../../ink.js'; +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 e0a1d4d16..84446c39f 100644 --- a/src/commands/plugin/AddMarketplace.tsx +++ b/src/commands/plugin/AddMarketplace.tsx @@ -1,38 +1,35 @@ -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 } from '../../components/design-system/Byline.js' -import { KeyboardShortcutHint } from '../../components/design-system/KeyboardShortcutHint.js' -import { Spinner } from '../../components/Spinner.js' -import TextInput from '../../components/TextInput.js' -import { Box, Text } from '../../ink.js' -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 } from '../../components/design-system/Byline.js'; +import { KeyboardShortcutHint } from '../../components/design-system/KeyboardShortcutHint.js'; +import { Spinner } from '../../components/Spinner.js'; +import TextInput from '../../components/TextInput.js'; +import { Box, Text } from '../../ink.js'; +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, @@ -47,95 +44,88 @@ 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 ( @@ -166,9 +156,7 @@ export function AddMarketplace({ {isLoading && ( - - {progressMessage || 'Adding marketplace to configuration…'} - + {progressMessage || 'Adding marketplace to configuration…'} )} {error && ( @@ -186,15 +174,10 @@ export function AddMarketplace({ - + - ) + ); } diff --git a/src/commands/plugin/BrowseMarketplace.tsx b/src/commands/plugin/BrowseMarketplace.tsx index e8733052e..b17984020 100644 --- a/src/commands/plugin/BrowseMarketplace.tsx +++ b/src/commands/plugin/BrowseMarketplace.tsx @@ -1,80 +1,65 @@ -import figures from 'figures' -import * as React from 'react' -import { useEffect, useState } from 'react' -import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js' -import { Byline } from '../../components/design-system/Byline.js' -import { Box, Text } from '../../ink.js' -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 { Byline } from '../../components/design-system/Byline.js'; +import { Box, Text } from '../../ink.js'; +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, @@ -87,46 +72,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(() => { @@ -137,111 +110,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 marketplaceInfos: MarketplaceInfo[] = [] - for (const { - name, - config: marketplaceConfig, - data: marketplace, - } of marketplaces) { + const { marketplaces, failures } = await loadMarketplacesWithGracefulDegradation(config); + + 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.name, name)), - ) + const installedFromThisMarketplace = count(marketplace.plugins, plugin => + isPluginInstalled(createPluginId(plugin.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, @@ -250,9 +206,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; } } } @@ -264,65 +220,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, @@ -331,70 +281,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({ @@ -402,228 +344,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.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.error) + setIsInstalling(false); + setInstallError(result.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); } }, }, @@ -631,16 +564,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 @@ -686,9 +615,7 @@ export function BrowseMarketplace({ Select marketplace No marketplaces configured. - - Add a marketplace first using {"'Add marketplace'"}. - + Add a marketplace first using {"'Add marketplace'"}. - ) + ); } return ( @@ -718,23 +645,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}`} @@ -744,12 +664,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 ( @@ -779,9 +694,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} @@ -819,9 +732,7 @@ export function BrowseMarketplace({ )} {selectedPlugin.entry.hooks && ( - - · Hooks: {Object.keys(selectedPlugin.entry.hooks).join(', ')} - + · Hooks: {Object.keys(selectedPlugin.entry.hooks).join(', ')} )} {selectedPlugin.entry.mcpServers && ( @@ -844,9 +755,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: @@ -854,9 +763,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 )} )} @@ -878,9 +785,7 @@ export function BrowseMarketplace({ {detailsMenuIndex === index && {'> '}} {detailsMenuIndex !== index && {' '}} - {isInstalling && option.action === 'install' - ? 'Installing…' - : option.label} + {isInstalling && option.action === 'install' ? 'Installing…' : option.label} ))} @@ -889,23 +794,13 @@ export function BrowseMarketplace({ - - + + - ) + ); } // Plugin installation view @@ -916,25 +811,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 ( @@ -951,22 +839,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 @@ -976,37 +858,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 */} @@ -1027,5 +897,5 @@ export function BrowseMarketplace({ 0} /> - ) + ); } diff --git a/src/commands/plugin/DiscoverPlugins.tsx b/src/commands/plugin/DiscoverPlugins.tsx index 442e2686f..a8cc5f2fe 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 { Byline } from '../../components/design-system/Byline.js' -import { SearchBox } from '../../components/SearchBox.js' -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 { Byline } from '../../components/design-system/Byline.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 '../../ink.js' -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 '../../ink.js'; +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 -} - -type ViewState = - | 'plugin-list' - | 'plugin-details' - | { type: 'plugin-options'; plugin: LoadedPlugin; pluginId: 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 }; 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.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.error) + setIsInstalling(false); + setInstallError(result.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 868f26e32..c35f80915 100644 --- a/src/commands/plugin/ManageMarketplaces.tsx +++ b/src/commands/plugin/ManageMarketplaces.tsx @@ -1,72 +1,66 @@ -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 } from '../../components/design-system/Byline.js' -import { KeyboardShortcutHint } from '../../components/design-system/KeyboardShortcutHint.js' +} from 'src/services/analytics/index.js'; +import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js'; +import { Byline } from '../../components/design-system/Byline.js'; +import { KeyboardShortcutHint } from '../../components/design-system/KeyboardShortcutHint.js'; // 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 '../../ink.js' -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 '../../ink.js'; +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 -} - -type InternalViewState = 'list' | 'details' | 'confirm-remove' + name: string; + source: string; + lastUpdated?: string; + pluginCount?: number; + installedPlugins?: LoadedPlugin[]; + pendingUpdate?: boolean; + pendingRemove?: boolean; + autoUpdate?: boolean; +}; + +type InternalViewState = 'list' | 'details' | 'confirm-remove'; export function ManageMarketplaces({ setViewState, @@ -78,39 +72,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, @@ -121,149 +109,136 @@ 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, + }); } } @@ -275,35 +250,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, @@ -314,93 +284,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})`, @@ -413,63 +373,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( @@ -481,59 +429,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) => @@ -541,54 +488,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'); } }, }, @@ -596,23 +538,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) { @@ -653,12 +595,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 ( @@ -668,39 +610,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 ( @@ -708,32 +647,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 && ( @@ -761,33 +698,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. + + + )} @@ -812,11 +744,11 @@ export function ManageMarketplaces({ - ) + ); } // Show marketplace list - const { updateCount, removeCount } = getPendingCounts() + const { updateCount, removeCount } = getPendingCounts(); return ( @@ -837,58 +769,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()}} - ) + ); })} @@ -896,8 +808,7 @@ export function ManageMarketplaces({ {hasPendingChanges() && ( - Pending changes:{' '} - Enter to apply + Pending changes: Enter to apply {updateCount > 0 && ( @@ -926,18 +837,15 @@ export function ManageMarketplaces({ )} - + - ) + ); } type ManageMarketplacesKeyHintsProps = { - exitState: Props['exitState'] - hasPendingActions: boolean -} + exitState: Props['exitState']; + hasPendingActions: boolean; +}; function ManageMarketplacesKeyHints({ exitState, @@ -950,7 +858,7 @@ function ManageMarketplacesKeyHints({ Press {exitState.keyName} again to go back - ) + ); } return ( @@ -966,19 +874,10 @@ function ManageMarketplacesKeyHints({ /> )} {!hasPendingActions && ( - - )} - {!hasPendingActions && ( - - )} - {!hasPendingActions && ( - + )} + {!hasPendingActions && } + {!hasPendingActions && } - ) + ); } diff --git a/src/commands/plugin/ManagePlugins.tsx b/src/commands/plugin/ManagePlugins.tsx index e1ec554ac..94862708d 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 '../../components/design-system/Byline.js' -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 '../../components/design-system/Byline.js'; +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 '../../ink.js' -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 '../../ink.js'; +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 - - let mcpbPath: string | null = null - if ( - typeof mcpServersSpec === 'string' && - isMcpbSource(mcpServersSpec) - ) { - mcpbPath = mcpServersSpec + const mcpServersSpec = selectedPlugin.plugin.manifest.mcpServers; + + 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 - - if ( - typeof mcpServersSpec === 'string' && - isMcpbSource(mcpServersSpec) - ) { - mcpbPath = mcpServersSpec + const mcpServersSpec = selectedPlugin.plugin.manifest.mcpServers; + 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') - 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 8ef1f6809..25073b8b8 100644 --- a/src/commands/plugin/PluginOptionsDialog.tsx +++ b/src/commands/plugin/PluginOptionsDialog.tsx @@ -1,18 +1,12 @@ -import figures from 'figures' -import React, { useCallback, useState } from 'react' -import { Dialog } from '../../components/design-system/Dialog.js' -import { stringWidth } from '../../ink/stringWidth.js' +import figures from 'figures'; +import React, { useCallback, useState } from 'react'; +import { Dialog } from '../../components/design-system/Dialog.js'; +import { stringWidth } from '../../ink/stringWidth.js'; // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw text input for config dialog -import { Box, Text, useInput } from '../../ink.js' -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 } from '../../ink.js'; +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. @@ -32,43 +26,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] ?? '' - - if ( - schema?.sensitive === true && - value === '' && - initialValues?.[fieldKey] !== undefined - ) { - continue + const schema = configSchema[fieldKey]; + const value = collected[fieldKey] ?? ''; + + 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, @@ -78,68 +68,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( { @@ -147,47 +125,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} @@ -201,14 +170,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 e0c3d54da..3baf6256e 100644 --- a/src/commands/plugin/PluginSettings.tsx +++ b/src/commands/plugin/PluginSettings.tsx @@ -1,75 +1,60 @@ -import figures from 'figures' -import * as React from 'react' -import { useCallback, useEffect, useState } from 'react' -import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js' -import { Byline } from '../../components/design-system/Byline.js' -import { Pane } from '../../components/design-system/Pane.js' -import { Tab, Tabs } from '../../components/design-system/Tabs.js' -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' -import { Box, Text } from '../../ink.js' -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' - -function MarketplaceList({ - onComplete, -}: { - onComplete: (result?: string) => void -}): React.ReactNode { +import figures from 'figures'; +import * as React from 'react'; +import { useCallback, useEffect, useState } from 'react'; +import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js'; +import { Byline } from '../../components/design-system/Byline.js'; +import { Pane } from '../../components/design-system/Pane.js'; +import { Tab, Tabs } from '../../components/design-system/Tabs.js'; +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; +import { Box, Text } from '../../ink.js'; +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'; + +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 ("external" !== 'ant') { - return null + if ('external' !== 'ant') { + return null; } return ( @@ -90,78 +75,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 @@ -174,7 +156,7 @@ function buildMarketplaceAction(name: string): ErrorRowAction { targetMarketplace: name, action: 'remove', }, - } + }; } function buildPluginAction(pluginName: string): ErrorRowAction { @@ -186,17 +168,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); } /** @@ -204,11 +182,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( @@ -220,102 +198,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) --- @@ -325,52 +283,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); } } } @@ -380,40 +335,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( @@ -422,35 +372,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, @@ -459,30 +409,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 @@ -491,72 +441,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 ( @@ -566,28 +500,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})} @@ -602,7 +529,7 @@ function ErrorsTabContent({ )} - ) + ); })} {actionMessage && ( @@ -614,12 +541,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 @@ -746,16 +657,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. @@ -763,9 +673,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. @@ -776,32 +684,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 @@ -810,44 +716,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') { @@ -857,17 +763,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 @@ -878,48 +776,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') { @@ -937,7 +823,7 @@ export function PluginSettings({ onAddComplete={markPluginsChanged} cliMode={cliMode} /> - ) + ); } // Render tabbed interface using the design system Tabs component return ( @@ -948,11 +834,7 @@ export function PluginSettings({ onTabChange={handleTabChange} color="suggestion" disableNavigation={childSearchActive} - banner={ - showMcpRedirectMessage && activeTab === 'installed' ? ( - - ) : undefined - } + banner={showMcpRedirectMessage && activeTab === 'installed' ? : undefined} > {viewState.type === 'browse-marketplace' ? ( @@ -975,11 +857,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} /> )} @@ -989,19 +867,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} /> @@ -1012,16 +880,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} /> @@ -1033,5 +893,5 @@ export function PluginSettings({ - ) + ); } diff --git a/src/commands/plugin/PluginTrustWarning.tsx b/src/commands/plugin/PluginTrustWarning.tsx index 2295db691..dbec4fe54 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 '../../ink.js' -import { getPluginTrustMessage } from '../../utils/plugins/marketplaceHelpers.js' +import figures from 'figures'; +import * as React from 'react'; +import { Box, Text } from '../../ink.js'; +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 5ce8783de..630b97a4b 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 '../../ink.js' -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 '../../ink.js'; +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 4d8afd8b6..16353c003 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 '../../ink.js' -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 '../../ink.js'; +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 unknown subcommand gracefully", () => { - expect(parsePluginArgs("foobar")).toEqual({ type: "menu" }); - }); - - test("marketplace without action returns type only", () => { - expect(parsePluginArgs("marketplace")).toEqual({ type: "marketplace" }); - }); -}); + 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('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 caec86b46..dfc4f8173 100644 --- a/src/commands/plugin/pluginDetailsHelpers.tsx +++ b/src/commands/plugin/pluginDetailsHelpers.tsx @@ -4,29 +4,29 @@ * Used by both DiscoverPlugins and BrowseMarketplace components. */ -import * as React from 'react' -import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js' -import { Byline } from '../../components/design-system/Byline.js' -import { Box, Text } from '../../ink.js' -import type { PluginMarketplaceEntry } from '../../utils/plugins/schemas.js' +import * as React from 'react'; +import { ConfigurableShortcutHint } from '../../components/ConfigurableShortcutHint.js'; +import { Byline } from '../../components/design-system/Byline.js'; +import { Box, Text } from '../../ink.js'; +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 @@ -36,17 +36,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; } /** @@ -66,25 +62,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 ( @@ -98,26 +90,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/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 new file mode 100644 index 000000000..64d68555f --- /dev/null +++ b/src/commands/provider.ts @@ -0,0 +1,118 @@ +import type { Command } from '../commands.js' +import type { LocalCommandCall } from '../types/command.js' +import { getAPIProvider } from '../utils/model/providers.js' +import { updateSettingsForSource } from '../utils/settings/settings.js' +import { getSettings_DEPRECATED } from '../utils/settings/settings.js' +import { applyConfigEnvironmentVariables } from '../utils/managedEnv.js' + +function getEnvVarForProvider(provider: string): string { + switch (provider) { + case 'bedrock': + return 'CLAUDE_CODE_USE_BEDROCK' + case 'vertex': + return 'CLAUDE_CODE_USE_VERTEX' + case 'foundry': + return 'CLAUDE_CODE_USE_FOUNDRY' + default: + throw new Error(`Unknown provider: ${provider}`) + } +} + +// Get merged env: process.env + settings.env (from userSettings) +function getMergedEnv(): Record { + const settings = getSettings_DEPRECATED() + const merged = { ...process.env } + if (settings?.env) { + Object.assign(merged, settings.env) + } + return merged +} + +const call: LocalCommandCall = async (args, context) => { + const arg = args.trim().toLowerCase() + + // No argument: show current provider + if (!arg) { + const current = getAPIProvider() + return { type: 'text', value: `Current API provider: ${current}` } + } + + // unset - clear settings, fallback to env vars + if (arg === 'unset') { + updateSettingsForSource('userSettings', { modelType: undefined }) + // Also clear all provider-specific env vars to prevent conflicts + delete process.env.CLAUDE_CODE_USE_BEDROCK + delete process.env.CLAUDE_CODE_USE_VERTEX + delete process.env.CLAUDE_CODE_USE_FOUNDRY + delete process.env.CLAUDE_CODE_USE_OPENAI + return { + type: 'text', + value: 'API provider cleared (will use environment variables).', + } + } + + // Validate provider + const validProviders = ['anthropic', 'openai', 'bedrock', 'vertex', 'foundry'] + if (!validProviders.includes(arg)) { + return { + type: 'text', + value: `Invalid provider: ${arg}\nValid: ${validProviders.join(', ')}`, + } + } + + // Check env vars when switching to openai (including settings.env) + if (arg === 'openai') { + const mergedEnv = getMergedEnv() + const hasKey = !!mergedEnv.OPENAI_API_KEY + const hasUrl = !!mergedEnv.OPENAI_BASE_URL + if (!hasKey || !hasUrl) { + updateSettingsForSource('userSettings', { modelType: 'openai' }) + const missing = [] + if (!hasKey) missing.push('OPENAI_API_KEY') + if (!hasUrl) missing.push('OPENAI_BASE_URL') + return { + type: 'text', + value: `Switched to OpenAI provider.\nWarning: Missing env vars: ${missing.join(', ')}\nConfigure them via /login or set manually.`, + } + } + } + + // Handle different provider types + // - 'anthropic' and 'openai' are stored in settings.json (persistent) + // - 'bedrock', 'vertex', 'foundry' are env-only (do NOT touch settings.json) + if (arg === 'anthropic' || arg === 'openai') { + // Clear any cloud provider env vars to avoid conflicts + delete process.env.CLAUDE_CODE_USE_BEDROCK + delete process.env.CLAUDE_CODE_USE_VERTEX + delete process.env.CLAUDE_CODE_USE_FOUNDRY + // Update settings.json + updateSettingsForSource('userSettings', { modelType: arg }) + // Ensure settings.env gets applied to process.env + applyConfigEnvironmentVariables() + return { type: 'text', value: `API provider set to ${arg}.` } + } else { + // Cloud providers: set env vars only, do NOT touch settings.modelType + delete process.env.CLAUDE_CODE_USE_OPENAI + delete process.env.OPENAI_API_KEY + delete process.env.OPENAI_BASE_URL + process.env[getEnvVarForProvider(arg)] = '1' + // Do not modify settings.json - cloud providers controlled solely by env vars + applyConfigEnvironmentVariables() + return { + type: 'text', + value: `API provider set to ${arg} (via environment variable).`, + } + } +} + +const provider = { + type: 'local', + name: 'provider', + description: 'Switch API provider (anthropic/openai/bedrock/vertex/foundry)', + aliases: ['api'], + argumentHint: '[anthropic|openai|bedrock|vertex|foundry|unset]', + supportsNonInteractive: true, + load: () => Promise.resolve({ call }), +} satisfies Command + +export default provider diff --git a/src/commands/rate-limit-options/rate-limit-options.tsx b/src/commands/rate-limit-options/rate-limit-options.tsx index e86eb040d..f0beacbe3 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 '../../components/design-system/Dialog.js' -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' +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 '../../components/design-system/Dialog.js'; +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 RateLimitOptionsMenuProps = { onDone: ( result?: string, options?: | { - display?: CommandResultDisplay | undefined + display?: CommandResultDisplay | undefined; } | undefined, - ) => 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, - ) - - const options = useMemo< - OptionWithDescription[] - >(() => { - const actionOptions: OptionWithDescription[] = - [] + ) => 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); + + 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 05813453d..59c338092 100644 --- a/src/commands/remote-setup/remote-setup.tsx +++ b/src/commands/remote-setup/remote-setup.tsx @@ -1,17 +1,17 @@ -import { execa } from 'execa' -import * as React from 'react' -import { useEffect, useState } from 'react' -import { Select } from '../../components/CustomSelect/index.js' -import { Dialog } from '../../components/design-system/Dialog.js' -import { LoadingState } from '../../components/design-system/LoadingState.js' -import { Box, Text } from '../../ink.js' +import { execa } from 'execa'; +import * as React from 'react'; +import { useEffect, useState } from 'react'; +import { Select } from '../../components/CustomSelect/index.js'; +import { Dialog } from '../../components/design-system/Dialog.js'; +import { LoadingState } from '../../components/design-system/LoadingState.js'; +import { Box, Text } from '../../ink.js'; 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, @@ -19,25 +19,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' @@ -47,124 +47,112 @@ 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) { logEvent('tengu_remote_setup_result', { result: 'import_failed' as SafeString, error_kind: result.error.kind as SafeString, - }) - onDone(errorMessage(result.error, getCodeWebUrl())) - return + }); + onDone(errorMessage(result.error, 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 + + - onChange(value as 'accept' | 'accept-default' | 'decline') - } + onChange={value => onChange(value as 'accept' | 'accept-default' | 'decline')} onCancel={onDecline} /> - ) + ); } diff --git a/src/components/AutoUpdater.tsx b/src/components/AutoUpdater.tsx index 09b523fc4..2f4e954d0 100644 --- a/src/components/AutoUpdater.tsx +++ b/src/components/AutoUpdater.tsx @@ -1,12 +1,12 @@ -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 { useInterval } from 'usehooks-ts' -import { useUpdateNotification } from '../hooks/useUpdateNotification.js' -import { Box, Text } from '../ink.js' +} from 'src/services/analytics/index.js'; +import { useInterval } from 'usehooks-ts'; +import { useUpdateNotification } from '../hooks/useUpdateNotification.js'; +import { Box, Text } from '../ink.js'; import { type AutoUpdaterResult, getLatestVersion, @@ -14,26 +14,23 @@ import { type InstallStatus, installGlobalPackage, shouldSkipVersion, -} from '../utils/autoUpdater.js' -import { getGlobalConfig, isAutoUpdaterDisabled } from '../utils/config.js' -import { logForDebugging } from '../utils/debug.js' -import { getCurrentInstallationType } from '../utils/doctorDiagnostic.js' -import { - installOrUpdateClaudePackage, - localInstallationExists, -} from '../utils/localInstaller.js' -import { removeInstalledSymlink } from '../utils/nativeInstaller/index.js' -import { gt, gte } from '../utils/semver.js' -import { getInitialSettings } from '../utils/settings/settings.js' +} from '../utils/autoUpdater.js'; +import { getGlobalConfig, isAutoUpdaterDisabled } from '../utils/config.js'; +import { logForDebugging } from '../utils/debug.js'; +import { getCurrentInstallationType } from '../utils/doctorDiagnostic.js'; +import { installOrUpdateClaudePackage, localInstallationExists } from '../utils/localInstaller.js'; +import { removeInstalledSymlink } from '../utils/nativeInstaller/index.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 AutoUpdater({ isUpdating, @@ -44,61 +41,56 @@ export function AutoUpdater({ verbose, }: Props): React.ReactNode { const [versions, setVersions] = useState<{ - global?: string | null - latest?: string | null - }>({}) - const [hasLocalInstall, setHasLocalInstall] = useState(false) - const updateSemver = useUpdateNotification(autoUpdaterResult?.version) + global?: string | null; + latest?: string | null; + }>({}); + const [hasLocalInstall, setHasLocalInstall] = useState(false); + const updateSemver = useUpdateNotification(autoUpdaterResult?.version); useEffect(() => { - void localInstallationExists().then(setHasLocalInstall) - }, []) + void localInstallationExists().then(setHasLocalInstall); + }, []); // Track latest isUpdating value in a ref so the memoized checkForUpdates // callback always sees the current value. Without this, the 30-minute // interval fires with a stale closure where isUpdating is false, allowing // a concurrent installGlobalPackage() to run while one is already in // progress. - 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 ( - "production" === 'test' || - "production" === 'development' - ) { - logForDebugging( - 'AutoUpdater: Skipping update check in test/dev environment', - ) - return + if ('production' === 'test' || 'production' === 'development') { + logForDebugging('AutoUpdater: Skipping update check in test/dev environment'); + return; } - const currentVersion = MACRO.VERSION - const channel = getInitialSettings()?.autoUpdatesChannel ?? 'latest' - let latestVersion = await getLatestVersion(channel) - const isDisabled = isAutoUpdaterDisabled() + const currentVersion = MACRO.VERSION; + const channel = getInitialSettings()?.autoUpdatesChannel ?? 'latest'; + let latestVersion = await getLatestVersion(channel); + const isDisabled = isAutoUpdaterDisabled(); // Check if max version is set (server-side kill switch for auto-updates) - const maxVersion = await getMaxVersion() + const maxVersion = await getMaxVersion(); if (maxVersion && latestVersion && gt(latestVersion, maxVersion)) { logForDebugging( `AutoUpdater: maxVersion ${maxVersion} is set, capping update from ${latestVersion} to ${maxVersion}`, - ) + ); if (gte(currentVersion, maxVersion)) { logForDebugging( `AutoUpdater: current version ${currentVersion} is already at or above maxVersion ${maxVersion}, skipping update`, - ) - setVersions({ global: currentVersion, latest: latestVersion }) - return + ); + setVersions({ global: currentVersion, latest: latestVersion }); + return; } - latestVersion = maxVersion + latestVersion = maxVersion; } - setVersions({ global: currentVersion, latest: latestVersion }) + setVersions({ global: currentVersion, latest: latestVersion }); // Check if update needed and perform update if ( @@ -108,127 +100,113 @@ export function AutoUpdater({ !gte(currentVersion, latestVersion) && !shouldSkipVersion(latestVersion) ) { - const startTime = Date.now() - onChangeIsUpdating(true) + const startTime = Date.now(); + onChangeIsUpdating(true); // Remove native installer symlink since we're using JS-based updates // But only if user hasn't migrated to native installation - const config = getGlobalConfig() + const config = getGlobalConfig(); if (config.installMethod !== 'native') { - await removeInstalledSymlink() + await removeInstalledSymlink(); } // Detect actual running installation type - const installationType = await getCurrentInstallationType() - logForDebugging( - `AutoUpdater: Detected installation type: ${installationType}`, - ) + const installationType = await getCurrentInstallationType(); + logForDebugging(`AutoUpdater: Detected installation type: ${installationType}`); // Skip update for development builds if (installationType === 'development') { - logForDebugging('AutoUpdater: Cannot auto-update development build') - onChangeIsUpdating(false) - return + logForDebugging('AutoUpdater: Cannot auto-update development build'); + onChangeIsUpdating(false); + return; } // Choose the appropriate update method based on what's actually running - let installStatus: InstallStatus - let updateMethod: 'local' | 'global' + let installStatus: InstallStatus; + let updateMethod: 'local' | 'global'; if (installationType === 'npm-local') { // Use local update for local installations - logForDebugging('AutoUpdater: Using local update method') - updateMethod = 'local' - installStatus = await installOrUpdateClaudePackage(channel) + logForDebugging('AutoUpdater: Using local update method'); + updateMethod = 'local'; + installStatus = await installOrUpdateClaudePackage(channel); } else if (installationType === 'npm-global') { // Use global update for global installations - logForDebugging('AutoUpdater: Using global update method') - updateMethod = 'global' - installStatus = await installGlobalPackage() + logForDebugging('AutoUpdater: Using global update method'); + updateMethod = 'global'; + installStatus = await installGlobalPackage(); } else if (installationType === 'native') { // This shouldn't happen - native should use NativeAutoUpdater - logForDebugging( - 'AutoUpdater: Unexpected native installation in non-native updater', - ) - onChangeIsUpdating(false) - return + logForDebugging('AutoUpdater: Unexpected native installation in non-native updater'); + onChangeIsUpdating(false); + return; } else { // Fallback to config-based detection for unknown types - logForDebugging( - `AutoUpdater: Unknown installation type, falling back to config`, - ) - const isMigrated = config.installMethod === 'local' - updateMethod = isMigrated ? 'local' : 'global' + logForDebugging(`AutoUpdater: Unknown installation type, falling back to config`); + const isMigrated = config.installMethod === 'local'; + updateMethod = isMigrated ? 'local' : 'global'; if (isMigrated) { - installStatus = await installOrUpdateClaudePackage(channel) + installStatus = await installOrUpdateClaudePackage(channel); } else { - installStatus = await installGlobalPackage() + installStatus = await installGlobalPackage(); } } - onChangeIsUpdating(false) + onChangeIsUpdating(false); if (installStatus === 'success') { logEvent('tengu_auto_updater_success', { - fromVersion: - currentVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - toVersion: - latestVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + fromVersion: currentVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + toVersion: latestVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, durationMs: Date.now() - startTime, wasMigrated: updateMethod === 'local', - installationType: - installationType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + installationType: installationType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); } else { logEvent('tengu_auto_updater_fail', { - fromVersion: - currentVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - attemptedVersion: - latestVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - status: - installStatus as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + fromVersion: currentVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + attemptedVersion: latestVersion as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + status: installStatus as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, durationMs: Date.now() - startTime, wasMigrated: updateMethod === 'local', - installationType: - installationType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + installationType: installationType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); } onAutoUpdaterResult({ version: latestVersion, status: installStatus, - }) + }); } // 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]) + }, [onAutoUpdaterResult]); // Initial check useEffect(() => { - void checkForUpdates() - }, [checkForUpdates]) + void checkForUpdates(); + }, [checkForUpdates]); // Check every 30 minutes - useInterval(checkForUpdates, 30 * 60 * 1000) + useInterval(checkForUpdates, 30 * 60 * 1000); if (!autoUpdaterResult?.version && (!versions.global || !versions.latest)) { - return null + return null; } if (!autoUpdaterResult?.version && !isUpdating) { - return null + return null; } return ( {verbose && ( - globalVersion: {versions.global} · latestVersion:{' '} - {versions.latest} + globalVersion: {versions.global} · latestVersion: {versions.latest} )} {isUpdating ? ( @@ -248,8 +226,7 @@ export function AutoUpdater({ ) )} - {(autoUpdaterResult?.status === 'install_failed' || - autoUpdaterResult?.status === 'no_permissions') && ( + {(autoUpdaterResult?.status === 'install_failed' || autoUpdaterResult?.status === 'no_permissions') && ( ✗ Auto-update failed · Try claude doctor or{' '} @@ -260,5 +237,5 @@ export function AutoUpdater({ )} - ) + ); } diff --git a/src/components/AutoUpdaterWrapper.tsx b/src/components/AutoUpdaterWrapper.tsx index 709c776d2..f1f4c5807 100644 --- a/src/components/AutoUpdaterWrapper.tsx +++ b/src/components/AutoUpdaterWrapper.tsx @@ -1,21 +1,21 @@ -import { feature } from 'bun:bundle' -import * as React from 'react' -import type { AutoUpdaterResult } from '../utils/autoUpdater.js' -import { isAutoUpdaterDisabled } from '../utils/config.js' -import { logForDebugging } from '../utils/debug.js' -import { getCurrentInstallationType } from '../utils/doctorDiagnostic.js' -import { AutoUpdater } from './AutoUpdater.js' -import { NativeAutoUpdater } from './NativeAutoUpdater.js' -import { PackageManagerAutoUpdater } from './PackageManagerAutoUpdater.js' +import { feature } from 'bun:bundle'; +import * as React from 'react'; +import type { AutoUpdaterResult } from '../utils/autoUpdater.js'; +import { isAutoUpdaterDisabled } from '../utils/config.js'; +import { logForDebugging } from '../utils/debug.js'; +import { getCurrentInstallationType } from '../utils/doctorDiagnostic.js'; +import { AutoUpdater } from './AutoUpdater.js'; +import { NativeAutoUpdater } from './NativeAutoUpdater.js'; +import { PackageManagerAutoUpdater } from './PackageManagerAutoUpdater.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 AutoUpdaterWrapper({ isUpdating, @@ -25,41 +25,30 @@ export function AutoUpdaterWrapper({ showSuccessMessage, verbose, }: Props): React.ReactNode { - const [useNativeInstaller, setUseNativeInstaller] = React.useState< - boolean | null - >(null) - const [isPackageManager, setIsPackageManager] = React.useState< - boolean | null - >(null) + const [useNativeInstaller, setUseNativeInstaller] = React.useState(null); + const [isPackageManager, setIsPackageManager] = React.useState(null); React.useEffect(() => { async function checkInstallation() { // Skip installation type detection if auto-updates are disabled (ant-only) // This avoids potentially slow package manager detection (spawnSync calls) - if ( - feature('SKIP_DETECTION_WHEN_AUTOUPDATES_DISABLED') && - isAutoUpdaterDisabled() - ) { - logForDebugging( - 'AutoUpdaterWrapper: Skipping detection, auto-updates disabled', - ) - return + if (feature('SKIP_DETECTION_WHEN_AUTOUPDATES_DISABLED') && isAutoUpdaterDisabled()) { + logForDebugging('AutoUpdaterWrapper: Skipping detection, auto-updates disabled'); + return; } - const installationType = await getCurrentInstallationType() - logForDebugging( - `AutoUpdaterWrapper: Installation type: ${installationType}`, - ) - setUseNativeInstaller(installationType === 'native') - setIsPackageManager(installationType === 'package-manager') + const installationType = await getCurrentInstallationType(); + logForDebugging(`AutoUpdaterWrapper: Installation type: ${installationType}`); + setUseNativeInstaller(installationType === 'native'); + setIsPackageManager(installationType === 'package-manager'); } - void checkInstallation() - }, []) + void checkInstallation(); + }, []); // Don't render until we know the installation type if (useNativeInstaller === null || isPackageManager === null) { - return null + return null; } if (isPackageManager) { @@ -72,10 +61,10 @@ export function AutoUpdaterWrapper({ onChangeIsUpdating={onChangeIsUpdating} showSuccessMessage={showSuccessMessage} /> - ) + ); } - const Updater = useNativeInstaller ? NativeAutoUpdater : AutoUpdater + const Updater = useNativeInstaller ? NativeAutoUpdater : AutoUpdater; return ( - ) + ); } diff --git a/src/components/AwsAuthStatusBox.tsx b/src/components/AwsAuthStatusBox.tsx index ea2d1a5d3..1ff53db34 100644 --- a/src/components/AwsAuthStatusBox.tsx +++ b/src/components/AwsAuthStatusBox.tsx @@ -1,41 +1,30 @@ -import React, { useEffect, useState } from 'react' -import { Box, Link, Text } from '../ink.js' -import { - type AwsAuthStatus, - AwsAuthStatusManager, -} from '../utils/awsAuthStatusManager.js' +import React, { useEffect, useState } from 'react'; +import { Box, Link, Text } from '../ink.js'; +import { type AwsAuthStatus, AwsAuthStatusManager } from '../utils/awsAuthStatusManager.js'; -const URL_RE = /https?:\/\/\S+/ +const URL_RE = /https?:\/\/\S+/; export function AwsAuthStatusBox(): React.ReactNode { - const [status, setStatus] = useState( - AwsAuthStatusManager.getInstance().getStatus(), - ) + const [status, setStatus] = useState(AwsAuthStatusManager.getInstance().getStatus()); useEffect(() => { // Subscribe to status updates - const unsubscribe = AwsAuthStatusManager.getInstance().subscribe(setStatus) - return unsubscribe - }, []) + const unsubscribe = AwsAuthStatusManager.getInstance().subscribe(setStatus); + return unsubscribe; + }, []); // Don't show anything if not authenticating and no error if (!status.isAuthenticating && !status.error && status.output.length === 0) { - return null + return null; } // Don't show if authentication succeeded (no error and not authenticating) if (!status.isAuthenticating && !status.error) { - return null + return null; } return ( - + Cloud Authentication @@ -43,25 +32,25 @@ export function AwsAuthStatusBox(): React.ReactNode { {status.output.length > 0 && ( {status.output.slice(-5).map((line, index) => { - const m = line.match(URL_RE) + const m = line.match(URL_RE); if (!m) { return ( {line} - ) + ); } - const url = m[0] - const start = m.index ?? 0 - const before = line.slice(0, start) - const after = line.slice(start + url.length) + const url = m[0]; + const start = m.index ?? 0; + const before = line.slice(0, start); + const after = line.slice(start + url.length); return ( {before} {url} {after} - ) + ); })} )} @@ -72,5 +61,5 @@ export function AwsAuthStatusBox(): React.ReactNode { )} - ) + ); } diff --git a/src/components/BaseTextInput.tsx b/src/components/BaseTextInput.tsx index 07d12974b..a81af8e2a 100644 --- a/src/components/BaseTextInput.tsx +++ b/src/components/BaseTextInput.tsx @@ -1,23 +1,20 @@ -import React from 'react' -import { renderPlaceholder } from '../hooks/renderPlaceholder.js' -import { usePasteHandler } from '../hooks/usePasteHandler.js' -import { useDeclaredCursor } from '../ink/hooks/use-declared-cursor.js' -import { Ansi, Box, Text, useInput } from '../ink.js' -import type { - BaseInputState, - BaseTextInputProps, -} from '../types/textInputTypes.js' -import type { TextHighlight } from '../utils/textHighlighting.js' -import { HighlightedInput } from './PromptInput/ShimmeredInput.js' +import React from 'react'; +import { renderPlaceholder } from '../hooks/renderPlaceholder.js'; +import { usePasteHandler } from '../hooks/usePasteHandler.js'; +import { useDeclaredCursor } from '../ink/hooks/use-declared-cursor.js'; +import { Ansi, Box, Text, useInput } from '../ink.js'; +import type { BaseInputState, BaseTextInputProps } from '../types/textInputTypes.js'; +import type { TextHighlight } from '../utils/textHighlighting.js'; +import { HighlightedInput } from './PromptInput/ShimmeredInput.js'; type BaseTextInputComponentProps = BaseTextInputProps & { - inputState: BaseInputState - children?: React.ReactNode - terminalFocus: boolean - highlights?: TextHighlight[] - invert?: (text: string) => string - hidePlaceholderText?: boolean -} + inputState: BaseInputState; + children?: React.ReactNode; + terminalFocus: boolean; + highlights?: TextHighlight[]; + invert?: (text: string) => string; + hidePlaceholderText?: boolean; +}; /** * A base component for text inputs that handles rendering and basic input @@ -30,7 +27,7 @@ export function BaseTextInput({ hidePlaceholderText, ...props }: BaseTextInputComponentProps): React.ReactNode { - const { onInput, renderedValue, cursorLine, cursorColumn } = inputState + const { onInput, renderedValue, cursorLine, cursorColumn } = inputState; // Park the native terminal cursor at the input caret. Terminal emulators // position IME preedit text at the physical cursor, and screen readers / @@ -43,27 +40,27 @@ export function BaseTextInput({ line: cursorLine, column: cursorColumn, active: Boolean(props.focus && props.showCursor && terminalFocus), - }) + }); const { wrappedOnInput, isPasting } = usePasteHandler({ onPaste: props.onPaste, onInput: (input, key) => { // Prevent Enter key from triggering submission during paste if (isPasting && key.return) { - return + return; } - onInput(input, key) + onInput(input, key); }, onImagePaste: props.onImagePaste, - }) + }); // Notify parent when paste state changes - const { onIsPastingChange } = props + const { onIsPastingChange } = props; React.useEffect(() => { if (onIsPastingChange) { - onIsPastingChange(isPasting) + onIsPastingChange(isPasting); } - }, [isPasting, onIsPastingChange]) + }, [isPasting, onIsPastingChange]); const { showPlaceholder, renderedPlaceholder } = renderPlaceholder({ placeholder: props.placeholder, @@ -73,9 +70,9 @@ export function BaseTextInput({ terminalFocus, invert, hidePlaceholderText, - }) + }); - useInput(wrappedOnInput, { isActive: props.focus }) + useInput(wrappedOnInput, { isActive: props.focus }); // Show argument hint only when we have a value and the hint is provided // Only show the argument hint when: @@ -84,30 +81,21 @@ export function BaseTextInput({ // 3. The command doesn't have arguments yet (no text after the space) // 4. We're actually typing a command (the value starts with /) const commandWithoutArgs = - (props.value && props.value.trim().indexOf(' ') === -1) || - (props.value && props.value.endsWith(' ')) + (props.value && props.value.trim().indexOf(' ') === -1) || (props.value && props.value.endsWith(' ')); const showArgumentHint = Boolean( - props.argumentHint && - props.value && - commandWithoutArgs && - props.value.startsWith('/'), - ) + props.argumentHint && props.value && commandWithoutArgs && props.value.startsWith('/'), + ); // Filter out highlights that contain the cursor position const cursorFiltered = props.showCursor && props.highlights - ? props.highlights.filter( - h => - h.dimColor || - props.cursorOffset < h.start || - props.cursorOffset >= h.end, - ) - : props.highlights + ? props.highlights.filter(h => h.dimColor || props.cursorOffset < h.start || props.cursorOffset >= h.end) + : props.highlights; // Adjust highlights for viewport windowing: highlight positions reference the // full input text, but renderedValue only contains the windowed subset. - const { viewportCharOffset, viewportCharEnd } = inputState + const { viewportCharOffset, viewportCharEnd } = inputState; const filteredHighlights = cursorFiltered && viewportCharOffset > 0 ? cursorFiltered @@ -117,17 +105,14 @@ export function BaseTextInput({ start: Math.max(0, h.start - viewportCharOffset), end: h.end - viewportCharOffset, })) - : cursorFiltered + : cursorFiltered; - const hasHighlights = filteredHighlights && filteredHighlights.length > 0 + const hasHighlights = filteredHighlights && filteredHighlights.length > 0; if (hasHighlights) { return ( - + {showArgumentHint && ( {props.value?.endsWith(' ') ? '' : ' '} @@ -136,7 +121,7 @@ export function BaseTextInput({ )} {children} - ) + ); } return ( @@ -158,5 +143,5 @@ export function BaseTextInput({ {children} - ) + ); } diff --git a/src/components/BashModeProgress.tsx b/src/components/BashModeProgress.tsx index 0b6d4b408..748f00a20 100644 --- a/src/components/BashModeProgress.tsx +++ b/src/components/BashModeProgress.tsx @@ -1,27 +1,20 @@ -import React from 'react' -import { Box } from '../ink.js' -import { BashTool } from '../tools/BashTool/BashTool.js' -import type { ShellProgress } from '../types/tools.js' -import { UserBashInputMessage } from './messages/UserBashInputMessage.js' -import { ShellProgressMessage } from './shell/ShellProgressMessage.js' +import React from 'react'; +import { Box } from '../ink.js'; +import { BashTool } from '../tools/BashTool/BashTool.js'; +import type { ShellProgress } from '../types/tools.js'; +import { UserBashInputMessage } from './messages/UserBashInputMessage.js'; +import { ShellProgressMessage } from './shell/ShellProgressMessage.js'; type Props = { - input: string - progress: ShellProgress | null - verbose: boolean -} + input: string; + progress: ShellProgress | null; + verbose: boolean; +}; -export function BashModeProgress({ - input, - progress, - verbose, -}: Props): React.ReactNode { +export function BashModeProgress({ input, progress, verbose }: Props): React.ReactNode { return ( - ${input}`, type: 'text' }} - /> + ${input}`, type: 'text' }} /> {progress ? ( - ) + ); } diff --git a/src/components/BridgeDialog.tsx b/src/components/BridgeDialog.tsx index 9a23311fb..59315770f 100644 --- a/src/components/BridgeDialog.tsx +++ b/src/components/BridgeDialog.tsx @@ -1,67 +1,64 @@ -import { basename } from 'path' -import { toString as qrToString } from 'qrcode' -import * as React from 'react' -import { useEffect, useState } from 'react' -import { getOriginalCwd } from '../bootstrap/state.js' +import { basename } from 'path'; +import { toString as qrToString } from 'qrcode'; +import * as React from 'react'; +import { useEffect, useState } from 'react'; +import { getOriginalCwd } from '../bootstrap/state.js'; import { buildActiveFooterText, buildIdleFooterText, FAILED_FOOTER_TEXT, getBridgeStatus, -} from '../bridge/bridgeStatusUtil.js' -import { - BRIDGE_FAILED_INDICATOR, - BRIDGE_READY_INDICATOR, -} from '../constants/figures.js' -import { useRegisterOverlay } from '../context/overlayContext.js' +} from '../bridge/bridgeStatusUtil.js'; +import { BRIDGE_FAILED_INDICATOR, BRIDGE_READY_INDICATOR } from '../constants/figures.js'; +import { useRegisterOverlay } from '../context/overlayContext.js'; // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw 'd' key for disconnect, not a configurable keybinding action -import { Box, Text, useInput } from '../ink.js' -import { useKeybindings } from '../keybindings/useKeybinding.js' -import { useAppState, useSetAppState } from '../state/AppState.js' -import { saveGlobalConfig } from '../utils/config.js' -import { getBranch } from '../utils/git.js' -import { Dialog } from './design-system/Dialog.js' +import { Box, Text, useInput } from '../ink.js'; +import { useKeybindings } from '../keybindings/useKeybinding.js'; +import { useAppState, useSetAppState } from '../state/AppState.js'; +import { saveGlobalConfig } from '../utils/config.js'; +import { getBranch } from '../utils/git.js'; +import { Dialog } from './design-system/Dialog.js'; type Props = { - onDone: () => void -} + onDone: () => void; +}; export function BridgeDialog({ onDone }: Props): React.ReactNode { - useRegisterOverlay('bridge-dialog') + useRegisterOverlay('bridge-dialog'); - const connected = useAppState(s => s.replBridgeConnected) - const sessionActive = useAppState(s => s.replBridgeSessionActive) - const reconnecting = useAppState(s => s.replBridgeReconnecting) - const connectUrl = useAppState(s => s.replBridgeConnectUrl) - const sessionUrl = useAppState(s => s.replBridgeSessionUrl) - const error = useAppState(s => s.replBridgeError) - const explicit = useAppState(s => s.replBridgeExplicit) - const environmentId = useAppState(s => s.replBridgeEnvironmentId) - const sessionId = useAppState(s => s.replBridgeSessionId) - const verbose = useAppState(s => s.verbose) - const setAppState = useSetAppState() + const connected = useAppState(s => s.replBridgeConnected); + const sessionActive = useAppState(s => s.replBridgeSessionActive); + const reconnecting = useAppState(s => s.replBridgeReconnecting); + const connectUrl = useAppState(s => s.replBridgeConnectUrl); + const sessionUrl = useAppState(s => s.replBridgeSessionUrl); + const error = useAppState(s => s.replBridgeError); + const explicit = useAppState(s => s.replBridgeExplicit); + const environmentId = useAppState(s => s.replBridgeEnvironmentId); + const sessionId = useAppState(s => s.replBridgeSessionId); + const verbose = useAppState(s => s.verbose); + const setAppState = useSetAppState(); - const [showQR, setShowQR] = useState(false) - const [qrText, setQrText] = useState('') - const [branchName, setBranchName] = useState('') + const [showQR, setShowQR] = useState(false); + const [qrText, setQrText] = useState(''); + const [branchName, setBranchName] = useState(''); - const repoName = basename(getOriginalCwd()) + const repoName = basename(getOriginalCwd()); // Fetch branch name on mount useEffect(() => { getBranch() .then(setBranchName) - .catch(() => {}) - }, []) + .catch(() => {}); + }, []); // The URL to display/QR: session URL when connected, connect URL when ready - 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', @@ -69,18 +66,18 @@ export function BridgeDialog({ onDone }: Props): React.ReactNode { small: true, }) .then(setQrText) - .catch(() => setQrText('')) - }, [showQR, displayUrl]) + .catch(() => setQrText('')); + }, [showQR, displayUrl]); useKeybindings( { 'confirm:yes': onDone, 'confirm:toggle': () => { - setShowQR(prev => !prev) + setShowQR(prev => !prev); }, }, { context: 'Confirmation' }, - ) + ); useInput(input => { if (input === 'd') { @@ -90,33 +87,32 @@ export function BridgeDialog({ onDone }: Props): React.ReactNode { // GB-rollout user out permanently. if (explicit) { saveGlobalConfig(current => { - if (current.remoteControlAtStartup === false) return current - return { ...current, remoteControlAtStartup: false } - }) + if (current.remoteControlAtStartup === false) return current; + return { ...current, remoteControlAtStartup: false }; + }); } setAppState(prev => { - if (!prev.replBridgeEnabled) return prev - return { ...prev, replBridgeEnabled: false } - }) - onDone() + if (!prev.replBridgeEnabled) return prev; + return { ...prev, replBridgeEnabled: false }; + }); + onDone(); } - }) + }); const { label: statusLabel, color: statusColor } = getBridgeStatus({ error, connected, sessionActive, reconnecting, - }) - const indicator = error ? BRIDGE_FAILED_INDICATOR : BRIDGE_READY_INDICATOR - const qrLines = qrText ? qrText.split('\n').filter(l => l.length > 0) : [] + }); + const indicator = error ? BRIDGE_FAILED_INDICATOR : BRIDGE_READY_INDICATOR; + const qrLines = qrText ? qrText.split('\n').filter(l => l.length > 0) : []; // Build suffix with repo and branch (matches standalone bridge format) - const contextParts: string[] = [] - if (repoName) contextParts.push(repoName) - if (branchName) contextParts.push(branchName) - const contextSuffix = - contextParts.length > 0 ? ' \u00b7 ' + contextParts.join(' \u00b7 ') : '' + const contextParts: string[] = []; + if (repoName) contextParts.push(repoName); + if (branchName) contextParts.push(branchName); + const contextSuffix = contextParts.length > 0 ? ' \u00b7 ' + contextParts.join(' \u00b7 ') : ''; // Footer text matches standalone bridge const footerText = error @@ -125,7 +121,7 @@ export function BridgeDialog({ onDone }: Props): React.ReactNode { ? sessionActive ? buildActiveFooterText(displayUrl) : buildIdleFooterText(displayUrl) - : undefined + : undefined; return ( @@ -138,9 +134,7 @@ export function BridgeDialog({ onDone }: Props): React.ReactNode { {contextSuffix} {error && {error}} - {verbose && environmentId && ( - Environment: {environmentId} - )} + {verbose && environmentId && Environment: {environmentId}} {verbose && sessionId && Session: {sessionId}} {showQR && qrLines.length > 0 && ( @@ -151,10 +145,8 @@ export function BridgeDialog({ onDone }: Props): React.ReactNode { )} {footerText && {footerText}} - - d to disconnect · space for QR code · Enter/Esc to close - + d to disconnect · space for QR code · Enter/Esc to close - ) + ); } diff --git a/src/components/BypassPermissionsModeDialog.tsx b/src/components/BypassPermissionsModeDialog.tsx index adc708c77..7053fe4fc 100644 --- a/src/components/BypassPermissionsModeDialog.tsx +++ b/src/components/BypassPermissionsModeDialog.tsx @@ -1,61 +1,54 @@ -import React, { useCallback } from 'react' -import { logEvent } from 'src/services/analytics/index.js' -import { Box, Link, Newline, Text } from '../ink.js' -import { gracefulShutdownSync } from '../utils/gracefulShutdown.js' -import { updateSettingsForSource } from '../utils/settings/settings.js' -import { Select } from './CustomSelect/index.js' -import { Dialog } from './design-system/Dialog.js' +import React, { useCallback } from 'react'; +import { logEvent } from 'src/services/analytics/index.js'; +import { Box, Link, Newline, Text } from '../ink.js'; +import { gracefulShutdownSync } from '../utils/gracefulShutdown.js'; +import { updateSettingsForSource } from '../utils/settings/settings.js'; +import { Select } from './CustomSelect/index.js'; +import { Dialog } from './design-system/Dialog.js'; type Props = { - onAccept(): void -} + onAccept(): void; +}; -export function BypassPermissionsModeDialog({ - onAccept, -}: Props): React.ReactNode { +export function BypassPermissionsModeDialog({ onAccept }: Props): React.ReactNode { React.useEffect(() => { - logEvent('tengu_bypass_permissions_mode_dialog_shown', {}) - }, []) + logEvent('tengu_bypass_permissions_mode_dialog_shown', {}); + }, []); function onChange(value: 'accept' | 'decline') { switch (value) { case 'accept': { - logEvent('tengu_bypass_permissions_mode_dialog_accept', {}) + logEvent('tengu_bypass_permissions_mode_dialog_accept', {}); updateSettingsForSource('userSettings', { skipDangerousModePermissionPrompt: true, - }) - onAccept() - break + }); + onAccept(); + break; } case 'decline': { - gracefulShutdownSync(1) - break + gracefulShutdownSync(1); + break; } } } const handleEscape = useCallback(() => { - gracefulShutdownSync(0) - }, []) + gracefulShutdownSync(0); + }, []); return ( - + - In Bypass Permissions mode, Claude Code will not ask for your approval - before running potentially dangerous commands. + In Bypass Permissions mode, Claude Code will not ask for your approval before running potentially dangerous + commands. - This mode should only be used in a sandboxed container/VM that has - restricted internet access and can easily be restored if damaged. + This mode should only be used in a sandboxed container/VM that has restricted internet access and can easily + be restored if damaged. - By proceeding, you accept all responsibility for actions taken while - running in Bypass Permissions mode. + By proceeding, you accept all responsibility for actions taken while running in Bypass Permissions mode. @@ -69,5 +62,5 @@ export function BypassPermissionsModeDialog({ onChange={value => onChange(value as 'accept' | 'decline')} /> - ) + ); } diff --git a/src/components/ChannelDowngradeDialog.tsx b/src/components/ChannelDowngradeDialog.tsx index 54db87690..b2b79a1cd 100644 --- a/src/components/ChannelDowngradeDialog.tsx +++ b/src/components/ChannelDowngradeDialog.tsx @@ -1,42 +1,32 @@ -import React from 'react' -import { Text } from '../ink.js' -import { Select } from './CustomSelect/index.js' -import { Dialog } from './design-system/Dialog.js' +import React from 'react'; +import { Text } from '../ink.js'; +import { Select } from './CustomSelect/index.js'; +import { Dialog } from './design-system/Dialog.js'; -export type ChannelDowngradeChoice = 'downgrade' | 'stay' | 'cancel' +export type ChannelDowngradeChoice = 'downgrade' | 'stay' | 'cancel'; type Props = { - currentVersion: string - onChoice: (choice: ChannelDowngradeChoice) => void -} + currentVersion: string; + onChoice: (choice: ChannelDowngradeChoice) => void; +}; /** * Dialog shown when switching from latest to stable channel. * Allows user to choose whether to downgrade or stay on current version. */ -export function ChannelDowngradeDialog({ - currentVersion, - onChoice, -}: Props): React.ReactNode { +export function ChannelDowngradeDialog({ currentVersion, onChoice }: Props): React.ReactNode { function handleSelect(value: ChannelDowngradeChoice): void { - onChoice(value) + onChoice(value); } function handleCancel(): void { - onChoice('cancel') + onChoice('cancel'); } return ( - + - The stable channel may have an older version than what you're - currently running ({currentVersion}). + The stable channel may have an older version than what you're currently running ({currentVersion}). How would you like to handle this? onResponse('no')} - /> + handleSelect('not-now')} - /> + + + - - You can also configure this in /config or with the --ide flag - + You can also configure this in /config or with the --ide flag - ) + ); } export function shouldShowAutoConnectDialog(): boolean { - const config = getGlobalConfig() - return ( - !isSupportedTerminal() && - config.autoConnectIde !== true && - config.hasIdeAutoConnectDialogBeenShown !== true - ) + const config = getGlobalConfig(); + return !isSupportedTerminal() && config.autoConnectIde !== true && config.hasIdeAutoConnectDialogBeenShown !== true; } type IdeDisableAutoConnectDialogProps = { - onComplete: (disableAutoConnect: boolean) => void -} + onComplete: (disableAutoConnect: boolean) => void; +}; -export function IdeDisableAutoConnectDialog({ - onComplete, -}: IdeDisableAutoConnectDialogProps): React.ReactNode { +export function IdeDisableAutoConnectDialog({ onComplete }: IdeDisableAutoConnectDialogProps): React.ReactNode { const handleSelect = useCallback( (value: string) => { - const disableAutoConnect = value === 'yes' + const disableAutoConnect = value === 'yes'; if (disableAutoConnect) { saveGlobalConfig(current => ({ ...current, autoConnectIde: false, - })) + })); } - onComplete(disableAutoConnect) + onComplete(disableAutoConnect); }, [onComplete], - ) + ); const handleCancel = useCallback(() => { - onComplete(false) - }, [onComplete]) + onComplete(false); + }, [onComplete]); const options = [ { label: 'No', value: 'no' }, { label: 'Yes', value: 'yes' }, - ] + ]; return ( onDone(value)} /> - ) + ); } function formatIdleDuration(minutes: number): string { if (minutes < 1) { - return '< 1m' + return '< 1m'; } if (minutes < 60) { - return `${Math.floor(minutes)}m` + return `${Math.floor(minutes)}m`; } - const hours = Math.floor(minutes / 60) - const remainingMinutes = Math.floor(minutes % 60) + const hours = Math.floor(minutes / 60); + const remainingMinutes = Math.floor(minutes % 60); if (remainingMinutes === 0) { - return `${hours}h` + return `${hours}h`; } - return `${hours}h ${remainingMinutes}m` + return `${hours}h ${remainingMinutes}m`; } diff --git a/src/components/InterruptedByUser.tsx b/src/components/InterruptedByUser.tsx index 0a77c7153..471ccf294 100644 --- a/src/components/InterruptedByUser.tsx +++ b/src/components/InterruptedByUser.tsx @@ -1,5 +1,5 @@ -import * as React from 'react' -import { Text } from '../ink.js' +import * as React from 'react'; +import { Text } from '../ink.js'; export function InterruptedByUser(): React.ReactNode { return ( @@ -11,5 +11,5 @@ export function InterruptedByUser(): React.ReactNode { · What should Claude do instead? )} - ) + ); } diff --git a/src/components/InvalidConfigDialog.tsx b/src/components/InvalidConfigDialog.tsx index 8fa3bba97..d387f044c 100644 --- a/src/components/InvalidConfigDialog.tsx +++ b/src/components/InvalidConfigDialog.tsx @@ -1,26 +1,23 @@ -import React from 'react' -import { Box, render, Text } from '../ink.js' -import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js' -import { AppStateProvider } from '../state/AppState.js' -import type { ConfigParseError } from '../utils/errors.js' -import { getBaseRenderOptions } from '../utils/renderOptions.js' -import { - jsonStringify, - writeFileSync_DEPRECATED, -} from '../utils/slowOperations.js' -import type { ThemeName } from '../utils/theme.js' -import { Select } from './CustomSelect/index.js' -import { Dialog } from './design-system/Dialog.js' +import React from 'react'; +import { Box, render, Text } from '../ink.js'; +import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js'; +import { AppStateProvider } from '../state/AppState.js'; +import type { ConfigParseError } from '../utils/errors.js'; +import { getBaseRenderOptions } from '../utils/renderOptions.js'; +import { jsonStringify, writeFileSync_DEPRECATED } from '../utils/slowOperations.js'; +import type { ThemeName } from '../utils/theme.js'; +import { Select } from './CustomSelect/index.js'; +import { Dialog } from './design-system/Dialog.js'; interface InvalidConfigHandlerProps { - error: ConfigParseError + error: ConfigParseError; } interface InvalidConfigDialogProps { - filePath: string - errorDescription: string - onExit: () => void - onReset: () => void + filePath: string; + errorDescription: string; + onExit: () => void; + onReset: () => void; } /** @@ -35,18 +32,17 @@ function InvalidConfigDialog({ // Handler for Select onChange const handleSelect = (value: string) => { if (value === 'exit') { - onExit() + onExit(); } else { - onReset() + onReset(); } - } + }; return ( - The configuration file at {filePath} contains - invalid JSON. + The configuration file at {filePath} contains invalid JSON. {errorDescription} @@ -62,27 +58,25 @@ function InvalidConfigDialog({ /> - ) + ); } /** * Safe fallback theme name for error dialogs to avoid circular dependency. * Uses a hardcoded dark theme that doesn't require reading from config. */ -const SAFE_ERROR_THEME_NAME: ThemeName = 'dark' +const SAFE_ERROR_THEME_NAME: ThemeName = 'dark'; -export async function showInvalidConfigDialog({ - error, -}: InvalidConfigHandlerProps): Promise { +export async function showInvalidConfigDialog({ error }: InvalidConfigHandlerProps): Promise { // Extend RenderOptions with theme property for this specific usage - type SafeRenderOptions = Parameters[1] & { theme?: ThemeName } + type SafeRenderOptions = Parameters[1] & { theme?: ThemeName }; const renderOptions: SafeRenderOptions = { ...getBaseRenderOptions(false), // IMPORTANT: Use hardcoded theme name to avoid circular dependency with getGlobalConfig() // This allows the error dialog to show even when config file has JSON syntax errors theme: SAFE_ERROR_THEME_NAME, - } + }; await new Promise(async resolve => { const { unmount } = await render( @@ -92,24 +86,23 @@ export async function showInvalidConfigDialog({ filePath={error.filePath} errorDescription={error.message} onExit={() => { - unmount() - void resolve() - process.exit(1) + unmount(); + void resolve(); + process.exit(1); }} onReset={() => { - writeFileSync_DEPRECATED( - error.filePath, - jsonStringify(error.defaultConfig, null, 2), - { flush: false, encoding: 'utf8' }, - ) - unmount() - void resolve() - process.exit(0) + writeFileSync_DEPRECATED(error.filePath, jsonStringify(error.defaultConfig, null, 2), { + flush: false, + encoding: 'utf8', + }); + unmount(); + void resolve(); + process.exit(0); }} /> , renderOptions, - ) - }) + ); + }); } diff --git a/src/components/InvalidSettingsDialog.tsx b/src/components/InvalidSettingsDialog.tsx index c1fddf96a..db3a99780 100644 --- a/src/components/InvalidSettingsDialog.tsx +++ b/src/components/InvalidSettingsDialog.tsx @@ -1,39 +1,33 @@ -import React from 'react' -import { Text } from '../ink.js' -import type { ValidationError } from '../utils/settings/validation.js' -import { Select } from './CustomSelect/index.js' -import { Dialog } from './design-system/Dialog.js' -import { ValidationErrorsList } from './ValidationErrorsList.js' +import React from 'react'; +import { Text } from '../ink.js'; +import type { ValidationError } from '../utils/settings/validation.js'; +import { Select } from './CustomSelect/index.js'; +import { Dialog } from './design-system/Dialog.js'; +import { ValidationErrorsList } from './ValidationErrorsList.js'; type Props = { - settingsErrors: ValidationError[] - onContinue: () => void - onExit: () => void -} + settingsErrors: ValidationError[]; + onContinue: () => void; + onExit: () => void; +}; /** * Dialog shown when settings files have validation errors. * User must choose to continue (skipping invalid files) or exit to fix them. */ -export function InvalidSettingsDialog({ - settingsErrors, - onContinue, - onExit, -}: Props): React.ReactNode { +export function InvalidSettingsDialog({ settingsErrors, onContinue, onExit }: Props): React.ReactNode { function handleSelect(value: string): void { if (value === 'exit') { - onExit() + onExit(); } else { - onContinue() + onContinue(); } } return ( - - Files with errors are skipped entirely, not just the invalid settings. - + Files with errors are skipped entirely, not just the invalid settings. onResponse('no')} - /> + onChange('no')} /> - ) + ); } diff --git a/src/components/MCPServerDesktopImportDialog.tsx b/src/components/MCPServerDesktopImportDialog.tsx index 50b9ef6d6..2a3adb564 100644 --- a/src/components/MCPServerDesktopImportDialog.tsx +++ b/src/components/MCPServerDesktopImportDialog.tsx @@ -1,69 +1,57 @@ -import React, { useCallback, useEffect, useState } from 'react' -import { gracefulShutdown } from 'src/utils/gracefulShutdown.js' -import { writeToStdout } from 'src/utils/process.js' -import { Box, color, Text, useTheme } from '../ink.js' -import { addMcpConfig, getAllMcpConfigs } from '../services/mcp/config.js' -import type { - ConfigScope, - McpServerConfig, - ScopedMcpServerConfig, -} from '../services/mcp/types.js' -import { plural } from '../utils/stringUtils.js' -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' -import { SelectMulti } from './CustomSelect/SelectMulti.js' -import { Byline } from './design-system/Byline.js' -import { Dialog } from './design-system/Dialog.js' -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' +import React, { useCallback, useEffect, useState } from 'react'; +import { gracefulShutdown } from 'src/utils/gracefulShutdown.js'; +import { writeToStdout } from 'src/utils/process.js'; +import { Box, color, Text, useTheme } from '../ink.js'; +import { addMcpConfig, getAllMcpConfigs } from '../services/mcp/config.js'; +import type { ConfigScope, McpServerConfig, ScopedMcpServerConfig } from '../services/mcp/types.js'; +import { plural } from '../utils/stringUtils.js'; +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; +import { SelectMulti } from './CustomSelect/SelectMulti.js'; +import { Byline } from './design-system/Byline.js'; +import { Dialog } from './design-system/Dialog.js'; +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; type Props = { - servers: Record - scope: ConfigScope - onDone(): void -} + servers: Record; + scope: ConfigScope; + onDone(): void; +}; -export function MCPServerDesktopImportDialog({ - servers, - scope, - onDone, -}: Props): React.ReactNode { - const serverNames = Object.keys(servers) - const [existingServers, setExistingServers] = useState< - Record - >({}) +export function MCPServerDesktopImportDialog({ servers, scope, onDone }: Props): React.ReactNode { + const serverNames = Object.keys(servers); + const [existingServers, setExistingServers] = useState>({}); useEffect(() => { - void getAllMcpConfigs().then(({ servers }) => setExistingServers(servers)) - }, []) + void getAllMcpConfigs().then(({ servers }) => setExistingServers(servers)); + }, []); - const collisions = serverNames.filter( - name => existingServers[name] !== undefined, - ) + const collisions = serverNames.filter(name => existingServers[name] !== undefined); async function onSubmit(selectedServers: string[]) { - let importedCount = 0 + let importedCount = 0; for (const serverName of selectedServers) { - const serverConfig = servers[serverName] + const serverConfig = servers[serverName]; if (serverConfig) { // If the server name already exists, find a new name with _1, _2, etc. - let finalName = serverName + let finalName = serverName; if (existingServers[finalName] !== undefined) { - let counter = 1 + let counter = 1; while (existingServers[`${serverName}_${counter}`] !== undefined) { - counter++ + counter++; } - finalName = `${serverName}_${counter}` + finalName = `${serverName}_${counter}`; } - await addMcpConfig(finalName, serverConfig, scope) - importedCount++ + await addMcpConfig(finalName, serverConfig, scope); + importedCount++; } } - done(importedCount) + done(importedCount); } - const [theme] = useTheme() + const [theme] = useTheme(); // Define done before using in useCallback const done = useCallback( @@ -71,21 +59,21 @@ export function MCPServerDesktopImportDialog({ if (importedCount > 0) { writeToStdout( `\n${color('success', theme)(`Successfully imported ${importedCount} MCP ${plural(importedCount, 'server')} to ${scope} config.`)}\n`, - ) + ); } else { - writeToStdout('\nNo servers were imported.') + writeToStdout('\nNo servers were imported.'); } - onDone() + onDone(); - void gracefulShutdown() + void gracefulShutdown(); }, [theme, scope, onDone], - ) + ); // Handle ESC to cancel (import 0 servers) const handleEscCancel = useCallback(() => { - done(0) - }, [done]) + done(0); + }, [done]); return ( <> @@ -98,8 +86,8 @@ export function MCPServerDesktopImportDialog({ > {collisions.length > 0 && ( - Note: Some servers already exist with the same name. If selected, - they will be imported with a numbered suffix. + Note: Some servers already exist with the same name. If selected, they will be imported with a numbered + suffix. )} Please select the servers you want to import: @@ -120,15 +108,10 @@ export function MCPServerDesktopImportDialog({ - + - ) + ); } diff --git a/src/components/MCPServerDialogCopy.tsx b/src/components/MCPServerDialogCopy.tsx index 93dce3655..b04d3695c 100644 --- a/src/components/MCPServerDialogCopy.tsx +++ b/src/components/MCPServerDialogCopy.tsx @@ -1,12 +1,11 @@ -import React from 'react' -import { Link, Text } from '../ink.js' +import React from 'react'; +import { Link, Text } from '../ink.js'; export function MCPServerDialogCopy(): React.ReactNode { return ( - MCP servers may execute code or access system resources. All tool calls - require approval. Learn more in the{' '} + MCP servers may execute code or access system resources. All tool calls require approval. Learn more in the{' '} MCP documentation. - ) + ); } diff --git a/src/components/MCPServerMultiselectDialog.tsx b/src/components/MCPServerMultiselectDialog.tsx index e14c46d46..3688d6890 100644 --- a/src/components/MCPServerMultiselectDialog.tsx +++ b/src/components/MCPServerMultiselectDialog.tsx @@ -1,80 +1,66 @@ -import partition from 'lodash-es/partition.js' -import React, { useCallback } from 'react' -import { logEvent } from 'src/services/analytics/index.js' -import { Box, Text } from '../ink.js' -import { - getSettings_DEPRECATED, - updateSettingsForSource, -} from '../utils/settings/settings.js' -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' -import { SelectMulti } from './CustomSelect/SelectMulti.js' -import { Byline } from './design-system/Byline.js' -import { Dialog } from './design-system/Dialog.js' -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' -import { MCPServerDialogCopy } from './MCPServerDialogCopy.js' +import partition from 'lodash-es/partition.js'; +import React, { useCallback } from 'react'; +import { logEvent } from 'src/services/analytics/index.js'; +import { Box, Text } from '../ink.js'; +import { getSettings_DEPRECATED, updateSettingsForSource } from '../utils/settings/settings.js'; +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; +import { SelectMulti } from './CustomSelect/SelectMulti.js'; +import { Byline } from './design-system/Byline.js'; +import { Dialog } from './design-system/Dialog.js'; +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; +import { MCPServerDialogCopy } from './MCPServerDialogCopy.js'; type Props = { - serverNames: string[] - onDone(): void -} + serverNames: string[]; + onDone(): void; +}; -export function MCPServerMultiselectDialog({ - serverNames, - onDone, -}: Props): React.ReactNode { +export function MCPServerMultiselectDialog({ serverNames, onDone }: Props): React.ReactNode { function onSubmit(selectedServers: string[]) { - const currentSettings = getSettings_DEPRECATED() || {} - const enabledServers = currentSettings.enabledMcpjsonServers || [] - const disabledServers = currentSettings.disabledMcpjsonServers || [] + const currentSettings = getSettings_DEPRECATED() || {}; + const enabledServers = currentSettings.enabledMcpjsonServers || []; + const disabledServers = currentSettings.disabledMcpjsonServers || []; // Use partition to separate approved and rejected servers - const [approvedServers, rejectedServers] = partition(serverNames, server => - selectedServers.includes(server), - ) + const [approvedServers, rejectedServers] = partition(serverNames, server => selectedServers.includes(server)); logEvent('tengu_mcp_multidialog_choice', { approved: approvedServers.length, rejected: rejectedServers.length, - }) + }); // Update settings with approved servers if (approvedServers.length > 0) { - const newEnabledServers = [ - ...new Set([...enabledServers, ...approvedServers]), - ] + const newEnabledServers = [...new Set([...enabledServers, ...approvedServers])]; updateSettingsForSource('localSettings', { enabledMcpjsonServers: newEnabledServers, - }) + }); } // Update settings with rejected servers if (rejectedServers.length > 0) { - const newDisabledServers = [ - ...new Set([...disabledServers, ...rejectedServers]), - ] + const newDisabledServers = [...new Set([...disabledServers, ...rejectedServers])]; updateSettingsForSource('localSettings', { disabledMcpjsonServers: newDisabledServers, - }) + }); } - onDone() + onDone(); } // Handle ESC to reject all servers const handleEscRejectAll = useCallback(() => { - const currentSettings = getSettings_DEPRECATED() || {} - const disabledServers = currentSettings.disabledMcpjsonServers || [] + const currentSettings = getSettings_DEPRECATED() || {}; + const disabledServers = currentSettings.disabledMcpjsonServers || []; - const newDisabledServers = [ - ...new Set([...disabledServers, ...serverNames]), - ] + const newDisabledServers = [...new Set([...disabledServers, ...serverNames])]; updateSettingsForSource('localSettings', { disabledMcpjsonServers: newDisabledServers, - }) + }); - onDone() - }, [serverNames, onDone]) + onDone(); + }, [serverNames, onDone]); return ( <> @@ -113,5 +99,5 @@ export function MCPServerMultiselectDialog({ - ) + ); } diff --git a/src/components/ManagedSettingsSecurityDialog/ManagedSettingsSecurityDialog.tsx b/src/components/ManagedSettingsSecurityDialog/ManagedSettingsSecurityDialog.tsx index 392979770..5c4af6116 100644 --- a/src/components/ManagedSettingsSecurityDialog/ManagedSettingsSecurityDialog.tsx +++ b/src/components/ManagedSettingsSecurityDialog/ManagedSettingsSecurityDialog.tsx @@ -1,52 +1,40 @@ -import React from 'react' -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' -import { Box, Text } from '../../ink.js' -import { useKeybinding } from '../../keybindings/useKeybinding.js' -import type { SettingsJson } from '../../utils/settings/types.js' -import { Select } from '../CustomSelect/index.js' -import { PermissionDialog } from '../permissions/PermissionDialog.js' -import { - extractDangerousSettings, - formatDangerousSettingsList, -} from './utils.js' +import React from 'react'; +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; +import { Box, Text } from '../../ink.js'; +import { useKeybinding } from '../../keybindings/useKeybinding.js'; +import type { SettingsJson } from '../../utils/settings/types.js'; +import { Select } from '../CustomSelect/index.js'; +import { PermissionDialog } from '../permissions/PermissionDialog.js'; +import { extractDangerousSettings, formatDangerousSettingsList } from './utils.js'; type Props = { - settings: SettingsJson - onAccept: () => void - onReject: () => void -} + settings: SettingsJson; + onAccept: () => void; + onReject: () => void; +}; -export function ManagedSettingsSecurityDialog({ - settings, - onAccept, - onReject, -}: Props): React.ReactNode { - const dangerous = extractDangerousSettings(settings) - const settingsList = formatDangerousSettingsList(dangerous) +export function ManagedSettingsSecurityDialog({ settings, onAccept, onReject }: Props): React.ReactNode { + const dangerous = extractDangerousSettings(settings); + const settingsList = formatDangerousSettingsList(dangerous); - const exitState = useExitOnCtrlCDWithKeybindings() + const exitState = useExitOnCtrlCDWithKeybindings(); - useKeybinding('confirm:no', onReject, { context: 'Confirmation' }) + useKeybinding('confirm:no', onReject, { context: 'Confirmation' }); function onChange(value: 'accept' | 'exit'): void { if (value === 'exit') { - onReject() - return + onReject(); + return; } - onAccept() + onAccept(); } return ( - + - Your organization has configured managed settings that could allow - execution of arbitrary code or interception of your prompts and - responses. + Your organization has configured managed settings that could allow execution of arbitrary code or interception + of your prompts and responses. @@ -62,8 +50,8 @@ export function ManagedSettingsSecurityDialog({ - Only accept if you trust your organization's IT administration - and expect these settings to be configured. + Only accept if you trust your organization's IT administration and expect these settings to be + configured. + if (key.ctrl && input === 'c') { - onCancel() - return + onCancel(); + return; } // Handle retry in error state with 'ctrl+r' if (key.ctrl && input === 'r' && loadErrorType) { - handleRetry() - return + handleRetry(); + return; } // Handle enter key for error states to allow continuation with regular teleport if (loadErrorType !== null && key.return) { - onCancel() // This will continue with regular teleport flow - return + onCancel(); // This will continue with regular teleport flow + return; } - }) + }); const handleErrorComplete = useCallback(() => { - setHasCompletedTeleportErrorFlow(true) - void loadSessions() - }, [setHasCompletedTeleportErrorFlow, loadSessions]) + setHasCompletedTeleportErrorFlow(true); + void loadSessions(); + }, [setHasCompletedTeleportErrorFlow, loadSessions]); // Show error dialog if needed if (!hasCompletedTeleportErrorFlow) { - return + return ; } if (loading) { @@ -138,11 +130,9 @@ export function ResumeTask({ Loading Claude Code sessions… - - {retrying ? 'Retrying…' : 'Fetching your Claude Code sessions…'} - + {retrying ? 'Retrying…' : 'Fetching your Claude Code sessions…'} - ) + ); } if (loadErrorType) { @@ -155,11 +145,10 @@ export function ResumeTask({ {renderErrorSpecificGuidance(loadErrorType)} - Press Ctrl+R to retry · Press{' '} - {escKey} to cancel + Press Ctrl+R to retry · Press {escKey} to cancel - ) + ); } if (sessions.length === 0) { @@ -175,41 +164,38 @@ export function ResumeTask({ - ) + ); } const sessionMetadata = sessions.map(session => ({ ...session, timeString: formatRelativeTime(new Date(session.updated_at)), - })) - const maxTimeStringLength = Math.max( - UPDATED_STRING.length, - ...sessionMetadata.map(meta => meta.timeString.length), - ) + })); + const maxTimeStringLength = Math.max(UPDATED_STRING.length, ...sessionMetadata.map(meta => meta.timeString.length)); const options = sessionMetadata.map(({ timeString, title, id }) => { - const paddedTime = timeString.padEnd(maxTimeStringLength, ' ') + const paddedTime = timeString.padEnd(maxTimeStringLength, ' '); // TODO: include branch name when API returns it return { label: `${paddedTime} ${title}`, value: id, - } - }) + }; + }); // Adjust layout for embedded vs full-screen rendering // Overhead: padding (2) + title (1) + marginY (2) + header (1) + footer (1) = 7 - const layoutOverhead = 7 + const layoutOverhead = 7; const maxVisibleOptions = Math.max( 1, isEmbedded ? Math.min(sessions.length, 5, rows - 6 - layoutOverhead) : Math.min(sessions.length, rows - 1 - layoutOverhead), - ) - const maxHeight = maxVisibleOptions + layoutOverhead + ); + const maxHeight = maxVisibleOptions + layoutOverhead; // Show scroll position in title when list needs scrolling - const showScrollPosition = sessions.length > maxVisibleOptions + const showScrollPosition = sessions.length > maxVisibleOptions; return ( @@ -235,15 +221,15 @@ export function ResumeTask({ visibleOptionCount={maxVisibleOptions} options={options} onChange={value => { - const session = sessions.find(s => s.id === value) + const session = sessions.find(s => s.id === value); if (session) { - onSelect(session) + onSelect(session); } }} onFocus={value => { - const index = options.findIndex(o => o.value === value) + const index = options.findIndex(o => o.value === value); if (index >= 0) { - setFocusedIndex(index + 1) + setFocusedIndex(index + 1); } }} /> @@ -253,31 +239,22 @@ export function ResumeTask({ - + - ) + ); } /** * Determines the type of error based on the error message */ function determineErrorType(errorMessage: string): LoadErrorType { - const message = errorMessage.toLowerCase() + const message = errorMessage.toLowerCase(); - if ( - message.includes('fetch') || - message.includes('network') || - message.includes('timeout') - ) { - return 'network' + if (message.includes('fetch') || message.includes('network') || message.includes('timeout')) { + return 'network'; } if ( @@ -290,58 +267,50 @@ function determineErrorType(errorMessage: string): LoadErrorType { message.includes('console account') || message.includes('403') ) { - return 'auth' + return 'auth'; } - if ( - message.includes('api') || - message.includes('rate limit') || - message.includes('500') || - message.includes('529') - ) { - return 'api' + if (message.includes('api') || message.includes('rate limit') || message.includes('500') || message.includes('529')) { + return 'api'; } - return 'other' + return 'other'; } /** * Renders error-specific troubleshooting guidance */ -function renderErrorSpecificGuidance( - errorType: LoadErrorType, -): React.ReactNode { +function renderErrorSpecificGuidance(errorType: LoadErrorType): React.ReactNode { switch (errorType) { case 'network': return ( Check your internet connection - ) + ); case 'auth': return ( Teleport requires a Claude account - Run /login and select "Claude account with - subscription" + Run /login and select "Claude account with subscription" - ) + ); case 'api': return ( Sorry, Claude encountered an error - ) + ); case 'other': return ( Sorry, Claude Code encountered an error - ) + ); } } diff --git a/src/components/SandboxViolationExpandedView.tsx b/src/components/SandboxViolationExpandedView.tsx index 4b8bbbd7a..b460aa968 100644 --- a/src/components/SandboxViolationExpandedView.tsx +++ b/src/components/SandboxViolationExpandedView.tsx @@ -1,53 +1,50 @@ -import * as React from 'react' -import { type ReactNode, useEffect, useState } from 'react' -import { Box, Text } from '../ink.js' -import type { SandboxViolationEvent } from '../utils/sandbox/sandbox-adapter.js' -import { SandboxManager } from '../utils/sandbox/sandbox-adapter.js' +import * as React from 'react'; +import { type ReactNode, useEffect, useState } from 'react'; +import { Box, Text } from '../ink.js'; +import type { SandboxViolationEvent } from '../utils/sandbox/sandbox-adapter.js'; +import { SandboxManager } from '../utils/sandbox/sandbox-adapter.js'; /** * Format a timestamp as "h:mm:ssa" (e.g., "1:30:45pm"). * Replaces date-fns format() to avoid pulling in a 39MB dependency for one call. */ function formatTime(date: Date): string { - const h = date.getHours() % 12 || 12 - const m = String(date.getMinutes()).padStart(2, '0') - const s = String(date.getSeconds()).padStart(2, '0') - const ampm = date.getHours() < 12 ? 'am' : 'pm' - return `${h}:${m}:${s}${ampm}` + const h = date.getHours() % 12 || 12; + const m = String(date.getMinutes()).padStart(2, '0'); + const s = String(date.getSeconds()).padStart(2, '0'); + const ampm = date.getHours() < 12 ? 'am' : 'pm'; + return `${h}:${m}:${s}${ampm}`; } -import { getPlatform } from 'src/utils/platform.js' +import { getPlatform } from 'src/utils/platform.js'; export function SandboxViolationExpandedView(): ReactNode { - const [violations, setViolations] = useState([]) - const [totalCount, setTotalCount] = useState(0) + const [violations, setViolations] = useState([]); + const [totalCount, setTotalCount] = useState(0); useEffect(() => { // This is harmless if sandboxing is not enabled - const store = SandboxManager.getSandboxViolationStore() - const unsubscribe = store.subscribe( - (allViolations: SandboxViolationEvent[]) => { - setViolations(allViolations.slice(-10)) - setTotalCount(store.getTotalCount()) - }, - ) - return unsubscribe - }, []) + const store = SandboxManager.getSandboxViolationStore(); + const unsubscribe = store.subscribe((allViolations: SandboxViolationEvent[]) => { + setViolations(allViolations.slice(-10)); + setTotalCount(store.getTotalCount()); + }); + return unsubscribe; + }, []); if (!SandboxManager.isSandboxingEnabled() || getPlatform() === 'linux') { - return null + return null; } if (totalCount === 0) { - return null + return null; } return ( - ⧈ Sandbox blocked {totalCount} total{' '} - {totalCount === 1 ? 'operation' : 'operations'} + ⧈ Sandbox blocked {totalCount} total {totalCount === 1 ? 'operation' : 'operations'} {violations.map((v, i) => ( @@ -64,5 +61,5 @@ export function SandboxViolationExpandedView(): ReactNode { - ) + ); } diff --git a/src/components/ScrollKeybindingHandler.tsx b/src/components/ScrollKeybindingHandler.tsx index e51787f9f..fabfb6407 100644 --- a/src/components/ScrollKeybindingHandler.tsx +++ b/src/components/ScrollKeybindingHandler.tsx @@ -1,33 +1,30 @@ -import React, { type RefObject, useEffect, useRef } from 'react' -import { useNotifications } from '../context/notifications.js' -import { - useCopyOnSelect, - useSelectionBgColor, -} from '../hooks/useCopyOnSelect.js' -import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js' -import { useSelection } from '../ink/hooks/use-selection.js' -import type { FocusMove, SelectionState } from '../ink/selection.js' -import { isXtermJs } from '../ink/terminal.js' -import { getClipboardPath } from '../ink/termio/osc.js' +import React, { type RefObject, useEffect, useRef } from 'react'; +import { useNotifications } from '../context/notifications.js'; +import { useCopyOnSelect, useSelectionBgColor } from '../hooks/useCopyOnSelect.js'; +import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js'; +import { useSelection } from '../ink/hooks/use-selection.js'; +import type { FocusMove, SelectionState } from '../ink/selection.js'; +import { isXtermJs } from '../ink/terminal.js'; +import { getClipboardPath } from '../ink/termio/osc.js'; // eslint-disable-next-line custom-rules/prefer-use-keybindings -- Esc needs conditional propagation based on selection state -import { type Key, useInput } from '../ink.js' -import { useKeybindings } from '../keybindings/useKeybinding.js' -import { logForDebugging } from '../utils/debug.js' +import { type Key, useInput } from '../ink.js'; +import { useKeybindings } from '../keybindings/useKeybinding.js'; +import { logForDebugging } from '../utils/debug.js'; type Props = { - scrollRef: RefObject - isActive: boolean + scrollRef: RefObject; + isActive: boolean; /** Called after every scroll action with the resulting sticky state and * the handle (for reading scrollTop/scrollHeight post-scroll). */ - onScroll?: (sticky: boolean, handle: ScrollBoxHandle) => void + onScroll?: (sticky: boolean, handle: ScrollBoxHandle) => void; /** Enables modal pager keys (g/G, ctrl+u/d/b/f). Only safe when there * is no text input competing for those characters — i.e. transcript * mode. Defaults to false. When true, G works regardless of editorMode * and sticky state; ctrl+u/d/b/f don't conflict with kill-line/exit/ * task:background/kill-agents (none are mounted, or they mount after * this component so stopImmediatePropagation wins). */ - isModal?: boolean -} + isModal?: boolean; +}; // Terminals send one SGR wheel event per intended row (verified in Ghostty // src/Surface.zig: `for (0..@abs(y.delta)) |_| { mouseReport(.four, ...) }`). @@ -48,9 +45,9 @@ type Props = { // iTerm2 "faster scroll" similar) — base=1 is correct there. Others send 1 // event/notch — users on those can set CLAUDE_CODE_SCROLL_SPEED=3 to match // vim/nvim/opencode app-side defaults. We can't detect which, so knob it. -const WHEEL_ACCEL_WINDOW_MS = 40 -const WHEEL_ACCEL_STEP = 0.3 -const WHEEL_ACCEL_MAX = 6 +const WHEEL_ACCEL_WINDOW_MS = 40; +const WHEEL_ACCEL_STEP = 0.3; +const WHEEL_ACCEL_MAX = 6; // Encoder bounce debounce + wheel-mode decay curve. Worn/cheap optical // encoders emit spurious reverse-direction ticks during fast spins — measured @@ -66,24 +63,24 @@ const WHEEL_ACCEL_MAX = 6 // threshold needed, large gaps just have m≈0 → mult→1. Wheel mode is STICKY: // once a bounce confirms it's a mouse, the decay curve applies until an idle // gap or trackpad-flick-burst signals a possible device switch. -const WHEEL_BOUNCE_GAP_MAX_MS = 200 // flip-back must arrive within this +const WHEEL_BOUNCE_GAP_MAX_MS = 200; // flip-back must arrive within this // Mouse is ~9 events/sec vs VS Code's ~30 — STEP is 3× xterm.js's 5 to // compensate. At gap=100ms (m≈0.63): one click gives 1+15*0.63≈10.5. -const WHEEL_MODE_STEP = 15 -const WHEEL_MODE_CAP = 15 +const WHEEL_MODE_STEP = 15; +const WHEEL_MODE_CAP = 15; // Max mult growth per event. Without this, the +STEP*m term jumps mult // from 1→10 in one event when wheelMode engages mid-scroll (bounce // detected after N events in trackpad mode at mult=1). User sees scroll // suddenly go 10× faster. Cap=3 gives 1→4→7→10→13→15 over ~0.5s at // 9 events/sec — smooth ramp instead of a jump. Decay is unaffected // (target1500ms OR a * trackpad-signature burst (see burstCount). State lives in a useRef so * it persists across device switches; the disengages handle mouse→trackpad. */ - wheelMode: boolean + wheelMode: boolean; /** Consecutive <5ms events. Trackpad flick produces 100+ at <5ms; mouse * produces ≤3 (verified in /tmp/wheel-tune.txt). 5+ in a row → trackpad * signature → disengage wheel mode so device-switch doesn't leak mouse * accel to trackpad. */ - burstCount: number -} + burstCount: number; +}; /** Compute rows for one wheel event, mutating accel state. Returns 0 when * a direction flip is deferred for bounce detection — call sites no-op on * step=0 (scrollBy(0) is a no-op, onScroll(false) is idempotent). Exported * for tests. */ -export function computeWheelStep( - state: WheelAccelState, - dir: 1 | -1, - now: number, -): number { +export function computeWheelStep(state: WheelAccelState, dir: 1 | -1, now: number): number { if (!state.xtermJs) { // Device-switch guard ①: idle disengage. Runs BEFORE pendingFlip resolve // so a pending bounce (28% of last-mouse-events) doesn't bypass it via // the real-reversal early return. state.time is either the last committed // event OR the deferred flip — both count as "last activity". if (state.wheelMode && now - state.time > WHEEL_MODE_IDLE_DISENGAGE_MS) { - state.wheelMode = false - state.burstCount = 0 - state.mult = state.base + state.wheelMode = false; + state.burstCount = 0; + state.mult = state.base; } // Resolve any deferred flip BEFORE touching state.time/dir — we need the // pre-flip state.dir to distinguish bounce (flip-back) from real reversal // (flip persisted), and state.time (= bounce timestamp) for the gap check. if (state.pendingFlip) { - state.pendingFlip = false + state.pendingFlip = false; if (dir !== state.dir || now - state.time > WHEEL_BOUNCE_GAP_MAX_MS) { // Real reversal: new dir persisted, OR flip-back arrived too late. // Commit. The deferred event's 1 row is lost (acceptable latency). - state.dir = dir - state.time = now - state.mult = state.base - return Math.floor(state.mult) + state.dir = dir; + state.time = now; + state.mult = state.base; + return Math.floor(state.mult); } // Bounce confirmed: flipped back to original dir within the window. // state.dir/mult unchanged from pre-bounce. state.time was advanced to // the bounce below, so gap here = flip-back interval — reflects the // user's actual click cadence (bounce IS a physical click, just noisy). - state.wheelMode = true + state.wheelMode = true; } - const gap = now - state.time + const gap = now - state.time; if (dir !== state.dir && state.dir !== 0) { // Flip. Defer — next event decides bounce vs. real reversal. Advance // time (but NOT dir/mult): if this turns out to be a bounce, the // confirm event's gap will be the flip-back interval, which reflects // the user's actual click rate. The bounce IS a physical wheel click, // just misread by the encoder — it should count toward cadence. - state.pendingFlip = true - state.time = now - return 0 + state.pendingFlip = true; + state.time = now; + return 0; } - state.dir = dir - state.time = now + state.dir = dir; + state.time = now; // ─── MOUSE (wheel mode, sticky until device-switch signal) ─── if (state.wheelMode) { @@ -247,14 +240,14 @@ export function computeWheelStep( // Device-switch guard ②: trackpad flick produces 100+ events at <5ms // (measured); mouse produces ≤3. 5+ consecutive → trackpad flick. if (++state.burstCount >= 5) { - state.wheelMode = false - state.burstCount = 0 - state.mult = state.base + state.wheelMode = false; + state.burstCount = 0; + state.mult = state.base; } else { - return 1 + return 1; } } else { - state.burstCount = 0 + state.burstCount = 0; } } // Re-check: may have disengaged above. @@ -263,11 +256,11 @@ export function computeWheelStep( // the curve handles it (gap=1000ms → m≈0.01 → mult≈1). No frac — // rounding loss is minor at high mult, and frac persisting across idle // was causing off-by-one on the first click back. - const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS) - const cap = Math.max(WHEEL_MODE_CAP, state.base * 2) - const next = 1 + (state.mult - 1) * m + WHEEL_MODE_STEP * m - state.mult = Math.min(cap, next, state.mult + WHEEL_MODE_RAMP) - return Math.floor(state.mult) + const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS); + const cap = Math.max(WHEEL_MODE_CAP, state.base * 2); + const next = 1 + (state.mult - 1) * m + WHEEL_MODE_STEP * m; + state.mult = Math.min(cap, next, state.mult + WHEEL_MODE_RAMP); + return Math.floor(state.mult); } // ─── TRACKPAD / HI-RES (native, non-wheel-mode) ─── @@ -275,44 +268,43 @@ export function computeWheelStep( // Trackpad flick delivers 200+ events at <20ms gaps → rails to cap 6. // Trackpad slow swipe at 40-400ms gaps → resets every event → 1 row each. if (gap > WHEEL_ACCEL_WINDOW_MS) { - state.mult = state.base + state.mult = state.base; } else { - const cap = Math.max(WHEEL_ACCEL_MAX, state.base * 2) - state.mult = Math.min(cap, state.mult + WHEEL_ACCEL_STEP) + const cap = Math.max(WHEEL_ACCEL_MAX, state.base * 2); + state.mult = Math.min(cap, state.mult + WHEEL_ACCEL_STEP); } - return Math.floor(state.mult) + return Math.floor(state.mult); } // ─── VSCODE (xterm.js, browser wheel events) ─── // Browser wheel events — no encoder bounce, no SGR bursts. Decay curve // unchanged from the original tuning. Same formula shape as wheel mode // above (keep in sync) but STEP=5 not 15 — higher event rate here. - const gap = now - state.time - const sameDir = dir === state.dir - state.time = now - state.dir = dir + const gap = now - state.time; + const sameDir = dir === state.dir; + state.time = now; + state.dir = dir; // xterm.js path. Debug log shows two patterns: (a) 20-50ms gaps during // sustained scroll (~30 Hz), (b) <5ms same-batch bursts on flicks. For // (b) give 1 row/event — the burst count IS the acceleration, same as // native. For (a) the decay curve gives 3-5 rows. For sparse events // (100ms+, slow deliberate scroll) the curve gives 1-3. - if (sameDir && gap < WHEEL_BURST_MS) return 1 + if (sameDir && gap < WHEEL_BURST_MS) return 1; if (!sameDir || gap > WHEEL_DECAY_IDLE_MS) { // Direction reversal or long idle: start at 2 (not 1) so the first // click after a pause moves a visible amount. Without this, idle- // then-resume in the same direction decays to mult≈1 (1 row). - state.mult = 2 - state.frac = 0 + state.mult = 2; + state.frac = 0; } else { - const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS) - const cap = - gap >= WHEEL_DECAY_GAP_MS ? WHEEL_DECAY_CAP_SLOW : WHEEL_DECAY_CAP_FAST - state.mult = Math.min(cap, 1 + (state.mult - 1) * m + WHEEL_DECAY_STEP * m) + const m = Math.pow(0.5, gap / WHEEL_DECAY_HALFLIFE_MS); + const cap = gap >= WHEEL_DECAY_GAP_MS ? WHEEL_DECAY_CAP_SLOW : WHEEL_DECAY_CAP_FAST; + state.mult = Math.min(cap, 1 + (state.mult - 1) * m + WHEEL_DECAY_STEP * m); } - const total = state.mult + state.frac - const rows = Math.floor(total) - state.frac = total - rows - return rows + const total = state.mult + state.frac; + const rows = Math.floor(total); + state.frac = total - rows; + return rows; } /** Read CLAUDE_CODE_SCROLL_SPEED, default 1, clamp (0, 20]. @@ -322,10 +314,10 @@ export function computeWheelStep( * detect which kind of terminal we're in, hence the knob. Called lazily * from initAndLogWheelAccel so globalSettings.env has loaded. */ export function readScrollSpeedBase(): number { - const raw = process.env.CLAUDE_CODE_SCROLL_SPEED - if (!raw) return 1 - const n = parseFloat(raw) - return Number.isNaN(n) || n <= 0 ? 1 : Math.min(n, 20) + const raw = process.env.CLAUDE_CODE_SCROLL_SPEED; + if (!raw) return 1; + const n = parseFloat(raw); + return Number.isNaN(n) || n <= 0 ? 1 : Math.min(n, 20); } /** Initial wheel accel state. xtermJs=true selects the decay curve. @@ -341,7 +333,7 @@ export function initWheelAccel(xtermJs = false, base = 1): WheelAccelState { pendingFlip: false, wheelMode: false, burstCount: 0, - } + }; } // Lazy-init helper. isXtermJs() combines the TERM_PROGRAM env check + async @@ -351,25 +343,25 @@ export function initWheelAccel(xtermJs = false, base = 1): WheelAccelState { // The renderer also calls isXtermJsHost() (in render-node-to-output) to // select the drain algorithm — no state to pass through. function initAndLogWheelAccel(): WheelAccelState { - const xtermJs = isXtermJs() - const base = readScrollSpeedBase() + const xtermJs = isXtermJs(); + const base = readScrollSpeedBase(); logForDebugging( `wheel accel: ${xtermJs ? 'decay (xterm.js)' : 'window (native)'} · base=${base} · TERM_PROGRAM=${process.env.TERM_PROGRAM ?? 'unset'}`, - ) - return initWheelAccel(xtermJs, base) + ); + return initWheelAccel(xtermJs, base); } // Drag-to-scroll: when dragging past the viewport edge, scroll by this many // rows every AUTOSCROLL_INTERVAL_MS. Mode 1002 mouse tracking only fires on // cell change, so a timer is needed to continue scrolling while stationary. -const AUTOSCROLL_LINES = 2 -const AUTOSCROLL_INTERVAL_MS = 50 +const AUTOSCROLL_LINES = 2; +const AUTOSCROLL_INTERVAL_MS = 50; // Hard cap on consecutive auto-scroll ticks. If the release event is lost // (mouse released outside terminal window — some emulators don't capture the // pointer and drop the release), isDragging stays true and the timer would // run until a scroll boundary. Cap bounds the damage; any new drag motion // event restarts the count via check()→start(). -const AUTOSCROLL_MAX_TICKS = 200 // 10s @ 50ms +const AUTOSCROLL_MAX_TICKS = 200; // 10s @ 50ms /** * Keyboard scroll navigation for the fullscreen layout's message scroll box. @@ -377,36 +369,31 @@ const AUTOSCROLL_MAX_TICKS = 200 // 10s @ 50ms * Scrolling breaks sticky mode; Ctrl+End re-enables it. Wheeling down at * the bottom also re-enables sticky so new content follows naturally. */ -export function ScrollKeybindingHandler({ - scrollRef, - isActive, - onScroll, - isModal = false, -}: Props): React.ReactNode { - const selection = useSelection() - const { addNotification } = useNotifications() +export function ScrollKeybindingHandler({ scrollRef, isActive, onScroll, isModal = false }: Props): React.ReactNode { + const selection = useSelection(); + const { addNotification } = useNotifications(); // Lazy-inited on first wheel event so the XTVERSION probe (fired at // raw-mode-enable time) has resolved by then — initializing in useRef() // would read getWheelBase() before the probe reply arrives over SSH. - const wheelAccel = useRef(null) + const wheelAccel = useRef(null); function showCopiedToast(text: string): void { // getClipboardPath reads env synchronously — predicts what setClipboard // did (native pbcopy / tmux load-buffer / raw OSC 52) so we can tell // the user whether paste will Just Work or needs prefix+]. - const path = getClipboardPath() - const n = text.length - let msg: string + const path = getClipboardPath(); + const n = text.length; + let msg: string; switch (path) { case 'native': - msg = `copied ${n} chars to clipboard` - break + msg = `copied ${n} chars to clipboard`; + break; case 'tmux-buffer': - msg = `copied ${n} chars to tmux buffer · paste with prefix + ]` - break + msg = `copied ${n} chars to tmux buffer · paste with prefix + ]`; + break; case 'osc52': - msg = `sent ${n} chars via OSC 52 · check terminal clipboard settings if paste fails` - break + msg = `sent ${n} chars via OSC 52 · check terminal clipboard settings if paste fails`; + break; } addNotification({ key: 'selection-copied', @@ -414,12 +401,12 @@ export function ScrollKeybindingHandler({ color: 'suggestion', priority: 'immediate', timeoutMs: path === 'native' ? 2000 : 4000, - }) + }); } function copyAndToast(): void { - const text = selection.copySelection() - if (text) showCopiedToast(text) + const text = selection.copySelection(); + if (text) showCopiedToast(text); } // Translate selection to track a keyboard page jump. Selection coords are @@ -432,110 +419,107 @@ export function ScrollKeybindingHandler({ // still clears — its async pendingScrollDelta drain means the actual // delta isn't known synchronously (follow-up). function translateSelectionForJump(s: ScrollBoxHandle, delta: number): void { - const sel = selection.getState() - if (!sel?.anchor || !sel.focus) return - const top = s.getViewportTop() - const bottom = top + s.getViewportHeight() - 1 + const sel = selection.getState(); + if (!sel?.anchor || !sel.focus) return; + const top = s.getViewportTop(); + const bottom = top + s.getViewportHeight() - 1; // Only translate if the selection is ON scrollbox content. Selections // in the footer/prompt/StickyPromptHeader are on static text — the // scroll doesn't move what's under them. Same guard as ink.tsx's // auto-follow translate (commit 36a8d154). - if (sel.anchor.row < top || sel.anchor.row > bottom) return + if (sel.anchor.row < top || sel.anchor.row > bottom) return; // Cross-boundary: anchor in scrollbox, focus in footer/header. Mirror // ink.tsx's Flag-3 guard — fall through without shifting OR capturing. // The static endpoint pins the selection; shifting would teleport it // into scrollbox content. - if (sel.focus.row < top || sel.focus.row > bottom) return - const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()) - const cur = s.getScrollTop() + s.getPendingDelta() + if (sel.focus.row < top || sel.focus.row > bottom) return; + const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()); + const cur = s.getScrollTop() + s.getPendingDelta(); // Actual scroll distance after boundary clamp. jumpBy may call // scrollToBottom when target >= max but the view can't move past max, // so the selection shift is bounded here. - const actual = Math.max(0, Math.min(max, cur + delta)) - cur - if (actual === 0) return + const actual = Math.max(0, Math.min(max, cur + delta)) - cur; + if (actual === 0) return; if (actual > 0) { // Scrolling down: content moves up. Rows at the TOP leave viewport. // Anchor+focus shift -actual so they track the content that moved up. - selection.captureScrolledRows(top, top + actual - 1, 'above') - selection.shiftSelection(-actual, top, bottom) + selection.captureScrolledRows(top, top + actual - 1, 'above'); + selection.shiftSelection(-actual, top, bottom); } else { // Scrolling up: content moves down. Rows at the BOTTOM leave viewport. - const a = -actual - selection.captureScrolledRows(bottom - a + 1, bottom, 'below') - selection.shiftSelection(a, top, bottom) + const a = -actual; + selection.captureScrolledRows(bottom - a + 1, bottom, 'below'); + selection.shiftSelection(a, top, bottom); } } useKeybindings( { 'scroll:pageUp': () => { - const s = scrollRef.current - if (!s) return - const d = -Math.max(1, Math.floor(s.getViewportHeight() / 2)) - translateSelectionForJump(s, d) - const sticky = jumpBy(s, d) - onScroll?.(sticky, s) + const s = scrollRef.current; + if (!s) return; + const d = -Math.max(1, Math.floor(s.getViewportHeight() / 2)); + translateSelectionForJump(s, d); + const sticky = jumpBy(s, d); + onScroll?.(sticky, s); }, 'scroll:pageDown': () => { - const s = scrollRef.current - if (!s) return - const d = Math.max(1, Math.floor(s.getViewportHeight() / 2)) - translateSelectionForJump(s, d) - const sticky = jumpBy(s, d) - onScroll?.(sticky, s) + const s = scrollRef.current; + if (!s) return; + const d = Math.max(1, Math.floor(s.getViewportHeight() / 2)); + translateSelectionForJump(s, d); + const sticky = jumpBy(s, d); + onScroll?.(sticky, s); }, 'scroll:lineUp': () => { // Wheel: scrollBy accumulates into pendingScrollDelta, drained async // by the renderer. captureScrolledRows can't read the outgoing rows // before they leave (drain is non-deterministic). Clear for now. - selection.clearSelection() - const s = scrollRef.current + selection.clearSelection(); + const s = scrollRef.current; // Return false (not consumed) when the ScrollBox content fits — // scroll would be a no-op. Lets a child component's handler take // the wheel event instead (e.g. Settings Config's list navigation // inside the centered Modal, where the paginated slice always fits). - if (!s || s.getScrollHeight() <= s.getViewportHeight()) return false - wheelAccel.current ??= initAndLogWheelAccel() - scrollUp(s, computeWheelStep(wheelAccel.current, -1, performance.now())) - onScroll?.(false, s) + if (!s || s.getScrollHeight() <= s.getViewportHeight()) return false; + wheelAccel.current ??= initAndLogWheelAccel(); + scrollUp(s, computeWheelStep(wheelAccel.current, -1, performance.now())); + onScroll?.(false, s); }, 'scroll:lineDown': () => { - selection.clearSelection() - const s = scrollRef.current - if (!s || s.getScrollHeight() <= s.getViewportHeight()) return false - wheelAccel.current ??= initAndLogWheelAccel() - const step = computeWheelStep(wheelAccel.current, 1, performance.now()) - const reachedBottom = scrollDown(s, step) - onScroll?.(reachedBottom, s) + selection.clearSelection(); + const s = scrollRef.current; + if (!s || s.getScrollHeight() <= s.getViewportHeight()) return false; + wheelAccel.current ??= initAndLogWheelAccel(); + const step = computeWheelStep(wheelAccel.current, 1, performance.now()); + const reachedBottom = scrollDown(s, step); + onScroll?.(reachedBottom, s); }, 'scroll:top': () => { - const s = scrollRef.current - if (!s) return - translateSelectionForJump(s, -(s.getScrollTop() + s.getPendingDelta())) - s.scrollTo(0) - onScroll?.(false, s) + const s = scrollRef.current; + if (!s) return; + translateSelectionForJump(s, -(s.getScrollTop() + s.getPendingDelta())); + s.scrollTo(0); + onScroll?.(false, s); }, 'scroll:bottom': () => { - const s = scrollRef.current - if (!s) return - const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()) - translateSelectionForJump( - s, - max - (s.getScrollTop() + s.getPendingDelta()), - ) + const s = scrollRef.current; + if (!s) return; + const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()); + translateSelectionForJump(s, max - (s.getScrollTop() + s.getPendingDelta())); // scrollTo(max) eager-writes scrollTop so the render-phase sticky // follow computes followDelta=0. Without this, scrollToBottom() // alone leaves scrollTop stale → followDelta=max-stale → // shiftSelectionForFollow applies the SAME shift we already did // above, 2× offset. scrollToBottom() then re-enables sticky. - s.scrollTo(max) - s.scrollToBottom() - onScroll?.(true, s) + s.scrollTo(max); + s.scrollToBottom(); + onScroll?.(true, s); }, 'selection:copy': copyAndToast, }, { context: 'Scroll', isActive }, - ) + ); // scroll:halfPage*/fullPage* have no default key bindings — ctrl+u/d/b/f // all have real owners in normal mode (kill-line/exit/task:background/ @@ -544,40 +528,40 @@ export function ScrollKeybindingHandler({ useKeybindings( { 'scroll:halfPageUp': () => { - const s = scrollRef.current - if (!s) return - const d = -Math.max(1, Math.floor(s.getViewportHeight() / 2)) - translateSelectionForJump(s, d) - const sticky = jumpBy(s, d) - onScroll?.(sticky, s) + const s = scrollRef.current; + if (!s) return; + const d = -Math.max(1, Math.floor(s.getViewportHeight() / 2)); + translateSelectionForJump(s, d); + const sticky = jumpBy(s, d); + onScroll?.(sticky, s); }, 'scroll:halfPageDown': () => { - const s = scrollRef.current - if (!s) return - const d = Math.max(1, Math.floor(s.getViewportHeight() / 2)) - translateSelectionForJump(s, d) - const sticky = jumpBy(s, d) - onScroll?.(sticky, s) + const s = scrollRef.current; + if (!s) return; + const d = Math.max(1, Math.floor(s.getViewportHeight() / 2)); + translateSelectionForJump(s, d); + const sticky = jumpBy(s, d); + onScroll?.(sticky, s); }, 'scroll:fullPageUp': () => { - const s = scrollRef.current - if (!s) return - const d = -Math.max(1, s.getViewportHeight()) - translateSelectionForJump(s, d) - const sticky = jumpBy(s, d) - onScroll?.(sticky, s) + const s = scrollRef.current; + if (!s) return; + const d = -Math.max(1, s.getViewportHeight()); + translateSelectionForJump(s, d); + const sticky = jumpBy(s, d); + onScroll?.(sticky, s); }, 'scroll:fullPageDown': () => { - const s = scrollRef.current - if (!s) return - const d = Math.max(1, s.getViewportHeight()) - translateSelectionForJump(s, d) - const sticky = jumpBy(s, d) - onScroll?.(sticky, s) + const s = scrollRef.current; + if (!s) return; + const d = Math.max(1, s.getViewportHeight()); + translateSelectionForJump(s, d); + const sticky = jumpBy(s, d); + onScroll?.(sticky, s); }, }, { context: 'Scroll', isActive }, - ) + ); // Modal pager keys — transcript mode only. less/tmux copy-mode lineage: // ctrl+u/d (half-page), ctrl+b/f (full-page), g/G (top/bottom). Tom's @@ -597,17 +581,15 @@ export function ScrollKeybindingHandler({ // abandoned (❯ oscillation). See team memory scroll-copy-mode-design.md. useInput( (input, key, event) => { - const s = scrollRef.current - if (!s) return - const sticky = applyModalPagerAction(s, modalPagerAction(input, key), d => - translateSelectionForJump(s, d), - ) - if (sticky === null) return - onScroll?.(sticky, s) - event.stopImmediatePropagation() + const s = scrollRef.current; + if (!s) return; + const sticky = applyModalPagerAction(s, modalPagerAction(input, key), d => translateSelectionForJump(s, d)); + if (sticky === null) return; + onScroll?.(sticky, s); + event.stopImmediatePropagation(); }, { isActive: isActive && isModal }, - ) + ); // Esc clears selection; any other keystroke also clears it (matches // native terminal behavior where selection disappears on input). @@ -622,35 +604,35 @@ export function ScrollKeybindingHandler({ // via useKeybindings and consumes its event before reaching here. useInput( (input, key, event) => { - if (!selection.hasSelection()) return + if (!selection.hasSelection()) return; if (key.escape) { - selection.clearSelection() - event.stopImmediatePropagation() - return + selection.clearSelection(); + event.stopImmediatePropagation(); + return; } if (key.ctrl && !key.shift && !key.meta && input === 'c') { - copyAndToast() - event.stopImmediatePropagation() - return + copyAndToast(); + event.stopImmediatePropagation(); + return; } - const move = selectionFocusMoveForKey(key) + const move = selectionFocusMoveForKey(key); if (move) { - selection.moveFocus(move) - event.stopImmediatePropagation() - return + selection.moveFocus(move); + event.stopImmediatePropagation(); + return; } if (shouldClearSelectionOnKey(key)) { - selection.clearSelection() + selection.clearSelection(); } }, { isActive }, - ) + ); - useDragToScroll(scrollRef, selection, isActive, onScroll) - useCopyOnSelect(selection, isActive, showCopiedToast) - useSelectionBgColor(selection) + useDragToScroll(scrollRef, selection, isActive, onScroll); + useCopyOnSelect(selection, isActive, showCopiedToast); + useSelectionBgColor(selection); - return null + return null; } /** @@ -671,45 +653,39 @@ function useDragToScroll( isActive: boolean, onScroll: Props['onScroll'], ): void { - const timerRef = useRef(null) - const dirRef = useRef<-1 | 0 | 1>(0) // -1 scrolling up, +1 down, 0 idle + const timerRef = useRef(null); + const dirRef = useRef<-1 | 0 | 1>(0); // -1 scrolling up, +1 down, 0 idle // Survives stop() — reset only on drag-finish. See check() for semantics. - const lastScrolledDirRef = useRef<-1 | 0 | 1>(0) - const ticksRef = useRef(0) + const lastScrolledDirRef = useRef<-1 | 0 | 1>(0); + const ticksRef = useRef(0); // onScroll may change identity every render (if not memoized by caller). // Read through a ref so the effect doesn't re-subscribe and kill the timer // on each scroll-induced re-render. - const onScrollRef = useRef(onScroll) - onScrollRef.current = onScroll + const onScrollRef = useRef(onScroll); + onScrollRef.current = onScroll; useEffect(() => { - if (!isActive) return + if (!isActive) return; function stop(): void { - dirRef.current = 0 + dirRef.current = 0; if (timerRef.current) { - clearInterval(timerRef.current) - timerRef.current = null + clearInterval(timerRef.current); + timerRef.current = null; } } function tick(): void { - const sel = selection.getState() - const s = scrollRef.current - const dir = dirRef.current + const sel = selection.getState(); + const s = scrollRef.current; + const dir = dirRef.current; // dir === 0 defends against a stale interval (start() may have set one // after the immediate tick already called stop() at a scroll boundary). // ticks cap defends against a lost release event (mouse released // outside terminal window) leaving isDragging stuck true. - if ( - !sel?.isDragging || - !sel.focus || - !s || - dir === 0 || - ++ticksRef.current > AUTOSCROLL_MAX_TICKS - ) { - stop() - return + if (!sel?.isDragging || !sel.focus || !s || dir === 0 || ++ticksRef.current > AUTOSCROLL_MAX_TICKS) { + stop(); + return; } // scrollBy accumulates into pendingScrollDelta; the screen buffer // doesn't update until the next render drains it. If a previous @@ -718,44 +694,44 @@ function useDragToScroll( // accumulator AND missing the rows that actually scrolled out). // Skip this tick; the 50ms interval will retry after Ink's 16ms // render catches up. Also prevents shiftAnchor from desyncing. - if (s.getPendingDelta() !== 0) return - const top = s.getViewportTop() - const bottom = top + s.getViewportHeight() - 1 + if (s.getPendingDelta() !== 0) return; + const top = s.getViewportTop(); + const bottom = top + s.getViewportHeight() - 1; // Clamp anchor within [top, bottom]. Not [0, bottom]: the ScrollBox // padding row at 0 would produce a blank line between scrolledOffAbove // and the on-screen content in getSelectedText. The padding-row // highlight was a minor visual nicety; text correctness wins. if (dir < 0) { if (s.getScrollTop() <= 0) { - stop() - return + stop(); + return; } // Scrolling up: content moves down in viewport, so anchor row +N. // Clamp to actual scroll distance so anchor stays in sync when near // the top boundary (renderer clamps scrollTop to 0 on drain). - const actual = Math.min(AUTOSCROLL_LINES, s.getScrollTop()) + const actual = Math.min(AUTOSCROLL_LINES, s.getScrollTop()); // Capture rows about to scroll out the BOTTOM before scrollBy // overwrites them. Only rows inside the selection are captured // (captureScrolledRows intersects with selection bounds). - selection.captureScrolledRows(bottom - actual + 1, bottom, 'below') - selection.shiftAnchor(actual, 0, bottom) - s.scrollBy(-AUTOSCROLL_LINES) + selection.captureScrolledRows(bottom - actual + 1, bottom, 'below'); + selection.shiftAnchor(actual, 0, bottom); + s.scrollBy(-AUTOSCROLL_LINES); } else { - const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()) + const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()); if (s.getScrollTop() >= max) { - stop() - return + stop(); + return; } // Scrolling down: content moves up in viewport, so anchor row -N. // Clamp to actual scroll distance so anchor stays in sync when near // the bottom boundary (renderer clamps scrollTop to max on drain). - const actual = Math.min(AUTOSCROLL_LINES, max - s.getScrollTop()) + const actual = Math.min(AUTOSCROLL_LINES, max - s.getScrollTop()); // Capture rows about to scroll out the TOP. - selection.captureScrolledRows(top, top + actual - 1, 'above') - selection.shiftAnchor(-actual, top, bottom) - s.scrollBy(AUTOSCROLL_LINES) + selection.captureScrolledRows(top, top + actual - 1, 'above'); + selection.shiftAnchor(-actual, top, bottom); + s.scrollBy(AUTOSCROLL_LINES); } - onScrollRef.current?.(false, s) + onScrollRef.current?.(false, s); } function start(dir: -1 | 1): void { @@ -763,17 +739,17 @@ function useDragToScroll( // may have zeroed this during the pre-crossing phase (accumulators // empty until the anchor row enters the capture range). Re-record // on every call so the corruption is instantly healed. - lastScrolledDirRef.current = dir - if (dirRef.current === dir) return // already going this way - stop() - dirRef.current = dir - ticksRef.current = 0 - tick() + lastScrolledDirRef.current = dir; + if (dirRef.current === dir) return; // already going this way + stop(); + dirRef.current = dir; + ticksRef.current = 0; + tick(); // tick() may have hit a scroll boundary and called stop() (dir reset to // 0). Only start the interval if we're still going — otherwise the // interval would run forever with dir === 0 doing nothing useful. if (dirRef.current === dir) { - timerRef.current = setInterval(tick, AUTOSCROLL_INTERVAL_MS) + timerRef.current = setInterval(tick, AUTOSCROLL_INTERVAL_MS); } } @@ -785,14 +761,14 @@ function useDragToScroll( // scrolling, highlight walks up with the text). Keeping sticky also // avoids useVirtualScroll's tail-walk → forward-walk phantom growth. function check(): void { - const s = scrollRef.current + const s = scrollRef.current; if (!s) { - stop() - return + stop(); + return; } - const top = s.getViewportTop() - const bottom = top + s.getViewportHeight() - 1 - const sel = selection.getState() + const top = s.getViewportTop(); + const bottom = top + s.getViewportHeight() - 1; + const sel = selection.getState(); // Pass the LAST-scrolled direction (not dirRef) so the anchor guard is // bypassed after shiftAnchor has clamped anchor toward row 0. Using // lastScrolledDirRef (survives stop()) lets autoscroll resume after a @@ -805,18 +781,10 @@ function useDragToScroll( // stuck true, the reason AUTOSCROLL_MAX_TICKS exists) still resets. // Safe: start() below re-records lastScrolledDirRef before its // early-return, so a mid-scroll reset here is instantly undone. - if ( - !sel?.isDragging || - (sel.scrolledOffAbove.length === 0 && sel.scrolledOffBelow.length === 0) - ) { - lastScrolledDirRef.current = 0 + if (!sel?.isDragging || (sel.scrolledOffAbove.length === 0 && sel.scrolledOffBelow.length === 0)) { + lastScrolledDirRef.current = 0; } - const dir = dragScrollDirection( - sel, - top, - bottom, - lastScrolledDirRef.current, - ) + const dir = dragScrollDirection(sel, top, bottom, lastScrolledDirRef.current); if (dir === 0) { // Blocked reversal: focus jumped to the opposite edge (off-window // drag return, fast flick). handleSelectionDrag already moved focus @@ -824,26 +792,26 @@ function useDragToScroll( // now orphaned (holds rows on the wrong side). Clear it so // getSelectedText matches the visible highlight. if (lastScrolledDirRef.current !== 0 && sel?.focus) { - const want = sel.focus.row < top ? -1 : sel.focus.row > bottom ? 1 : 0 + const want = sel.focus.row < top ? -1 : sel.focus.row > bottom ? 1 : 0; if (want !== 0 && want !== lastScrolledDirRef.current) { - sel.scrolledOffAbove = [] - sel.scrolledOffBelow = [] - sel.scrolledOffAboveSW = [] - sel.scrolledOffBelowSW = [] - lastScrolledDirRef.current = 0 + sel.scrolledOffAbove = []; + sel.scrolledOffBelow = []; + sel.scrolledOffAboveSW = []; + sel.scrolledOffBelowSW = []; + lastScrolledDirRef.current = 0; } } - stop() - } else start(dir) + stop(); + } else start(dir); } - const unsubscribe = selection.subscribe(check) + const unsubscribe = selection.subscribe(check); return () => { - unsubscribe() - stop() - lastScrolledDirRef.current = 0 - } - }, [isActive, scrollRef, selection]) + unsubscribe(); + stop(); + lastScrolledDirRef.current = 0; + }; + }, [isActive, scrollRef, selection]); } /** @@ -868,20 +836,20 @@ export function dragScrollDirection( bottom: number, alreadyScrollingDir: -1 | 0 | 1 = 0, ): -1 | 0 | 1 { - if (!sel?.isDragging || !sel.anchor || !sel.focus) return 0 - const row = sel.focus.row - const want: -1 | 0 | 1 = row < top ? -1 : row > bottom ? 1 : 0 + if (!sel?.isDragging || !sel.anchor || !sel.focus) return 0; + const row = sel.focus.row; + const want: -1 | 0 | 1 = row < top ? -1 : row > bottom ? 1 : 0; if (alreadyScrollingDir !== 0) { // Same-direction only. Focus on the opposite side, or back inside the // viewport, stops the scroll — captured rows stay in scrolledOffAbove/ // Below but never scroll back on-screen, so getSelectedText is correct. - return want === alreadyScrollingDir ? want : 0 + return want === alreadyScrollingDir ? want : 0; } // Anchor must be inside the viewport for us to own this drag. If the // user started selecting in the input box or header, autoscrolling the // message history is surprising and corrupts the anchor via shiftAnchor. - if (sel.anchor.row < top || sel.anchor.row > bottom) return 0 - return want + if (sel.anchor.row < top || sel.anchor.row > bottom) return 0; + return want; } // Keyboard page jumps: scrollTo() writes scrollTop directly and clears @@ -892,36 +860,36 @@ export function dragScrollDirection( // Target is relative to scrollTop+pendingDelta so a jump mid-wheel-burst // lands where the wheel was heading. export function jumpBy(s: ScrollBoxHandle, delta: number): boolean { - const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()) - const target = s.getScrollTop() + s.getPendingDelta() + delta + const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()); + const target = s.getScrollTop() + s.getPendingDelta() + delta; if (target >= max) { // Eager-write scrollTop so follow-scroll sees followDelta=0. Callers // that ran translateSelectionForJump already shifted; scrollToBottom() // alone would double-shift via the render-phase sticky follow. - s.scrollTo(max) - s.scrollToBottom() - return true + s.scrollTo(max); + s.scrollToBottom(); + return true; } - s.scrollTo(Math.max(0, target)) - return false + s.scrollTo(Math.max(0, target)); + return false; } // Wheel-down past maxScroll re-enables sticky so wheeling at the bottom // naturally re-pins (matches typical chat-app behavior). Returns the // resulting sticky state so callers can propagate it. function scrollDown(s: ScrollBoxHandle, amount: number): boolean { - const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()) + const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()); // Include pendingDelta: scrollBy accumulates into pendingScrollDelta // without updating scrollTop, so getScrollTop() alone is stale within // a batch of wheel events. Without this, wheeling to the bottom never // re-enables sticky scroll. - const effectiveTop = s.getScrollTop() + s.getPendingDelta() + const effectiveTop = s.getScrollTop() + s.getPendingDelta(); if (effectiveTop + amount >= max) { - s.scrollToBottom() - return true + s.scrollToBottom(); + return true; } - s.scrollBy(amount) - return false + s.scrollBy(amount); + return false; } // Wheel-up past scrollTop=0 clamps via scrollTo(0), clearing @@ -933,12 +901,12 @@ function scrollDown(s: ScrollBoxHandle, amount: number): boolean { export function scrollUp(s: ScrollBoxHandle, amount: number): void { // Include pendingDelta: scrollBy accumulates without updating scrollTop, // so getScrollTop() alone is stale within a batch of wheel events. - const effectiveTop = s.getScrollTop() + s.getPendingDelta() + const effectiveTop = s.getScrollTop() + s.getPendingDelta(); if (effectiveTop - amount <= 0) { - s.scrollTo(0) - return + s.scrollTo(0); + return; } - s.scrollBy(-amount) + s.scrollBy(-amount); } export type ModalPagerAction = @@ -949,7 +917,7 @@ export type ModalPagerAction = | 'fullPageUp' | 'fullPageDown' | 'top' - | 'bottom' + | 'bottom'; /** * Maps a keystroke to a modal pager action. Exported for testing. @@ -968,69 +936,66 @@ export type ModalPagerAction = */ export function modalPagerAction( input: string, - key: Pick< - Key, - 'ctrl' | 'meta' | 'shift' | 'upArrow' | 'downArrow' | 'home' | 'end' - >, + key: Pick, ): ModalPagerAction | null { - if (key.meta) return null + if (key.meta) return null; // Special keys first — arrows/home/end arrive with empty or junk input, // so these must be checked before any input-string logic. shift is // reserved for selection-extend (selectionFocusMoveForKey); ctrl+home/end // already has a useKeybindings route to scroll:top/bottom. if (!key.ctrl && !key.shift) { - if (key.upArrow) return 'lineUp' - if (key.downArrow) return 'lineDown' - if (key.home) return 'top' - if (key.end) return 'bottom' + if (key.upArrow) return 'lineUp'; + if (key.downArrow) return 'lineDown'; + if (key.home) return 'top'; + if (key.end) return 'bottom'; } if (key.ctrl) { - if (key.shift) return null + if (key.shift) return null; switch (input) { case 'u': - return 'halfPageUp' + return 'halfPageUp'; case 'd': - return 'halfPageDown' + return 'halfPageDown'; case 'b': - return 'fullPageUp' + return 'fullPageUp'; case 'f': - return 'fullPageDown' + return 'fullPageDown'; // emacs-style line scroll (less accepts both ctrl+n/p and ctrl+e/y). // Works during search nav — fine-adjust after a jump without // leaving modal. No !searchOpen gate on this useInput's isActive. case 'n': - return 'lineDown' + return 'lineDown'; case 'p': - return 'lineUp' + return 'lineUp'; default: - return null + return null; } } // Bare letters. Key-repeat batches: only act on uniform runs. - const c = input[0] - if (!c || input !== c.repeat(input.length)) return null + const c = input[0]; + if (!c || input !== c.repeat(input.length)) return null; // kitty sends G as input='g' shift=true; legacy as 'G' shift=false. // Check BEFORE the shift-gate so both hit 'bottom'. - if (c === 'G' || (c === 'g' && key.shift)) return 'bottom' - if (key.shift) return null + if (c === 'G' || (c === 'g' && key.shift)) return 'bottom'; + if (key.shift) return null; switch (c) { case 'g': - return 'top' + return 'top'; // j/k re-added per Tom Mar 18 — reversal of Mar 16 removal. Works // during search nav (fine-adjust after n/N lands) since isModal is // independent of searchOpen. case 'j': - return 'lineDown' + return 'lineDown'; case 'k': - return 'lineUp' + return 'lineUp'; // less: space = page down, b = page up. ctrl+b already maps above; // bare b is the less-native version. case ' ': - return 'fullPageDown' + return 'fullPageDown'; case 'b': - return 'fullPageUp' + return 'fullPageUp'; default: - return null + return null; } } @@ -1048,39 +1013,39 @@ export function applyModalPagerAction( ): boolean | null { switch (act) { case null: - return null + return null; case 'lineUp': case 'lineDown': { - const d = act === 'lineDown' ? 1 : -1 - onBeforeJump(d) - return jumpBy(s, d) + const d = act === 'lineDown' ? 1 : -1; + onBeforeJump(d); + return jumpBy(s, d); } case 'halfPageUp': case 'halfPageDown': { - const half = Math.max(1, Math.floor(s.getViewportHeight() / 2)) - const d = act === 'halfPageDown' ? half : -half - onBeforeJump(d) - return jumpBy(s, d) + const half = Math.max(1, Math.floor(s.getViewportHeight() / 2)); + const d = act === 'halfPageDown' ? half : -half; + onBeforeJump(d); + return jumpBy(s, d); } case 'fullPageUp': case 'fullPageDown': { - const page = Math.max(1, s.getViewportHeight()) - const d = act === 'fullPageDown' ? page : -page - onBeforeJump(d) - return jumpBy(s, d) + const page = Math.max(1, s.getViewportHeight()); + const d = act === 'fullPageDown' ? page : -page; + onBeforeJump(d); + return jumpBy(s, d); } case 'top': - onBeforeJump(-(s.getScrollTop() + s.getPendingDelta())) - s.scrollTo(0) - return false + onBeforeJump(-(s.getScrollTop() + s.getPendingDelta())); + s.scrollTo(0); + return false; case 'bottom': { - const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()) - onBeforeJump(max - (s.getScrollTop() + s.getPendingDelta())) + const max = Math.max(0, s.getScrollHeight() - s.getViewportHeight()); + onBeforeJump(max - (s.getScrollTop() + s.getPendingDelta())); // Eager-write scrollTop before scrollToBottom — same double-shift // fix as scroll:bottom and jumpBy's max branch. - s.scrollTo(max) - s.scrollToBottom() - return true + s.scrollTo(max); + s.scrollToBottom(); + return true; } } } diff --git a/src/components/SearchBox.tsx b/src/components/SearchBox.tsx index d35d67edd..05750c9da 100644 --- a/src/components/SearchBox.tsx +++ b/src/components/SearchBox.tsx @@ -1,16 +1,16 @@ -import React from 'react' -import { Box, Text } from '../ink.js' +import React from 'react'; +import { Box, Text } from '../ink.js'; type Props = { - query: string - placeholder?: string - isFocused: boolean - isTerminalFocused: boolean - prefix?: string - width?: number | string - cursorOffset?: number - borderless?: boolean -} + query: string; + placeholder?: string; + isFocused: boolean; + isTerminalFocused: boolean; + prefix?: string; + width?: number | string; + cursorOffset?: number; + borderless?: boolean; +}; export function SearchBox({ query, @@ -22,7 +22,7 @@ export function SearchBox({ cursorOffset, borderless = false, }: Props): React.ReactNode { - const offset = cursorOffset ?? query.length + const offset = cursorOffset ?? query.length; return ( {query.slice(0, offset)} - - {offset < query.length ? query[offset] : ' '} - - {offset < query.length && ( - {query.slice(offset + 1)} - )} + {offset < query.length ? query[offset] : ' '} + {offset < query.length && {query.slice(offset + 1)}} ) : ( {query} @@ -67,5 +63,5 @@ export function SearchBox({ )} - ) + ); } diff --git a/src/components/SessionBackgroundHint.tsx b/src/components/SessionBackgroundHint.tsx index a7f5e8f59..7b1235953 100644 --- a/src/components/SessionBackgroundHint.tsx +++ b/src/components/SessionBackgroundHint.tsx @@ -1,27 +1,20 @@ -import * as React from 'react' -import { useCallback, useState } from 'react' -import { useDoublePress } from '../hooks/useDoublePress.js' -import { Box, Text } from '../ink.js' -import { useKeybinding } from '../keybindings/useKeybinding.js' -import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js' -import { - useAppState, - useAppStateStore, - useSetAppState, -} from '../state/AppState.js' -import { - backgroundAll, - hasForegroundTasks, -} from '../tasks/LocalShellTask/LocalShellTask.js' -import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js' -import { env } from '../utils/env.js' -import { isEnvTruthy } from '../utils/envUtils.js' -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' +import * as React from 'react'; +import { useCallback, useState } from 'react'; +import { useDoublePress } from '../hooks/useDoublePress.js'; +import { Box, Text } from '../ink.js'; +import { useKeybinding } from '../keybindings/useKeybinding.js'; +import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'; +import { useAppState, useAppStateStore, useSetAppState } from '../state/AppState.js'; +import { backgroundAll, hasForegroundTasks } from '../tasks/LocalShellTask/LocalShellTask.js'; +import { getGlobalConfig, saveGlobalConfig } from '../utils/config.js'; +import { env } from '../utils/env.js'; +import { isEnvTruthy } from '../utils/envUtils.js'; +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; type Props = { - onBackgroundSession: () => void - isLoading: boolean -} + onBackgroundSession: () => void; + isLoading: boolean; +}; /** * Shows a hint when user presses Ctrl+B to background the current session. @@ -31,64 +24,53 @@ type Props = { * 1. isLoading is true (a query is in progress) * 2. No foreground tasks (bash/agent) are running (those take priority for Ctrl+B) */ -export function SessionBackgroundHint({ - onBackgroundSession, - isLoading, -}: Props): React.ReactElement | null { - const setAppState = useSetAppState() - const appStateStore = useAppStateStore() +export function SessionBackgroundHint({ onBackgroundSession, isLoading }: Props): React.ReactElement | null { + const setAppState = useSetAppState(); + const appStateStore = useAppStateStore(); - const [showSessionHint, setShowSessionHint] = useState(false) + const [showSessionHint, setShowSessionHint] = useState(false); const handleDoublePress = useDoublePress( setShowSessionHint, onBackgroundSession, () => {}, // First press just shows the hint - ) + ); // Handler for task:background - prioritizes foreground tasks, falls back to session backgrounding // Skip all background functionality if background tasks are disabled const handleBackground = useCallback(() => { if (isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS)) { - return + return; } - const state = appStateStore.getState() + const state = appStateStore.getState(); if (hasForegroundTasks(state)) { // Existing behavior - background running bash/agent tasks - backgroundAll(() => appStateStore.getState(), setAppState) + backgroundAll(() => appStateStore.getState(), setAppState); if (!getGlobalConfig().hasUsedBackgroundTask) { - saveGlobalConfig(c => - c.hasUsedBackgroundTask ? c : { ...c, hasUsedBackgroundTask: true }, - ) + saveGlobalConfig(c => (c.hasUsedBackgroundTask ? c : { ...c, hasUsedBackgroundTask: true })); } - } else if ( - isEnvTruthy("false") && - isLoading - ) { + } else if (isEnvTruthy('false') && isLoading) { // New behavior - double-press to background session (gated) - handleDoublePress() + handleDoublePress(); } - }, [setAppState, appStateStore, isLoading, handleDoublePress]) + }, [setAppState, appStateStore, isLoading, handleDoublePress]); // Only eat ctrl+b when there's something to background. Without this gate // the binding double-fires with readline backward-char at an idle prompt. - const hasForeground = useAppState(hasForegroundTasks) - const sessionBgEnabled = isEnvTruthy("false") + const hasForeground = useAppState(hasForegroundTasks); + const sessionBgEnabled = isEnvTruthy('false'); useKeybinding('task:background', handleBackground, { context: 'Task', isActive: hasForeground || (sessionBgEnabled && isLoading), - }) + }); // Get the configured shortcut for task:background - const baseShortcut = useShortcutDisplay('task:background', 'Task', 'ctrl+b') + const baseShortcut = useShortcutDisplay('task:background', 'Task', 'ctrl+b'); // In tmux, ctrl+b is the prefix key, so users need to press it twice to send ctrl+b - const shortcut = - env.terminal === 'tmux' && baseShortcut === 'ctrl+b' - ? 'ctrl+b ctrl+b' - : baseShortcut + const shortcut = env.terminal === 'tmux' && baseShortcut === 'ctrl+b' ? 'ctrl+b ctrl+b' : baseShortcut; if (!isLoading || !showSessionHint) { - return null + return null; } return ( @@ -97,5 +79,5 @@ export function SessionBackgroundHint({ - ) + ); } diff --git a/src/components/SessionPreview.tsx b/src/components/SessionPreview.tsx index 2d0e10a97..cd95c3d3a 100644 --- a/src/components/SessionPreview.tsx +++ b/src/components/SessionPreview.tsx @@ -1,60 +1,52 @@ -import type { UUID } from 'crypto' -import React, { useCallback } from 'react' -import { Box, Text } from '../ink.js' -import { useKeybinding } from '../keybindings/useKeybinding.js' -import { getAllBaseTools } from '../tools.js' -import type { LogOption } from '../types/logs.js' -import { formatRelativeTimeAgo } from '../utils/format.js' -import { - getSessionIdFromLog, - isLiteLog, - loadFullLog, -} from '../utils/sessionStorage.js' -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' -import { Byline } from './design-system/Byline.js' -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' -import { LoadingState } from './design-system/LoadingState.js' -import { Messages } from './Messages.js' +import type { UUID } from 'crypto'; +import React, { useCallback } from 'react'; +import { Box, Text } from '../ink.js'; +import { useKeybinding } from '../keybindings/useKeybinding.js'; +import { getAllBaseTools } from '../tools.js'; +import type { LogOption } from '../types/logs.js'; +import { formatRelativeTimeAgo } from '../utils/format.js'; +import { getSessionIdFromLog, isLiteLog, loadFullLog } from '../utils/sessionStorage.js'; +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; +import { Byline } from './design-system/Byline.js'; +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; +import { LoadingState } from './design-system/LoadingState.js'; +import { Messages } from './Messages.js'; type Props = { - log: LogOption - onExit: () => void - onSelect: (log: LogOption) => void -} + log: LogOption; + onExit: () => void; + onSelect: (log: LogOption) => void; +}; -export function SessionPreview({ - log, - onExit, - onSelect, -}: Props): React.ReactNode { +export function SessionPreview({ log, onExit, onSelect }: Props): React.ReactNode { // fullLog holds the complete log with messages loaded. // The input `log` may be a "lite log" (empty messages array), // so we load the full messages on mount and store them here. - const [fullLog, setFullLog] = React.useState(null) + const [fullLog, setFullLog] = React.useState(null); // Load full messages if this is a lite log React.useEffect(() => { - setFullLog(null) + setFullLog(null); if (isLiteLog(log)) { - void loadFullLog(log).then(setFullLog) + void loadFullLog(log).then(setFullLog); } - }, [log]) + }, [log]); - const isLoading = isLiteLog(log) && fullLog === null - const displayLog = fullLog ?? log - const conversationId = getSessionIdFromLog(displayLog) || ('' as UUID) + const isLoading = isLiteLog(log) && fullLog === null; + const displayLog = fullLog ?? log; + const conversationId = getSessionIdFromLog(displayLog) || ('' as UUID); // Get all base tools for preview (no permissions needed for read-only view) - const tools = getAllBaseTools() + const tools = getAllBaseTools(); // Handle keyboard input via keybindings - useKeybinding('confirm:no', onExit, { context: 'Confirmation' }) + useKeybinding('confirm:no', onExit, { context: 'Confirmation' }); const handleSelect = useCallback(() => { - onSelect(fullLog ?? log) - }, [onSelect, fullLog, log]) + onSelect(fullLog ?? log); + }, [onSelect, fullLog, log]); - useKeybinding('confirm:yes', handleSelect, { context: 'Confirmation' }) + useKeybinding('confirm:yes', handleSelect, { context: 'Confirmation' }); // Show loading state while fetching full log if (isLoading) { @@ -63,16 +55,11 @@ export function SessionPreview({ - + - ) + ); } return ( @@ -103,22 +90,16 @@ export function SessionPreview({ paddingLeft={2} > - {formatRelativeTimeAgo(displayLog.modified)} ·{' '} - {displayLog.messageCount} messages + {formatRelativeTimeAgo(displayLog.modified)} · {displayLog.messageCount} messages {displayLog.gitBranch ? ` · ${displayLog.gitBranch}` : ''} - + - ) + ); } diff --git a/src/components/Settings/Config.tsx b/src/components/Settings/Config.tsx index 09f832f0c..d86553de2 100644 --- a/src/components/Settings/Config.tsx +++ b/src/components/Settings/Config.tsx @@ -1,34 +1,20 @@ // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered -import { feature } from 'bun:bundle' -import { - Box, - Text, - useTheme, - useThemeSetting, - useTerminalFocus, -} from '../../ink.js' -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' -import * as React from 'react' -import { useState, useCallback } from 'react' -import { - useKeybinding, - useKeybindings, -} from '../../keybindings/useKeybinding.js' -import figures from 'figures' -import { - type GlobalConfig, - saveGlobalConfig, - getCurrentProjectConfig, - type OutputStyle, -} from '../../utils/config.js' -import { normalizeApiKeyForConfig } from '../../utils/authPortable.js' +import { feature } from 'bun:bundle'; +import { Box, Text, useTheme, useThemeSetting, useTerminalFocus } from '../../ink.js'; +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; +import * as React from 'react'; +import { useState, useCallback } from 'react'; +import { useKeybinding, useKeybindings } from '../../keybindings/useKeybinding.js'; +import figures from 'figures'; +import { type GlobalConfig, saveGlobalConfig, getCurrentProjectConfig, type OutputStyle } from '../../utils/config.js'; +import { normalizeApiKeyForConfig } from '../../utils/authPortable.js'; import { getGlobalConfig, getAutoUpdaterDisabledReason, formatAutoUpdaterDisabledReason, getRemoteControlAtStartup, -} from '../../utils/config.js' -import chalk from 'chalk' +} from '../../utils/config.js'; +import chalk from 'chalk'; import { permissionModeTitle, permissionModeFromString, @@ -38,75 +24,51 @@ import { PERMISSION_MODES, type ExternalPermissionMode, type PermissionMode, -} from '../../utils/permissions/PermissionMode.js' +} from '../../utils/permissions/PermissionMode.js'; import { getAutoModeEnabledState, hasAutoModeOptInAnySource, transitionPlanAutoMode, -} from '../../utils/permissions/permissionSetup.js' -import { logError } from '../../utils/log.js' +} from '../../utils/permissions/permissionSetup.js'; +import { logError } from '../../utils/log.js'; import { logEvent, type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, -} from 'src/services/analytics/index.js' -import { isBridgeEnabled } from '../../bridge/bridgeEnabled.js' -import { ThemePicker } from '../ThemePicker.js' -import { - useAppState, - useSetAppState, - useAppStateStore, -} from '../../state/AppState.js' -import { ModelPicker } from '../ModelPicker.js' -import { - modelDisplayString, - isOpus1mMergeEnabled, -} from '../../utils/model/model.js' -import { isBilledAsExtraUsage } from '../../utils/extraUsage.js' -import { ClaudeMdExternalIncludesDialog } from '../ClaudeMdExternalIncludesDialog.js' -import { - ChannelDowngradeDialog, - type ChannelDowngradeChoice, -} from '../ChannelDowngradeDialog.js' -import { Dialog } from '../design-system/Dialog.js' -import { Select } from '../CustomSelect/index.js' -import { OutputStylePicker } from '../OutputStylePicker.js' -import { LanguagePicker } from '../LanguagePicker.js' -import { - getExternalClaudeMdIncludes, - getMemoryFiles, - hasExternalClaudeMdIncludes, -} from 'src/utils/claudemd.js' -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' -import { Byline } from '../design-system/Byline.js' -import { useTabHeaderFocus } from '../design-system/Tabs.js' -import { useIsInsideModal } from '../../context/modalContext.js' -import { SearchBox } from '../SearchBox.js' -import { - isSupportedTerminal, - hasAccessToIDEExtensionDiffFeature, -} from '../../utils/ide.js' -import { - getInitialSettings, - getSettingsForSource, - updateSettingsForSource, -} from '../../utils/settings/settings.js' -import { getUserMsgOptIn, setUserMsgOptIn } from '../../bootstrap/state.js' -import { DEFAULT_OUTPUT_STYLE_NAME } from 'src/constants/outputStyles.js' -import { isEnvTruthy, isRunningOnHomespace } from 'src/utils/envUtils.js' -import type { - LocalJSXCommandContext, - CommandResultDisplay, -} from '../../commands.js' -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' -import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' +} from 'src/services/analytics/index.js'; +import { isBridgeEnabled } from '../../bridge/bridgeEnabled.js'; +import { ThemePicker } from '../ThemePicker.js'; +import { useAppState, useSetAppState, useAppStateStore } from '../../state/AppState.js'; +import { ModelPicker } from '../ModelPicker.js'; +import { modelDisplayString, isOpus1mMergeEnabled } from '../../utils/model/model.js'; +import { isBilledAsExtraUsage } from '../../utils/extraUsage.js'; +import { ClaudeMdExternalIncludesDialog } from '../ClaudeMdExternalIncludesDialog.js'; +import { ChannelDowngradeDialog, type ChannelDowngradeChoice } from '../ChannelDowngradeDialog.js'; +import { Dialog } from '../design-system/Dialog.js'; +import { Select } from '../CustomSelect/index.js'; +import { OutputStylePicker } from '../OutputStylePicker.js'; +import { LanguagePicker } from '../LanguagePicker.js'; +import { getExternalClaudeMdIncludes, getMemoryFiles, hasExternalClaudeMdIncludes } from 'src/utils/claudemd.js'; +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; +import { Byline } from '../design-system/Byline.js'; +import { useTabHeaderFocus } from '../design-system/Tabs.js'; +import { useIsInsideModal } from '../../context/modalContext.js'; +import { SearchBox } from '../SearchBox.js'; +import { isSupportedTerminal, hasAccessToIDEExtensionDiffFeature } from '../../utils/ide.js'; +import { getInitialSettings, getSettingsForSource, updateSettingsForSource } from '../../utils/settings/settings.js'; +import { getUserMsgOptIn, setUserMsgOptIn } from '../../bootstrap/state.js'; +import { DEFAULT_OUTPUT_STYLE_NAME } from 'src/constants/outputStyles.js'; +import { isEnvTruthy, isRunningOnHomespace } from 'src/utils/envUtils.js'; +import type { LocalJSXCommandContext, CommandResultDisplay } from '../../commands.js'; +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'; +import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'; import { getCliTeammateModeOverride, clearCliTeammateModeOverride, -} from '../../utils/swarm/backends/teammateModeSnapshot.js' -import { getHardcodedTeammateModelFallback } from '../../utils/swarm/teammateModel.js' -import { useSearchInput } from '../../hooks/useSearchInput.js' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' +} from '../../utils/swarm/backends/teammateModeSnapshot.js'; +import { getHardcodedTeammateModelFallback } from '../../utils/swarm/teammateModel.js'; +import { useSearchInput } from '../../hooks/useSearchInput.js'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; import { clearFastModeCooldown, FAST_MODE_MODEL_DISPLAY, @@ -114,50 +76,47 @@ import { isFastModeEnabled, getFastModeModel, isFastModeSupportedByModel, -} from '../../utils/fastMode.js' -import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js' +} from '../../utils/fastMode.js'; +import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'; type Props = { - onClose: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void - context: LocalJSXCommandContext - setTabsHidden: (hidden: boolean) => void - onIsSearchModeChange?: (inSearchMode: boolean) => void - contentHeight?: number -} + onClose: (result?: string, options?: { display?: CommandResultDisplay }) => void; + context: LocalJSXCommandContext; + setTabsHidden: (hidden: boolean) => void; + onIsSearchModeChange?: (inSearchMode: boolean) => void; + contentHeight?: number; +}; type SettingBase = | { - id: string - label: string + id: string; + label: string; } | { - id: string - label: React.ReactNode - searchText: string - } + id: string; + label: React.ReactNode; + searchText: string; + }; type Setting = | (SettingBase & { - value: boolean - onChange(value: boolean): void - type: 'boolean' + value: boolean; + onChange(value: boolean): void; + type: 'boolean'; }) | (SettingBase & { - value: string - options: string[] - onChange(value: string): void - type: 'enum' + value: string; + options: string[]; + onChange(value: string): void; + type: 'enum'; }) | (SettingBase & { // For enums that are set by a custom component, we don't need to pass options, // but we still need a value to display in the top-level config menu - value: string - onChange(value: string): void - type: 'managedEnum' - }) + value: string; + onChange(value: string): void; + type: 'managedEnum'; + }); type SubMenu = | 'Theme' @@ -167,7 +126,7 @@ type SubMenu = | 'OutputStyle' | 'ChannelDowngrade' | 'Language' - | 'EnableAutoUpdates' + | 'EnableAutoUpdates'; export function Config({ onClose, context, @@ -175,46 +134,42 @@ export function Config({ onIsSearchModeChange, contentHeight, }: Props): React.ReactNode { - const { headerFocused, focusHeader } = useTabHeaderFocus() - const insideModal = useIsInsideModal() - const [, setTheme] = useTheme() - const themeSetting = useThemeSetting() - const [globalConfig, setGlobalConfig] = useState(getGlobalConfig()) - const initialConfig = React.useRef(getGlobalConfig()) - const [settingsData, setSettingsData] = useState(getInitialSettings()) - const initialSettingsData = React.useRef(getInitialSettings()) + const { headerFocused, focusHeader } = useTabHeaderFocus(); + const insideModal = useIsInsideModal(); + const [, setTheme] = useTheme(); + const themeSetting = useThemeSetting(); + const [globalConfig, setGlobalConfig] = useState(getGlobalConfig()); + const initialConfig = React.useRef(getGlobalConfig()); + const [settingsData, setSettingsData] = useState(getInitialSettings()); + const initialSettingsData = React.useRef(getInitialSettings()); const [currentOutputStyle, setCurrentOutputStyle] = useState( settingsData?.outputStyle || DEFAULT_OUTPUT_STYLE_NAME, - ) - const initialOutputStyle = React.useRef(currentOutputStyle) - const [currentLanguage, setCurrentLanguage] = useState( - settingsData?.language, - ) - const initialLanguage = React.useRef(currentLanguage) - const [selectedIndex, setSelectedIndex] = useState(0) - const [scrollOffset, setScrollOffset] = useState(0) - const [isSearchMode, setIsSearchMode] = useState(true) - const isTerminalFocused = useTerminalFocus() - const { rows } = useTerminalSize() + ); + const initialOutputStyle = React.useRef(currentOutputStyle); + const [currentLanguage, setCurrentLanguage] = useState(settingsData?.language); + const initialLanguage = React.useRef(currentLanguage); + const [selectedIndex, setSelectedIndex] = useState(0); + const [scrollOffset, setScrollOffset] = useState(0); + const [isSearchMode, setIsSearchMode] = useState(true); + const isTerminalFocused = useTerminalFocus(); + const { rows } = useTerminalSize(); // contentHeight is set by Settings.tsx (same value passed to Tabs to fix // pane height across all tabs — prevents layout jank when switching). // Reserve ~10 rows for chrome (search box, gaps, footer, scroll hints). // Fallback calc for standalone rendering (tests). - const paneCap = contentHeight ?? Math.min(Math.floor(rows * 0.8), 30) - const maxVisible = Math.max(5, paneCap - 10) - const mainLoopModel = useAppState(s => s.mainLoopModel) - const verbose = useAppState(s => s.verbose) - const thinkingEnabled = useAppState(s => s.thinkingEnabled) - const isFastMode = useAppState(s => - isFastModeEnabled() ? s.fastMode : false, - ) - const promptSuggestionEnabled = useAppState(s => s.promptSuggestionEnabled) + const paneCap = contentHeight ?? Math.min(Math.floor(rows * 0.8), 30); + const maxVisible = Math.max(5, paneCap - 10); + const mainLoopModel = useAppState(s => s.mainLoopModel); + const verbose = useAppState(s => s.verbose); + const thinkingEnabled = useAppState(s => s.thinkingEnabled); + const isFastMode = useAppState(s => (isFastModeEnabled() ? s.fastMode : false)); + const promptSuggestionEnabled = useAppState(s => s.promptSuggestionEnabled); // Show auto in the default-mode dropdown when the user has opted in OR the // config is fully 'enabled' — even if currently circuit-broken ('disabled'), // an opted-in user should still see it in settings (it's a temporary state). const showAutoInDefaultModePicker = feature('TRANSCRIPT_CLASSIFIER') ? hasAutoModeOptInAnySource() || getAutoModeEnabledState() === 'enabled' - : false + : false; // Chat/Transcript view picker is visible to entitled users (pass the GB // gate) even if they haven't opted in this session — it IS the persistent // opt-in. 'chat' written here is read at next startup by main.tsx which @@ -225,28 +180,24 @@ export function Config({ ? ( require('../../tools/BriefTool/BriefTool.js') as typeof import('../../tools/BriefTool/BriefTool.js') ).isBriefEntitled() - : false + : false; /* eslint-enable @typescript-eslint/no-require-imports */ - const setAppState = useSetAppState() - const [changes, setChanges] = useState<{ [key: string]: unknown }>({}) - const initialThinkingEnabled = React.useRef(thinkingEnabled) + const setAppState = useSetAppState(); + const [changes, setChanges] = useState<{ [key: string]: unknown }>({}); + const initialThinkingEnabled = React.useRef(thinkingEnabled); // Per-source settings snapshots for revert-on-escape. getInitialSettings() // returns merged-across-sources which can't tell us what to delete vs // restore; per-source snapshots + updateSettingsForSource's // undefined-deletes-key semantics can. Lazy-init via useState (no setter) to // avoid reading settings files on every render — useRef evaluates its arg // eagerly even though only the first result is kept. - const [initialLocalSettings] = useState(() => - getSettingsForSource('localSettings'), - ) - const [initialUserSettings] = useState(() => - getSettingsForSource('userSettings'), - ) - const initialThemeSetting = React.useRef(themeSetting) + const [initialLocalSettings] = useState(() => getSettingsForSource('localSettings')); + const [initialUserSettings] = useState(() => getSettingsForSource('userSettings')); + const initialThemeSetting = React.useRef(themeSetting); // AppState fields Config may modify — snapshot once at mount. - const store = useAppStateStore() + const store = useAppStateStore(); const [initialAppState] = useState(() => { - const s = store.getState() + const s = store.getState(); return { mainLoopModel: s.mainLoopModel, mainLoopModelForSession: s.mainLoopModelForSession, @@ -258,19 +209,19 @@ export function Config({ replBridgeEnabled: s.replBridgeEnabled, replBridgeOutboundOnly: s.replBridgeOutboundOnly, settings: s.settings, - } - }) + }; + }); // Bootstrap state snapshot — userMsgOptIn is outside AppState, so // revertChanges needs to restore it separately. Without this, cycling // defaultView to 'chat' then Escape leaves the tool active while the // display filter reverts — the exact ambient-activation behavior this // PR's entitlement/opt-in split is meant to prevent. - const [initialUserMsgOptIn] = useState(() => getUserMsgOptIn()) + const [initialUserMsgOptIn] = useState(() => getUserMsgOptIn()); // Set on first user-visible change; gates revertChanges() on Escape so // opening-then-closing doesn't trigger redundant disk writes. - const isDirty = React.useRef(false) - const [showThinkingWarning, setShowThinkingWarning] = useState(false) - const [showSubmenu, setShowSubmenu] = useState(null) + const isDirty = React.useRef(false); + const [showThinkingWarning, setShowThinkingWarning] = useState(false); + const [showSubmenu, setShowSubmenu] = useState(null); const { query: searchQuery, setQuery: setSearchQuery, @@ -282,74 +233,65 @@ export function Config({ // Ctrl+C/D must reach Settings' useExitOnCtrlCD; 'd' also avoids // double-action (delete-char + exit-pending). passthroughCtrlKeys: ['c', 'd'], - }) + }); // Tell the parent when Config's own Esc handler is active so Settings cedes // confirm:no. Only true when search mode owns the keyboard — not when the // tab header is focused (then Settings must handle Esc-to-close). - const ownsEsc = isSearchMode && !headerFocused + const ownsEsc = isSearchMode && !headerFocused; React.useEffect(() => { - onIsSearchModeChange?.(ownsEsc) - }, [ownsEsc, onIsSearchModeChange]) + onIsSearchModeChange?.(ownsEsc); + }, [ownsEsc, onIsSearchModeChange]); - const isConnectedToIde = hasAccessToIDEExtensionDiffFeature( - context.options.mcpClients, - ) + const isConnectedToIde = hasAccessToIDEExtensionDiffFeature(context.options.mcpClients); - const isFileCheckpointingAvailable = !isEnvTruthy( - process.env.CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING, - ) + const isFileCheckpointingAvailable = !isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_FILE_CHECKPOINTING); - const memoryFiles = React.use(getMemoryFiles(true)) - const shouldShowExternalIncludesToggle = - hasExternalClaudeMdIncludes(memoryFiles) + const memoryFiles = React.use(getMemoryFiles(true)); + const shouldShowExternalIncludesToggle = hasExternalClaudeMdIncludes(memoryFiles); - const autoUpdaterDisabledReason = getAutoUpdaterDisabledReason() + const autoUpdaterDisabledReason = getAutoUpdaterDisabledReason(); function onChangeMainModelConfig(value: string | null): void { - const previousModel = mainLoopModel + const previousModel = mainLoopModel; logEvent('tengu_config_model_changed', { - from_model: - previousModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - to_model: - value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + from_model: previousModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + to_model: value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); setAppState(prev => ({ ...prev, mainLoopModel: value, mainLoopModelForSession: null, - })) + })); setChanges(prev => { const valStr = modelDisplayString(value) + - (isBilledAsExtraUsage(value, false, isOpus1mMergeEnabled()) - ? ' · Billed as extra usage' - : '') + (isBilledAsExtraUsage(value, false, isOpus1mMergeEnabled()) ? ' · Billed as extra usage' : ''); if ('model' in prev) { - const { model, ...rest } = prev - return { ...rest, model: valStr } + const { model, ...rest } = prev; + return { ...rest, model: valStr }; } - return { ...prev, model: valStr } - }) + return { ...prev, model: valStr }; + }); } function onChangeVerbose(value: boolean): void { // Update the global config to persist the setting - saveGlobalConfig(current => ({ ...current, verbose: value })) - setGlobalConfig({ ...getGlobalConfig(), verbose: value }) + saveGlobalConfig(current => ({ ...current, verbose: value })); + setGlobalConfig({ ...getGlobalConfig(), verbose: value }); // Update the app state for immediate UI feedback setAppState(prev => ({ ...prev, verbose: value, - })) + })); setChanges(prev => { if ('verbose' in prev) { - const { verbose, ...rest } = prev - return rest + const { verbose, ...rest } = prev; + return rest; } - return { ...prev, verbose: value } - }) + return { ...prev, verbose: value }; + }); } // TODO: Add MCP servers @@ -361,11 +303,11 @@ export function Config({ value: globalConfig.autoCompactEnabled, type: 'boolean' as const, onChange(autoCompactEnabled: boolean) { - saveGlobalConfig(current => ({ ...current, autoCompactEnabled })) - setGlobalConfig({ ...getGlobalConfig(), autoCompactEnabled }) + saveGlobalConfig(current => ({ ...current, autoCompactEnabled })); + setGlobalConfig({ ...getGlobalConfig(), autoCompactEnabled }); logEvent('tengu_auto_compact_setting_changed', { enabled: autoCompactEnabled, - }) + }); }, }, { @@ -376,15 +318,15 @@ export function Config({ onChange(spinnerTipsEnabled: boolean) { updateSettingsForSource('localSettings', { spinnerTipsEnabled, - }) + }); // Update local state to reflect the change immediately setSettingsData(prev => ({ ...prev, spinnerTipsEnabled, - })) + })); logEvent('tengu_tips_setting_changed', { enabled: spinnerTipsEnabled, - }) + }); }, }, { @@ -395,19 +337,19 @@ export function Config({ onChange(prefersReducedMotion: boolean) { updateSettingsForSource('localSettings', { prefersReducedMotion, - }) + }); setSettingsData(prev => ({ ...prev, prefersReducedMotion, - })) + })); // Sync to AppState so components react immediately setAppState(prev => ({ ...prev, settings: { ...prev.settings, prefersReducedMotion }, - })) + })); logEvent('tengu_reduce_motion_setting_changed', { enabled: prefersReducedMotion, - }) + }); }, }, { @@ -416,11 +358,11 @@ export function Config({ value: thinkingEnabled ?? true, type: 'boolean' as const, onChange(enabled: boolean) { - setAppState(prev => ({ ...prev, thinkingEnabled: enabled })) + setAppState(prev => ({ ...prev, thinkingEnabled: enabled })); updateSettingsForSource('userSettings', { alwaysThinkingEnabled: enabled ? undefined : false, - }) - logEvent('tengu_thinking_toggled', { enabled }) + }); + logEvent('tengu_thinking_toggled', { enabled }); }, }, // Fast mode toggle (ant-only, eliminated from external builds) @@ -432,28 +374,28 @@ export function Config({ value: !!isFastMode, type: 'boolean' as const, onChange(enabled: boolean) { - clearFastModeCooldown() + clearFastModeCooldown(); updateSettingsForSource('userSettings', { fastMode: enabled ? true : undefined, - }) + }); if (enabled) { setAppState(prev => ({ ...prev, mainLoopModel: getFastModeModel(), mainLoopModelForSession: null, fastMode: true, - })) + })); setChanges(prev => ({ ...prev, model: getFastModeModel(), 'Fast mode': 'ON', - })) + })); } else { setAppState(prev => ({ ...prev, fastMode: false, - })) - setChanges(prev => ({ ...prev, 'Fast mode': 'OFF' })) + })); + setChanges(prev => ({ ...prev, 'Fast mode': 'OFF' })); } }, }, @@ -470,10 +412,10 @@ export function Config({ setAppState(prev => ({ ...prev, promptSuggestionEnabled: enabled, - })) + })); updateSettingsForSource('userSettings', { promptSuggestionEnabled: enabled ? undefined : false, - }) + }); }, }, ] @@ -488,19 +430,19 @@ export function Config({ type: 'boolean' as const, onChange(enabled: boolean) { saveGlobalConfig(current => { - if (current.speculationEnabled === enabled) return current + if (current.speculationEnabled === enabled) return current; return { ...current, speculationEnabled: enabled, - } - }) + }; + }); setGlobalConfig({ ...getGlobalConfig(), speculationEnabled: enabled, - }) + }); logEvent('tengu_speculation_setting_changed', { enabled, - }) + }); }, }, ] @@ -516,14 +458,14 @@ export function Config({ saveGlobalConfig(current => ({ ...current, fileCheckpointingEnabled: enabled, - })) + })); setGlobalConfig({ ...getGlobalConfig(), fileCheckpointingEnabled: enabled, - }) + }); logEvent('tengu_file_history_snapshots_setting_changed', { enabled: enabled, - }) + }); }, }, ] @@ -544,11 +486,11 @@ export function Config({ saveGlobalConfig(current => ({ ...current, terminalProgressBarEnabled, - })) - setGlobalConfig({ ...getGlobalConfig(), terminalProgressBarEnabled }) + })); + setGlobalConfig({ ...getGlobalConfig(), terminalProgressBarEnabled }); logEvent('tengu_terminal_progress_bar_setting_changed', { enabled: terminalProgressBarEnabled, - }) + }); }, }, ...(getFeatureValue_CACHED_MAY_BE_STALE('tengu_terminal_sidebar', false) @@ -562,14 +504,14 @@ export function Config({ saveGlobalConfig(current => ({ ...current, showStatusInTerminalTab, - })) + })); setGlobalConfig({ ...getGlobalConfig(), showStatusInTerminalTab, - }) + }); logEvent('tengu_terminal_tab_status_setting_changed', { enabled: showStatusInTerminalTab, - }) + }); }, }, ] @@ -580,11 +522,11 @@ export function Config({ value: globalConfig.showTurnDuration, type: 'boolean' as const, onChange(showTurnDuration: boolean) { - saveGlobalConfig(current => ({ ...current, showTurnDuration })) - setGlobalConfig({ ...getGlobalConfig(), showTurnDuration }) + saveGlobalConfig(current => ({ ...current, showTurnDuration })); + setGlobalConfig({ ...getGlobalConfig(), showTurnDuration }); logEvent('tengu_show_turn_duration_setting_changed', { enabled: showTurnDuration, - }) + }); }, }, { @@ -592,40 +534,31 @@ export function Config({ label: 'Default permission mode', value: settingsData?.permissions?.defaultMode || 'default', options: (() => { - const priorityOrder: PermissionMode[] = ['default', 'plan'] - const allModes: readonly PermissionMode[] = feature( - 'TRANSCRIPT_CLASSIFIER', - ) + const priorityOrder: PermissionMode[] = ['default', 'plan']; + const allModes: readonly PermissionMode[] = feature('TRANSCRIPT_CLASSIFIER') ? PERMISSION_MODES - : EXTERNAL_PERMISSION_MODES - const excluded: PermissionMode[] = ['bypassPermissions'] + : EXTERNAL_PERMISSION_MODES; + const excluded: PermissionMode[] = ['bypassPermissions']; if (feature('TRANSCRIPT_CLASSIFIER') && !showAutoInDefaultModePicker) { - excluded.push('auto') + excluded.push('auto'); } - return [ - ...priorityOrder, - ...allModes.filter( - m => !priorityOrder.includes(m) && !excluded.includes(m), - ), - ] + return [...priorityOrder, ...allModes.filter(m => !priorityOrder.includes(m) && !excluded.includes(m))]; })(), type: 'enum' as const, onChange(mode: string) { - const parsedMode = permissionModeFromString(mode) + const parsedMode = permissionModeFromString(mode); // Internal modes (e.g. auto) are stored directly - const validatedMode = isExternalPermissionMode(parsedMode) - ? toExternalPermissionMode(parsedMode) - : parsedMode + const validatedMode = isExternalPermissionMode(parsedMode) ? toExternalPermissionMode(parsedMode) : parsedMode; const result = updateSettingsForSource('userSettings', { permissions: { ...settingsData?.permissions, defaultMode: validatedMode as ExternalPermissionMode, }, - }) + }); if (result.error) { - logError(result.error) - return + logError(result.error); + return; } // Update local state to reflect the change immediately. @@ -638,15 +571,13 @@ export function Config({ ...prev?.permissions, defaultMode: validatedMode as (typeof PERMISSION_MODES)[number], }, - })) + })); // Track changes - setChanges(prev => ({ ...prev, defaultPermissionMode: mode })) + setChanges(prev => ({ ...prev, defaultPermissionMode: mode })); logEvent('tengu_config_changed', { - setting: - 'defaultPermissionMode' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - value: - mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + setting: 'defaultPermissionMode' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + value: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); }, }, ...(feature('TRANSCRIPT_CLASSIFIER') && showAutoInDefaultModePicker @@ -654,30 +585,28 @@ export function Config({ { id: 'useAutoModeDuringPlan', label: 'Use auto mode during plan', - value: - (settingsData as { useAutoModeDuringPlan?: boolean } | undefined) - ?.useAutoModeDuringPlan ?? true, + value: (settingsData as { useAutoModeDuringPlan?: boolean } | undefined)?.useAutoModeDuringPlan ?? true, type: 'boolean' as const, onChange(useAutoModeDuringPlan: boolean) { updateSettingsForSource('userSettings', { useAutoModeDuringPlan, - }) + }); setSettingsData(prev => ({ ...prev, useAutoModeDuringPlan, - })) + })); // Internal writes suppress the file watcher, so // applySettingsChange won't fire. Reconcile directly so // mid-plan toggles take effect immediately. setAppState(prev => { - const next = transitionPlanAutoMode(prev.toolPermissionContext) - if (next === prev.toolPermissionContext) return prev - return { ...prev, toolPermissionContext: next } - }) + const next = transitionPlanAutoMode(prev.toolPermissionContext); + if (next === prev.toolPermissionContext) return prev; + return { ...prev, toolPermissionContext: next }; + }); setChanges(prev => ({ ...prev, 'Use auto mode during plan': useAutoModeDuringPlan, - })) + })); }, }, ] @@ -688,11 +617,11 @@ export function Config({ value: globalConfig.respectGitignore, type: 'boolean' as const, onChange(respectGitignore: boolean) { - saveGlobalConfig(current => ({ ...current, respectGitignore })) - setGlobalConfig({ ...getGlobalConfig(), respectGitignore }) + saveGlobalConfig(current => ({ ...current, respectGitignore })); + setGlobalConfig({ ...getGlobalConfig(), respectGitignore }); logEvent('tengu_respect_gitignore_setting_changed', { enabled: respectGitignore, - }) + }); }, }, { @@ -701,15 +630,12 @@ export function Config({ value: globalConfig.copyFullResponse, type: 'boolean' as const, onChange(copyFullResponse: boolean) { - saveGlobalConfig(current => ({ ...current, copyFullResponse })) - setGlobalConfig({ ...getGlobalConfig(), copyFullResponse }) + saveGlobalConfig(current => ({ ...current, copyFullResponse })); + setGlobalConfig({ ...getGlobalConfig(), copyFullResponse }); logEvent('tengu_config_changed', { - setting: - 'copyFullResponse' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - value: String( - copyFullResponse, - ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + setting: 'copyFullResponse' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + value: String(copyFullResponse) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); }, }, // Copy-on-select is only meaningful with in-app selection (fullscreen @@ -722,15 +648,12 @@ export function Config({ value: globalConfig.copyOnSelect ?? true, type: 'boolean' as const, onChange(copyOnSelect: boolean) { - saveGlobalConfig(current => ({ ...current, copyOnSelect })) - setGlobalConfig({ ...getGlobalConfig(), copyOnSelect }) + saveGlobalConfig(current => ({ ...current, copyOnSelect })); + setGlobalConfig({ ...getGlobalConfig(), copyOnSelect }); logEvent('tengu_config_changed', { - setting: - 'copyOnSelect' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - value: String( - copyOnSelect, - ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + setting: 'copyOnSelect' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + value: String(copyOnSelect) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); }, }, ] @@ -762,30 +685,19 @@ export function Config({ }, { id: 'notifChannel', - label: - feature('KAIROS') || feature('KAIROS_PUSH_NOTIFICATION') - ? 'Local notifications' - : 'Notifications', + label: feature('KAIROS') || feature('KAIROS_PUSH_NOTIFICATION') ? 'Local notifications' : 'Notifications', value: globalConfig.preferredNotifChannel, - options: [ - 'auto', - 'iterm2', - 'terminal_bell', - 'iterm2_with_bell', - 'kitty', - 'ghostty', - 'notifications_disabled', - ], + options: ['auto', 'iterm2', 'terminal_bell', 'iterm2_with_bell', 'kitty', 'ghostty', 'notifications_disabled'], type: 'enum', onChange(notifChannel: GlobalConfig['preferredNotifChannel']) { saveGlobalConfig(current => ({ ...current, preferredNotifChannel: notifChannel, - })) + })); setGlobalConfig({ ...getGlobalConfig(), preferredNotifChannel: notifChannel, - }) + }); }, }, ...(feature('KAIROS') || feature('KAIROS_PUSH_NOTIFICATION') @@ -799,11 +711,11 @@ export function Config({ saveGlobalConfig(current => ({ ...current, taskCompleteNotifEnabled, - })) + })); setGlobalConfig({ ...getGlobalConfig(), taskCompleteNotifEnabled, - }) + }); }, }, { @@ -815,11 +727,11 @@ export function Config({ saveGlobalConfig(current => ({ ...current, inputNeededNotifEnabled, - })) + })); setGlobalConfig({ ...getGlobalConfig(), inputNeededNotifEnabled, - }) + }); }, }, { @@ -831,11 +743,11 @@ export function Config({ saveGlobalConfig(current => ({ ...current, agentPushNotifEnabled, - })) + })); setGlobalConfig({ ...getGlobalConfig(), agentPushNotifEnabled, - }) + }); }, }, ] @@ -855,34 +767,27 @@ export function Config({ // 'default' means the setting is unset — currently resolves to // transcript (main.tsx falls through when defaultView !== 'chat'). // String() narrows the conditional-schema-spread union to string. - value: - settingsData?.defaultView === undefined - ? 'default' - : String(settingsData.defaultView), + value: settingsData?.defaultView === undefined ? 'default' : String(settingsData.defaultView), options: ['transcript', 'chat', 'default'], type: 'enum' as const, onChange(selected: string) { - const defaultView = - selected === 'default' - ? undefined - : (selected as 'chat' | 'transcript') - updateSettingsForSource('localSettings', { defaultView }) - setSettingsData(prev => ({ ...prev, defaultView })) - const nextBrief = defaultView === 'chat' + const defaultView = selected === 'default' ? undefined : (selected as 'chat' | 'transcript'); + updateSettingsForSource('localSettings', { defaultView }); + setSettingsData(prev => ({ ...prev, defaultView })); + const nextBrief = defaultView === 'chat'; setAppState(prev => { - if (prev.isBriefOnly === nextBrief) return prev - return { ...prev, isBriefOnly: nextBrief } - }) + if (prev.isBriefOnly === nextBrief) return prev; + return { ...prev, isBriefOnly: nextBrief }; + }); // Keep userMsgOptIn in sync so the tool list follows the view. // Two-way now (same as /brief) — accepting a cache invalidation // is better than leaving the tool on after switching away. // Reverted on Escape via initialUserMsgOptIn snapshot. - setUserMsgOptIn(nextBrief) - setChanges(prev => ({ ...prev, 'Default view': selected })) + setUserMsgOptIn(nextBrief); + setChanges(prev => ({ ...prev, 'Default view': selected })); logEvent('tengu_default_view_setting_changed', { - value: (defaultView ?? - 'unset') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + value: (defaultView ?? 'unset') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); }, }, ] @@ -898,27 +803,23 @@ export function Config({ id: 'editorMode', label: 'Editor mode', // Convert 'emacs' to 'normal' for backward compatibility - value: - globalConfig.editorMode === 'emacs' - ? 'normal' - : globalConfig.editorMode || 'normal', + value: globalConfig.editorMode === 'emacs' ? 'normal' : globalConfig.editorMode || 'normal', options: ['normal', 'vim'], type: 'enum', onChange(value: string) { saveGlobalConfig(current => ({ ...current, editorMode: value as GlobalConfig['editorMode'], - })) + })); setGlobalConfig({ ...getGlobalConfig(), editorMode: value as GlobalConfig['editorMode'], - }) + }); logEvent('tengu_editor_mode_changed', { mode: value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - source: - 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + source: 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); }, }, { @@ -928,19 +829,19 @@ export function Config({ type: 'boolean' as const, onChange(enabled: boolean) { saveGlobalConfig(current => { - if (current.prStatusFooterEnabled === enabled) return current + if (current.prStatusFooterEnabled === enabled) return current; return { ...current, prStatusFooterEnabled: enabled, - } - }) + }; + }); setGlobalConfig({ ...getGlobalConfig(), prStatusFooterEnabled: enabled, - }) + }); logEvent('tengu_pr_status_footer_setting_changed', { enabled, - }) + }); }, }, { @@ -962,17 +863,16 @@ export function Config({ saveGlobalConfig(current => ({ ...current, diffTool: diffTool as GlobalConfig['diffTool'], - })) + })); setGlobalConfig({ ...getGlobalConfig(), diffTool: diffTool as GlobalConfig['diffTool'], - }) + }); logEvent('tengu_diff_tool_changed', { tool: diffTool as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - source: - 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + source: 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); }, }, ] @@ -985,14 +885,13 @@ export function Config({ value: globalConfig.autoConnectIde ?? false, type: 'boolean' as const, onChange(autoConnectIde: boolean) { - saveGlobalConfig(current => ({ ...current, autoConnectIde })) - setGlobalConfig({ ...getGlobalConfig(), autoConnectIde }) + saveGlobalConfig(current => ({ ...current, autoConnectIde })); + setGlobalConfig({ ...getGlobalConfig(), autoConnectIde }); logEvent('tengu_auto_connect_ide_changed', { enabled: autoConnectIde, - source: - 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + source: 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); }, }, ] @@ -1008,14 +907,13 @@ export function Config({ saveGlobalConfig(current => ({ ...current, autoInstallIdeExtension, - })) - setGlobalConfig({ ...getGlobalConfig(), autoInstallIdeExtension }) + })); + setGlobalConfig({ ...getGlobalConfig(), autoInstallIdeExtension }); logEvent('tengu_auto_install_ide_extension_changed', { enabled: autoInstallIdeExtension, - source: - 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + source: 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); }, }, ] @@ -1029,23 +927,21 @@ export function Config({ saveGlobalConfig(current => ({ ...current, claudeInChromeDefaultEnabled: enabled, - })) + })); setGlobalConfig({ ...getGlobalConfig(), claudeInChromeDefaultEnabled: enabled, - }) + }); logEvent('tengu_claude_in_chrome_setting_changed', { enabled, - }) + }); }, }, // Teammate mode (only shown when agent swarms are enabled) ...(isAgentSwarmsEnabled() ? (() => { - const cliOverride = getCliTeammateModeOverride() - const label = cliOverride - ? `Teammate mode [overridden: ${cliOverride}]` - : 'Teammate mode' + const cliOverride = getCliTeammateModeOverride(); + const label = cliOverride ? `Teammate mode [overridden: ${cliOverride}]` : 'Teammate mode'; return [ { id: 'teammateMode', @@ -1054,38 +950,32 @@ export function Config({ options: ['auto', 'tmux', 'in-process'], type: 'enum' as const, onChange(mode: string) { - if ( - mode !== 'auto' && - mode !== 'tmux' && - mode !== 'in-process' - ) { - return + if (mode !== 'auto' && mode !== 'tmux' && mode !== 'in-process') { + return; } // Clear CLI override and set new mode (pass mode to avoid race condition) - clearCliTeammateModeOverride(mode) + clearCliTeammateModeOverride(mode); saveGlobalConfig(current => ({ ...current, teammateMode: mode, - })) + })); setGlobalConfig({ ...getGlobalConfig(), teammateMode: mode, - }) + }); logEvent('tengu_teammate_mode_changed', { mode: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + }); }, }, { id: 'teammateDefaultModel', label: 'Default teammate model', - value: teammateModelDisplayString( - globalConfig.teammateDefaultModel, - ), + value: teammateModelDisplayString(globalConfig.teammateDefaultModel), type: 'managedEnum' as const, onChange() {}, }, - ] + ]; })() : []), // Remote at startup toggle — gated on build flag + GrowthBook + policy @@ -1104,41 +994,36 @@ export function Config({ if (selected === 'default') { // Unset the config key so it falls back to the platform default saveGlobalConfig(current => { - if (current.remoteControlAtStartup === undefined) - return current - const next = { ...current } - delete next.remoteControlAtStartup - return next - }) + if (current.remoteControlAtStartup === undefined) return current; + const next = { ...current }; + delete next.remoteControlAtStartup; + return next; + }); setGlobalConfig({ ...getGlobalConfig(), remoteControlAtStartup: undefined, - }) + }); } else { - const enabled = selected === 'true' + const enabled = selected === 'true'; saveGlobalConfig(current => { - if (current.remoteControlAtStartup === enabled) return current - return { ...current, remoteControlAtStartup: enabled } - }) + if (current.remoteControlAtStartup === enabled) return current; + return { ...current, remoteControlAtStartup: enabled }; + }); setGlobalConfig({ ...getGlobalConfig(), remoteControlAtStartup: enabled, - }) + }); } // Sync to AppState so useReplBridge reacts immediately - const resolved = getRemoteControlAtStartup() + const resolved = getRemoteControlAtStartup(); setAppState(prev => { - if ( - prev.replBridgeEnabled === resolved && - !prev.replBridgeOutboundOnly - ) - return prev + if (prev.replBridgeEnabled === resolved && !prev.replBridgeOutboundOnly) return prev; return { ...prev, replBridgeEnabled: resolved, replBridgeOutboundOnly: false, - } - }) + }; + }); }, }, ] @@ -1149,11 +1034,11 @@ export function Config({ id: 'showExternalIncludesDialog', label: 'External CLAUDE.md includes', value: (() => { - const projectConfig = getCurrentProjectConfig() + const projectConfig = getCurrentProjectConfig(); if (projectConfig.hasClaudeMdExternalIncludesApproved) { - return 'true' + return 'true'; } else { - return 'false' + return 'false'; } })(), type: 'managedEnum' as const, @@ -1169,10 +1054,7 @@ export function Config({ id: 'apiKey', label: ( - Use custom API key:{' '} - - {normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY)} - + Use custom API key: {normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY)} ), searchText: 'Use custom API key', @@ -1185,94 +1067,82 @@ export function Config({ type: 'boolean' as const, onChange(useCustomKey: boolean) { saveGlobalConfig(current => { - const updated = { ...current } + const updated = { ...current }; if (!updated.customApiKeyResponses) { updated.customApiKeyResponses = { approved: [], rejected: [], - } + }; } if (!updated.customApiKeyResponses.approved) { updated.customApiKeyResponses = { ...updated.customApiKeyResponses, approved: [], - } + }; } if (!updated.customApiKeyResponses.rejected) { updated.customApiKeyResponses = { ...updated.customApiKeyResponses, rejected: [], - } + }; } if (process.env.ANTHROPIC_API_KEY) { - const truncatedKey = normalizeApiKeyForConfig( - process.env.ANTHROPIC_API_KEY, - ) + const truncatedKey = normalizeApiKeyForConfig(process.env.ANTHROPIC_API_KEY); if (useCustomKey) { updated.customApiKeyResponses = { ...updated.customApiKeyResponses, approved: [ - ...( - updated.customApiKeyResponses.approved ?? [] - ).filter(k => k !== truncatedKey), + ...(updated.customApiKeyResponses.approved ?? []).filter(k => k !== truncatedKey), truncatedKey, ], - rejected: ( - updated.customApiKeyResponses.rejected ?? [] - ).filter(k => k !== truncatedKey), - } + rejected: (updated.customApiKeyResponses.rejected ?? []).filter(k => k !== truncatedKey), + }; } else { updated.customApiKeyResponses = { ...updated.customApiKeyResponses, - approved: ( - updated.customApiKeyResponses.approved ?? [] - ).filter(k => k !== truncatedKey), + approved: (updated.customApiKeyResponses.approved ?? []).filter(k => k !== truncatedKey), rejected: [ - ...( - updated.customApiKeyResponses.rejected ?? [] - ).filter(k => k !== truncatedKey), + ...(updated.customApiKeyResponses.rejected ?? []).filter(k => k !== truncatedKey), truncatedKey, ], - } + }; } } - return updated - }) - setGlobalConfig(getGlobalConfig()) + return updated; + }); + setGlobalConfig(getGlobalConfig()); }, }, ] : []), - ] + ]; // Filter settings based on search query const filteredSettingsItems = React.useMemo(() => { - if (!searchQuery) return settingsItems - const lowerQuery = searchQuery.toLowerCase() + if (!searchQuery) return settingsItems; + const lowerQuery = searchQuery.toLowerCase(); return settingsItems.filter(setting => { - if (setting.id.toLowerCase().includes(lowerQuery)) return true - const searchableText = - 'searchText' in setting ? setting.searchText : setting.label - return searchableText.toLowerCase().includes(lowerQuery) - }) - }, [settingsItems, searchQuery]) + if (setting.id.toLowerCase().includes(lowerQuery)) return true; + const searchableText = 'searchText' in setting ? setting.searchText : setting.label; + return searchableText.toLowerCase().includes(lowerQuery); + }); + }, [settingsItems, searchQuery]); // Adjust selected index when filtered list shrinks, and keep the selected // item visible when maxVisible changes (e.g., terminal resize). React.useEffect(() => { if (selectedIndex >= filteredSettingsItems.length) { - const newIndex = Math.max(0, filteredSettingsItems.length - 1) - setSelectedIndex(newIndex) - setScrollOffset(Math.max(0, newIndex - maxVisible + 1)) - return + const newIndex = Math.max(0, filteredSettingsItems.length - 1); + setSelectedIndex(newIndex); + setScrollOffset(Math.max(0, newIndex - maxVisible + 1)); + return; } setScrollOffset(prev => { - if (selectedIndex < prev) return selectedIndex - if (selectedIndex >= prev + maxVisible) - return selectedIndex - maxVisible + 1 - return prev - }) - }, [filteredSettingsItems.length, selectedIndex, maxVisible]) + if (selectedIndex < prev) return selectedIndex; + if (selectedIndex >= prev + maxVisible) return selectedIndex - maxVisible + 1; + return prev; + }); + }, [filteredSettingsItems.length, selectedIndex, maxVisible]); // Keep the selected item visible within the scroll window. // Called synchronously from navigation handlers to avoid a render frame @@ -1280,13 +1150,13 @@ export function Config({ const adjustScrollOffset = useCallback( (newIndex: number) => { setScrollOffset(prev => { - if (newIndex < prev) return newIndex - if (newIndex >= prev + maxVisible) return newIndex - maxVisible + 1 - return prev - }) + if (newIndex < prev) return newIndex; + if (newIndex >= prev + maxVisible) return newIndex - maxVisible + 1; + return prev; + }); }, [maxVisible], - ) + ); // Enter: keep all changes (already persisted by onChange handlers), close // with a summary of what changed. @@ -1294,164 +1164,101 @@ export function Config({ // Submenu handling: each submenu has its own Enter/Esc — don't close // the whole panel while one is open. if (showSubmenu !== null) { - return + return; } // Log any changes that were made // TODO: Make these proper messages - const formattedChanges: string[] = Object.entries(changes).map( - ([key, value]) => { - logEvent('tengu_config_changed', { - key: key as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - value: - value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - return `Set ${key} to ${chalk.bold(value)}` - }, - ) + const formattedChanges: string[] = Object.entries(changes).map(([key, value]) => { + logEvent('tengu_config_changed', { + key: key as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + value: value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + return `Set ${key} to ${chalk.bold(value)}`; + }); // Check for API key changes // On homespace, ANTHROPIC_API_KEY is preserved in process.env for child // processes but ignored by Claude Code itself (see auth.ts). - const effectiveApiKey = isRunningOnHomespace() - ? undefined - : process.env.ANTHROPIC_API_KEY + const effectiveApiKey = isRunningOnHomespace() ? undefined : process.env.ANTHROPIC_API_KEY; const initialUsingCustomKey = Boolean( effectiveApiKey && - initialConfig.current.customApiKeyResponses?.approved?.includes( - normalizeApiKeyForConfig(effectiveApiKey), - ), - ) + initialConfig.current.customApiKeyResponses?.approved?.includes(normalizeApiKeyForConfig(effectiveApiKey)), + ); const currentUsingCustomKey = Boolean( effectiveApiKey && - globalConfig.customApiKeyResponses?.approved?.includes( - normalizeApiKeyForConfig(effectiveApiKey), - ), - ) + globalConfig.customApiKeyResponses?.approved?.includes(normalizeApiKeyForConfig(effectiveApiKey)), + ); if (initialUsingCustomKey !== currentUsingCustomKey) { - formattedChanges.push( - `${currentUsingCustomKey ? 'Enabled' : 'Disabled'} custom API key`, - ) + formattedChanges.push(`${currentUsingCustomKey ? 'Enabled' : 'Disabled'} custom API key`); logEvent('tengu_config_changed', { key: 'env.ANTHROPIC_API_KEY' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - value: - currentUsingCustomKey as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + value: currentUsingCustomKey as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); } if (globalConfig.theme !== initialConfig.current.theme) { - formattedChanges.push(`Set theme to ${chalk.bold(globalConfig.theme)}`) + formattedChanges.push(`Set theme to ${chalk.bold(globalConfig.theme)}`); } - if ( - globalConfig.preferredNotifChannel !== - initialConfig.current.preferredNotifChannel - ) { - formattedChanges.push( - `Set notifications to ${chalk.bold(globalConfig.preferredNotifChannel)}`, - ) + if (globalConfig.preferredNotifChannel !== initialConfig.current.preferredNotifChannel) { + formattedChanges.push(`Set notifications to ${chalk.bold(globalConfig.preferredNotifChannel)}`); } if (currentOutputStyle !== initialOutputStyle.current) { - formattedChanges.push( - `Set output style to ${chalk.bold(currentOutputStyle)}`, - ) + formattedChanges.push(`Set output style to ${chalk.bold(currentOutputStyle)}`); } if (currentLanguage !== initialLanguage.current) { - formattedChanges.push( - `Set response language to ${chalk.bold(currentLanguage ?? 'Default (English)')}`, - ) + formattedChanges.push(`Set response language to ${chalk.bold(currentLanguage ?? 'Default (English)')}`); } if (globalConfig.editorMode !== initialConfig.current.editorMode) { - formattedChanges.push( - `Set editor mode to ${chalk.bold(globalConfig.editorMode || 'emacs')}`, - ) + formattedChanges.push(`Set editor mode to ${chalk.bold(globalConfig.editorMode || 'emacs')}`); } if (globalConfig.diffTool !== initialConfig.current.diffTool) { - formattedChanges.push( - `Set diff tool to ${chalk.bold(globalConfig.diffTool)}`, - ) + formattedChanges.push(`Set diff tool to ${chalk.bold(globalConfig.diffTool)}`); } if (globalConfig.autoConnectIde !== initialConfig.current.autoConnectIde) { - formattedChanges.push( - `${globalConfig.autoConnectIde ? 'Enabled' : 'Disabled'} auto-connect to IDE`, - ) + formattedChanges.push(`${globalConfig.autoConnectIde ? 'Enabled' : 'Disabled'} auto-connect to IDE`); } - if ( - globalConfig.autoInstallIdeExtension !== - initialConfig.current.autoInstallIdeExtension - ) { + if (globalConfig.autoInstallIdeExtension !== initialConfig.current.autoInstallIdeExtension) { formattedChanges.push( `${globalConfig.autoInstallIdeExtension ? 'Enabled' : 'Disabled'} auto-install IDE extension`, - ) + ); } - if ( - globalConfig.autoCompactEnabled !== - initialConfig.current.autoCompactEnabled - ) { - formattedChanges.push( - `${globalConfig.autoCompactEnabled ? 'Enabled' : 'Disabled'} auto-compact`, - ) + if (globalConfig.autoCompactEnabled !== initialConfig.current.autoCompactEnabled) { + formattedChanges.push(`${globalConfig.autoCompactEnabled ? 'Enabled' : 'Disabled'} auto-compact`); } - if ( - globalConfig.respectGitignore !== initialConfig.current.respectGitignore - ) { + if (globalConfig.respectGitignore !== initialConfig.current.respectGitignore) { formattedChanges.push( `${globalConfig.respectGitignore ? 'Enabled' : 'Disabled'} respect .gitignore in file picker`, - ) + ); } - if ( - globalConfig.copyFullResponse !== initialConfig.current.copyFullResponse - ) { - formattedChanges.push( - `${globalConfig.copyFullResponse ? 'Enabled' : 'Disabled'} always copy full response`, - ) + if (globalConfig.copyFullResponse !== initialConfig.current.copyFullResponse) { + formattedChanges.push(`${globalConfig.copyFullResponse ? 'Enabled' : 'Disabled'} always copy full response`); } if (globalConfig.copyOnSelect !== initialConfig.current.copyOnSelect) { - formattedChanges.push( - `${globalConfig.copyOnSelect ? 'Enabled' : 'Disabled'} copy on select`, - ) + formattedChanges.push(`${globalConfig.copyOnSelect ? 'Enabled' : 'Disabled'} copy on select`); } - if ( - globalConfig.terminalProgressBarEnabled !== - initialConfig.current.terminalProgressBarEnabled - ) { + if (globalConfig.terminalProgressBarEnabled !== initialConfig.current.terminalProgressBarEnabled) { formattedChanges.push( `${globalConfig.terminalProgressBarEnabled ? 'Enabled' : 'Disabled'} terminal progress bar`, - ) + ); } - if ( - globalConfig.showStatusInTerminalTab !== - initialConfig.current.showStatusInTerminalTab - ) { - formattedChanges.push( - `${globalConfig.showStatusInTerminalTab ? 'Enabled' : 'Disabled'} terminal tab status`, - ) + if (globalConfig.showStatusInTerminalTab !== initialConfig.current.showStatusInTerminalTab) { + formattedChanges.push(`${globalConfig.showStatusInTerminalTab ? 'Enabled' : 'Disabled'} terminal tab status`); } - if ( - globalConfig.showTurnDuration !== initialConfig.current.showTurnDuration - ) { - formattedChanges.push( - `${globalConfig.showTurnDuration ? 'Enabled' : 'Disabled'} turn duration`, - ) + if (globalConfig.showTurnDuration !== initialConfig.current.showTurnDuration) { + formattedChanges.push(`${globalConfig.showTurnDuration ? 'Enabled' : 'Disabled'} turn duration`); } - if ( - globalConfig.remoteControlAtStartup !== - initialConfig.current.remoteControlAtStartup - ) { + if (globalConfig.remoteControlAtStartup !== initialConfig.current.remoteControlAtStartup) { const remoteLabel = globalConfig.remoteControlAtStartup === undefined ? 'Reset Remote Control to default' - : `${globalConfig.remoteControlAtStartup ? 'Enabled' : 'Disabled'} Remote Control for all sessions` - formattedChanges.push(remoteLabel) + : `${globalConfig.remoteControlAtStartup ? 'Enabled' : 'Disabled'} Remote Control for all sessions`; + formattedChanges.push(remoteLabel); } - if ( - settingsData?.autoUpdatesChannel !== - initialSettingsData.current?.autoUpdatesChannel - ) { - formattedChanges.push( - `Set auto-update channel to ${chalk.bold(settingsData?.autoUpdatesChannel ?? 'latest')}`, - ) + if (settingsData?.autoUpdatesChannel !== initialSettingsData.current?.autoUpdatesChannel) { + formattedChanges.push(`Set auto-update channel to ${chalk.bold(settingsData?.autoUpdatesChannel ?? 'latest')}`); } if (formattedChanges.length > 0) { - onClose(formattedChanges.join('\n')) + onClose(formattedChanges.join('\n')); } else { - onClose('Config dialog dismissed', { display: 'system' }) + onClose('Config dialog dismissed', { display: 'system' }); } }, [ showSubmenu, @@ -1461,11 +1268,9 @@ export function Config({ currentOutputStyle, currentLanguage, settingsData?.autoUpdatesChannel, - isFastModeEnabled() - ? (settingsData as Record | undefined)?.fastMode - : undefined, + isFastModeEnabled() ? (settingsData as Record | undefined)?.fastMode : undefined, onClose, - ]) + ]); // Restore all state stores to their mount-time snapshots. Changes are // applied to disk/AppState immediately on toggle, so "cancel" means @@ -1475,22 +1280,22 @@ export function Config({ // config overwrite since setTheme internally calls saveGlobalConfig with // a partial update — we want the full snapshot to be the last write. if (themeSetting !== initialThemeSetting.current) { - setTheme(initialThemeSetting.current) + setTheme(initialThemeSetting.current); } // Global config: full overwrite from snapshot. saveGlobalConfig skips if // the returned ref equals current (test mode checks ref; prod writes to // disk but content is identical). - saveGlobalConfig(() => initialConfig.current) + saveGlobalConfig(() => initialConfig.current); // Settings files: restore each key Config may have touched. undefined // deletes the key (updateSettingsForSource customizer at settings.ts:368). - const il = initialLocalSettings + const il = initialLocalSettings; updateSettingsForSource('localSettings', { spinnerTipsEnabled: il?.spinnerTipsEnabled, prefersReducedMotion: il?.prefersReducedMotion, defaultView: il?.defaultView, outputStyle: il?.outputStyle, - }) - const iu = initialUserSettings + }); + const iu = initialUserSettings; updateSettingsForSource('userSettings', { alwaysThinkingEnabled: iu?.alwaysThinkingEnabled, fastMode: iu?.fastMode, @@ -1500,9 +1305,7 @@ export function Config({ language: iu?.language, ...(feature('TRANSCRIPT_CLASSIFIER') ? { - useAutoModeDuringPlan: ( - iu as { useAutoModeDuringPlan?: boolean } | undefined - )?.useAutoModeDuringPlan, + useAutoModeDuringPlan: (iu as { useAutoModeDuringPlan?: boolean } | undefined)?.useAutoModeDuringPlan, } : {}), // ThemePicker's Ctrl+T writes this key directly — include it so the @@ -1515,12 +1318,10 @@ export function Config({ // Explicitly include defaultMode so undefined triggers the customizer's // delete path even when iu.permissions lacks that key. permissions: - iu?.permissions === undefined - ? undefined - : { ...iu.permissions, defaultMode: iu.permissions.defaultMode }, - }) + iu?.permissions === undefined ? undefined : { ...iu.permissions, defaultMode: iu.permissions.defaultMode }, + }); // AppState: batch-restore all possibly-touched fields. - const ia = initialAppState + const ia = initialAppState; setAppState(prev => ({ ...prev, mainLoopModel: ia.mainLoopModel, @@ -1536,12 +1337,12 @@ export function Config({ // Reconcile auto-mode state after useAutoModeDuringPlan revert above — // the onChange handler may have activated/deactivated auto mid-plan. toolPermissionContext: transitionPlanAutoMode(prev.toolPermissionContext), - })) + })); // Bootstrap state: restore userMsgOptIn. Only touched by the defaultView // onChange above, so no feature() guard needed here (that path only // exists when showDefaultViewPicker is true). if (getUserMsgOptIn() !== initialUserMsgOptIn) { - setUserMsgOptIn(initialUserMsgOptIn) + setUserMsgOptIn(initialUserMsgOptIn); } }, [ themeSetting, @@ -1551,18 +1352,18 @@ export function Config({ initialAppState, initialUserMsgOptIn, setAppState, - ]) + ]); // Escape: revert all changes (if any) and close. const handleEscape = useCallback(() => { if (showSubmenu !== null) { - return + return; } if (isDirty.current) { - revertChanges() + revertChanges(); } - onClose('Config dialog dismissed', { display: 'system' }) - }, [showSubmenu, revertChanges, onClose]) + onClose('Config dialog dismissed', { display: 'system' }); + }, [showSubmenu, revertChanges, onClose]); // Disable when submenu is open so the submenu's Dialog handles ESC, and in // search mode so the onKeyDown handler (which clears-then-exits search) @@ -1570,35 +1371,35 @@ export function Config({ useKeybinding('confirm:no', handleEscape, { context: 'Settings', isActive: showSubmenu === null && !isSearchMode && !headerFocused, - }) + }); // Save-and-close fires on Enter only when not in search mode (Enter there // exits search to the list — see the isSearchMode branch in handleKeyDown). useKeybinding('settings:close', handleSaveAndClose, { context: 'Settings', isActive: showSubmenu === null && !isSearchMode && !headerFocused, - }) + }); // Settings navigation and toggle actions via configurable keybindings. // Only active when not in search mode and no submenu is open. const toggleSetting = useCallback(() => { - const setting = filteredSettingsItems[selectedIndex] + const setting = filteredSettingsItems[selectedIndex]; if (!setting || !setting.onChange) { - return + return; } if (setting.type === 'boolean') { - isDirty.current = true - setting.onChange(!setting.value) + isDirty.current = true; + setting.onChange(!setting.value); if (setting.id === 'thinkingEnabled') { - const newValue = !setting.value - const backToInitial = newValue === initialThinkingEnabled.current + const newValue = !setting.value; + const backToInitial = newValue === initialThinkingEnabled.current; if (backToInitial) { - setShowThinkingWarning(false) + setShowThinkingWarning(false); } else if (context.messages.some(m => m.type === 'assistant')) { - setShowThinkingWarning(true) + setShowThinkingWarning(true); } } - return + return; } if ( @@ -1613,70 +1414,69 @@ export function Config({ // completion callback, not here (submenu may be cancelled). switch (setting.id) { case 'theme': - setShowSubmenu('Theme') - setTabsHidden(true) - return + setShowSubmenu('Theme'); + setTabsHidden(true); + return; case 'model': - setShowSubmenu('Model') - setTabsHidden(true) - return + setShowSubmenu('Model'); + setTabsHidden(true); + return; case 'teammateDefaultModel': - setShowSubmenu('TeammateModel') - setTabsHidden(true) - return + setShowSubmenu('TeammateModel'); + setTabsHidden(true); + return; case 'showExternalIncludesDialog': - setShowSubmenu('ExternalIncludes') - setTabsHidden(true) - return + setShowSubmenu('ExternalIncludes'); + setTabsHidden(true); + return; case 'outputStyle': - setShowSubmenu('OutputStyle') - setTabsHidden(true) - return + setShowSubmenu('OutputStyle'); + setTabsHidden(true); + return; case 'language': - setShowSubmenu('Language') - setTabsHidden(true) - return + setShowSubmenu('Language'); + setTabsHidden(true); + return; } } if (setting.id === 'autoUpdatesChannel') { if (autoUpdaterDisabledReason) { // Auto-updates are disabled - show enable dialog instead - setShowSubmenu('EnableAutoUpdates') - setTabsHidden(true) - return + setShowSubmenu('EnableAutoUpdates'); + setTabsHidden(true); + return; } - const currentChannel = settingsData?.autoUpdatesChannel ?? 'latest' + const currentChannel = settingsData?.autoUpdatesChannel ?? 'latest'; if (currentChannel === 'latest') { // Switching to stable - show downgrade dialog - setShowSubmenu('ChannelDowngrade') - setTabsHidden(true) + setShowSubmenu('ChannelDowngrade'); + setTabsHidden(true); } else { // Switching to latest - just do it and clear minimumVersion - isDirty.current = true + isDirty.current = true; updateSettingsForSource('userSettings', { autoUpdatesChannel: 'latest', minimumVersion: undefined, - }) + }); setSettingsData(prev => ({ ...prev, autoUpdatesChannel: 'latest', minimumVersion: undefined, - })) + })); logEvent('tengu_autoupdate_channel_changed', { - channel: - 'latest' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + channel: 'latest' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); } - return + return; } if (setting.type === 'enum') { - isDirty.current = true - const currentIndex = setting.options.indexOf(setting.value) - const nextIndex = (currentIndex + 1) % setting.options.length - setting.onChange(setting.options[nextIndex]!) - return + isDirty.current = true; + const currentIndex = setting.options.indexOf(setting.value); + const nextIndex = (currentIndex + 1) % setting.options.length; + setting.onChange(setting.options[nextIndex]!); + return; } }, [ autoUpdaterDisabledReason, @@ -1684,17 +1484,14 @@ export function Config({ selectedIndex, settingsData?.autoUpdatesChannel, setTabsHidden, - ]) + ]); const moveSelection = (delta: -1 | 1): void => { - setShowThinkingWarning(false) - const newIndex = Math.max( - 0, - Math.min(filteredSettingsItems.length - 1, selectedIndex + delta), - ) - setSelectedIndex(newIndex) - adjustScrollOffset(newIndex) - } + setShowThinkingWarning(false); + const newIndex = Math.max(0, Math.min(filteredSettingsItems.length - 1, selectedIndex + delta)); + setSelectedIndex(newIndex); + adjustScrollOffset(newIndex); + }; useKeybindings( { @@ -1703,11 +1500,11 @@ export function Config({ // ↑ at top enters search mode so users can type-to-filter after // reaching the list boundary. Wheel-up (scroll:lineUp) clamps // instead — overshoot shouldn't move focus away from the list. - setShowThinkingWarning(false) - setIsSearchMode(true) - setScrollOffset(0) + setShowThinkingWarning(false); + setIsSearchMode(true); + setScrollOffset(0); } else { - moveSelection(-1) + moveSelection(-1); } }, 'select:next': () => moveSelection(1), @@ -1719,92 +1516,79 @@ export function Config({ 'scroll:lineDown': () => moveSelection(1), 'select:accept': toggleSetting, 'settings:search': () => { - setIsSearchMode(true) - setSearchQuery('') + setIsSearchMode(true); + setSearchQuery(''); }, }, { context: 'Settings', isActive: showSubmenu === null && !isSearchMode && !headerFocused, }, - ) + ); // Combined key handling across search/list modes. Branch order mirrors // the original useInput gate priority: submenu and header short-circuit // first (their own handlers own input), then search vs. list. const handleKeyDown = useCallback( (e: KeyboardEvent) => { - if (showSubmenu !== null) return - if (headerFocused) return + if (showSubmenu !== null) return; + if (headerFocused) return; // Search mode: Esc clears then exits, Enter/↓ moves to the list. if (isSearchMode) { if (e.key === 'escape') { - e.preventDefault() + e.preventDefault(); if (searchQuery.length > 0) { - setSearchQuery('') + setSearchQuery(''); } else { - setIsSearchMode(false) + setIsSearchMode(false); } - return + return; } if (e.key === 'return' || e.key === 'down' || e.key === 'wheeldown') { - e.preventDefault() - setIsSearchMode(false) - setSelectedIndex(0) - setScrollOffset(0) + e.preventDefault(); + setIsSearchMode(false); + setSelectedIndex(0); + setScrollOffset(0); } - return + return; } // List mode: left/right/tab cycle the selected option's value. These // keys used to switch tabs; now they only do so when the tab row is // explicitly focused (see headerFocused in Settings.tsx). if (e.key === 'left' || e.key === 'right' || e.key === 'tab') { - e.preventDefault() - toggleSetting() - return + e.preventDefault(); + toggleSetting(); + return; } // Fallback: printable characters (other than those bound to actions) // enter search mode. Carve out j/k// — useKeybindings (still on the // useInput path) consumes these via stopImmediatePropagation, but // onKeyDown dispatches independently so we must skip them explicitly. - if (e.ctrl || e.meta) return - if (e.key === 'j' || e.key === 'k' || e.key === '/') return + if (e.ctrl || e.meta) return; + if (e.key === 'j' || e.key === 'k' || e.key === '/') return; if (e.key.length === 1 && e.key !== ' ') { - e.preventDefault() - setIsSearchMode(true) - setSearchQuery(e.key) + e.preventDefault(); + setIsSearchMode(true); + setSearchQuery(e.key); } }, - [ - showSubmenu, - headerFocused, - isSearchMode, - searchQuery, - setSearchQuery, - toggleSetting, - ], - ) + [showSubmenu, headerFocused, isSearchMode, searchQuery, setSearchQuery, toggleSetting], + ); return ( - + {showSubmenu === 'Theme' ? ( <> { - isDirty.current = true - setTheme(setting) - setShowSubmenu(null) - setTabsHidden(false) + isDirty.current = true; + setTheme(setting); + setShowSubmenu(null); + setTabsHidden(false); }} onCancel={() => { - setShowSubmenu(null) - setTabsHidden(false) + setShowSubmenu(null); + setTabsHidden(false); }} hideEscToCancel skipExitHandling={true} // Skip exit handling as Config already handles it @@ -1828,20 +1612,18 @@ export function Config({ { - isDirty.current = true - onChangeMainModelConfig(model) - setShowSubmenu(null) - setTabsHidden(false) + isDirty.current = true; + onChangeMainModelConfig(model); + setShowSubmenu(null); + setTabsHidden(false); }} onCancel={() => { - setShowSubmenu(null) - setTabsHidden(false) + setShowSubmenu(null); + setTabsHidden(false); }} showFastModeNotice={ isFastModeEnabled() - ? isFastMode && - isFastModeSupportedByModel(mainLoopModel) && - isFastModeAvailable() + ? isFastMode && isFastModeSupportedByModel(mainLoopModel) && isFastModeAvailable() : false } /> @@ -1864,39 +1646,33 @@ export function Config({ skipSettingsWrite headerText="Default model for newly spawned teammates. The leader can override via the tool call's model parameter." onSelect={(model, _effort) => { - setShowSubmenu(null) - setTabsHidden(false) + setShowSubmenu(null); + setTabsHidden(false); // First-open-then-Enter from unset: picker highlights "Default" // (initial=null) and confirming would write null, silently // switching Opus-fallback → follow-leader. Treat as no-op. - if ( - globalConfig.teammateDefaultModel === undefined && - model === null - ) { - return + if (globalConfig.teammateDefaultModel === undefined && model === null) { + return; } - isDirty.current = true + isDirty.current = true; saveGlobalConfig(current => - current.teammateDefaultModel === model - ? current - : { ...current, teammateDefaultModel: model }, - ) + current.teammateDefaultModel === model ? current : { ...current, teammateDefaultModel: model }, + ); setGlobalConfig({ ...getGlobalConfig(), teammateDefaultModel: model, - }) + }); setChanges(prev => ({ ...prev, teammateDefaultModel: teammateModelDisplayString(model), - })) + })); logEvent('tengu_teammate_default_model_changed', { - model: - model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + model: model as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); }} onCancel={() => { - setShowSubmenu(null) - setTabsHidden(false) + setShowSubmenu(null); + setTabsHidden(false); }} /> @@ -1915,8 +1691,8 @@ export function Config({ <> { - setShowSubmenu(null) - setTabsHidden(false) + setShowSubmenu(null); + setTabsHidden(false); }} externalIncludes={getExternalClaudeMdIncludes(memoryFiles)} /> @@ -1937,28 +1713,26 @@ export function Config({ { - isDirty.current = true - setCurrentOutputStyle(style ?? DEFAULT_OUTPUT_STYLE_NAME) - setShowSubmenu(null) - setTabsHidden(false) + isDirty.current = true; + setCurrentOutputStyle(style ?? DEFAULT_OUTPUT_STYLE_NAME); + setShowSubmenu(null); + setTabsHidden(false); // Save to local settings updateSettingsForSource('localSettings', { outputStyle: style, - }) + }); void logEvent('tengu_output_style_changed', { style: (style ?? DEFAULT_OUTPUT_STYLE_NAME) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - source: - 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - settings_source: - 'localSettings' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + source: 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + settings_source: 'localSettings' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); }} onCancel={() => { - setShowSubmenu(null) - setTabsHidden(false) + setShowSubmenu(null); + setTabsHidden(false); }} /> @@ -1978,37 +1752,30 @@ export function Config({ { - isDirty.current = true - setCurrentLanguage(language) - setShowSubmenu(null) - setTabsHidden(false) + isDirty.current = true; + setCurrentLanguage(language); + setShowSubmenu(null); + setTabsHidden(false); // Save to user settings updateSettingsForSource('userSettings', { language, - }) + }); void logEvent('tengu_language_changed', { - language: (language ?? - 'default') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - source: - 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + language: (language ?? 'default') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + source: 'config_panel' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); }} onCancel={() => { - setShowSubmenu(null) - setTabsHidden(false) + setShowSubmenu(null); + setTabsHidden(false); }} /> - + @@ -2016,8 +1783,8 @@ export function Config({ { - setShowSubmenu(null) - setTabsHidden(false) + setShowSubmenu(null); + setTabsHidden(false); }} hideBorder hideInputGuide @@ -2030,10 +1797,7 @@ export function Config({ : 'Auto-updates are disabled in development builds.'} {autoUpdaterDisabledReason?.type === 'env' && ( - - Unset {autoUpdaterDisabledReason.envVar} to re-enable - auto-updates. - + Unset {autoUpdaterDisabledReason.envVar} to re-enable auto-updates. )} ) : ( @@ -2049,29 +1813,28 @@ export function Config({ }, ]} onChange={(channel: string) => { - isDirty.current = true - setShowSubmenu(null) - setTabsHidden(false) + isDirty.current = true; + setShowSubmenu(null); + setTabsHidden(false); saveGlobalConfig(current => ({ ...current, autoUpdates: true, - })) - setGlobalConfig({ ...getGlobalConfig(), autoUpdates: true }) + })); + setGlobalConfig({ ...getGlobalConfig(), autoUpdates: true }); updateSettingsForSource('userSettings', { autoUpdatesChannel: channel as 'latest' | 'stable', minimumVersion: undefined, - }) + }); setSettingsData(prev => ({ ...prev, autoUpdatesChannel: channel as 'latest' | 'stable', minimumVersion: undefined, - })) + })); logEvent('tengu_autoupdate_enabled', { - channel: - channel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + channel: channel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); }} /> )} @@ -2080,46 +1843,41 @@ export function Config({ { - setShowSubmenu(null) - setTabsHidden(false) + setShowSubmenu(null); + setTabsHidden(false); if (choice === 'cancel') { // User cancelled - don't change anything - return + return; } - isDirty.current = true + isDirty.current = true; // Switch to stable channel const newSettings: { - autoUpdatesChannel: 'stable' - minimumVersion?: string + autoUpdatesChannel: 'stable'; + minimumVersion?: string; } = { autoUpdatesChannel: 'stable', - } + }; if (choice === 'stay') { // User wants to stay on current version until stable catches up - newSettings.minimumVersion = MACRO.VERSION + newSettings.minimumVersion = MACRO.VERSION; } - updateSettingsForSource('userSettings', newSettings) + updateSettingsForSource('userSettings', newSettings); setSettingsData(prev => ({ ...prev, ...newSettings, - })) + })); logEvent('tengu_autoupdate_channel_changed', { - channel: - 'stable' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + channel: 'stable' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, minimum_version_set: choice === 'stay', - }) + }); }} /> ) : ( - + )} - {filteredSettingsItems - .slice(scrollOffset, scrollOffset + maxVisible) - .map((setting, i) => { - const actualIndex = scrollOffset + i - const isSelected = - actualIndex === selectedIndex && - !headerFocused && - !isSearchMode + {filteredSettingsItems.slice(scrollOffset, scrollOffset + maxVisible).map((setting, i) => { + const actualIndex = scrollOffset + i; + const isSelected = actualIndex === selectedIndex && !headerFocused && !isSearchMode; - return ( - - - + return ( + + + + + {isSelected ? figures.pointer : ' '} {setting.label} + + + + {setting.type === 'boolean' ? ( + <> + {setting.value.toString()} + {showThinkingWarning && setting.id === 'thinkingEnabled' && ( + + {' '} + Changing thinking mode mid-conversation will increase latency and may reduce quality. + + )} + + ) : setting.id === 'theme' ? ( - {isSelected ? figures.pointer : ' '}{' '} - {setting.label} + {THEME_LABELS[setting.value.toString()] ?? setting.value.toString()} - - - {setting.type === 'boolean' ? ( - <> - - {setting.value.toString()} - - {showThinkingWarning && - setting.id === 'thinkingEnabled' && ( - - {' '} - Changing thinking mode mid-conversation - will increase latency and may reduce - quality. - - )} - - ) : setting.id === 'theme' ? ( - - {THEME_LABELS[setting.value.toString()] ?? - setting.value.toString()} - - ) : setting.id === 'notifChannel' ? ( - - - - ) : setting.id === 'defaultPermissionMode' ? ( - - {permissionModeTitle( - setting.value as PermissionMode, - )} - - ) : setting.id === 'autoUpdatesChannel' && - autoUpdaterDisabledReason ? ( - - - disabled - - - ( - {formatAutoUpdaterDisabledReason( - autoUpdaterDisabledReason, - )} - ) - - - ) : ( - - {setting.value.toString()} - - )} - + ) : setting.id === 'notifChannel' ? ( + + + + ) : setting.id === 'defaultPermissionMode' ? ( + + {permissionModeTitle(setting.value as PermissionMode)} + + ) : setting.id === 'autoUpdatesChannel' && autoUpdaterDisabledReason ? ( + + disabled + ({formatAutoUpdaterDisabledReason(autoUpdaterDisabledReason)}) + + ) : ( + {setting.value.toString()} + )} - - ) - })} + + + ); + })} {scrollOffset + maxVisible < filteredSettingsItems.length && ( - {figures.arrowDown}{' '} - {filteredSettingsItems.length - scrollOffset - maxVisible}{' '} - more below + {figures.arrowDown} {filteredSettingsItems.length - scrollOffset - maxVisible} more below )} @@ -2241,12 +1958,7 @@ export function Config({ - + ) : isSearchMode ? ( @@ -2255,12 +1967,7 @@ export function Config({ Type to filter - + ) : ( @@ -2284,27 +1991,22 @@ export function Config({ fallback="/" description="search" /> - + )} )} - ) + ); } function teammateModelDisplayString(value: string | null | undefined): string { if (value === undefined) { - return modelDisplayString(getHardcodedTeammateModelFallback()) + return modelDisplayString(getHardcodedTeammateModelFallback()); } - if (value === null) return "Default (leader's model)" - return modelDisplayString(value) + if (value === null) return "Default (leader's model)"; + return modelDisplayString(value); } const THEME_LABELS: Record = { @@ -2315,41 +2017,41 @@ const THEME_LABELS: Record = { 'light-daltonized': 'Light mode (colorblind-friendly)', 'dark-ansi': 'Dark mode (ANSI colors only)', 'light-ansi': 'Light mode (ANSI colors only)', -} +}; function NotifChannelLabel({ value }: { value: string }): React.ReactNode { switch (value) { case 'auto': - return 'Auto' + return 'Auto'; case 'iterm2': return ( iTerm2 (OSC 9) - ) + ); case 'terminal_bell': return ( Terminal Bell (\a) - ) + ); case 'kitty': return ( Kitty (OSC 99) - ) + ); case 'ghostty': return ( Ghostty (OSC 777) - ) + ); case 'iterm2_with_bell': - return 'iTerm2 w/ Bell' + return 'iTerm2 w/ Bell'; case 'notifications_disabled': - return 'Disabled' + return 'Disabled'; default: - return value + return value; } } diff --git a/src/components/Settings/Settings.tsx b/src/components/Settings/Settings.tsx index d54f6e758..ec3c97dd4 100644 --- a/src/components/Settings/Settings.tsx +++ b/src/components/Settings/Settings.tsx @@ -1,43 +1,30 @@ // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered -import * as React from 'react' -import { Suspense, useState } from 'react' -import { useKeybinding } from '../../keybindings/useKeybinding.js' -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' -import { - useIsInsideModal, - useModalOrTerminalSize, -} from '../../context/modalContext.js' -import { Pane } from '../design-system/Pane.js' -import { Tabs, Tab } from '../design-system/Tabs.js' -import { Status, buildDiagnostics } from './Status.js' -import { Config } from './Config.js' -import { Usage } from './Usage.js' -import type { - LocalJSXCommandContext, - CommandResultDisplay, -} from '../../commands.js' +import * as React from 'react'; +import { Suspense, useState } from 'react'; +import { useKeybinding } from '../../keybindings/useKeybinding.js'; +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import { useIsInsideModal, useModalOrTerminalSize } from '../../context/modalContext.js'; +import { Pane } from '../design-system/Pane.js'; +import { Tabs, Tab } from '../design-system/Tabs.js'; +import { Status, buildDiagnostics } from './Status.js'; +import { Config } from './Config.js'; +import { Usage } from './Usage.js'; +import type { LocalJSXCommandContext, CommandResultDisplay } from '../../commands.js'; type Props = { - onClose: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void - context: LocalJSXCommandContext - defaultTab: 'Status' | 'Config' | 'Usage' | 'Gates' -} + onClose: (result?: string, options?: { display?: CommandResultDisplay }) => void; + context: LocalJSXCommandContext; + defaultTab: 'Status' | 'Config' | 'Usage' | 'Gates'; +}; -export function Settings({ - onClose, - context, - defaultTab, -}: Props): React.ReactNode { - const [selectedTab, setSelectedTab] = useState(defaultTab) - const [tabsHidden, setTabsHidden] = useState(false) +export function Settings({ onClose, context, defaultTab }: Props): React.ReactNode { + const [selectedTab, setSelectedTab] = useState(defaultTab); + const [tabsHidden, setTabsHidden] = useState(false); // True while Config's own Esc handler is active (search mode with content // focused). Settings must cede Esc so search can clear/exit first. - const [configOwnsEsc, setConfigOwnsEsc] = useState(false) - const [gatesOwnsEsc, setGatesOwnsEsc] = useState(false) + const [configOwnsEsc, setConfigOwnsEsc] = useState(false); + const [gatesOwnsEsc, setGatesOwnsEsc] = useState(false); // Fixed content height so switching tabs doesn't shift the pane height. // Outside modals cap at min(80% viewport, 30). Inside a Modal the modal's // innerSize.rows IS the ScrollBox viewport — the 0.8 multiplier over- @@ -47,41 +34,34 @@ export function Settings({ // marginY={1} (2 rows) which is stripped inside modals → +2 to recover. // Then -2 for Tabs' header row + its marginTop=1. Plus +1 observed gap // from the paneCap-10 estimate being slightly generous. Net: rows + 1. - const insideModal = useIsInsideModal() - const { rows } = useModalOrTerminalSize(useTerminalSize()) - const contentHeight = insideModal - ? rows + 1 - : Math.max(15, Math.min(Math.floor(rows * 0.8), 30)) + const insideModal = useIsInsideModal(); + const { rows } = useModalOrTerminalSize(useTerminalSize()); + const contentHeight = insideModal ? rows + 1 : Math.max(15, Math.min(Math.floor(rows * 0.8), 30)); // Kick off diagnostics once when the pane opens. Status use()s this so // it resolves once per /config invocation — no re-fetch flash when // tabbing back to Status (Tab unmounts children when not selected). - const [diagnosticsPromise] = useState(() => - buildDiagnostics().catch(() => []), - ) + const [diagnosticsPromise] = useState(() => buildDiagnostics().catch(() => [])); - useExitOnCtrlCDWithKeybindings() + useExitOnCtrlCDWithKeybindings(); // Handle escape via keybinding - only when not in submenu const handleEscape = () => { // Don't handle escape when a submenu is showing (tabsHidden means submenu is open) // Let the submenu handle escape to return to the main menu if (tabsHidden) { - return + return; } // TODO: Update to "Settings" dialog once we define '/settings'. - onClose('Status dialog dismissed', { display: 'system' }) - } + onClose('Status dialog dismissed', { display: 'system' }); + }; // Disable when submenu is open so the submenu's Dialog can handle ESC, // and when Config's search mode is active so its useInput handler // (clear query → exit search) processes Escape first. useKeybinding('confirm:no', handleEscape, { context: 'Settings', - isActive: - !tabsHidden && - !(selectedTab === 'Config' && configOwnsEsc) && - !(selectedTab === 'Gates' && gatesOwnsEsc), - }) + isActive: !tabsHidden && !(selectedTab === 'Config' && configOwnsEsc) && !(selectedTab === 'Gates' && gatesOwnsEsc), + }); const tabs = [ @@ -104,14 +84,11 @@ export function Settings({ ...(process.env.USER_TYPE === 'ant' ? [ - + , ] : []), - ] + ]; return ( @@ -132,5 +109,5 @@ export function Settings({ {tabs} - ) + ); } diff --git a/src/components/Settings/Status.tsx b/src/components/Settings/Status.tsx index 1cd4aac14..6a3d3bbfc 100644 --- a/src/components/Settings/Status.tsx +++ b/src/components/Settings/Status.tsx @@ -1,13 +1,13 @@ -import figures from 'figures' -import * as React from 'react' -import { Suspense, use } from 'react' -import { getSessionId } from '../../bootstrap/state.js' -import type { LocalJSXCommandContext } from '../../commands.js' -import { useIsInsideModal } from '../../context/modalContext.js' -import { Box, Text, useTheme } from '../../ink.js' -import { type AppState, useAppState } from '../../state/AppState.js' -import { getCwd } from '../../utils/cwd.js' -import { getCurrentSessionTitle } from '../../utils/sessionStorage.js' +import figures from 'figures'; +import * as React from 'react'; +import { Suspense, use } from 'react'; +import { getSessionId } from '../../bootstrap/state.js'; +import type { LocalJSXCommandContext } from '../../commands.js'; +import { useIsInsideModal } from '../../context/modalContext.js'; +import { Box, Text, useTheme } from '../../ink.js'; +import { type AppState, useAppState } from '../../state/AppState.js'; +import { getCwd } from '../../utils/cwd.js'; +import { getCurrentSessionTitle } from '../../utils/sessionStorage.js'; import { buildAccountProperties, buildAPIProviderProperties, @@ -21,19 +21,19 @@ import { type Diagnostic, getModelDisplayLabel, type Property, -} from '../../utils/status.js' -import type { ThemeName } from '../../utils/theme.js' -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +} from '../../utils/status.js'; +import type { ThemeName } from '../../utils/theme.js'; +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; type Props = { - context: LocalJSXCommandContext - diagnosticsPromise: Promise -} + context: LocalJSXCommandContext; + diagnosticsPromise: Promise; +}; function buildPrimarySection(): Property[] { - const sessionId = getSessionId() - const customTitle = getCurrentSessionTitle(sessionId) - const nameValue = customTitle ?? /rename to add a name + const sessionId = getSessionId(); + const customTitle = getCurrentSessionTitle(sessionId); + const nameValue = customTitle ?? /rename to add a name; return [ { label: 'Version', value: MACRO.VERSION }, @@ -42,7 +42,7 @@ function buildPrimarySection(): Property[] { { label: 'cwd', value: getCwd() }, ...buildAccountProperties(), ...buildAPIProviderProperties(), - ] + ]; } function buildSecondarySection({ @@ -51,24 +51,20 @@ function buildSecondarySection({ theme, context, }: { - mainLoopModel: AppState['mainLoopModel'] - mcp: AppState['mcp'] - theme: ThemeName - context: LocalJSXCommandContext + mainLoopModel: AppState['mainLoopModel']; + mcp: AppState['mcp']; + theme: ThemeName; + context: LocalJSXCommandContext; }): Property[] { - const modelLabel = getModelDisplayLabel(mainLoopModel) + const modelLabel = getModelDisplayLabel(mainLoopModel); return [ { label: 'Model', value: modelLabel }, - ...buildIDEProperties( - mcp.clients, - context.options.ideInstallationStatus, - theme, - ), + ...buildIDEProperties(mcp.clients, context.options.ideInstallationStatus, theme), ...buildMcpProperties(mcp.clients, theme), ...buildSandboxProperties(), ...buildSettingSourcesProperties(), - ] + ]; } export async function buildDiagnostics(): Promise { @@ -76,14 +72,10 @@ export async function buildDiagnostics(): Promise { ...(await buildInstallationDiagnostics()), ...(await buildInstallationHealthDiagnostics()), ...(await buildMemoryDiagnostics()), - ] + ]; } -function PropertyValue({ - value, -}: { - value: Property['value'] -}): React.ReactNode { +function PropertyValue({ value }: { value: Property['value'] }): React.ReactNode { if (Array.isArray(value)) { return ( @@ -93,38 +85,32 @@ function PropertyValue({ {item} {i < value.length - 1 ? ',' : ''} - ) + ); })} - ) + ); } if (typeof value === 'string') { - return {value} + return {value}; } - return value + return value; } -export function Status({ - context, - diagnosticsPromise, -}: Props): React.ReactNode { - const mainLoopModel = useAppState(s => s.mainLoopModel) - const mcp = useAppState(s => s.mcp) - const [theme] = useTheme() +export function Status({ context, diagnosticsPromise }: Props): React.ReactNode { + const mainLoopModel = useAppState(s => s.mainLoopModel); + const mcp = useAppState(s => s.mcp); + const [theme] = useTheme(); // Sections are synchronous — compute in render so they're never empty. // diagnosticsPromise is created once in Settings.tsx so it resolves once // per pane invocation instead of re-fetching on every tab switch (Tab // unmounts children when not selected, which was causing the flash). const sections = React.useMemo( - () => [ - buildPrimarySection(), - buildSecondarySection({ mainLoopModel, mcp, theme, context }), - ], + () => [buildPrimarySection(), buildSecondarySection({ mainLoopModel, mcp, theme, context })], [mainLoopModel, mcp, theme, context], - ) + ); // flexGrow so the "Esc to cancel" footer pins to the bottom of the // Modal's inner ScrollBox when content is short. The ScrollBox content @@ -132,7 +118,7 @@ export function Status({ // to match. Without it, short Status content floats at the top and the // footer sits mid-modal with 2-3 trailing blank rows below. Outside a // Modal (non-fullscreen), leave layout alone — no ScrollBox to fill. - const grow = useIsInsideModal() ? 1 : undefined + const grow = useIsInsideModal() ? 1 : undefined; return ( @@ -156,37 +142,24 @@ export function Status({ - + - ) + ); } -function Diagnostics({ - promise, -}: { - promise: Promise -}): React.ReactNode { - const diagnostics = use(promise) - if (diagnostics.length === 0) return null +function Diagnostics({ promise }: { promise: Promise }): React.ReactNode { + const diagnostics = use(promise); + if (diagnostics.length === 0) return null; return ( System Diagnostics {diagnostics.map((diagnostic, i) => ( {figures.warning} - {typeof diagnostic === 'string' ? ( - {diagnostic} - ) : ( - diagnostic - )} + {typeof diagnostic === 'string' ? {diagnostic} : diagnostic} ))} - ) + ); } diff --git a/src/components/Settings/Usage.tsx b/src/components/Settings/Usage.tsx index d52d19577..20d073246 100644 --- a/src/components/Settings/Usage.tsx +++ b/src/components/Settings/Usage.tsx @@ -1,66 +1,52 @@ -import * as React from 'react' -import { useEffect, useState } from 'react' -import { extraUsage as extraUsageCommand } from 'src/commands/extra-usage/index.js' -import { formatCost } from 'src/cost-tracker.js' -import { getSubscriptionType } from 'src/utils/auth.js' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' -import { Box, Text } from '../../ink.js' -import { useKeybinding } from '../../keybindings/useKeybinding.js' -import { - type ExtraUsage, - fetchUtilization, - type RateLimit, - type Utilization, -} from '../../services/api/usage.js' -import { formatResetText } from '../../utils/format.js' -import { logError } from '../../utils/log.js' -import { jsonStringify } from '../../utils/slowOperations.js' -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' -import { Byline } from '../design-system/Byline.js' -import { ProgressBar } from '../design-system/ProgressBar.js' -import { - isEligibleForOverageCreditGrant, - OverageCreditUpsell, -} from '../LogoV2/OverageCreditUpsell.js' +import * as React from 'react'; +import { useEffect, useState } from 'react'; +import { extraUsage as extraUsageCommand } from 'src/commands/extra-usage/index.js'; +import { formatCost } from 'src/cost-tracker.js'; +import { getSubscriptionType } from 'src/utils/auth.js'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import { Box, Text } from '../../ink.js'; +import { useKeybinding } from '../../keybindings/useKeybinding.js'; +import { type ExtraUsage, fetchUtilization, type RateLimit, type Utilization } from '../../services/api/usage.js'; +import { formatResetText } from '../../utils/format.js'; +import { logError } from '../../utils/log.js'; +import { jsonStringify } from '../../utils/slowOperations.js'; +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; +import { Byline } from '../design-system/Byline.js'; +import { ProgressBar } from '../design-system/ProgressBar.js'; +import { isEligibleForOverageCreditGrant, OverageCreditUpsell } from '../LogoV2/OverageCreditUpsell.js'; type LimitBarProps = { - title: string - limit: RateLimit - maxWidth: number - showTimeInReset?: boolean - extraSubtext?: string -} + title: string; + limit: RateLimit; + maxWidth: number; + showTimeInReset?: boolean; + extraSubtext?: string; +}; -function LimitBar({ - title, - limit, - maxWidth, - showTimeInReset = true, - extraSubtext, -}: LimitBarProps): React.ReactNode { - const { utilization, resets_at } = limit +function LimitBar({ title, limit, maxWidth, showTimeInReset = true, extraSubtext }: LimitBarProps): React.ReactNode { + const { utilization, resets_at } = limit; if (utilization === null) { - return null + return null; } // Calculate usage percentage - const usedText = `${Math.floor(utilization)}% used` + const usedText = `${Math.floor(utilization)}% used`; - let subtext: string | undefined + let subtext: string | undefined; if (resets_at) { - subtext = `Resets ${formatResetText(resets_at, true, showTimeInReset)}` + subtext = `Resets ${formatResetText(resets_at, true, showTimeInReset)}`; } if (extraSubtext) { if (subtext) { - subtext = `${extraSubtext} · ${subtext}` + subtext = `${extraSubtext} · ${subtext}`; } else { - subtext = extraSubtext + subtext = extraSubtext; } } - const maxBarWidth = 50 - const usedLabelSpace = 12 + const maxBarWidth = 50; + const usedLabelSpace = 12; if (maxWidth >= maxBarWidth + usedLabelSpace) { return ( @@ -76,7 +62,7 @@ function LimitBar({ {subtext && {subtext}} - ) + ); } else { return ( @@ -97,52 +83,46 @@ function LimitBar({ /> {usedText} - ) + ); } } export function Usage(): React.ReactNode { - const [utilization, setUtilization] = useState(null) - const [error, setError] = useState(null) - const [isLoading, setIsLoading] = useState(true) - const { columns } = useTerminalSize() + const [utilization, setUtilization] = useState(null); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const { columns } = useTerminalSize(); - const availableWidth = columns - 2 // 2 for screen padding - const maxWidth = Math.min(availableWidth, 80) + const availableWidth = columns - 2; // 2 for screen padding + const maxWidth = Math.min(availableWidth, 80); const loadUtilization = React.useCallback(async () => { - setIsLoading(true) - setError(null) + setIsLoading(true); + setError(null); try { - const data = await fetchUtilization() - setUtilization(data) + const data = await fetchUtilization(); + setUtilization(data); } catch (err) { - logError(err as Error) - const axiosError = err as { response?: { data?: unknown } } - const responseBody = axiosError.response?.data - ? jsonStringify(axiosError.response.data) - : undefined - setError( - responseBody - ? `Failed to load usage data: ${responseBody}` - : 'Failed to load usage data', - ) + logError(err as Error); + const axiosError = err as { response?: { data?: unknown } }; + const responseBody = axiosError.response?.data ? jsonStringify(axiosError.response.data) : undefined; + setError(responseBody ? `Failed to load usage data: ${responseBody}` : 'Failed to load usage data'); } finally { - setIsLoading(false) + setIsLoading(false); } - }, []) + }, []); useEffect(() => { - void loadUtilization() - }, [loadUtilization]) + void loadUtilization(); + }, [loadUtilization]); useKeybinding( 'settings:retry', () => { - void loadUtilization() + void loadUtilization(); }, { context: 'Settings', isActive: !!error && !isLoading }, - ) + ); if (error) { return ( @@ -150,22 +130,12 @@ export function Usage(): React.ReactNode { Error: {error} - - + + - ) + ); } if (!utilization) { @@ -173,26 +143,18 @@ export function Usage(): React.ReactNode { Loading usage data… - + - ) + ); } // Only Max and Team plans have a Sonnet limit that differs from the weekly // limit (see rateLimitMessages.ts). For other plans the bar is redundant. // Show for null (unknown plan) to stay consistent with rateLimitMessages.ts, // which labels it "Sonnet limit" in that case. - const subscriptionType = getSubscriptionType() - const showSonnetBar = - subscriptionType === 'max' || - subscriptionType === 'team' || - subscriptionType === null + const subscriptionType = getSubscriptionType(); + const showSonnetBar = subscriptionType === 'max' || subscriptionType === 'team' || subscriptionType === null; const limits = [ { @@ -211,65 +173,40 @@ export function Usage(): React.ReactNode { }, ] : []), - ] + ]; return ( - {limits.some(({ limit }) => limit) || ( - /usage is only available for subscription plans. - )} + {limits.some(({ limit }) => limit) || /usage is only available for subscription plans.} {limits.map( - ({ title, limit }) => - limit && ( - - ), + ({ title, limit }) => limit && , )} - {utilization.extra_usage && ( - - )} + {utilization.extra_usage && } - {isEligibleForOverageCreditGrant() && ( - - )} + {isEligibleForOverageCreditGrant() && } - + - ) + ); } type ExtraUsageSectionProps = { - extraUsage: ExtraUsage - maxWidth: number -} + extraUsage: ExtraUsage; + maxWidth: number; +}; -const EXTRA_USAGE_SECTION_TITLE = 'Extra usage' +const EXTRA_USAGE_SECTION_TITLE = 'Extra usage'; -function ExtraUsageSection({ - extraUsage, - maxWidth, -}: ExtraUsageSectionProps): React.ReactNode { - const subscriptionType = getSubscriptionType() - const isProOrMax = subscriptionType === 'pro' || subscriptionType === 'max' +function ExtraUsageSection({ extraUsage, maxWidth }: ExtraUsageSectionProps): React.ReactNode { + const subscriptionType = getSubscriptionType(); + const isProOrMax = subscriptionType === 'pro' || subscriptionType === 'max'; if (!isProOrMax) { // Only show to Pro and Max, consistent with claude.ai non-admin usage settings - return false + return false; } if (!extraUsage.is_enabled) { @@ -279,10 +216,10 @@ function ExtraUsageSection({ {EXTRA_USAGE_SECTION_TITLE} Extra usage not enabled · /extra-usage to enable - ) + ); } - return null + return null; } if (extraUsage.monthly_limit === null) { @@ -291,20 +228,17 @@ function ExtraUsageSection({ {EXTRA_USAGE_SECTION_TITLE} Unlimited - ) + ); } - if ( - typeof extraUsage.used_credits !== 'number' || - typeof extraUsage.utilization !== 'number' - ) { - return null + if (typeof extraUsage.used_credits !== 'number' || typeof extraUsage.utilization !== 'number') { + return null; } - const formattedUsedCredits = formatCost(extraUsage.used_credits / 100, 2) - const formattedMonthlyLimit = formatCost(extraUsage.monthly_limit / 100, 2) - const now = new Date() - const oneMonthReset = new Date(now.getFullYear(), now.getMonth() + 1, 1) + const formattedUsedCredits = formatCost(extraUsage.used_credits / 100, 2); + const formattedMonthlyLimit = formatCost(extraUsage.monthly_limit / 100, 2); + const now = new Date(); + const oneMonthReset = new Date(now.getFullYear(), now.getMonth() + 1, 1); return ( - ) + ); } diff --git a/src/components/Settings/src/commands/extra-usage/index.ts b/src/components/Settings/src/commands/extra-usage/index.ts index 981d9a598..6bcb018f0 100644 --- a/src/components/Settings/src/commands/extra-usage/index.ts +++ b/src/components/Settings/src/commands/extra-usage/index.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type extraUsage = any; +export type extraUsage = any diff --git a/src/components/Settings/src/constants/outputStyles.ts b/src/components/Settings/src/constants/outputStyles.ts index 42c1edbfa..bd22fa5e4 100644 --- a/src/components/Settings/src/constants/outputStyles.ts +++ b/src/components/Settings/src/constants/outputStyles.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type DEFAULT_OUTPUT_STYLE_NAME = any; +export type DEFAULT_OUTPUT_STYLE_NAME = any diff --git a/src/components/Settings/src/cost-tracker.ts b/src/components/Settings/src/cost-tracker.ts index 135cea9b7..e5bd62225 100644 --- a/src/components/Settings/src/cost-tracker.ts +++ b/src/components/Settings/src/cost-tracker.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type formatCost = any; +export type formatCost = any diff --git a/src/components/Settings/src/services/analytics/index.ts b/src/components/Settings/src/services/analytics/index.ts index ce0a9a827..eca4493cf 100644 --- a/src/components/Settings/src/services/analytics/index.ts +++ b/src/components/Settings/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/Settings/src/utils/auth.ts b/src/components/Settings/src/utils/auth.ts index d7b41ce38..4650ab01c 100644 --- a/src/components/Settings/src/utils/auth.ts +++ b/src/components/Settings/src/utils/auth.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getSubscriptionType = any; +export type getSubscriptionType = any diff --git a/src/components/Settings/src/utils/claudemd.ts b/src/components/Settings/src/utils/claudemd.ts index 1ebadee7b..cf6f52066 100644 --- a/src/components/Settings/src/utils/claudemd.ts +++ b/src/components/Settings/src/utils/claudemd.ts @@ -1,4 +1,4 @@ // Auto-generated type stub — replace with real implementation -export type getExternalClaudeMdIncludes = any; -export type getMemoryFiles = any; -export type hasExternalClaudeMdIncludes = any; +export type getExternalClaudeMdIncludes = any +export type getMemoryFiles = any +export type hasExternalClaudeMdIncludes = any diff --git a/src/components/Settings/src/utils/envUtils.ts b/src/components/Settings/src/utils/envUtils.ts index 04dde7791..0ca4dbf6e 100644 --- a/src/components/Settings/src/utils/envUtils.ts +++ b/src/components/Settings/src/utils/envUtils.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type isEnvTruthy = any; -export type isRunningOnHomespace = any; +export type isEnvTruthy = any +export type isRunningOnHomespace = any diff --git a/src/components/ShowInIDEPrompt.tsx b/src/components/ShowInIDEPrompt.tsx index e5ff331a3..3b3632c52 100644 --- a/src/components/ShowInIDEPrompt.tsx +++ b/src/components/ShowInIDEPrompt.tsx @@ -1,30 +1,30 @@ -import { basename, relative } from 'path' -import React from 'react' -import { Box, Text } from '../ink.js' -import { getCwd } from '../utils/cwd.js' -import { isSupportedVSCodeTerminal } from '../utils/ide.js' -import { Select } from './CustomSelect/index.js' -import { Pane } from './design-system/Pane.js' +import { basename, relative } from 'path'; +import React from 'react'; +import { Box, Text } from '../ink.js'; +import { getCwd } from '../utils/cwd.js'; +import { isSupportedVSCodeTerminal } from '../utils/ide.js'; +import { Select } from './CustomSelect/index.js'; +import { Pane } from './design-system/Pane.js'; import type { PermissionOption, PermissionOptionWithLabel, -} from './permissions/FilePermissionDialog/permissionOptions.js' +} from './permissions/FilePermissionDialog/permissionOptions.js'; type Props = { - filePath: string - input: A - onChange: (option: PermissionOption, args: A, feedback?: string) => void - options: PermissionOptionWithLabel[] - ideName: string - symlinkTarget?: string | null - rejectFeedback: string - acceptFeedback: string - setFocusedOption: (value: string) => void - onInputModeToggle: (value: string) => void - focusedOption: string - yesInputMode: boolean - noInputMode: boolean -} + filePath: string; + input: A; + onChange: (option: PermissionOption, args: A, feedback?: string) => void; + options: PermissionOptionWithLabel[]; + ideName: string; + symlinkTarget?: string | null; + rejectFeedback: string; + acceptFeedback: string; + setFocusedOption: (value: string) => void; + onInputModeToggle: (value: string) => void; + focusedOption: string; + yesInputMode: boolean; + noInputMode: boolean; +}; export function ShowInIDEPrompt({ onChange, @@ -54,33 +54,30 @@ export function ShowInIDEPrompt({ : `Symlink target: ${symlinkTarget}`} )} - {isSupportedVSCodeTerminal() && ( - Save file to continue… - )} + {isSupportedVSCodeTerminal() && Save file to continue…} - Do you want to make this edit to{' '} - {basename(filePath)}? + Do you want to make this edit to {basename(filePath)}? - ) + ); } } } @@ -138,22 +119,17 @@ export function TeleportError({ * Gets current teleport errors that need to be resolved * @returns Set of teleport error types that need to be handled */ -export async function getTeleportErrors(): Promise< - Set -> { - const errors = new Set() +export async function getTeleportErrors(): Promise> { + const errors = new Set(); - const [needsLogin, isGitClean] = await Promise.all([ - checkNeedsClaudeAiLogin(), - checkIsGitClean(), - ]) + const [needsLogin, isGitClean] = await Promise.all([checkNeedsClaudeAiLogin(), checkIsGitClean()]); if (needsLogin) { - errors.add('needsLogin') + errors.add('needsLogin'); } if (!isGitClean) { - errors.add('needsGitStash') + errors.add('needsGitStash'); } - return errors + return errors; } diff --git a/src/components/TeleportProgress.tsx b/src/components/TeleportProgress.tsx index f8ef62110..b1b40c0d2 100644 --- a/src/components/TeleportProgress.tsx +++ b/src/components/TeleportProgress.tsx @@ -1,39 +1,36 @@ -import figures from 'figures' -import * as React from 'react' -import { useState } from 'react' -import type { Root } from '../ink.js' -import { Box, Text, useAnimationFrame } from '../ink.js' -import { AppStateProvider } from '../state/AppState.js' +import figures from 'figures'; +import * as React from 'react'; +import { useState } from 'react'; +import type { Root } from '../ink.js'; +import { Box, Text, useAnimationFrame } from '../ink.js'; +import { AppStateProvider } from '../state/AppState.js'; import { checkOutTeleportedSessionBranch, processMessagesForTeleportResume, type TeleportProgressStep, type TeleportResult, teleportResumeCodeSession, -} from '../utils/teleport.js' +} from '../utils/teleport.js'; type Props = { - currentStep: TeleportProgressStep - sessionId?: string -} + currentStep: TeleportProgressStep; + sessionId?: string; +}; -const SPINNER_FRAMES = ['◐', '◓', '◑', '◒'] +const SPINNER_FRAMES = ['◐', '◓', '◑', '◒']; const STEPS: { key: TeleportProgressStep; label: string }[] = [ { key: 'validating', label: 'Validating session' }, { key: 'fetching_logs', label: 'Fetching session logs' }, { key: 'fetching_branch', label: 'Getting branch info' }, { key: 'checking_out', label: 'Checking out branch' }, -] +]; -export function TeleportProgress({ - currentStep, - sessionId, -}: Props): React.ReactNode { - const [ref, time] = useAnimationFrame(100) - const frame = Math.floor(time / 100) % SPINNER_FRAMES.length +export function TeleportProgress({ currentStep, sessionId }: Props): React.ReactNode { + const [ref, time] = useAnimationFrame(100); + const frame = Math.floor(time / 100) % SPINNER_FRAMES.length; - const currentStepIndex = STEPS.findIndex(s => s.key === currentStep) + const currentStepIndex = STEPS.findIndex(s => s.key === currentStep); return ( @@ -51,22 +48,22 @@ export function TeleportProgress({ {STEPS.map((step, index) => { - const isComplete = index < currentStepIndex - const isCurrent = index === currentStepIndex - const isPending = index > currentStepIndex + const isComplete = index < currentStepIndex; + const isCurrent = index === currentStepIndex; + const isPending = index > currentStepIndex; - let icon: string - let color: string | undefined + let icon: string; + let color: string | undefined; if (isComplete) { - icon = figures.tick - color = 'green' + icon = figures.tick; + color = 'green'; } else if (isCurrent) { - icon = SPINNER_FRAMES[frame]! - color = 'claude' + icon = SPINNER_FRAMES[frame]!; + color = 'claude'; } else { - icon = figures.circle - color = undefined + icon = figures.circle; + color = undefined; } return ( @@ -80,43 +77,38 @@ export function TeleportProgress({ {step.label} - ) + ); })} - ) + ); } /** * Teleports to a remote session with progress UI rendered into the existing root. * Fetches the session, checks out the branch, and returns the result. */ -export async function teleportWithProgress( - root: Root, - sessionId: string, -): Promise { +export async function teleportWithProgress(root: Root, sessionId: string): Promise { // Capture the setState function from the rendered component - let setStep: (step: TeleportProgressStep) => void = () => {} + let setStep: (step: TeleportProgressStep) => void = () => {}; function TeleportProgressWrapper(): React.ReactNode { - const [step, _setStep] = useState('validating') - setStep = _setStep - return + const [step, _setStep] = useState('validating'); + setStep = _setStep; + return ; } root.render( , - ) + ); - const result = await teleportResumeCodeSession(sessionId, setStep) - setStep('checking_out') - const { branchName, branchError } = await checkOutTeleportedSessionBranch( - result.branch, - ) + const result = await teleportResumeCodeSession(sessionId, setStep); + setStep('checking_out'); + const { branchName, branchError } = await checkOutTeleportedSessionBranch(result.branch); return { messages: processMessagesForTeleportResume(result.log, branchError), branchName, - } + }; } diff --git a/src/components/TeleportRepoMismatchDialog.tsx b/src/components/TeleportRepoMismatchDialog.tsx index 126a9c432..4daf16f1e 100644 --- a/src/components/TeleportRepoMismatchDialog.tsx +++ b/src/components/TeleportRepoMismatchDialog.tsx @@ -1,20 +1,17 @@ -import React, { useCallback, useState } from 'react' -import { Box, Text } from '../ink.js' -import { getDisplayPath } from '../utils/file.js' -import { - removePathFromRepo, - validateRepoAtPath, -} from '../utils/githubRepoPathMapping.js' -import { Select } from './CustomSelect/index.js' -import { Dialog } from './design-system/Dialog.js' -import { Spinner } from './Spinner.js' +import React, { useCallback, useState } from 'react'; +import { Box, Text } from '../ink.js'; +import { getDisplayPath } from '../utils/file.js'; +import { removePathFromRepo, validateRepoAtPath } from '../utils/githubRepoPathMapping.js'; +import { Select } from './CustomSelect/index.js'; +import { Dialog } from './design-system/Dialog.js'; +import { Spinner } from './Spinner.js'; type Props = { - targetRepo: string - initialPaths: string[] - onSelectPath: (path: string) => void - onCancel: () => void -} + targetRepo: string; + initialPaths: string[]; + onSelectPath: (path: string) => void; + onCancel: () => void; +}; export function TeleportRepoMismatchDialog({ targetRepo, @@ -22,39 +19,37 @@ export function TeleportRepoMismatchDialog({ onSelectPath, onCancel, }: Props): React.ReactNode { - const [availablePaths, setAvailablePaths] = useState(initialPaths) - const [errorMessage, setErrorMessage] = useState(null) - const [validating, setValidating] = useState(false) + const [availablePaths, setAvailablePaths] = useState(initialPaths); + const [errorMessage, setErrorMessage] = useState(null); + const [validating, setValidating] = useState(false); const handleChange = useCallback( async (value: string): Promise => { if (value === 'cancel') { - onCancel() - return + onCancel(); + return; } - setValidating(true) - setErrorMessage(null) + setValidating(true); + setErrorMessage(null); - const isValid = await validateRepoAtPath(value, targetRepo) + const isValid = await validateRepoAtPath(value, targetRepo); if (isValid) { - onSelectPath(value) - return + onSelectPath(value); + return; } // Path is invalid - remove it from config and update state - removePathFromRepo(targetRepo, value) - const updatedPaths = availablePaths.filter(p => p !== value) - setAvailablePaths(updatedPaths) - setValidating(false) + removePathFromRepo(targetRepo, value); + const updatedPaths = availablePaths.filter(p => p !== value); + setAvailablePaths(updatedPaths); + setValidating(false); - setErrorMessage( - `${getDisplayPath(value)} no longer contains the correct repository. Select another path.`, - ) + setErrorMessage(`${getDisplayPath(value)} no longer contains the correct repository. Select another path.`); }, [targetRepo, availablePaths, onSelectPath, onCancel], - ) + ); const options = [ ...availablePaths.map(path => ({ @@ -66,7 +61,7 @@ export function TeleportRepoMismatchDialog({ value: path, })), { label: 'Cancel', value: 'cancel' }, - ] + ]; return ( @@ -85,20 +80,15 @@ export function TeleportRepoMismatchDialog({ Validating repository… ) : ( - void handleChange(value)} /> )} ) : ( {errorMessage && {errorMessage}} - - Run claude --teleport from a checkout of {targetRepo} - + Run claude --teleport from a checkout of {targetRepo} )} - ) + ); } diff --git a/src/components/TeleportResumeWrapper.tsx b/src/components/TeleportResumeWrapper.tsx index 60e6c7806..ead722aa0 100644 --- a/src/components/TeleportResumeWrapper.tsx +++ b/src/components/TeleportResumeWrapper.tsx @@ -1,25 +1,22 @@ -import React, { useEffect } from 'react' +import React, { useEffect } from 'react'; 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 { - type TeleportSource, - useTeleportResume, -} from '../hooks/useTeleportResume.js' -import { Box, Text } from '../ink.js' -import { useKeybinding } from '../keybindings/useKeybinding.js' -import { ResumeTask } from './ResumeTask.js' -import { Spinner } from './Spinner.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 { type TeleportSource, useTeleportResume } from '../hooks/useTeleportResume.js'; +import { Box, Text } from '../ink.js'; +import { useKeybinding } from '../keybindings/useKeybinding.js'; +import { ResumeTask } from './ResumeTask.js'; +import { Spinner } from './Spinner.js'; interface TeleportResumeWrapperProps { - onComplete: (result: TeleportRemoteResponse) => void - onCancel: () => void - onError?: (error: string, formattedMessage?: string) => void - isEmbedded?: boolean - source: TeleportSource + onComplete: (result: TeleportRemoteResponse) => void; + onCancel: () => void; + onError?: (error: string, formattedMessage?: string) => void; + isEmbedded?: boolean; + source: TeleportSource; } /** @@ -33,40 +30,38 @@ export function TeleportResumeWrapper({ isEmbedded = false, source, }: TeleportResumeWrapperProps): React.ReactNode { - const { resumeSession, isResuming, error, selectedSession } = - useTeleportResume(source) + const { resumeSession, isResuming, error, selectedSession } = useTeleportResume(source); // Log when teleport flow starts (for funnel tracking) useEffect(() => { logEvent('tengu_teleport_started', { - source: - source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - }, [source]) + source: source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + }, [source]); const handleSelect = async (session: CodeSession) => { - const result = await resumeSession(session) + const result = await resumeSession(session); if (result) { - onComplete(result) + onComplete(result); } else if (error) { // If there's an error handler provided, use it if (onError) { - onError(error.message, error.formattedMessage) + onError(error.message, error.formattedMessage); } // Otherwise the error will be displayed in the UI } - } + }; const handleCancel = () => { - logEvent('tengu_teleport_cancelled', {}) - onCancel() - } + logEvent('tengu_teleport_cancelled', {}); + onCancel(); + }; // Allow Esc to dismiss the error state useKeybinding('app:interrupt', handleCancel, { context: 'Global', isActive: !!error && !onError, - }) + }); // Show loading spinner when resuming if (isResuming && selectedSession) { @@ -78,7 +73,7 @@ export function TeleportResumeWrapper({ Loading "{selectedSession.title}"… - ) + ); } // Show error if there was a problem resuming @@ -95,14 +90,8 @@ export function TeleportResumeWrapper({ - ) + ); } - return ( - - ) + return ; } diff --git a/src/components/TeleportStash.tsx b/src/components/TeleportStash.tsx index 8baa30580..1cfdd0db5 100644 --- a/src/components/TeleportStash.tsx +++ b/src/components/TeleportStash.tsx @@ -1,81 +1,75 @@ -import figures from 'figures' -import React, { useEffect, useState } from 'react' -import { Box, Text } from '../ink.js' -import { logForDebugging } from '../utils/debug.js' -import type { GitFileStatus } from '../utils/git.js' -import { getFileStatus, stashToCleanState } from '../utils/git.js' -import { Select } from './CustomSelect/index.js' -import { Dialog } from './design-system/Dialog.js' -import { Spinner } from './Spinner.js' +import figures from 'figures'; +import React, { useEffect, useState } from 'react'; +import { Box, Text } from '../ink.js'; +import { logForDebugging } from '../utils/debug.js'; +import type { GitFileStatus } from '../utils/git.js'; +import { getFileStatus, stashToCleanState } from '../utils/git.js'; +import { Select } from './CustomSelect/index.js'; +import { Dialog } from './design-system/Dialog.js'; +import { Spinner } from './Spinner.js'; type TeleportStashProps = { - onStashAndContinue: () => void - onCancel: () => void -} + onStashAndContinue: () => void; + onCancel: () => void; +}; -export function TeleportStash({ - onStashAndContinue, - onCancel, -}: TeleportStashProps): React.ReactNode { - const [gitFileStatus, setGitFileStatus] = useState(null) - const changedFiles = - gitFileStatus !== null - ? [...gitFileStatus.tracked, ...gitFileStatus.untracked] - : [] - const [loading, setLoading] = useState(true) - const [stashing, setStashing] = useState(false) - const [error, setError] = useState(null) +export function TeleportStash({ onStashAndContinue, onCancel }: TeleportStashProps): React.ReactNode { + const [gitFileStatus, setGitFileStatus] = useState(null); + const changedFiles = gitFileStatus !== null ? [...gitFileStatus.tracked, ...gitFileStatus.untracked] : []; + const [loading, setLoading] = useState(true); + const [stashing, setStashing] = useState(false); + const [error, setError] = useState(null); // Load changed files on mount useEffect(() => { const loadChangedFiles = async () => { try { - const fileStatus = await getFileStatus() - setGitFileStatus(fileStatus) + const fileStatus = await getFileStatus(); + setGitFileStatus(fileStatus); } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err) + const errorMessage = err instanceof Error ? err.message : String(err); logForDebugging(`Error getting changed files: ${errorMessage}`, { level: 'error', - }) - setError('Failed to get changed files') + }); + setError('Failed to get changed files'); } finally { - setLoading(false) + setLoading(false); } - } + }; - void loadChangedFiles() - }, []) + void loadChangedFiles(); + }, []); const handleStash = async () => { - setStashing(true) + setStashing(true); try { - logForDebugging('Stashing changes before teleport...') - const success = await stashToCleanState('Teleport auto-stash') + logForDebugging('Stashing changes before teleport...'); + const success = await stashToCleanState('Teleport auto-stash'); if (success) { - logForDebugging('Successfully stashed changes') - onStashAndContinue() + logForDebugging('Successfully stashed changes'); + onStashAndContinue(); } else { - setError('Failed to stash changes') + setError('Failed to stash changes'); } } catch (err) { - const errorMessage = err instanceof Error ? err.message : String(err) + const errorMessage = err instanceof Error ? err.message : String(err); logForDebugging(`Error stashing changes: ${errorMessage}`, { level: 'error', - }) - setError('Failed to stash changes') + }); + setError('Failed to stash changes'); } finally { - setStashing(false) + setStashing(false); } - } + }; const handleSelectChange = (value: string) => { if (value === 'stash') { - void handleStash() + void handleStash(); } else { - onCancel() + onCancel(); } - } + }; if (loading) { return ( @@ -85,7 +79,7 @@ export function TeleportStash({ Checking git status{figures.ellipsis} - ) + ); } if (error) { @@ -100,34 +94,28 @@ export function TeleportStash({ to cancel - ) + ); } - const showFileCount = changedFiles.length > 8 + const showFileCount = changedFiles.length > 8; return ( - - Teleport will switch git branches. The following changes were found: - + Teleport will switch git branches. The following changes were found: {changedFiles.length > 0 ? ( showFileCount ? ( {changedFiles.length} files changed ) : ( - changedFiles.map((file: string, index: number) => ( - {file} - )) + changedFiles.map((file: string, index: number) => {file}) ) ) : ( No changes detected )} - - Would you like to stash these changes and continue with teleport? - + Would you like to stash these changes and continue with teleport? {stashing ? ( @@ -144,5 +132,5 @@ export function TeleportStash({ /> )} - ) + ); } diff --git a/src/components/TextInput.tsx b/src/components/TextInput.tsx index 486c73ef2..51ea31195 100644 --- a/src/components/TextInput.tsx +++ b/src/components/TextInput.tsx @@ -1,106 +1,93 @@ -import { feature } from 'bun:bundle' -import chalk from 'chalk' -import React, { useMemo, useRef } from 'react' -import { useVoiceState } from '../context/voice.js' -import { useClipboardImageHint } from '../hooks/useClipboardImageHint.js' -import { useSettings } from '../hooks/useSettings.js' -import { useTextInput } from '../hooks/useTextInput.js' -import { - Box, - color, - useAnimationFrame, - useTerminalFocus, - useTheme, -} from '../ink.js' -import type { BaseTextInputProps } from '../types/textInputTypes.js' -import { isEnvTruthy } from '../utils/envUtils.js' -import type { TextHighlight } from '../utils/textHighlighting.js' -import { BaseTextInput } from './BaseTextInput.js' -import { hueToRgb } from './Spinner/utils.js' +import { feature } from 'bun:bundle'; +import chalk from 'chalk'; +import React, { useMemo, useRef } from 'react'; +import { useVoiceState } from '../context/voice.js'; +import { useClipboardImageHint } from '../hooks/useClipboardImageHint.js'; +import { useSettings } from '../hooks/useSettings.js'; +import { useTextInput } from '../hooks/useTextInput.js'; +import { Box, color, useAnimationFrame, useTerminalFocus, useTheme } from '../ink.js'; +import type { BaseTextInputProps } from '../types/textInputTypes.js'; +import { isEnvTruthy } from '../utils/envUtils.js'; +import type { TextHighlight } from '../utils/textHighlighting.js'; +import { BaseTextInput } from './BaseTextInput.js'; +import { hueToRgb } from './Spinner/utils.js'; // Block characters for waveform bars: space (silent) + 8 rising block elements. -const BARS = ' \u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588' +const BARS = ' \u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588'; // Mini waveform cursor width -const CURSOR_WAVEFORM_WIDTH = 1 +const CURSOR_WAVEFORM_WIDTH = 1; // Smoothing factor (0 = instant, 1 = frozen). Applied as EMA to // smooth both rises and falls for a steady, non-jittery bar. -const SMOOTH = 0.7 +const SMOOTH = 0.7; // Boost factor for audio levels — computeLevel normalizes with a // conservative divisor (rms/2000), so normal speech sits around // 0.3-0.5. This multiplier lets the bar use the full range. -const LEVEL_BOOST = 1.8 +const LEVEL_BOOST = 1.8; // Raw audio level threshold (pre-boost) below which the cursor is // grey. computeLevel returns sqrt(rms/2000), so ambient mic noise // typically sits at 0.05-0.15. Speech starts around 0.2+. -const SILENCE_THRESHOLD = 0.15 +const SILENCE_THRESHOLD = 0.15; export type Props = BaseTextInputProps & { - highlights?: TextHighlight[] -} + highlights?: TextHighlight[]; +}; export default function TextInput(props: Props): React.ReactNode { - const [theme] = useTheme() - const isTerminalFocused = useTerminalFocus() + const [theme] = useTheme(); + const isTerminalFocused = useTerminalFocus(); // Hoisted to mount-time — this component re-renders on every keystroke. - const accessibilityEnabled = useMemo( - () => isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY), - [], - ) - const settings = useSettings() - const reducedMotion = settings.prefersReducedMotion ?? false + const accessibilityEnabled = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY), []); + const settings = useSettings(); + const reducedMotion = settings.prefersReducedMotion ?? false; const voiceState = feature('VOICE_MODE') ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant useVoiceState(s => s.voiceState) - : ('idle' as const) - const isVoiceRecording = voiceState === 'recording' + : ('idle' as const); + const isVoiceRecording = voiceState === 'recording'; const audioLevels = feature('VOICE_MODE') ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant useVoiceState(s => s.voiceAudioLevels) - : [] - const smoothedRef = useRef(new Array(CURSOR_WAVEFORM_WIDTH).fill(0)) + : []; + const smoothedRef = useRef(new Array(CURSOR_WAVEFORM_WIDTH).fill(0)); - const needsAnimation = isVoiceRecording && !reducedMotion + const needsAnimation = isVoiceRecording && !reducedMotion; const [animRef, animTime] = feature('VOICE_MODE') ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant useAnimationFrame(needsAnimation ? 50 : null) - : [() => {}, 0] + : [() => {}, 0]; // Show hint when terminal regains focus and clipboard has an image - useClipboardImageHint(isTerminalFocused, !!props.onImagePaste) + useClipboardImageHint(isTerminalFocused, !!props.onImagePaste); // Cursor invert function: mini waveform during voice recording, // standard chalk.inverse otherwise. No warmup pulse — the ~120ms // warmup window is too short for a 1s-period pulse to register, and // driving TextInput re-renders at 50ms during warmup (while spaces // are simultaneously arriving every 30-80ms) causes visible stutter. - const canShowCursor = isTerminalFocused && !accessibilityEnabled - let invert: (text: string) => string + const canShowCursor = isTerminalFocused && !accessibilityEnabled; + let invert: (text: string) => string; if (!canShowCursor) { - invert = (text: string) => text + invert = (text: string) => text; } else if (isVoiceRecording && !reducedMotion) { // Single-bar waveform from the latest audio level - const smoothed = smoothedRef.current - const raw = - audioLevels.length > 0 ? (audioLevels[audioLevels.length - 1] ?? 0) : 0 - const target = Math.min(raw * LEVEL_BOOST, 1) - smoothed[0] = (smoothed[0] ?? 0) * SMOOTH + target * (1 - SMOOTH) - const displayLevel = smoothed[0] ?? 0 - const barIndex = Math.max( - 1, - Math.min(Math.round(displayLevel * (BARS.length - 1)), BARS.length - 1), - ) - const isSilent = raw < SILENCE_THRESHOLD - const hue = ((animTime / 1000) * 90) % 360 - const { r, g, b } = isSilent ? { r: 128, g: 128, b: 128 } : hueToRgb(hue) - invert = () => chalk.rgb(r, g, b)(BARS[barIndex]!) + const smoothed = smoothedRef.current; + const raw = audioLevels.length > 0 ? (audioLevels[audioLevels.length - 1] ?? 0) : 0; + const target = Math.min(raw * LEVEL_BOOST, 1); + smoothed[0] = (smoothed[0] ?? 0) * SMOOTH + target * (1 - SMOOTH); + const displayLevel = smoothed[0] ?? 0; + const barIndex = Math.max(1, Math.min(Math.round(displayLevel * (BARS.length - 1)), BARS.length - 1)); + const isSilent = raw < SILENCE_THRESHOLD; + const hue = ((animTime / 1000) * 90) % 360; + const { r, g, b } = isSilent ? { r: 128, g: 128, b: 128 } : hueToRgb(hue); + invert = () => chalk.rgb(r, g, b)(BARS[barIndex]!); } else { - invert = chalk.inverse + invert = chalk.inverse; } const textInputState = useTextInput({ @@ -123,15 +110,14 @@ export default function TextInput(props: Props): React.ReactNode { columns: props.columns, maxVisibleLines: props.maxVisibleLines, onImagePaste: props.onImagePaste, - disableCursorMovementForUpDownKeys: - props.disableCursorMovementForUpDownKeys, + disableCursorMovementForUpDownKeys: props.disableCursorMovementForUpDownKeys, disableEscapeDoublePress: props.disableEscapeDoublePress, externalOffset: props.cursorOffset, onOffsetChange: props.onChangeCursorOffset, inputFilter: props.inputFilter, inlineGhostText: props.inlineGhostText, dim: chalk.dim, - }) + }); return ( @@ -144,5 +130,5 @@ export default function TextInput(props: Props): React.ReactNode { {...props} /> - ) + ); } diff --git a/src/components/ThemePicker.tsx b/src/components/ThemePicker.tsx index b14bcfd2c..786d2f0db 100644 --- a/src/components/ThemePicker.tsx +++ b/src/components/ThemePicker.tsx @@ -1,41 +1,32 @@ -import { feature } from 'bun:bundle' -import * as React from 'react' -import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js' -import { useTerminalSize } from '../hooks/useTerminalSize.js' -import { - Box, - Text, - usePreviewTheme, - useTheme, - useThemeSetting, -} from '../ink.js' -import { useRegisterKeybindingContext } from '../keybindings/KeybindingContext.js' -import { useKeybinding } from '../keybindings/useKeybinding.js' -import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js' -import { useAppState, useSetAppState } from '../state/AppState.js' -import { gracefulShutdown } from '../utils/gracefulShutdown.js' -import { updateSettingsForSource } from '../utils/settings/settings.js' -import type { ThemeSetting } from '../utils/theme.js' -import { Select } from './CustomSelect/index.js' -import { Byline } from './design-system/Byline.js' -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' -import { - getColorModuleUnavailableReason, - getSyntaxTheme, -} from './StructuredDiff/colorDiff.js' -import { StructuredDiff } from './StructuredDiff.js' +import { feature } from 'bun:bundle'; +import * as React from 'react'; +import { useExitOnCtrlCDWithKeybindings } from '../hooks/useExitOnCtrlCDWithKeybindings.js'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { Box, Text, usePreviewTheme, useTheme, useThemeSetting } from '../ink.js'; +import { useRegisterKeybindingContext } from '../keybindings/KeybindingContext.js'; +import { useKeybinding } from '../keybindings/useKeybinding.js'; +import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'; +import { useAppState, useSetAppState } from '../state/AppState.js'; +import { gracefulShutdown } from '../utils/gracefulShutdown.js'; +import { updateSettingsForSource } from '../utils/settings/settings.js'; +import type { ThemeSetting } from '../utils/theme.js'; +import { Select } from './CustomSelect/index.js'; +import { Byline } from './design-system/Byline.js'; +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; +import { getColorModuleUnavailableReason, getSyntaxTheme } from './StructuredDiff/colorDiff.js'; +import { StructuredDiff } from './StructuredDiff.js'; export type ThemePickerProps = { - onThemeSelect: (setting: ThemeSetting) => void - showIntroText?: boolean - helpText?: string - showHelpTextBelow?: boolean - hideEscToCancel?: boolean + onThemeSelect: (setting: ThemeSetting) => void; + showIntroText?: boolean; + helpText?: string; + showHelpTextBelow?: boolean; + hideEscToCancel?: boolean; /** Skip exit handling when running in a context that already has it (e.g., onboarding) */ - skipExitHandling?: boolean + skipExitHandling?: boolean; /** Called when the user cancels (presses Escape). If skipExitHandling is true and this is provided, it will be called instead of just saving the preview. */ - onCancel?: () => void -} + onCancel?: () => void; +}; export function ThemePicker({ onThemeSelect, @@ -46,51 +37,41 @@ export function ThemePicker({ skipExitHandling = false, onCancel: onCancelProp, }: ThemePickerProps): React.ReactNode { - const [theme] = useTheme() - const themeSetting = useThemeSetting() - const { columns } = useTerminalSize() - const colorModuleUnavailableReason = getColorModuleUnavailableReason() - const syntaxTheme = - colorModuleUnavailableReason === null ? getSyntaxTheme(theme) : null - const { setPreviewTheme, savePreview, cancelPreview } = usePreviewTheme() - const syntaxHighlightingDisabled = - useAppState(s => s.settings.syntaxHighlightingDisabled) ?? false - const setAppState = useSetAppState() + const [theme] = useTheme(); + const themeSetting = useThemeSetting(); + const { columns } = useTerminalSize(); + const colorModuleUnavailableReason = getColorModuleUnavailableReason(); + const syntaxTheme = colorModuleUnavailableReason === null ? getSyntaxTheme(theme) : null; + const { setPreviewTheme, savePreview, cancelPreview } = usePreviewTheme(); + const syntaxHighlightingDisabled = useAppState(s => s.settings.syntaxHighlightingDisabled) ?? false; + const setAppState = useSetAppState(); // Register ThemePicker context so its keybindings take precedence over Global - useRegisterKeybindingContext('ThemePicker') + useRegisterKeybindingContext('ThemePicker'); - const syntaxToggleShortcut = useShortcutDisplay( - 'theme:toggleSyntaxHighlighting', - 'ThemePicker', - 'ctrl+t', - ) + const syntaxToggleShortcut = useShortcutDisplay('theme:toggleSyntaxHighlighting', 'ThemePicker', 'ctrl+t'); useKeybinding( 'theme:toggleSyntaxHighlighting', () => { if (colorModuleUnavailableReason === null) { - const newValue = !syntaxHighlightingDisabled + const newValue = !syntaxHighlightingDisabled; updateSettingsForSource('userSettings', { syntaxHighlightingDisabled: newValue, - }) + }); setAppState(prev => ({ ...prev, settings: { ...prev.settings, syntaxHighlightingDisabled: newValue }, - })) + })); } }, { context: 'ThemePicker' }, - ) + ); // Always call the hook to follow React rules, but conditionally assign the exit handler - const exitState = useExitOnCtrlCDWithKeybindings( - skipExitHandling ? () => {} : undefined, - ) + const exitState = useExitOnCtrlCDWithKeybindings(skipExitHandling ? () => {} : undefined); const themeOptions: { label: string; value: ThemeSetting }[] = [ - ...(feature('AUTO_THEME') - ? [{ label: 'Auto (match terminal)', value: 'auto' as const }] - : []), + ...(feature('AUTO_THEME') ? [{ label: 'Auto (match terminal)', value: 'auto' as const }] : []), { label: 'Dark mode', value: 'dark' }, { label: 'Light mode', value: 'light' }, { @@ -109,7 +90,7 @@ export function ThemePicker({ label: 'Light mode (ANSI colors only)', value: 'light-ansi', }, - ] + ]; const content = ( @@ -122,29 +103,27 @@ export function ThemePicker({ )} - - Choose the text style that looks best with your terminal - + Choose the text style that looks best with your terminal {helpText && !showHelpTextBelow && {helpText}} - {exitState.pending ? ( - <>Press {exitState.keyName} again to exit - ) : ( - <>Enter to confirm · Esc to cancel - )} + {exitState.pending ? <>Press {exitState.keyName} again to exit : <>Enter to confirm · Esc to cancel} - ) + ); } diff --git a/src/components/TrustDialog/src/services/analytics/index.ts b/src/components/TrustDialog/src/services/analytics/index.ts index 60402f927..c095b5a65 100644 --- a/src/components/TrustDialog/src/services/analytics/index.ts +++ b/src/components/TrustDialog/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/components/TrustDialog/src/utils/permissions/PermissionRule.ts b/src/components/TrustDialog/src/utils/permissions/PermissionRule.ts index 349593ae4..6991af450 100644 --- a/src/components/TrustDialog/src/utils/permissions/PermissionRule.ts +++ b/src/components/TrustDialog/src/utils/permissions/PermissionRule.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type PermissionRule = any; +export type PermissionRule = any diff --git a/src/components/TrustDialog/src/utils/settings/settings.ts b/src/components/TrustDialog/src/utils/settings/settings.ts index 76099c0ec..64b7205a4 100644 --- a/src/components/TrustDialog/src/utils/settings/settings.ts +++ b/src/components/TrustDialog/src/utils/settings/settings.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getSettingsForSource = any; +export type getSettingsForSource = any diff --git a/src/components/TrustDialog/src/utils/settings/types.ts b/src/components/TrustDialog/src/utils/settings/types.ts index 2e9d3270c..6d1dcb1df 100644 --- a/src/components/TrustDialog/src/utils/settings/types.ts +++ b/src/components/TrustDialog/src/utils/settings/types.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type SettingsJson = any; +export type SettingsJson = any diff --git a/src/components/ValidationErrorsList.tsx b/src/components/ValidationErrorsList.tsx index a7eea5400..bc42f9f15 100644 --- a/src/components/ValidationErrorsList.tsx +++ b/src/components/ValidationErrorsList.tsx @@ -1,134 +1,120 @@ -import setWith from 'lodash-es/setWith.js' -import * as React from 'react' -import { Box, Text, useTheme } from '../ink.js' -import type { ValidationError } from '../utils/settings/validation.js' -import { type TreeNode, treeify } from '../utils/treeify.js' +import setWith from 'lodash-es/setWith.js'; +import * as React from 'react'; +import { Box, Text, useTheme } from '../ink.js'; +import type { ValidationError } from '../utils/settings/validation.js'; +import { type TreeNode, treeify } from '../utils/treeify.js'; /** * Builds a nested tree structure from dot-notation paths * Uses lodash setWith to avoid automatic array creation */ function buildNestedTree(errors: ValidationError[]): TreeNode { - const tree: TreeNode = {} + const tree: TreeNode = {}; errors.forEach(error => { if (!error.path) { // Root level error - use empty string as key - tree[''] = error.message - return + tree[''] = error.message; + return; } // Try to enhance the path with meaningful values - const pathParts = error.path.split('.') - let modifiedPath = error.path + const pathParts = error.path.split('.'); + let modifiedPath = error.path; // If we have an invalid value, try to make the path more readable - if ( - error.invalidValue !== null && - error.invalidValue !== undefined && - pathParts.length > 0 - ) { - const newPathParts: string[] = [] + if (error.invalidValue !== null && error.invalidValue !== undefined && pathParts.length > 0) { + const newPathParts: string[] = []; for (let i = 0; i < pathParts.length; i++) { - const part = pathParts[i] - if (!part) continue + const part = pathParts[i]; + if (!part) continue; - const numericPart = parseInt(part, 10) + const numericPart = parseInt(part, 10); // If this is a numeric index and it's the last part where we have the invalid value if (!isNaN(numericPart) && i === pathParts.length - 1) { // Format the value for display - let displayValue: string + let displayValue: string; if (typeof error.invalidValue === 'string') { - displayValue = `"${error.invalidValue}"` + displayValue = `"${error.invalidValue}"`; } else if (error.invalidValue === null) { - displayValue = 'null' + displayValue = 'null'; } else if (error.invalidValue === undefined) { - displayValue = 'undefined' + displayValue = 'undefined'; } else { - displayValue = String(error.invalidValue) + displayValue = String(error.invalidValue); } - newPathParts.push(displayValue) + newPathParts.push(displayValue); } else { // Keep other parts as-is - newPathParts.push(part) + newPathParts.push(part); } } - modifiedPath = newPathParts.join('.') + modifiedPath = newPathParts.join('.'); } - setWith(tree, modifiedPath, error.message, Object) - }) + setWith(tree, modifiedPath, error.message, Object); + }); - return tree + return tree; } /** * Groups and displays validation errors using treeify with deduplication */ -export function ValidationErrorsList({ - errors, -}: { - errors: ValidationError[] -}): React.ReactNode { - const [themeName] = useTheme() +export function ValidationErrorsList({ errors }: { errors: ValidationError[] }): React.ReactNode { + const [themeName] = useTheme(); if (errors.length === 0) { - return null + return null; } // Group errors by file - const errorsByFile = errors.reduce>( - (acc, error) => { - const file = error.file || '(file not specified)' - if (!acc[file]) { - acc[file] = [] - } - acc[file]!.push(error) - return acc - }, - {}, - ) + const errorsByFile = errors.reduce>((acc, error) => { + const file = error.file || '(file not specified)'; + if (!acc[file]) { + acc[file] = []; + } + acc[file]!.push(error); + return acc; + }, {}); // Sort files alphabetically - const sortedFiles = Object.keys(errorsByFile).sort() + const sortedFiles = Object.keys(errorsByFile).sort(); return ( {sortedFiles.map(file => { - const fileErrors = errorsByFile[file] || [] + const fileErrors = errorsByFile[file] || []; // Sort errors by path fileErrors.sort((a, b) => { - if (!a.path && b.path) return -1 - if (a.path && !b.path) return 1 - return (a.path || '').localeCompare(b.path || '') - }) + if (!a.path && b.path) return -1; + if (a.path && !b.path) return 1; + return (a.path || '').localeCompare(b.path || ''); + }); // Build nested tree structure from error paths - const errorTree = buildNestedTree(fileErrors) + const errorTree = buildNestedTree(fileErrors); // Collect unique suggestion+docLink pairs - const suggestionPairs = new Map< - string, - { suggestion?: string; docLink?: string } - >() + const suggestionPairs = new Map(); fileErrors.forEach(error => { if (error.suggestion || error.docLink) { // Create a key from suggestion+docLink combination - const key = `${error.suggestion || ''}|${error.docLink || ''}` + const key = `${error.suggestion || ''}|${error.docLink || ''}`; if (!suggestionPairs.has(key)) { suggestionPairs.set(key, { suggestion: error.suggestion, docLink: error.docLink, - }) + }); } } - }) + }); // Render the tree const treeOutput = treeify(errorTree, { @@ -139,7 +125,7 @@ export function ValidationErrorsList({ key: 'text', value: 'inactive', }, - }) + }); return ( @@ -151,11 +137,7 @@ export function ValidationErrorsList({ {suggestionPairs.size > 0 && ( {Array.from(suggestionPairs.values()).map((pair, index) => ( - + {pair.suggestion && ( {pair.suggestion} @@ -171,8 +153,8 @@ export function ValidationErrorsList({ )} - ) + ); })} - ) + ); } diff --git a/src/components/VimTextInput.tsx b/src/components/VimTextInput.tsx index bc8e8211f..8b70954a0 100644 --- a/src/components/VimTextInput.tsx +++ b/src/components/VimTextInput.tsx @@ -1,22 +1,22 @@ -import chalk from 'chalk' -import React from 'react' -import { useClipboardImageHint } from '../hooks/useClipboardImageHint.js' -import { useVimInput } from '../hooks/useVimInput.js' -import { Box, color, useTerminalFocus, useTheme } from '../ink.js' -import type { VimTextInputProps } from '../types/textInputTypes.js' -import type { TextHighlight } from '../utils/textHighlighting.js' -import { BaseTextInput } from './BaseTextInput.js' +import chalk from 'chalk'; +import React from 'react'; +import { useClipboardImageHint } from '../hooks/useClipboardImageHint.js'; +import { useVimInput } from '../hooks/useVimInput.js'; +import { Box, color, useTerminalFocus, useTheme } from '../ink.js'; +import type { VimTextInputProps } from '../types/textInputTypes.js'; +import type { TextHighlight } from '../utils/textHighlighting.js'; +import { BaseTextInput } from './BaseTextInput.js'; export type Props = VimTextInputProps & { - highlights?: TextHighlight[] -} + highlights?: TextHighlight[]; +}; export default function VimTextInput(props: Props): React.ReactNode { - const [theme] = useTheme() - const isTerminalFocused = useTerminalFocus() + const [theme] = useTheme(); + const isTerminalFocused = useTerminalFocus(); // Show hint when terminal regains focus and clipboard has an image - useClipboardImageHint(isTerminalFocused, !!props.onImagePaste) + useClipboardImageHint(isTerminalFocused, !!props.onImagePaste); const vimInputState = useVimInput({ value: props.value, @@ -38,23 +38,22 @@ export default function VimTextInput(props: Props): React.ReactNode { columns: props.columns, maxVisibleLines: props.maxVisibleLines, onImagePaste: props.onImagePaste, - disableCursorMovementForUpDownKeys: - props.disableCursorMovementForUpDownKeys, + disableCursorMovementForUpDownKeys: props.disableCursorMovementForUpDownKeys, disableEscapeDoublePress: props.disableEscapeDoublePress, externalOffset: props.cursorOffset, onOffsetChange: props.onChangeCursorOffset, inputFilter: props.inputFilter, onModeChange: props.onModeChange, onUndo: props.onUndo, - }) + }); - const { mode, setMode } = vimInputState + const { mode, setMode } = vimInputState; React.useEffect(() => { if (props.initialMode && props.initialMode !== mode) { - setMode(props.initialMode) + setMode(props.initialMode); } - }, [props.initialMode, mode, setMode]) + }, [props.initialMode, mode, setMode]); return ( @@ -65,5 +64,5 @@ export default function VimTextInput(props: Props): React.ReactNode { {...props} /> - ) + ); } diff --git a/src/components/VirtualMessageList.tsx b/src/components/VirtualMessageList.tsx index bbe4a196c..ab8f2e2f8 100644 --- a/src/components/VirtualMessageList.tsx +++ b/src/components/VirtualMessageList.tsx @@ -1,29 +1,21 @@ -import type { RefObject } from 'react' -import * as React from 'react' -import { - useCallback, - useContext, - useEffect, - useImperativeHandle, - useRef, - useState, - useSyncExternalStore, -} from 'react' -import { useVirtualScroll } from '../hooks/useVirtualScroll.js' -import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js' -import type { DOMElement } from '../ink/dom.js' -import type { MatchPosition } from '../ink/render-to-screen.js' -import { Box } from '../ink.js' -import type { RenderableMessage } from '../types/message.js' -import { TextHoverColorContext } from './design-system/ThemedText.js' -import { ScrollChromeContext } from './FullscreenLayout.js' +import type { RefObject } from 'react'; +import * as React from 'react'; +import { useCallback, useContext, useEffect, useImperativeHandle, useRef, useState, useSyncExternalStore } from 'react'; +import { useVirtualScroll } from '../hooks/useVirtualScroll.js'; +import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js'; +import type { DOMElement } from '../ink/dom.js'; +import type { MatchPosition } from '../ink/render-to-screen.js'; +import { Box } from '../ink.js'; +import type { RenderableMessage } from '../types/message.js'; +import { TextHoverColorContext } from './design-system/ThemedText.js'; +import { ScrollChromeContext } from './FullscreenLayout.js'; // Rows of breathing room above the target when we scrollTo. -const HEADROOM = 3 +const HEADROOM = 3; -import { logForDebugging } from '../utils/debug.js' -import { sleep } from '../utils/sleep.js' -import { renderableSearchText } from '../utils/transcriptSearch.js' +import { logForDebugging } from '../utils/debug.js'; +import { sleep } from '../utils/sleep.js'; +import { renderableSearchText } from '../utils/transcriptSearch.js'; import { isNavigableMessage, type MessageActionsNav, @@ -31,18 +23,18 @@ import { type NavigableMessage, stripSystemReminders, toolCallOf, -} from './messageActions.js' +} from './messageActions.js'; // Fallback extractor: lower + cache here for callers without the // Messages.tsx tool-lookup path (tests, static contexts). Messages.tsx // provides its own lowering cache that also handles tool extractSearchText. -const fallbackLowerCache = new WeakMap() +const fallbackLowerCache = new WeakMap(); function defaultExtractSearchText(msg: RenderableMessage): string { - const cached = fallbackLowerCache.get(msg) - if (cached !== undefined) return cached - const lowered = renderableSearchText(msg) - fallbackLowerCache.set(msg, lowered) - return lowered + const cached = fallbackLowerCache.get(msg); + if (cached !== undefined) return cached; + const lowered = renderableSearchText(msg); + fallbackLowerCache.set(msg, lowered); + return lowered; } export type StickyPrompt = @@ -50,84 +42,84 @@ export type StickyPrompt = // Click sets this — header HIDES but padding stays collapsed (0) so // the content ❯ lands at screen row 0 instead of row 1. Cleared on // the next sticky-prompt compute (user scrolls again). - | 'clicked' + | 'clicked'; /** Huge pasted prompts (cat file | claude) can be MBs. Header wraps into * 2 rows via overflow:hidden — this just bounds the React prop size. */ -const STICKY_TEXT_CAP = 500 +const STICKY_TEXT_CAP = 500; /** Imperative handle for transcript navigation. Methods compute matches * HERE (renderableMessages indices are only valid inside this component — * Messages.tsx filters and reorders, REPL can't compute externally). */ export type JumpHandle = { - jumpToIndex: (i: number) => void - setSearchQuery: (q: string) => void - nextMatch: () => void - prevMatch: () => void + jumpToIndex: (i: number) => void; + setSearchQuery: (q: string) => void; + nextMatch: () => void; + prevMatch: () => void; /** Capture current scrollTop as the incsearch anchor. Typing jumps * around as preview; 0-matches snaps back here. Enter/n/N never * restore (they don't call setSearchQuery with empty). Next / call * overwrites. */ - setAnchor: () => void + setAnchor: () => void; /** Warm the search-text cache by extracting every message's text. * Returns elapsed ms, or 0 if already warm (subsequent / in same * transcript session). Yields before work so the caller can paint * "indexing…" first. Caller shows "indexed in Xms" on resolve. */ - warmSearchIndex: () => Promise + warmSearchIndex: () => Promise; /** Manual scroll (j/k/PgUp/wheel) exited the search context. Clear * positions (yellow goes away, inverse highlights stay). Next n/N * re-establishes via step()→jump(). Wired from ScrollKeybindingHandler's * onScroll — only fires for keyboard/wheel, not programmatic scrollTo. */ - disarmSearch: () => void -} + disarmSearch: () => void; +}; type Props = { - messages: RenderableMessage[] - scrollRef: RefObject + messages: RenderableMessage[]; + scrollRef: RefObject; /** Invalidates heightCache on change — cached heights from a different * width are wrong (text rewrap → black screen on scroll-up after widen). */ - columns: number - itemKey: (msg: RenderableMessage) => string - renderItem: (msg: RenderableMessage, index: number) => React.ReactNode + columns: number; + itemKey: (msg: RenderableMessage) => string; + renderItem: (msg: RenderableMessage, index: number) => React.ReactNode; /** Fires when a message Box is clicked (toggle per-message verbose). */ - onItemClick?: (msg: RenderableMessage) => void + onItemClick?: (msg: RenderableMessage) => void; /** Per-item filter — suppress hover/click for messages where the verbose * toggle does nothing (text, file edits, etc). Defaults to all-clickable. */ - isItemClickable?: (msg: RenderableMessage) => boolean + isItemClickable?: (msg: RenderableMessage) => boolean; /** Expanded items get a persistent grey bg (not just on hover). */ - isItemExpanded?: (msg: RenderableMessage) => boolean + isItemExpanded?: (msg: RenderableMessage) => boolean; /** PRE-LOWERED search text. Messages.tsx caches the lowered result * once at warm time so setSearchQuery's per-keystroke loop does * only indexOf (zero toLowerCase alloc). Falls back to a lowering * wrapper on renderableSearchText for callers without the cache. */ - extractSearchText?: (msg: RenderableMessage) => string + extractSearchText?: (msg: RenderableMessage) => string; /** Enable the sticky-prompt tracker. StickyTracker writes via * ScrollChromeContext (not a callback prop) so state lives in * FullscreenLayout instead of REPL. */ - trackStickyPrompt?: boolean - selectedIndex?: number + trackStickyPrompt?: boolean; + selectedIndex?: number; /** Nav handle lives here because height measurement lives here. */ - cursorNavRef?: React.Ref - setCursor?: (c: MessageActionsState | null) => void - jumpRef?: RefObject + cursorNavRef?: React.Ref; + setCursor?: (c: MessageActionsState | null) => void; + jumpRef?: RefObject; /** Fires when search matches change (query edit, n/N). current is * 1-based for "3/47" display; 0 means no matches. */ - onSearchMatchesChange?: (count: number, current: number) => void + onSearchMatchesChange?: (count: number, current: number) => void; /** Paint existing DOM subtree to fresh Screen, scan. Element from the * main tree (all providers). Message-relative positions (row 0 = el * top). Works for any height — closes the tall-message gap. */ - scanElement?: (el: DOMElement) => MatchPosition[] + scanElement?: (el: DOMElement) => MatchPosition[]; /** Position-based CURRENT highlight. Positions known upfront (from * scanElement), navigation = index arithmetic + scrollTo. rowOffset * = message's current screen-top; positions stay stable. */ setPositions?: ( state: { - positions: MatchPosition[] - rowOffset: number - currentIdx: number + positions: MatchPosition[]; + rowOffset: number; + currentIdx: number; } | null, - ) => void -} + ) => void; +}; /** * Returns the text of a real user prompt, or null for anything else. @@ -146,7 +138,7 @@ type Props = { * prompt that happened to get a reminder is rejected by the startsWith('<') * check. Shows up on `cc -c` resumes where memory-update reminders are dense. */ -const promptTextCache = new WeakMap() +const promptTextCache = new WeakMap(); function stickyPromptText(msg: RenderableMessage): string | null { // Cache keyed on message object — messages are append-only and don't @@ -154,37 +146,34 @@ function stickyPromptText(msg: RenderableMessage): string | null { // per-scroll-tick) calls this 5-50+ times with the SAME messages every // tick; the system-reminder strip allocates a fresh string on each // parse. WeakMap self-GCs on compaction/clear (messages[] replaced). - const cached = promptTextCache.get(msg) - if (cached !== undefined) return cached - const result = computeStickyPromptText(msg) - promptTextCache.set(msg, result) - return result + const cached = promptTextCache.get(msg); + if (cached !== undefined) return cached; + const result = computeStickyPromptText(msg); + promptTextCache.set(msg, result); + return result; } function computeStickyPromptText(msg: RenderableMessage): string | null { - let raw: string | null = null + let raw: string | null = null; if (msg.type === 'user') { - if (msg.isMeta || msg.isVisibleInTranscriptOnly) return null - const block = msg.message.content[0] - if (block?.type !== 'text') return null - raw = block.text + if (msg.isMeta || msg.isVisibleInTranscriptOnly) return null; + const block = msg.message.content[0]; + if (block?.type !== 'text') return null; + raw = block.text; } else if ( msg.type === 'attachment' && msg.attachment.type === 'queued_command' && msg.attachment.commandMode !== 'task-notification' && !msg.attachment.isMeta ) { - const p = msg.attachment.prompt - raw = - typeof p === 'string' - ? p - : p.flatMap(b => (b.type === 'text' ? [b.text] : [])).join('\n') + const p = msg.attachment.prompt; + raw = typeof p === 'string' ? p : p.flatMap(b => (b.type === 'text' ? [b.text] : [])).join('\n'); } - if (raw === null) return null + if (raw === null) return null; - const t = stripSystemReminders(raw) - if (t.startsWith('<') || t === '') return null - return t + const t = stripSystemReminders(raw); + if (t.startsWith('<') || t === '') return null; + return t; } /** @@ -196,18 +185,18 @@ function computeStickyPromptText(msg: RenderableMessage): string | null { * a ref. Single-child column Box passes Yoga height through unchanged. */ type VirtualItemProps = { - itemKey: string - msg: RenderableMessage - idx: number - measureRef: (key: string) => (el: DOMElement | null) => void - expanded: boolean | undefined - hovered: boolean - clickable: boolean - onClickK: (msg: RenderableMessage, cellIsBlank: boolean) => void - onEnterK: (k: string) => void - onLeaveK: (k: string) => void - renderItem: (msg: RenderableMessage, idx: number) => React.ReactNode -} + itemKey: string; + msg: RenderableMessage; + idx: number; + measureRef: (key: string) => (el: DOMElement | null) => void; + expanded: boolean | undefined; + hovered: boolean; + clickable: boolean; + onClickK: (msg: RenderableMessage, cellIsBlank: boolean) => void; + onEnterK: (k: string) => void; + onLeaveK: (k: string) => void; + renderItem: (msg: RenderableMessage, idx: number) => React.ReactNode; +}; // Item wrapper with stable click handlers. The per-item closures were the // `operationNewArrowFunction` leafs → `FunctionExecutable::finalizeUnconditionally` @@ -248,13 +237,11 @@ function VirtualItem({ onMouseEnter={clickable ? () => onEnterK(k) : undefined} onMouseLeave={clickable ? () => onLeaveK(k) : undefined} > - + {renderItem(msg, idx)} - ) + ); } export function VirtualMessageList({ @@ -280,23 +267,23 @@ export function VirtualMessageList({ // the full string array on every commit allocates O(n) per message (~1MB // churn at 27k messages). Append-only delta push when the prefix matches; // fall back to full rebuild on compaction, /clear, or itemKey change. - const keysRef = useRef([]) - const prevMessagesRef = useRef(messages) - const prevItemKeyRef = useRef(itemKey) + const keysRef = useRef([]); + const prevMessagesRef = useRef(messages); + const prevItemKeyRef = useRef(itemKey); if ( prevItemKeyRef.current !== itemKey || messages.length < keysRef.current.length || messages[0] !== prevMessagesRef.current[0] ) { - keysRef.current = messages.map(m => itemKey(m)) + keysRef.current = messages.map(m => itemKey(m)); } else { for (let i = keysRef.current.length; i < messages.length; i++) { - keysRef.current.push(itemKey(messages[i]!)) + keysRef.current.push(itemKey(messages[i]!)); } } - prevMessagesRef.current = messages - prevItemKeyRef.current = itemKey - const keys = keysRef.current + prevMessagesRef.current = messages; + prevItemKeyRef.current = itemKey; + const keys = keysRef.current; const { range, topSpacer, @@ -308,18 +295,18 @@ export function VirtualMessageList({ getItemElement, getItemHeight, scrollToIndex, - } = useVirtualScroll(scrollRef, keys, columns) - const [start, end] = range + } = useVirtualScroll(scrollRef, keys, columns); + const [start, end] = range; // Unmeasured (undefined height) falls through — assume visible. const isVisible = useCallback( (i: number) => { - const h = getItemHeight(i) - if (h === 0) return false - return isNavigableMessage(messages[i]!) + const h = getItemHeight(i); + if (h === 0) return false; + return isNavigableMessage(messages[i]!); }, [getItemHeight, messages], - ) + ); useImperativeHandle(cursorNavRef, (): MessageActionsNav => { const select = (m: NavigableMessage) => setCursor?.({ @@ -327,32 +314,28 @@ export function VirtualMessageList({ msgType: m.type, expanded: false, toolName: toolCallOf(m)?.name, - }) - const selIdx = selectedIndex ?? -1 - const scan = ( - from: number, - dir: 1 | -1, - pred: (i: number) => boolean = isVisible, - ) => { + }); + const selIdx = selectedIndex ?? -1; + const scan = (from: number, dir: 1 | -1, pred: (i: number) => boolean = isVisible) => { for (let i = from; i >= 0 && i < messages.length; i += dir) { if (pred(i)) { - select(messages[i]!) - return true + select(messages[i]!); + return true; } } - return false - } - const isUser = (i: number) => isVisible(i) && messages[i]!.type === 'user' + return false; + }; + const isUser = (i: number) => isVisible(i) && messages[i]!.type === 'user'; return { // Entry via shift+↑ = same semantic as in-cursor shift+↑ (prevUser). enterCursor: () => scan(messages.length - 1, -1, isUser), navigatePrev: () => scan(selIdx - 1, -1), navigateNext: () => { - if (scan(selIdx + 1, 1)) return + if (scan(selIdx + 1, 1)) return; // Past last visible → exit + repin. Last message's TOP is at viewport // top (selection-scroll effect); its BOTTOM may be below the fold. - scrollRef.current?.scrollToBottom() - setCursor?.(null) + scrollRef.current?.scrollToBottom(); + setCursor?.(null); }, // type:'user' only — queued_command attachments look like prompts but have no raw UserMessage to rewind to. navigatePrevUser: () => scan(selIdx - 1, -1, isUser), @@ -360,8 +343,8 @@ export function VirtualMessageList({ navigateTop: () => scan(0, 1), navigateBottom: () => scan(messages.length - 1, -1), getSelected: () => (selIdx >= 0 ? (messages[selIdx] ?? null) : null), - } - }, [messages, selectedIndex, setCursor, isVisible]) + }; + }, [messages, selectedIndex, setCursor, isVisible]); // Two-phase jump + search engine. Read-through-ref so the handle stays // stable across renders — offsets/messages identity changes every render, // can't go in useImperativeHandle deps without recreating the handle. @@ -372,7 +355,7 @@ export function VirtualMessageList({ getItemTop, messages, scrollToIndex, - }) + }); jumpState.current = { offsets, start, @@ -380,52 +363,52 @@ export function VirtualMessageList({ getItemTop, messages, scrollToIndex, - } + }; // Keep cursor-selected message visible. offsets rebuilds every render // — as a bare dep this re-pinned on every mousewheel tick. Read through // jumpState instead; past-overscan jumps land via scrollToIndex, next // nav is precise. useEffect(() => { - if (selectedIndex === undefined) return - const s = jumpState.current - const el = s.getItemElement(selectedIndex) + if (selectedIndex === undefined) return; + const s = jumpState.current; + const el = s.getItemElement(selectedIndex); if (el) { - scrollRef.current?.scrollToElement(el, 1) + scrollRef.current?.scrollToElement(el, 1); } else { - s.scrollToIndex(selectedIndex) + s.scrollToIndex(selectedIndex); } - }, [selectedIndex, scrollRef]) + }, [selectedIndex, scrollRef]); // Pending seek request. jump() sets this + bumps seekGen. The seek // effect fires post-paint (passive effect — after resetAfterCommit), // checks if target is mounted. Yes → scan+highlight. No → re-estimate // with a fresher anchor (start moved toward idx) and scrollTo again. const scanRequestRef = useRef<{ - idx: number - wantLast: boolean - tries: number - } | null>(null) + idx: number; + wantLast: boolean; + tries: number; + } | null>(null); // Message-relative positions from scanElement. Row 0 = message top. // Stable across scroll — highlight computes rowOffset fresh. msgIdx // for computing rowOffset = getItemTop(msgIdx) - scrollTop. const elementPositions = useRef<{ - msgIdx: number - positions: MatchPosition[] - }>({ msgIdx: -1, positions: [] }) + msgIdx: number; + positions: MatchPosition[]; + }>({ msgIdx: -1, positions: [] }); // Wraparound guard. Auto-advance stops if ptr wraps back to here. - const startPtrRef = useRef(-1) + const startPtrRef = useRef(-1); // Phantom-burst cap. Resets on scan success. - const phantomBurstRef = useRef(0) + const phantomBurstRef = useRef(0); // One-deep queue: n/N arriving mid-seek gets stored (not dropped) and // fires after the seek completes. Holding n stays smooth without // queueing 30 jumps. Latest press overwrites — we want the direction // the user is going NOW, not where they were 10 keypresses ago. - const pendingStepRef = useRef<1 | -1 | 0>(0) + const pendingStepRef = useRef<1 | -1 | 0>(0); // step + highlight via ref so the seek effect reads latest without // closure-capture or deps churn. - const stepRef = useRef<(d: 1 | -1) => void>(() => {}) - const highlightRef = useRef<(ord: number) => void>(() => {}) + const stepRef = useRef<(d: 1 | -1) => void>(() => {}); + const highlightRef = useRef<(ord: number) => void>(() => {}); const searchState = useRef({ matches: [] as number[], // deduplicated msg indices ptr: 0, @@ -436,11 +419,11 @@ export function VirtualMessageList({ // close enough for the badge; exact counts would need scanElement on // every matched message (~1-3ms × N). total = prefixSum[matches.length]. prefixSum: [] as number[], - }) + }); // scrollTop at the moment / was pressed. Incsearch preview-jumps snap // back here when matches drop to 0. -1 = no anchor (before first /). - const searchAnchor = useRef(-1) - const indexWarmed = useRef(false) + const searchAnchor = useRef(-1); + const indexWarmed = useRef(false); // Scroll target for message i: land at MESSAGE TOP. est = top - HEADROOM // so lo = top - est = HEADROOM ≥ 0 (or lo = top if est clamped to 0). @@ -449,8 +432,8 @@ export function VirtualMessageList({ // (was a safety net for frac garbage — without frac, est IS the next // message's top, spam-n/N converges because message tops are ordered). function targetFor(i: number): number { - const top = jumpState.current.getItemTop(i) - return Math.max(0, top - HEADROOM) + const top = jumpState.current.getItemTop(i); + return Math.max(0, top - HEADROOM); } // Highlight positions[ord]. Positions are MESSAGE-RELATIVE (row 0 = @@ -458,48 +441,48 @@ export function VirtualMessageList({ // scrollTop fresh. If ord's position is off-viewport, scroll to bring // it in, recompute rowOffset. setPositions triggers overlay write. function highlight(ord: number): void { - const s = scrollRef.current - const { msgIdx, positions } = elementPositions.current + const s = scrollRef.current; + const { msgIdx, positions } = elementPositions.current; if (!s || positions.length === 0 || msgIdx < 0) { - setPositions?.(null) - return + setPositions?.(null); + return; } - const idx = Math.max(0, Math.min(ord, positions.length - 1)) - const p = positions[idx]! - const top = jumpState.current.getItemTop(msgIdx) + const idx = Math.max(0, Math.min(ord, positions.length - 1)); + const p = positions[idx]!; + const top = jumpState.current.getItemTop(msgIdx); // lo = item's position within scroll content (wrapper-relative). // viewportTop = where the scroll content starts on SCREEN (after // ScrollBox padding/border + any chrome above). Highlight writes to // screen-absolute, so rowOffset = viewportTop + lo. Observed: off-by- // 1+ without viewportTop (FullscreenLayout has paddingTop=1 on the // ScrollBox, plus any header above). - const vpTop = s.getViewportTop() - let lo = top - s.getScrollTop() - const vp = s.getViewportHeight() - let screenRow = vpTop + lo + p.row + const vpTop = s.getViewportTop(); + let lo = top - s.getScrollTop(); + const vp = s.getViewportHeight(); + let screenRow = vpTop + lo + p.row; // Off viewport → scroll to bring it in (HEADROOM from top). // scrollTo commits sync; read-back after gives fresh lo. if (screenRow < vpTop || screenRow >= vpTop + vp) { - s.scrollTo(Math.max(0, top + p.row - HEADROOM)) - lo = top - s.getScrollTop() - screenRow = vpTop + lo + p.row + s.scrollTo(Math.max(0, top + p.row - HEADROOM)); + lo = top - s.getScrollTop(); + screenRow = vpTop + lo + p.row; } - setPositions?.({ positions, rowOffset: vpTop + lo, currentIdx: idx }) + setPositions?.({ positions, rowOffset: vpTop + lo, currentIdx: idx }); // Badge: global current = sum of occurrences before this msg + ord+1. // prefixSum[ptr] is engine-counted (indexOf on extractSearchText); // may drift from render-count for ghost messages but close enough — // badge is a rough location hint, not a proof. - const st = searchState.current - const total = st.prefixSum.at(-1) ?? 0 - const current = (st.prefixSum[st.ptr] ?? 0) + idx + 1 - onSearchMatchesChange?.(total, current) + const st = searchState.current; + const total = st.prefixSum.at(-1) ?? 0; + const current = (st.prefixSum[st.ptr] ?? 0) + idx + 1; + onSearchMatchesChange?.(total, current); logForDebugging( `highlight(i=${msgIdx}, ord=${idx}/${positions.length}): ` + `pos={row:${p.row},col:${p.col}} lo=${lo} screenRow=${screenRow} ` + `badge=${current}/${total}`, - ) + ); } - highlightRef.current = highlight + highlightRef.current = highlight; // Seek effect. jump() sets scanRequestRef + scrollToIndex + bump. // bump → re-render → useVirtualScroll mounts the target (scrollToIndex @@ -509,92 +492,92 @@ export function VirtualMessageList({ // // Dep is ONLY seekGen — effect doesn't re-run on random renders // (onSearchMatchesChange churn during incsearch). - const [seekGen, setSeekGen] = useState(0) - const bumpSeek = useCallback(() => setSeekGen(g => g + 1), []) + const [seekGen, setSeekGen] = useState(0); + const bumpSeek = useCallback(() => setSeekGen(g => g + 1), []); useEffect(() => { - const req = scanRequestRef.current - if (!req) return - const { idx, wantLast, tries } = req - const s = scrollRef.current - if (!s) return - const { getItemElement, getItemTop, scrollToIndex } = jumpState.current - const el = getItemElement(idx) - const h = el?.yogaNode?.getComputedHeight() ?? 0 + const req = scanRequestRef.current; + if (!req) return; + const { idx, wantLast, tries } = req; + const s = scrollRef.current; + if (!s) return; + const { getItemElement, getItemTop, scrollToIndex } = jumpState.current; + const el = getItemElement(idx); + const h = el?.yogaNode?.getComputedHeight() ?? 0; if (!el || h === 0) { // Not mounted after scrollToIndex. Shouldn't happen — scrollToIndex // guarantees mount by construction (scrollTop and topSpacer agree // via the same offsets value). Sanity: retry once, then skip. if (tries > 1) { - scanRequestRef.current = null - logForDebugging(`seek(i=${idx}): no mount after scrollToIndex, skip`) - stepRef.current(wantLast ? -1 : 1) - return + scanRequestRef.current = null; + logForDebugging(`seek(i=${idx}): no mount after scrollToIndex, skip`); + stepRef.current(wantLast ? -1 : 1); + return; } - scanRequestRef.current = { idx, wantLast, tries: tries + 1 } - scrollToIndex(idx) - bumpSeek() - return + scanRequestRef.current = { idx, wantLast, tries: tries + 1 }; + scrollToIndex(idx); + bumpSeek(); + return; } - scanRequestRef.current = null + scanRequestRef.current = null; // Precise scrollTo — scrollToIndex got us in the neighborhood // (item is mounted, maybe a few-dozen rows off due to overscan // estimate drift). Now land it at top-HEADROOM. - s.scrollTo(Math.max(0, getItemTop(idx) - HEADROOM)) - const positions = scanElement?.(el) ?? [] - elementPositions.current = { msgIdx: idx, positions } - logForDebugging(`seek(i=${idx} t=${tries}): ${positions.length} positions`) + s.scrollTo(Math.max(0, getItemTop(idx) - HEADROOM)); + const positions = scanElement?.(el) ?? []; + elementPositions.current = { msgIdx: idx, positions }; + logForDebugging(`seek(i=${idx} t=${tries}): ${positions.length} positions`); if (positions.length === 0) { // Phantom — engine matched, render didn't. Auto-advance. if (++phantomBurstRef.current > 20) { - phantomBurstRef.current = 0 - return + phantomBurstRef.current = 0; + return; } - stepRef.current(wantLast ? -1 : 1) - return + stepRef.current(wantLast ? -1 : 1); + return; } - phantomBurstRef.current = 0 - const ord = wantLast ? positions.length - 1 : 0 - searchState.current.screenOrd = ord - startPtrRef.current = -1 - highlightRef.current(ord) - const pending = pendingStepRef.current + phantomBurstRef.current = 0; + const ord = wantLast ? positions.length - 1 : 0; + searchState.current.screenOrd = ord; + startPtrRef.current = -1; + highlightRef.current(ord); + const pending = pendingStepRef.current; if (pending) { - pendingStepRef.current = 0 - stepRef.current(pending) + pendingStepRef.current = 0; + stepRef.current(pending); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [seekGen]) + }, [seekGen]); // Scroll to message i's top, arm scanPending. scan-effect reads fresh // screen next tick. wantLast: N-into-message — screenOrd = length-1. function jump(i: number, wantLast: boolean): void { - const s = scrollRef.current - if (!s) return - const js = jumpState.current - const { getItemElement, scrollToIndex } = js + const s = scrollRef.current; + if (!s) return; + const js = jumpState.current; + const { getItemElement, scrollToIndex } = js; // offsets is a Float64Array whose .length is the allocated buffer (only // grows) — messages.length is the logical item count. - if (i < 0 || i >= js.messages.length) return + if (i < 0 || i >= js.messages.length) return; // Clear stale highlight before scroll. Between now and the seek // effect's highlight, inverse-only from scan-highlight shows. - setPositions?.(null) - elementPositions.current = { msgIdx: -1, positions: [] } - scanRequestRef.current = { idx: i, wantLast, tries: 0 } - const el = getItemElement(i) - const h = el?.yogaNode?.getComputedHeight() ?? 0 + setPositions?.(null); + elementPositions.current = { msgIdx: -1, positions: [] }; + scanRequestRef.current = { idx: i, wantLast, tries: 0 }; + const el = getItemElement(i); + const h = el?.yogaNode?.getComputedHeight() ?? 0; // Mounted → precise scrollTo. Unmounted → scrollToIndex mounts it // (scrollTop and topSpacer agree via the same offsets value — exact // by construction, no estimation). Seek effect does the precise // scrollTo after paint either way. if (el && h > 0) { - s.scrollTo(targetFor(i)) + s.scrollTo(targetFor(i)); } else { - scrollToIndex(i) + scrollToIndex(i); } - bumpSeek() + bumpSeek(); } // Advance screenOrd within elementPositions. Exhausted → ptr advances, @@ -602,175 +585,168 @@ export function VirtualMessageList({ // jump) triggers auto-advance from scan-effect. Wraparound guard stops // if every message is a phantom. function step(delta: 1 | -1): void { - const st = searchState.current - const { matches, prefixSum } = st - const total = prefixSum.at(-1) ?? 0 - if (matches.length === 0) return + const st = searchState.current; + const { matches, prefixSum } = st; + const total = prefixSum.at(-1) ?? 0; + if (matches.length === 0) return; // Seek in-flight — queue this press (one-deep, latest overwrites). // The seek effect fires it after highlight. if (scanRequestRef.current) { - pendingStepRef.current = delta - return + pendingStepRef.current = delta; + return; } - if (startPtrRef.current < 0) startPtrRef.current = st.ptr + if (startPtrRef.current < 0) startPtrRef.current = st.ptr; - const { positions } = elementPositions.current - const newOrd = st.screenOrd + delta + const { positions } = elementPositions.current; + const newOrd = st.screenOrd + delta; if (newOrd >= 0 && newOrd < positions.length) { - st.screenOrd = newOrd - highlight(newOrd) // updates badge internally - startPtrRef.current = -1 - return + st.screenOrd = newOrd; + highlight(newOrd); // updates badge internally + startPtrRef.current = -1; + return; } // Exhausted visible. Advance ptr → jump → re-scan. - const ptr = (st.ptr + delta + matches.length) % matches.length + const ptr = (st.ptr + delta + matches.length) % matches.length; if (ptr === startPtrRef.current) { - setPositions?.(null) - startPtrRef.current = -1 - logForDebugging( - `step: wraparound at ptr=${ptr}, all ${matches.length} msgs phantoms`, - ) - return + setPositions?.(null); + startPtrRef.current = -1; + logForDebugging(`step: wraparound at ptr=${ptr}, all ${matches.length} msgs phantoms`); + return; } - st.ptr = ptr - st.screenOrd = 0 // resolved after scan (wantLast → length-1) - jump(matches[ptr]!, delta < 0) + st.ptr = ptr; + st.screenOrd = 0; // resolved after scan (wantLast → length-1) + jump(matches[ptr]!, delta < 0); // screenOrd will resolve after scan. Best-effort: prefixSum[ptr] + 0 // for n (first pos), prefixSum[ptr+1] for N (last pos = count-1). // The scan-effect's highlight will be the real value; this is a // pre-scan placeholder so the badge updates immediately. - const placeholder = - delta < 0 ? (prefixSum[ptr + 1] ?? total) : prefixSum[ptr]! + 1 - onSearchMatchesChange?.(total, placeholder) + const placeholder = delta < 0 ? (prefixSum[ptr + 1] ?? total) : prefixSum[ptr]! + 1; + onSearchMatchesChange?.(total, placeholder); } - stepRef.current = step + stepRef.current = step; useImperativeHandle( jumpRef, () => ({ // Non-search jump (sticky header click, etc). No scan, no positions. jumpToIndex: (i: number) => { - const s = scrollRef.current - if (s) s.scrollTo(targetFor(i)) + const s = scrollRef.current; + if (s) s.scrollTo(targetFor(i)); }, setSearchQuery: (q: string) => { // New search invalidates everything. - scanRequestRef.current = null - elementPositions.current = { msgIdx: -1, positions: [] } - startPtrRef.current = -1 - setPositions?.(null) - const lq = q.toLowerCase() + scanRequestRef.current = null; + elementPositions.current = { msgIdx: -1, positions: [] }; + startPtrRef.current = -1; + setPositions?.(null); + const lq = q.toLowerCase(); // One entry per MESSAGE (deduplicated). Boolean "does this msg // contain the query". ~10ms for 9k messages with cached lowered. - const matches: number[] = [] + const matches: number[] = []; // Per-message occurrence count → prefixSum for global current // index. Engine-counted (cheap indexOf loop); may differ from // render-count (scanElement) for ghost/phantom messages but close // enough for the badge. The badge is a rough location hint. - const prefixSum: number[] = [0] + const prefixSum: number[] = [0]; if (lq) { - const msgs = jumpState.current.messages + const msgs = jumpState.current.messages; for (let i = 0; i < msgs.length; i++) { - const text = extractSearchText(msgs[i]!) - let pos = text.indexOf(lq) - let cnt = 0 + const text = extractSearchText(msgs[i]!); + let pos = text.indexOf(lq); + let cnt = 0; while (pos >= 0) { - cnt++ - pos = text.indexOf(lq, pos + lq.length) + cnt++; + pos = text.indexOf(lq, pos + lq.length); } if (cnt > 0) { - matches.push(i) - prefixSum.push(prefixSum.at(-1)! + cnt) + matches.push(i); + prefixSum.push(prefixSum.at(-1)! + cnt); } } } - const total = prefixSum.at(-1)! + const total = prefixSum.at(-1)!; // Nearest MESSAGE to the anchor. <= so ties go to later. - let ptr = 0 - const s = scrollRef.current - const { offsets, start, getItemTop } = jumpState.current - const firstTop = getItemTop(start) - const origin = firstTop >= 0 ? firstTop - offsets[start]! : 0 + let ptr = 0; + const s = scrollRef.current; + const { offsets, start, getItemTop } = jumpState.current; + const firstTop = getItemTop(start); + const origin = firstTop >= 0 ? firstTop - offsets[start]! : 0; if (matches.length > 0 && s) { - const curTop = - searchAnchor.current >= 0 ? searchAnchor.current : s.getScrollTop() - let best = Infinity + const curTop = searchAnchor.current >= 0 ? searchAnchor.current : s.getScrollTop(); + let best = Infinity; for (let k = 0; k < matches.length; k++) { - const d = Math.abs(origin + offsets[matches[k]!]! - curTop) + const d = Math.abs(origin + offsets[matches[k]!]! - curTop); if (d <= best) { - best = d - ptr = k + best = d; + ptr = k; } } logForDebugging( `setSearchQuery('${q}'): ${matches.length} msgs · ptr=${ptr} ` + `msgIdx=${matches[ptr]} curTop=${curTop} origin=${origin}`, - ) + ); } - searchState.current = { matches, ptr, screenOrd: 0, prefixSum } + searchState.current = { matches, ptr, screenOrd: 0, prefixSum }; if (matches.length > 0) { // wantLast=true: preview the LAST occurrence in the nearest // message. At sticky-bottom (common / entry), nearest is the // last msg; its last occurrence is closest to where the user // was — minimal view movement. n advances forward from there. - jump(matches[ptr]!, true) + jump(matches[ptr]!, true); } else if (searchAnchor.current >= 0 && s) { // /foob → 0 matches → snap back to anchor. less/vim incsearch. - s.scrollTo(searchAnchor.current) + s.scrollTo(searchAnchor.current); } // Global occurrence count + 1-based current. wantLast=true so the // scan will land on the last occurrence in matches[ptr]. Placeholder // = prefixSum[ptr+1] (count through this msg). highlight() updates // to the exact value after scan completes. - onSearchMatchesChange?.( - total, - matches.length > 0 ? (prefixSum[ptr + 1] ?? total) : 0, - ) + onSearchMatchesChange?.(total, matches.length > 0 ? (prefixSum[ptr + 1] ?? total) : 0); }, nextMatch: () => step(1), prevMatch: () => step(-1), setAnchor: () => { - const s = scrollRef.current - if (s) searchAnchor.current = s.getScrollTop() + const s = scrollRef.current; + if (s) searchAnchor.current = s.getScrollTop(); }, disarmSearch: () => { // Manual scroll invalidates screen-absolute positions. - setPositions?.(null) - scanRequestRef.current = null - elementPositions.current = { msgIdx: -1, positions: [] } - startPtrRef.current = -1 + setPositions?.(null); + scanRequestRef.current = null; + elementPositions.current = { msgIdx: -1, positions: [] }; + startPtrRef.current = -1; }, warmSearchIndex: async () => { - if (indexWarmed.current) return 0 - const msgs = jumpState.current.messages - const CHUNK = 500 - let workMs = 0 - const wallStart = performance.now() + if (indexWarmed.current) return 0; + const msgs = jumpState.current.messages; + const CHUNK = 500; + let workMs = 0; + const wallStart = performance.now(); for (let i = 0; i < msgs.length; i += CHUNK) { - await sleep(0) - const t0 = performance.now() - const end = Math.min(i + CHUNK, msgs.length) + await sleep(0); + const t0 = performance.now(); + const end = Math.min(i + CHUNK, msgs.length); for (let j = i; j < end; j++) { - extractSearchText(msgs[j]!) + extractSearchText(msgs[j]!); } - workMs += performance.now() - t0 + workMs += performance.now() - t0; } - const wallMs = Math.round(performance.now() - wallStart) + const wallMs = Math.round(performance.now() - wallStart); logForDebugging( `warmSearchIndex: ${msgs.length} msgs · work=${Math.round(workMs)}ms wall=${wallMs}ms chunks=${Math.ceil(msgs.length / CHUNK)}`, - ) - indexWarmed.current = true - return Math.round(workMs) + ); + indexWarmed.current = true; + return Math.round(workMs); }, }), // Closures over refs + callbacks. scrollRef stable; others are // useCallback([]) or prop-drilled from REPL (stable). // eslint-disable-next-line react-hooks/exhaustive-deps [scrollRef], - ) + ); // StickyTracker goes AFTER the list content. It returns null (no DOM node) // so order shouldn't matter for layout — but putting it first means every @@ -778,7 +754,7 @@ export function VirtualMessageList({ // the sibling items (React walks children in order). After the items, it's // a leaf reconcile. Defensive: also avoids any Yoga child-index quirks if // the Ink reconciler ever materializes a placeholder for null returns. - const [hoveredKey, setHoveredKey] = useState(null) + const [hoveredKey, setHoveredKey] = useState(null); // Stable click/hover handlers — called with k, dispatch from a ref so // closure identity doesn't change per render. The per-item handler // closures (`e => ...`, `() => setHoveredKey(k)`) were the @@ -788,31 +764,28 @@ export function VirtualMessageList({ // scroll = 1800 short-lived closures/sec. With stable refs the item // wrapper props don't change → VirtualItem.memo bails for the ~35 // unchanged items, only ~25 fresh items pay createElement cost. - const handlersRef = useRef({ onItemClick, setHoveredKey }) - handlersRef.current = { onItemClick, setHoveredKey } - const onClickK = useCallback( - (msg: RenderableMessage, cellIsBlank: boolean) => { - const h = handlersRef.current - if (!cellIsBlank && h.onItemClick) h.onItemClick(msg) - }, - [], - ) + const handlersRef = useRef({ onItemClick, setHoveredKey }); + handlersRef.current = { onItemClick, setHoveredKey }; + const onClickK = useCallback((msg: RenderableMessage, cellIsBlank: boolean) => { + const h = handlersRef.current; + if (!cellIsBlank && h.onItemClick) h.onItemClick(msg); + }, []); const onEnterK = useCallback((k: string) => { - handlersRef.current.setHoveredKey(k) - }, []) + handlersRef.current.setHoveredKey(k); + }, []); const onLeaveK = useCallback((k: string) => { - handlersRef.current.setHoveredKey(prev => (prev === k ? null : prev)) - }, []) + handlersRef.current.setHoveredKey(prev => (prev === k ? null : prev)); + }, []); return ( <> {messages.slice(start, end).map((msg, i) => { - const idx = start + i - const k = keys[idx]! - const clickable = !!onItemClick && (isItemClickable?.(msg) ?? true) - const hovered = clickable && hoveredKey === k - const expanded = isItemExpanded?.(msg) + const idx = start + i; + const k = keys[idx]!; + const clickable = !!onItemClick && (isItemClickable?.(msg) ?? true); + const hovered = clickable && hoveredKey === k; + const expanded = isItemExpanded?.(msg); return ( - ) + ); })} {bottomSpacer > 0 && } {trackStickyPrompt && ( @@ -843,10 +816,10 @@ export function VirtualMessageList({ /> )} - ) + ); } -const NOOP_UNSUB = () => {} +const NOOP_UNSUB = () => {}; /** * Effect-only child that tracks the last user-prompt scrolled above the @@ -876,38 +849,33 @@ function StickyTracker({ getItemElement, scrollRef, }: { - messages: RenderableMessage[] - start: number - end: number - offsets: ArrayLike - getItemTop: (index: number) => number - getItemElement: (index: number) => DOMElement | null - scrollRef: RefObject + messages: RenderableMessage[]; + start: number; + end: number; + offsets: ArrayLike; + getItemTop: (index: number) => number; + getItemElement: (index: number) => DOMElement | null; + scrollRef: RefObject; }): null { - const { setStickyPrompt } = useContext(ScrollChromeContext) + const { setStickyPrompt } = useContext(ScrollChromeContext); // Fine-grained subscription — snapshot is unquantized scrollTop+delta so // every scroll action (wheel tick, PgUp, drag) triggers a re-render of // THIS component only. Sticky bit folded into the sign so sticky→broken // also triggers (scrollToBottom sets sticky without moving scrollTop). const subscribe = useCallback( - (listener: () => void) => - scrollRef.current?.subscribe(listener) ?? NOOP_UNSUB, + (listener: () => void) => scrollRef.current?.subscribe(listener) ?? NOOP_UNSUB, [scrollRef], - ) + ); useSyncExternalStore(subscribe, () => { - const s = scrollRef.current - if (!s) return NaN - const t = s.getScrollTop() + s.getPendingDelta() - return s.isSticky() ? -1 - t : t - }) + const s = scrollRef.current; + if (!s) return NaN; + const t = s.getScrollTop() + s.getPendingDelta(); + return s.isSticky() ? -1 - t : t; + }); // Read live scroll state on every render. - const isSticky = scrollRef.current?.isSticky() ?? true - const target = Math.max( - 0, - (scrollRef.current?.getScrollTop() ?? 0) + - (scrollRef.current?.getPendingDelta() ?? 0), - ) + const isSticky = scrollRef.current?.isSticky() ?? true; + const target = Math.max(0, (scrollRef.current?.getScrollTop() ?? 0) + (scrollRef.current?.getPendingDelta() ?? 0)); // Walk the mounted range to find the first item at-or-below the viewport // top. `range` is from the parent's coarse-quantum render (may be slightly @@ -915,40 +883,39 @@ function StickyTracker({ // directions. Items without a Yoga layout yet (newly mounted this frame) // are treated as at-or-below — they're somewhere in view, and assuming // otherwise would show a sticky for a prompt that's actually on screen. - let firstVisible = start - let firstVisibleTop = -1 + let firstVisible = start; + let firstVisibleTop = -1; for (let i = end - 1; i >= start; i--) { - const top = getItemTop(i) + const top = getItemTop(i); if (top >= 0) { - if (top < target) break - firstVisibleTop = top + if (top < target) break; + firstVisibleTop = top; } - firstVisible = i + firstVisible = i; } - let idx = -1 - let text: string | null = null + let idx = -1; + let text: string | null = null; if (firstVisible > 0 && !isSticky) { for (let i = firstVisible - 1; i >= 0; i--) { - const t = stickyPromptText(messages[i]!) - if (t === null) continue + const t = stickyPromptText(messages[i]!); + if (t === null) continue; // The prompt's wrapping Box top is above target (that's why it's in // the [0, firstVisible) range), but its ❯ is at top+1 (marginTop=1). // If the ❯ is at-or-below target, it's VISIBLE at viewport top — // showing the same text in the header would duplicate it. Happens // in the 1-row gap between Box top scrolling past and ❯ scrolling // past. Skip to the next-older prompt (its ❯ is definitely above). - const top = getItemTop(i) - if (top >= 0 && top + 1 >= target) continue - idx = i - text = t - break + const top = getItemTop(i); + if (top >= 0 && top + 1 >= target) continue; + idx = i; + text = t; + break; } } - const baseOffset = - firstVisibleTop >= 0 ? firstVisibleTop - offsets[firstVisible]! : 0 - const estimate = idx >= 0 ? Math.max(0, baseOffset + offsets[idx]!) : -1 + const baseOffset = firstVisibleTop >= 0 ? firstVisibleTop - offsets[firstVisible]! : 0; + const estimate = idx >= 0 ? Math.max(0, baseOffset + offsets[idx]!) : -1; // For click-jumps to items not yet mounted (user scrolled far past, // prompt is in the topSpacer). Click handler scrolls to the estimate @@ -957,7 +924,7 @@ function StickyTracker({ // reads el.yogaNode.getComputedTop() in the SAME calculateLayout pass // that produces scrollHeight) — no throttle race. Cap retries: a /clear // race could unmount the item mid-sequence. - const pending = useRef({ idx: -1, tries: 0 }) + const pending = useRef({ idx: -1, tries: 0 }); // Suppression state machine. The click handler arms; the onChange effect // consumes (armed→force) then fires-and-clears on the render AFTER that // (force→none). The force step poisons the dedup: after click, idx often @@ -965,14 +932,14 @@ function StickyTracker({ // without force the last.idx===idx guard would hold 'clicked' until the // user crossed a prompt boundary. Previously encoded in last.idx as // -1/-2/-3 which overlapped with real indices — too clever. - type Suppress = 'none' | 'armed' | 'force' - const suppress = useRef('none') + type Suppress = 'none' | 'armed' | 'force'; + const suppress = useRef('none'); // Dedup on idx only — estimate derives from firstVisibleTop which shifts // every scroll tick, so including it in the key made the guard dead // (setStickyPrompt fired a fresh {text,scrollTo} per-frame). The scrollTo // closure still captures the current estimate; it just doesn't need to // re-fire when only estimate moved. - const lastIdx = useRef(-1) + const lastIdx = useRef(-1); // setStickyPrompt effect FIRST — must see pending.idx before the // correction effect below clears it. On the estimate-fallback path, the @@ -982,79 +949,79 @@ function StickyTracker({ // header over 'clicked'. useEffect(() => { // Hold while two-phase correction is in flight. - if (pending.current.idx >= 0) return + if (pending.current.idx >= 0) return; if (suppress.current === 'armed') { - suppress.current = 'force' - return + suppress.current = 'force'; + return; } - const force = suppress.current === 'force' - suppress.current = 'none' - if (!force && lastIdx.current === idx) return - lastIdx.current = idx + const force = suppress.current === 'force'; + suppress.current = 'none'; + if (!force && lastIdx.current === idx) return; + lastIdx.current = idx; if (text === null) { - setStickyPrompt(null) - return + setStickyPrompt(null); + return; } // First paragraph only (split on blank line) — a prompt like // "still seeing bugs:\n\n1. foo\n2. bar" previews as just the // lead-in. trimStart so a leading blank line (queued_command mid- // turn messages sometimes have one) doesn't find paraEnd at 0. - const trimmed = text.trimStart() - const paraEnd = trimmed.search(/\n\s*\n/) + const trimmed = text.trimStart(); + const paraEnd = trimmed.search(/\n\s*\n/); const collapsed = (paraEnd >= 0 ? trimmed.slice(0, paraEnd) : trimmed) .slice(0, STICKY_TEXT_CAP) .replace(/\s+/g, ' ') - .trim() + .trim(); if (collapsed === '') { - setStickyPrompt(null) - return + setStickyPrompt(null); + return; } - const capturedIdx = idx - const capturedEstimate = estimate + const capturedIdx = idx; + const capturedEstimate = estimate; setStickyPrompt({ text: collapsed, scrollTo: () => { // Hide header, keep padding collapsed — FullscreenLayout's // 'clicked' sentinel → scrollBox_y=0 + pad=0 → viewportTop=0. - setStickyPrompt('clicked') - suppress.current = 'armed' + setStickyPrompt('clicked'); + suppress.current = 'armed'; // scrollToElement anchors by DOMElement ref, not a number: // render-node-to-output reads el.yogaNode.getComputedTop() at // paint time (same Yoga pass as scrollHeight). No staleness from // the throttled render — the ref is stable, the position read is // deferred. offset=1 = UserPromptMessage marginTop. - const el = getItemElement(capturedIdx) + const el = getItemElement(capturedIdx); if (el) { - scrollRef.current?.scrollToElement(el, 1) + scrollRef.current?.scrollToElement(el, 1); } else { // Not mounted (scrolled far past — in topSpacer). Jump to // estimate to mount it; correction effect re-anchors once it // appears. Estimate is DEFAULT_ESTIMATE-based — lands short. - scrollRef.current?.scrollTo(capturedEstimate) - pending.current = { idx: capturedIdx, tries: 0 } + scrollRef.current?.scrollTo(capturedEstimate); + pending.current = { idx: capturedIdx, tries: 0 }; } }, - }) + }); // No deps — must run every render. Suppression state lives in a ref // (not idx/estimate), so a deps-gated effect would never see it tick. // Body's own guards short-circuit when nothing changed. // eslint-disable-next-line react-hooks/exhaustive-deps - }) + }); // Correction: for click-jumps to unmounted items. Click handler scrolled // to the estimate; this re-anchors by element once the item appears. // scrollToElement defers the Yoga read to paint time — deterministic. // SECOND so it clears pending AFTER the onChange gate above has seen it. useEffect(() => { - if (pending.current.idx < 0) return - const el = getItemElement(pending.current.idx) + if (pending.current.idx < 0) return; + const el = getItemElement(pending.current.idx); if (el) { - scrollRef.current?.scrollToElement(el, 1) - pending.current = { idx: -1, tries: 0 } + scrollRef.current?.scrollToElement(el, 1); + pending.current = { idx: -1, tries: 0 }; } else if (++pending.current.tries > 5) { - pending.current = { idx: -1, tries: 0 } + pending.current = { idx: -1, tries: 0 }; } - }) + }); - return null + return null; } diff --git a/src/components/WorkflowMultiselectDialog.tsx b/src/components/WorkflowMultiselectDialog.tsx index e06737514..8eb4f815c 100644 --- a/src/components/WorkflowMultiselectDialog.tsx +++ b/src/components/WorkflowMultiselectDialog.tsx @@ -1,22 +1,22 @@ -import React, { useCallback, useState } from 'react' -import type { Workflow } from '../commands/install-github-app/types.js' -import type { ExitState } from '../hooks/useExitOnCtrlCDWithKeybindings.js' -import { Box, Link, Text } from '../ink.js' -import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js' -import { SelectMulti } from './CustomSelect/SelectMulti.js' -import { Byline } from './design-system/Byline.js' -import { Dialog } from './design-system/Dialog.js' -import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js' +import React, { useCallback, useState } from 'react'; +import type { Workflow } from '../commands/install-github-app/types.js'; +import type { ExitState } from '../hooks/useExitOnCtrlCDWithKeybindings.js'; +import { Box, Link, Text } from '../ink.js'; +import { ConfigurableShortcutHint } from './ConfigurableShortcutHint.js'; +import { SelectMulti } from './CustomSelect/SelectMulti.js'; +import { Byline } from './design-system/Byline.js'; +import { Dialog } from './design-system/Dialog.js'; +import { KeyboardShortcutHint } from './design-system/KeyboardShortcutHint.js'; type WorkflowOption = { - value: Workflow - label: string -} + value: Workflow; + label: string; +}; type Props = { - onSubmit: (selectedWorkflows: Workflow[]) => void - defaultSelections: Workflow[] -} + onSubmit: (selectedWorkflows: Workflow[]) => void; + defaultSelections: Workflow[]; +}; const WORKFLOWS: WorkflowOption[] = [ { @@ -27,53 +27,45 @@ const WORKFLOWS: WorkflowOption[] = [ value: 'claude-review' as const, label: 'Claude Code Review - Automated code review on new PRs', }, -] +]; function renderInputGuide(exitState: ExitState): React.ReactNode { if (exitState.pending) { - return Press {exitState.keyName} again to exit + return Press {exitState.keyName} again to exit; } return ( - + - ) + ); } -export function WorkflowMultiselectDialog({ - onSubmit, - defaultSelections, -}: Props): React.ReactNode { - const [showError, setShowError] = useState(false) +export function WorkflowMultiselectDialog({ onSubmit, defaultSelections }: Props): React.ReactNode { + const [showError, setShowError] = useState(false); const handleSubmit = useCallback( (selectedValues: Workflow[]) => { if (selectedValues.length === 0) { - setShowError(true) - return + setShowError(true); + return; } - setShowError(false) - onSubmit(selectedValues) + setShowError(false); + onSubmit(selectedValues); }, [onSubmit], - ) + ); const handleChange = useCallback(() => { - setShowError(false) - }, []) + setShowError(false); + }, []); // Cancel just shows the error - user must select at least one workflow const handleCancel = useCallback(() => { - setShowError(true) - }, []) + setShowError(true); + }, []); return ( - - You must select at least one workflow to continue - + You must select at least one workflow to continue )} - ) + ); } diff --git a/src/components/WorktreeExitDialog.tsx b/src/components/WorktreeExitDialog.tsx index ba5ab0f83..b6bb8eed3 100644 --- a/src/components/WorktreeExitDialog.tsx +++ b/src/components/WorktreeExitDialog.tsx @@ -1,59 +1,44 @@ -import React, { useEffect, useState } from 'react' -import type { CommandResultDisplay } from 'src/commands.js' -import { logEvent } from 'src/services/analytics/index.js' -import { logForDebugging } from 'src/utils/debug.js' -import { Box, Text } from '../ink.js' -import { execFileNoThrow } from '../utils/execFileNoThrow.js' -import { getPlansDirectory } from '../utils/plans.js' -import { setCwd } from '../utils/Shell.js' -import { - cleanupWorktree, - getCurrentWorktreeSession, - keepWorktree, - killTmuxSession, -} from '../utils/worktree.js' -import { Select } from './CustomSelect/select.js' -import { Dialog } from './design-system/Dialog.js' -import { Spinner } from './Spinner.js' +import React, { useEffect, useState } from 'react'; +import type { CommandResultDisplay } from 'src/commands.js'; +import { logEvent } from 'src/services/analytics/index.js'; +import { logForDebugging } from 'src/utils/debug.js'; +import { Box, Text } from '../ink.js'; +import { execFileNoThrow } from '../utils/execFileNoThrow.js'; +import { getPlansDirectory } from '../utils/plans.js'; +import { setCwd } from '../utils/Shell.js'; +import { cleanupWorktree, getCurrentWorktreeSession, keepWorktree, killTmuxSession } from '../utils/worktree.js'; +import { Select } from './CustomSelect/select.js'; +import { Dialog } from './design-system/Dialog.js'; +import { Spinner } from './Spinner.js'; // Inline require breaks the cycle this file would otherwise close: // sessionStorage → commands → exit → ExitFlow → here. All call sites // are inside callbacks, so the lazy require never sees an undefined import. function recordWorktreeExit(): void { /* eslint-disable @typescript-eslint/no-require-imports */ - ;( - require('../utils/sessionStorage.js') as typeof import('../utils/sessionStorage.js') - ).saveWorktreeState(null) + (require('../utils/sessionStorage.js') as typeof import('../utils/sessionStorage.js')).saveWorktreeState(null); /* eslint-enable @typescript-eslint/no-require-imports */ } type Props = { - onDone: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void - onCancel?: () => void -} + onDone: (result?: string, options?: { display?: CommandResultDisplay }) => void; + onCancel?: () => void; +}; -export function WorktreeExitDialog({ - onDone, - onCancel, -}: Props): React.ReactNode { - const [status, setStatus] = useState< - 'loading' | 'asking' | 'keeping' | 'removing' | 'done' - >('loading') - const [changes, setChanges] = useState([]) - const [commitCount, setCommitCount] = useState(0) - const [resultMessage, setResultMessage] = useState() - const worktreeSession = getCurrentWorktreeSession() +export function WorktreeExitDialog({ onDone, onCancel }: Props): React.ReactNode { + const [status, setStatus] = useState<'loading' | 'asking' | 'keeping' | 'removing' | 'done'>('loading'); + const [changes, setChanges] = useState([]); + const [commitCount, setCommitCount] = useState(0); + const [resultMessage, setResultMessage] = useState(); + const worktreeSession = getCurrentWorktreeSession(); useEffect(() => { async function loadChanges() { - let changeLines: string[] = [] - const gitStatus = await execFileNoThrow('git', ['status', '--porcelain']) + let changeLines: string[] = []; + const gitStatus = await execFileNoThrow('git', ['status', '--porcelain']); if (gitStatus.stdout) { - changeLines = gitStatus.stdout.split('\n').filter(_ => _.trim() !== '') - setChanges(changeLines) + changeLines = gitStatus.stdout.split('\n').filter(_ => _.trim() !== ''); + setChanges(changeLines); } // Check for commits to eject @@ -63,140 +48,138 @@ export function WorktreeExitDialog({ 'rev-list', '--count', `${worktreeSession.originalHeadCommit}..HEAD`, - ]) - const count = parseInt(commitsStr.trim()) || 0 - setCommitCount(count) + ]); + const count = parseInt(commitsStr.trim()) || 0; + setCommitCount(count); // If no changes and no commits, clean up silently if (changeLines.length === 0 && count === 0) { - setStatus('removing') + setStatus('removing'); void cleanupWorktree() .then(() => { - process.chdir(worktreeSession.originalCwd) - setCwd(worktreeSession.originalCwd) - recordWorktreeExit() - getPlansDirectory.cache.clear?.() - setResultMessage('Worktree removed (no changes)') + process.chdir(worktreeSession.originalCwd); + setCwd(worktreeSession.originalCwd); + recordWorktreeExit(); + getPlansDirectory.cache.clear?.(); + setResultMessage('Worktree removed (no changes)'); }) .catch(error => { logForDebugging(`Failed to clean up worktree: ${error}`, { level: 'error', - }) - setResultMessage('Worktree cleanup failed, exiting anyway') + }); + setResultMessage('Worktree cleanup failed, exiting anyway'); }) .then(() => { - setStatus('done') - }) - return + setStatus('done'); + }); + return; } else { - setStatus('asking') + setStatus('asking'); } } } - void loadChanges() + void loadChanges(); // eslint-disable-next-line react-hooks/exhaustive-deps // biome-ignore lint/correctness/useExhaustiveDependencies: intentional - }, [worktreeSession]) + }, [worktreeSession]); useEffect(() => { if (status === 'done') { - onDone(resultMessage) + onDone(resultMessage); } - }, [status, onDone, resultMessage]) + }, [status, onDone, resultMessage]); if (!worktreeSession) { - onDone('No active worktree session found', { display: 'system' }) - return null + onDone('No active worktree session found', { display: 'system' }); + return null; } if (status === 'loading' || status === 'done') { - return null + return null; } async function handleSelect(value: string) { - if (!worktreeSession) return + if (!worktreeSession) return; - const hasTmux = Boolean(worktreeSession.tmuxSessionName) + const hasTmux = Boolean(worktreeSession.tmuxSessionName); if (value === 'keep' || value === 'keep-with-tmux') { - setStatus('keeping') + setStatus('keeping'); logEvent('tengu_worktree_kept', { commits: commitCount, changed_files: changes.length, - }) - await keepWorktree() - process.chdir(worktreeSession.originalCwd) - setCwd(worktreeSession.originalCwd) - recordWorktreeExit() - getPlansDirectory.cache.clear?.() + }); + await keepWorktree(); + process.chdir(worktreeSession.originalCwd); + setCwd(worktreeSession.originalCwd); + recordWorktreeExit(); + getPlansDirectory.cache.clear?.(); if (hasTmux) { setResultMessage( `Worktree kept. Your work is saved at ${worktreeSession.worktreePath} on branch ${worktreeSession.worktreeBranch}. Reattach to tmux session with: tmux attach -t ${worktreeSession.tmuxSessionName}`, - ) + ); } else { setResultMessage( `Worktree kept. Your work is saved at ${worktreeSession.worktreePath} on branch ${worktreeSession.worktreeBranch}`, - ) + ); } - setStatus('done') + setStatus('done'); } else if (value === 'keep-kill-tmux') { - setStatus('keeping') + setStatus('keeping'); logEvent('tengu_worktree_kept', { commits: commitCount, changed_files: changes.length, - }) + }); if (worktreeSession.tmuxSessionName) { - await killTmuxSession(worktreeSession.tmuxSessionName) + await killTmuxSession(worktreeSession.tmuxSessionName); } - await keepWorktree() - process.chdir(worktreeSession.originalCwd) - setCwd(worktreeSession.originalCwd) - recordWorktreeExit() - getPlansDirectory.cache.clear?.() + await keepWorktree(); + process.chdir(worktreeSession.originalCwd); + setCwd(worktreeSession.originalCwd); + recordWorktreeExit(); + getPlansDirectory.cache.clear?.(); setResultMessage( `Worktree kept at ${worktreeSession.worktreePath} on branch ${worktreeSession.worktreeBranch}. Tmux session terminated.`, - ) - setStatus('done') + ); + setStatus('done'); } else if (value === 'remove' || value === 'remove-with-tmux') { - setStatus('removing') + setStatus('removing'); logEvent('tengu_worktree_removed', { commits: commitCount, changed_files: changes.length, - }) + }); if (worktreeSession.tmuxSessionName) { - await killTmuxSession(worktreeSession.tmuxSessionName) + await killTmuxSession(worktreeSession.tmuxSessionName); } try { - await cleanupWorktree() - process.chdir(worktreeSession.originalCwd) - setCwd(worktreeSession.originalCwd) - recordWorktreeExit() - getPlansDirectory.cache.clear?.() + await cleanupWorktree(); + process.chdir(worktreeSession.originalCwd); + setCwd(worktreeSession.originalCwd); + recordWorktreeExit(); + getPlansDirectory.cache.clear?.(); } catch (error) { logForDebugging(`Failed to clean up worktree: ${error}`, { level: 'error', - }) - setResultMessage('Worktree cleanup failed, exiting anyway') - setStatus('done') - return + }); + setResultMessage('Worktree cleanup failed, exiting anyway'); + setStatus('done'); + return; } - const tmuxNote = hasTmux ? ' Tmux session terminated.' : '' + const tmuxNote = hasTmux ? ' Tmux session terminated.' : ''; if (commitCount > 0 && changes.length > 0) { setResultMessage( `Worktree removed. ${commitCount} ${commitCount === 1 ? 'commit' : 'commits'} and uncommitted changes were discarded.${tmuxNote}`, - ) + ); } else if (commitCount > 0) { setResultMessage( `Worktree removed. ${commitCount} ${commitCount === 1 ? 'commit' : 'commits'} on ${worktreeSession.worktreeBranch} ${commitCount === 1 ? 'was' : 'were'} discarded.${tmuxNote}`, - ) + ); } else if (changes.length > 0) { - setResultMessage( - `Worktree removed. Uncommitted changes were discarded.${tmuxNote}`, - ) + setResultMessage(`Worktree removed. Uncommitted changes were discarded.${tmuxNote}`); } else { - setResultMessage(`Worktree removed.${tmuxNote}`) + setResultMessage(`Worktree removed.${tmuxNote}`); } - setStatus('done') + setStatus('done'); } } @@ -206,7 +189,7 @@ export function WorktreeExitDialog({ Keeping worktree… - ) + ); } if (status === 'removing') { @@ -215,41 +198,38 @@ export function WorktreeExitDialog({ Removing worktree… - ) + ); } - const branchName = worktreeSession.worktreeBranch - const hasUncommitted = changes.length > 0 - const hasCommits = commitCount > 0 + const branchName = worktreeSession.worktreeBranch; + const hasUncommitted = changes.length > 0; + const hasCommits = commitCount > 0; - let subtitle = '' + let subtitle = ''; if (hasUncommitted && hasCommits) { - subtitle = `You have ${changes.length} uncommitted ${changes.length === 1 ? 'file' : 'files'} and ${commitCount} ${commitCount === 1 ? 'commit' : 'commits'} on ${branchName}. All will be lost if you remove.` + subtitle = `You have ${changes.length} uncommitted ${changes.length === 1 ? 'file' : 'files'} and ${commitCount} ${commitCount === 1 ? 'commit' : 'commits'} on ${branchName}. All will be lost if you remove.`; } else if (hasUncommitted) { - subtitle = `You have ${changes.length} uncommitted ${changes.length === 1 ? 'file' : 'files'}. These will be lost if you remove the worktree.` + subtitle = `You have ${changes.length} uncommitted ${changes.length === 1 ? 'file' : 'files'}. These will be lost if you remove the worktree.`; } else if (hasCommits) { - subtitle = `You have ${commitCount} ${commitCount === 1 ? 'commit' : 'commits'} on ${branchName}. The branch will be deleted if you remove the worktree.` + subtitle = `You have ${commitCount} ${commitCount === 1 ? 'commit' : 'commits'} on ${branchName}. The branch will be deleted if you remove the worktree.`; } else { - subtitle = - 'You are working in a worktree. Keep it to continue working there, or remove it to clean up.' + subtitle = 'You are working in a worktree. Keep it to continue working there, or remove it to clean up.'; } function handleCancel() { if (onCancel) { // Abort exit and return to the session - onCancel() - return + onCancel(); + return; } // Fallback: treat Escape as "keep" if no onCancel provided - void handleSelect('keep') + void handleSelect('keep'); } const removeDescription = - hasUncommitted || hasCommits - ? 'All changes and commits will be lost.' - : 'Clean up the worktree directory.' + hasUncommitted || hasCommits ? 'All changes and commits will be lost.' : 'Clean up the worktree directory.'; - const hasTmuxSession = Boolean(worktreeSession.tmuxSessionName) + const hasTmuxSession = Boolean(worktreeSession.tmuxSessionName); const options = hasTmuxSession ? [ @@ -280,21 +260,13 @@ export function WorktreeExitDialog({ value: 'remove', description: removeDescription, }, - ] + ]; - const defaultValue = hasTmuxSession ? 'keep-with-tmux' : 'keep' + const defaultValue = hasTmuxSession ? 'keep-with-tmux' : 'keep'; return ( - - - ) + ); } diff --git a/src/components/agents/AgentDetail.tsx b/src/components/agents/AgentDetail.tsx index 4c817b134..ddee3776a 100644 --- a/src/components/agents/AgentDetail.tsx +++ b/src/components/agents/AgentDetail.tsx @@ -1,75 +1,63 @@ -import figures from 'figures' -import * as React from 'react' -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' -import { Box, Text } from '../../ink.js' -import { useKeybinding } from '../../keybindings/useKeybinding.js' -import type { Tools } from '../../Tool.js' -import { getAgentColor } from '../../tools/AgentTool/agentColorManager.js' -import { getMemoryScopeDisplay } from '../../tools/AgentTool/agentMemory.js' -import { resolveAgentTools } from '../../tools/AgentTool/agentToolUtils.js' -import { - type AgentDefinition, - isBuiltInAgent, -} from '../../tools/AgentTool/loadAgentsDir.js' -import { getAgentModelDisplay } from '../../utils/model/agent.js' -import { Markdown } from '../Markdown.js' -import { getActualRelativeAgentFilePath } from './agentFileUtils.js' +import figures from 'figures'; +import * as React from 'react'; +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; +import { Box, Text } from '../../ink.js'; +import { useKeybinding } from '../../keybindings/useKeybinding.js'; +import type { Tools } from '../../Tool.js'; +import { getAgentColor } from '../../tools/AgentTool/agentColorManager.js'; +import { getMemoryScopeDisplay } from '../../tools/AgentTool/agentMemory.js'; +import { resolveAgentTools } from '../../tools/AgentTool/agentToolUtils.js'; +import { type AgentDefinition, isBuiltInAgent } from '../../tools/AgentTool/loadAgentsDir.js'; +import { getAgentModelDisplay } from '../../utils/model/agent.js'; +import { Markdown } from '../Markdown.js'; +import { getActualRelativeAgentFilePath } from './agentFileUtils.js'; type Props = { - agent: AgentDefinition - tools: Tools - allAgents?: AgentDefinition[] - onBack: () => void -} + agent: AgentDefinition; + tools: Tools; + allAgents?: AgentDefinition[]; + onBack: () => void; +}; export function AgentDetail({ agent, tools, onBack }: Props): React.ReactNode { - const resolvedTools = resolveAgentTools(agent, tools, false) - const filePath = getActualRelativeAgentFilePath(agent) - const backgroundColor = getAgentColor(agent.agentType) + const resolvedTools = resolveAgentTools(agent, tools, false); + const filePath = getActualRelativeAgentFilePath(agent); + const backgroundColor = getAgentColor(agent.agentType); // Handle Esc to go back - useKeybinding('confirm:no', onBack, { context: 'Confirmation' }) + useKeybinding('confirm:no', onBack, { context: 'Confirmation' }); // Handle Enter to go back const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'return') { - e.preventDefault() - onBack() + e.preventDefault(); + onBack(); } - } + }; function renderToolsList(): React.ReactNode { if (resolvedTools.hasWildcard) { - return All tools + return All tools; } if (!agent.tools || agent.tools.length === 0) { - return None + return None; } return ( <> - {resolvedTools.validTools.length > 0 && ( - {resolvedTools.validTools.join(', ')} - )} + {resolvedTools.validTools.length > 0 && {resolvedTools.validTools.join(', ')}} {resolvedTools.invalidTools.length > 0 && ( - {figures.warning} Unrecognized:{' '} - {resolvedTools.invalidTools.join(', ')} + {figures.warning} Unrecognized: {resolvedTools.invalidTools.join(', ')} )} - ) + ); } return ( - + {filePath} @@ -113,9 +101,7 @@ export function AgentDetail({ agent, tools, onBack }: Props): React.ReactNode { {agent.skills && agent.skills.length > 0 && ( Skills:{' '} - {agent.skills.length > 10 - ? `${agent.skills.length} skills` - : agent.skills.join(', ')} + {agent.skills.length > 10 ? `${agent.skills.length} skills` : agent.skills.join(', ')} )} @@ -144,5 +130,5 @@ export function AgentDetail({ agent, tools, onBack }: Props): React.ReactNode { )} - ) + ); } diff --git a/src/components/agents/AgentEditor.tsx b/src/components/agents/AgentEditor.tsx index e5c7b1847..81fdb9b71 100644 --- a/src/components/agents/AgentEditor.tsx +++ b/src/components/agents/AgentEditor.tsx @@ -1,88 +1,78 @@ -import chalk from 'chalk' -import figures from 'figures' -import * as React from 'react' -import { useCallback, useMemo, useState } from 'react' -import { useSetAppState } from 'src/state/AppState.js' -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' -import { Box, Text } from '../../ink.js' -import { useKeybinding } from '../../keybindings/useKeybinding.js' -import type { Tools } from '../../Tool.js' -import { - type AgentColorName, - setAgentColor, -} from '../../tools/AgentTool/agentColorManager.js' +import chalk from 'chalk'; +import figures from 'figures'; +import * as React from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import { useSetAppState } from 'src/state/AppState.js'; +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; +import { Box, Text } from '../../ink.js'; +import { useKeybinding } from '../../keybindings/useKeybinding.js'; +import type { Tools } from '../../Tool.js'; +import { type AgentColorName, setAgentColor } from '../../tools/AgentTool/agentColorManager.js'; import { type AgentDefinition, getActiveAgentsFromList, isCustomAgent, isPluginAgent, -} from '../../tools/AgentTool/loadAgentsDir.js' -import { editFileInEditor } from '../../utils/promptEditor.js' -import { getActualAgentFilePath, updateAgentFile } from './agentFileUtils.js' -import { ColorPicker } from './ColorPicker.js' -import { ModelSelector } from './ModelSelector.js' -import { ToolSelector } from './ToolSelector.js' -import { getAgentSourceDisplayName } from './utils.js' +} from '../../tools/AgentTool/loadAgentsDir.js'; +import { editFileInEditor } from '../../utils/promptEditor.js'; +import { getActualAgentFilePath, updateAgentFile } from './agentFileUtils.js'; +import { ColorPicker } from './ColorPicker.js'; +import { ModelSelector } from './ModelSelector.js'; +import { ToolSelector } from './ToolSelector.js'; +import { getAgentSourceDisplayName } from './utils.js'; type Props = { - agent: AgentDefinition - tools: Tools - onSaved: (message: string) => void - onBack: () => void -} + agent: AgentDefinition; + tools: Tools; + onSaved: (message: string) => void; + onBack: () => void; +}; -type EditMode = 'menu' | 'edit-tools' | 'edit-color' | 'edit-model' +type EditMode = 'menu' | 'edit-tools' | 'edit-color' | 'edit-model'; type SaveChanges = { - tools?: string[] - color?: AgentColorName - model?: string -} - -export function AgentEditor({ - agent, - tools, - onSaved, - onBack, -}: Props): React.ReactNode { - const setAppState = useSetAppState() - const [editMode, setEditMode] = useState('menu') - const [selectedMenuIndex, setSelectedMenuIndex] = useState(0) - const [error, setError] = useState(null) - const [selectedColor, setSelectedColor] = useState< - AgentColorName | undefined - >(agent.color as AgentColorName | undefined) + tools?: string[]; + color?: AgentColorName; + model?: string; +}; + +export function AgentEditor({ agent, tools, onSaved, onBack }: Props): React.ReactNode { + const setAppState = useSetAppState(); + const [editMode, setEditMode] = useState('menu'); + const [selectedMenuIndex, setSelectedMenuIndex] = useState(0); + const [error, setError] = useState(null); + const [selectedColor, setSelectedColor] = useState( + agent.color as AgentColorName | undefined, + ); const handleOpenInEditor = useCallback(async () => { - const filePath = getActualAgentFilePath(agent) - const result = await editFileInEditor(filePath) + const filePath = getActualAgentFilePath(agent); + const result = await editFileInEditor(filePath); if (result.error) { - setError(result.error) + setError(result.error); } else { - onSaved( - `Opened ${agent.agentType} in editor. If you made edits, restart to load the latest version.`, - ) + onSaved(`Opened ${agent.agentType} in editor. If you made edits, restart to load the latest version.`); } - }, [agent, onSaved]) + }, [agent, onSaved]); const handleSave = useCallback( async (changes: SaveChanges = {}) => { - const { tools: newTools, color: newColor, model: newModel } = changes - const finalColor = newColor ?? selectedColor - const hasToolsChanged = newTools !== undefined - const hasModelChanged = newModel !== undefined - const hasColorChanged = finalColor !== agent.color + const { tools: newTools, color: newColor, model: newModel } = changes; + const finalColor = newColor ?? selectedColor; + const hasToolsChanged = newTools !== undefined; + const hasModelChanged = newModel !== undefined; + const hasColorChanged = finalColor !== agent.color; if (!hasToolsChanged && !hasModelChanged && !hasColorChanged) { - return false + return false; } try { // Only custom/plugin agents can be edited // this is for type safety; the UI shouldn't allow editing otherwise if (!isCustomAgent(agent) && !isPluginAgent(agent)) { - return false + return false; } await updateAgentFile( @@ -92,10 +82,10 @@ export function AgentEditor({ agent.getSystemPrompt(), finalColor, newModel ?? agent.model, - ) + ); if (hasColorChanged && finalColor) { - setAgentColor(agent.agentType, finalColor) + setAgentColor(agent.agentType, finalColor); } setAppState(state => { @@ -108,7 +98,7 @@ export function AgentEditor({ model: newModel ?? a.model, } : a, - ) + ); return { ...state, agentDefinitions: { @@ -116,18 +106,18 @@ export function AgentEditor({ activeAgents: getActiveAgentsFromList(allAgents), allAgents, }, - } - }) + }; + }); - onSaved(`Updated agent: ${chalk.bold(agent.agentType)}`) - return true + onSaved(`Updated agent: ${chalk.bold(agent.agentType)}`); + return true; } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to save agent') - return false + setError(err instanceof Error ? err.message : 'Failed to save agent'); + return false; } }, [agent, selectedColor, onSaved, setAppState], - ) + ); const menuItems = useMemo( () => [ @@ -137,53 +127,45 @@ export function AgentEditor({ { label: 'Edit color', action: () => setEditMode('edit-color') }, ], [handleOpenInEditor], - ) + ); const handleEscape = useCallback(() => { - setError(null) + setError(null); if (editMode === 'menu') { - onBack() + onBack(); } else { - setEditMode('menu') + setEditMode('menu'); } - }, [editMode, onBack]) + }, [editMode, onBack]); const handleMenuKeyDown = useCallback( (e: KeyboardEvent) => { if (e.key === 'up') { - e.preventDefault() - setSelectedMenuIndex(index => Math.max(0, index - 1)) + e.preventDefault(); + setSelectedMenuIndex(index => Math.max(0, index - 1)); } else if (e.key === 'down') { - e.preventDefault() - setSelectedMenuIndex(index => Math.min(menuItems.length - 1, index + 1)) + e.preventDefault(); + setSelectedMenuIndex(index => Math.min(menuItems.length - 1, index + 1)); } else if (e.key === 'return') { - e.preventDefault() - const selectedItem = menuItems[selectedMenuIndex] + e.preventDefault(); + const selectedItem = menuItems[selectedMenuIndex]; if (selectedItem) { - void selectedItem.action() + void selectedItem.action(); } } }, [menuItems, selectedMenuIndex], - ) + ); - useKeybinding('confirm:no', handleEscape, { context: 'Confirmation' }) + useKeybinding('confirm:no', handleEscape, { context: 'Confirmation' }); const renderMenu = (): React.ReactNode => ( - + Source: {getAgentSourceDisplayName(agent.source)} {menuItems.map((item, index) => ( - + {index === selectedMenuIndex ? `${figures.pointer} ` : ' '} {item.label} @@ -196,11 +178,11 @@ export function AgentEditor({ )} - ) + ); switch (editMode) { case 'menu': - return renderMenu() + return renderMenu(); case 'edit-tools': return ( @@ -208,39 +190,37 @@ export function AgentEditor({ tools={tools} initialTools={agent.tools} onComplete={async finalTools => { - setEditMode('menu') - await handleSave({ tools: finalTools }) + setEditMode('menu'); + await handleSave({ tools: finalTools }); }} /> - ) + ); case 'edit-color': return ( { - setSelectedColor(color) - setEditMode('menu') - await handleSave({ color }) + setSelectedColor(color); + setEditMode('menu'); + await handleSave({ color }); }} /> - ) + ); case 'edit-model': return ( { - setEditMode('menu') - await handleSave({ model }) + setEditMode('menu'); + await handleSave({ model }); }} /> - ) + ); default: - return null + return null; } } diff --git a/src/components/agents/AgentNavigationFooter.tsx b/src/components/agents/AgentNavigationFooter.tsx index 9c4fa9f76..bd65bd196 100644 --- a/src/components/agents/AgentNavigationFooter.tsx +++ b/src/components/agents/AgentNavigationFooter.tsx @@ -1,23 +1,19 @@ -import * as React from 'react' -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' -import { Box, Text } from '../../ink.js' +import * as React from 'react'; +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; +import { Box, Text } from '../../ink.js'; type Props = { - instructions?: string -} + instructions?: string; +}; export function AgentNavigationFooter({ instructions = 'Press ↑↓ to navigate · Enter to select · Esc to go back', }: Props): React.ReactNode { - const exitState = useExitOnCtrlCDWithKeybindings() + const exitState = useExitOnCtrlCDWithKeybindings(); return ( - - {exitState.pending - ? `Press ${exitState.keyName} again to exit` - : instructions} - + {exitState.pending ? `Press ${exitState.keyName} again to exit` : instructions} - ) + ); } diff --git a/src/components/agents/AgentsList.tsx b/src/components/agents/AgentsList.tsx index 6eadf1ef7..d89e9b869 100644 --- a/src/components/agents/AgentsList.tsx +++ b/src/components/agents/AgentsList.tsx @@ -1,54 +1,43 @@ -import figures from 'figures' -import * as React from 'react' -import type { SettingSource } from 'src/utils/settings/constants.js' -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' -import { Box, Text } from '../../ink.js' -import type { ResolvedAgent } from '../../tools/AgentTool/agentDisplay.js' +import figures from 'figures'; +import * as React from 'react'; +import type { SettingSource } from 'src/utils/settings/constants.js'; +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; +import { Box, Text } from '../../ink.js'; +import type { ResolvedAgent } from '../../tools/AgentTool/agentDisplay.js'; import { AGENT_SOURCE_GROUPS, compareAgentsByName, getOverrideSourceLabel, resolveAgentModelDisplay, -} from '../../tools/AgentTool/agentDisplay.js' -import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js' -import { count } from '../../utils/array.js' -import { Dialog } from '../design-system/Dialog.js' -import { Divider } from '../design-system/Divider.js' -import { getAgentSourceDisplayName } from './utils.js' +} from '../../tools/AgentTool/agentDisplay.js'; +import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js'; +import { count } from '../../utils/array.js'; +import { Dialog } from '../design-system/Dialog.js'; +import { Divider } from '../design-system/Divider.js'; +import { getAgentSourceDisplayName } from './utils.js'; type Props = { - source: SettingSource | 'all' | 'built-in' | 'plugin' - agents: ResolvedAgent[] - onBack: () => void - onSelect: (agent: AgentDefinition) => void - onCreateNew?: () => void - changes?: string[] -} + source: SettingSource | 'all' | 'built-in' | 'plugin'; + agents: ResolvedAgent[]; + onBack: () => void; + onSelect: (agent: AgentDefinition) => void; + onCreateNew?: () => void; + changes?: string[]; +}; -export function AgentsList({ - source, - agents, - onBack, - onSelect, - onCreateNew, - changes, -}: Props): React.ReactNode { - const [selectedAgent, setSelectedAgent] = - React.useState(null) - const [isCreateNewSelected, setIsCreateNewSelected] = React.useState(true) +export function AgentsList({ source, agents, onBack, onSelect, onCreateNew, changes }: Props): React.ReactNode { + const [selectedAgent, setSelectedAgent] = React.useState(null); + const [isCreateNewSelected, setIsCreateNewSelected] = React.useState(true); // Sort agents alphabetically by name within each source group - const sortedAgents = React.useMemo( - () => [...agents].sort(compareAgentsByName), - [agents], - ) + const sortedAgents = React.useMemo(() => [...agents].sort(compareAgentsByName), [agents]); const getOverrideInfo = (agent: ResolvedAgent) => { return { isOverridden: !!agent.overriddenBy, overriddenBy: agent.overriddenBy || null, - } - } + }; + }; const renderCreateNewOption = () => { return ( @@ -56,26 +45,24 @@ export function AgentsList({ {isCreateNewSelected ? `${figures.pointer} ` : ' '} - - Create new agent - + Create new agent - ) - } + ); + }; const renderAgent = (agent: ResolvedAgent) => { - const isBuiltIn = agent.source === 'built-in' + const isBuiltIn = agent.source === 'built-in'; const isSelected = !isBuiltIn && !isCreateNewSelected && selectedAgent?.agentType === agent.agentType && - selectedAgent?.source === agent.source + selectedAgent?.source === agent.source; - const { isOverridden, overriddenBy } = getOverrideInfo(agent) - const dimmed = isBuiltIn || isOverridden - const textColor = !isBuiltIn && isSelected ? 'suggestion' : undefined + const { isOverridden, overriddenBy } = getOverrideInfo(agent); + const dimmed = isBuiltIn || isOverridden; + const textColor = !isBuiltIn && isSelected ? 'suggestion' : undefined; - const resolvedModel = resolveAgentModelDisplay(agent) + const resolvedModel = resolveAgentModelDisplay(agent); return ( @@ -98,75 +85,64 @@ export function AgentsList({ )} {overriddenBy && ( - + {' '} {figures.warning} shadowed by {getOverrideSourceLabel(overriddenBy)} )} - ) - } + ); + }; const selectableAgentsInOrder = React.useMemo(() => { - const nonBuiltIn = sortedAgents.filter(a => a.source !== 'built-in') + const nonBuiltIn = sortedAgents.filter(a => a.source !== 'built-in'); if (source === 'all') { - return AGENT_SOURCE_GROUPS.filter(g => g.source !== 'built-in').flatMap( - ({ source: groupSource }) => - nonBuiltIn.filter(a => a.source === groupSource), - ) + return AGENT_SOURCE_GROUPS.filter(g => g.source !== 'built-in').flatMap(({ source: groupSource }) => + nonBuiltIn.filter(a => a.source === groupSource), + ); } - return nonBuiltIn - }, [sortedAgents, source]) + return nonBuiltIn; + }, [sortedAgents, source]); // Set initial selection React.useEffect(() => { - if ( - !selectedAgent && - !isCreateNewSelected && - selectableAgentsInOrder.length > 0 - ) { + if (!selectedAgent && !isCreateNewSelected && selectableAgentsInOrder.length > 0) { if (onCreateNew) { - setIsCreateNewSelected(true) + setIsCreateNewSelected(true); } else { - setSelectedAgent(selectableAgentsInOrder[0] || null) + setSelectedAgent(selectableAgentsInOrder[0] || null); } } - }, [selectableAgentsInOrder, selectedAgent, isCreateNewSelected, onCreateNew]) + }, [selectableAgentsInOrder, selectedAgent, isCreateNewSelected, onCreateNew]); const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'return') { - e.preventDefault() + e.preventDefault(); if (isCreateNewSelected && onCreateNew) { - onCreateNew() + onCreateNew(); } else if (selectedAgent) { - onSelect(selectedAgent) + onSelect(selectedAgent); } - return + return; } - if (e.key !== 'up' && e.key !== 'down') return - e.preventDefault() + if (e.key !== 'up' && e.key !== 'down') return; + e.preventDefault(); // Handle navigation with "Create New Agent" option - const hasCreateOption = !!onCreateNew - const totalItems = - selectableAgentsInOrder.length + (hasCreateOption ? 1 : 0) + const hasCreateOption = !!onCreateNew; + const totalItems = selectableAgentsInOrder.length + (hasCreateOption ? 1 : 0); - if (totalItems === 0) return + if (totalItems === 0) return; // Calculate current position in list (0 = create new, 1+ = agents) - let currentPosition = 0 + let currentPosition = 0; if (!isCreateNewSelected && selectedAgent) { const agentIndex = selectableAgentsInOrder.findIndex( - a => - a.agentType === selectedAgent.agentType && - a.source === selectedAgent.source, - ) + a => a.agentType === selectedAgent.agentType && a.source === selectedAgent.source, + ); if (agentIndex >= 0) { - currentPosition = hasCreateOption ? agentIndex + 1 : agentIndex + currentPosition = hasCreateOption ? agentIndex + 1 : agentIndex; } } @@ -178,26 +154,24 @@ export function AgentsList({ : currentPosition - 1 : currentPosition === totalItems - 1 ? 0 - : currentPosition + 1 + : currentPosition + 1; // Update selection based on new position if (hasCreateOption && newPosition === 0) { - setIsCreateNewSelected(true) - setSelectedAgent(null) + setIsCreateNewSelected(true); + setSelectedAgent(null); } else { - const agentIndex = hasCreateOption ? newPosition - 1 : newPosition - const newAgent = selectableAgentsInOrder[agentIndex] + const agentIndex = hasCreateOption ? newPosition - 1 : newPosition; + const newAgent = selectableAgentsInOrder[agentIndex]; if (newAgent) { - setIsCreateNewSelected(false) - setSelectedAgent(newAgent) + setIsCreateNewSelected(false); + setSelectedAgent(newAgent); } } - } + }; - const renderBuiltInAgentsSection = ( - title = 'Built-in (always available):', - ) => { - const builtInAgents = sortedAgents.filter(a => a.source === 'built-in') + const renderBuiltInAgentsSection = (title = 'Built-in (always available):') => { + const builtInAgents = sortedAgents.filter(a => a.source === 'built-in'); return ( @@ -205,13 +179,13 @@ export function AgentsList({ {builtInAgents.map(renderAgent)} - ) - } + ); + }; const renderAgentGroup = (title: string, groupAgents: ResolvedAgent[]) => { - if (!groupAgents.length) return null + if (!groupAgents.length) return null; - const folderPath = groupAgents[0]?.baseDir + const folderPath = groupAgents[0]?.baseDir; return ( @@ -223,55 +197,35 @@ export function AgentsList({ {groupAgents.map(agent => renderAgent(agent))} - ) - } + ); + }; - const sourceTitle = getAgentSourceDisplayName(source) + const sourceTitle = getAgentSourceDisplayName(source); - const builtInAgents = sortedAgents.filter(a => a.source === 'built-in') + const builtInAgents = sortedAgents.filter(a => a.source === 'built-in'); const hasNoAgents = - !sortedAgents.length || - (source !== 'built-in' && !sortedAgents.some(a => a.source !== 'built-in')) + !sortedAgents.length || (source !== 'built-in' && !sortedAgents.some(a => a.source !== 'built-in')); if (hasNoAgents) { return ( - - + + {onCreateNew && {renderCreateNewOption()}} + No agents found. Create specialized subagents that Claude can delegate to. + Each subagent has its own context window, custom system prompt, and specific tools. - No agents found. Create specialized subagents that Claude can - delegate to. + Try creating: Code Reviewer, Code Simplifier, Security Reviewer, Tech Lead, or UX Reviewer. - - Each subagent has its own context window, custom system prompt, and - specific tools. - - - Try creating: Code Reviewer, Code Simplifier, Security Reviewer, - Tech Lead, or UX Reviewer. - - {source !== 'built-in' && - sortedAgents.some(a => a.source === 'built-in') && ( - <> - - {renderBuiltInAgentsSection()} - - )} + {source !== 'built-in' && sortedAgents.some(a => a.source === 'built-in') && ( + <> + + {renderBuiltInAgentsSection()} + + )} - ) + ); } return ( @@ -286,25 +240,18 @@ export function AgentsList({ {changes[changes.length - 1]} )} - + {onCreateNew && {renderCreateNewOption()}} {source === 'all' ? ( <> - {AGENT_SOURCE_GROUPS.filter(g => g.source !== 'built-in').map( - ({ label, source: groupSource }) => ( - - {renderAgentGroup( - label, - sortedAgents.filter(a => a.source === groupSource), - )} - - ), - )} + {AGENT_SOURCE_GROUPS.filter(g => g.source !== 'built-in').map(({ label, source: groupSource }) => ( + + {renderAgentGroup( + label, + sortedAgents.filter(a => a.source === groupSource), + )} + + ))} {builtInAgents.length > 0 && ( @@ -325,9 +272,7 @@ export function AgentsList({ ) : ( <> - {sortedAgents - .filter(a => a.source !== 'built-in') - .map(agent => renderAgent(agent))} + {sortedAgents.filter(a => a.source !== 'built-in').map(agent => renderAgent(agent))} {sortedAgents.some(a => a.source === 'built-in') && ( <> @@ -338,5 +283,5 @@ export function AgentsList({ )} - ) + ); } diff --git a/src/components/agents/AgentsMenu.tsx b/src/components/agents/AgentsMenu.tsx index 91de932b4..d8c0c7727 100644 --- a/src/components/agents/AgentsMenu.tsx +++ b/src/components/agents/AgentsMenu.tsx @@ -1,62 +1,50 @@ -import chalk from 'chalk' -import * as React from 'react' -import { useCallback, useMemo, useState } from 'react' -import type { SettingSource } from 'src/utils/settings/constants.js' -import type { CommandResultDisplay } from '../../commands.js' -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' -import { useMergedTools } from '../../hooks/useMergedTools.js' -import { Box, Text } from '../../ink.js' -import { useAppState, useSetAppState } from '../../state/AppState.js' -import type { Tools } from '../../Tool.js' -import { - type ResolvedAgent, - resolveAgentOverrides, -} from '../../tools/AgentTool/agentDisplay.js' -import { - type AgentDefinition, - getActiveAgentsFromList, -} from '../../tools/AgentTool/loadAgentsDir.js' -import { toError } from '../../utils/errors.js' -import { logError } from '../../utils/log.js' -import { Select } from '../CustomSelect/select.js' -import { Dialog } from '../design-system/Dialog.js' -import { AgentDetail } from './AgentDetail.js' -import { AgentEditor } from './AgentEditor.js' -import { AgentNavigationFooter } from './AgentNavigationFooter.js' -import { AgentsList } from './AgentsList.js' -import { deleteAgentFromFile } from './agentFileUtils.js' -import { CreateAgentWizard } from './new-agent-creation/CreateAgentWizard.js' -import type { ModeState } from './types.js' +import chalk from 'chalk'; +import * as React from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import type { SettingSource } from 'src/utils/settings/constants.js'; +import type { CommandResultDisplay } from '../../commands.js'; +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; +import { useMergedTools } from '../../hooks/useMergedTools.js'; +import { Box, Text } from '../../ink.js'; +import { useAppState, useSetAppState } from '../../state/AppState.js'; +import type { Tools } from '../../Tool.js'; +import { type ResolvedAgent, resolveAgentOverrides } from '../../tools/AgentTool/agentDisplay.js'; +import { type AgentDefinition, getActiveAgentsFromList } from '../../tools/AgentTool/loadAgentsDir.js'; +import { toError } from '../../utils/errors.js'; +import { logError } from '../../utils/log.js'; +import { Select } from '../CustomSelect/select.js'; +import { Dialog } from '../design-system/Dialog.js'; +import { AgentDetail } from './AgentDetail.js'; +import { AgentEditor } from './AgentEditor.js'; +import { AgentNavigationFooter } from './AgentNavigationFooter.js'; +import { AgentsList } from './AgentsList.js'; +import { deleteAgentFromFile } from './agentFileUtils.js'; +import { CreateAgentWizard } from './new-agent-creation/CreateAgentWizard.js'; +import type { ModeState } from './types.js'; type Props = { - tools: Tools - onExit: ( - result?: string, - options?: { display?: CommandResultDisplay }, - ) => void -} + tools: Tools; + onExit: (result?: string, options?: { display?: CommandResultDisplay }) => void; +}; export function AgentsMenu({ tools, onExit }: Props): React.ReactNode { const [modeState, setModeState] = useState({ mode: 'list-agents', source: 'all', - }) - const agentDefinitions = useAppState(s => s.agentDefinitions) - const mcpTools = useAppState(s => s.mcp.tools) - const toolPermissionContext = useAppState(s => s.toolPermissionContext) - const setAppState = useSetAppState() - const { allAgents, activeAgents: agents } = agentDefinitions - const [changes, setChanges] = useState([]) + }); + const agentDefinitions = useAppState(s => s.agentDefinitions); + const mcpTools = useAppState(s => s.mcp.tools); + const toolPermissionContext = useAppState(s => s.toolPermissionContext); + const setAppState = useSetAppState(); + const { allAgents, activeAgents: agents } = agentDefinitions; + const [changes, setChanges] = useState([]); // Get MCP tools from app state and merge with local tools - const mergedTools = useMergedTools(tools, mcpTools, toolPermissionContext) + const mergedTools = useMergedTools(tools, mcpTools, toolPermissionContext); - useExitOnCtrlCDWithKeybindings() + useExitOnCtrlCDWithKeybindings(); - const agentsBySource: Record< - SettingSource | 'all' | 'built-in' | 'plugin', - AgentDefinition[] - > = useMemo( + const agentsBySource: Record = useMemo( () => ({ 'built-in': allAgents.filter(a => a.source === 'built-in'), userSettings: allAgents.filter(a => a.source === 'userSettings'), @@ -68,22 +56,21 @@ export function AgentsMenu({ tools, onExit }: Props): React.ReactNode { all: allAgents, }), [allAgents], - ) + ); const handleAgentCreated = useCallback((message: string) => { - setChanges(prev => [...prev, message]) - setModeState({ mode: 'list-agents', source: 'all' }) - }, []) + setChanges(prev => [...prev, message]); + setModeState({ mode: 'list-agents', source: 'all' }); + }, []); const handleAgentDeleted = useCallback( async (agent: AgentDefinition) => { try { - await deleteAgentFromFile(agent) + await deleteAgentFromFile(agent); setAppState(state => { const allAgents = state.agentDefinitions.allAgents.filter( - a => - !(a.agentType === agent.agentType && a.source === agent.source), - ) + a => !(a.agentType === agent.agentType && a.source === agent.source), + ); return { ...state, agentDefinitions: { @@ -91,21 +78,18 @@ export function AgentsMenu({ tools, onExit }: Props): React.ReactNode { allAgents, activeAgents: getActiveAgentsFromList(allAgents), }, - } - }) + }; + }); - setChanges(prev => [ - ...prev, - `Deleted agent: ${chalk.bold(agent.agentType)}`, - ]) + setChanges(prev => [...prev, `Deleted agent: ${chalk.bold(agent.agentType)}`]); // Go back to the agents list after deletion - setModeState({ mode: 'list-agents', source: 'all' }) + setModeState({ mode: 'list-agents', source: 'all' }); } catch (error) { - logError(toError(error)) + logError(toError(error)); } }, [setAppState], - ) + ); // Render based on mode switch (modeState.mode) { @@ -121,11 +105,11 @@ export function AgentsMenu({ tools, onExit }: Props): React.ReactNode { ...agentsBySource['flagSettings'], ...agentsBySource['plugin'], ] - : agentsBySource[modeState.source] + : agentsBySource[modeState.source]; // Resolve overrides and filter to the agents we want to show - const allResolved = resolveAgentOverrides(agentsToShow, agents) - const resolvedAgents: ResolvedAgent[] = allResolved + const allResolved = resolveAgentOverrides(agentsToShow, agents); + const resolvedAgents: ResolvedAgent[] = allResolved; return ( <> @@ -133,13 +117,10 @@ export function AgentsMenu({ tools, onExit }: Props): React.ReactNode { source={modeState.source} agents={resolvedAgents} onBack={() => { - const exitMessage = - changes.length > 0 - ? `Agent changes:\n${changes.join('\n')}` - : undefined + const exitMessage = changes.length > 0 ? `Agent changes:\n${changes.join('\n')}` : undefined; onExit(exitMessage ?? 'Agents dialog dismissed', { display: changes.length === 0 ? 'system' : undefined, - }) + }); }} onSelect={agent => setModeState({ @@ -153,7 +134,7 @@ export function AgentsMenu({ tools, onExit }: Props): React.ReactNode { /> - ) + ); } case 'create-agent': @@ -164,21 +145,17 @@ export function AgentsMenu({ tools, onExit }: Props): React.ReactNode { onComplete={handleAgentCreated} onCancel={() => setModeState({ mode: 'list-agents', source: 'all' })} /> - ) + ); case 'agent-menu': { // Always use fresh agent data const freshAgent = allAgents.find( - a => - a.agentType === modeState.agent.agentType && - a.source === modeState.agent.source, - ) - const agentToUse = freshAgent || modeState.agent + a => a.agentType === modeState.agent.agentType && a.source === modeState.agent.source, + ); + const agentToUse = freshAgent || modeState.agent; const isEditable = - agentToUse.source !== 'built-in' && - agentToUse.source !== 'plugin' && - agentToUse.source !== 'flagSettings' + agentToUse.source !== 'built-in' && agentToUse.source !== 'plugin' && agentToUse.source !== 'flagSettings'; const menuItems = [ { label: 'View agent', value: 'view' }, ...(isEditable @@ -188,7 +165,7 @@ export function AgentsMenu({ tools, onExit }: Props): React.ReactNode { ] : []), { label: 'Back', value: 'back' }, - ] + ]; const handleMenuSelect = (value: string): void => { switch (value) { @@ -197,27 +174,27 @@ export function AgentsMenu({ tools, onExit }: Props): React.ReactNode { mode: 'view-agent', agent: agentToUse, previousMode: modeState.previousMode, - }) - break + }); + break; case 'edit': setModeState({ mode: 'edit-agent', agent: agentToUse, previousMode: modeState, - }) - break + }); + break; case 'delete': setModeState({ mode: 'delete-confirm', agent: agentToUse, previousMode: modeState, - }) - break + }); + break; case 'back': - setModeState(modeState.previousMode) - break + setModeState(modeState.previousMode); + break; } - } + }; return ( <> @@ -241,17 +218,15 @@ export function AgentsMenu({ tools, onExit }: Props): React.ReactNode { - ) + ); } case 'view-agent': { // Always use fresh agent data from allAgents const freshAgent = allAgents.find( - a => - a.agentType === modeState.agent.agentType && - a.source === modeState.agent.source, - ) - const agentToDisplay = freshAgent || modeState.agent + a => a.agentType === modeState.agent.agentType && a.source === modeState.agent.source, + ); + const agentToDisplay = freshAgent || modeState.agent; return ( <> @@ -281,28 +256,26 @@ export function AgentsMenu({ tools, onExit }: Props): React.ReactNode { - ) + ); } case 'delete-confirm': { const deleteOptions = [ { label: 'Yes, delete', value: 'yes' }, { label: 'No, cancel', value: 'no' }, - ] + ]; return ( <> { - if ('previousMode' in modeState) - setModeState(modeState.previousMode) + if ('previousMode' in modeState) setModeState(modeState.previousMode); }} color="error" > - Are you sure you want to delete the agent{' '} - {modeState.agent.agentType}? + Are you sure you want to delete the agent {modeState.agent.agentType}? Source: {modeState.agent.source} @@ -312,16 +285,16 @@ export function AgentsMenu({ tools, onExit }: Props): React.ReactNode { options={deleteOptions} onChange={(value: string) => { if (value === 'yes') { - void handleAgentDeleted(modeState.agent) + void handleAgentDeleted(modeState.agent); } else { if ('previousMode' in modeState) { - setModeState(modeState.previousMode) + setModeState(modeState.previousMode); } } }} onCancel={() => { if ('previousMode' in modeState) { - setModeState(modeState.previousMode) + setModeState(modeState.previousMode); } }} /> @@ -329,17 +302,15 @@ export function AgentsMenu({ tools, onExit }: Props): React.ReactNode { - ) + ); } case 'edit-agent': { // Always use fresh agent data const freshAgent = allAgents.find( - a => - a.agentType === modeState.agent.agentType && - a.source === modeState.agent.source, - ) - const agentToEdit = freshAgent || modeState.agent + a => a.agentType === modeState.agent.agentType && a.source === modeState.agent.source, + ); + const agentToEdit = freshAgent || modeState.agent; return ( <> @@ -352,18 +323,18 @@ export function AgentsMenu({ tools, onExit }: Props): React.ReactNode { agent={agentToEdit} tools={mergedTools} onSaved={message => { - handleAgentCreated(message) - setModeState(modeState.previousMode) + handleAgentCreated(message); + setModeState(modeState.previousMode); }} onBack={() => setModeState(modeState.previousMode)} /> - ) + ); } default: - return null + return null; } } diff --git a/src/components/agents/ColorPicker.tsx b/src/components/agents/ColorPicker.tsx index 8549424cd..73ef2cbda 100644 --- a/src/components/agents/ColorPicker.tsx +++ b/src/components/agents/ColorPicker.tsx @@ -1,85 +1,70 @@ -import figures from 'figures' -import React, { useState } from 'react' -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' -import { Box, Text } from '../../ink.js' +import figures from 'figures'; +import React, { useState } from 'react'; +import type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; +import { Box, Text } from '../../ink.js'; import { AGENT_COLOR_TO_THEME_COLOR, AGENT_COLORS, type AgentColorName, -} from '../../tools/AgentTool/agentColorManager.js' -import { capitalize } from '../../utils/stringUtils.js' +} from '../../tools/AgentTool/agentColorManager.js'; +import { capitalize } from '../../utils/stringUtils.js'; -type ColorOption = AgentColorName | 'automatic' +type ColorOption = AgentColorName | 'automatic'; -const COLOR_OPTIONS: ColorOption[] = ['automatic', ...AGENT_COLORS] +const COLOR_OPTIONS: ColorOption[] = ['automatic', ...AGENT_COLORS]; type Props = { - agentName: string - currentColor?: AgentColorName | 'automatic' - onConfirm: (color: AgentColorName | undefined) => void -} + agentName: string; + currentColor?: AgentColorName | 'automatic'; + onConfirm: (color: AgentColorName | undefined) => void; +}; -export function ColorPicker({ - agentName, - currentColor = 'automatic', - onConfirm, -}: Props): React.ReactNode { +export function ColorPicker({ agentName, currentColor = 'automatic', onConfirm }: Props): React.ReactNode { const [selectedIndex, setSelectedIndex] = useState( Math.max( 0, COLOR_OPTIONS.findIndex(opt => opt === currentColor), ), - ) + ); const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'up') { - e.preventDefault() - setSelectedIndex(prev => (prev > 0 ? prev - 1 : COLOR_OPTIONS.length - 1)) + e.preventDefault(); + setSelectedIndex(prev => (prev > 0 ? prev - 1 : COLOR_OPTIONS.length - 1)); } else if (e.key === 'down') { - e.preventDefault() - setSelectedIndex(prev => (prev < COLOR_OPTIONS.length - 1 ? prev + 1 : 0)) + e.preventDefault(); + setSelectedIndex(prev => (prev < COLOR_OPTIONS.length - 1 ? prev + 1 : 0)); } else if (e.key === 'return') { - e.preventDefault() - const selected = COLOR_OPTIONS[selectedIndex] - onConfirm(selected === 'automatic' ? undefined : selected) + e.preventDefault(); + const selected = COLOR_OPTIONS[selectedIndex]; + onConfirm(selected === 'automatic' ? undefined : selected); } - } + }; - const selectedValue = COLOR_OPTIONS[selectedIndex] + const selectedValue = COLOR_OPTIONS[selectedIndex]; return ( - + {COLOR_OPTIONS.map((option, index) => { - const isSelected = index === selectedIndex + const isSelected = index === selectedIndex; return ( - - {isSelected ? figures.pointer : ' '} - + {isSelected ? figures.pointer : ' '} {option === 'automatic' ? ( Automatic color ) : ( - + {' '} {capitalize(option)} )} - ) + ); })} @@ -91,16 +76,12 @@ export function ColorPicker({ @{agentName}{' '} ) : ( - + {' '} @{agentName}{' '} )} - ) + ); } diff --git a/src/components/agents/ModelSelector.tsx b/src/components/agents/ModelSelector.tsx index 4f1b2e8af..c616e3ae5 100644 --- a/src/components/agents/ModelSelector.tsx +++ b/src/components/agents/ModelSelector.tsx @@ -1,21 +1,17 @@ -import * as React from 'react' -import { Box, Text } from '../../ink.js' -import { getAgentModelOptions } from '../../utils/model/agent.js' -import { Select } from '../CustomSelect/select.js' +import * as React from 'react'; +import { Box, Text } from '../../ink.js'; +import { getAgentModelOptions } from '../../utils/model/agent.js'; +import { Select } from '../CustomSelect/select.js'; interface ModelSelectorProps { - initialModel?: string - onComplete: (model?: string) => void - onCancel?: () => void + initialModel?: string; + onComplete: (model?: string) => void; + onCancel?: () => void; } -export function ModelSelector({ - initialModel, - onComplete, - onCancel, -}: ModelSelectorProps): React.ReactNode { +export function ModelSelector({ initialModel, onComplete, onCancel }: ModelSelectorProps): React.ReactNode { const modelOptions = React.useMemo(() => { - const base = getAgentModelOptions() + const base = getAgentModelOptions(); // If the agent's current model is a full ID (e.g. 'claude-opus-4-5') not // in the alias list, inject it as an option so it can round-trip through // confirm without being overwritten. @@ -27,19 +23,17 @@ export function ModelSelector({ description: 'Current model (custom ID)', }, ...base, - ] + ]; } - return base - }, [initialModel]) + return base; + }, [initialModel]); - const defaultModel = initialModel ?? 'sonnet' + const defaultModel = initialModel ?? 'sonnet'; return ( - - Model determines the agent's reasoning capabilities and speed. - + Model determines the agent's reasoning capabilities and speed. + { - onRespond(value) + onRespond(value); }} /> - ) + ); } diff --git a/src/components/hooks/SelectEventMode.tsx b/src/components/hooks/SelectEventMode.tsx index a18d01952..822514b3f 100644 --- a/src/components/hooks/SelectEventMode.tsx +++ b/src/components/hooks/SelectEventMode.tsx @@ -7,23 +7,23 @@ * edit settings.json directly or ask Claude. */ -import figures from 'figures' -import * as React from 'react' -import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' -import type { HookEventMetadata } from 'src/utils/hooks/hooksConfigManager.js' -import { Box, Link, Text } from '../../ink.js' -import { plural } from '../../utils/stringUtils.js' -import { Select } from '../CustomSelect/select.js' -import { Dialog } from '../design-system/Dialog.js' +import figures from 'figures'; +import * as React from 'react'; +import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js'; +import type { HookEventMetadata } from 'src/utils/hooks/hooksConfigManager.js'; +import { Box, Link, Text } from '../../ink.js'; +import { plural } from '../../utils/stringUtils.js'; +import { Select } from '../CustomSelect/select.js'; +import { Dialog } from '../design-system/Dialog.js'; type Props = { - hookEventMetadata: Record - hooksByEvent: Partial> - totalHooksCount: number - restrictedByPolicy: boolean - onSelectEvent: (event: HookEvent) => void - onCancel: () => void -} + hookEventMetadata: Record; + hooksByEvent: Partial>; + totalHooksCount: number; + restrictedByPolicy: boolean; + onSelectEvent: (event: HookEvent) => void; + onCancel: () => void; +}; export function SelectEventMode({ hookEventMetadata, @@ -33,28 +33,24 @@ export function SelectEventMode({ onSelectEvent, onCancel, }: Props): React.ReactNode { - const subtitle = `${totalHooksCount} ${plural(totalHooksCount, 'hook')} configured` + const subtitle = `${totalHooksCount} ${plural(totalHooksCount, 'hook')} configured`; return ( {restrictedByPolicy && ( - - {figures.info} Hooks Restricted by Policy - + {figures.info} Hooks Restricted by Policy - Only hooks from managed settings can run. User-defined hooks from - ~/.claude/settings.json, .claude/settings.json, and - .claude/settings.local.json are blocked. + Only hooks from managed settings can run. User-defined hooks from ~/.claude/settings.json, + .claude/settings.json, and .claude/settings.local.json are blocked. )} - {figures.info} This menu is read-only. To add or modify hooks, edit - settings.json directly or ask Claude.{' '} + {figures.info} This menu is read-only. To add or modify hooks, edit settings.json directly or ask Claude.{' '} Learn more @@ -62,29 +58,27 @@ export function SelectEventMode({ ({ @@ -74,15 +68,15 @@ export function SelectHookMode({ : hookSourceHeaderDisplayString(hook.source), }))} onChange={value => { - const index = parseInt(value, 10) - const hook = hooksForSelectedMatcher[index] + const index = parseInt(value, 10); + const hook = hooksForSelectedMatcher[index]; if (hook) { - onSelect(hook) + onSelect(hook); } }} onCancel={onCancel} /> - ) + ); } diff --git a/src/components/hooks/SelectMatcherMode.tsx b/src/components/hooks/SelectMatcherMode.tsx index 6792a47b1..c73e664be 100644 --- a/src/components/hooks/SelectMatcherMode.tsx +++ b/src/components/hooks/SelectMatcherMode.tsx @@ -4,35 +4,32 @@ * The /hooks menu is read-only: this view no longer offers "add new matcher" * and simply lets the user drill into each matcher to see its hooks. */ -import * as React from 'react' -import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' -import { Box, Text } from '../../ink.js' +import * as React from 'react'; +import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js'; +import { Box, Text } from '../../ink.js'; import { type HookSource, hookSourceInlineDisplayString, type IndividualHookConfig, -} from '../../utils/hooks/hooksSettings.js' -import { plural } from '../../utils/stringUtils.js' -import { Select } from '../CustomSelect/select.js' -import { Dialog } from '../design-system/Dialog.js' +} from '../../utils/hooks/hooksSettings.js'; +import { plural } from '../../utils/stringUtils.js'; +import { Select } from '../CustomSelect/select.js'; +import { Dialog } from '../design-system/Dialog.js'; type MatcherWithSource = { - matcher: string - sources: HookSource[] - hookCount: number -} + matcher: string; + sources: HookSource[]; + hookCount: number; +}; type Props = { - selectedEvent: HookEvent - matchersForSelectedEvent: string[] - hooksByEventAndMatcher: Record< - HookEvent, - Record - > - eventDescription: string - onSelect: (matcher: string) => void - onCancel: () => void -} + selectedEvent: HookEvent; + matchersForSelectedEvent: string[]; + hooksByEventAndMatcher: Record>; + eventDescription: string; + onSelect: (matcher: string) => void; + onCancel: () => void; +}; export function SelectMatcherMode({ selectedEvent, @@ -45,15 +42,15 @@ export function SelectMatcherMode({ // Group matchers with their sources (already sorted by priority in parent) const matchersWithSources: MatcherWithSource[] = React.useMemo(() => { return matchersForSelectedEvent.map(matcher => { - const hooks = hooksByEventAndMatcher[selectedEvent]?.[matcher] || [] - const sources = Array.from(new Set(hooks.map(h => h.source))) + const hooks = hooksByEventAndMatcher[selectedEvent]?.[matcher] || []; + const sources = Array.from(new Set(hooks.map(h => h.source))); return { matcher, sources, hookCount: hooks.length, - } - }) - }, [matchersForSelectedEvent, hooksByEventAndMatcher, selectedEvent]) + }; + }); + }, [matchersForSelectedEvent, hooksByEventAndMatcher, selectedEvent]); if (matchersForSelectedEvent.length === 0) { return ( @@ -65,39 +62,31 @@ export function SelectMatcherMode({ > 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 index = parseInt(value) - const tool = serverTools[index] + const index = parseInt(value); + const tool = serverTools[index]; if (tool) { - onSelectTool(tool, index) + onSelectTool(tool, index); } }} onCancel={onBack} /> )} - ) + ); } diff --git a/src/components/mcp/McpParsingWarnings.tsx b/src/components/mcp/McpParsingWarnings.tsx index 49e8353b9..512130e34 100644 --- a/src/components/mcp/McpParsingWarnings.tsx +++ b/src/components/mcp/McpParsingWarnings.tsx @@ -1,36 +1,31 @@ -import React, { useMemo } from 'react' -import { getMcpConfigsByScope } from 'src/services/mcp/config.js' -import type { ConfigScope } from 'src/services/mcp/types.js' -import { - describeMcpConfigFilePath, - getScopeLabel, -} from 'src/services/mcp/utils.js' -import type { ValidationError } from 'src/utils/settings/validation.js' -import { Box, Link, Text } from '../../ink.js' +import React, { useMemo } from 'react'; +import { getMcpConfigsByScope } from 'src/services/mcp/config.js'; +import type { ConfigScope } from 'src/services/mcp/types.js'; +import { describeMcpConfigFilePath, getScopeLabel } from 'src/services/mcp/utils.js'; +import type { ValidationError } from 'src/utils/settings/validation.js'; +import { Box, Link, Text } from '../../ink.js'; function McpConfigErrorSection({ scope, parsingErrors, warnings, }: { - scope: ConfigScope - parsingErrors: ValidationError[] - warnings: ValidationError[] + scope: ConfigScope; + parsingErrors: ValidationError[]; + warnings: ValidationError[]; }): React.ReactNode { - const hasErrors = parsingErrors.length > 0 - const hasWarnings = warnings.length > 0 + const hasErrors = parsingErrors.length > 0; + const hasWarnings = warnings.length > 0; if (!hasErrors && !hasWarnings) { - return null + return null; } return ( {(hasErrors || hasWarnings) && ( - - [{hasErrors ? 'Failed to parse' : 'Contains warnings'}]{' '} - + [{hasErrors ? 'Failed to parse' : 'Contains warnings'}] )} {getScopeLabel(scope)} @@ -40,7 +35,7 @@ function McpConfigErrorSection({ {parsingErrors.map((error, i) => { - const serverName = error.mcpErrorMetadata?.serverName + const serverName = error.mcpErrorMetadata?.serverName; return ( @@ -54,10 +49,10 @@ function McpConfigErrorSection({ - ) + ); })} {warnings.map((warning, i) => { - const serverName = warning.mcpErrorMetadata?.serverName + const serverName = warning.mcpErrorMetadata?.serverName; return ( @@ -67,18 +62,16 @@ function McpConfigErrorSection({ {' '} {serverName && `[${serverName}] `} - {warning.path && warning.path !== '' - ? `${warning.path}: ` - : ''} + {warning.path && warning.path !== '' ? `${warning.path}: ` : ''} {warning.message} - ) + ); })} - ) + ); } export function McpParsingWarnings(): React.ReactNode { @@ -92,21 +85,17 @@ export function McpParsingWarnings(): React.ReactNode { { scope: 'local', config: getMcpConfigsByScope('local') }, { scope: 'enterprise', config: getMcpConfigsByScope('enterprise') }, ] satisfies Array<{ - scope: ConfigScope - config: { errors: ValidationError[] } + scope: ConfigScope; + config: { errors: ValidationError[] }; }>, [], - ) + ); - const hasParsingErrors = scopes.some( - ({ config }) => filterErrors(config.errors, 'fatal').length > 0, - ) - const hasWarnings = scopes.some( - ({ config }) => filterErrors(config.errors, 'warning').length > 0, - ) + const hasParsingErrors = scopes.some(({ config }) => filterErrors(config.errors, 'fatal').length > 0); + const hasWarnings = scopes.some(({ config }) => filterErrors(config.errors, 'warning').length > 0); if (!hasParsingErrors && !hasWarnings) { - return null + return null; } return ( @@ -115,9 +104,7 @@ export function McpParsingWarnings(): React.ReactNode { For help configuring MCP servers, see:{' '} - - https://code.claude.com/docs/en/mcp - + https://code.claude.com/docs/en/mcp {scopes.map(({ scope, config }) => ( @@ -136,12 +123,9 @@ export function McpParsingWarnings(): React.ReactNode { * - Approved / disabled status of servers */} - ) + ); } -function filterErrors( - errors: ValidationError[], - severity: 'fatal' | 'warning', -): ValidationError[] { - return errors.filter(e => e.mcpErrorMetadata?.severity === severity) +function filterErrors(errors: ValidationError[], severity: 'fatal' | 'warning'): ValidationError[] { + return errors.filter(e => e.mcpErrorMetadata?.severity === severity); } diff --git a/src/components/mcp/src/services/analytics/index.ts b/src/components/mcp/src/services/analytics/index.ts index 142e7b6f5..2e0f796da 100644 --- a/src/components/mcp/src/services/analytics/index.ts +++ b/src/components/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/components/mcp/src/services/mcp/config.ts b/src/components/mcp/src/services/mcp/config.ts index 05cc41c4c..a9189cc08 100644 --- a/src/components/mcp/src/services/mcp/config.ts +++ b/src/components/mcp/src/services/mcp/config.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getMcpConfigsByScope = any; +export type getMcpConfigsByScope = any diff --git a/src/components/mcp/src/services/mcp/types.ts b/src/components/mcp/src/services/mcp/types.ts index f604edd2e..11f265958 100644 --- a/src/components/mcp/src/services/mcp/types.ts +++ b/src/components/mcp/src/services/mcp/types.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type ConfigScope = any; +export type ConfigScope = any diff --git a/src/components/mcp/src/services/mcp/utils.ts b/src/components/mcp/src/services/mcp/utils.ts index e6cf8a737..1f6122d82 100644 --- a/src/components/mcp/src/services/mcp/utils.ts +++ b/src/components/mcp/src/services/mcp/utils.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type describeMcpConfigFilePath = any; -export type getScopeLabel = any; +export type describeMcpConfigFilePath = any +export type getScopeLabel = any diff --git a/src/components/mcp/src/utils/settings/validation.ts b/src/components/mcp/src/utils/settings/validation.ts index 6ed579a6a..6c0d4406c 100644 --- a/src/components/mcp/src/utils/settings/validation.ts +++ b/src/components/mcp/src/utils/settings/validation.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type ValidationError = any; +export type ValidationError = any diff --git a/src/components/mcp/types.ts b/src/components/mcp/types.ts index cd22d6bf2..19a79d3ab 100644 --- a/src/components/mcp/types.ts +++ b/src/components/mcp/types.ts @@ -1,8 +1,8 @@ // Auto-generated stub — replace with real implementation -export type ServerInfo = any; -export type AgentMcpServerInfo = any; -export type MCPViewState = any; -export type StdioServerInfo = any; -export type ClaudeAIServerInfo = any; -export type HTTPServerInfo = any; -export type SSEServerInfo = any; +export type ServerInfo = any +export type AgentMcpServerInfo = any +export type MCPViewState = any +export type StdioServerInfo = any +export type ClaudeAIServerInfo = any +export type HTTPServerInfo = any +export type SSEServerInfo = any diff --git a/src/components/mcp/utils/reconnectHelpers.tsx b/src/components/mcp/utils/reconnectHelpers.tsx index cf7459804..bf9b9a6ed 100644 --- a/src/components/mcp/utils/reconnectHelpers.tsx +++ b/src/components/mcp/utils/reconnectHelpers.tsx @@ -1,13 +1,10 @@ -import type { Command } from '../../../commands.js' -import type { - MCPServerConnection, - ServerResource, -} from '../../../services/mcp/types.js' -import type { Tool } from '../../../Tool.js' +import type { Command } from '../../../commands.js'; +import type { MCPServerConnection, ServerResource } from '../../../services/mcp/types.js'; +import type { Tool } from '../../../Tool.js'; export interface ReconnectResult { - message: string - success: boolean + message: string; + success: boolean; } /** @@ -15,10 +12,10 @@ export interface ReconnectResult { */ export function handleReconnectResult( result: { - client: MCPServerConnection - tools: Tool[] - commands: Command[] - resources?: ServerResource[] + client: MCPServerConnection; + tools: Tool[]; + commands: Command[]; + resources?: ServerResource[]; }, serverName: string, ): ReconnectResult { @@ -27,35 +24,32 @@ export function handleReconnectResult( return { message: `Reconnected to ${serverName}.`, success: true, - } + }; case 'needs-auth': return { message: `${serverName} requires authentication. Use the 'Authenticate' option.`, success: false, - } + }; case 'failed': return { message: `Failed to reconnect to ${serverName}.`, success: false, - } + }; default: return { message: `Unknown result when reconnecting to ${serverName}.`, success: false, - } + }; } } /** * Handles errors from reconnect attempts */ -export function handleReconnectError( - error: unknown, - serverName: string, -): string { - const errorMessage = error instanceof Error ? error.message : String(error) - return `Error reconnecting to ${serverName}: ${errorMessage}` +export function handleReconnectError(error: unknown, serverName: string): string { + const errorMessage = error instanceof Error ? error.message : String(error); + return `Error reconnecting to ${serverName}: ${errorMessage}`; } diff --git a/src/components/memory/MemoryFileSelector.tsx b/src/components/memory/MemoryFileSelector.tsx index 2e2bdf623..45ed5a43e 100644 --- a/src/components/memory/MemoryFileSelector.tsx +++ b/src/components/memory/MemoryFileSelector.tsx @@ -1,73 +1,66 @@ -import { feature } from 'bun:bundle' -import chalk from 'chalk' -import { mkdir } from 'fs/promises' -import { join } from 'path' -import * as React from 'react' -import { use, useEffect, useState } from 'react' -import { getOriginalCwd } from '../../bootstrap/state.js' -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' -import { Box, Text } from '../../ink.js' -import { useKeybinding } from '../../keybindings/useKeybinding.js' -import { getAutoMemPath, isAutoMemoryEnabled } from '../../memdir/paths.js' -import { logEvent } from '../../services/analytics/index.js' -import { isAutoDreamEnabled } from '../../services/autoDream/config.js' -import { readLastConsolidatedAt } from '../../services/autoDream/consolidationLock.js' -import { useAppState } from '../../state/AppState.js' -import { getAgentMemoryDir } from '../../tools/AgentTool/agentMemory.js' -import { openPath } from '../../utils/browser.js' -import { getMemoryFiles, type MemoryFileInfo } from '../../utils/claudemd.js' -import { getClaudeConfigHomeDir } from '../../utils/envUtils.js' -import { getDisplayPath } from '../../utils/file.js' -import { formatRelativeTimeAgo } from '../../utils/format.js' -import { projectIsInGitRepo } from '../../utils/memory/versions.js' -import { updateSettingsForSource } from '../../utils/settings/settings.js' -import { Select } from '../CustomSelect/index.js' -import { ListItem } from '../design-system/ListItem.js' +import { feature } from 'bun:bundle'; +import chalk from 'chalk'; +import { mkdir } from 'fs/promises'; +import { join } from 'path'; +import * as React from 'react'; +import { use, useEffect, useState } from 'react'; +import { getOriginalCwd } from '../../bootstrap/state.js'; +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; +import { Box, Text } from '../../ink.js'; +import { useKeybinding } from '../../keybindings/useKeybinding.js'; +import { getAutoMemPath, isAutoMemoryEnabled } from '../../memdir/paths.js'; +import { logEvent } from '../../services/analytics/index.js'; +import { isAutoDreamEnabled } from '../../services/autoDream/config.js'; +import { readLastConsolidatedAt } from '../../services/autoDream/consolidationLock.js'; +import { useAppState } from '../../state/AppState.js'; +import { getAgentMemoryDir } from '../../tools/AgentTool/agentMemory.js'; +import { openPath } from '../../utils/browser.js'; +import { getMemoryFiles, type MemoryFileInfo } from '../../utils/claudemd.js'; +import { getClaudeConfigHomeDir } from '../../utils/envUtils.js'; +import { getDisplayPath } from '../../utils/file.js'; +import { formatRelativeTimeAgo } from '../../utils/format.js'; +import { projectIsInGitRepo } from '../../utils/memory/versions.js'; +import { updateSettingsForSource } from '../../utils/settings/settings.js'; +import { Select } from '../CustomSelect/index.js'; +import { ListItem } from '../design-system/ListItem.js'; /* eslint-disable @typescript-eslint/no-require-imports */ const teamMemPaths = feature('TEAMMEM') ? (require('../../memdir/teamMemPaths.js') as typeof import('../../memdir/teamMemPaths.js')) - : null + : null; /* eslint-enable @typescript-eslint/no-require-imports */ interface ExtendedMemoryFileInfo extends MemoryFileInfo { - isNested?: boolean - exists: boolean + isNested?: boolean; + exists: boolean; } // Remember last selected path -let lastSelectedPath: string | undefined +let lastSelectedPath: string | undefined; -const OPEN_FOLDER_PREFIX = '__open_folder__' +const OPEN_FOLDER_PREFIX = '__open_folder__'; type Props = { - onSelect: (path: string) => void - onCancel: () => void -} + onSelect: (path: string) => void; + onCancel: () => void; +}; -export function MemoryFileSelector({ - onSelect, - onCancel, -}: Props): React.ReactNode { - const existingMemoryFiles = use(getMemoryFiles()) +export function MemoryFileSelector({ onSelect, onCancel }: Props): React.ReactNode { + const existingMemoryFiles = use(getMemoryFiles()); // Create entries for User and Project CLAUDE.md even if they don't exist - const userMemoryPath = join(getClaudeConfigHomeDir(), 'CLAUDE.md') - const projectMemoryPath = join(getOriginalCwd(), 'CLAUDE.md') + const userMemoryPath = join(getClaudeConfigHomeDir(), 'CLAUDE.md'); + const projectMemoryPath = join(getOriginalCwd(), 'CLAUDE.md'); // Check if these are already in the existing files - const hasUserMemory = existingMemoryFiles.some(f => f.path === userMemoryPath) - const hasProjectMemory = existingMemoryFiles.some( - f => f.path === projectMemoryPath, - ) + const hasUserMemory = existingMemoryFiles.some(f => f.path === userMemoryPath); + const hasProjectMemory = existingMemoryFiles.some(f => f.path === projectMemoryPath); // Filter out AutoMem/TeamMem entrypoints: these are MEMORY.md files, and // /memory already surfaces "Open auto-memory folder" / "Open team memory // folder" options below. Listing the entrypoint file separately is redundant. const allMemoryFiles: ExtendedMemoryFileInfo[] = [ - ...existingMemoryFiles - .filter(f => f.type !== 'AutoMem' && f.type !== 'TeamMem') - .map(f => ({ ...f, exists: true })), + ...existingMemoryFiles.filter(f => f.type !== 'AutoMem' && f.type !== 'TeamMem').map(f => ({ ...f, exists: true })), // Add User memory if it doesn't exist ...(hasUserMemory ? [] @@ -90,86 +83,74 @@ export function MemoryFileSelector({ exists: false, }, ]), - ] + ]; - const depths = new Map() + const depths = new Map(); // Create options for the select component const memoryOptions = allMemoryFiles.map(file => { - const displayPath = getDisplayPath(file.path) - const existsLabel = file.exists ? '' : ' (new)' + const displayPath = getDisplayPath(file.path); + const existsLabel = file.exists ? '' : ' (new)'; // Calculate depth based on parent - const depth = file.parent ? (depths.get(file.parent) ?? 0) + 1 : 0 - depths.set(file.path, depth) - const indent = depth > 0 ? ' '.repeat(depth - 1) : '' + const depth = file.parent ? (depths.get(file.parent) ?? 0) + 1 : 0; + depths.set(file.path, depth); + const indent = depth > 0 ? ' '.repeat(depth - 1) : ''; // Format label based on type - let label: string - if ( - file.type === 'User' && - !file.isNested && - file.path === userMemoryPath - ) { - label = `User memory` - } else if ( - file.type === 'Project' && - !file.isNested && - file.path === projectMemoryPath - ) { - label = `Project memory` + let label: string; + if (file.type === 'User' && !file.isNested && file.path === userMemoryPath) { + label = `User memory`; + } else if (file.type === 'Project' && !file.isNested && file.path === projectMemoryPath) { + label = `Project memory`; } else if (depth > 0) { // For child nodes (imported files), show indented with L - label = `${indent}L ${displayPath}${existsLabel}` + label = `${indent}L ${displayPath}${existsLabel}`; } else { // For other memory files, just show the path - label = `${displayPath}` + label = `${displayPath}`; } // Create description based on type - keep the original descriptions for built-in types - let description: string - const isGit = projectIsInGitRepo(getOriginalCwd()) + let description: string; + const isGit = projectIsInGitRepo(getOriginalCwd()); if (file.type === 'User' && !file.isNested) { - description = 'Saved in ~/.claude/CLAUDE.md' - } else if ( - file.type === 'Project' && - !file.isNested && - file.path === projectMemoryPath - ) { - description = `${isGit ? 'Checked in at' : 'Saved in'} ./CLAUDE.md` + description = 'Saved in ~/.claude/CLAUDE.md'; + } else if (file.type === 'Project' && !file.isNested && file.path === projectMemoryPath) { + description = `${isGit ? 'Checked in at' : 'Saved in'} ./CLAUDE.md`; } else if (file.parent) { // For imported files (with @-import) - description = '@-imported' + description = '@-imported'; } else if (file.isNested) { // For nested files (dynamically loaded) - description = 'dynamically loaded' + description = 'dynamically loaded'; } else { - description = '' + description = ''; } return { label, value: file.path, description, - } - }) + }; + }); // Add "Open folder" options for auto-memory and agent memory directories const folderOptions: Array<{ - label: string - value: string - description: string - }> = [] + label: string; + value: string; + description: string; + }> = []; - const agentDefinitions = useAppState(s => s.agentDefinitions) + const agentDefinitions = useAppState(s => s.agentDefinitions); if (isAutoMemoryEnabled()) { // Always show auto-memory folder option folderOptions.push({ label: 'Open auto-memory folder', value: `${OPEN_FOLDER_PREFIX}${getAutoMemPath()}`, description: '', - }) + }); // Team memory directly below auto-memory (team dir is a subdir of auto dir) if (feature('TEAMMEM') && teamMemPaths!.isTeamMemoryEnabled()) { @@ -177,52 +158,49 @@ export function MemoryFileSelector({ label: 'Open team memory folder', value: `${OPEN_FOLDER_PREFIX}${teamMemPaths!.getTeamMemPath()}`, description: '', - }) + }); } // Add agent memory folders for agents that have memory configured for (const agent of agentDefinitions.activeAgents) { if (agent.memory) { - const agentDir = getAgentMemoryDir(agent.agentType, agent.memory) + const agentDir = getAgentMemoryDir(agent.agentType, agent.memory); folderOptions.push({ label: `Open ${chalk.bold(agent.agentType)} agent memory`, value: `${OPEN_FOLDER_PREFIX}${agentDir}`, description: `${agent.memory} scope`, - }) + }); } } } - memoryOptions.push(...folderOptions) + memoryOptions.push(...folderOptions); // Initialize with last selected path if it's still in the options, otherwise use first option const initialPath = - lastSelectedPath && - memoryOptions.some(opt => opt.value === lastSelectedPath) + lastSelectedPath && memoryOptions.some(opt => opt.value === lastSelectedPath) ? lastSelectedPath - : memoryOptions[0]?.value || '' + : memoryOptions[0]?.value || ''; // Toggle state (local copy of settings so the UI updates immediately) - const [autoMemoryOn, setAutoMemoryOn] = useState(isAutoMemoryEnabled) - const [autoDreamOn, setAutoDreamOn] = useState(isAutoDreamEnabled) + const [autoMemoryOn, setAutoMemoryOn] = useState(isAutoMemoryEnabled); + const [autoDreamOn, setAutoDreamOn] = useState(isAutoDreamEnabled); // Dream row is only meaningful when auto-memory is on (dream consolidates // that dir). Snapshot at mount so the row doesn't vanish mid-navigation // if the user toggles auto-memory off. - const [showDreamRow] = useState(isAutoMemoryEnabled) + const [showDreamRow] = useState(isAutoMemoryEnabled); // Dream status: prefer live task state (this session fired it), fall back // to the cross-process lock mtime. const isDreamRunning = useAppState(s => - Object.values(s.tasks).some( - t => t.type === 'dream' && t.status === 'running', - ), - ) - const [lastDreamAt, setLastDreamAt] = useState(null) + Object.values(s.tasks).some(t => t.type === 'dream' && t.status === 'running'), + ); + const [lastDreamAt, setLastDreamAt] = useState(null); useEffect(() => { - if (!showDreamRow) return - void readLastConsolidatedAt().then(setLastDreamAt) - }, [showDreamRow, isDreamRunning]) + if (!showDreamRow) return; + void readLastConsolidatedAt().then(setLastDreamAt); + }, [showDreamRow, isDreamRunning]); const dreamStatus = isDreamRunning ? 'running' @@ -230,55 +208,53 @@ export function MemoryFileSelector({ ? '' // stat in flight : lastDreamAt === 0 ? 'never' - : `last ran ${formatRelativeTimeAgo(new Date(lastDreamAt))}` + : `last ran ${formatRelativeTimeAgo(new Date(lastDreamAt))}`; // null = Select has focus, 0 = auto-memory, 1 = auto-dream (if showDreamRow) - const [focusedToggle, setFocusedToggle] = useState(null) - const toggleFocused = focusedToggle !== null - const lastToggleIndex = showDreamRow ? 1 : 0 + const [focusedToggle, setFocusedToggle] = useState(null); + const toggleFocused = focusedToggle !== null; + const lastToggleIndex = showDreamRow ? 1 : 0; function handleToggleAutoMemory(): void { - const newValue = !autoMemoryOn - updateSettingsForSource('userSettings', { autoMemoryEnabled: newValue }) - setAutoMemoryOn(newValue) - logEvent('tengu_auto_memory_toggled', { enabled: newValue }) + const newValue = !autoMemoryOn; + updateSettingsForSource('userSettings', { autoMemoryEnabled: newValue }); + setAutoMemoryOn(newValue); + logEvent('tengu_auto_memory_toggled', { enabled: newValue }); } function handleToggleAutoDream(): void { - const newValue = !autoDreamOn - updateSettingsForSource('userSettings', { autoDreamEnabled: newValue }) - setAutoDreamOn(newValue) - logEvent('tengu_auto_dream_toggled', { enabled: newValue }) + const newValue = !autoDreamOn; + updateSettingsForSource('userSettings', { autoDreamEnabled: newValue }); + setAutoDreamOn(newValue); + logEvent('tengu_auto_dream_toggled', { enabled: newValue }); } - useExitOnCtrlCDWithKeybindings() + useExitOnCtrlCDWithKeybindings(); - useKeybinding('confirm:no', onCancel, { context: 'Confirmation' }) + useKeybinding('confirm:no', onCancel, { context: 'Confirmation' }); useKeybinding( 'confirm:yes', () => { - if (focusedToggle === 0) handleToggleAutoMemory() - else if (focusedToggle === 1) handleToggleAutoDream() + if (focusedToggle === 0) handleToggleAutoMemory(); + else if (focusedToggle === 1) handleToggleAutoDream(); }, { context: 'Confirmation', isActive: toggleFocused }, - ) + ); useKeybinding( 'select:next', () => { - setFocusedToggle(prev => - prev !== null && prev < lastToggleIndex ? prev + 1 : null, - ) + setFocusedToggle(prev => (prev !== null && prev < lastToggleIndex ? prev + 1 : null)); }, { context: 'Select', isActive: toggleFocused }, - ) + ); useKeybinding( 'select:previous', () => { - setFocusedToggle(prev => (prev !== null && prev > 0 ? prev - 1 : prev)) + setFocusedToggle(prev => (prev !== null && prev > 0 ? prev - 1 : prev)); }, { context: 'Select', isActive: toggleFocused }, - ) + ); return ( @@ -291,9 +267,7 @@ export function MemoryFileSelector({ Auto-dream: {autoDreamOn ? 'on' : 'off'} {dreamStatus && · {dreamStatus}} - {!isDreamRunning && autoDreamOn && ( - · /dream to run - )} + {!isDreamRunning && autoDreamOn && · /dream to run} )} @@ -305,20 +279,20 @@ export function MemoryFileSelector({ isDisabled={toggleFocused} onChange={value => { if (value.startsWith(OPEN_FOLDER_PREFIX)) { - const folderPath = value.slice(OPEN_FOLDER_PREFIX.length) + const folderPath = value.slice(OPEN_FOLDER_PREFIX.length); // Ensure folder exists before opening (idempotent; swallow // permission errors to match previous behavior) void mkdir(folderPath, { recursive: true }) .catch(() => {}) - .then(() => openPath(folderPath)) - return + .then(() => openPath(folderPath)); + return; } - lastSelectedPath = value // Remember the selection - onSelect(value) + lastSelectedPath = value; // Remember the selection + onSelect(value); }} onCancel={onCancel} onUpFromFirstItem={() => setFocusedToggle(lastToggleIndex)} /> - ) + ); } diff --git a/src/components/memory/MemoryUpdateNotification.tsx b/src/components/memory/MemoryUpdateNotification.tsx index 890a567b0..35ca3cce7 100644 --- a/src/components/memory/MemoryUpdateNotification.tsx +++ b/src/components/memory/MemoryUpdateNotification.tsx @@ -1,42 +1,32 @@ -import { homedir } from 'os' -import { relative } from 'path' -import React from 'react' -import { Box, Text } from '../../ink.js' -import { getCwd } from '../../utils/cwd.js' +import { homedir } from 'os'; +import { relative } from 'path'; +import React from 'react'; +import { Box, Text } from '../../ink.js'; +import { getCwd } from '../../utils/cwd.js'; export function getRelativeMemoryPath(path: string): string { - const homeDir = homedir() - const cwd = getCwd() + const homeDir = homedir(); + const cwd = getCwd(); // Calculate relative paths - const relativeToHome = path.startsWith(homeDir) - ? '~' + path.slice(homeDir.length) - : null + const relativeToHome = path.startsWith(homeDir) ? '~' + path.slice(homeDir.length) : null; - const relativeToCwd = path.startsWith(cwd) ? './' + relative(cwd, path) : null + const relativeToCwd = path.startsWith(cwd) ? './' + relative(cwd, path) : null; // Return the shorter path, or absolute if neither is applicable if (relativeToHome && relativeToCwd) { - return relativeToHome.length <= relativeToCwd.length - ? relativeToHome - : relativeToCwd + return relativeToHome.length <= relativeToCwd.length ? relativeToHome : relativeToCwd; } - return relativeToHome || relativeToCwd || path + return relativeToHome || relativeToCwd || path; } -export function MemoryUpdateNotification({ - memoryPath, -}: { - memoryPath: string -}): React.ReactNode { - const displayPath = getRelativeMemoryPath(memoryPath) +export function MemoryUpdateNotification({ memoryPath }: { memoryPath: string }): React.ReactNode { + const displayPath = getRelativeMemoryPath(memoryPath); return ( - - Memory updated in {displayPath} · /memory to edit - + Memory updated in {displayPath} · /memory to edit - ) + ); } diff --git a/src/components/messageActions.tsx b/src/components/messageActions.tsx index 3e368c306..9d079660e 100644 --- a/src/components/messageActions.tsx +++ b/src/components/messageActions.tsx @@ -1,14 +1,11 @@ -import figures from 'figures' -import type { RefObject } from 'react' -import React, { useCallback, useMemo, useRef } from 'react' -import { Box, Text } from '../ink.js' -import { useKeybindings } from '../keybindings/useKeybinding.js' -import { logEvent } from '../services/analytics/index.js' -import type { - NormalizedUserMessage, - RenderableMessage, -} from '../types/message.js' -import { isEmptyMessageText, SYNTHETIC_MESSAGES } from '../utils/messages.js' +import figures from 'figures'; +import type { RefObject } from 'react'; +import React, { useCallback, useMemo, useRef } from 'react'; +import { Box, Text } from '../ink.js'; +import { useKeybindings } from '../keybindings/useKeybinding.js'; +import { logEvent } from '../services/analytics/index.js'; +import type { NormalizedUserMessage, RenderableMessage } from '../types/message.js'; +import { isEmptyMessageText, SYNTHETIC_MESSAGES } from '../utils/messages.js'; const NAVIGABLE_TYPES = [ 'user', @@ -17,38 +14,33 @@ const NAVIGABLE_TYPES = [ 'collapsed_read_search', 'system', 'attachment', -] as const -export type NavigableType = (typeof NAVIGABLE_TYPES)[number] +] as const; +export type NavigableType = (typeof NAVIGABLE_TYPES)[number]; -export type NavigableOf = Extract< - RenderableMessage, - { type: T } -> -export type NavigableMessage = RenderableMessage +export type NavigableOf = Extract; +export type NavigableMessage = RenderableMessage; // Tier-2 blocklist (tier-1 is height > 0) — things that render but aren't actionable. export function isNavigableMessage(msg: NavigableMessage): boolean { switch (msg.type) { case 'assistant': { - const b = msg.message.content[0] + const b = msg.message.content[0]; // Text responses (minus AssistantTextMessage's return-null cases — tier-1 // misses unmeasured virtual items), or tool calls with extractable input. return ( - (b?.type === 'text' && - !isEmptyMessageText(b.text) && - !SYNTHETIC_MESSAGES.has(b.text)) || + (b?.type === 'text' && !isEmptyMessageText(b.text) && !SYNTHETIC_MESSAGES.has(b.text)) || (b?.type === 'tool_use' && b.name in PRIMARY_INPUT) - ) + ); } case 'user': { - if (msg.isMeta || msg.isCompactSummary) return false - const b = msg.message.content[0] - if (b?.type !== 'text') return false + if (msg.isMeta || msg.isCompactSummary) return false; + const b = msg.message.content[0]; + if (b?.type !== 'text') return false; // Interrupt etc. — synthetic, not user-authored. - if (SYNTHETIC_MESSAGES.has(b.text)) return false + if (SYNTHETIC_MESSAGES.has(b.text)) return false; // Same filter as VirtualMessageList sticky-prompt: XML-wrapped (command // expansions, bash-stdout, etc.) aren't real prompts. - return !stripSystemReminders(b.text).startsWith('<') + return !stripSystemReminders(b.text).startsWith('<'); } case 'system': // biome-ignore lint/nursery/useExhaustiveSwitchCases: blocklist — fallthrough return-true is the design @@ -60,30 +52,29 @@ export function isNavigableMessage(msg: NavigableMessage): boolean { case 'agents_killed': case 'away_summary': case 'thinking': - return false + return false; } - return true + return true; case 'grouped_tool_use': case 'collapsed_read_search': - return true + return true; case 'attachment': switch (msg.attachment.type) { case 'queued_command': case 'diagnostics': case 'hook_blocking_error': case 'hook_error_during_execution': - return true + return true; } - return false + return false; } } type PrimaryInput = { - label: string - extract: (input: Record) => string | undefined -} -const str = (k: string) => (i: Record) => - typeof i[k] === 'string' ? i[k] : undefined + label: string; + extract: (input: Record) => string | undefined; +}; +const str = (k: string) => (i: Record) => (typeof i[k] === 'string' ? i[k] : undefined); const PRIMARY_INPUT: Record = { Read: { label: 'path', extract: str('file_path') }, Edit: { label: 'path', extract: str('file_path') }, @@ -98,55 +89,45 @@ const PRIMARY_INPUT: Record = { Agent: { label: 'prompt', extract: str('prompt') }, Tmux: { label: 'command', - extract: i => - Array.isArray(i.args) ? `tmux ${i.args.join(' ')}` : undefined, + extract: i => (Array.isArray(i.args) ? `tmux ${i.args.join(' ')}` : undefined), }, -} +}; // Only AgentTool has renderGroupedToolUse — Edit/Bash/etc. stay as assistant tool_use blocks. -export function toolCallOf( - msg: NavigableMessage, -): { name: string; input: Record } | undefined { +export function toolCallOf(msg: NavigableMessage): { name: string; input: Record } | undefined { if (msg.type === 'assistant') { - const b = msg.message.content[0] - if (b?.type === 'tool_use') - return { name: b.name, input: b.input as Record } + const b = msg.message.content[0]; + if (b?.type === 'tool_use') return { name: b.name, input: b.input as Record }; } if (msg.type === 'grouped_tool_use') { - const b = msg.messages[0]?.message.content[0] - if (b?.type === 'tool_use') - return { name: msg.toolName, input: b.input as Record } + const b = msg.messages[0]?.message.content[0]; + if (b?.type === 'tool_use') return { name: msg.toolName, input: b.input as Record }; } - return undefined + return undefined; } export type MessageActionCaps = { - copy: (text: string) => void - edit: (msg: NormalizedUserMessage) => Promise -} + copy: (text: string) => void; + edit: (msg: NormalizedUserMessage) => Promise; +}; // Identity builder — preserves tuple type so `run`'s param narrows (array literal widens without this). function action(a: { - key: K - label: string | ((s: MessageActionsState) => string) - types: readonly T[] - applies?: (s: MessageActionsState) => boolean - stays?: true - run: (m: NavigableOf, caps: MessageActionCaps) => void + key: K; + label: string | ((s: MessageActionsState) => string); + types: readonly T[]; + applies?: (s: MessageActionsState) => boolean; + stays?: true; + run: (m: NavigableOf, caps: MessageActionCaps) => void; }) { - return a + return a; } export const MESSAGE_ACTIONS = [ action({ key: 'enter', label: s => (s.expanded ? 'collapse' : 'expand'), - types: [ - 'grouped_tool_use', - 'collapsed_read_search', - 'attachment', - 'system', - ], + types: ['grouped_tool_use', 'collapsed_read_search', 'attachment', 'system'], stays: true, // Empty — `stays` handled inline by dispatch. run: () => {}, @@ -170,48 +151,43 @@ export const MESSAGE_ACTIONS = [ types: ['grouped_tool_use', 'assistant'], applies: s => s.toolName != null && s.toolName in PRIMARY_INPUT, run: (m, c) => { - const tc = toolCallOf(m) - if (!tc) return - const val = PRIMARY_INPUT[tc.name]?.extract(tc.input) - if (val) c.copy(val) + const tc = toolCallOf(m); + if (!tc) return; + const val = PRIMARY_INPUT[tc.name]?.extract(tc.input); + if (val) c.copy(val); }, }), -] as const +] as const; -function isApplicable( - a: (typeof MESSAGE_ACTIONS)[number], - c: MessageActionsState, -): boolean { - if (!(a.types as readonly string[]).includes(c.msgType)) return false - return !a.applies || a.applies(c) +function isApplicable(a: (typeof MESSAGE_ACTIONS)[number], c: MessageActionsState): boolean { + if (!(a.types as readonly string[]).includes(c.msgType)) return false; + return !a.applies || a.applies(c); } export type MessageActionsState = { - uuid: string - msgType: NavigableType - expanded: boolean - toolName?: string -} + uuid: string; + msgType: NavigableType; + expanded: boolean; + toolName?: string; +}; export type MessageActionsNav = { - enterCursor: () => void - navigatePrev: () => void - navigateNext: () => void - navigatePrevUser: () => void - navigateNextUser: () => void - navigateTop: () => void - navigateBottom: () => void - getSelected: () => NavigableMessage | null -} + enterCursor: () => void; + navigatePrev: () => void; + navigateNext: () => void; + navigatePrevUser: () => void; + navigateNextUser: () => void; + navigateTop: () => void; + navigateBottom: () => void; + getSelected: () => NavigableMessage | null; +}; -export const MessageActionsSelectedContext = React.createContext(false) -export const InVirtualListContext = React.createContext(false) +export const MessageActionsSelectedContext = React.createContext(false); +export const InVirtualListContext = React.createContext(false); // bg must go on the Box that HAS marginTop (margin stays outside paint) — that's inside each consumer. export function useSelectedMessageBg(): 'messageActionsBackground' | undefined { - return React.useContext(MessageActionsSelectedContext) - ? 'messageActionsBackground' - : undefined + return React.useContext(MessageActionsSelectedContext) ? 'messageActionsBackground' : undefined; } // Can't call useKeybindings here — hook runs outside provider. Returns handlers instead. @@ -221,14 +197,14 @@ export function useMessageActions( navRef: RefObject, caps: MessageActionCaps, ): { - enter: () => void - handlers: Record void> + enter: () => void; + handlers: Record void>; } { // Refs keep handlers stable — no useKeybindings re-register per message append. - const cursorRef = useRef(cursor) - cursorRef.current = cursor - const capsRef = useRef(caps) - capsRef.current = caps + const cursorRef = useRef(cursor); + cursorRef.current = cursor; + const capsRef = useRef(caps); + capsRef.current = caps; const handlers = useMemo(() => { const h: Record void> = { @@ -238,40 +214,36 @@ export function useMessageActions( 'messageActions:nextUser': () => navRef.current?.navigateNextUser(), 'messageActions:top': () => navRef.current?.navigateTop(), 'messageActions:bottom': () => navRef.current?.navigateBottom(), - 'messageActions:escape': () => - setCursor(c => (c?.expanded ? { ...c, expanded: false } : null)), + 'messageActions:escape': () => setCursor(c => (c?.expanded ? { ...c, expanded: false } : null)), // ctrl+c skips the collapse step — from expanded-during-streaming, two-stage // would mean 3 presses to interrupt (collapse→null→cancel). 'messageActions:ctrlc': () => setCursor(null), - } + }; for (const key of new Set(MESSAGE_ACTIONS.map(a => a.key))) { h[`messageActions:${key}`] = () => { - const c = cursorRef.current - if (!c) return - const a = MESSAGE_ACTIONS.find(a => a.key === key && isApplicable(a, c)) - if (!a) return + const c = cursorRef.current; + if (!c) return; + const a = MESSAGE_ACTIONS.find(a => a.key === key && isApplicable(a, c)); + if (!a) return; if (a.stays) { - setCursor(c => (c ? { ...c, expanded: !c.expanded } : null)) - return + setCursor(c => (c ? { ...c, expanded: !c.expanded } : null)); + return; } - const m = navRef.current?.getSelected() - if (!m) return - ;(a.run as (m: NavigableMessage, c: MessageActionCaps) => void)( - m, - capsRef.current, - ) - setCursor(null) - } + const m = navRef.current?.getSelected(); + if (!m) return; + (a.run as (m: NavigableMessage, c: MessageActionCaps) => void)(m, capsRef.current); + setCursor(null); + }; } - return h - }, [setCursor, navRef]) + return h; + }, [setCursor, navRef]); const enter = useCallback(() => { - logEvent('tengu_message_actions_enter', {}) - navRef.current?.enterCursor() - }, [navRef]) + logEvent('tengu_message_actions_enter', {}); + navRef.current?.enterCursor(); + }, [navRef]); - return { enter, handlers } + return { enter, handlers }; } // Must mount inside . @@ -279,34 +251,22 @@ export function MessageActionsKeybindings({ handlers, isActive, }: { - handlers: Record void> - isActive: boolean + handlers: Record void>; + isActive: boolean; }): null { - useKeybindings(handlers, { context: 'MessageActions', isActive }) - return null + useKeybindings(handlers, { context: 'MessageActions', isActive }); + return null; } // borderTop-only Box matches PromptInput's ─── line for stable footer height. -export function MessageActionsBar({ - cursor, -}: { - cursor: MessageActionsState -}): React.ReactNode { - const applicable = MESSAGE_ACTIONS.filter(a => isApplicable(a, cursor)) +export function MessageActionsBar({ cursor }: { cursor: MessageActionsState }): React.ReactNode { + const applicable = MESSAGE_ACTIONS.filter(a => isApplicable(a, cursor)); return ( - + {applicable.map((a, i) => { - const label = - typeof a.label === 'function' ? a.label(cursor) : a.label + const label = typeof a.label === 'function' ? a.label(cursor) : a.label; return ( {i > 0 && · } @@ -316,7 +276,7 @@ export function MessageActionsBar({ {label} - ) + ); })} · @@ -330,67 +290,61 @@ export function MessageActionsBar({ back - ) + ); } export function stripSystemReminders(text: string): string { - const CLOSE = '' - let t = text.trimStart() + const CLOSE = ''; + let t = text.trimStart(); while (t.startsWith('')) { - const end = t.indexOf(CLOSE) - if (end < 0) break - t = t.slice(end + CLOSE.length).trimStart() + const end = t.indexOf(CLOSE); + if (end < 0) break; + t = t.slice(end + CLOSE.length).trimStart(); } - return t + return t; } export function copyTextOf(msg: NavigableMessage): string { switch (msg.type) { case 'user': { - const b = msg.message.content[0] - return b?.type === 'text' ? stripSystemReminders(b.text) : '' + const b = msg.message.content[0]; + return b?.type === 'text' ? stripSystemReminders(b.text) : ''; } case 'assistant': { - const b = msg.message.content[0] - if (b?.type === 'text') return b.text - const tc = toolCallOf(msg) - return tc ? (PRIMARY_INPUT[tc.name]?.extract(tc.input) ?? '') : '' + const b = msg.message.content[0]; + if (b?.type === 'text') return b.text; + const tc = toolCallOf(msg); + return tc ? (PRIMARY_INPUT[tc.name]?.extract(tc.input) ?? '') : ''; } case 'grouped_tool_use': - return msg.results.map(toolResultText).filter(Boolean).join('\n\n') + return msg.results.map(toolResultText).filter(Boolean).join('\n\n'); case 'collapsed_read_search': return msg.messages .flatMap(m => - m.type === 'user' - ? [toolResultText(m)] - : m.type === 'grouped_tool_use' - ? m.results.map(toolResultText) - : [], + m.type === 'user' ? [toolResultText(m)] : m.type === 'grouped_tool_use' ? m.results.map(toolResultText) : [], ) .filter(Boolean) - .join('\n\n') + .join('\n\n'); case 'system': - if ('content' in msg) return msg.content - if ('error' in msg) return String(msg.error) - return msg.subtype + if ('content' in msg) return msg.content; + if ('error' in msg) return String(msg.error); + return msg.subtype; case 'attachment': { - const a = msg.attachment + const a = msg.attachment; if (a.type === 'queued_command') { - const p = a.prompt - return typeof p === 'string' - ? p - : p.flatMap(b => (b.type === 'text' ? [b.text] : [])).join('\n') + const p = a.prompt; + return typeof p === 'string' ? p : p.flatMap(b => (b.type === 'text' ? [b.text] : [])).join('\n'); } - return `[${a.type}]` + return `[${a.type}]`; } } } function toolResultText(r: NormalizedUserMessage): string { - const b = r.message.content[0] - if (b?.type !== 'tool_result') return '' - const c = b.content - if (typeof c === 'string') return c - if (!c) return '' - return c.flatMap(x => (x.type === 'text' ? [x.text] : [])).join('\n') + const b = r.message.content[0]; + if (b?.type !== 'tool_result') return ''; + const c = b.content; + if (typeof c === 'string') return c; + if (!c) return ''; + return c.flatMap(x => (x.type === 'text' ? [x.text] : [])).join('\n'); } diff --git a/src/components/messages/AdvisorMessage.tsx b/src/components/messages/AdvisorMessage.tsx index 4a77fe7ca..202782a56 100644 --- a/src/components/messages/AdvisorMessage.tsx +++ b/src/components/messages/AdvisorMessage.tsx @@ -1,22 +1,22 @@ -import figures from 'figures' -import React from 'react' -import { Box, Text } from '../../ink.js' -import type { AdvisorBlock } from '../../utils/advisor.js' -import { renderModelName } from '../../utils/model/model.js' -import { jsonStringify } from '../../utils/slowOperations.js' -import { CtrlOToExpand } from '../CtrlOToExpand.js' -import { MessageResponse } from '../MessageResponse.js' -import { ToolUseLoader } from '../ToolUseLoader.js' +import figures from 'figures'; +import React from 'react'; +import { Box, Text } from '../../ink.js'; +import type { AdvisorBlock } from '../../utils/advisor.js'; +import { renderModelName } from '../../utils/model/model.js'; +import { jsonStringify } from '../../utils/slowOperations.js'; +import { CtrlOToExpand } from '../CtrlOToExpand.js'; +import { MessageResponse } from '../MessageResponse.js'; +import { ToolUseLoader } from '../ToolUseLoader.js'; type Props = { - block: AdvisorBlock - addMargin: boolean - resolvedToolUseIDs: Set - erroredToolUseIDs: Set - shouldAnimate: boolean - verbose: boolean - advisorModel?: string -} + block: AdvisorBlock; + addMargin: boolean; + resolvedToolUseIDs: Set; + erroredToolUseIDs: Set; + shouldAnimate: boolean; + verbose: boolean; + advisorModel?: string; +}; export function AdvisorMessage({ block, @@ -28,10 +28,7 @@ export function AdvisorMessage({ advisorModel, }: Props): React.ReactNode { if (block.type === 'server_tool_use') { - const input = - block.input && Object.keys(block.input).length > 0 - ? jsonStringify(block.input) - : null + const input = block.input && Object.keys(block.input).length > 0 ? jsonStringify(block.input) : null; return ( Advising - {advisorModel ? ( - using {renderModelName(advisorModel)} - ) : null} + {advisorModel ? using {renderModelName(advisorModel)} : null} {input ? · {input} : null} - ) + ); } - let body: React.ReactNode + let body: React.ReactNode; switch (block.content.type) { case 'advisor_tool_result_error': - body = ( - - Advisor unavailable ({block.content.error_code}) - - ) - break + body = Advisor unavailable ({block.content.error_code}); + break; case 'advisor_result': body = verbose ? ( {block.content.text} ) : ( - {figures.tick} Advisor has reviewed the conversation and will apply - the feedback + {figures.tick} Advisor has reviewed the conversation and will apply the feedback - ) - break + ); + break; case 'advisor_redacted_result': - body = ( - - {figures.tick} Advisor has reviewed the conversation and will apply - the feedback - - ) - break + body = {figures.tick} Advisor has reviewed the conversation and will apply the feedback; + break; } return ( {body} - ) + ); } diff --git a/src/components/messages/AssistantRedactedThinkingMessage.tsx b/src/components/messages/AssistantRedactedThinkingMessage.tsx index eb0f66d35..0d186d506 100644 --- a/src/components/messages/AssistantRedactedThinkingMessage.tsx +++ b/src/components/messages/AssistantRedactedThinkingMessage.tsx @@ -1,18 +1,16 @@ -import React from 'react' -import { Box, Text } from '../../ink.js' +import React from 'react'; +import { Box, Text } from '../../ink.js'; type Props = { - addMargin: boolean -} + addMargin: boolean; +}; -export function AssistantRedactedThinkingMessage({ - addMargin = false, -}: Props): React.ReactNode { +export function AssistantRedactedThinkingMessage({ addMargin = false }: Props): React.ReactNode { return ( ✻ Thinking… - ) + ); } diff --git a/src/components/messages/AssistantTextMessage.tsx b/src/components/messages/AssistantTextMessage.tsx index 005d2481e..9425540b2 100644 --- a/src/components/messages/AssistantTextMessage.tsx +++ b/src/components/messages/AssistantTextMessage.tsx @@ -1,9 +1,9 @@ -import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' -import React, { useContext } from 'react' -import { ERROR_MESSAGE_USER_ABORT } from 'src/services/compact/compact.js' -import { isRateLimitErrorMessage } from 'src/services/rateLimitMessages.js' -import { BLACK_CIRCLE } from '../../constants/figures.js' -import { Box, NoSelect, Text } from '../../ink.js' +import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; +import React, { useContext } from 'react'; +import { ERROR_MESSAGE_USER_ABORT } from 'src/services/compact/compact.js'; +import { isRateLimitErrorMessage } from 'src/services/rateLimitMessages.js'; +import { BLACK_CIRCLE } from '../../constants/figures.js'; +import { Box, NoSelect, Text } from '../../ink.js'; import { API_ERROR_MESSAGE_PREFIX, API_TIMEOUT_ERROR_MESSAGE, @@ -16,50 +16,40 @@ import { PROMPT_TOO_LONG_ERROR_MESSAGE, startsWithApiErrorPrefix, TOKEN_REVOKED_ERROR_MESSAGE, -} from '../../services/api/errors.js' -import { - isEmptyMessageText, - NO_RESPONSE_REQUESTED, -} from '../../utils/messages.js' -import { getUpgradeMessage } from '../../utils/model/contextWindowUpgradeCheck.js' -import { - getDefaultSonnetModel, - renderModelName, -} from '../../utils/model/model.js' -import { isMacOsKeychainLocked } from '../../utils/secureStorage/macOsKeychainStorage.js' -import { CtrlOToExpand } from '../CtrlOToExpand.js' -import { InterruptedByUser } from '../InterruptedByUser.js' -import { Markdown } from '../Markdown.js' -import { MessageResponse } from '../MessageResponse.js' -import { MessageActionsSelectedContext } from '../messageActions.js' -import { RateLimitMessage } from './RateLimitMessage.js' - -const MAX_API_ERROR_CHARS = 1000 +} from '../../services/api/errors.js'; +import { isEmptyMessageText, NO_RESPONSE_REQUESTED } from '../../utils/messages.js'; +import { getUpgradeMessage } from '../../utils/model/contextWindowUpgradeCheck.js'; +import { getDefaultSonnetModel, renderModelName } from '../../utils/model/model.js'; +import { isMacOsKeychainLocked } from '../../utils/secureStorage/macOsKeychainStorage.js'; +import { CtrlOToExpand } from '../CtrlOToExpand.js'; +import { InterruptedByUser } from '../InterruptedByUser.js'; +import { Markdown } from '../Markdown.js'; +import { MessageResponse } from '../MessageResponse.js'; +import { MessageActionsSelectedContext } from '../messageActions.js'; +import { RateLimitMessage } from './RateLimitMessage.js'; + +const MAX_API_ERROR_CHARS = 1000; type Props = { - param: TextBlockParam - addMargin: boolean - shouldShowDot: boolean - verbose: boolean - width?: number | string - onOpenRateLimitOptions?: () => void -} + param: TextBlockParam; + addMargin: boolean; + shouldShowDot: boolean; + verbose: boolean; + width?: number | string; + onOpenRateLimitOptions?: () => void; +}; function InvalidApiKeyMessage(): React.ReactNode { - const isKeychainLocked = isMacOsKeychainLocked() + const isKeychainLocked = isMacOsKeychainLocked(); return ( {INVALID_API_KEY_ERROR_MESSAGE} - {isKeychainLocked && ( - - · Run in another terminal: security unlock-keychain - - )} + {isKeychainLocked && · Run in another terminal: security unlock-keychain} - ) + ); } export function AssistantTextMessage({ @@ -69,30 +59,25 @@ export function AssistantTextMessage({ verbose, onOpenRateLimitOptions, }: Props): React.ReactNode { - const isSelected = useContext(MessageActionsSelectedContext) + const isSelected = useContext(MessageActionsSelectedContext); if (isEmptyMessageText(text)) { - return null + return null; } // Handle all rate limit error messages from getRateLimitErrorMessage // Use the exported function to avoid fragile string coupling if (isRateLimitErrorMessage(text)) { - return ( - - ) + return ; } switch (text) { // Local JSX commands don't need a response, but we still want Claude to see them // Tool results render their own interrupt messages case NO_RESPONSE_REQUESTED: - return null + return null; case PROMPT_TOO_LONG_ERROR_MESSAGE: { - const upgradeHint = getUpgradeMessage('warning') + const upgradeHint = getUpgradeMessage('warning'); return ( @@ -100,28 +85,27 @@ export function AssistantTextMessage({ {upgradeHint ? ` · ${upgradeHint}` : ''} - ) + ); } case CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE: return ( - Credit balance too low · Add funds: - https://platform.claude.com/settings/billing + Credit balance too low · Add funds: https://platform.claude.com/settings/billing - ) + ); case INVALID_API_KEY_ERROR_MESSAGE: - return + return ; case INVALID_API_KEY_ERROR_MESSAGE_EXTERNAL: return ( {INVALID_API_KEY_ERROR_MESSAGE_EXTERNAL} - ) + ); case ORG_DISABLED_ERROR_MESSAGE_ENV_KEY: case ORG_DISABLED_ERROR_MESSAGE_ENV_KEY_WITH_OAUTH: @@ -129,45 +113,37 @@ export function AssistantTextMessage({ {text} - ) + ); case TOKEN_REVOKED_ERROR_MESSAGE: return ( {TOKEN_REVOKED_ERROR_MESSAGE} - ) + ); case API_TIMEOUT_ERROR_MESSAGE: return ( {API_TIMEOUT_ERROR_MESSAGE} - {process.env.API_TIMEOUT_MS && ( - <> - {' '} - (API_TIMEOUT_MS={process.env.API_TIMEOUT_MS}ms, try increasing - it) - - )} + {process.env.API_TIMEOUT_MS && <> (API_TIMEOUT_MS={process.env.API_TIMEOUT_MS}ms, try increasing it)} - ) + ); case CUSTOM_OFF_SWITCH_MESSAGE: return ( - - We are experiencing high demand for Opus 4. - + We are experiencing high demand for Opus 4. - To continue immediately, use /model to switch to{' '} - {renderModelName(getDefaultSonnetModel())} and continue coding. + To continue immediately, use /model to switch to {renderModelName(getDefaultSonnetModel())} and continue + coding. - ) + ); // TODO: Move this to a user turn case ERROR_MESSAGE_USER_ABORT: @@ -175,11 +151,11 @@ export function AssistantTextMessage({ - ) + ); default: if (startsWithApiErrorPrefix(text)) { - const truncated = !verbose && text.length > MAX_API_ERROR_CHARS + const truncated = !verbose && text.length > MAX_API_ERROR_CHARS; return ( @@ -193,7 +169,7 @@ export function AssistantTextMessage({ {truncated && } - ) + ); } return ( {shouldShowDot && ( - - {BLACK_CIRCLE} - + {BLACK_CIRCLE} )} @@ -217,6 +191,6 @@ export function AssistantTextMessage({ - ) + ); } } diff --git a/src/components/messages/AssistantThinkingMessage.tsx b/src/components/messages/AssistantThinkingMessage.tsx index 2fc88512d..6baf8dde4 100644 --- a/src/components/messages/AssistantThinkingMessage.tsx +++ b/src/components/messages/AssistantThinkingMessage.tsx @@ -1,24 +1,18 @@ -import type { - ThinkingBlock, - ThinkingBlockParam, -} from '@anthropic-ai/sdk/resources/index.mjs' -import React from 'react' -import { Box, Text } from '../../ink.js' -import { CtrlOToExpand } from '../CtrlOToExpand.js' -import { Markdown } from '../Markdown.js' +import type { ThinkingBlock, ThinkingBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; +import React from 'react'; +import { Box, Text } from '../../ink.js'; +import { CtrlOToExpand } from '../CtrlOToExpand.js'; +import { Markdown } from '../Markdown.js'; type Props = { // Accept either full ThinkingBlock/ThinkingBlockParam or a minimal shape with just type and thinking - param: - | ThinkingBlock - | ThinkingBlockParam - | { type: 'thinking'; thinking: string } - addMargin: boolean - isTranscriptMode: boolean - verbose: boolean + param: ThinkingBlock | ThinkingBlockParam | { type: 'thinking'; thinking: string }; + addMargin: boolean; + isTranscriptMode: boolean; + verbose: boolean; /** When true, hide this thinking block entirely (used for past thinking in transcript mode) */ - hideInTranscript?: boolean -} + hideInTranscript?: boolean; +}; export function AssistantThinkingMessage({ param: { thinking }, @@ -28,15 +22,15 @@ export function AssistantThinkingMessage({ hideInTranscript = false, }: Props): React.ReactNode { if (!thinking) { - return null + return null; } if (hideInTranscript) { - return null + return null; } - const shouldShowFullThinking = isTranscriptMode || verbose - const label = '∴ Thinking' + const shouldShowFullThinking = isTranscriptMode || verbose; + const label = '∴ Thinking'; if (!shouldShowFullThinking) { return ( @@ -45,16 +39,11 @@ export function AssistantThinkingMessage({ {label} - ) + ); } return ( - + {label}… @@ -62,5 +51,5 @@ export function AssistantThinkingMessage({ {thinking} - ) + ); } diff --git a/src/components/messages/AssistantToolUseMessage.tsx b/src/components/messages/AssistantToolUseMessage.tsx index 65a92aad6..d04a6839c 100644 --- a/src/components/messages/AssistantToolUseMessage.tsx +++ b/src/components/messages/AssistantToolUseMessage.tsx @@ -1,42 +1,37 @@ -import type { ToolUseBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' -import React, { useMemo } from 'react' -import { useTerminalSize } from 'src/hooks/useTerminalSize.js' -import type { ThemeName } from 'src/utils/theme.js' -import type { Command } from '../../commands.js' -import { BLACK_CIRCLE } from '../../constants/figures.js' -import { stringWidth } from '../../ink/stringWidth.js' -import { Box, Text, useTheme } from '../../ink.js' -import { useAppStateMaybeOutsideOfProvider } from '../../state/AppState.js' -import { - findToolByName, - type Tool, - type ToolProgressData, - type Tools, -} from '../../Tool.js' -import type { ProgressMessage } from '../../types/message.js' -import { useIsClassifierChecking } from '../../utils/classifierApprovalsHook.js' -import { logError } from '../../utils/log.js' -import type { buildMessageLookups } from '../../utils/messages.js' -import { MessageResponse } from '../MessageResponse.js' -import { useSelectedMessageBg } from '../messageActions.js' -import { SentryErrorBoundary } from '../SentryErrorBoundary.js' -import { ToolUseLoader } from '../ToolUseLoader.js' -import { HookProgressMessage } from './HookProgressMessage.js' +import type { ToolUseBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; +import React, { useMemo } from 'react'; +import { useTerminalSize } from 'src/hooks/useTerminalSize.js'; +import type { ThemeName } from 'src/utils/theme.js'; +import type { Command } from '../../commands.js'; +import { BLACK_CIRCLE } from '../../constants/figures.js'; +import { stringWidth } from '../../ink/stringWidth.js'; +import { Box, Text, useTheme } from '../../ink.js'; +import { useAppStateMaybeOutsideOfProvider } from '../../state/AppState.js'; +import { findToolByName, type Tool, type ToolProgressData, type Tools } from '../../Tool.js'; +import type { ProgressMessage } from '../../types/message.js'; +import { useIsClassifierChecking } from '../../utils/classifierApprovalsHook.js'; +import { logError } from '../../utils/log.js'; +import type { buildMessageLookups } from '../../utils/messages.js'; +import { MessageResponse } from '../MessageResponse.js'; +import { useSelectedMessageBg } from '../messageActions.js'; +import { SentryErrorBoundary } from '../SentryErrorBoundary.js'; +import { ToolUseLoader } from '../ToolUseLoader.js'; +import { HookProgressMessage } from './HookProgressMessage.js'; type Props = { - param: ToolUseBlockParam - addMargin: boolean - tools: Tools - commands: Command[] - verbose: boolean - inProgressToolUseIDs: Set - progressMessagesForMessage: ProgressMessage[] - shouldAnimate: boolean - shouldShowDot: boolean - inProgressToolCallCount?: number - lookups: ReturnType - isTranscriptMode?: boolean -} + param: ToolUseBlockParam; + addMargin: boolean; + tools: Tools; + commands: Command[]; + verbose: boolean; + inProgressToolUseIDs: Set; + progressMessagesForMessage: ProgressMessage[]; + shouldAnimate: boolean; + shouldShowDot: boolean; + inProgressToolCallCount?: number; + lookups: ReturnType; + isTranscriptMode?: boolean; +}; export function AssistantToolUseMessage({ param, @@ -52,29 +47,21 @@ export function AssistantToolUseMessage({ lookups, isTranscriptMode, }: Props): React.ReactNode { - const terminalSize = useTerminalSize() - const [theme] = useTheme() - const bg = useSelectedMessageBg() - const pendingWorkerRequest = useAppStateMaybeOutsideOfProvider( - state => state.pendingWorkerRequest, - ) - const isClassifierCheckingRaw = useIsClassifierChecking(param.id) - const permissionMode = useAppStateMaybeOutsideOfProvider( - state => state.toolPermissionContext.mode, - ) + const terminalSize = useTerminalSize(); + const [theme] = useTheme(); + const bg = useSelectedMessageBg(); + const pendingWorkerRequest = useAppStateMaybeOutsideOfProvider(state => state.pendingWorkerRequest); + const isClassifierCheckingRaw = useIsClassifierChecking(param.id); + const permissionMode = useAppStateMaybeOutsideOfProvider(state => state.toolPermissionContext.mode); // strippedDangerousRules is set by stripDangerousPermissionsForAutoMode // (even to {}) whenever auto is active, and cleared by restoreDangerousPermissions // on deactivation — a reliable proxy for isAutoModeActive() during plan. // prePlanMode would be stale after transitionPlanAutoMode deactivates mid-plan. const hasStrippedRules = useAppStateMaybeOutsideOfProvider( state => !!state.toolPermissionContext.strippedDangerousRules, - ) - const isAutoClassifier = - permissionMode === 'auto' || (permissionMode === 'plan' && hasStrippedRules) - const isClassifierChecking = - process.env.USER_TYPE === 'ant' && - isClassifierCheckingRaw && - permissionMode !== 'auto' + ); + const isAutoClassifier = permissionMode === 'auto' || (permissionMode === 'plan' && hasStrippedRules); + const isClassifierChecking = process.env.USER_TYPE === 'ant' && isClassifierCheckingRaw && permissionMode !== 'auto'; // Memoize on param identity (stable — from the persisted message object). // Zod safeParse allocates per call, and some tools' userFacingName() @@ -82,47 +69,34 @@ export function AssistantToolUseMessage({ // this, ~50 bash messages × shell-quote-per-render pushed transition // render past the shimmer tick → abort → infinite retry (#21605). const parsed = useMemo(() => { - if (!tools) return null - const tool = findToolByName(tools, param.name) - if (!tool) return null - const input = tool.inputSchema.safeParse(param.input) - const data = input.success ? input.data : undefined + if (!tools) return null; + const tool = findToolByName(tools, param.name); + if (!tool) return null; + const input = tool.inputSchema.safeParse(param.input); + const data = input.success ? input.data : undefined; return { tool, input, userFacingToolName: tool.userFacingName(data), - userFacingToolNameBackgroundColor: - tool.userFacingNameBackgroundColor?.(data), + userFacingToolNameBackgroundColor: tool.userFacingNameBackgroundColor?.(data), isTransparentWrapper: tool.isTransparentWrapper?.() ?? false, - } - }, [tools, param]) + }; + }, [tools, param]); if (!parsed) { // Guard against undefined tools (required prop) or unknown tool name - logError( - new Error( - tools - ? `Tool ${param.name} not found` - : `Tools array is undefined for tool ${param.name}`, - ), - ) - return null + logError(new Error(tools ? `Tool ${param.name} not found` : `Tools array is undefined for tool ${param.name}`)); + return null; } - const { - tool, - input, - userFacingToolName, - userFacingToolNameBackgroundColor, - isTransparentWrapper, - } = parsed + const { tool, input, userFacingToolName, userFacingToolNameBackgroundColor, isTransparentWrapper } = parsed; - const isResolved = lookups.resolvedToolUseIDs.has(param.id) - const isQueued = !inProgressToolUseIDs.has(param.id) && !isResolved - const isWaitingForPermission = pendingWorkerRequest?.toolUseId === param.id + const isResolved = lookups.resolvedToolUseIDs.has(param.id); + const isQueued = !inProgressToolUseIDs.has(param.id) && !isResolved; + const isWaitingForPermission = pendingWorkerRequest?.toolUseId === param.id; if (isTransparentWrapper) { - if (isQueued || isResolved) return null + if (isQueued || isResolved) return null; return ( {renderToolUseProgressMessage( @@ -135,18 +109,18 @@ export function AssistantToolUseMessage({ terminalSize, )} - ) + ); } if (userFacingToolName === '') { - return null + return null; } const renderedToolUseMessage = input.success ? renderToolUseMessage(tool, input.data, { theme, verbose, commands }) - : null + : null; if (renderedToolUseMessage === null) { - return null + return null; } return ( @@ -158,11 +132,7 @@ export function AssistantToolUseMessage({ backgroundColor={bg} > - + {shouldShowDot && (isQueued ? ( @@ -183,9 +153,7 @@ export function AssistantToolUseMessage({ bold wrap="truncate-end" backgroundColor={userFacingToolNameBackgroundColor} - color={ - userFacingToolNameBackgroundColor ? 'inverseText' : undefined - } + color={userFacingToolNameBackgroundColor ? 'inverseText' : undefined} > {userFacingToolName} @@ -196,18 +164,14 @@ export function AssistantToolUseMessage({ )} {/* Render tool-specific tags (timeout, model, resume ID, etc.) */} - {input.success && - tool.renderToolUseTag && - tool.renderToolUseTag(input.data)} + {input.success && tool.renderToolUseTag && tool.renderToolUseTag(input.data)} {!isResolved && !isQueued && (isClassifierChecking ? ( - {isAutoClassifier - ? 'Auto classifier checking\u2026' - : 'Bash classifier checking\u2026'} + {isAutoClassifier ? 'Auto classifier checking\u2026' : 'Bash classifier checking\u2026'} ) : isWaitingForPermission ? ( @@ -232,29 +196,23 @@ export function AssistantToolUseMessage({ {!isResolved && isQueued && renderToolUseQueuedMessage(tool)} - ) + ); } function renderToolUseMessage( tool: Tool, input: unknown, - { - theme, - verbose, - commands, - }: { theme: ThemeName; verbose: boolean; commands: Command[] }, + { theme, verbose, commands }: { theme: ThemeName; verbose: boolean; commands: Command[] }, ): React.ReactNode { try { - const parsed = tool.inputSchema.safeParse(input) + const parsed = tool.inputSchema.safeParse(input); if (!parsed.success) { - return '' + return ''; } - return tool.renderToolUseMessage(parsed.data, { theme, verbose, commands }) + return tool.renderToolUseMessage(parsed.data, { theme, verbose, commands }); } catch (error) { - logError( - new Error(`Error rendering tool use message for ${tool.name}: ${error}`), - ) - return '' + logError(new Error(`Error rendering tool use message for ${tool.name}: ${error}`)); + return ''; } } @@ -269,16 +227,15 @@ function renderToolUseProgressMessage( inProgressToolCallCount, isTranscriptMode, }: { - verbose: boolean - inProgressToolCallCount?: number - isTranscriptMode?: boolean + verbose: boolean; + inProgressToolCallCount?: number; + isTranscriptMode?: boolean; }, terminalSize: { columns: number; rows: number }, ): React.ReactNode { const toolProgressMessages = progressMessagesForMessage.filter( - (msg): msg is ProgressMessage => - msg.data.type !== 'hook_progress', - ) + (msg): msg is ProgressMessage => msg.data.type !== 'hook_progress', + ); try { const toolMessages = tool.renderToolUseProgressMessage?.(toolProgressMessages, { @@ -287,7 +244,7 @@ function renderToolUseProgressMessage( terminalSize, inProgressToolCallCount: inProgressToolCallCount ?? 1, isTranscriptMode, - }) ?? null + }) ?? null; return ( <> @@ -301,26 +258,18 @@ function renderToolUseProgressMessage( {toolMessages} - ) + ); } catch (error) { - logError( - new Error( - `Error rendering tool use progress message for ${tool.name}: ${error}`, - ), - ) - return null + logError(new Error(`Error rendering tool use progress message for ${tool.name}: ${error}`)); + return null; } } function renderToolUseQueuedMessage(tool: Tool): React.ReactNode { try { - return tool.renderToolUseQueuedMessage?.() + return tool.renderToolUseQueuedMessage?.(); } catch (error) { - logError( - new Error( - `Error rendering tool use queued message for ${tool.name}: ${error}`, - ), - ) - return null + logError(new Error(`Error rendering tool use queued message for ${tool.name}: ${error}`)); + return null; } } diff --git a/src/components/messages/AttachmentMessage.tsx b/src/components/messages/AttachmentMessage.tsx index 51f9ea67d..79cbcd5c0 100644 --- a/src/components/messages/AttachmentMessage.tsx +++ b/src/components/messages/AttachmentMessage.tsx @@ -1,88 +1,77 @@ // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered -import React, { useMemo } from 'react' -import { Ansi, Box, Text } from '../../ink.js' -import type { Attachment } from 'src/utils/attachments.js' -import type { NullRenderingAttachmentType } from './nullRenderingAttachments.js' -import { useAppState } from '../../state/AppState.js' -import { getDisplayPath } from 'src/utils/file.js' -import { formatFileSize } from 'src/utils/format.js' -import { MessageResponse } from '../MessageResponse.js' -import { basename, sep } from 'path' -import { UserTextMessage } from './UserTextMessage.js' -import { DiagnosticsDisplay } from '../DiagnosticsDisplay.js' -import { getContentText } from 'src/utils/messages.js' -import type { Theme } from 'src/utils/theme.js' -import { UserImageMessage } from './UserImageMessage.js' -import { toInkColor } from '../../utils/ink.js' -import { jsonParse } from '../../utils/slowOperations.js' -import { plural } from '../../utils/stringUtils.js' -import { isEnvTruthy } from '../../utils/envUtils.js' -import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' -import { - tryRenderPlanApprovalMessage, - formatTeammateMessageContent, -} from './PlanApprovalMessage.js' -import { BLACK_CIRCLE } from '../../constants/figures.js' -import { TeammateMessageContent } from './UserTeammateMessage.js' -import { isShutdownApproved } from '../../utils/teammateMailbox.js' -import { CtrlOToExpand } from '../CtrlOToExpand.js' -import { FilePathLink } from '../FilePathLink.js' -import { feature } from 'bun:bundle' -import { useSelectedMessageBg } from '../messageActions.js' +import React, { useMemo } from 'react'; +import { Ansi, Box, Text } from '../../ink.js'; +import type { Attachment } from 'src/utils/attachments.js'; +import type { NullRenderingAttachmentType } from './nullRenderingAttachments.js'; +import { useAppState } from '../../state/AppState.js'; +import { getDisplayPath } from 'src/utils/file.js'; +import { formatFileSize } from 'src/utils/format.js'; +import { MessageResponse } from '../MessageResponse.js'; +import { basename, sep } from 'path'; +import { UserTextMessage } from './UserTextMessage.js'; +import { DiagnosticsDisplay } from '../DiagnosticsDisplay.js'; +import { getContentText } from 'src/utils/messages.js'; +import type { Theme } from 'src/utils/theme.js'; +import { UserImageMessage } from './UserImageMessage.js'; +import { toInkColor } from '../../utils/ink.js'; +import { jsonParse } from '../../utils/slowOperations.js'; +import { plural } from '../../utils/stringUtils.js'; +import { isEnvTruthy } from '../../utils/envUtils.js'; +import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'; +import { tryRenderPlanApprovalMessage, formatTeammateMessageContent } from './PlanApprovalMessage.js'; +import { BLACK_CIRCLE } from '../../constants/figures.js'; +import { TeammateMessageContent } from './UserTeammateMessage.js'; +import { isShutdownApproved } from '../../utils/teammateMailbox.js'; +import { CtrlOToExpand } from '../CtrlOToExpand.js'; +import { FilePathLink } from '../FilePathLink.js'; +import { feature } from 'bun:bundle'; +import { useSelectedMessageBg } from '../messageActions.js'; type Props = { - addMargin: boolean - attachment: Attachment - verbose: boolean - isTranscriptMode?: boolean -} + addMargin: boolean; + attachment: Attachment; + verbose: boolean; + isTranscriptMode?: boolean; +}; -export function AttachmentMessage({ - attachment, - addMargin, - verbose, - isTranscriptMode, -}: Props): React.ReactNode { - const bg = useSelectedMessageBg() +export function AttachmentMessage({ attachment, addMargin, verbose, isTranscriptMode }: Props): React.ReactNode { + const bg = useSelectedMessageBg(); // Hoisted to mount-time — per-message component, re-renders on every scroll. const isDemoEnv = feature('EXPERIMENTAL_SKILL_SEARCH') ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant useMemo(() => isEnvTruthy(process.env.IS_DEMO), []) - : false + : false; // Handle teammate_mailbox BEFORE switch if (isAgentSwarmsEnabled() && attachment.type === 'teammate_mailbox') { // Filter out idle notifications BEFORE counting - they are hidden in the UI // so showing them in the count would be confusing ("2 messages in mailbox:" with nothing shown) const visibleMessages = attachment.messages.filter(msg => { if (isShutdownApproved(msg.text)) { - return false + return false; } try { - const parsed = jsonParse(msg.text) - return ( - parsed?.type !== 'idle_notification' && - parsed?.type !== 'teammate_terminated' - ) + const parsed = jsonParse(msg.text); + return parsed?.type !== 'idle_notification' && parsed?.type !== 'teammate_terminated'; } catch { - return true // Non-JSON messages are visible + return true; // Non-JSON messages are visible } - }) + }); if (visibleMessages.length === 0) { - return null + return null; } return ( {visibleMessages.map((msg, idx) => { // Try to parse as JSON for task_assignment messages let parsedMsg: { - type?: string - taskId?: string - subject?: string - assignedBy?: string - } | null = null + type?: string; + taskId?: string; + subject?: string; + assignedBy?: string; + } | null = null; try { - parsedMsg = jsonParse(msg.text) + parsedMsg = jsonParse(msg.text); } catch { // Not JSON, treat as plain text } @@ -96,26 +85,20 @@ export function AttachmentMessage({ - {parsedMsg.subject} (from {parsedMsg.assignedBy || msg.from}) - ) + ); } // Note: idle_notification messages already filtered out above // Try to render as plan approval message (request or response) - const planApprovalElement = tryRenderPlanApprovalMessage( - msg.text, - msg.from, - ) + const planApprovalElement = tryRenderPlanApprovalMessage(msg.text, msg.from); if (planApprovalElement) { - return ( - {planApprovalElement} - ) + return {planApprovalElement}; } // Plain text message - sender header with chevron, truncated content - const inkColor = toInkColor(msg.color) - const formattedContent = - formatTeammateMessageContent(msg.text) ?? msg.text + const inkColor = toInkColor(msg.color); + const formattedContent = formatTeammateMessageContent(msg.text) ?? msg.text; return ( - ) + ); })} - ) + ); } // skill_discovery rendered here (not in the switch) so the 'skill_discovery' @@ -136,25 +119,22 @@ export function AttachmentMessage({ // be conditionally eliminated; an if-body can. if (feature('EXPERIMENTAL_SKILL_SEARCH')) { if (attachment.type === 'skill_discovery') { - if (attachment.skills.length === 0) return null + if (attachment.skills.length === 0) return null; // Ant users get shortIds inline so they can /skill-feedback while the // turn is still fresh. External users (when this un-gates) just see // names — shortId is undefined outside ant builds anyway. - const names = attachment.skills - .map(s => (s.shortId ? `${s.name} [${s.shortId}]` : s.name)) - .join(', ') - const firstId = attachment.skills[0]?.shortId + const names = attachment.skills.map(s => (s.shortId ? `${s.name} [${s.shortId}]` : s.name)).join(', '); + const firstId = attachment.skills[0]?.shortId; const hint = process.env.USER_TYPE === 'ant' && !isDemoEnv && firstId ? ` · /skill-feedback ${firstId} 1=wrong 2=noisy 3=good [comment]` - : '' + : ''; return ( - {attachment.skills.length} relevant{' '} - {plural(attachment.skills.length, 'skill')}: {names} + {attachment.skills.length} relevant {plural(attachment.skills.length, 'skill')}: {names} {hint && {hint}} - ) + ); } } @@ -165,23 +145,22 @@ export function AttachmentMessage({ Listed directory {attachment.displayPath + sep} - ) + ); case 'file': case 'already_read_file': if (attachment.content.type === 'notebook') { return ( - Read {attachment.displayPath} ( - {attachment.content.file.cells.length} cells) + Read {attachment.displayPath} ({attachment.content.file.cells.length} cells) - ) + ); } if (attachment.content.type === 'file_unchanged') { return ( Read {attachment.displayPath} (unchanged) - ) + ); } return ( @@ -191,46 +170,39 @@ export function AttachmentMessage({ : formatFileSize(attachment.content.file.originalSize)} ) - ) + ); case 'compact_file_reference': return ( Referenced file {attachment.displayPath} - ) + ); case 'pdf_reference': return ( - Referenced PDF {attachment.displayPath} ( - {attachment.pageCount} pages) + Referenced PDF {attachment.displayPath} ({attachment.pageCount} pages) - ) + ); case 'selected_lines_in_ide': return ( - ⧉ Selected{' '} - {attachment.lineEnd - attachment.lineStart + 1}{' '} - lines from {attachment.displayPath} in{' '} - {attachment.ideName} + ⧉ Selected {attachment.lineEnd - attachment.lineStart + 1} lines from{' '} + {attachment.displayPath} in {attachment.ideName} - ) + ); case 'nested_memory': return ( Loaded {attachment.displayPath} - ) + ); case 'relevant_memories': // Usually absorbed into a CollapsedReadSearchGroup (collapseReadSearch.ts) // so this only renders when the preceding tool was non-collapsible (Edit, // Write) and no group was open. Match CollapsedReadSearchContent's style: // 2-space gutter, dim text, count only — filenames/content in ctrl+o. return ( - + @@ -249,9 +221,7 @@ export function AttachmentMessage({ - - {basename(m.path)} - + {basename(m.path)} {isTranscriptMode && ( @@ -264,9 +234,9 @@ export function AttachmentMessage({ ))} - ) + ); case 'dynamic_skill': { - const skillCount = attachment.skillNames.length + const skillCount = attachment.skillNames.length; return ( Loaded{' '} @@ -275,37 +245,32 @@ export function AttachmentMessage({ {' '} from {attachment.displayPath} - ) + ); } case 'skill_listing': { if (attachment.isInitial) { - return null + return null; } return ( - {attachment.skillCount}{' '} - {plural(attachment.skillCount, 'skill')} available + {attachment.skillCount} {plural(attachment.skillCount, 'skill')} available - ) + ); } case 'agent_listing_delta': { if (attachment.isInitial || attachment.addedTypes.length === 0) { - return null + return null; } - const count = attachment.addedTypes.length + const count = attachment.addedTypes.length; return ( {count} agent {plural(count, 'type')} available - ) + ); } case 'queued_command': { - const text = - typeof attachment.prompt === 'string' - ? attachment.prompt - : getContentText(attachment.prompt) || '' - const hasImages = - attachment.imagePasteIds && attachment.imagePasteIds.length > 0 + const text = typeof attachment.prompt === 'string' ? attachment.prompt : getContentText(attachment.prompt) || ''; + const hasImages = attachment.imagePasteIds && attachment.imagePasteIds.length > 0; return ( - {hasImages && - attachment.imagePasteIds?.map(id => ( - - ))} + {hasImages && attachment.imagePasteIds?.map(id => )} - ) + ); } case 'plan_file_reference': - return ( - - Plan file referenced ({getDisplayPath(attachment.planFilePath)}) - - ) + return Plan file referenced ({getDisplayPath(attachment.planFilePath)}); case 'invoked_skills': { if (attachment.skills.length === 0) { - return null + return null; } - const skillNames = attachment.skills.map(s => s.name).join(', ') - return Skills restored ({skillNames}) + const skillNames = attachment.skills.map(s => s.name).join(', '); + return Skills restored ({skillNames}); } case 'diagnostics': - return + return ; case 'mcp_resource': return ( - Read MCP resource {attachment.name} from{' '} - {attachment.server} + Read MCP resource {attachment.name} from {attachment.server} - ) + ); case 'command_permissions': // The skill success message is rendered by SkillTool's renderToolResultMessage, // so we don't render anything here to avoid duplicate messages. - return null + return null; case 'async_hook_response': { // SessionStart hook completions are only shown in verbose mode if (attachment.hookEvent === 'SessionStart' && !verbose) { - return null + return null; } // Generally hide async hook completion messages unless in verbose mode if (!verbose && !isTranscriptMode) { - return null + return null; } return ( Async hook {attachment.hookEvent} completed - ) + ); } case 'hook_blocking_error': { // Stop hooks are rendered as a summary in SystemStopHookSummaryMessage - if ( - attachment.hookEvent === 'Stop' || - attachment.hookEvent === 'SubagentStop' - ) { - return null + if (attachment.hookEvent === 'Stop' || attachment.hookEvent === 'SubagentStop') { + return null; } // Show stderr to the user so they can understand why the hook blocked - const stderr = attachment.blockingError.blockingError.trim() + const stderr = attachment.blockingError.blockingError.trim(); return ( <> - - {attachment.hookName} hook returned blocking error - + {attachment.hookName} hook returned blocking error {stderr ? {stderr} : null} - ) + ); } case 'hook_non_blocking_error': { // Stop hooks are rendered as a summary in SystemStopHookSummaryMessage - if ( - attachment.hookEvent === 'Stop' || - attachment.hookEvent === 'SubagentStop' - ) { - return null + if (attachment.hookEvent === 'Stop' || attachment.hookEvent === 'SubagentStop') { + return null; } // Full hook output is logged to debug log via hookEvents.ts - return {attachment.hookName} hook error + return {attachment.hookName} hook error; } case 'hook_error_during_execution': // Stop hooks are rendered as a summary in SystemStopHookSummaryMessage - if ( - attachment.hookEvent === 'Stop' || - attachment.hookEvent === 'SubagentStop' - ) { - return null + if (attachment.hookEvent === 'Stop' || attachment.hookEvent === 'SubagentStop') { + return null; } // Full hook output is logged to debug log via hookEvents.ts - return {attachment.hookName} hook warning + return {attachment.hookName} hook warning; case 'hook_success': // Full hook output is logged to debug log via hookEvents.ts - return null + return null; case 'hook_stopped_continuation': // Stop hooks are rendered as a summary in SystemStopHookSummaryMessage - if ( - attachment.hookEvent === 'Stop' || - attachment.hookEvent === 'SubagentStop' - ) { - return null + if (attachment.hookEvent === 'Stop' || attachment.hookEvent === 'SubagentStop') { + return null; } return ( {attachment.hookName} hook stopped continuation: {attachment.message} - ) + ); case 'hook_system_message': return ( {attachment.hookName} says: {attachment.content} - ) + ); case 'hook_permission_decision': { - const action = attachment.decision === 'allow' ? 'Allowed' : 'Denied' + const action = attachment.decision === 'allow' ? 'Allowed' : 'Denied'; return ( {action} by {attachment.hookEvent} hook - ) + ); } case 'task_status': - return + return ; case 'teammate_shutdown_batch': return ( - + {BLACK_CIRCLE} - {attachment.count} {plural(attachment.count, 'teammate')} shut down - gracefully + {attachment.count} {plural(attachment.count, 'teammate')} shut down gracefully - ) + ); default: // Exhaustiveness: every type reaching here must be in NULL_RENDERING_TYPES. // If TS errors, a new Attachment type was added without a case above AND @@ -459,43 +396,32 @@ export function AttachmentMessage({ // skill_discovery and teammate_mailbox are handled BEFORE the switch in // runtime-gated blocks (feature() / isAgentSwarmsEnabled()) that TS can't // narrow through — excluded here via type union (compile-time only, no emit). - attachment.type satisfies - | NullRenderingAttachmentType - | 'skill_discovery' - | 'teammate_mailbox' - return null + attachment.type satisfies NullRenderingAttachmentType | 'skill_discovery' | 'teammate_mailbox'; + return null; } } -type TaskStatusAttachment = Extract +type TaskStatusAttachment = Extract; -function TaskStatusMessage({ - attachment, -}: { - attachment: TaskStatusAttachment -}): React.ReactNode { +function TaskStatusMessage({ attachment }: { attachment: TaskStatusAttachment }): React.ReactNode { // For ants, killed task status is shown in the CoordinatorTaskPanel. // Don't render it again in the chat. if (process.env.USER_TYPE === 'ant' && attachment.status === 'killed') { - return null + return null; } // Only access teammate-specific code when swarms are enabled. // TeammateTaskStatus subscribes to AppState; by gating the mount we // avoid adding a store listener for every non-teammate attachment. if (isAgentSwarmsEnabled() && attachment.taskType === 'in_process_teammate') { - return + return ; } - return + return ; } -function GenericTaskStatus({ - attachment, -}: { - attachment: TaskStatusAttachment -}): React.ReactNode { - const bg = useSelectedMessageBg() +function GenericTaskStatus({ attachment }: { attachment: TaskStatusAttachment }): React.ReactNode { + const bg = useSelectedMessageBg(); const statusText = attachment.status === 'completed' ? 'completed in background' @@ -503,7 +429,7 @@ function GenericTaskStatus({ ? 'stopped' : attachment.status === 'running' ? 'still running in background' - : attachment.status + : attachment.status; return ( {BLACK_CIRCLE} @@ -511,26 +437,19 @@ function GenericTaskStatus({ Task "{attachment.description}" {statusText} - ) + ); } -function TeammateTaskStatus({ - attachment, -}: { - attachment: TaskStatusAttachment -}): React.ReactNode { - const bg = useSelectedMessageBg() +function TeammateTaskStatus({ attachment }: { attachment: TaskStatusAttachment }): React.ReactNode { + const bg = useSelectedMessageBg(); // Narrow selector: only re-render when this specific task changes. - const task = useAppState(s => s.tasks[attachment.taskId]) + const task = useAppState(s => s.tasks[attachment.taskId]); if (task?.type !== 'in_process_teammate') { // Fall through to generic rendering (task not yet in store, or wrong type) - return + return ; } - const agentColor = toInkColor(task.identity.color) - const statusText = - attachment.status === 'completed' - ? 'shut down gracefully' - : attachment.status + const agentColor = toInkColor(task.identity.color); + const statusText = attachment.status === 'completed' ? 'shut down gracefully' : attachment.status; return ( {BLACK_CIRCLE} @@ -542,7 +461,7 @@ function TeammateTaskStatus({ {statusText} - ) + ); } // We allow setting dimColor to false here to help work around the dim-bold bug. // https://github.com/chalk/chalk/issues/290 @@ -551,11 +470,11 @@ function Line({ children, color, }: { - dimColor?: boolean - children: React.ReactNode - color?: keyof Theme + dimColor?: boolean; + children: React.ReactNode; + color?: keyof Theme; }): React.ReactNode { - const bg = useSelectedMessageBg() + const bg = useSelectedMessageBg(); return ( @@ -564,5 +483,5 @@ function Line({ - ) + ); } diff --git a/src/components/messages/CollapsedReadSearchContent.tsx b/src/components/messages/CollapsedReadSearchContent.tsx index d8df34f69..27008640b 100644 --- a/src/components/messages/CollapsedReadSearchContent.tsx +++ b/src/components/messages/CollapsedReadSearchContent.tsx @@ -1,47 +1,44 @@ -import { feature } from 'bun:bundle' -import { basename } from 'path' -import React, { useRef } from 'react' -import { useMinDisplayTime } from '../../hooks/useMinDisplayTime.js' -import { Ansi, Box, Text, useTheme } from '../../ink.js' -import { findToolByName, type Tools } from '../../Tool.js' -import { getReplPrimitiveTools } from '../../tools/REPLTool/primitiveTools.js' -import type { - CollapsedReadSearchGroup, - NormalizedAssistantMessage, -} from '../../types/message.js' -import { uniq } from '../../utils/array.js' -import { getToolUseIdsFromCollapsedGroup } from '../../utils/collapseReadSearch.js' -import { getDisplayPath } from '../../utils/file.js' -import { formatDuration, formatSecondsShort } from '../../utils/format.js' -import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js' -import type { buildMessageLookups } from '../../utils/messages.js' -import type { ThemeName } from '../../utils/theme.js' -import { CtrlOToExpand } from '../CtrlOToExpand.js' -import { useSelectedMessageBg } from '../messageActions.js' -import { PrBadge } from '../PrBadge.js' -import { ToolUseLoader } from '../ToolUseLoader.js' +import { feature } from 'bun:bundle'; +import { basename } from 'path'; +import React, { useRef } from 'react'; +import { useMinDisplayTime } from '../../hooks/useMinDisplayTime.js'; +import { Ansi, Box, Text, useTheme } from '../../ink.js'; +import { findToolByName, type Tools } from '../../Tool.js'; +import { getReplPrimitiveTools } from '../../tools/REPLTool/primitiveTools.js'; +import type { CollapsedReadSearchGroup, NormalizedAssistantMessage } from '../../types/message.js'; +import { uniq } from '../../utils/array.js'; +import { getToolUseIdsFromCollapsedGroup } from '../../utils/collapseReadSearch.js'; +import { getDisplayPath } from '../../utils/file.js'; +import { formatDuration, formatSecondsShort } from '../../utils/format.js'; +import { isFullscreenEnvEnabled } from '../../utils/fullscreen.js'; +import type { buildMessageLookups } from '../../utils/messages.js'; +import type { ThemeName } from '../../utils/theme.js'; +import { CtrlOToExpand } from '../CtrlOToExpand.js'; +import { useSelectedMessageBg } from '../messageActions.js'; +import { PrBadge } from '../PrBadge.js'; +import { ToolUseLoader } from '../ToolUseLoader.js'; /* eslint-disable @typescript-eslint/no-require-imports */ const teamMemCollapsed = feature('TEAMMEM') ? (require('./teamMemCollapsed.js') as typeof import('./teamMemCollapsed.js')) - : null + : null; /* eslint-enable @typescript-eslint/no-require-imports */ // Hold each ⤿ hint for a minimum duration so fast-completing tool calls // (bash commands, file reads, search patterns) are actually readable instead // of flickering past in a single frame. -const MIN_HINT_DISPLAY_MS = 700 +const MIN_HINT_DISPLAY_MS = 700; type Props = { - message: CollapsedReadSearchGroup - inProgressToolUseIDs: Set - shouldAnimate: boolean - verbose: boolean - tools: Tools - lookups: ReturnType + message: CollapsedReadSearchGroup; + inProgressToolUseIDs: Set; + shouldAnimate: boolean; + verbose: boolean; + tools: Tools; + lookups: ReturnType; /** True if this is the currently active collapsed group (last one, still loading) */ - isActiveGroup?: boolean -} + isActiveGroup?: boolean; +}; /** Render a single tool use in verbose mode */ function VerboseToolUse({ @@ -52,52 +49,38 @@ function VerboseToolUse({ shouldAnimate, theme, }: { - content: { type: 'tool_use'; id: string; name: string; input: unknown } - tools: Tools - lookups: ReturnType - inProgressToolUseIDs: Set - shouldAnimate: boolean - theme: ThemeName + content: { type: 'tool_use'; id: string; name: string; input: unknown }; + tools: Tools; + lookups: ReturnType; + inProgressToolUseIDs: Set; + shouldAnimate: boolean; + theme: ThemeName; }): React.ReactNode { - const bg = useSelectedMessageBg() + const bg = useSelectedMessageBg(); // Same REPL-primitive fallback as getToolSearchOrReadInfo — REPL mode strips // these from the execution tools list, but virtual messages still need them // to render in verbose mode. - const tool = - findToolByName(tools, content.name) ?? - findToolByName(getReplPrimitiveTools(), content.name) - if (!tool) return null - - const isResolved = lookups.resolvedToolUseIDs.has(content.id) - const isError = lookups.erroredToolUseIDs.has(content.id) - const isInProgress = inProgressToolUseIDs.has(content.id) - - const resultMsg = lookups.toolResultByToolUseID.get(content.id) - const rawToolResult = - resultMsg?.type === 'user' ? resultMsg.toolUseResult : undefined - const parsedOutput = tool.outputSchema?.safeParse(rawToolResult) - const toolResult = parsedOutput?.success ? parsedOutput.data : undefined - - const parsedInput = tool.inputSchema.safeParse(content.input) - const input = parsedInput.success ? parsedInput.data : undefined - const userFacingName = tool.userFacingName(input) - const toolUseMessage = input - ? tool.renderToolUseMessage(input, { theme, verbose: true }) - : null + const tool = findToolByName(tools, content.name) ?? findToolByName(getReplPrimitiveTools(), content.name); + if (!tool) return null; + + const isResolved = lookups.resolvedToolUseIDs.has(content.id); + const isError = lookups.erroredToolUseIDs.has(content.id); + const isInProgress = inProgressToolUseIDs.has(content.id); + + const resultMsg = lookups.toolResultByToolUseID.get(content.id); + const rawToolResult = resultMsg?.type === 'user' ? resultMsg.toolUseResult : undefined; + const parsedOutput = tool.outputSchema?.safeParse(rawToolResult); + const toolResult = parsedOutput?.success ? parsedOutput.data : undefined; + + const parsedInput = tool.inputSchema.safeParse(content.input); + const input = parsedInput.success ? parsedInput.data : undefined; + const userFacingName = tool.userFacingName(input); + const toolUseMessage = input ? tool.renderToolUseMessage(input, { theme, verbose: true }) : null; return ( - + - + {userFacingName} {toolUseMessage && ({toolUseMessage})} @@ -114,7 +97,7 @@ function VerboseToolUse({ )} - ) + ); } export function CollapsedReadSearchContent({ @@ -126,7 +109,7 @@ export function CollapsedReadSearchContent({ lookups, isActiveGroup, }: Props): React.ReactNode { - const bg = useSelectedMessageBg() + const bg = useSelectedMessageBg(); const { searchCount: rawSearchCount, readCount: rawReadCount, @@ -136,49 +119,35 @@ export function CollapsedReadSearchContent({ memoryReadCount, memoryWriteCount, messages: groupMessages, - } = message - const [theme] = useTheme() - const toolUseIds = getToolUseIdsFromCollapsedGroup(message) - const anyError = toolUseIds.some(id => lookups.erroredToolUseIDs.has(id)) - const hasMemoryOps = - memorySearchCount > 0 || memoryReadCount > 0 || memoryWriteCount > 0 - const hasTeamMemoryOps = feature('TEAMMEM') - ? teamMemCollapsed!.checkHasTeamMemOps(message) - : false + } = message; + const [theme] = useTheme(); + const toolUseIds = getToolUseIdsFromCollapsedGroup(message); + const anyError = toolUseIds.some(id => lookups.erroredToolUseIDs.has(id)); + const hasMemoryOps = memorySearchCount > 0 || memoryReadCount > 0 || memoryWriteCount > 0; + const hasTeamMemoryOps = feature('TEAMMEM') ? teamMemCollapsed!.checkHasTeamMemOps(message) : false; // Track the max seen counts so they only ever increase. The debounce timer // causes extra re-renders at arbitrary times; during a brief "invisible window" // in the streaming executor the group count can dip, which causes jitter. - const maxReadCountRef = useRef(0) - const maxSearchCountRef = useRef(0) - const maxListCountRef = useRef(0) - const maxMcpCountRef = useRef(0) - const maxBashCountRef = useRef(0) - maxReadCountRef.current = Math.max(maxReadCountRef.current, rawReadCount) - maxSearchCountRef.current = Math.max( - maxSearchCountRef.current, - rawSearchCount, - ) - maxListCountRef.current = Math.max(maxListCountRef.current, rawListCount) - maxMcpCountRef.current = Math.max( - maxMcpCountRef.current, - message.mcpCallCount ?? 0, - ) - maxBashCountRef.current = Math.max( - maxBashCountRef.current, - message.bashCount ?? 0, - ) - const readCount = maxReadCountRef.current - const searchCount = maxSearchCountRef.current - const listCount = maxListCountRef.current - const mcpCallCount = maxMcpCountRef.current + const maxReadCountRef = useRef(0); + const maxSearchCountRef = useRef(0); + const maxListCountRef = useRef(0); + const maxMcpCountRef = useRef(0); + const maxBashCountRef = useRef(0); + maxReadCountRef.current = Math.max(maxReadCountRef.current, rawReadCount); + maxSearchCountRef.current = Math.max(maxSearchCountRef.current, rawSearchCount); + maxListCountRef.current = Math.max(maxListCountRef.current, rawListCount); + maxMcpCountRef.current = Math.max(maxMcpCountRef.current, message.mcpCallCount ?? 0); + maxBashCountRef.current = Math.max(maxBashCountRef.current, message.bashCount ?? 0); + const readCount = maxReadCountRef.current; + const searchCount = maxSearchCountRef.current; + const listCount = maxListCountRef.current; + const mcpCallCount = maxMcpCountRef.current; // Subtract commands surfaced as "Committed …" / "Created PR …" so the // same command isn't counted twice. gitOpBashCount is read live (no max-ref // needed — it's 0 until results arrive, then only grows). - const gitOpBashCount = message.gitOpBashCount ?? 0 - const bashCount = isFullscreenEnvEnabled() - ? Math.max(0, maxBashCountRef.current - gitOpBashCount) - : 0 + const gitOpBashCount = message.gitOpBashCount ?? 0; + const bashCount = isFullscreenEnvEnabled() ? Math.max(0, maxBashCountRef.current - gitOpBashCount) : 0; const hasNonMemoryOps = searchCount > 0 || @@ -187,18 +156,16 @@ export function CollapsedReadSearchContent({ replCount > 0 || mcpCallCount > 0 || bashCount > 0 || - gitOpBashCount > 0 + gitOpBashCount > 0; - const readPaths = message.readFilePaths - const searchArgs = message.searchArgs - let incomingHint = message.latestDisplayHint + const readPaths = message.readFilePaths; + const searchArgs = message.searchArgs; + let incomingHint = message.latestDisplayHint; if (incomingHint === undefined) { - const lastSearchRaw = searchArgs?.at(-1) - const lastSearch = - lastSearchRaw !== undefined ? `"${lastSearchRaw}"` : undefined - const lastRead = readPaths?.at(-1) - incomingHint = - lastRead !== undefined ? getDisplayPath(lastRead) : lastSearch + const lastSearchRaw = searchArgs?.at(-1); + const lastSearch = lastSearchRaw !== undefined ? `"${lastSearchRaw}"` : undefined; + const lastRead = readPaths?.at(-1); + incomingHint = lastRead !== undefined ? getDisplayPath(lastRead) : lastSearch; } // Active REPL calls emit repl_tool_call progress with the current inner @@ -206,41 +173,38 @@ export function CollapsedReadSearchContent({ // so this is the only source of a live hint during execution. if (isActiveGroup) { for (const id of toolUseIds) { - if (!inProgressToolUseIDs.has(id)) continue - const latest = lookups.progressMessagesByToolUseID.get(id)?.at(-1)?.data + if (!inProgressToolUseIDs.has(id)) continue; + const latest = lookups.progressMessagesByToolUseID.get(id)?.at(-1)?.data; if (latest?.type === 'repl_tool_call' && latest.phase === 'start') { const input = latest.toolInput as { - command?: string - pattern?: string - file_path?: string - } + command?: string; + pattern?: string; + file_path?: string; + }; incomingHint = - input.file_path ?? - (input.pattern ? `"${input.pattern}"` : undefined) ?? - input.command ?? - latest.toolName + input.file_path ?? (input.pattern ? `"${input.pattern}"` : undefined) ?? input.command ?? latest.toolName; } } } - const displayedHint = useMinDisplayTime(incomingHint, MIN_HINT_DISPLAY_MS) + const displayedHint = useMinDisplayTime(incomingHint, MIN_HINT_DISPLAY_MS); // In verbose mode, render each tool use with its 1-line result summary if (verbose) { - const toolUses: NormalizedAssistantMessage[] = [] + const toolUses: NormalizedAssistantMessage[] = []; for (const msg of groupMessages) { if (msg.type === 'assistant') { - toolUses.push(msg) + toolUses.push(msg); } else if (msg.type === 'grouped_tool_use') { - toolUses.push(...msg.messages) + toolUses.push(...msg.messages); } } return ( {toolUses.map(msg => { - const content = msg.message.content[0] - if (content?.type !== 'tool_use') return null + const content = msg.message.content[0]; + if (content?.type !== 'tool_use') return null; return ( - ) + ); })} {message.hookInfos && message.hookInfos.length > 0 && ( <> - {' ⎿ '}Ran {message.hookCount} PreToolUse{' '} - {message.hookCount === 1 ? 'hook' : 'hooks'} ( + {' ⎿ '}Ran {message.hookCount} PreToolUse {message.hookCount === 1 ? 'hook' : 'hooks'} ( {formatSecondsShort(message.hookTotalMs ?? 0)}) {message.hookInfos.map((info, idx) => ( @@ -281,7 +244,7 @@ export function CollapsedReadSearchContent({ ))} - ) + ); } // Non-verbose mode: Show counts with blinking grey dot while active, green dot when finalized @@ -290,79 +253,69 @@ export function CollapsedReadSearchContent({ // Defensive: If all counts are 0, don't render the collapsed group // This shouldn't happen in normal operation, but handles edge cases if (!hasMemoryOps && !hasTeamMemoryOps && !hasNonMemoryOps) { - return null + return null; } // Find the slowest in-progress shell command in this group. BashTool yields // progress every second but the collapsed renderer never showed it — long // commands (npm install, tests) looked frozen. Shown after 2s so fast // commands stay clean; the ticking counter reassures that slow ones aren't stuck. - let shellProgressSuffix = '' + let shellProgressSuffix = ''; if (isFullscreenEnvEnabled() && isActiveGroup) { - let elapsed: number | undefined - let lines = 0 + let elapsed: number | undefined; + let lines = 0; for (const id of toolUseIds) { - if (!inProgressToolUseIDs.has(id)) continue - const data = lookups.progressMessagesByToolUseID.get(id)?.at(-1)?.data - if ( - data?.type !== 'bash_progress' && - data?.type !== 'powershell_progress' - ) { - continue + if (!inProgressToolUseIDs.has(id)) continue; + const data = lookups.progressMessagesByToolUseID.get(id)?.at(-1)?.data; + if (data?.type !== 'bash_progress' && data?.type !== 'powershell_progress') { + continue; } if (elapsed === undefined || data.elapsedTimeSeconds > elapsed) { - elapsed = data.elapsedTimeSeconds - lines = data.totalLines + elapsed = data.elapsedTimeSeconds; + lines = data.totalLines; } } if (elapsed !== undefined && elapsed >= 2) { - const time = formatDuration(elapsed * 1000) - shellProgressSuffix = - lines > 0 - ? ` (${time} · ${lines} ${lines === 1 ? 'line' : 'lines'})` - : ` (${time})` + const time = formatDuration(elapsed * 1000); + shellProgressSuffix = lines > 0 ? ` (${time} · ${lines} ${lines === 1 ? 'line' : 'lines'})` : ` (${time})`; } } // Build non-memory parts first (search, read, repl, mcp, bash) — these render // before memory so the line reads "Ran 3 bash commands, recalled 1 memory". - const nonMemParts: React.ReactNode[] = [] + const nonMemParts: React.ReactNode[] = []; // Git operations lead the line — they're the load-bearing outcome. function pushPart(key: string, verb: string, body: React.ReactNode): void { - const isFirst = nonMemParts.length === 0 - if (!isFirst) nonMemParts.push(, ) + const isFirst = nonMemParts.length === 0; + if (!isFirst) nonMemParts.push(, ); nonMemParts.push( {isFirst ? verb[0]!.toUpperCase() + verb.slice(1) : verb} {body} , - ) + ); } if (isFullscreenEnvEnabled() && message.commits?.length) { const byKind = { committed: 'committed', amended: 'amended commit', 'cherry-picked': 'cherry-picked', - } + }; for (const kind of ['committed', 'amended', 'cherry-picked'] as const) { - const shas = message.commits.filter(c => c.kind === kind).map(c => c.sha) + const shas = message.commits.filter(c => c.kind === kind).map(c => c.sha); if (shas.length) { - pushPart(kind, byKind[kind], {shas.join(', ')}) + pushPart(kind, byKind[kind], {shas.join(', ')}); } } } if (isFullscreenEnvEnabled() && message.pushes?.length) { - const branches = uniq(message.pushes.map(p => p.branch)) - pushPart('push', 'pushed to', {branches.join(', ')}) + const branches = uniq(message.pushes.map(p => p.branch)); + pushPart('push', 'pushed to', {branches.join(', ')}); } if (isFullscreenEnvEnabled() && message.branches?.length) { - const byAction = { merged: 'merged', rebased: 'rebased onto' } + const byAction = { merged: 'merged', rebased: 'rebased onto' }; for (const b of message.branches) { - pushPart( - `br-${b.action}-${b.ref}`, - byAction[b.action], - {b.ref}, - ) + pushPart(`br-${b.action}-${b.ref}`, byAction[b.action], {b.ref}); } } if (isFullscreenEnvEnabled() && message.prs?.length) { @@ -373,108 +326,79 @@ export function CollapsedReadSearchContent({ commented: 'commented on', closed: 'closed', ready: 'marked ready', - } + }; for (const pr of message.prs) { pushPart( `pr-${pr.action}-${pr.number}`, verbs[pr.action], - pr.url ? ( - - ) : ( - PR #{pr.number} - ), - ) + pr.url ? : PR #{pr.number}, + ); } } if (searchCount > 0) { - const isFirst = nonMemParts.length === 0 + const isFirst = nonMemParts.length === 0; const searchVerb = isActiveGroup ? isFirst ? 'Searching for' : 'searching for' : isFirst ? 'Searched for' - : 'searched for' + : 'searched for'; if (!isFirst) { - nonMemParts.push(, ) + nonMemParts.push(, ); } nonMemParts.push( - {searchVerb} {searchCount}{' '} - {searchCount === 1 ? 'pattern' : 'patterns'} + {searchVerb} {searchCount} {searchCount === 1 ? 'pattern' : 'patterns'} , - ) + ); } if (readCount > 0) { - const isFirst = nonMemParts.length === 0 - const readVerb = isActiveGroup - ? isFirst - ? 'Reading' - : 'reading' - : isFirst - ? 'Read' - : 'read' + const isFirst = nonMemParts.length === 0; + const readVerb = isActiveGroup ? (isFirst ? 'Reading' : 'reading') : isFirst ? 'Read' : 'read'; if (!isFirst) { - nonMemParts.push(, ) + nonMemParts.push(, ); } nonMemParts.push( - {readVerb} {readCount}{' '} - {readCount === 1 ? 'file' : 'files'} + {readVerb} {readCount} {readCount === 1 ? 'file' : 'files'} , - ) + ); } if (listCount > 0) { - const isFirst = nonMemParts.length === 0 - const listVerb = isActiveGroup - ? isFirst - ? 'Listing' - : 'listing' - : isFirst - ? 'Listed' - : 'listed' + const isFirst = nonMemParts.length === 0; + const listVerb = isActiveGroup ? (isFirst ? 'Listing' : 'listing') : isFirst ? 'Listed' : 'listed'; if (!isFirst) { - nonMemParts.push(, ) + nonMemParts.push(, ); } nonMemParts.push( - {listVerb} {listCount}{' '} - {listCount === 1 ? 'directory' : 'directories'} + {listVerb} {listCount} {listCount === 1 ? 'directory' : 'directories'} , - ) + ); } if (replCount > 0) { - const replVerb = isActiveGroup ? "REPL'ing" : "REPL'd" + const replVerb = isActiveGroup ? "REPL'ing" : "REPL'd"; if (nonMemParts.length > 0) { - nonMemParts.push(, ) + nonMemParts.push(, ); } nonMemParts.push( - {replVerb} {replCount}{' '} - {replCount === 1 ? 'time' : 'times'} + {replVerb} {replCount} {replCount === 1 ? 'time' : 'times'} , - ) + ); } if (mcpCallCount > 0) { - const serverLabel = - message.mcpServerNames - ?.map(n => n.replace(/^claude\.ai /, '')) - .join(', ') || 'MCP' - const isFirst = nonMemParts.length === 0 - const verb = isActiveGroup - ? isFirst - ? 'Querying' - : 'querying' - : isFirst - ? 'Queried' - : 'queried' + const serverLabel = message.mcpServerNames?.map(n => n.replace(/^claude\.ai /, '')).join(', ') || 'MCP'; + const isFirst = nonMemParts.length === 0; + const verb = isActiveGroup ? (isFirst ? 'Querying' : 'querying') : isFirst ? 'Queried' : 'queried'; if (!isFirst) { - nonMemParts.push(, ) + nonMemParts.push(, ); } nonMemParts.push( @@ -486,96 +410,65 @@ export function CollapsedReadSearchContent({ )} , - ) + ); } if (isFullscreenEnvEnabled() && bashCount > 0) { - const isFirst = nonMemParts.length === 0 - const verb = isActiveGroup - ? isFirst - ? 'Running' - : 'running' - : isFirst - ? 'Ran' - : 'ran' + const isFirst = nonMemParts.length === 0; + const verb = isActiveGroup ? (isFirst ? 'Running' : 'running') : isFirst ? 'Ran' : 'ran'; if (!isFirst) { - nonMemParts.push(, ) + nonMemParts.push(, ); } nonMemParts.push( - {verb} {bashCount} bash{' '} - {bashCount === 1 ? 'command' : 'commands'} + {verb} {bashCount} bash {bashCount === 1 ? 'command' : 'commands'} , - ) + ); } // Build memory parts (auto-memory) — rendered after nonMemParts - const hasPrecedingNonMem = nonMemParts.length > 0 - const memParts: React.ReactNode[] = [] + const hasPrecedingNonMem = nonMemParts.length > 0; + const memParts: React.ReactNode[] = []; if (memoryReadCount > 0) { - const isFirst = !hasPrecedingNonMem && memParts.length === 0 - const verb = isActiveGroup - ? isFirst - ? 'Recalling' - : 'recalling' - : isFirst - ? 'Recalled' - : 'recalled' + const isFirst = !hasPrecedingNonMem && memParts.length === 0; + const verb = isActiveGroup ? (isFirst ? 'Recalling' : 'recalling') : isFirst ? 'Recalled' : 'recalled'; if (!isFirst) { - memParts.push(, ) + memParts.push(, ); } memParts.push( - {verb} {memoryReadCount}{' '} - {memoryReadCount === 1 ? 'memory' : 'memories'} + {verb} {memoryReadCount} {memoryReadCount === 1 ? 'memory' : 'memories'} , - ) + ); } if (memorySearchCount > 0) { - const isFirst = !hasPrecedingNonMem && memParts.length === 0 - const verb = isActiveGroup - ? isFirst - ? 'Searching' - : 'searching' - : isFirst - ? 'Searched' - : 'searched' + const isFirst = !hasPrecedingNonMem && memParts.length === 0; + const verb = isActiveGroup ? (isFirst ? 'Searching' : 'searching') : isFirst ? 'Searched' : 'searched'; if (!isFirst) { - memParts.push(, ) + memParts.push(, ); } - memParts.push({`${verb} memories`}) + memParts.push({`${verb} memories`}); } if (memoryWriteCount > 0) { - const isFirst = !hasPrecedingNonMem && memParts.length === 0 - const verb = isActiveGroup - ? isFirst - ? 'Writing' - : 'writing' - : isFirst - ? 'Wrote' - : 'wrote' + const isFirst = !hasPrecedingNonMem && memParts.length === 0; + const verb = isActiveGroup ? (isFirst ? 'Writing' : 'writing') : isFirst ? 'Wrote' : 'wrote'; if (!isFirst) { - memParts.push(, ) + memParts.push(, ); } memParts.push( - {verb} {memoryWriteCount}{' '} - {memoryWriteCount === 1 ? 'memory' : 'memories'} + {verb} {memoryWriteCount} {memoryWriteCount === 1 ? 'memory' : 'memories'} , - ) + ); } return ( - {isActiveGroup ? ( - - ) : ( - - )} + {isActiveGroup ? : } {nonMemParts} {memParts} @@ -609,11 +502,10 @@ export function CollapsedReadSearchContent({ )} {message.hookTotalMs !== undefined && message.hookTotalMs > 0 && ( - {' ⎿ '}Ran {message.hookCount} PreToolUse{' '} - {message.hookCount === 1 ? 'hook' : 'hooks'} ( + {' ⎿ '}Ran {message.hookCount} PreToolUse {message.hookCount === 1 ? 'hook' : 'hooks'} ( {formatSecondsShort(message.hookTotalMs)}) )} - ) + ); } diff --git a/src/components/messages/CompactBoundaryMessage.tsx b/src/components/messages/CompactBoundaryMessage.tsx index 7c4e87af1..0ab382239 100644 --- a/src/components/messages/CompactBoundaryMessage.tsx +++ b/src/components/messages/CompactBoundaryMessage.tsx @@ -1,19 +1,13 @@ -import * as React from 'react' -import { Box, Text } from '../../ink.js' -import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js' +import * as React from 'react'; +import { Box, Text } from '../../ink.js'; +import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js'; export function CompactBoundaryMessage(): React.ReactNode { - const historyShortcut = useShortcutDisplay( - 'app:toggleTranscript', - 'Global', - 'ctrl+o', - ) + const historyShortcut = useShortcutDisplay('app:toggleTranscript', 'Global', 'ctrl+o'); return ( - - ✻ Conversation compacted ({historyShortcut} for history) - + ✻ Conversation compacted ({historyShortcut} for history) - ) + ); } diff --git a/src/components/messages/GroupedToolUseContent.tsx b/src/components/messages/GroupedToolUseContent.tsx index 2376e377c..14760e27a 100644 --- a/src/components/messages/GroupedToolUseContent.tsx +++ b/src/components/messages/GroupedToolUseContent.tsx @@ -1,23 +1,16 @@ -import type { - ToolResultBlockParam, - ToolUseBlockParam, -} from '@anthropic-ai/sdk/resources/messages/messages.mjs' -import * as React from 'react' -import { - filterToolProgressMessages, - findToolByName, - type Tools, -} from '../../Tool.js' -import type { GroupedToolUseMessage } from '../../types/message.js' -import type { buildMessageLookups } from '../../utils/messages.js' +import type { ToolResultBlockParam, ToolUseBlockParam } from '@anthropic-ai/sdk/resources/messages/messages.mjs'; +import * as React from 'react'; +import { filterToolProgressMessages, findToolByName, type Tools } from '../../Tool.js'; +import type { GroupedToolUseMessage } from '../../types/message.js'; +import type { buildMessageLookups } from '../../utils/messages.js'; type Props = { - message: GroupedToolUseMessage - tools: Tools - lookups: ReturnType - inProgressToolUseIDs: Set - shouldAnimate: boolean -} + message: GroupedToolUseMessage; + tools: Tools; + lookups: ReturnType; + inProgressToolUseIDs: Set; + shouldAnimate: boolean; +}; export function GroupedToolUseContent({ message, @@ -26,46 +19,41 @@ export function GroupedToolUseContent({ inProgressToolUseIDs, shouldAnimate, }: Props): React.ReactNode { - const tool = findToolByName(tools, message.toolName) + const tool = findToolByName(tools, message.toolName); if (!tool?.renderGroupedToolUse) { - return null + return null; } // Build a map from tool_use_id to result data - const resultsByToolUseId = new Map< - string, - { param: ToolResultBlockParam; output: unknown } - >() + const resultsByToolUseId = new Map(); for (const resultMsg of message.results) { for (const content of resultMsg.message.content) { if (content.type === 'tool_result') { resultsByToolUseId.set(content.tool_use_id, { param: content, output: resultMsg.toolUseResult, - }) + }); } } } const toolUsesData = message.messages.map(msg => { - const content = msg.message.content[0] - const result = resultsByToolUseId.get(content.id) + const content = msg.message.content[0]; + const result = resultsByToolUseId.get(content.id); return { param: content as ToolUseBlockParam, isResolved: lookups.resolvedToolUseIDs.has(content.id), isError: lookups.erroredToolUseIDs.has(content.id), isInProgress: inProgressToolUseIDs.has(content.id), - progressMessages: filterToolProgressMessages( - lookups.progressMessagesByToolUseID.get(content.id) ?? [], - ), + progressMessages: filterToolProgressMessages(lookups.progressMessagesByToolUseID.get(content.id) ?? []), result, - } - }) + }; + }); - const anyInProgress = toolUsesData.some(d => d.isInProgress) + const anyInProgress = toolUsesData.some(d => d.isInProgress); return tool.renderGroupedToolUse(toolUsesData, { shouldAnimate: shouldAnimate && anyInProgress, tools, - }) + }); } diff --git a/src/components/messages/HighlightedThinkingText.tsx b/src/components/messages/HighlightedThinkingText.tsx index 1b4fd0c3c..74c1edeae 100644 --- a/src/components/messages/HighlightedThinkingText.tsx +++ b/src/components/messages/HighlightedThinkingText.tsx @@ -1,35 +1,27 @@ -import figures from 'figures' -import * as React from 'react' -import { useContext } from 'react' -import { useQueuedMessage } from '../../context/QueuedMessageContext.js' -import { Box, Text } from '../../ink.js' -import { formatBriefTimestamp } from '../../utils/formatBriefTimestamp.js' -import { - findThinkingTriggerPositions, - getRainbowColor, - isUltrathinkEnabled, -} from '../../utils/thinking.js' -import { MessageActionsSelectedContext } from '../messageActions.js' +import figures from 'figures'; +import * as React from 'react'; +import { useContext } from 'react'; +import { useQueuedMessage } from '../../context/QueuedMessageContext.js'; +import { Box, Text } from '../../ink.js'; +import { formatBriefTimestamp } from '../../utils/formatBriefTimestamp.js'; +import { findThinkingTriggerPositions, getRainbowColor, isUltrathinkEnabled } from '../../utils/thinking.js'; +import { MessageActionsSelectedContext } from '../messageActions.js'; type Props = { - text: string - useBriefLayout?: boolean - timestamp?: string -} + text: string; + useBriefLayout?: boolean; + timestamp?: string; +}; -export function HighlightedThinkingText({ - text, - useBriefLayout, - timestamp, -}: Props): React.ReactNode { +export function HighlightedThinkingText({ text, useBriefLayout, timestamp }: Props): React.ReactNode { // Brief/assistant mode: chat-style "You" label instead of the ❯ highlight. // Parent drops its backgroundColor when this is true, so no grey shows // through. No manual wrap needed — Ink wraps inside the parent Box. - const isQueued = useQueuedMessage()?.isQueued ?? false - const isSelected = useContext(MessageActionsSelectedContext) - const pointerColor = isSelected ? 'suggestion' : 'subtle' + const isQueued = useQueuedMessage()?.isQueued ?? false; + const isSelected = useContext(MessageActionsSelectedContext); + const pointerColor = isSelected ? 'suggestion' : 'subtle'; if (useBriefLayout) { - const ts = timestamp ? formatBriefTimestamp(timestamp) : '' + const ts = timestamp ? formatBriefTimestamp(timestamp) : ''; return ( @@ -38,12 +30,10 @@ export function HighlightedThinkingText({ {text} - ) + ); } - const triggers = isUltrathinkEnabled() - ? findThinkingTriggerPositions(text) - : [] + const triggers = isUltrathinkEnabled() ? findThinkingTriggerPositions(text) : []; if (triggers.length === 0) { return ( @@ -51,35 +41,35 @@ export function HighlightedThinkingText({ {figures.pointer} {text} - ) + ); } // Static rainbow (no shimmer — transcript messages don't animate) - const parts: React.ReactNode[] = [] - let cursor = 0 + const parts: React.ReactNode[] = []; + let cursor = 0; for (const t of triggers) { if (t.start > cursor) { parts.push( {text.slice(cursor, t.start)} , - ) + ); } for (let i = t.start; i < t.end; i++) { parts.push( {text[i]} , - ) + ); } - cursor = t.end + cursor = t.end; } if (cursor < text.length) { parts.push( {text.slice(cursor)} , - ) + ); } return ( @@ -87,5 +77,5 @@ export function HighlightedThinkingText({ {figures.pointer} {parts} - ) + ); } diff --git a/src/components/messages/HookProgressMessage.tsx b/src/components/messages/HookProgressMessage.tsx index 61bfddf96..5fd66022f 100644 --- a/src/components/messages/HookProgressMessage.tsx +++ b/src/components/messages/HookProgressMessage.tsx @@ -1,29 +1,22 @@ -import * as React from 'react' -import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js' -import type { buildMessageLookups } from 'src/utils/messages.js' -import { Box, Text } from '../../ink.js' -import { MessageResponse } from '../MessageResponse.js' +import * as React from 'react'; +import type { HookEvent } from 'src/entrypoints/agentSdkTypes.js'; +import type { buildMessageLookups } from 'src/utils/messages.js'; +import { Box, Text } from '../../ink.js'; +import { MessageResponse } from '../MessageResponse.js'; type Props = { - hookEvent: HookEvent - lookups: ReturnType - toolUseID: string - verbose: boolean - isTranscriptMode?: boolean -} + hookEvent: HookEvent; + lookups: ReturnType; + toolUseID: string; + verbose: boolean; + isTranscriptMode?: boolean; +}; -export function HookProgressMessage({ - hookEvent, - lookups, - toolUseID, - isTranscriptMode, -}: Props): React.ReactNode { - const inProgressHookCount = - lookups.inProgressHookCounts.get(toolUseID)?.get(hookEvent) ?? 0 - const resolvedHookCount = - lookups.resolvedHookCounts.get(toolUseID)?.get(hookEvent) ?? 0 +export function HookProgressMessage({ hookEvent, lookups, toolUseID, isTranscriptMode }: Props): React.ReactNode { + const inProgressHookCount = lookups.inProgressHookCounts.get(toolUseID)?.get(hookEvent) ?? 0; + const resolvedHookCount = lookups.resolvedHookCounts.get(toolUseID)?.get(hookEvent) ?? 0; if (inProgressHookCount === 0) { - return null + return null; } if (hookEvent === 'PreToolUse' || hookEvent === 'PostToolUse') { @@ -37,20 +30,18 @@ export function HookProgressMessage({ {hookEvent} - - {inProgressHookCount === 1 ? ' hook' : ' hooks'} ran - + {inProgressHookCount === 1 ? ' hook' : ' hooks'} ran - ) + ); } // Outside transcript mode, hide — completion info is shown via // async_hook_response attachments instead. - return null + return null; } if (resolvedHookCount === inProgressHookCount) { - return null + return null; } return ( @@ -63,5 +54,5 @@ export function HookProgressMessage({ {inProgressHookCount === 1 ? ' hook…' : ' hooks…'} - ) + ); } diff --git a/src/components/messages/PlanApprovalMessage.tsx b/src/components/messages/PlanApprovalMessage.tsx index a7fbced71..22e0f928d 100644 --- a/src/components/messages/PlanApprovalMessage.tsx +++ b/src/components/messages/PlanApprovalMessage.tsx @@ -1,7 +1,7 @@ -import * as React from 'react' -import { Markdown } from '../../components/Markdown.js' -import { Box, Text } from '../../ink.js' -import { jsonParse } from '../../utils/slowOperations.js' +import * as React from 'react'; +import { Markdown } from '../../components/Markdown.js'; +import { Box, Text } from '../../ink.js'; +import { jsonParse } from '../../utils/slowOperations.js'; import { type IdleNotificationMessage, isIdleNotification, @@ -9,29 +9,22 @@ import { isPlanApprovalResponse, type PlanApprovalRequestMessage, type PlanApprovalResponseMessage, -} from '../../utils/teammateMailbox.js' -import { getShutdownMessageSummary } from './ShutdownMessage.js' -import { getTaskAssignmentSummary } from './TaskAssignmentMessage.js' +} from '../../utils/teammateMailbox.js'; +import { getShutdownMessageSummary } from './ShutdownMessage.js'; +import { getTaskAssignmentSummary } from './TaskAssignmentMessage.js'; type PlanApprovalRequestProps = { - request: PlanApprovalRequestMessage -} + request: PlanApprovalRequestMessage; +}; /** * Renders a plan approval request with a planMode-colored border, * showing the plan content and instructions for approving/rejecting. */ -export function PlanApprovalRequestDisplay({ - request, -}: PlanApprovalRequestProps): React.ReactNode { +export function PlanApprovalRequestDisplay({ request }: PlanApprovalRequestProps): React.ReactNode { return ( - + Plan Approval Request from {request.from} @@ -51,56 +44,38 @@ export function PlanApprovalRequestDisplay({ Plan file: {request.planFilePath} - ) + ); } type PlanApprovalResponseProps = { - response: PlanApprovalResponseMessage - senderName: string -} + response: PlanApprovalResponseMessage; + senderName: string; +}; /** * Renders a plan approval response with a success (green) or error (red) border. */ -export function PlanApprovalResponseDisplay({ - response, - senderName, -}: PlanApprovalResponseProps): React.ReactNode { +export function PlanApprovalResponseDisplay({ response, senderName }: PlanApprovalResponseProps): React.ReactNode { if (response.approved) { return ( - + ✓ Plan Approved by {senderName} - - You can now proceed with implementation. Your plan mode - restrictions have been lifted. - + You can now proceed with implementation. Your plan mode restrictions have been lifted. - ) + ); } return ( - + ✗ Plan Rejected by {senderName} @@ -119,40 +94,29 @@ export function PlanApprovalResponseDisplay({ )} - - Please revise your plan based on the feedback and call ExitPlanMode - again. - + Please revise your plan based on the feedback and call ExitPlanMode again. - ) + ); } /** * Try to parse and render a plan approval message from raw content. * Returns the rendered component if it's a plan approval message, null otherwise. */ -export function tryRenderPlanApprovalMessage( - content: string, - senderName: string, -): React.ReactNode | null { - const request = isPlanApprovalRequest(content) +export function tryRenderPlanApprovalMessage(content: string, senderName: string): React.ReactNode | null { + const request = isPlanApprovalRequest(content); if (request) { - return + return ; } - const response = isPlanApprovalResponse(content) + const response = isPlanApprovalResponse(content); if (response) { - return ( - - ) + return ; } - return null + return null; } /** @@ -161,36 +125,36 @@ export function tryRenderPlanApprovalMessage( * Returns null if the content is not a plan approval message. */ function getPlanApprovalSummary(content: string): string | null { - const request = isPlanApprovalRequest(content) + const request = isPlanApprovalRequest(content); if (request) { - return `[Plan Approval Request from ${request.from}]` + return `[Plan Approval Request from ${request.from}]`; } - const response = isPlanApprovalResponse(content) + const response = isPlanApprovalResponse(content); if (response) { if (response.approved) { - return '[Plan Approved] You can now proceed with implementation' + return '[Plan Approved] You can now proceed with implementation'; } else { - return `[Plan Rejected] ${response.feedback || 'Please revise your plan'}` + return `[Plan Rejected] ${response.feedback || 'Please revise your plan'}`; } } - return null + return null; } /** * Get a brief summary text for an idle notification. */ function getIdleNotificationSummary(msg: IdleNotificationMessage): string { - const parts: string[] = ['Agent idle'] + const parts: string[] = ['Agent idle']; if (msg.completedTaskId) { - const status = msg.completedStatus || 'completed' - parts.push(`Task ${msg.completedTaskId} ${status}`) + const status = msg.completedStatus || 'completed'; + parts.push(`Task ${msg.completedTaskId} ${status}`); } if (msg.summary) { - parts.push(`Last DM: ${msg.summary}`) + parts.push(`Last DM: ${msg.summary}`); } - return parts.join(' · ') + return parts.join(' · '); } /** @@ -199,35 +163,35 @@ function getIdleNotificationSummary(msg: IdleNotificationMessage): string { * Otherwise returns the original content. */ export function formatTeammateMessageContent(content: string): string { - const planSummary = getPlanApprovalSummary(content) + const planSummary = getPlanApprovalSummary(content); if (planSummary) { - return planSummary + return planSummary; } - const shutdownSummary = getShutdownMessageSummary(content) + const shutdownSummary = getShutdownMessageSummary(content); if (shutdownSummary) { - return shutdownSummary + return shutdownSummary; } - const idleMsg = isIdleNotification(content) + const idleMsg = isIdleNotification(content); if (idleMsg) { - return getIdleNotificationSummary(idleMsg) + return getIdleNotificationSummary(idleMsg); } - const taskAssignmentSummary = getTaskAssignmentSummary(content) + const taskAssignmentSummary = getTaskAssignmentSummary(content); if (taskAssignmentSummary) { - return taskAssignmentSummary + return taskAssignmentSummary; } // Check for teammate_terminated message try { - const parsed = jsonParse(content) as { type?: string; message?: string } + const parsed = jsonParse(content) as { type?: string; message?: string }; if (parsed?.type === 'teammate_terminated' && parsed.message) { - return parsed.message + return parsed.message; } } catch { // Not JSON } - return content + return content; } diff --git a/src/components/messages/RateLimitMessage.tsx b/src/components/messages/RateLimitMessage.tsx index c9a42815b..a5a942599 100644 --- a/src/components/messages/RateLimitMessage.tsx +++ b/src/components/messages/RateLimitMessage.tsx @@ -1,24 +1,20 @@ -import React, { useEffect, useMemo, useState } from 'react' -import { extraUsage } from 'src/commands/extra-usage/index.js' -import { Box, Text } from 'src/ink.js' -import { useClaudeAiLimits } from 'src/services/claudeAiLimitsHook.js' -import { shouldProcessMockLimits } from 'src/services/rateLimitMocking.js' // Used for /mock-limits command -import { - getRateLimitTier, - getSubscriptionType, - isClaudeAISubscriber, -} from 'src/utils/auth.js' -import { hasClaudeAiBillingAccess } from 'src/utils/billing.js' -import { MessageResponse } from '../MessageResponse.js' +import React, { useEffect, useMemo, useState } from 'react'; +import { extraUsage } from 'src/commands/extra-usage/index.js'; +import { Box, Text } from 'src/ink.js'; +import { useClaudeAiLimits } from 'src/services/claudeAiLimitsHook.js'; +import { shouldProcessMockLimits } from 'src/services/rateLimitMocking.js'; // Used for /mock-limits command +import { getRateLimitTier, getSubscriptionType, isClaudeAISubscriber } from 'src/utils/auth.js'; +import { hasClaudeAiBillingAccess } from 'src/utils/billing.js'; +import { MessageResponse } from '../MessageResponse.js'; type UpsellParams = { - shouldShowUpsell: boolean - isMax20x: boolean - isExtraUsageCommandEnabled: boolean - shouldAutoOpenRateLimitOptionsMenu: boolean - isTeamOrEnterprise: boolean - hasBillingAccess: boolean -} + shouldShowUpsell: boolean; + isMax20x: boolean; + isExtraUsageCommandEnabled: boolean; + shouldAutoOpenRateLimitOptionsMenu: boolean; + isTeamOrEnterprise: boolean; + hasBillingAccess: boolean; +}; export function getUpsellMessage({ shouldShowUpsell, @@ -28,79 +24,69 @@ export function getUpsellMessage({ isTeamOrEnterprise, hasBillingAccess, }: UpsellParams): string | null { - if (!shouldShowUpsell) return null + if (!shouldShowUpsell) return null; if (isMax20x) { if (isExtraUsageCommandEnabled) { - return '/extra-usage to finish what you\u2019re working on.' + return '/extra-usage to finish what you\u2019re working on.'; } - return '/login to switch to an API usage-billed account.' + return '/login to switch to an API usage-billed account.'; } if (shouldAutoOpenRateLimitOptionsMenu) { - return 'Opening your options\u2026' + return 'Opening your options\u2026'; } if (!isTeamOrEnterprise && !isExtraUsageCommandEnabled) { - return '/upgrade to increase your usage limit.' + return '/upgrade to increase your usage limit.'; } if (isTeamOrEnterprise) { - if (!isExtraUsageCommandEnabled) return null + if (!isExtraUsageCommandEnabled) return null; if (hasBillingAccess) { - return '/extra-usage to finish what you\u2019re working on.' + return '/extra-usage to finish what you\u2019re working on.'; } - return '/extra-usage to request more usage from your admin.' + return '/extra-usage to request more usage from your admin.'; } - return '/upgrade or /extra-usage to finish what you\u2019re working on.' + return '/upgrade or /extra-usage to finish what you\u2019re working on.'; } type RateLimitMessageProps = { - text: string - onOpenRateLimitOptions?: () => void -} - -export function RateLimitMessage({ - text, - onOpenRateLimitOptions, -}: RateLimitMessageProps): React.ReactNode { - const subscriptionType = getSubscriptionType() - const rateLimitTier = getRateLimitTier() - const isTeamOrEnterprise = - subscriptionType === 'team' || subscriptionType === 'enterprise' - const isMax20x = rateLimitTier === 'default_claude_max_20x' + text: string; + onOpenRateLimitOptions?: () => void; +}; + +export function RateLimitMessage({ text, onOpenRateLimitOptions }: RateLimitMessageProps): React.ReactNode { + const subscriptionType = getSubscriptionType(); + const rateLimitTier = getRateLimitTier(); + const isTeamOrEnterprise = subscriptionType === 'team' || subscriptionType === 'enterprise'; + const isMax20x = rateLimitTier === 'default_claude_max_20x'; // Always show upsell when using /mock-limits command, otherwise show for subscribers - const shouldShowUpsell = shouldProcessMockLimits() || isClaudeAISubscriber() + const shouldShowUpsell = shouldProcessMockLimits() || isClaudeAISubscriber(); - const canSeeRateLimitOptionsUpsell = shouldShowUpsell && !isMax20x + const canSeeRateLimitOptionsUpsell = shouldShowUpsell && !isMax20x; - const [hasOpenedInteractiveMenu, setHasOpenedInteractiveMenu] = - useState(false) + const [hasOpenedInteractiveMenu, setHasOpenedInteractiveMenu] = useState(false); // Check actual rate limit status - only auto-open if user is currently rate limited // AND we've verified this with the API (resetsAt is only set after API response). // This prevents false alerts when resuming sessions with old rate limit messages. - const claudeAiLimits = useClaudeAiLimits() + const claudeAiLimits = useClaudeAiLimits(); const isCurrentlyRateLimited = - claudeAiLimits.status === 'rejected' && - claudeAiLimits.resetsAt !== undefined && - !claudeAiLimits.isUsingOverage + claudeAiLimits.status === 'rejected' && claudeAiLimits.resetsAt !== undefined && !claudeAiLimits.isUsingOverage; const shouldAutoOpenRateLimitOptionsMenu = - canSeeRateLimitOptionsUpsell && - !hasOpenedInteractiveMenu && - isCurrentlyRateLimited && - onOpenRateLimitOptions + canSeeRateLimitOptionsUpsell && !hasOpenedInteractiveMenu && isCurrentlyRateLimited && onOpenRateLimitOptions; useEffect(() => { if (shouldAutoOpenRateLimitOptionsMenu) { - setHasOpenedInteractiveMenu(true) - onOpenRateLimitOptions() + setHasOpenedInteractiveMenu(true); + onOpenRateLimitOptions(); } - }, [shouldAutoOpenRateLimitOptionsMenu, onOpenRateLimitOptions]) + }, [shouldAutoOpenRateLimitOptionsMenu, onOpenRateLimitOptions]); const upsell = useMemo(() => { const message = getUpsellMessage({ @@ -110,15 +96,10 @@ export function RateLimitMessage({ shouldAutoOpenRateLimitOptionsMenu: !!shouldAutoOpenRateLimitOptionsMenu, isTeamOrEnterprise, hasBillingAccess: hasClaudeAiBillingAccess(), - }) - if (!message) return null - return {message} - }, [ - shouldShowUpsell, - isMax20x, - isTeamOrEnterprise, - shouldAutoOpenRateLimitOptionsMenu, - ]) + }); + if (!message) return null; + return {message}; + }, [shouldShowUpsell, isMax20x, isTeamOrEnterprise, shouldAutoOpenRateLimitOptionsMenu]); return ( @@ -127,5 +108,5 @@ export function RateLimitMessage({ {hasOpenedInteractiveMenu ? null : upsell} - ) + ); } diff --git a/src/components/messages/ShutdownMessage.tsx b/src/components/messages/ShutdownMessage.tsx index 82e0d59e1..770056378 100644 --- a/src/components/messages/ShutdownMessage.tsx +++ b/src/components/messages/ShutdownMessage.tsx @@ -1,32 +1,24 @@ -import * as React from 'react' -import { Box, Text } from '../../ink.js' +import * as React from 'react'; +import { Box, Text } from '../../ink.js'; import { isShutdownApproved, isShutdownRejected, isShutdownRequest, type ShutdownRejectedMessage, type ShutdownRequestMessage, -} from '../../utils/teammateMailbox.js' +} from '../../utils/teammateMailbox.js'; type ShutdownRequestProps = { - request: ShutdownRequestMessage -} + request: ShutdownRequestMessage; +}; /** * Renders a shutdown request with a warning-colored border. */ -export function ShutdownRequestDisplay({ - request, -}: ShutdownRequestProps): React.ReactNode { +export function ShutdownRequestDisplay({ request }: ShutdownRequestProps): React.ReactNode { return ( - + Shutdown request from {request.from} @@ -39,28 +31,20 @@ export function ShutdownRequestDisplay({ )} - ) + ); } type ShutdownRejectedProps = { - response: ShutdownRejectedMessage -} + response: ShutdownRejectedMessage; +}; /** * Renders a shutdown rejected message with a subtle (grey) border. */ -export function ShutdownRejectedDisplay({ - response, -}: ShutdownRejectedProps): React.ReactNode { +export function ShutdownRejectedDisplay({ response }: ShutdownRejectedProps): React.ReactNode { return ( - + Shutdown rejected by {response.from} @@ -75,39 +59,34 @@ export function ShutdownRejectedDisplay({ Reason: {response.reason} - - Teammate is continuing to work. You may request shutdown again - later. - + Teammate is continuing to work. You may request shutdown again later. - ) + ); } /** * Try to parse and render a shutdown message from raw content. * Returns the rendered component if it's a shutdown message, null otherwise. */ -export function tryRenderShutdownMessage( - content: string, -): React.ReactNode | null { - const request = isShutdownRequest(content) +export function tryRenderShutdownMessage(content: string): React.ReactNode | null { + const request = isShutdownRequest(content); if (request) { - return + return ; } // Shutdown approved is handled inline by the caller — skip it here if (isShutdownApproved(content)) { - return null + return null; } - const rejected = isShutdownRejected(content) + const rejected = isShutdownRejected(content); if (rejected) { - return + return ; } - return null + return null; } /** @@ -116,20 +95,20 @@ export function tryRenderShutdownMessage( * Returns null if the content is not a shutdown message. */ export function getShutdownMessageSummary(content: string): string | null { - const request = isShutdownRequest(content) + const request = isShutdownRequest(content); if (request) { - return `[Shutdown Request from ${request.from}]${request.reason ? ` ${request.reason}` : ''}` + return `[Shutdown Request from ${request.from}]${request.reason ? ` ${request.reason}` : ''}`; } - const approved = isShutdownApproved(content) + const approved = isShutdownApproved(content); if (approved) { - return `[Shutdown Approved] ${approved.from} is now exiting` + return `[Shutdown Approved] ${approved.from} is now exiting`; } - const rejected = isShutdownRejected(content) + const rejected = isShutdownRejected(content); if (rejected) { - return `[Shutdown Rejected] ${rejected.from}: ${rejected.reason}` + return `[Shutdown Rejected] ${rejected.from}: ${rejected.reason}`; } - return null + return null; } diff --git a/src/components/messages/SnipBoundaryMessage.ts b/src/components/messages/SnipBoundaryMessage.ts index 898e2d09c..87592f151 100644 --- a/src/components/messages/SnipBoundaryMessage.ts +++ b/src/components/messages/SnipBoundaryMessage.ts @@ -1,3 +1,4 @@ // Auto-generated stub — replace with real implementation -export {}; -export const SnipBoundaryMessage: (props: Record) => null = () => null; +export {} +export const SnipBoundaryMessage: (props: Record) => null = + () => null diff --git a/src/components/messages/SystemAPIErrorMessage.tsx b/src/components/messages/SystemAPIErrorMessage.tsx index c87dec717..0dd71d270 100644 --- a/src/components/messages/SystemAPIErrorMessage.tsx +++ b/src/components/messages/SystemAPIErrorMessage.tsx @@ -1,18 +1,18 @@ -import * as React from 'react' -import { useState } from 'react' -import { Box, Text } from 'src/ink.js' -import { formatAPIError } from 'src/services/api/errorUtils.js' -import type { SystemAPIErrorMessage } from 'src/types/message.js' -import { useInterval } from 'usehooks-ts' -import { CtrlOToExpand } from '../CtrlOToExpand.js' -import { MessageResponse } from '../MessageResponse.js' +import * as React from 'react'; +import { useState } from 'react'; +import { Box, Text } from 'src/ink.js'; +import { formatAPIError } from 'src/services/api/errorUtils.js'; +import type { SystemAPIErrorMessage } from 'src/types/message.js'; +import { useInterval } from 'usehooks-ts'; +import { CtrlOToExpand } from '../CtrlOToExpand.js'; +import { MessageResponse } from '../MessageResponse.js'; -const MAX_API_ERROR_CHARS = 1000 +const MAX_API_ERROR_CHARS = 1000; type Props = { - message: SystemAPIErrorMessage - verbose: boolean -} + message: SystemAPIErrorMessage; + verbose: boolean; +}; export function SystemAPIErrorMessage({ message: { retryAttempt, error, retryInMs, maxRetries }, @@ -20,45 +20,32 @@ export function SystemAPIErrorMessage({ }: Props): React.ReactNode { // Hidden for early retries on external builds to avoid noise. Compute before // useInterval so we never register a timer that just drives a null render. - const hidden = process.env.USER_TYPE === 'external' && retryAttempt < 4 + const hidden = process.env.USER_TYPE === 'external' && retryAttempt < 4; - const [countdownMs, setCountdownMs] = useState(0) - const done = countdownMs >= retryInMs - useInterval( - () => setCountdownMs(ms => ms + 1000), - hidden || done ? null : 1000, - ) + const [countdownMs, setCountdownMs] = useState(0); + const done = countdownMs >= retryInMs; + useInterval(() => setCountdownMs(ms => ms + 1000), hidden || done ? null : 1000); if (hidden) { - return null + return null; } - const retryInSecondsLive = Math.max( - 0, - Math.round((retryInMs - countdownMs) / 1000), - ) + const retryInSecondsLive = Math.max(0, Math.round((retryInMs - countdownMs) / 1000)); - const formatted = formatAPIError(error) - const truncated = !verbose && formatted.length > MAX_API_ERROR_CHARS + const formatted = formatAPIError(error); + const truncated = !verbose && formatted.length > MAX_API_ERROR_CHARS; return ( - - {truncated - ? formatted.slice(0, MAX_API_ERROR_CHARS) + '…' - : formatted} - + {truncated ? formatted.slice(0, MAX_API_ERROR_CHARS) + '…' : formatted} {truncated && } - Retrying in {retryInSecondsLive}{' '} - {retryInSecondsLive === 1 ? 'second' : 'seconds'}… (attempt{' '} - {retryAttempt}/{maxRetries}) - {process.env.API_TIMEOUT_MS - ? ` · API_TIMEOUT_MS=${process.env.API_TIMEOUT_MS}ms, try increasing it` - : ''} + Retrying in {retryInSecondsLive} {retryInSecondsLive === 1 ? 'second' : 'seconds'}… (attempt {retryAttempt}/ + {maxRetries}) + {process.env.API_TIMEOUT_MS ? ` · API_TIMEOUT_MS=${process.env.API_TIMEOUT_MS}ms, try increasing it` : ''} - ) + ); } diff --git a/src/components/messages/SystemTextMessage.tsx b/src/components/messages/SystemTextMessage.tsx index 7d05f054a..9ce4d1d36 100644 --- a/src/components/messages/SystemTextMessage.tsx +++ b/src/components/messages/SystemTextMessage.tsx @@ -1,26 +1,20 @@ // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered -import { Box, Text, type TextProps } from '../../ink.js' -import { feature } from 'bun:bundle' -import * as React from 'react' -import { useState } from 'react' -import sample from 'lodash-es/sample.js' -import { - BLACK_CIRCLE, - REFERENCE_MARK, - TEARDROP_ASTERISK, -} from '../../constants/figures.js' -import figures from 'figures' -import { basename } from 'path' -import { MessageResponse } from '../MessageResponse.js' -import { FilePathLink } from '../FilePathLink.js' -import { openPath } from '../../utils/browser.js' +import { Box, Text, type TextProps } from '../../ink.js'; +import { feature } from 'bun:bundle'; +import * as React from 'react'; +import { useState } from 'react'; +import sample from 'lodash-es/sample.js'; +import { BLACK_CIRCLE, REFERENCE_MARK, TEARDROP_ASTERISK } from '../../constants/figures.js'; +import figures from 'figures'; +import { basename } from 'path'; +import { MessageResponse } from '../MessageResponse.js'; +import { FilePathLink } from '../FilePathLink.js'; +import { openPath } from '../../utils/browser.js'; /* eslint-disable @typescript-eslint/no-require-imports */ -const teamMemSaved = feature('TEAMMEM') - ? (require('./teamMemSaved.js') as typeof import('./teamMemSaved.js')) - : null +const teamMemSaved = feature('TEAMMEM') ? (require('./teamMemSaved.js') as typeof import('./teamMemSaved.js')) : null; /* eslint-enable @typescript-eslint/no-require-imports */ -import { TURN_COMPLETION_VERBS } from '../../constants/turnCompletionVerbs.js' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { TURN_COMPLETION_VERBS } from '../../constants/turnCompletionVerbs.js'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; import type { SystemMessage, SystemStopHookSummaryMessage, @@ -28,89 +22,69 @@ import type { SystemTurnDurationMessage, SystemThinkingMessage, SystemMemorySavedMessage, -} from '../../types/message.js' -import { SystemAPIErrorMessage } from './SystemAPIErrorMessage.js' -import { - formatDuration, - formatNumber, - formatSecondsShort, -} from '../../utils/format.js' -import { getGlobalConfig } from '../../utils/config.js' -import Link from '../../ink/components/Link.js' -import ThemedText from '../design-system/ThemedText.js' -import { CtrlOToExpand } from '../CtrlOToExpand.js' -import { useAppStateStore } from '../../state/AppState.js' -import { isBackgroundTask, type TaskState } from '../../tasks/types.js' -import { getPillLabel } from '../../tasks/pillLabel.js' -import { useSelectedMessageBg } from '../messageActions.js' +} from '../../types/message.js'; +import { SystemAPIErrorMessage } from './SystemAPIErrorMessage.js'; +import { formatDuration, formatNumber, formatSecondsShort } from '../../utils/format.js'; +import { getGlobalConfig } from '../../utils/config.js'; +import Link from '../../ink/components/Link.js'; +import ThemedText from '../design-system/ThemedText.js'; +import { CtrlOToExpand } from '../CtrlOToExpand.js'; +import { useAppStateStore } from '../../state/AppState.js'; +import { isBackgroundTask, type TaskState } from '../../tasks/types.js'; +import { getPillLabel } from '../../tasks/pillLabel.js'; +import { useSelectedMessageBg } from '../messageActions.js'; type Props = { - message: SystemMessage - addMargin: boolean - verbose: boolean - isTranscriptMode?: boolean -} + message: SystemMessage; + addMargin: boolean; + verbose: boolean; + isTranscriptMode?: boolean; +}; -export function SystemTextMessage({ - message, - addMargin, - verbose, - isTranscriptMode, -}: Props): React.ReactNode { - const bg = useSelectedMessageBg() +export function SystemTextMessage({ message, addMargin, verbose, isTranscriptMode }: Props): React.ReactNode { + const bg = useSelectedMessageBg(); // Turn duration messages are always shown in grey if (message.subtype === 'turn_duration') { - return + return ; } if (message.subtype === 'memory_saved') { - return + return ; } if (message.subtype === 'away_summary') { return ( - + {REFERENCE_MARK} {message.content} - ) + ); } // Agents killed confirmation if (message.subtype === 'agents_killed') { return ( - + {BLACK_CIRCLE} All background agents stopped - ) + ); } // Thinking messages are subtle, like turn duration (ant-only) if (message.subtype === 'thinking') { if (process.env.USER_TYPE === 'ant') { - return + return ; } - return null + return null; } - if (message.subtype === 'bridge_status') { - return + return ; } if (message.subtype === 'scheduled_task_fire') { @@ -120,7 +94,7 @@ export function SystemTextMessage({ {TEARDROP_ASTERISK} {message.content} - ) + ); } if (message.subtype === 'permission_retry') { @@ -130,18 +104,18 @@ export function SystemTextMessage({ Allowed {message.commands.join(', ')} - ) + ); } // Stop hook summaries should always be visible - const isStopHookSummary = message.subtype === 'stop_hook_summary' + const isStopHookSummary = message.subtype === 'stop_hook_summary'; if (!isStopHookSummary && !verbose && message.level === 'info') { - return null + return null; } if (message.subtype === 'api_error') { - return + return ; } if (message.subtype === 'stop_hook_summary') { @@ -152,14 +126,14 @@ export function SystemTextMessage({ verbose={verbose} isTranscriptMode={isTranscriptMode} /> - ) + ); } - const content = message.content + const content = message.content; // In case the event doesn't have a content // validation, so content can be undefined at runtime despite the types. if (typeof content !== 'string') { - return null + return null; } return ( @@ -171,7 +145,7 @@ export function SystemTextMessage({ dimColor={message.level === 'info'} /> - ) + ); } function StopHookSummaryMessage({ @@ -180,83 +154,61 @@ function StopHookSummaryMessage({ verbose, isTranscriptMode, }: { - message: SystemStopHookSummaryMessage - addMargin: boolean - verbose: boolean - isTranscriptMode?: boolean + message: SystemStopHookSummaryMessage; + addMargin: boolean; + verbose: boolean; + isTranscriptMode?: boolean; }): React.ReactNode { - const bg = useSelectedMessageBg() - const { - hookCount, - hookInfos, - hookErrors, - preventedContinuation, - stopReason, - } = message - const { columns } = useTerminalSize() + const bg = useSelectedMessageBg(); + const { hookCount, hookInfos, hookErrors, preventedContinuation, stopReason } = message; + const { columns } = useTerminalSize(); // Prefer wall-clock time when available (hooks run in parallel) - const totalDurationMs = - message.totalDurationMs ?? - hookInfos.reduce((sum, h) => sum + (h.durationMs ?? 0), 0) - const isAnt = process.env.USER_TYPE === 'ant' + const totalDurationMs = message.totalDurationMs ?? hookInfos.reduce((sum, h) => sum + (h.durationMs ?? 0), 0); + const isAnt = process.env.USER_TYPE === 'ant'; // Only show summary if there are errors or continuation was prevented // For ants: also show when hooks took > 500ms // Non-stop hooks (e.g. PreToolUse) are pre-filtered by the caller if (hookErrors.length === 0 && !preventedContinuation && !message.hookLabel) { if (!isAnt || totalDurationMs < HOOK_TIMING_DISPLAY_THRESHOLD_MS) { - return null + return null; } } - const totalStr = - isAnt && totalDurationMs > 0 - ? ` (${formatSecondsShort(totalDurationMs)})` - : '' + const totalStr = isAnt && totalDurationMs > 0 ? ` (${formatSecondsShort(totalDurationMs)})` : ''; // Non-stop hooks (e.g. PreToolUse) render as a child line without bullet if (message.hookLabel) { return ( - {' ⎿ '}Ran {hookCount} {message.hookLabel}{' '} - {hookCount === 1 ? 'hook' : 'hooks'} + {' ⎿ '}Ran {hookCount} {message.hookLabel} {hookCount === 1 ? 'hook' : 'hooks'} {totalStr} {isTranscriptMode && hookInfos.map((info, idx) => { const durationStr = - isAnt && info.durationMs !== undefined - ? ` (${formatSecondsShort(info.durationMs)})` - : '' + isAnt && info.durationMs !== undefined ? ` (${formatSecondsShort(info.durationMs)})` : ''; return ( {' ⎿ '} - {info.command === 'prompt' - ? `prompt: ${info.promptText || ''}` - : info.command} + {info.command === 'prompt' ? `prompt: ${info.promptText || ''}` : info.command} {durationStr} - ) + ); })} - ) + ); } return ( - + {BLACK_CIRCLE} - Ran {hookCount} {message.hookLabel ?? 'stop'}{' '} - {hookCount === 1 ? 'hook' : 'hooks'} + Ran {hookCount} {message.hookLabel ?? 'stop'} {hookCount === 1 ? 'hook' : 'hooks'} {totalStr} {!verbose && hookInfos.length > 0 && ( <> @@ -269,18 +221,14 @@ function StopHookSummaryMessage({ hookInfos.length > 0 && hookInfos.map((info, idx) => { const durationStr = - isAnt && info.durationMs !== undefined - ? ` (${formatSecondsShort(info.durationMs)})` - : '' + isAnt && info.durationMs !== undefined ? ` (${formatSecondsShort(info.durationMs)})` : ''; return ( ⎿   - {info.command === 'prompt' - ? `prompt: ${info.promptText || ''}` - : info.command} + {info.command === 'prompt' ? `prompt: ${info.promptText || ''}` : info.command} {durationStr} - ) + ); })} {preventedContinuation && stopReason && ( @@ -297,7 +245,7 @@ function StopHookSummaryMessage({ ))} - ) + ); } function SystemTextMessageInner({ @@ -307,22 +255,17 @@ function SystemTextMessageInner({ color, dimColor, }: { - content: string - addMargin: boolean - dot: boolean - color?: TextProps['color'] - dimColor?: boolean + content: string; + addMargin: boolean; + dot: boolean; + color?: TextProps['color']; + dimColor?: boolean; }): React.ReactNode { - const { columns } = useTerminalSize() - const bg = useSelectedMessageBg() + const { columns } = useTerminalSize(); + const bg = useSelectedMessageBg(); return ( - + {dot && ( @@ -336,95 +279,79 @@ function SystemTextMessageInner({ - ) + ); } function TurnDurationMessage({ message, addMargin, }: { - message: SystemTurnDurationMessage - addMargin: boolean + message: SystemTurnDurationMessage; + addMargin: boolean; }): React.ReactNode { - const bg = useSelectedMessageBg() - const [verb] = useState(() => sample(TURN_COMPLETION_VERBS) ?? 'Worked') - const store = useAppStateStore() + const bg = useSelectedMessageBg(); + const [verb] = useState(() => sample(TURN_COMPLETION_VERBS) ?? 'Worked'); + const store = useAppStateStore(); const [backgroundTaskSummary] = useState(() => { - const tasks = store.getState().tasks - const running = (Object.values(tasks ?? {}) as TaskState[]).filter( - isBackgroundTask, - ) - return running.length > 0 ? getPillLabel(running) : null - }) + const tasks = store.getState().tasks; + const running = (Object.values(tasks ?? {}) as TaskState[]).filter(isBackgroundTask); + return running.length > 0 ? getPillLabel(running) : null; + }); - const showTurnDuration = getGlobalConfig().showTurnDuration ?? true + const showTurnDuration = getGlobalConfig().showTurnDuration ?? true; - const duration = formatDuration(message.durationMs) - const hasBudget = message.budgetLimit !== undefined + const duration = formatDuration(message.durationMs); + const hasBudget = message.budgetLimit !== undefined; const budgetSuffix = (() => { - if (!hasBudget) return '' - const tokens = message.budgetTokens! - const limit = message.budgetLimit! + if (!hasBudget) return ''; + const tokens = message.budgetTokens!; + const limit = message.budgetLimit!; const usage = tokens >= limit ? `${formatNumber(tokens)} used (${formatNumber(limit)} min ${figures.tick})` - : `${formatNumber(tokens)} / ${formatNumber(limit)} (${Math.round((tokens / limit) * 100)}%)` + : `${formatNumber(tokens)} / ${formatNumber(limit)} (${Math.round((tokens / limit) * 100)}%)`; const nudges = message.budgetNudges! > 0 ? ` \u00B7 ${message.budgetNudges} ${message.budgetNudges === 1 ? 'nudge' : 'nudges'}` - : '' - return `${showTurnDuration ? ' \u00B7 ' : ''}${usage}${nudges}` - })() + : ''; + return `${showTurnDuration ? ' \u00B7 ' : ''}${usage}${nudges}`; + })(); if (!showTurnDuration && !hasBudget) { - return null + return null; } return ( - + {TEARDROP_ASTERISK} {showTurnDuration && `${verb} for ${duration}`} {budgetSuffix} - {backgroundTaskSummary && - ` \u00B7 ${backgroundTaskSummary} still running`} + {backgroundTaskSummary && ` \u00B7 ${backgroundTaskSummary} still running`} - ) + ); } function MemorySavedMessage({ message, addMargin, }: { - message: SystemMemorySavedMessage - addMargin: boolean + message: SystemMemorySavedMessage; + addMargin: boolean; }): React.ReactNode { - const bg = useSelectedMessageBg() - const { writtenPaths } = message - const team = feature('TEAMMEM') - ? teamMemSaved!.teamMemSavedPart(message) - : null - const privateCount = writtenPaths.length - (team?.count ?? 0) + const bg = useSelectedMessageBg(); + const { writtenPaths } = message; + const team = feature('TEAMMEM') ? teamMemSaved!.teamMemSavedPart(message) : null; + const privateCount = writtenPaths.length - (team?.count ?? 0); const parts = [ - privateCount > 0 - ? `${privateCount} ${privateCount === 1 ? 'memory' : 'memories'}` - : null, + privateCount > 0 ? `${privateCount} ${privateCount === 1 ? 'memory' : 'memories'}` : null, team?.segment, - ].filter(Boolean) + ].filter(Boolean); return ( - + {BLACK_CIRCLE} @@ -437,73 +364,58 @@ function MemorySavedMessage({ ))} - ) + ); } function MemoryFileRow({ path }: { path: string }): React.ReactNode { - const [hover, setHover] = useState(false) + const [hover, setHover] = useState(false); return ( - void openPath(path)} - onMouseEnter={() => setHover(true)} - onMouseLeave={() => setHover(false)} - > + void openPath(path)} onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}> {basename(path)} - ) + ); } function ThinkingMessage({ message, addMargin, }: { - message: SystemThinkingMessage - addMargin: boolean + message: SystemThinkingMessage; + addMargin: boolean; }): React.ReactNode { - const bg = useSelectedMessageBg() + const bg = useSelectedMessageBg(); return ( - + {TEARDROP_ASTERISK} {message.content} - ) + ); } function BridgeStatusMessage({ message, addMargin, }: { - message: SystemBridgeStatusMessage - addMargin: boolean + message: SystemBridgeStatusMessage; + addMargin: boolean; }): React.ReactNode { - const bg = useSelectedMessageBg() + const bg = useSelectedMessageBg(); return ( - + - /remote-control is active. - Code in CLI or at + /remote-control is active. Code in CLI or at {message.url} {message.upgradeNudge && ⎿ {message.upgradeNudge}} - ) + ); } diff --git a/src/components/messages/TaskAssignmentMessage.tsx b/src/components/messages/TaskAssignmentMessage.tsx index 1f7797873..9173d5b1f 100644 --- a/src/components/messages/TaskAssignmentMessage.tsx +++ b/src/components/messages/TaskAssignmentMessage.tsx @@ -1,13 +1,10 @@ -import * as React from 'react' -import { Box, Text } from '../../ink.js' -import { - isTaskAssignment, - type TaskAssignmentMessage, -} from '../../utils/teammateMailbox.js' +import * as React from 'react'; +import { Box, Text } from '../../ink.js'; +import { isTaskAssignment, type TaskAssignmentMessage } from '../../utils/teammateMailbox.js'; type Props = { - assignment: TaskAssignmentMessage -} + assignment: TaskAssignmentMessage; +}; /** * Renders a task assignment with a cyan border (team-related color). @@ -15,13 +12,7 @@ type Props = { export function TaskAssignmentDisplay({ assignment }: Props): React.ReactNode { return ( - + Task #{assignment.taskId} assigned by {assignment.assignedBy} @@ -37,29 +28,27 @@ export function TaskAssignmentDisplay({ assignment }: Props): React.ReactNode { )} - ) + ); } /** * Try to parse and render a task assignment message from raw content. */ -export function tryRenderTaskAssignmentMessage( - content: string, -): React.ReactNode | null { - const assignment = isTaskAssignment(content) +export function tryRenderTaskAssignmentMessage(content: string): React.ReactNode | null { + const assignment = isTaskAssignment(content); if (assignment) { - return + return ; } - return null + return null; } /** * Get a brief summary text for a task assignment message. */ export function getTaskAssignmentSummary(content: string): string | null { - const assignment = isTaskAssignment(content) + const assignment = isTaskAssignment(content); if (assignment) { - return `[Task Assigned] #${assignment.taskId} - ${assignment.subject}` + return `[Task Assigned] #${assignment.taskId} - ${assignment.subject}`; } - return null + return null; } diff --git a/src/components/messages/UserAgentNotificationMessage.tsx b/src/components/messages/UserAgentNotificationMessage.tsx index 7e19c34d7..f84521cd2 100644 --- a/src/components/messages/UserAgentNotificationMessage.tsx +++ b/src/components/messages/UserAgentNotificationMessage.tsx @@ -1,36 +1,33 @@ -import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' -import * as React from 'react' -import { BLACK_CIRCLE } from '../../constants/figures.js' -import { Box, Text, type TextProps } from '../../ink.js' -import { extractTag } from '../../utils/messages.js' +import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; +import * as React from 'react'; +import { BLACK_CIRCLE } from '../../constants/figures.js'; +import { Box, Text, type TextProps } from '../../ink.js'; +import { extractTag } from '../../utils/messages.js'; type Props = { - addMargin: boolean - param: TextBlockParam -} + addMargin: boolean; + param: TextBlockParam; +}; function getStatusColor(status: string | null): TextProps['color'] { switch (status) { case 'completed': - return 'success' + return 'success'; case 'failed': - return 'error' + return 'error'; case 'killed': - return 'warning' + return 'warning'; default: - return 'text' + return 'text'; } } -export function UserAgentNotificationMessage({ - addMargin, - param: { text }, -}: Props): React.ReactNode { - const summary = extractTag(text, 'summary') - if (!summary) return null +export function UserAgentNotificationMessage({ addMargin, param: { text } }: Props): React.ReactNode { + const summary = extractTag(text, 'summary'); + if (!summary) return null; - const status = extractTag(text, 'status') - const color = getStatusColor(status) + const status = extractTag(text, 'status'); + const color = getStatusColor(status); return ( @@ -38,5 +35,5 @@ export function UserAgentNotificationMessage({ {BLACK_CIRCLE} {summary} - ) + ); } diff --git a/src/components/messages/UserBashInputMessage.tsx b/src/components/messages/UserBashInputMessage.tsx index c78fafea1..52d960478 100644 --- a/src/components/messages/UserBashInputMessage.tsx +++ b/src/components/messages/UserBashInputMessage.tsx @@ -1,20 +1,17 @@ -import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' -import * as React from 'react' -import { Box, Text } from '../../ink.js' -import { extractTag } from '../../utils/messages.js' +import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; +import * as React from 'react'; +import { Box, Text } from '../../ink.js'; +import { extractTag } from '../../utils/messages.js'; type Props = { - addMargin: boolean - param: TextBlockParam -} + addMargin: boolean; + param: TextBlockParam; +}; -export function UserBashInputMessage({ - param: { text }, - addMargin, -}: Props): React.ReactNode { - const input = extractTag(text, 'bash-input') +export function UserBashInputMessage({ param: { text }, addMargin }: Props): React.ReactNode { + const input = extractTag(text, 'bash-input'); if (!input) { - return null + return null; } return ( ! {input} - ) + ); } diff --git a/src/components/messages/UserBashOutputMessage.tsx b/src/components/messages/UserBashOutputMessage.tsx index 99ec8ea7e..b53c2fc52 100644 --- a/src/components/messages/UserBashOutputMessage.tsx +++ b/src/components/messages/UserBashOutputMessage.tsx @@ -1,20 +1,12 @@ -import * as React from 'react' -import BashToolResultMessage from '../../tools/BashTool/BashToolResultMessage.js' -import { extractTag } from '../../utils/messages.js' +import * as React from 'react'; +import BashToolResultMessage from '../../tools/BashTool/BashToolResultMessage.js'; +import { extractTag } from '../../utils/messages.js'; -export function UserBashOutputMessage({ - content, - verbose, -}: { - content: string - verbose?: boolean -}): React.ReactNode { - const rawStdout = extractTag(content, 'bash-stdout') ?? '' +export function UserBashOutputMessage({ content, verbose }: { content: string; verbose?: boolean }): React.ReactNode { + const rawStdout = extractTag(content, 'bash-stdout') ?? ''; // Unwrap if present — keep the inner content (file path + // preview) for the user; the wrapper tag itself is model-facing signaling. - const stdout = extractTag(rawStdout, 'persisted-output') ?? rawStdout - const stderr = extractTag(content, 'bash-stderr') ?? '' - return ( - - ) + const stdout = extractTag(rawStdout, 'persisted-output') ?? rawStdout; + const stderr = extractTag(content, 'bash-stderr') ?? ''; + return ; } diff --git a/src/components/messages/UserChannelMessage.tsx b/src/components/messages/UserChannelMessage.tsx index 8e7101b1a..bc8f49f1c 100644 --- a/src/components/messages/UserChannelMessage.tsx +++ b/src/components/messages/UserChannelMessage.tsx @@ -1,42 +1,37 @@ -import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' -import * as React from 'react' -import { CHANNEL_ARROW } from '../../constants/figures.js' -import { CHANNEL_TAG } from '../../constants/xml.js' -import { Box, Text } from '../../ink.js' -import { truncateToWidth } from '../../utils/format.js' +import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; +import * as React from 'react'; +import { CHANNEL_ARROW } from '../../constants/figures.js'; +import { CHANNEL_TAG } from '../../constants/xml.js'; +import { Box, Text } from '../../ink.js'; +import { truncateToWidth } from '../../utils/format.js'; type Props = { - addMargin: boolean - param: TextBlockParam -} + addMargin: boolean; + param: TextBlockParam; +}; // content // source is always first (wrapChannelMessage writes it), user is optional. -const CHANNEL_RE = new RegExp( - `<${CHANNEL_TAG}\\s+source="([^"]+)"([^>]*)>\\n?([\\s\\S]*?)\\n?`, -) -const USER_ATTR_RE = /\buser="([^"]+)"/ +const CHANNEL_RE = new RegExp(`<${CHANNEL_TAG}\\s+source="([^"]+)"([^>]*)>\\n?([\\s\\S]*?)\\n?`); +const USER_ATTR_RE = /\buser="([^"]+)"/; // Plugin-provided servers get names like plugin:slack-channel:slack via // addPluginScopeToServers — show just the leaf. Matches the suffix-match // logic in isServerInChannels. function displayServerName(name: string): string { - const i = name.lastIndexOf(':') - return i === -1 ? name : name.slice(i + 1) + const i = name.lastIndexOf(':'); + return i === -1 ? name : name.slice(i + 1); } -const TRUNCATE_AT = 60 +const TRUNCATE_AT = 60; -export function UserChannelMessage({ - addMargin, - param: { text }, -}: Props): React.ReactNode { - const m = CHANNEL_RE.exec(text) - if (!m) return null - const [, source, attrs, content] = m - const user = USER_ATTR_RE.exec(attrs ?? '')?.[1] - const body = (content ?? '').trim().replace(/\s+/g, ' ') - const truncated = truncateToWidth(body, TRUNCATE_AT) +export function UserChannelMessage({ addMargin, param: { text } }: Props): React.ReactNode { + const m = CHANNEL_RE.exec(text); + if (!m) return null; + const [, source, attrs, content] = m; + const user = USER_ATTR_RE.exec(attrs ?? '')?.[1]; + const body = (content ?? '').trim().replace(/\s+/g, ' '); + const truncated = truncateToWidth(body, TRUNCATE_AT); return ( @@ -48,5 +43,5 @@ export function UserChannelMessage({ {truncated} - ) + ); } diff --git a/src/components/messages/UserCommandMessage.tsx b/src/components/messages/UserCommandMessage.tsx index 31f6b2871..b153d4bed 100644 --- a/src/components/messages/UserCommandMessage.tsx +++ b/src/components/messages/UserCommandMessage.tsx @@ -1,25 +1,22 @@ -import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' -import figures from 'figures' -import * as React from 'react' -import { COMMAND_MESSAGE_TAG } from '../../constants/xml.js' -import { Box, Text } from '../../ink.js' -import { extractTag } from '../../utils/messages.js' +import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; +import figures from 'figures'; +import * as React from 'react'; +import { COMMAND_MESSAGE_TAG } from '../../constants/xml.js'; +import { Box, Text } from '../../ink.js'; +import { extractTag } from '../../utils/messages.js'; type Props = { - addMargin: boolean - param: TextBlockParam -} + addMargin: boolean; + param: TextBlockParam; +}; -export function UserCommandMessage({ - addMargin, - param: { text }, -}: Props): React.ReactNode { - const commandMessage = extractTag(text, COMMAND_MESSAGE_TAG) - const args = extractTag(text, 'command-args') - const isSkillFormat = extractTag(text, 'skill-format') === 'true' +export function UserCommandMessage({ addMargin, param: { text } }: Props): React.ReactNode { + const commandMessage = extractTag(text, COMMAND_MESSAGE_TAG); + const args = extractTag(text, 'command-args'); + const isSkillFormat = extractTag(text, 'skill-format') === 'true'; if (!commandMessage) { - return null + return null; } // Skills use "Skill(name)" format @@ -36,22 +33,17 @@ export function UserCommandMessage({ Skill({commandMessage}) - ) + ); } // Slash command format: show as "❯ /command args" - const content = `/${[commandMessage, args].filter(Boolean).join(' ')}` + const content = `/${[commandMessage, args].filter(Boolean).join(' ')}`; return ( - + {figures.pointer} {content} - ) + ); } diff --git a/src/components/messages/UserCrossSessionMessage.ts b/src/components/messages/UserCrossSessionMessage.ts index 5b86d7802..1d9677b3d 100644 --- a/src/components/messages/UserCrossSessionMessage.ts +++ b/src/components/messages/UserCrossSessionMessage.ts @@ -1,3 +1,4 @@ // Auto-generated stub — replace with real implementation -export {}; -export const UserCrossSessionMessage: (props: Record) => null = () => null; +export {} +export const UserCrossSessionMessage: (props: Record) => null = + () => null diff --git a/src/components/messages/UserForkBoilerplateMessage.ts b/src/components/messages/UserForkBoilerplateMessage.ts index 8d79de68f..c45f71de9 100644 --- a/src/components/messages/UserForkBoilerplateMessage.ts +++ b/src/components/messages/UserForkBoilerplateMessage.ts @@ -1,3 +1,5 @@ // Auto-generated stub — replace with real implementation -export {}; -export const UserForkBoilerplateMessage: (props: Record) => null = () => null; +export {} +export const UserForkBoilerplateMessage: ( + props: Record, +) => null = () => null diff --git a/src/components/messages/UserGitHubWebhookMessage.ts b/src/components/messages/UserGitHubWebhookMessage.ts index 6ed1e016f..0bd63f9a5 100644 --- a/src/components/messages/UserGitHubWebhookMessage.ts +++ b/src/components/messages/UserGitHubWebhookMessage.ts @@ -1,3 +1,5 @@ // Auto-generated stub — replace with real implementation -export {}; -export const UserGitHubWebhookMessage: (props: Record) => null = () => null; +export {} +export const UserGitHubWebhookMessage: ( + props: Record, +) => null = () => null diff --git a/src/components/messages/UserImageMessage.tsx b/src/components/messages/UserImageMessage.tsx index 3f542dfb6..36ad3fc86 100644 --- a/src/components/messages/UserImageMessage.tsx +++ b/src/components/messages/UserImageMessage.tsx @@ -1,15 +1,15 @@ -import * as React from 'react' -import { pathToFileURL } from 'url' -import Link from '../../ink/components/Link.js' -import { supportsHyperlinks } from '../../ink/supports-hyperlinks.js' -import { Box, Text } from '../../ink.js' -import { getStoredImagePath } from '../../utils/imageStore.js' -import { MessageResponse } from '../MessageResponse.js' +import * as React from 'react'; +import { pathToFileURL } from 'url'; +import Link from '../../ink/components/Link.js'; +import { supportsHyperlinks } from '../../ink/supports-hyperlinks.js'; +import { Box, Text } from '../../ink.js'; +import { getStoredImagePath } from '../../utils/imageStore.js'; +import { MessageResponse } from '../MessageResponse.js'; type Props = { - imageId?: number - addMargin?: boolean -} + imageId?: number; + addMargin?: boolean; +}; /** * Renders an image attachment in user messages. @@ -17,12 +17,9 @@ type Props = { * Uses MessageResponse styling to appear connected to the message above, * unless addMargin is true (image starts a new user turn without text). */ -export function UserImageMessage({ - imageId, - addMargin, -}: Props): React.ReactNode { - const label = imageId ? `[Image #${imageId}]` : '[Image]' - const imagePath = imageId ? getStoredImagePath(imageId) : null +export function UserImageMessage({ imageId, addMargin }: Props): React.ReactNode { + const label = imageId ? `[Image #${imageId}]` : '[Image]'; + const imagePath = imageId ? getStoredImagePath(imageId) : null; const content = imagePath && supportsHyperlinks() ? ( @@ -31,13 +28,13 @@ export function UserImageMessage({ ) : ( {label} - ) + ); // When this image starts a new user turn (no text before it), // show with margin instead of the connected line style if (addMargin) { - return {content} + return {content}; } - return {content} + return {content}; } diff --git a/src/components/messages/UserLocalCommandOutputMessage.tsx b/src/components/messages/UserLocalCommandOutputMessage.tsx index b1c95616a..319fba793 100644 --- a/src/components/messages/UserLocalCommandOutputMessage.tsx +++ b/src/components/messages/UserLocalCommandOutputMessage.tsx @@ -1,44 +1,39 @@ -import * as React from 'react' -import { DIAMOND_FILLED, DIAMOND_OPEN } from '../../constants/figures.js' -import { NO_CONTENT_MESSAGE } from '../../constants/messages.js' -import { Box, Text } from '../../ink.js' -import { extractTag } from '../../utils/messages.js' -import { Markdown } from '../Markdown.js' -import { MessageResponse } from '../MessageResponse.js' +import * as React from 'react'; +import { DIAMOND_FILLED, DIAMOND_OPEN } from '../../constants/figures.js'; +import { NO_CONTENT_MESSAGE } from '../../constants/messages.js'; +import { Box, Text } from '../../ink.js'; +import { extractTag } from '../../utils/messages.js'; +import { Markdown } from '../Markdown.js'; +import { MessageResponse } from '../MessageResponse.js'; type Props = { - content: string -} + content: string; +}; -export function UserLocalCommandOutputMessage({ - content, -}: Props): React.ReactNode { - const stdout = extractTag(content, 'local-command-stdout') - const stderr = extractTag(content, 'local-command-stderr') +export function UserLocalCommandOutputMessage({ content }: Props): React.ReactNode { + const stdout = extractTag(content, 'local-command-stdout'); + const stderr = extractTag(content, 'local-command-stderr'); if (!stdout && !stderr) { return ( {NO_CONTENT_MESSAGE} - ) + ); } - const lines: React.ReactNode[] = [] + const lines: React.ReactNode[] = []; if (stdout?.trim()) { - lines.push({stdout.trim()}) + lines.push({stdout.trim()}); } if (stderr?.trim()) { - lines.push({stderr.trim()}) + lines.push({stderr.trim()}); } - return lines + return lines; } function IndentedContent({ children }: { children: string }): React.ReactNode { - if ( - children.startsWith(`${DIAMOND_OPEN} `) || - children.startsWith(`${DIAMOND_FILLED} `) - ) { - return {children} + if (children.startsWith(`${DIAMOND_OPEN} `) || children.startsWith(`${DIAMOND_FILLED} `)) { + return {children}; } return ( @@ -47,21 +42,17 @@ function IndentedContent({ children }: { children: string }): React.ReactNode { {children} - ) + ); } -function CloudLaunchContent({ - children, -}: { - children: string -}): React.ReactNode { - const diamond = children[0]! - const nl = children.indexOf('\n') - const header = nl === -1 ? children.slice(2) : children.slice(2, nl) - const rest = nl === -1 ? '' : children.slice(nl + 1).trim() - const sep = header.indexOf(' · ') - const label = sep === -1 ? header : header.slice(0, sep) - const suffix = sep === -1 ? '' : header.slice(sep) +function CloudLaunchContent({ children }: { children: string }): React.ReactNode { + const diamond = children[0]!; + const nl = children.indexOf('\n'); + const header = nl === -1 ? children.slice(2) : children.slice(2, nl); + const rest = nl === -1 ? '' : children.slice(nl + 1).trim(); + const sep = header.indexOf(' · '); + const label = sep === -1 ? header : header.slice(0, sep); + const suffix = sep === -1 ? '' : header.slice(sep); return ( @@ -76,5 +67,5 @@ function CloudLaunchContent({ )} - ) + ); } diff --git a/src/components/messages/UserMemoryInputMessage.tsx b/src/components/messages/UserMemoryInputMessage.tsx index 25a8d7a1c..666aa017e 100644 --- a/src/components/messages/UserMemoryInputMessage.tsx +++ b/src/components/messages/UserMemoryInputMessage.tsx @@ -1,28 +1,25 @@ -import sample from 'lodash-es/sample.js' -import * as React from 'react' -import { useMemo } from 'react' -import { Box, Text } from '../../ink.js' -import { extractTag } from '../../utils/messages.js' -import { MessageResponse } from '../MessageResponse.js' +import sample from 'lodash-es/sample.js'; +import * as React from 'react'; +import { useMemo } from 'react'; +import { Box, Text } from '../../ink.js'; +import { extractTag } from '../../utils/messages.js'; +import { MessageResponse } from '../MessageResponse.js'; function getSavingMessage(): string { - return sample(['Got it.', 'Good to know.', 'Noted.']) + return sample(['Got it.', 'Good to know.', 'Noted.']); } type Props = { - addMargin: boolean - text: string -} + addMargin: boolean; + text: string; +}; -export function UserMemoryInputMessage({ - text, - addMargin, -}: Props): React.ReactNode { - const input = extractTag(text, 'user-memory-input') - const savingText = useMemo(() => getSavingMessage(), []) +export function UserMemoryInputMessage({ text, addMargin }: Props): React.ReactNode { + const input = extractTag(text, 'user-memory-input'); + const savingText = useMemo(() => getSavingMessage(), []); if (!input) { - return null + return null; } return ( @@ -40,5 +37,5 @@ export function UserMemoryInputMessage({ {savingText} - ) + ); } diff --git a/src/components/messages/UserPlanMessage.tsx b/src/components/messages/UserPlanMessage.tsx index 5ef8fa89a..ee339d6be 100644 --- a/src/components/messages/UserPlanMessage.tsx +++ b/src/components/messages/UserPlanMessage.tsx @@ -1,24 +1,15 @@ -import * as React from 'react' -import { Box, Text } from '../../ink.js' -import { Markdown } from '../Markdown.js' +import * as React from 'react'; +import { Box, Text } from '../../ink.js'; +import { Markdown } from '../Markdown.js'; type Props = { - addMargin: boolean - planContent: string -} + addMargin: boolean; + planContent: string; +}; -export function UserPlanMessage({ - addMargin, - planContent, -}: Props): React.ReactNode { +export function UserPlanMessage({ addMargin, planContent }: Props): React.ReactNode { return ( - + Plan to implement @@ -26,5 +17,5 @@ export function UserPlanMessage({ {planContent} - ) + ); } diff --git a/src/components/messages/UserPromptMessage.tsx b/src/components/messages/UserPromptMessage.tsx index 090cac272..02fa1b736 100644 --- a/src/components/messages/UserPromptMessage.tsx +++ b/src/components/messages/UserPromptMessage.tsx @@ -1,22 +1,22 @@ -import { feature } from 'bun:bundle' -import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' -import React, { useContext, useMemo } from 'react' -import { getKairosActive, getUserMsgOptIn } from '../../bootstrap/state.js' -import { Box } from '../../ink.js' -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' -import { useAppState } from '../../state/AppState.js' -import { isEnvTruthy } from '../../utils/envUtils.js' -import { logError } from '../../utils/log.js' -import { countCharInString } from '../../utils/stringUtils.js' -import { MessageActionsSelectedContext } from '../messageActions.js' -import { HighlightedThinkingText } from './HighlightedThinkingText.js' +import { feature } from 'bun:bundle'; +import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; +import React, { useContext, useMemo } from 'react'; +import { getKairosActive, getUserMsgOptIn } from '../../bootstrap/state.js'; +import { Box } from '../../ink.js'; +import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js'; +import { useAppState } from '../../state/AppState.js'; +import { isEnvTruthy } from '../../utils/envUtils.js'; +import { logError } from '../../utils/log.js'; +import { countCharInString } from '../../utils/stringUtils.js'; +import { MessageActionsSelectedContext } from '../messageActions.js'; +import { HighlightedThinkingText } from './HighlightedThinkingText.js'; type Props = { - addMargin: boolean - param: TextBlockParam - isTranscriptMode?: boolean - timestamp?: string -} + addMargin: boolean; + param: TextBlockParam; + isTranscriptMode?: boolean; + timestamp?: string; +}; // Hard cap on displayed prompt text. Piping large files via stdin // (e.g. `cat 11k-line-file | claude`) creates a single user message whose @@ -26,16 +26,11 @@ type Props = { // avoids this via (print-and-forget to terminal scrollback). // Head+tail because `{ cat file; echo prompt; } | claude` puts the user's // actual question at the end. -const MAX_DISPLAY_CHARS = 10_000 -const TRUNCATE_HEAD_CHARS = 2_500 -const TRUNCATE_TAIL_CHARS = 2_500 +const MAX_DISPLAY_CHARS = 10_000; +const TRUNCATE_HEAD_CHARS = 2_500; +const TRUNCATE_TAIL_CHARS = 2_500; -export function UserPromptMessage({ - addMargin, - param: { text }, - isTranscriptMode, - timestamp, -}: Props): React.ReactNode { +export function UserPromptMessage({ addMargin, param: { text }, isTranscriptMode, timestamp }: Props): React.ReactNode { // REPL.tsx passes isBriefOnly={viewedTeammateTask ? false : isBriefOnly} // but that prop isn't threaded this deep — replicate the override by // reading viewingAgentTaskId directly. Computed here (not in the child) @@ -52,61 +47,49 @@ export function UserPromptMessage({ feature('KAIROS') || feature('KAIROS_BRIEF') ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant useAppState(s => s.isBriefOnly) - : false + : false; const viewingAgentTaskId = feature('KAIROS') || feature('KAIROS_BRIEF') ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant useAppState(s => s.viewingAgentTaskId) - : null + : null; // Hoisted to mount-time — per-message component, re-renders on every scroll. 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 + : false; const useBriefLayout = feature('KAIROS') || feature('KAIROS_BRIEF') ? (getKairosActive() || (getUserMsgOptIn() && - (briefEnvEnabled || - getFeatureValue_CACHED_MAY_BE_STALE( - 'tengu_kairos_brief', - false, - )))) && + (briefEnvEnabled || getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_brief', false)))) && isBriefOnly && !isTranscriptMode && !viewingAgentTaskId - : false + : false; // Truncate before the early return so the hook order is stable. const displayText = useMemo(() => { - if (text.length <= MAX_DISPLAY_CHARS) return text - const head = text.slice(0, TRUNCATE_HEAD_CHARS) - const tail = text.slice(-TRUNCATE_TAIL_CHARS) - const hiddenLines = - countCharInString(text, '\n', TRUNCATE_HEAD_CHARS) - - countCharInString(tail, '\n') - return `${head}\n… +${hiddenLines} lines …\n${tail}` - }, [text]) + if (text.length <= MAX_DISPLAY_CHARS) return text; + const head = text.slice(0, TRUNCATE_HEAD_CHARS); + const tail = text.slice(-TRUNCATE_TAIL_CHARS); + const hiddenLines = countCharInString(text, '\n', TRUNCATE_HEAD_CHARS) - countCharInString(tail, '\n'); + return `${head}\n… +${hiddenLines} lines …\n${tail}`; + }, [text]); - const isSelected = useContext(MessageActionsSelectedContext) + const isSelected = useContext(MessageActionsSelectedContext); if (!text) { - logError(new Error('No content found in user prompt message')) - return null + logError(new Error('No content found in user prompt message')); + return null; } return ( - ) + ); } diff --git a/src/components/messages/UserResourceUpdateMessage.tsx b/src/components/messages/UserResourceUpdateMessage.tsx index ce1f4f5d5..3b9a4d893 100644 --- a/src/components/messages/UserResourceUpdateMessage.tsx +++ b/src/components/messages/UserResourceUpdateMessage.tsx @@ -1,91 +1,83 @@ -import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' -import * as React from 'react' -import { REFRESH_ARROW } from '../../constants/figures.js' -import { Box, Text } from '../../ink.js' +import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; +import * as React from 'react'; +import { REFRESH_ARROW } from '../../constants/figures.js'; +import { Box, Text } from '../../ink.js'; type Props = { - addMargin: boolean - param: TextBlockParam -} + addMargin: boolean; + param: TextBlockParam; +}; type ParsedUpdate = { - kind: 'resource' | 'polling' - server: string + kind: 'resource' | 'polling'; + server: string; /** URI for resource updates, tool name for polling updates */ - target: string - reason?: string -} + target: string; + reason?: string; +}; // Parse resource and polling updates from XML format function parseUpdates(text: string): ParsedUpdate[] { - const updates: ParsedUpdate[] = [] + const updates: ParsedUpdate[] = []; // Match const resourceRegex = - /]*>(?:[\s\S]*?([^<]+)<\/reason>)?/g - let match + /]*>(?:[\s\S]*?([^<]+)<\/reason>)?/g; + let match; while ((match = resourceRegex.exec(text)) !== null) { updates.push({ kind: 'resource', server: match[1] ?? '', target: match[2] ?? '', reason: match[3], - }) + }); } // Match const pollingRegex = - /]*>(?:[\s\S]*?([^<]+)<\/reason>)?/g + /]*>(?:[\s\S]*?([^<]+)<\/reason>)?/g; while ((match = pollingRegex.exec(text)) !== null) { updates.push({ kind: 'polling', server: match[2] ?? '', target: match[3] ?? '', reason: match[4], - }) + }); } - return updates + return updates; } // Format URI for display - show just the meaningful part function formatUri(uri: string): string { // For file:// URIs, show just the filename if (uri.startsWith('file://')) { - const path = uri.slice(7) - const parts = path.split('/') - return parts[parts.length - 1] || path + const path = uri.slice(7); + const parts = path.split('/'); + return parts[parts.length - 1] || path; } // For other URIs, show the whole thing but truncated if (uri.length > 40) { - return uri.slice(0, 39) + '\u2026' + return uri.slice(0, 39) + '\u2026'; } - return uri + return uri; } -export function UserResourceUpdateMessage({ - addMargin, - param: { text }, -}: Props): React.ReactNode { - const updates = parseUpdates(text) - if (updates.length === 0) return null +export function UserResourceUpdateMessage({ addMargin, param: { text } }: Props): React.ReactNode { + const updates = parseUpdates(text); + if (updates.length === 0) return null; return ( {updates.map((update, i) => ( - {REFRESH_ARROW}{' '} - {update.server}:{' '} - - {update.kind === 'resource' - ? formatUri(update.target) - : update.target} - + {REFRESH_ARROW} {update.server}:{' '} + {update.kind === 'resource' ? formatUri(update.target) : update.target} {update.reason && · {update.reason}} ))} - ) + ); } diff --git a/src/components/messages/UserTeammateMessage.tsx b/src/components/messages/UserTeammateMessage.tsx index 4c174ff1c..d66d1c57b 100644 --- a/src/components/messages/UserTeammateMessage.tsx +++ b/src/components/messages/UserTeammateMessage.tsx @@ -1,33 +1,33 @@ -import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' -import figures from 'figures' -import * as React from 'react' -import { TEAMMATE_MESSAGE_TAG } from '../../constants/xml.js' -import { Ansi, Box, Text, type TextProps } from '../../ink.js' -import { toInkColor } from '../../utils/ink.js' -import { jsonParse } from '../../utils/slowOperations.js' -import { isShutdownApproved } from '../../utils/teammateMailbox.js' -import { MessageResponse } from '../MessageResponse.js' -import { tryRenderPlanApprovalMessage } from './PlanApprovalMessage.js' -import { tryRenderShutdownMessage } from './ShutdownMessage.js' -import { tryRenderTaskAssignmentMessage } from './TaskAssignmentMessage.js' +import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; +import figures from 'figures'; +import * as React from 'react'; +import { TEAMMATE_MESSAGE_TAG } from '../../constants/xml.js'; +import { Ansi, Box, Text, type TextProps } from '../../ink.js'; +import { toInkColor } from '../../utils/ink.js'; +import { jsonParse } from '../../utils/slowOperations.js'; +import { isShutdownApproved } from '../../utils/teammateMailbox.js'; +import { MessageResponse } from '../MessageResponse.js'; +import { tryRenderPlanApprovalMessage } from './PlanApprovalMessage.js'; +import { tryRenderShutdownMessage } from './ShutdownMessage.js'; +import { tryRenderTaskAssignmentMessage } from './TaskAssignmentMessage.js'; type Props = { - addMargin: boolean - param: TextBlockParam - isTranscriptMode?: boolean -} + addMargin: boolean; + param: TextBlockParam; + isTranscriptMode?: boolean; +}; type ParsedMessage = { - teammateId: string - content: string - color?: string - summary?: string -} + teammateId: string; + content: string; + color?: string; + summary?: string; +}; const TEAMMATE_MSG_REGEX = new RegExp( `<${TEAMMATE_MESSAGE_TAG}\\s+teammate_id="([^"]+)"(?:\\s+color="([^"]+)")?(?:\\s+summary="([^"]+)")?>\\n?([\\s\\S]*?)\\n?<\\/${TEAMMATE_MESSAGE_TAG}>`, 'g', -) +); /** * Parse all teammate messages from XML format: @@ -35,7 +35,7 @@ const TEAMMATE_MSG_REGEX = new RegExp( * Supports multiple messages in a single text block. */ function parseTeammateMessages(text: string): ParsedMessage[] { - const messages: ParsedMessage[] = [] + const messages: ParsedMessage[] = []; // Use matchAll to find all matches (this is a RegExp method, not child_process) for (const match of text.matchAll(TEAMMATE_MSG_REGEX)) { if (match[1] && match[4]) { @@ -44,114 +44,97 @@ function parseTeammateMessages(text: string): ParsedMessage[] { color: match[2], // may be undefined summary: match[3], // may be undefined content: match[4].trim(), - }) + }); } } - return messages + return messages; } function getDisplayName(teammateId: string): string { if (teammateId === 'leader') { - return 'leader' + return 'leader'; } - return teammateId + return teammateId; } -export function UserTeammateMessage({ - addMargin, - param: { text }, - isTranscriptMode, -}: Props): React.ReactNode { +export function UserTeammateMessage({ addMargin, param: { text }, isTranscriptMode }: Props): React.ReactNode { const messages = parseTeammateMessages(text).filter(msg => { // Pre-filter shutdown lifecycle messages to avoid empty wrapper // Box elements creating blank lines between model turns if (isShutdownApproved(msg.content)) { - return false + return false; } try { - const parsed = jsonParse(msg.content) - if (parsed?.type === 'teammate_terminated') return false + const parsed = jsonParse(msg.content); + if (parsed?.type === 'teammate_terminated') return false; } catch { // Not JSON, keep the message } - return true - }) + return true; + }); if (messages.length === 0) { - return null + return null; } return ( {messages.map((msg, index) => { - const inkColor = toInkColor(msg.color) - const displayName = getDisplayName(msg.teammateId) + const inkColor = toInkColor(msg.color); + const displayName = getDisplayName(msg.teammateId); // Try to render as plan approval message (request or response) - const planApprovalElement = tryRenderPlanApprovalMessage( - msg.content, - displayName, - ) + const planApprovalElement = tryRenderPlanApprovalMessage(msg.content, displayName); if (planApprovalElement) { - return ( - {planApprovalElement} - ) + return {planApprovalElement}; } // Try to render as shutdown message (request or rejected) - const shutdownElement = tryRenderShutdownMessage(msg.content) + const shutdownElement = tryRenderShutdownMessage(msg.content); if (shutdownElement) { - return {shutdownElement} + return {shutdownElement}; } // Try to render as task assignment message - const taskAssignmentElement = tryRenderTaskAssignmentMessage( - msg.content, - ) + const taskAssignmentElement = tryRenderTaskAssignmentMessage(msg.content); if (taskAssignmentElement) { - return ( - {taskAssignmentElement} - ) + return {taskAssignmentElement}; } // Try to parse as structured JSON message - let parsedIdleNotification: { type?: string } | null = null + let parsedIdleNotification: { type?: string } | null = null; try { - parsedIdleNotification = jsonParse(msg.content) + parsedIdleNotification = jsonParse(msg.content); } catch { // Not JSON } // Hide idle notifications - they are processed silently if (parsedIdleNotification?.type === 'idle_notification') { - return null + return null; } // Task completed notification - show which task was completed if (parsedIdleNotification?.type === 'task_completed') { const taskCompleted = parsedIdleNotification as { - type: string - from: string - taskId: string - taskSubject?: string - } + type: string; + from: string; + taskId: string; + taskSubject?: string; + }; return ( - {`@${displayName}${figures.pointer}`} + {`@${displayName}${figures.pointer}`} {' '} Completed task #{taskCompleted.taskId} - {taskCompleted.taskSubject && ( - ({taskCompleted.taskSubject}) - )} + {taskCompleted.taskSubject && ({taskCompleted.taskSubject})} - ) + ); } // Default: plain text message (truncated) @@ -164,19 +147,19 @@ export function UserTeammateMessage({ summary={msg.summary} isTranscriptMode={isTranscriptMode} /> - ) + ); })} - ) + ); } type TeammateMessageContentProps = { - displayName: string - inkColor: TextProps['color'] - content: string - summary?: string - isTranscriptMode?: boolean -} + displayName: string; + inkColor: TextProps['color']; + content: string; + summary?: string; + isTranscriptMode?: boolean; +}; export function TeammateMessageContent({ displayName, @@ -199,5 +182,5 @@ export function TeammateMessageContent({ )} - ) + ); } diff --git a/src/components/messages/UserTextMessage.tsx b/src/components/messages/UserTextMessage.tsx index 73ef3929d..e8798884c 100644 --- a/src/components/messages/UserTextMessage.tsx +++ b/src/components/messages/UserTextMessage.tsx @@ -1,41 +1,37 @@ -import { feature } from 'bun:bundle' -import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' -import * as React from 'react' -import { NO_CONTENT_MESSAGE } from '../../constants/messages.js' +import { feature } from 'bun:bundle'; +import type { TextBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; +import * as React from 'react'; +import { NO_CONTENT_MESSAGE } from '../../constants/messages.js'; import { COMMAND_MESSAGE_TAG, LOCAL_COMMAND_CAVEAT_TAG, TASK_NOTIFICATION_TAG, TEAMMATE_MESSAGE_TAG, TICK_TAG, -} from '../../constants/xml.js' -import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' -import { - extractTag, - INTERRUPT_MESSAGE, - INTERRUPT_MESSAGE_FOR_TOOL_USE, -} from '../../utils/messages.js' -import { InterruptedByUser } from '../InterruptedByUser.js' -import { MessageResponse } from '../MessageResponse.js' -import { UserAgentNotificationMessage } from './UserAgentNotificationMessage.js' -import { UserBashInputMessage } from './UserBashInputMessage.js' -import { UserBashOutputMessage } from './UserBashOutputMessage.js' -import { UserCommandMessage } from './UserCommandMessage.js' -import { UserLocalCommandOutputMessage } from './UserLocalCommandOutputMessage.js' -import { UserMemoryInputMessage } from './UserMemoryInputMessage.js' -import { UserPlanMessage } from './UserPlanMessage.js' -import { UserPromptMessage } from './UserPromptMessage.js' -import { UserResourceUpdateMessage } from './UserResourceUpdateMessage.js' -import { UserTeammateMessage } from './UserTeammateMessage.js' +} from '../../constants/xml.js'; +import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'; +import { extractTag, INTERRUPT_MESSAGE, INTERRUPT_MESSAGE_FOR_TOOL_USE } from '../../utils/messages.js'; +import { InterruptedByUser } from '../InterruptedByUser.js'; +import { MessageResponse } from '../MessageResponse.js'; +import { UserAgentNotificationMessage } from './UserAgentNotificationMessage.js'; +import { UserBashInputMessage } from './UserBashInputMessage.js'; +import { UserBashOutputMessage } from './UserBashOutputMessage.js'; +import { UserCommandMessage } from './UserCommandMessage.js'; +import { UserLocalCommandOutputMessage } from './UserLocalCommandOutputMessage.js'; +import { UserMemoryInputMessage } from './UserMemoryInputMessage.js'; +import { UserPlanMessage } from './UserPlanMessage.js'; +import { UserPromptMessage } from './UserPromptMessage.js'; +import { UserResourceUpdateMessage } from './UserResourceUpdateMessage.js'; +import { UserTeammateMessage } from './UserTeammateMessage.js'; type Props = { - addMargin: boolean - param: TextBlockParam - verbose: boolean - planContent?: string - isTranscriptMode?: boolean - timestamp?: string -} + addMargin: boolean; + param: TextBlockParam; + verbose: boolean; + planContent?: string; + isTranscriptMode?: boolean; + timestamp?: string; +}; export function UserTextMessage({ addMargin, @@ -46,49 +42,40 @@ export function UserTextMessage({ timestamp, }: Props): React.ReactNode { if (param.text.trim() === NO_CONTENT_MESSAGE) { - return null + return null; } // Plan to implement message (cleared context flow) if (planContent) { - return + return ; } if (extractTag(param.text, TICK_TAG)) { - return null + return null; } // Hide synthetic caveat messages (should be filtered by isMeta, this is defensive) if (param.text.includes(`<${LOCAL_COMMAND_CAVEAT_TAG}>`)) { - return null + return null; } // Show bash output - if ( - param.text.startsWith(' + if (param.text.startsWith('; } // Show command output - if ( - param.text.startsWith(' + if (param.text.startsWith('; } // Handle interruption messages specially - if ( - param.text === INTERRUPT_MESSAGE || - param.text === INTERRUPT_MESSAGE_FOR_TOOL_USE - ) { + if (param.text === INTERRUPT_MESSAGE || param.text === INTERRUPT_MESSAGE_FOR_TOOL_USE) { return ( - ) + ); } // GitHub webhook events (check_run, review comments, pushes) delivered via @@ -101,51 +88,39 @@ export function UserTextMessage({ if (param.text.startsWith('')) { /* eslint-disable @typescript-eslint/no-require-imports */ const { UserGitHubWebhookMessage } = - require('./UserGitHubWebhookMessage.js') as typeof import('./UserGitHubWebhookMessage.js') + require('./UserGitHubWebhookMessage.js') as typeof import('./UserGitHubWebhookMessage.js'); /* eslint-enable @typescript-eslint/no-require-imports */ - return + return ; } } // Bash inputs! if (param.text.includes('')) { - return + return ; } // Slash commands/ if (param.text.includes(`<${COMMAND_MESSAGE_TAG}>`)) { - return + return ; } if (param.text.includes('')) { - return + return ; } // Teammate messages - only check when swarms enabled - if ( - isAgentSwarmsEnabled() && - param.text.includes(`<${TEAMMATE_MESSAGE_TAG}`) - ) { - return ( - - ) + if (isAgentSwarmsEnabled() && param.text.includes(`<${TEAMMATE_MESSAGE_TAG}`)) { + return ; } // Task notifications (agent completions, bash completions, etc.) if (param.text.includes(`<${TASK_NOTIFICATION_TAG}`)) { - return + return ; } // MCP resource and polling update notifications - if ( - param.text.includes(' + if (param.text.includes('; } // Fork child's first message: collapse the rules/format boilerplate, show @@ -155,9 +130,9 @@ export function UserTextMessage({ if (param.text.includes('')) { /* eslint-disable @typescript-eslint/no-require-imports */ const { UserForkBoilerplateMessage } = - require('./UserForkBoilerplateMessage.js') as typeof import('./UserForkBoilerplateMessage.js') + require('./UserForkBoilerplateMessage.js') as typeof import('./UserForkBoilerplateMessage.js'); /* eslint-enable @typescript-eslint/no-require-imports */ - return + return ; } } @@ -168,9 +143,9 @@ export function UserTextMessage({ if (param.text.includes(' + return ; } } @@ -178,20 +153,14 @@ export function UserTextMessage({ if (feature('KAIROS') || feature('KAIROS_CHANNELS')) { if (param.text.includes(' - + {renderedMessage} {feature('BASH_CLASSIFIER') ? classifierRule && ( @@ -145,5 +127,5 @@ export function UserToolSuccessMessage({ /> - ) + ); } diff --git a/src/components/messages/UserToolResultMessage/src/components/InterruptedByUser.ts b/src/components/messages/UserToolResultMessage/src/components/InterruptedByUser.ts index b98ac191d..c26df5d69 100644 --- a/src/components/messages/UserToolResultMessage/src/components/InterruptedByUser.ts +++ b/src/components/messages/UserToolResultMessage/src/components/InterruptedByUser.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type InterruptedByUser = any; +export type InterruptedByUser = any diff --git a/src/components/messages/UserToolResultMessage/src/components/Markdown.ts b/src/components/messages/UserToolResultMessage/src/components/Markdown.ts index 44bbabff4..395cc4df0 100644 --- a/src/components/messages/UserToolResultMessage/src/components/Markdown.ts +++ b/src/components/messages/UserToolResultMessage/src/components/Markdown.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type Markdown = any; +export type Markdown = any diff --git a/src/components/messages/UserToolResultMessage/src/components/MessageResponse.ts b/src/components/messages/UserToolResultMessage/src/components/MessageResponse.ts index cc1c024e1..89ad6892a 100644 --- a/src/components/messages/UserToolResultMessage/src/components/MessageResponse.ts +++ b/src/components/messages/UserToolResultMessage/src/components/MessageResponse.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type MessageResponse = any; +export type MessageResponse = any diff --git a/src/components/messages/UserToolResultMessage/src/components/SentryErrorBoundary.ts b/src/components/messages/UserToolResultMessage/src/components/SentryErrorBoundary.ts index 6c2e95bfc..821c77dd7 100644 --- a/src/components/messages/UserToolResultMessage/src/components/SentryErrorBoundary.ts +++ b/src/components/messages/UserToolResultMessage/src/components/SentryErrorBoundary.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type SentryErrorBoundary = any; +export type SentryErrorBoundary = any diff --git a/src/components/messages/UserToolResultMessage/utils.tsx b/src/components/messages/UserToolResultMessage/utils.tsx index 4eeeb8004..c566cb357 100644 --- a/src/components/messages/UserToolResultMessage/utils.tsx +++ b/src/components/messages/UserToolResultMessage/utils.tsx @@ -1,7 +1,7 @@ -import type { ToolUseBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' -import { useMemo } from 'react' -import { findToolByName, type Tool, type Tools } from '../../../Tool.js' -import type { buildMessageLookups } from '../../../utils/messages.js' +import type { ToolUseBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; +import { useMemo } from 'react'; +import { findToolByName, type Tool, type Tools } from '../../../Tool.js'; +import type { buildMessageLookups } from '../../../utils/messages.js'; export function useGetToolFromMessages( toolUseID: string, @@ -9,14 +9,14 @@ export function useGetToolFromMessages( lookups: ReturnType, ): { tool: Tool; toolUse: ToolUseBlockParam } | null { return useMemo(() => { - const toolUse = lookups.toolUseByToolUseID.get(toolUseID) + const toolUse = lookups.toolUseByToolUseID.get(toolUseID); if (!toolUse) { - return null + return null; } - const tool = findToolByName(tools, toolUse.name) + const tool = findToolByName(tools, toolUse.name); if (!tool) { - return null + return null; } - return { tool, toolUse } - }, [toolUseID, lookups, tools]) + return { tool, toolUse }; + }, [toolUseID, lookups, tools]); } diff --git a/src/components/messages/nullRenderingAttachments.ts b/src/components/messages/nullRenderingAttachments.ts index 6cb3a7947..9b9a4e8b9 100644 --- a/src/components/messages/nullRenderingAttachments.ts +++ b/src/components/messages/nullRenderingAttachments.ts @@ -63,6 +63,8 @@ export function isNullRenderingAttachment( ): boolean { return ( msg.type === 'attachment' && - NULL_RENDERING_ATTACHMENT_TYPES.has(msg.attachment.type as Attachment['type']) + NULL_RENDERING_ATTACHMENT_TYPES.has( + msg.attachment.type as Attachment['type'], + ) ) } diff --git a/src/components/messages/src/commands/extra-usage/index.ts b/src/components/messages/src/commands/extra-usage/index.ts index 981d9a598..6bcb018f0 100644 --- a/src/components/messages/src/commands/extra-usage/index.ts +++ b/src/components/messages/src/commands/extra-usage/index.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type extraUsage = any; +export type extraUsage = any diff --git a/src/components/messages/src/entrypoints/agentSdkTypes.ts b/src/components/messages/src/entrypoints/agentSdkTypes.ts index 2ad98a3ad..0d880a5cf 100644 --- a/src/components/messages/src/entrypoints/agentSdkTypes.ts +++ b/src/components/messages/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/messages/src/hooks/useTerminalSize.ts b/src/components/messages/src/hooks/useTerminalSize.ts index 4a0ef3ea3..fdaf2e999 100644 --- a/src/components/messages/src/hooks/useTerminalSize.ts +++ b/src/components/messages/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/messages/src/ink.ts b/src/components/messages/src/ink.ts index 51d6eb4b7..7371bcca6 100644 --- a/src/components/messages/src/ink.ts +++ b/src/components/messages/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/messages/src/services/api/errorUtils.ts b/src/components/messages/src/services/api/errorUtils.ts index b931a01ac..716d87177 100644 --- a/src/components/messages/src/services/api/errorUtils.ts +++ b/src/components/messages/src/services/api/errorUtils.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type formatAPIError = any; +export type formatAPIError = any diff --git a/src/components/messages/src/services/claudeAiLimitsHook.ts b/src/components/messages/src/services/claudeAiLimitsHook.ts index 617852958..162891d0a 100644 --- a/src/components/messages/src/services/claudeAiLimitsHook.ts +++ b/src/components/messages/src/services/claudeAiLimitsHook.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type useClaudeAiLimits = any; +export type useClaudeAiLimits = any diff --git a/src/components/messages/src/services/compact/compact.ts b/src/components/messages/src/services/compact/compact.ts index 628b299f4..6f05abdb5 100644 --- a/src/components/messages/src/services/compact/compact.ts +++ b/src/components/messages/src/services/compact/compact.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type ERROR_MESSAGE_USER_ABORT = any; +export type ERROR_MESSAGE_USER_ABORT = any diff --git a/src/components/messages/src/services/rateLimitMessages.ts b/src/components/messages/src/services/rateLimitMessages.ts index 3bad4ecc1..9f2222768 100644 --- a/src/components/messages/src/services/rateLimitMessages.ts +++ b/src/components/messages/src/services/rateLimitMessages.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type isRateLimitErrorMessage = any; +export type isRateLimitErrorMessage = any diff --git a/src/components/messages/src/services/rateLimitMocking.ts b/src/components/messages/src/services/rateLimitMocking.ts index dcb1004a0..063f78c26 100644 --- a/src/components/messages/src/services/rateLimitMocking.ts +++ b/src/components/messages/src/services/rateLimitMocking.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type shouldProcessMockLimits = any; +export type shouldProcessMockLimits = any diff --git a/src/components/messages/src/types/message.ts b/src/components/messages/src/types/message.ts index 0e8a93593..fe4a8d239 100644 --- a/src/components/messages/src/types/message.ts +++ b/src/components/messages/src/types/message.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type SystemAPIErrorMessage = any; +export type SystemAPIErrorMessage = any diff --git a/src/components/messages/src/utils/attachments.ts b/src/components/messages/src/utils/attachments.ts index 69d3ceac5..52714a0e6 100644 --- a/src/components/messages/src/utils/attachments.ts +++ b/src/components/messages/src/utils/attachments.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type Attachment = any; +export type Attachment = any diff --git a/src/components/messages/src/utils/auth.ts b/src/components/messages/src/utils/auth.ts index 4a25a2f83..bd7582a9e 100644 --- a/src/components/messages/src/utils/auth.ts +++ b/src/components/messages/src/utils/auth.ts @@ -1,4 +1,4 @@ // Auto-generated type stub — replace with real implementation -export type getRateLimitTier = any; -export type getSubscriptionType = any; -export type isClaudeAISubscriber = any; +export type getRateLimitTier = any +export type getSubscriptionType = any +export type isClaudeAISubscriber = any diff --git a/src/components/messages/src/utils/billing.ts b/src/components/messages/src/utils/billing.ts index fd3cae2d7..de39042b1 100644 --- a/src/components/messages/src/utils/billing.ts +++ b/src/components/messages/src/utils/billing.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type hasClaudeAiBillingAccess = any; +export type hasClaudeAiBillingAccess = any diff --git a/src/components/messages/src/utils/file.ts b/src/components/messages/src/utils/file.ts index 74475b437..86769d40f 100644 --- a/src/components/messages/src/utils/file.ts +++ b/src/components/messages/src/utils/file.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getDisplayPath = any; +export type getDisplayPath = any diff --git a/src/components/messages/src/utils/format.ts b/src/components/messages/src/utils/format.ts index 0fcc3e5ee..b13a3d1c7 100644 --- a/src/components/messages/src/utils/format.ts +++ b/src/components/messages/src/utils/format.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type formatFileSize = any; +export type formatFileSize = any diff --git a/src/components/messages/src/utils/messages.ts b/src/components/messages/src/utils/messages.ts index f552071d2..6f917b58f 100644 --- a/src/components/messages/src/utils/messages.ts +++ b/src/components/messages/src/utils/messages.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type buildMessageLookups = any; -export type getContentText = any; +export type buildMessageLookups = any +export type getContentText = any diff --git a/src/components/messages/src/utils/theme.ts b/src/components/messages/src/utils/theme.ts index 6c2dc45a6..849f6973b 100644 --- a/src/components/messages/src/utils/theme.ts +++ b/src/components/messages/src/utils/theme.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type ThemeName = any; -export type Theme = any; +export type ThemeName = any +export type Theme = any diff --git a/src/components/messages/teamMemCollapsed.tsx b/src/components/messages/teamMemCollapsed.tsx index 63fcdaf0e..b98502f49 100644 --- a/src/components/messages/teamMemCollapsed.tsx +++ b/src/components/messages/teamMemCollapsed.tsx @@ -1,6 +1,6 @@ -import React from 'react' -import { Text } from '../../ink.js' -import type { CollapsedReadSearchGroup } from '../../types/message.js' +import React from 'react'; +import { Text } from '../../ink.js'; +import type { CollapsedReadSearchGroup } from '../../types/message.js'; /** * Plain function (not a React component) so the React Compiler won't @@ -12,7 +12,7 @@ export function checkHasTeamMemOps(message: CollapsedReadSearchGroup): boolean { (message.teamMemorySearchCount ?? 0) > 0 || (message.teamMemoryReadCount ?? 0) > 0 || (message.teamMemoryWriteCount ?? 0) > 0 - ) + ); } /** @@ -25,74 +25,54 @@ export function TeamMemCountParts({ isActiveGroup, hasPrecedingParts, }: { - message: CollapsedReadSearchGroup - isActiveGroup: boolean | undefined - hasPrecedingParts: boolean + message: CollapsedReadSearchGroup; + isActiveGroup: boolean | undefined; + hasPrecedingParts: boolean; }): React.ReactNode { - const tmReadCount = message.teamMemoryReadCount ?? 0 - const tmSearchCount = message.teamMemorySearchCount ?? 0 - const tmWriteCount = message.teamMemoryWriteCount ?? 0 + const tmReadCount = message.teamMemoryReadCount ?? 0; + const tmSearchCount = message.teamMemorySearchCount ?? 0; + const tmWriteCount = message.teamMemoryWriteCount ?? 0; if (tmReadCount === 0 && tmSearchCount === 0 && tmWriteCount === 0) { - return null + return null; } - const nodes: React.ReactNode[] = [] - let count = hasPrecedingParts ? 1 : 0 + const nodes: React.ReactNode[] = []; + let count = hasPrecedingParts ? 1 : 0; if (tmReadCount > 0) { - const verb = isActiveGroup - ? count === 0 - ? 'Recalling' - : 'recalling' - : count === 0 - ? 'Recalled' - : 'recalled' + const verb = isActiveGroup ? (count === 0 ? 'Recalling' : 'recalling') : count === 0 ? 'Recalled' : 'recalled'; if (count > 0) { - nodes.push(, ) + nodes.push(, ); } nodes.push( - {verb} {tmReadCount} team{' '} - {tmReadCount === 1 ? 'memory' : 'memories'} + {verb} {tmReadCount} team {tmReadCount === 1 ? 'memory' : 'memories'} , - ) - count++ + ); + count++; } if (tmSearchCount > 0) { - const verb = isActiveGroup - ? count === 0 - ? 'Searching' - : 'searching' - : count === 0 - ? 'Searched' - : 'searched' + const verb = isActiveGroup ? (count === 0 ? 'Searching' : 'searching') : count === 0 ? 'Searched' : 'searched'; if (count > 0) { - nodes.push(, ) + nodes.push(, ); } - nodes.push({`${verb} team memories`}) - count++ + nodes.push({`${verb} team memories`}); + count++; } if (tmWriteCount > 0) { - const verb = isActiveGroup - ? count === 0 - ? 'Writing' - : 'writing' - : count === 0 - ? 'Wrote' - : 'wrote' + const verb = isActiveGroup ? (count === 0 ? 'Writing' : 'writing') : count === 0 ? 'Wrote' : 'wrote'; if (count > 0) { - nodes.push(, ) + nodes.push(, ); } nodes.push( - {verb} {tmWriteCount} team{' '} - {tmWriteCount === 1 ? 'memory' : 'memories'} + {verb} {tmWriteCount} team {tmWriteCount === 1 ? 'memory' : 'memories'} , - ) + ); } - return <>{nodes} + return <>{nodes}; } diff --git a/src/components/permissions/AskUserQuestionPermissionRequest/AskUserQuestionPermissionRequest.tsx b/src/components/permissions/AskUserQuestionPermissionRequest/AskUserQuestionPermissionRequest.tsx index 3768dbd73..11bdb7f74 100644 --- a/src/components/permissions/AskUserQuestionPermissionRequest/AskUserQuestionPermissionRequest.tsx +++ b/src/components/permissions/AskUserQuestionPermissionRequest/AskUserQuestionPermissionRequest.tsx @@ -1,74 +1,51 @@ -import type { - Base64ImageSource, - ImageBlockParam, -} from '@anthropic-ai/sdk/resources/messages.mjs' -import React, { - Suspense, - use, - useCallback, - useMemo, - useRef, - useState, -} from 'react' -import { useSettings } from '../../../hooks/useSettings.js' -import { useTerminalSize } from '../../../hooks/useTerminalSize.js' -import { stringWidth } from '../../../ink/stringWidth.js' -import { useTheme } from '../../../ink.js' -import { useKeybindings } from '../../../keybindings/useKeybinding.js' +import type { Base64ImageSource, ImageBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'; +import React, { Suspense, use, useCallback, useMemo, useRef, useState } from 'react'; +import { useSettings } from '../../../hooks/useSettings.js'; +import { useTerminalSize } from '../../../hooks/useTerminalSize.js'; +import { stringWidth } from '../../../ink/stringWidth.js'; +import { useTheme } from '../../../ink.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 { useAppState } from '../../../state/AppState.js' -import type { Question } from '../../../tools/AskUserQuestionTool/AskUserQuestionTool.js' -import { AskUserQuestionTool } from '../../../tools/AskUserQuestionTool/AskUserQuestionTool.js' -import { - type CliHighlight, - getCliHighlightPromise, -} from '../../../utils/cliHighlight.js' -import type { PastedContent } from '../../../utils/config.js' -import type { ImageDimensions } from '../../../utils/imageResizer.js' -import { maybeResizeAndDownsampleImageBlock } from '../../../utils/imageResizer.js' -import { cacheImagePath, storeImage } from '../../../utils/imageStore.js' -import { logError } from '../../../utils/log.js' -import { applyMarkdown } from '../../../utils/markdown.js' -import { isPlanModeInterviewPhaseEnabled } from '../../../utils/planModeV2.js' -import { getPlanFilePath } from '../../../utils/plans.js' -import type { PermissionRequestProps } from '../PermissionRequest.js' -import { QuestionView } from './QuestionView.js' -import { SubmitQuestionsView } from './SubmitQuestionsView.js' -import { useMultipleChoiceState } from './use-multiple-choice-state.js' - -const MIN_CONTENT_HEIGHT = 12 -const MIN_CONTENT_WIDTH = 40 +} from '../../../services/analytics/index.js'; +import { useAppState } from '../../../state/AppState.js'; +import type { Question } from '../../../tools/AskUserQuestionTool/AskUserQuestionTool.js'; +import { AskUserQuestionTool } from '../../../tools/AskUserQuestionTool/AskUserQuestionTool.js'; +import { type CliHighlight, getCliHighlightPromise } from '../../../utils/cliHighlight.js'; +import type { PastedContent } from '../../../utils/config.js'; +import type { ImageDimensions } from '../../../utils/imageResizer.js'; +import { maybeResizeAndDownsampleImageBlock } from '../../../utils/imageResizer.js'; +import { cacheImagePath, storeImage } from '../../../utils/imageStore.js'; +import { logError } from '../../../utils/log.js'; +import { applyMarkdown } from '../../../utils/markdown.js'; +import { isPlanModeInterviewPhaseEnabled } from '../../../utils/planModeV2.js'; +import { getPlanFilePath } from '../../../utils/plans.js'; +import type { PermissionRequestProps } from '../PermissionRequest.js'; +import { QuestionView } from './QuestionView.js'; +import { SubmitQuestionsView } from './SubmitQuestionsView.js'; +import { useMultipleChoiceState } from './use-multiple-choice-state.js'; + +const MIN_CONTENT_HEIGHT = 12; +const MIN_CONTENT_WIDTH = 40; // Lines used by chrome around the content area (nav bar, title, footer, help text, etc.) -const CONTENT_CHROME_OVERHEAD = 15 +const CONTENT_CHROME_OVERHEAD = 15; -export function AskUserQuestionPermissionRequest( - props: PermissionRequestProps, -): React.ReactNode { - const settings = useSettings() +export function AskUserQuestionPermissionRequest(props: PermissionRequestProps): React.ReactNode { + const settings = useSettings(); if (settings.syntaxHighlightingDisabled) { - return + return ; } return ( - - } - > + }> - ) + ); } -function AskUserQuestionWithHighlight( - props: PermissionRequestProps, -): React.ReactNode { - const highlight = use(getCliHighlightPromise()) - return ( - - ) +function AskUserQuestionWithHighlight(props: PermissionRequestProps): React.ReactNode { + const highlight = use(getCliHighlightPromise()); + return ; } function AskUserQuestionPermissionRequestBody({ @@ -77,7 +54,7 @@ function AskUserQuestionPermissionRequestBody({ onReject, highlight, }: PermissionRequestProps & { - highlight: CliHighlight | null + highlight: CliHighlight | null; }): React.ReactNode { // Memoize parse result: safeParse returns a new object (and new `questions` // array) on every call. Without this, the render-body ref writes below make @@ -86,103 +63,81 @@ function AskUserQuestionPermissionRequestBody({ // useMemo (which runs applyMarkdown over every preview) never hits its cache. // `toolUseConfirm.input` is stable for the dialog's lifetime (this tool // returns `behavior: 'ask'` directly and never goes through the classifier). - const result = useMemo( - () => AskUserQuestionTool.inputSchema.safeParse(toolUseConfirm.input), - [toolUseConfirm.input], - ) - const questions = result.success ? result.data.questions || [] : [] - const { rows: terminalRows } = useTerminalSize() - const [theme] = useTheme() + const result = useMemo(() => AskUserQuestionTool.inputSchema.safeParse(toolUseConfirm.input), [toolUseConfirm.input]); + const questions = result.success ? result.data.questions || [] : []; + const { rows: terminalRows } = useTerminalSize(); + const [theme] = useTheme(); // Calculate consistent content dimensions across all questions to prevent layout shifts. // globalContentHeight represents the total height of the content area below the nav/title, // INCLUDING footer and help text, so all views (questions, previews, submit) match. const { globalContentHeight, globalContentWidth } = useMemo(() => { - let maxHeight = 0 - let maxWidth = 0 + let maxHeight = 0; + let maxWidth = 0; // Footer (divider + "Chat about this" + optional plan) + help text ≈ 7 lines - const FOOTER_HELP_LINES = 7 + const FOOTER_HELP_LINES = 7; // Cap at terminal height minus chrome overhead, but ensure at least MIN_CONTENT_HEIGHT - const maxAllowedHeight = Math.max( - MIN_CONTENT_HEIGHT, - terminalRows - CONTENT_CHROME_OVERHEAD, - ) + const maxAllowedHeight = Math.max(MIN_CONTENT_HEIGHT, terminalRows - CONTENT_CHROME_OVERHEAD); // PREVIEW_OVERHEAD matches the constant in PreviewQuestionView.tsx — lines // used by non-preview elements within the content area (margins, borders, // notes, footer, help text). Used here to cap preview content so that // globalContentHeight reflects the *truncated* height, not the raw height. - const PREVIEW_OVERHEAD = 11 + const PREVIEW_OVERHEAD = 11; for (const q of questions) { - const hasPreview = q.options.some(opt => opt.preview) + const hasPreview = q.options.some(opt => opt.preview); if (hasPreview) { // Compute the max preview content lines that would actually display // after truncation, matching the logic in PreviewQuestionView. - const maxPreviewContentLines = Math.max( - 1, - maxAllowedHeight - PREVIEW_OVERHEAD, - ) + const maxPreviewContentLines = Math.max(1, maxAllowedHeight - PREVIEW_OVERHEAD); // For preview questions, total = side-by-side height + footer/help // Side-by-side = max(left panel, right panel) // Right panel = preview box (content + borders + truncation indicator) + notes - let maxPreviewBoxHeight = 0 + let maxPreviewBoxHeight = 0; for (const opt of q.options) { if (opt.preview) { // Measure the *rendered* markdown (same transform as PreviewBox) so // that line counts and widths match what will actually be displayed. // applyMarkdown removes code fence markers, bold/italic syntax, etc. - const rendered = applyMarkdown(opt.preview, theme, highlight) - const previewLines = rendered.split('\n') - const isTruncated = previewLines.length > maxPreviewContentLines - const displayedLines = isTruncated - ? maxPreviewContentLines - : previewLines.length + const rendered = applyMarkdown(opt.preview, theme, highlight); + const previewLines = rendered.split('\n'); + const isTruncated = previewLines.length > maxPreviewContentLines; + const displayedLines = isTruncated ? maxPreviewContentLines : previewLines.length; // Preview box: displayed content + truncation indicator + 2 borders - maxPreviewBoxHeight = Math.max( - maxPreviewBoxHeight, - displayedLines + (isTruncated ? 1 : 0) + 2, - ) + maxPreviewBoxHeight = Math.max(maxPreviewBoxHeight, displayedLines + (isTruncated ? 1 : 0) + 2); for (const line of previewLines) { - maxWidth = Math.max(maxWidth, stringWidth(line)) + maxWidth = Math.max(maxWidth, stringWidth(line)); } } } // Right panel: preview box + notes (2 lines with margin) - const rightPanelHeight = maxPreviewBoxHeight + 2 + const rightPanelHeight = maxPreviewBoxHeight + 2; // Left panel: options + description - const leftPanelHeight = q.options.length + 2 - const sideByHeight = Math.max(leftPanelHeight, rightPanelHeight) - maxHeight = Math.max(maxHeight, sideByHeight + FOOTER_HELP_LINES) + const leftPanelHeight = q.options.length + 2; + const sideByHeight = Math.max(leftPanelHeight, rightPanelHeight); + maxHeight = Math.max(maxHeight, sideByHeight + FOOTER_HELP_LINES); } else { // For regular questions: options + "Other" + footer/help - maxHeight = Math.max( - maxHeight, - q.options.length + 3 + FOOTER_HELP_LINES, - ) + maxHeight = Math.max(maxHeight, q.options.length + 3 + FOOTER_HELP_LINES); } } return { - globalContentHeight: Math.min( - Math.max(maxHeight, MIN_CONTENT_HEIGHT), - maxAllowedHeight, - ), + globalContentHeight: Math.min(Math.max(maxHeight, MIN_CONTENT_HEIGHT), maxAllowedHeight), globalContentWidth: Math.max(maxWidth, MIN_CONTENT_WIDTH), - } - }, [questions, terminalRows, theme, highlight]) - const metadataSource = result.success - ? result.data.metadata?.source - : undefined + }; + }, [questions, terminalRows, theme, highlight]); + const metadataSource = result.success ? result.data.metadata?.source : undefined; const [pastedContentsByQuestion, setPastedContentsByQuestion] = useState< Record> - >({}) - const nextPasteIdRef = useRef(0) + >({}); + const nextPasteIdRef = useRef(0); function onImagePaste( questionText: string, @@ -192,7 +147,7 @@ function AskUserQuestionPermissionRequestBody({ dimensions?: ImageDimensions, _sourcePath?: string, ) { - const pasteId = nextPasteIdRef.current++ + const pasteId = nextPasteIdRef.current++; const newContent: PastedContent = { id: pasteId, type: 'image', @@ -200,34 +155,32 @@ function AskUserQuestionPermissionRequestBody({ mediaType: mediaType || 'image/png', filename: filename || 'Pasted image', dimensions, - } - cacheImagePath(newContent) - void storeImage(newContent) + }; + cacheImagePath(newContent); + void storeImage(newContent); setPastedContentsByQuestion(prev => ({ ...prev, [questionText]: { ...(prev[questionText] ?? {}), [pasteId]: newContent }, - })) + })); } const onRemoveImage = useCallback((questionText: string, id: number) => { setPastedContentsByQuestion(prev => { - const questionContents = { ...(prev[questionText] ?? {}) } - delete questionContents[id] - return { ...prev, [questionText]: questionContents } - }) - }, []) + const questionContents = { ...(prev[questionText] ?? {}) }; + delete questionContents[id]; + return { ...prev, [questionText]: questionContents }; + }); + }, []); const allImageAttachments = Object.values(pastedContentsByQuestion) .flatMap(contents => Object.values(contents)) - .filter(c => c.type === 'image') + .filter(c => c.type === 'image'); - const toolPermissionContextMode = useAppState( - s => s.toolPermissionContext.mode, - ) - const isInPlanMode = toolPermissionContextMode === 'plan' - const planFilePath = isInPlanMode ? getPlanFilePath() : undefined + const toolPermissionContextMode = useAppState(s => s.toolPermissionContext.mode); + const isInPlanMode = toolPermissionContextMode === 'plan'; + const planFilePath = isInPlanMode ? getPlanFilePath() : undefined; - const state = useMultipleChoiceState() + const state = useMultipleChoiceState(); const { currentQuestionIndex, answers, @@ -238,165 +191,120 @@ function AskUserQuestionPermissionRequestBody({ updateQuestionState, setAnswer, setTextInputMode, - } = state + } = state; - const currentQuestion = - currentQuestionIndex < (questions?.length || 0) - ? questions?.[currentQuestionIndex] - : null + const currentQuestion = currentQuestionIndex < (questions?.length || 0) ? questions?.[currentQuestionIndex] : null; - const isInSubmitView = currentQuestionIndex === (questions?.length || 0) - const allQuestionsAnswered = - questions?.every((q: Question) => q?.question && !!answers[q.question]) ?? - false + const isInSubmitView = currentQuestionIndex === (questions?.length || 0); + const allQuestionsAnswered = questions?.every((q: Question) => q?.question && !!answers[q.question]) ?? false; // Hide submit tab when there's only one question and it's single-select (auto-submit scenario) - const hideSubmitTab = questions.length === 1 && !questions[0]?.multiSelect + const hideSubmitTab = questions.length === 1 && !questions[0]?.multiSelect; const handleCancel = useCallback(() => { // Log rejection with metadata source if present if (metadataSource) { logEvent('tengu_ask_user_question_rejected', { - source: - metadataSource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + source: metadataSource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, questionCount: questions.length, isInPlanMode, - interviewPhaseEnabled: - isInPlanMode && isPlanModeInterviewPhaseEnabled(), - }) + interviewPhaseEnabled: isInPlanMode && isPlanModeInterviewPhaseEnabled(), + }); } - onDone() - onReject() - toolUseConfirm.onReject() - }, [ - onDone, - onReject, - toolUseConfirm, - metadataSource, - questions.length, - isInPlanMode, - ]) + onDone(); + onReject(); + toolUseConfirm.onReject(); + }, [onDone, onReject, toolUseConfirm, metadataSource, questions.length, isInPlanMode]); const handleRespondToClaude = useCallback(async () => { const questionsWithAnswers = questions .map((q: Question) => { - const answer = answers[q.question] + const answer = answers[q.question]; if (answer) { - return `- "${q.question}"\n Answer: ${answer}` + return `- "${q.question}"\n Answer: ${answer}`; } - return `- "${q.question}"\n (No answer provided)` + return `- "${q.question}"\n (No answer provided)`; }) - .join('\n') + .join('\n'); const feedback = `The user wants to clarify these questions. This means they may have additional information, context or questions for you. Take their response into account and then reformulate the questions if appropriate. Start by asking them what they would like to clarify. - Questions asked:\n${questionsWithAnswers}` + Questions asked:\n${questionsWithAnswers}`; if (metadataSource) { logEvent('tengu_ask_user_question_respond_to_claude', { - source: - metadataSource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + source: metadataSource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, questionCount: questions.length, isInPlanMode, - interviewPhaseEnabled: - isInPlanMode && isPlanModeInterviewPhaseEnabled(), - }) + interviewPhaseEnabled: isInPlanMode && isPlanModeInterviewPhaseEnabled(), + }); } - const imageBlocks = await convertImagesToBlocks(allImageAttachments) + const imageBlocks = await convertImagesToBlocks(allImageAttachments); - onDone() - toolUseConfirm.onReject( - feedback, - imageBlocks && imageBlocks.length > 0 ? imageBlocks : undefined, - ) - }, [ - questions, - answers, - onDone, - toolUseConfirm, - metadataSource, - isInPlanMode, - allImageAttachments, - ]) + onDone(); + toolUseConfirm.onReject(feedback, imageBlocks && imageBlocks.length > 0 ? imageBlocks : undefined); + }, [questions, answers, onDone, toolUseConfirm, metadataSource, isInPlanMode, allImageAttachments]); const handleFinishPlanInterview = useCallback(async () => { const questionsWithAnswers = questions .map((q: Question) => { - const answer = answers[q.question] + const answer = answers[q.question]; if (answer) { - return `- "${q.question}"\n Answer: ${answer}` + return `- "${q.question}"\n Answer: ${answer}`; } - return `- "${q.question}"\n (No answer provided)` + return `- "${q.question}"\n (No answer provided)`; }) - .join('\n') + .join('\n'); const feedback = `The user has indicated they have provided enough answers for the plan interview. Stop asking clarifying questions and proceed to finish the plan with the information you have. -Questions asked and answers provided:\n${questionsWithAnswers}` +Questions asked and answers provided:\n${questionsWithAnswers}`; if (metadataSource) { logEvent('tengu_ask_user_question_finish_plan_interview', { - source: - metadataSource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + source: metadataSource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, questionCount: questions.length, isInPlanMode, - interviewPhaseEnabled: - isInPlanMode && isPlanModeInterviewPhaseEnabled(), - }) + interviewPhaseEnabled: isInPlanMode && isPlanModeInterviewPhaseEnabled(), + }); } - const imageBlocks = await convertImagesToBlocks(allImageAttachments) + const imageBlocks = await convertImagesToBlocks(allImageAttachments); - onDone() - toolUseConfirm.onReject( - feedback, - imageBlocks && imageBlocks.length > 0 ? imageBlocks : undefined, - ) - }, [ - questions, - answers, - onDone, - toolUseConfirm, - metadataSource, - isInPlanMode, - allImageAttachments, - ]) + onDone(); + toolUseConfirm.onReject(feedback, imageBlocks && imageBlocks.length > 0 ? imageBlocks : undefined); + }, [questions, answers, onDone, toolUseConfirm, metadataSource, isInPlanMode, allImageAttachments]); const submitAnswers = useCallback( async (answersToSubmit: Record) => { // Log acceptance with metadata source if present if (metadataSource) { logEvent('tengu_ask_user_question_accepted', { - source: - metadataSource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + source: metadataSource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, questionCount: questions.length, answerCount: Object.keys(answersToSubmit).length, isInPlanMode, - interviewPhaseEnabled: - isInPlanMode && isPlanModeInterviewPhaseEnabled(), - }) + interviewPhaseEnabled: isInPlanMode && isPlanModeInterviewPhaseEnabled(), + }); } // Build annotations from questionStates (e.g., selected preview, user notes) - const annotations: Record = - {} + const annotations: Record = {}; for (const q of questions) { - const answer = answersToSubmit[q.question] - const notes = questionStates[q.question]?.textInputValue + const answer = answersToSubmit[q.question]; + const notes = questionStates[q.question]?.textInputValue; // Find the selected option's preview content - const selectedOption = answer - ? q.options.find(opt => opt.label === answer) - : undefined - const preview = selectedOption?.preview + const selectedOption = answer ? q.options.find(opt => opt.label === answer) : undefined; + const preview = selectedOption?.preview; if (preview || notes?.trim()) { annotations[q.question] = { ...(preview && { preview }), ...(notes?.trim() && { notes: notes.trim() }), - } + }; } } @@ -404,110 +312,86 @@ Questions asked and answers provided:\n${questionsWithAnswers}` ...toolUseConfirm.input, answers: answersToSubmit, ...(Object.keys(annotations).length > 0 && { annotations }), - } + }; - const contentBlocks = await convertImagesToBlocks(allImageAttachments) + const contentBlocks = await convertImagesToBlocks(allImageAttachments); - onDone() + onDone(); toolUseConfirm.onAllow( updatedInput, [], undefined, contentBlocks && contentBlocks.length > 0 ? contentBlocks : undefined, - ) + ); }, - [ - toolUseConfirm, - onDone, - metadataSource, - questions, - questionStates, - isInPlanMode, - allImageAttachments, - ], - ) + [toolUseConfirm, onDone, metadataSource, questions, questionStates, isInPlanMode, allImageAttachments], + ); const handleQuestionAnswer = useCallback( - ( - questionText: string, - label: string | string[], - textInput?: string, - shouldAdvance: boolean = true, - ) => { - let answer: string - const isMultiSelect = Array.isArray(label) + (questionText: string, label: string | string[], textInput?: string, shouldAdvance: boolean = true) => { + let answer: string; + const isMultiSelect = Array.isArray(label); if (isMultiSelect) { - answer = label.join(', ') + answer = label.join(', '); } else { if (textInput) { - const questionImages = Object.values( - pastedContentsByQuestion[questionText] ?? {}, - ).filter(c => c.type === 'image') - answer = - questionImages.length > 0 - ? `${textInput} (Image attached)` - : textInput + const questionImages = Object.values(pastedContentsByQuestion[questionText] ?? {}).filter( + c => c.type === 'image', + ); + answer = questionImages.length > 0 ? `${textInput} (Image attached)` : textInput; } else if (label === '__other__') { // Image-only submission — check if this question has images - const questionImages = Object.values( - pastedContentsByQuestion[questionText] ?? {}, - ).filter(c => c.type === 'image') - answer = questionImages.length > 0 ? '(Image attached)' : label + const questionImages = Object.values(pastedContentsByQuestion[questionText] ?? {}).filter( + c => c.type === 'image', + ); + answer = questionImages.length > 0 ? '(Image attached)' : label; } else { - answer = label + answer = label; } } // For single-select with only one question, auto-submit instead of showing review screen - const isSingleQuestion = questions.length === 1 + const isSingleQuestion = questions.length === 1; if (!isMultiSelect && isSingleQuestion && shouldAdvance) { const updatedAnswers = { ...answers, [questionText]: answer, - } - void submitAnswers(updatedAnswers).catch(logError) - return + }; + void submitAnswers(updatedAnswers).catch(logError); + return; } - setAnswer(questionText, answer, shouldAdvance) + setAnswer(questionText, answer, shouldAdvance); }, - [ - setAnswer, - questions.length, - answers, - submitAnswers, - pastedContentsByQuestion, - ], - ) + [setAnswer, questions.length, answers, submitAnswers, pastedContentsByQuestion], + ); function handleFinalResponse(value: 'submit' | 'cancel'): void { if (value === 'cancel') { - handleCancel() - return + handleCancel(); + return; } if (value === 'submit') { - void submitAnswers(answers).catch(logError) + void submitAnswers(answers).catch(logError); } } // When submit tab is hidden, don't allow navigating past the last question - const maxIndex = hideSubmitTab - ? (questions?.length || 1) - 1 - : questions?.length || 0 + const maxIndex = hideSubmitTab ? (questions?.length || 1) - 1 : questions?.length || 0; // Bounded navigation callbacks for question tabs const handleTabPrev = useCallback(() => { if (currentQuestionIndex > 0) { - prevQuestion() + prevQuestion(); } - }, [currentQuestionIndex, prevQuestion]) + }, [currentQuestionIndex, prevQuestion]); const handleTabNext = useCallback(() => { if (currentQuestionIndex < maxIndex) { - nextQuestion() + nextQuestion(); } - }, [currentQuestionIndex, maxIndex, nextQuestion]) + }, [currentQuestionIndex, maxIndex, nextQuestion]); // Use keybindings system for question navigation (left/right arrows, tab/shift+tab) // Raw useInput doesn't work because the keybinding system resolves left/right arrows @@ -520,7 +404,7 @@ Questions asked and answers provided:\n${questionsWithAnswers}` 'tabs:next': handleTabNext, }, { context: 'Tabs', isActive: !(isInTextInput && !isInSubmitView) }, - ) + ); if (currentQuestion) { return ( @@ -545,22 +429,13 @@ Questions asked and answers provided:\n${questionsWithAnswers}` onRespondToClaude={handleRespondToClaude} onFinishPlanInterview={handleFinishPlanInterview} onImagePaste={(base64, mediaType, filename, dims, path) => - onImagePaste( - currentQuestion.question, - base64, - mediaType, - filename, - dims, - path, - ) - } - pastedContents={ - pastedContentsByQuestion[currentQuestion.question] ?? {} + onImagePaste(currentQuestion.question, base64, mediaType, filename, dims, path) } + pastedContents={pastedContentsByQuestion[currentQuestion.question] ?? {}} onRemoveImage={id => onRemoveImage(currentQuestion.question, id)} /> - ) + ); } if (isInSubmitView) { @@ -576,30 +451,27 @@ Questions asked and answers provided:\n${questionsWithAnswers}` onFinalResponse={handleFinalResponse} /> - ) + ); } // This should never be reached - return null + return null; } -async function convertImagesToBlocks( - images: PastedContent[], -): Promise { - if (images.length === 0) return undefined +async function convertImagesToBlocks(images: PastedContent[]): Promise { + if (images.length === 0) return undefined; return Promise.all( images.map(async img => { const block: ImageBlockParam = { type: 'image', source: { type: 'base64', - media_type: (img.mediaType || - 'image/png') as Base64ImageSource['media_type'], + media_type: (img.mediaType || 'image/png') as Base64ImageSource['media_type'], data: img.content, }, - } - const resized = await maybeResizeAndDownsampleImageBlock(block) - return resized.block + }; + const resized = await maybeResizeAndDownsampleImageBlock(block); + return resized.block; }), - ) + ); } diff --git a/src/components/permissions/AskUserQuestionPermissionRequest/PreviewBox.tsx b/src/components/permissions/AskUserQuestionPermissionRequest/PreviewBox.tsx index 7b4fd6149..5a36f34da 100644 --- a/src/components/permissions/AskUserQuestionPermissionRequest/PreviewBox.tsx +++ b/src/components/permissions/AskUserQuestionPermissionRequest/PreviewBox.tsx @@ -1,28 +1,25 @@ -import React, { Suspense, use, useMemo } from 'react' -import { useSettings } from '../../../hooks/useSettings.js' -import { useTerminalSize } from '../../../hooks/useTerminalSize.js' -import { stringWidth } from '../../../ink/stringWidth.js' -import { Ansi, Box, Text, useTheme } from '../../../ink.js' -import { - type CliHighlight, - getCliHighlightPromise, -} from '../../../utils/cliHighlight.js' -import { applyMarkdown } from '../../../utils/markdown.js' -import sliceAnsi from '../../../utils/sliceAnsi.js' +import React, { Suspense, use, useMemo } from 'react'; +import { useSettings } from '../../../hooks/useSettings.js'; +import { useTerminalSize } from '../../../hooks/useTerminalSize.js'; +import { stringWidth } from '../../../ink/stringWidth.js'; +import { Ansi, Box, Text, useTheme } from '../../../ink.js'; +import { type CliHighlight, getCliHighlightPromise } from '../../../utils/cliHighlight.js'; +import { applyMarkdown } from '../../../utils/markdown.js'; +import sliceAnsi from '../../../utils/sliceAnsi.js'; type PreviewBoxProps = { /** The preview content to display. Markdown is rendered with syntax highlighting * for code blocks (```ts, ```py, etc.). Also supports plain multi-line text. */ - content: string + content: string; /** Maximum number of lines to display before truncating. @default 20 */ - maxLines?: number + maxLines?: number; /** Minimum height (in lines) for the preview box. Content will be padded if shorter. */ - minHeight?: number + minHeight?: number; /** Minimum width for the preview box. @default 40 */ - minWidth?: number + minWidth?: number; /** Maximum width available for this box (e.g., the container width). */ - maxWidth?: number -} + maxWidth?: number; +}; const BOX_CHARS = { topLeft: '┌', @@ -33,7 +30,7 @@ const BOX_CHARS = { vertical: '│', teeLeft: '├', teeRight: '┤', -} +}; /** * A bordered monospace box for displaying preview content. @@ -41,20 +38,20 @@ const BOX_CHARS = { * The parent component should pass maxLines based on its available height budget. */ export function PreviewBox(props: PreviewBoxProps): React.ReactNode { - const settings = useSettings() + const settings = useSettings(); if (settings.syntaxHighlightingDisabled) { - return + return ; } return ( }> - ) + ); } function PreviewBoxWithHighlight(props: PreviewBoxProps): React.ReactNode { - const highlight = use(getCliHighlightPromise()) - return + const highlight = use(getCliHighlightPromise()); + return ; } function PreviewBoxBody({ @@ -65,65 +62,51 @@ function PreviewBoxBody({ maxWidth, highlight, }: PreviewBoxProps & { highlight: CliHighlight | null }): React.ReactNode { - const { columns: terminalWidth } = useTerminalSize() - const [theme] = useTheme() - const effectiveMaxWidth = maxWidth ?? terminalWidth - 4 + const { columns: terminalWidth } = useTerminalSize(); + const [theme] = useTheme(); + const effectiveMaxWidth = maxWidth ?? terminalWidth - 4; // Use provided maxLines, or a reasonable default - const effectiveMaxLines = maxLines ?? 20 + const effectiveMaxLines = maxLines ?? 20; // Render markdown with syntax highlighting for code blocks. applyMarkdown // returns an ANSI-styled string (bold, colors, etc.) that we split into // lines. stringWidth and sliceAnsi below correctly handle ANSI codes. - const rendered = useMemo( - () => applyMarkdown(content, theme, highlight), - [content, theme, highlight], - ) - const contentLines = rendered.split('\n') - const isTruncated = contentLines.length > effectiveMaxLines + const rendered = useMemo(() => applyMarkdown(content, theme, highlight), [content, theme, highlight]); + const contentLines = rendered.split('\n'); + const isTruncated = contentLines.length > effectiveMaxLines; // Truncate to effectiveMaxLines - const truncatedLines = isTruncated - ? contentLines.slice(0, effectiveMaxLines) - : contentLines + const truncatedLines = isTruncated ? contentLines.slice(0, effectiveMaxLines) : contentLines; // Pad content with empty lines if shorter than minHeight, but never exceed // the truncation limit — otherwise padding undoes the truncation - const effectiveMinHeight = Math.min(minHeight ?? 0, effectiveMaxLines) - const paddingNeeded = Math.max( - 0, - effectiveMinHeight - truncatedLines.length - (isTruncated ? 1 : 0), - ) - const lines = - paddingNeeded > 0 - ? [...truncatedLines, ...Array(paddingNeeded).fill('')] - : truncatedLines + const effectiveMinHeight = Math.min(minHeight ?? 0, effectiveMaxLines); + const paddingNeeded = Math.max(0, effectiveMinHeight - truncatedLines.length - (isTruncated ? 1 : 0)); + const lines = paddingNeeded > 0 ? [...truncatedLines, ...Array(paddingNeeded).fill('')] : truncatedLines; // Calculate content width (max visual line width, handling unicode/emoji/CJK) - const contentWidth = Math.max( - minWidth, - ...lines.map(line => stringWidth(line)), - ) + const contentWidth = Math.max(minWidth, ...lines.map(line => stringWidth(line))); // Add 2 for border padding, cap at the container width to prevent line wrapping - const boxWidth = Math.min(contentWidth + 4, effectiveMaxWidth) - const innerWidth = boxWidth - 4 // Account for borders and padding + const boxWidth = Math.min(contentWidth + 4, effectiveMaxWidth); + const innerWidth = boxWidth - 4; // Account for borders and padding // Render top border - const topBorder = `${BOX_CHARS.topLeft}${BOX_CHARS.horizontal.repeat(boxWidth - 2)}${BOX_CHARS.topRight}` + const topBorder = `${BOX_CHARS.topLeft}${BOX_CHARS.horizontal.repeat(boxWidth - 2)}${BOX_CHARS.topRight}`; // Render bottom border - const bottomBorder = `${BOX_CHARS.bottomLeft}${BOX_CHARS.horizontal.repeat(boxWidth - 2)}${BOX_CHARS.bottomRight}` + const bottomBorder = `${BOX_CHARS.bottomLeft}${BOX_CHARS.horizontal.repeat(boxWidth - 2)}${BOX_CHARS.bottomRight}`; // Build the truncation separator bar (e.g. ├─── ✂ ─── 42 lines hidden ──────┤) const truncationBar = isTruncated ? (() => { - const hiddenCount = contentLines.length - effectiveMaxLines - const label = `${BOX_CHARS.horizontal.repeat(3)} \u2702 ${BOX_CHARS.horizontal.repeat(3)} ${hiddenCount} lines hidden ` - const labelWidth = stringWidth(label) - const fillWidth = Math.max(0, boxWidth - 2 - labelWidth) - return `${BOX_CHARS.teeLeft}${label}${BOX_CHARS.horizontal.repeat(fillWidth)}${BOX_CHARS.teeRight}` + const hiddenCount = contentLines.length - effectiveMaxLines; + const label = `${BOX_CHARS.horizontal.repeat(3)} \u2702 ${BOX_CHARS.horizontal.repeat(3)} ${hiddenCount} lines hidden `; + const labelWidth = stringWidth(label); + const fillWidth = Math.max(0, boxWidth - 2 - labelWidth); + return `${BOX_CHARS.teeLeft}${label}${BOX_CHARS.horizontal.repeat(fillWidth)}${BOX_CHARS.teeRight}`; })() - : null + : null; return ( @@ -132,12 +115,9 @@ function PreviewBoxBody({ {lines.map((line, index) => { // Pad or truncate line to fit inner width (using visual width for unicode/emoji/CJK). // sliceAnsi handles ANSI escape codes correctly; stringWidth strips them before measuring. - const lineWidth = stringWidth(line) - const displayLine = - lineWidth > innerWidth ? sliceAnsi(line, 0, innerWidth) : line - const padding = ' '.repeat( - Math.max(0, innerWidth - stringWidth(displayLine)), - ) + const lineWidth = stringWidth(line); + const displayLine = lineWidth > innerWidth ? sliceAnsi(line, 0, innerWidth) : line; + const padding = ' '.repeat(Math.max(0, innerWidth - stringWidth(displayLine))); return ( @@ -147,12 +127,12 @@ function PreviewBoxBody({ {padding} {BOX_CHARS.vertical} - ) + ); })} {truncationBar && {truncationBar}} {bottomBorder} - ) + ); } diff --git a/src/components/permissions/AskUserQuestionPermissionRequest/PreviewQuestionView.tsx b/src/components/permissions/AskUserQuestionPermissionRequest/PreviewQuestionView.tsx index 78289da5f..102d62a53 100644 --- a/src/components/permissions/AskUserQuestionPermissionRequest/PreviewQuestionView.tsx +++ b/src/components/permissions/AskUserQuestionPermissionRequest/PreviewQuestionView.tsx @@ -1,51 +1,39 @@ -import figures from 'figures' -import React, { useCallback, useMemo, useRef, useState } from 'react' -import { useTerminalSize } from '../../../hooks/useTerminalSize.js' -import type { KeyboardEvent } from '../../../ink/events/keyboard-event.js' -import { Box, Text } from '../../../ink.js' -import { - useKeybinding, - useKeybindings, -} from '../../../keybindings/useKeybinding.js' -import { useAppState } from '../../../state/AppState.js' -import type { Question } from '../../../tools/AskUserQuestionTool/AskUserQuestionTool.js' -import { getExternalEditor } from '../../../utils/editor.js' -import { toIDEDisplayName } from '../../../utils/ide.js' -import { editPromptInEditor } from '../../../utils/promptEditor.js' -import { Divider } from '../../design-system/Divider.js' -import TextInput from '../../TextInput.js' -import { PermissionRequestTitle } from '../PermissionRequestTitle.js' -import { PreviewBox } from './PreviewBox.js' -import { QuestionNavigationBar } from './QuestionNavigationBar.js' -import type { QuestionState } from './use-multiple-choice-state.js' +import figures from 'figures'; +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { useTerminalSize } from '../../../hooks/useTerminalSize.js'; +import type { KeyboardEvent } from '../../../ink/events/keyboard-event.js'; +import { Box, Text } from '../../../ink.js'; +import { useKeybinding, useKeybindings } from '../../../keybindings/useKeybinding.js'; +import { useAppState } from '../../../state/AppState.js'; +import type { Question } from '../../../tools/AskUserQuestionTool/AskUserQuestionTool.js'; +import { getExternalEditor } from '../../../utils/editor.js'; +import { toIDEDisplayName } from '../../../utils/ide.js'; +import { editPromptInEditor } from '../../../utils/promptEditor.js'; +import { Divider } from '../../design-system/Divider.js'; +import TextInput from '../../TextInput.js'; +import { PermissionRequestTitle } from '../PermissionRequestTitle.js'; +import { PreviewBox } from './PreviewBox.js'; +import { QuestionNavigationBar } from './QuestionNavigationBar.js'; +import type { QuestionState } from './use-multiple-choice-state.js'; type Props = { - question: Question - questions: Question[] - currentQuestionIndex: number - answers: Record - questionStates: Record - hideSubmitTab?: boolean - minContentHeight?: number - minContentWidth?: number - onUpdateQuestionState: ( - questionText: string, - updates: Partial, - isMultiSelect: boolean, - ) => void - onAnswer: ( - questionText: string, - label: string | string[], - textInput?: string, - shouldAdvance?: boolean, - ) => void - onTextInputFocus: (isInInput: boolean) => void - onCancel: () => void - onTabPrev?: () => void - onTabNext?: () => void - onRespondToClaude: () => void - onFinishPlanInterview: () => void -} + question: Question; + questions: Question[]; + currentQuestionIndex: number; + answers: Record; + questionStates: Record; + hideSubmitTab?: boolean; + minContentHeight?: number; + minContentWidth?: number; + onUpdateQuestionState: (questionText: string, updates: Partial, isMultiSelect: boolean) => void; + onAnswer: (questionText: string, label: string | string[], textInput?: string, shouldAdvance?: boolean) => void; + onTextInputFocus: (isInInput: boolean) => void; + onCancel: () => void; + onTabPrev?: () => void; + onTabNext?: () => void; + onRespondToClaude: () => void; + onFinishPlanInterview: () => void; +}; /** * A side-by-side question view for questions with preview content. @@ -69,93 +57,82 @@ export function PreviewQuestionView({ onRespondToClaude, onFinishPlanInterview, }: Props): React.ReactNode { - const isInPlanMode = useAppState(s => s.toolPermissionContext.mode) === 'plan' - const [isFooterFocused, setIsFooterFocused] = useState(false) - const [footerIndex, setFooterIndex] = useState(0) - const [isInNotesInput, setIsInNotesInput] = useState(false) - const [cursorOffset, setCursorOffset] = useState(0) + const isInPlanMode = useAppState(s => s.toolPermissionContext.mode) === 'plan'; + const [isFooterFocused, setIsFooterFocused] = useState(false); + const [footerIndex, setFooterIndex] = useState(0); + const [isInNotesInput, setIsInNotesInput] = useState(false); + const [cursorOffset, setCursorOffset] = useState(0); - const editor = getExternalEditor() - const editorName = editor ? toIDEDisplayName(editor) : null + const editor = getExternalEditor(); + const editorName = editor ? toIDEDisplayName(editor) : null; - const questionText = question.question - const questionState = questionStates[questionText] + const questionText = question.question; + const questionState = questionStates[questionText]; // Only real options — no "Other" for preview questions - const allOptions = question.options + const allOptions = question.options; // Track which option is focused (for preview display) - const [focusedIndex, setFocusedIndex] = useState(0) + const [focusedIndex, setFocusedIndex] = useState(0); // Reset focusedIndex when navigating to a different question - const prevQuestionText = useRef(questionText) + const prevQuestionText = useRef(questionText); if (prevQuestionText.current !== questionText) { - prevQuestionText.current = questionText - const selected = questionState?.selectedValue as string | undefined - const idx = selected - ? allOptions.findIndex(opt => opt.label === selected) - : -1 - setFocusedIndex(idx >= 0 ? idx : 0) + prevQuestionText.current = questionText; + const selected = questionState?.selectedValue as string | undefined; + const idx = selected ? allOptions.findIndex(opt => opt.label === selected) : -1; + setFocusedIndex(idx >= 0 ? idx : 0); } - const focusedOption = allOptions[focusedIndex] - const selectedValue = questionState?.selectedValue as string | undefined - const notesValue = questionState?.textInputValue || '' + const focusedOption = allOptions[focusedIndex]; + const selectedValue = questionState?.selectedValue as string | undefined; + const notesValue = questionState?.textInputValue || ''; const handleSelectOption = useCallback( (index: number) => { - const option = allOptions[index] - if (!option) return + const option = allOptions[index]; + if (!option) return; - setFocusedIndex(index) - onUpdateQuestionState( - questionText, - { selectedValue: option.label }, - false, - ) + setFocusedIndex(index); + onUpdateQuestionState(questionText, { selectedValue: option.label }, false); - onAnswer(questionText, option.label) + onAnswer(questionText, option.label); }, [allOptions, questionText, onUpdateQuestionState, onAnswer], - ) + ); const handleNavigate = useCallback( (direction: 'up' | 'down' | number) => { - if (isInNotesInput) return + if (isInNotesInput) return; - let newIndex: number + let newIndex: number; if (typeof direction === 'number') { - newIndex = direction + newIndex = direction; } else if (direction === 'up') { - newIndex = focusedIndex > 0 ? focusedIndex - 1 : focusedIndex + newIndex = focusedIndex > 0 ? focusedIndex - 1 : focusedIndex; } else { - newIndex = - focusedIndex < allOptions.length - 1 ? focusedIndex + 1 : focusedIndex + newIndex = focusedIndex < allOptions.length - 1 ? focusedIndex + 1 : focusedIndex; } if (newIndex >= 0 && newIndex < allOptions.length) { - setFocusedIndex(newIndex) + setFocusedIndex(newIndex); } }, [focusedIndex, allOptions.length, isInNotesInput], - ) + ); // Handle ctrl+g to open external editor for notes useKeybinding( 'chat:externalEditor', async () => { - const currentValue = questionState?.textInputValue || '' - const result = await editPromptInEditor(currentValue) + const currentValue = questionState?.textInputValue || ''; + const result = await editPromptInEditor(currentValue); if (result.content !== null && result.content !== currentValue) { - onUpdateQuestionState( - questionText, - { textInputValue: result.content }, - false, - ) + onUpdateQuestionState(questionText, { textInputValue: result.content }, false); } }, { context: 'Chat', isActive: isInNotesInput && !!editor }, - ) + ); // Handle left/right arrow and tab for question navigation. // This must be in the child component (not just the parent) because child useInput @@ -168,25 +145,25 @@ export function PreviewQuestionView({ 'tabs:next': () => onTabNext?.(), }, { context: 'Tabs', isActive: !isInNotesInput && !isFooterFocused }, - ) + ); // Re-submit the answer (plain label) when exiting notes input. // Notes are stored in questionStates and collected at submit time via annotations. const handleNotesExit = useCallback(() => { - setIsInNotesInput(false) - onTextInputFocus(false) + setIsInNotesInput(false); + onTextInputFocus(false); if (selectedValue) { - onAnswer(questionText, selectedValue) + onAnswer(questionText, selectedValue); } - }, [selectedValue, questionText, onAnswer, onTextInputFocus]) + }, [selectedValue, questionText, onAnswer, onTextInputFocus]); const handleDownFromPreview = useCallback(() => { - setIsFooterFocused(true) - }, []) + setIsFooterFocused(true); + }, []); const handleUpFromFooter = useCallback(() => { - setIsFooterFocused(false) - }, []) + setIsFooterFocused(false); + }, []); // Handle keyboard input for option/footer/notes navigation. // Always active — the handler routes internally based on isFooterFocused/isInNotesInput. @@ -194,79 +171,79 @@ export function PreviewQuestionView({ (e: KeyboardEvent) => { if (isFooterFocused) { if (e.key === 'up' || (e.ctrl && e.key === 'p')) { - e.preventDefault() + e.preventDefault(); if (footerIndex === 0) { - handleUpFromFooter() + handleUpFromFooter(); } else { - setFooterIndex(0) + setFooterIndex(0); } - return + return; } if (e.key === 'down' || (e.ctrl && e.key === 'n')) { - e.preventDefault() + e.preventDefault(); if (isInPlanMode && footerIndex === 0) { - setFooterIndex(1) + setFooterIndex(1); } - return + return; } if (e.key === 'return') { - e.preventDefault() + e.preventDefault(); if (footerIndex === 0) { - onRespondToClaude() + onRespondToClaude(); } else { - onFinishPlanInterview() + onFinishPlanInterview(); } - return + return; } if (e.key === 'escape') { - e.preventDefault() - onCancel() + e.preventDefault(); + onCancel(); } - return + return; } if (isInNotesInput) { // In notes input mode, handle escape to exit back to option navigation if (e.key === 'escape') { - e.preventDefault() - handleNotesExit() + e.preventDefault(); + handleNotesExit(); } - return + return; } // Handle option navigation (vertical) if (e.key === 'up' || (e.ctrl && e.key === 'p')) { - e.preventDefault() + e.preventDefault(); if (focusedIndex > 0) { - handleNavigate('up') + handleNavigate('up'); } } else if (e.key === 'down' || (e.ctrl && e.key === 'n')) { - e.preventDefault() + e.preventDefault(); if (focusedIndex === allOptions.length - 1) { // At bottom of options, go to footer - handleDownFromPreview() + handleDownFromPreview(); } else { - handleNavigate('down') + handleNavigate('down'); } } else if (e.key === 'return') { - e.preventDefault() - handleSelectOption(focusedIndex) + e.preventDefault(); + handleSelectOption(focusedIndex); } else if (e.key === 'n' && !e.ctrl && !e.meta) { // Press 'n' to focus the notes input - e.preventDefault() - setIsInNotesInput(true) - onTextInputFocus(true) + e.preventDefault(); + setIsInNotesInput(true); + onTextInputFocus(true); } else if (e.key === 'escape') { - e.preventDefault() - onCancel() + e.preventDefault(); + onCancel(); } else if (e.key.length === 1 && e.key >= '1' && e.key <= '9') { - e.preventDefault() - const idx = parseInt(e.key, 10) - 1 + e.preventDefault(); + const idx = parseInt(e.key, 10) - 1; if (idx < allOptions.length) { - handleNavigate(idx) + handleNavigate(idx); } } }, @@ -287,15 +264,15 @@ export function PreviewQuestionView({ onCancel, onTextInputFocus, ], - ) + ); - const previewContent = focusedOption?.preview || null + const previewContent = focusedOption?.preview || null; // The right panel's available width is terminal minus the left panel and gap. - const LEFT_PANEL_WIDTH = 30 - const GAP = 4 - const { columns } = useTerminalSize() - const previewMaxWidth = columns - LEFT_PANEL_WIDTH - GAP + const LEFT_PANEL_WIDTH = 30; + const GAP = 4; + const { columns } = useTerminalSize(); + const previewMaxWidth = columns - LEFT_PANEL_WIDTH - GAP; // Lines used within the content area that aren't preview content: // 1: marginTop on side-by-side box @@ -305,26 +282,18 @@ export function PreviewQuestionView({ // 1: "Chat about this" line // 1: plan mode line (may or may not show) // 2: help text (marginTop=1 + text) - const PREVIEW_OVERHEAD = 11 + const PREVIEW_OVERHEAD = 11; // Compute the max lines available for preview content from the parent's // height budget to prevent terminal overflow. We do NOT pad shorter options // to match the tallest — the outer box's minHeight handles cross-question // layout consistency, and within-question shifts are acceptable. const previewMaxLines = useMemo(() => { - return minContentHeight - ? Math.max(1, minContentHeight - PREVIEW_OVERHEAD) - : undefined - }, [minContentHeight]) + return minContentHeight ? Math.max(1, minContentHeight - PREVIEW_OVERHEAD) : undefined; + }, [minContentHeight]); return ( - + {allOptions.map((option, index) => { - const isFocused = focusedIndex === index - const isSelected = selectedValue === option.label + const isFocused = focusedIndex === index; + const isSelected = selectedValue === option.label; return ( - {isFocused ? ( - {figures.pointer} - ) : ( - - )} + {isFocused ? {figures.pointer} : } {index + 1}. - + {' '} {option.label} {isSelected && {figures.tick}} - ) + ); })} @@ -386,11 +342,7 @@ export function PreviewQuestionView({ value={notesValue} placeholder="Add notes on this design…" onChange={value => { - onUpdateQuestionState( - questionText, - { textInputValue: value }, - false, - ) + onUpdateQuestionState(questionText, { textInputValue: value }, false); }} onSubmit={handleNotesExit} onExit={handleNotesExit} @@ -418,15 +370,7 @@ export function PreviewQuestionView({ ) : ( )} - - Chat about this - + Chat about this {isInPlanMode && ( @@ -435,13 +379,7 @@ export function PreviewQuestionView({ ) : ( )} - + Skip interview and plan immediately @@ -449,17 +387,13 @@ export function PreviewQuestionView({ - Enter to select · {figures.arrowUp}/{figures.arrowDown} to - navigate · n to add notes + Enter to select · {figures.arrowUp}/{figures.arrowDown} to navigate · n to add notes {questions.length > 1 && <> · Tab to switch questions} - {isInNotesInput && editorName && ( - <> · ctrl+g to edit in {editorName} - )}{' '} - · Esc to cancel + {isInNotesInput && editorName && <> · ctrl+g to edit in {editorName}} · Esc to cancel - ) + ); } diff --git a/src/components/permissions/AskUserQuestionPermissionRequest/QuestionNavigationBar.tsx b/src/components/permissions/AskUserQuestionPermissionRequest/QuestionNavigationBar.tsx index 3440e9daf..b5ab89f74 100644 --- a/src/components/permissions/AskUserQuestionPermissionRequest/QuestionNavigationBar.tsx +++ b/src/components/permissions/AskUserQuestionPermissionRequest/QuestionNavigationBar.tsx @@ -1,17 +1,17 @@ -import figures from 'figures' -import React, { useMemo } from 'react' -import { useTerminalSize } from '../../../hooks/useTerminalSize.js' -import { stringWidth } from '../../../ink/stringWidth.js' -import { Box, Text } from '../../../ink.js' -import type { Question } from '../../../tools/AskUserQuestionTool/AskUserQuestionTool.js' -import { truncateToWidth } from '../../../utils/format.js' +import figures from 'figures'; +import React, { useMemo } from 'react'; +import { useTerminalSize } from '../../../hooks/useTerminalSize.js'; +import { stringWidth } from '../../../ink/stringWidth.js'; +import { Box, Text } from '../../../ink.js'; +import type { Question } from '../../../tools/AskUserQuestionTool/AskUserQuestionTool.js'; +import { truncateToWidth } from '../../../utils/format.js'; type Props = { - questions: Question[] - currentQuestionIndex: number - answers: Record - hideSubmitTab?: boolean -} + questions: Question[]; + currentQuestionIndex: number; + answers: Record; + hideSubmitTab?: boolean; +}; export function QuestionNavigationBar({ questions, @@ -19,94 +19,80 @@ export function QuestionNavigationBar({ answers, hideSubmitTab = false, }: Props): React.ReactNode { - const { columns } = useTerminalSize() + const { columns } = useTerminalSize(); // Calculate the display text for each tab based on available width const tabDisplayTexts = useMemo(() => { // Calculate fixed width elements - const leftArrow = '← ' - const rightArrow = ' →' - const submitText = hideSubmitTab ? '' : ` ${figures.tick} Submit ` - const checkboxWidth = 2 // checkbox + space - const paddingPerTab = 2 // space before and after each tab text + const leftArrow = '← '; + const rightArrow = ' →'; + const submitText = hideSubmitTab ? '' : ` ${figures.tick} Submit `; + const checkboxWidth = 2; // checkbox + space + const paddingPerTab = 2; // space before and after each tab text - const fixedWidth = - stringWidth(leftArrow) + stringWidth(rightArrow) + stringWidth(submitText) + const fixedWidth = stringWidth(leftArrow) + stringWidth(rightArrow) + stringWidth(submitText); // Available width for all question tabs - const availableForTabs = columns - fixedWidth + const availableForTabs = columns - fixedWidth; if (availableForTabs <= 0) { // Terminal too narrow, fallback to minimal display return questions.map((q: Question, index: number) => { - const header = q?.header || `Q${index + 1}` - return index === currentQuestionIndex ? header.slice(0, 3) : '' - }) + const header = q?.header || `Q${index + 1}`; + return index === currentQuestionIndex ? header.slice(0, 3) : ''; + }); } // Calculate ideal width for each tab (checkbox + padding + text) - const tabHeaders = questions.map( - (q: Question, index: number) => q?.header || `Q${index + 1}`, - ) - const idealWidths = tabHeaders.map( - header => checkboxWidth + paddingPerTab + stringWidth(header), - ) + const tabHeaders = questions.map((q: Question, index: number) => q?.header || `Q${index + 1}`); + const idealWidths = tabHeaders.map(header => checkboxWidth + paddingPerTab + stringWidth(header)); // Calculate total ideal width - const totalIdealWidth = idealWidths.reduce((sum, w) => sum + w, 0) + const totalIdealWidth = idealWidths.reduce((sum, w) => sum + w, 0); // If everything fits, use full headers if (totalIdealWidth <= availableForTabs) { - return tabHeaders + return tabHeaders; } // Need to truncate - prioritize current tab - const currentHeader = tabHeaders[currentQuestionIndex] || '' - const currentIdealWidth = - checkboxWidth + paddingPerTab + stringWidth(currentHeader) + const currentHeader = tabHeaders[currentQuestionIndex] || ''; + const currentIdealWidth = checkboxWidth + paddingPerTab + stringWidth(currentHeader); // Minimum width for other tabs (checkbox + padding + 1 char + ellipsis) - const minWidthPerTab = checkboxWidth + paddingPerTab + 2 // "X…" + const minWidthPerTab = checkboxWidth + paddingPerTab + 2; // "X…" // Calculate space for current tab (try to show full text) - const currentTabWidth = Math.min(currentIdealWidth, availableForTabs / 2) - const remainingWidth = availableForTabs - currentTabWidth + const currentTabWidth = Math.min(currentIdealWidth, availableForTabs / 2); + const remainingWidth = availableForTabs - currentTabWidth; // Calculate space for other tabs - const otherTabCount = questions.length - 1 - const widthPerOtherTab = Math.max( - minWidthPerTab, - Math.floor(remainingWidth / Math.max(otherTabCount, 1)), - ) + const otherTabCount = questions.length - 1; + const widthPerOtherTab = Math.max(minWidthPerTab, Math.floor(remainingWidth / Math.max(otherTabCount, 1))); return tabHeaders.map((header, index) => { if (index === currentQuestionIndex) { // Current tab - show as much as possible - const maxTextWidth = currentTabWidth - checkboxWidth - paddingPerTab - return truncateToWidth(header, maxTextWidth) + const maxTextWidth = currentTabWidth - checkboxWidth - paddingPerTab; + return truncateToWidth(header, maxTextWidth); } else { // Other tabs - truncate to fit - const maxTextWidth = widthPerOtherTab - checkboxWidth - paddingPerTab - return truncateToWidth(header, maxTextWidth) + const maxTextWidth = widthPerOtherTab - checkboxWidth - paddingPerTab; + return truncateToWidth(header, maxTextWidth); } - }) - }, [questions, currentQuestionIndex, columns, hideSubmitTab]) + }); + }, [questions, currentQuestionIndex, columns, hideSubmitTab]); - const hideArrows = questions.length === 1 && hideSubmitTab + const hideArrows = questions.length === 1 && hideSubmitTab; return ( - {!hideArrows && ( - - ←{' '} - - )} + {!hideArrows && } {questions.map((q: Question, index: number) => { - const isSelected = index === currentQuestionIndex - const isAnswered = q?.question && !!answers[q.question] - const checkbox = isAnswered ? figures.checkboxOn : figures.checkboxOff - const displayText = - tabDisplayTexts[index] || q?.header || `Q${index + 1}` + const isSelected = index === currentQuestionIndex; + const isAnswered = q?.question && !!answers[q.question]; + const checkbox = isAnswered ? figures.checkboxOn : figures.checkboxOff; + const displayText = tabDisplayTexts[index] || q?.header || `Q${index + 1}`; return ( @@ -122,7 +108,7 @@ export function QuestionNavigationBar({ )} - ) + ); })} {!hideSubmitTab && ( @@ -136,16 +122,7 @@ export function QuestionNavigationBar({ )} )} - {!hideArrows && ( - - {' '} - → - - )} + {!hideArrows && } - ) + ); } diff --git a/src/components/permissions/AskUserQuestionPermissionRequest/QuestionView.tsx b/src/components/permissions/AskUserQuestionPermissionRequest/QuestionView.tsx index ef45238ab..93f8a87cb 100644 --- a/src/components/permissions/AskUserQuestionPermissionRequest/QuestionView.tsx +++ b/src/components/permissions/AskUserQuestionPermissionRequest/QuestionView.tsx @@ -1,67 +1,51 @@ -import figures from 'figures' -import React, { useCallback, useState } from 'react' -import type { KeyboardEvent } from '../../../ink/events/keyboard-event.js' -import { Box, Text } from '../../../ink.js' -import { useAppState } from '../../../state/AppState.js' -import type { - Question, - QuestionOption, -} from '../../../tools/AskUserQuestionTool/AskUserQuestionTool.js' -import type { PastedContent } from '../../../utils/config.js' -import { getExternalEditor } from '../../../utils/editor.js' -import { toIDEDisplayName } from '../../../utils/ide.js' -import type { ImageDimensions } from '../../../utils/imageResizer.js' -import { editPromptInEditor } from '../../../utils/promptEditor.js' -import { - type OptionWithDescription, - Select, - SelectMulti, -} from '../../CustomSelect/index.js' -import { Divider } from '../../design-system/Divider.js' -import { FilePathLink } from '../../FilePathLink.js' -import { PermissionRequestTitle } from '../PermissionRequestTitle.js' -import { PreviewQuestionView } from './PreviewQuestionView.js' -import { QuestionNavigationBar } from './QuestionNavigationBar.js' -import type { QuestionState } from './use-multiple-choice-state.js' +import figures from 'figures'; +import React, { useCallback, useState } from 'react'; +import type { KeyboardEvent } from '../../../ink/events/keyboard-event.js'; +import { Box, Text } from '../../../ink.js'; +import { useAppState } from '../../../state/AppState.js'; +import type { Question, QuestionOption } from '../../../tools/AskUserQuestionTool/AskUserQuestionTool.js'; +import type { PastedContent } from '../../../utils/config.js'; +import { getExternalEditor } from '../../../utils/editor.js'; +import { toIDEDisplayName } from '../../../utils/ide.js'; +import type { ImageDimensions } from '../../../utils/imageResizer.js'; +import { editPromptInEditor } from '../../../utils/promptEditor.js'; +import { type OptionWithDescription, Select, SelectMulti } from '../../CustomSelect/index.js'; +import { Divider } from '../../design-system/Divider.js'; +import { FilePathLink } from '../../FilePathLink.js'; +import { PermissionRequestTitle } from '../PermissionRequestTitle.js'; +import { PreviewQuestionView } from './PreviewQuestionView.js'; +import { QuestionNavigationBar } from './QuestionNavigationBar.js'; +import type { QuestionState } from './use-multiple-choice-state.js'; type Props = { - question: Question - questions: Question[] - currentQuestionIndex: number - answers: Record - questionStates: Record - hideSubmitTab?: boolean - planFilePath?: string - pastedContents?: Record - minContentHeight?: number - minContentWidth?: number - onUpdateQuestionState: ( - questionText: string, - updates: Partial, - isMultiSelect: boolean, - ) => void - onAnswer: ( - questionText: string, - label: string | string[], - textInput?: string, - shouldAdvance?: boolean, - ) => void - onTextInputFocus: (isInInput: boolean) => void - onCancel: () => void - onSubmit: () => void - onTabPrev?: () => void - onTabNext?: () => void - onRespondToClaude: () => void - onFinishPlanInterview: () => void + question: Question; + questions: Question[]; + currentQuestionIndex: number; + answers: Record; + questionStates: Record; + hideSubmitTab?: boolean; + planFilePath?: string; + pastedContents?: Record; + minContentHeight?: number; + minContentWidth?: number; + onUpdateQuestionState: (questionText: string, updates: Partial, isMultiSelect: boolean) => void; + onAnswer: (questionText: string, label: string | string[], textInput?: string, shouldAdvance?: boolean) => void; + onTextInputFocus: (isInInput: boolean) => void; + onCancel: () => void; + onSubmit: () => void; + onTabPrev?: () => void; + onTabNext?: () => void; + onRespondToClaude: () => void; + onFinishPlanInterview: () => void; onImagePaste?: ( base64Image: string, mediaType?: string, filename?: string, dimensions?: ImageDimensions, sourcePath?: string, - ) => void - onRemoveImage?: (id: number) => void -} + ) => void; + onRemoveImage?: (id: number) => void; +}; export function QuestionView({ question, @@ -86,67 +70,67 @@ export function QuestionView({ pastedContents, onRemoveImage, }: Props): React.ReactNode { - const isInPlanMode = useAppState(s => s.toolPermissionContext.mode) === 'plan' - const [isFooterFocused, setIsFooterFocused] = useState(false) - const [footerIndex, setFooterIndex] = useState(0) - const [isOtherFocused, setIsOtherFocused] = useState(false) + const isInPlanMode = useAppState(s => s.toolPermissionContext.mode) === 'plan'; + const [isFooterFocused, setIsFooterFocused] = useState(false); + const [footerIndex, setFooterIndex] = useState(0); + const [isOtherFocused, setIsOtherFocused] = useState(false); - const editor = getExternalEditor() - const editorName = editor ? toIDEDisplayName(editor) : null + const editor = getExternalEditor(); + const editorName = editor ? toIDEDisplayName(editor) : null; const handleFocus = useCallback( (value: string) => { - const isOther = value === '__other__' - setIsOtherFocused(isOther) - onTextInputFocus(isOther) + const isOther = value === '__other__'; + setIsOtherFocused(isOther); + onTextInputFocus(isOther); }, [onTextInputFocus], - ) + ); const handleDownFromLastItem = useCallback(() => { - setIsFooterFocused(true) - }, []) + setIsFooterFocused(true); + }, []); const handleUpFromFooter = useCallback(() => { - setIsFooterFocused(false) - }, []) + setIsFooterFocused(false); + }, []); // Handle keyboard input when footer is focused const handleKeyDown = useCallback( (e: KeyboardEvent) => { - if (!isFooterFocused) return + if (!isFooterFocused) return; if (e.key === 'up' || (e.ctrl && e.key === 'p')) { - e.preventDefault() + e.preventDefault(); if (footerIndex === 0) { - handleUpFromFooter() + handleUpFromFooter(); } else { - setFooterIndex(0) + setFooterIndex(0); } - return + return; } if (e.key === 'down' || (e.ctrl && e.key === 'n')) { - e.preventDefault() + e.preventDefault(); if (isInPlanMode && footerIndex === 0) { - setFooterIndex(1) + setFooterIndex(1); } - return + return; } if (e.key === 'return') { - e.preventDefault() + e.preventDefault(); if (footerIndex === 0) { - onRespondToClaude() + onRespondToClaude(); } else { - onFinishPlanInterview() + onFinishPlanInterview(); } - return + return; } if (e.key === 'escape') { - e.preventDefault() - onCancel() + e.preventDefault(); + onCancel(); } }, [ @@ -158,37 +142,31 @@ export function QuestionView({ onFinishPlanInterview, onCancel, ], - ) + ); - const textOptions: OptionWithDescription[] = question.options.map( - (opt: QuestionOption) => ({ - type: 'text' as const, - value: opt.label, - label: opt.label, - description: opt.description, - }), - ) + const textOptions: OptionWithDescription[] = question.options.map((opt: QuestionOption) => ({ + type: 'text' as const, + value: opt.label, + label: opt.label, + description: opt.description, + })); - const questionText = question.question - const questionState = questionStates[questionText] + const questionText = question.question; + const questionState = questionStates[questionText]; const handleOpenEditor = useCallback( async (currentValue: string, setValue: (value: string) => void) => { - const result = await editPromptInEditor(currentValue) + const result = await editPromptInEditor(currentValue); if (result.content !== null && result.content !== currentValue) { // Update the Select's internal state for immediate UI update - setValue(result.content) + setValue(result.content); // Also update the question state for persistence - onUpdateQuestionState( - questionText, - { textInputValue: result.content }, - question.multiSelect ?? false, - ) + onUpdateQuestionState(questionText, { textInputValue: result.content }, question.multiSelect ?? false); } }, [questionText, onUpdateQuestionState, question.multiSelect], - ) + ); const otherOption: OptionWithDescription = { type: 'input' as const, @@ -197,20 +175,15 @@ export function QuestionView({ placeholder: question.multiSelect ? 'Type something' : 'Type something.', initialValue: questionState?.textInputValue ?? '', onChange: (value: string) => { - onUpdateQuestionState( - questionText, - { textInputValue: value }, - question.multiSelect ?? false, - ) + onUpdateQuestionState(questionText, { textInputValue: value }, question.multiSelect ?? false); }, - } + }; - const options = [...textOptions, otherOption] + const options = [...textOptions, otherOption]; // Check if any option has a preview and it's not multi-select // Previews only supported for single-select questions - const hasAnyPreview = - !question.multiSelect && question.options.some(opt => opt.preview) + const hasAnyPreview = !question.multiSelect && question.options.some(opt => opt.preview); // Delegate to PreviewQuestionView for carousel-style preview mode if (hasAnyPreview) { @@ -233,17 +206,11 @@ export function QuestionView({ onRespondToClaude={onRespondToClaude} onFinishPlanInterview={onFinishPlanInterview} /> - ) + ); } return ( - + {isInPlanMode && planFilePath && ( @@ -270,32 +237,18 @@ export function QuestionView({ { - onUpdateQuestionState( - questionText, - { selectedValue: values }, - true, - ) + onUpdateQuestionState(questionText, { selectedValue: values }, true); const textInput = values.includes('__other__') ? questionStates[questionText]?.textInputValue - : undefined - const finalValues = values - .filter(v => v !== '__other__') - .concat(textInput ? [textInput] : []) - onAnswer(questionText, finalValues, undefined, false) + : undefined; + const finalValues = values.filter(v => v !== '__other__').concat(textInput ? [textInput] : []); + onAnswer(questionText, finalValues, undefined, false); }} onFocus={handleFocus} onCancel={onCancel} - submitButtonText={ - currentQuestionIndex === questions.length - 1 - ? 'Submit' - : 'Next' - } + submitButtonText={currentQuestionIndex === questions.length - 1 ? 'Submit' : 'Next'} onSubmit={onSubmit} onDownFromLastItem={handleDownFromLastItem} isDisabled={isFooterFocused} @@ -308,22 +261,11 @@ export function QuestionView({ - ) + ); } diff --git a/src/components/permissions/BashPermissionRequest/BashPermissionRequest.tsx b/src/components/permissions/BashPermissionRequest/BashPermissionRequest.tsx index 1eb1ffffe..0d155e863 100644 --- a/src/components/permissions/BashPermissionRequest/BashPermissionRequest.tsx +++ b/src/components/permissions/BashPermissionRequest/BashPermissionRequest.tsx @@ -1,51 +1,45 @@ -import { feature } from 'bun:bundle' -import figures from 'figures' -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { Box, Text, useTheme } from '../../../ink.js' -import { useKeybinding } from '../../../keybindings/useKeybinding.js' -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../../services/analytics/growthbook.js' +import { feature } from 'bun:bundle'; +import figures from 'figures'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { Box, Text, useTheme } from '../../../ink.js'; +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 { useAppState } from '../../../state/AppState.js' -import { BashTool } from '../../../tools/BashTool/BashTool.js' -import { - getFirstWordPrefix, - getSimpleCommandPrefix, -} from '../../../tools/BashTool/bashPermissions.js' -import { getDestructiveCommandWarning } from '../../../tools/BashTool/destructiveCommandWarning.js' -import { parseSedEditCommand } from '../../../tools/BashTool/sedEditParser.js' -import { shouldUseSandbox } from '../../../tools/BashTool/shouldUseSandbox.js' -import { getCompoundCommandPrefixesStatic } from '../../../utils/bash/prefix.js' +} from '../../../services/analytics/index.js'; +import { sanitizeToolNameForAnalytics } from '../../../services/analytics/metadata.js'; +import { useAppState } from '../../../state/AppState.js'; +import { BashTool } from '../../../tools/BashTool/BashTool.js'; +import { getFirstWordPrefix, getSimpleCommandPrefix } from '../../../tools/BashTool/bashPermissions.js'; +import { getDestructiveCommandWarning } from '../../../tools/BashTool/destructiveCommandWarning.js'; +import { parseSedEditCommand } from '../../../tools/BashTool/sedEditParser.js'; +import { shouldUseSandbox } from '../../../tools/BashTool/shouldUseSandbox.js'; +import { getCompoundCommandPrefixesStatic } from '../../../utils/bash/prefix.js'; import { createPromptRuleContent, generateGenericDescription, getBashPromptAllowDescriptions, isClassifierPermissionsEnabled, -} from '../../../utils/permissions/bashClassifier.js' -import { extractRules } from '../../../utils/permissions/PermissionUpdate.js' -import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js' -import { SandboxManager } from '../../../utils/sandbox/sandbox-adapter.js' -import { Select } from '../../CustomSelect/select.js' -import { ShimmerChar } from '../../Spinner/ShimmerChar.js' -import { useShimmerAnimation } from '../../Spinner/useShimmerAnimation.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 { SedEditPermissionRequest } from '../SedEditPermissionRequest/SedEditPermissionRequest.js' -import { useShellPermissionFeedback } from '../useShellPermissionFeedback.js' -import { logUnaryPermissionEvent } from '../utils.js' -import { bashToolUseOptions } from './bashToolUseOptions.js' +} from '../../../utils/permissions/bashClassifier.js'; +import { extractRules } from '../../../utils/permissions/PermissionUpdate.js'; +import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js'; +import { SandboxManager } from '../../../utils/sandbox/sandbox-adapter.js'; +import { Select } from '../../CustomSelect/select.js'; +import { ShimmerChar } from '../../Spinner/ShimmerChar.js'; +import { useShimmerAnimation } from '../../Spinner/useShimmerAnimation.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 { SedEditPermissionRequest } from '../SedEditPermissionRequest/SedEditPermissionRequest.js'; +import { useShellPermissionFeedback } from '../useShellPermissionFeedback.js'; +import { logUnaryPermissionEvent } from '../utils.js'; +import { bashToolUseOptions } from './bashToolUseOptions.js'; -const CHECKING_TEXT = 'Attempting to auto-approve\u2026' +const CHECKING_TEXT = 'Attempting to auto-approve\u2026'; // Isolates the 20fps shimmer clock from BashPermissionRequestInner. Before this // extraction, useShimmerAnimation lived inside the 535-line Inner body, so every @@ -54,11 +48,7 @@ const CHECKING_TEXT = 'Attempting to auto-approve\u2026' // has a Compiler bailout (see below), so nothing was auto-memoized — the full // JSX tree was reconstructed 20-60 times per classifier check. function ClassifierCheckingSubtitle(): React.ReactNode { - const [ref, glimmerIndex] = useShimmerAnimation( - 'requesting', - CHECKING_TEXT, - false, - ) + const [ref, glimmerIndex] = useShimmerAnimation('requesting', CHECKING_TEXT, false); return ( @@ -74,28 +64,17 @@ function ClassifierCheckingSubtitle(): React.ReactNode { ))} - ) + ); } -export function BashPermissionRequest( - props: PermissionRequestProps, -): React.ReactNode { - const { - toolUseConfirm, - toolUseContext, - onDone, - onReject, - verbose, - workerBadge, - } = props +export function BashPermissionRequest(props: PermissionRequestProps): React.ReactNode { + const { toolUseConfirm, toolUseContext, onDone, onReject, verbose, workerBadge } = props; - const { command, description } = BashTool.inputSchema.parse( - toolUseConfirm.input, - ) + const { command, description } = BashTool.inputSchema.parse(toolUseConfirm.input); // Detect sed in-place edit commands and delegate to SedEditPermissionRequest // This renders sed edits like file edits with a diff view - const sedInfo = parseSedEditCommand(command) + const sedInfo = parseSedEditCommand(command); if (sedInfo) { return ( @@ -108,7 +87,7 @@ export function BashPermissionRequest( workerBadge={workerBadge} sedInfo={sedInfo} /> - ) + ); } // Regular bash command - render with hooks @@ -123,7 +102,7 @@ export function BashPermissionRequest( command={command} description={description} /> - ) + ); } // Inner component that uses hooks - only called for non-MCP CLI commands @@ -137,17 +116,17 @@ function BashPermissionRequestInner({ command, description, }: PermissionRequestProps & { - command: string - description?: string + command: string; + description?: string; }): React.ReactNode { - const [theme] = useTheme() - const toolPermissionContext = useAppState(s => s.toolPermissionContext) + const [theme] = useTheme(); + const toolPermissionContext = useAppState(s => s.toolPermissionContext); const explainerState = usePermissionExplainerUI({ toolName: toolUseConfirm.tool.name, toolInput: toolUseConfirm.input, toolDescription: toolUseConfirm.description, messages: toolUseContext.messages, - }) + }); const { yesInputMode, noInputMode, @@ -166,33 +145,28 @@ function BashPermissionRequestInner({ onDone, onReject, explainerVisible: explainerState.visible, - }) - const [showPermissionDebug, setShowPermissionDebug] = useState(false) - const [classifierDescription, setClassifierDescription] = useState( - description || '', - ) + }); + const [showPermissionDebug, setShowPermissionDebug] = useState(false); + const [classifierDescription, setClassifierDescription] = useState(description || ''); // Track whether the initial description (from prop or async generation) was empty. // Once we receive a non-empty description, this stays false. - const [ - initialClassifierDescriptionEmpty, - setInitialClassifierDescriptionEmpty, - ] = useState(!description?.trim()) + const [initialClassifierDescriptionEmpty, setInitialClassifierDescriptionEmpty] = useState(!description?.trim()); // Asynchronously generate a generic description for the classifier useEffect(() => { - if (!isClassifierPermissionsEnabled()) return + if (!isClassifierPermissionsEnabled()) return; - const abortController = new AbortController() + const abortController = new AbortController(); generateGenericDescription(command, description, abortController.signal) .then(generic => { if (generic && !abortController.signal.aborted) { - setClassifierDescription(generic) - setInitialClassifierDescriptionEmpty(false) + setClassifierDescription(generic); + setInitialClassifierDescriptionEmpty(false); } }) - .catch(() => {}) // Keep original on error - return () => abortController.abort() - }, [command, description]) + .catch(() => {}); // Keep original on error + return () => abortController.abort(); + }, [command, description]); // GH#11380: For compound commands (cd src && git status && npm test), the // backend already computed correct per-subcommand suggestions via tree-sitter @@ -208,8 +182,7 @@ function BashPermissionRequestInner({ // from the backend rule. When compound with 2+ rules, editablePrefix stays // undefined so bashToolUseOptions falls through to yes-apply-suggestions, // which saves all per-subcommand rules atomically. - const isCompound = - toolUseConfirm.permissionResult.decisionReason?.type === 'subcommandResults' + const isCompound = toolUseConfirm.permissionResult.decisionReason?.type === 'subcommandResults'; // Editable prefix — initialize synchronously with the best prefix we can // extract without tree-sitter, then refine via tree-sitter for compound @@ -219,52 +192,44 @@ function BashPermissionRequestInner({ // // Lazy initializer: this runs regex + split on every render if left in // the render body; it's only needed for initial state. - const [editablePrefix, setEditablePrefix] = useState( - () => { - if (isCompound) { - // Backend suggestion is the source of truth for compound commands. - // Single rule → seed the editable input so the user can refine it. - // Multiple/zero rules → undefined → yes-apply-suggestions handles it. - const backendBashRules = extractRules( - 'suggestions' in toolUseConfirm.permissionResult - ? toolUseConfirm.permissionResult.suggestions - : undefined, - ).filter(r => r.toolName === BashTool.name && r.ruleContent) - return backendBashRules.length === 1 - ? backendBashRules[0]!.ruleContent - : undefined - } - const two = getSimpleCommandPrefix(command) - if (two) return `${two}:*` - const one = getFirstWordPrefix(command) - if (one) return `${one}:*` - return command - }, - ) - const hasUserEditedPrefix = useRef(false) + const [editablePrefix, setEditablePrefix] = useState(() => { + if (isCompound) { + // Backend suggestion is the source of truth for compound commands. + // Single rule → seed the editable input so the user can refine it. + // Multiple/zero rules → undefined → yes-apply-suggestions handles it. + const backendBashRules = extractRules( + 'suggestions' in toolUseConfirm.permissionResult ? toolUseConfirm.permissionResult.suggestions : undefined, + ).filter(r => r.toolName === BashTool.name && r.ruleContent); + return backendBashRules.length === 1 ? backendBashRules[0]!.ruleContent : undefined; + } + const two = getSimpleCommandPrefix(command); + if (two) return `${two}:*`; + const one = getFirstWordPrefix(command); + if (one) return `${one}:*`; + return command; + }); + const hasUserEditedPrefix = useRef(false); const onEditablePrefixChange = useCallback((value: string) => { - hasUserEditedPrefix.current = true - setEditablePrefix(value) - }, []) + hasUserEditedPrefix.current = true; + setEditablePrefix(value); + }, []); useEffect(() => { // Skip async refinement for compound commands — the backend already ran // the full per-subcommand analysis and its suggestion is correct. - if (isCompound) return - let cancelled = false - getCompoundCommandPrefixesStatic(command, subcmd => - BashTool.isReadOnly({ command: subcmd }), - ) + if (isCompound) return; + let cancelled = false; + getCompoundCommandPrefixesStatic(command, subcmd => BashTool.isReadOnly({ command: subcmd })) .then(prefixes => { - if (cancelled || hasUserEditedPrefix.current) return + if (cancelled || hasUserEditedPrefix.current) return; if (prefixes.length > 0) { - setEditablePrefix(`${prefixes[0]}:*`) + setEditablePrefix(`${prefixes[0]}:*`); } }) - .catch(() => {}) // Keep sync prefix on tree-sitter failure + .catch(() => {}); // Keep sync prefix on tree-sitter failure return () => { - cancelled = true - } - }, [command, isCompound]) + cancelled = true; + }; + }, [command, isCompound]); // Track whether classifier check was ever in progress (persists after completion). // classifierCheckInProgress is set once at queue-push time (interactiveHandler) @@ -272,10 +237,8 @@ function BashPermissionRequestInner({ // sufficient — no latch/ref needed. The feature() ternary keeps the property // read out of external builds (forbidden-string check). const [classifierWasChecking] = useState( - feature('BASH_CLASSIFIER') - ? !!toolUseConfirm.classifierCheckInProgress - : false, - ) + feature('BASH_CLASSIFIER') ? !!toolUseConfirm.classifierCheckInProgress : false, + ); // These derive solely from the tool input (fixed for the dialog lifetime). // The shimmer clock used to live in this component and re-render it at 20fps @@ -284,39 +247,30 @@ function BashPermissionRequestInner({ // prove side-effect freedom), so this useMemo still guards against any // re-render source (e.g. Inner state updates). Same pattern as PR#20730. const { destructiveWarning, sandboxingEnabled, isSandboxed } = useMemo(() => { - 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 sandboxingEnabled = SandboxManager.isSandboxingEnabled() - const isSandboxed = - sandboxingEnabled && shouldUseSandbox(toolUseConfirm.input) + const sandboxingEnabled = SandboxManager.isSandboxingEnabled(); + const isSandboxed = sandboxingEnabled && shouldUseSandbox(toolUseConfirm.input); - return { destructiveWarning, sandboxingEnabled, isSandboxed } - }, [command, toolUseConfirm.input]) + return { destructiveWarning, sandboxingEnabled, isSandboxed }; + }, [command, toolUseConfirm.input]); - 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 existingAllowDescriptions = useMemo( () => getBashPromptAllowDescriptions(toolPermissionContext), [toolPermissionContext], - ) + ); const options = useMemo( () => bashToolUseOptions({ suggestions: - toolUseConfirm.permissionResult.behavior === 'ask' - ? toolUseConfirm.permissionResult.suggestions - : undefined, + toolUseConfirm.permissionResult.behavior === 'ask' ? toolUseConfirm.permissionResult.suggestions : undefined, decisionReason: toolUseConfirm.permissionResult.decisionReason, onRejectFeedbackChange: setRejectFeedback, onAcceptFeedbackChange: setAcceptFeedback, @@ -339,26 +293,24 @@ function BashPermissionRequestInner({ editablePrefix, onEditablePrefixChange, ], - ) + ); // Toggle permission debug info with keybinding const handleToggleDebug = useCallback(() => { - setShowPermissionDebug(prev => !prev) - }, []) + setShowPermissionDebug(prev => !prev); + }, []); useKeybinding('permission:toggleDebug', handleToggleDebug, { context: 'Confirmation', - }) + }); // Allow Esc to dismiss the checkmark after auto-approval const handleDismissCheckmark = useCallback(() => { - toolUseConfirm.onDismissCheckmark?.() - }, [toolUseConfirm]) + toolUseConfirm.onDismissCheckmark?.(); + }, [toolUseConfirm]); useKeybinding('confirm:no', handleDismissCheckmark, { context: 'Confirmation', - isActive: feature('BASH_CLASSIFIER') - ? !!toolUseConfirm.classifierAutoApproved - : false, - }) + isActive: feature('BASH_CLASSIFIER') ? !!toolUseConfirm.classifierAutoApproved : false, + }); function onSelect(value: string) { // Map options to numeric values for analytics (strings not allowed in logEvent) @@ -367,7 +319,7 @@ function BashPermissionRequestInner({ 'yes-apply-suggestions': 2, 'yes-prefix-edited': 2, no: 3, - } + }; if (feature('BASH_CLASSIFIER')) { optionIndex = { yes: 1, @@ -375,22 +327,22 @@ function BashPermissionRequestInner({ 'yes-prefix-edited': 2, 'yes-classifier-reviewed': 3, no: 4, - } + }; } 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[] = [ { @@ -404,18 +356,18 @@ function BashPermissionRequestInner({ behavior: 'allow', destination: 'localSettings', }, - ] - toolUseConfirm.onAllow(toolUseConfirm.input, prefixUpdates) + ]; + toolUseConfirm.onAllow(toolUseConfirm.input, prefixUpdates); } - onDone() - return + onDone(); + return; } if (feature('BASH_CLASSIFIER') && value === 'yes-classifier-reviewed') { - const trimmedDescription = classifierDescription.trim() - logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept') + const trimmedDescription = classifierDescription.trim(); + logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept'); if (!trimmedDescription) { - toolUseConfirm.onAllow(toolUseConfirm.input, []) + toolUseConfirm.onAllow(toolUseConfirm.input, []); } else { const permissionUpdates: PermissionUpdate[] = [ { @@ -429,17 +381,17 @@ function BashPermissionRequestInner({ behavior: 'allow', destination: 'session', }, - ] - toolUseConfirm.onAllow(toolUseConfirm.input, permissionUpdates) + ]; + toolUseConfirm.onAllow(toolUseConfirm.input, permissionUpdates); } - 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, @@ -447,28 +399,22 @@ function BashPermissionRequestInner({ 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', { @@ -477,11 +423,11 @@ function BashPermissionRequestInner({ 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; } } } @@ -503,16 +449,12 @@ function BashPermissionRequestInner({ ) : classifierWasChecking ? ( Requires manual approval ) : undefined - ) : undefined + ) : undefined; return ( @@ -522,20 +464,12 @@ function BashPermissionRequestInner({ { 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 @@ -545,31 +479,18 @@ function BashPermissionRequestInner({ ) : ( <> - + {destructiveWarning && ( {destructiveWarning} )} - + Do you want to proceed? - ) + ); } // ── App allowlist panel ─────────────────────────────────────────────────── -type AppListOption = 'allow_all' | 'deny' +type AppListOption = 'allow_all' | 'deny'; -const SENTINEL_WARNING: Record< - NonNullable>, - string -> = { +const SENTINEL_WARNING: Record>, string> = { shell: 'equivalent to shell access', filesystem: 'can read/write any file', system_settings: 'can change system settings', -} +}; -function ComputerUseAppListPanel({ - request, - onDone, -}: ComputerUseApprovalProps): React.ReactNode { +function ComputerUseAppListPanel({ request, onDone }: ComputerUseApprovalProps): React.ReactNode { // Pre-check every resolved, not-yet-granted app. Sentinels stay checked // too — the warning text is the signal, not an unchecked box. // Per-item toggles are a follow-up; for now every resolved app is granted // when the user accepts. `setChecked` is unused until then. const [checked] = useState>( - () => - new Set( - request.apps.flatMap(a => - a.resolved && !a.alreadyGranted ? [a.resolved.bundleId] : [], - ), - ), - ) + () => new Set(request.apps.flatMap(a => (a.resolved && !a.alreadyGranted ? [a.resolved.bundleId] : []))), + ); - type FlagKey = keyof typeof DEFAULT_GRANT_FLAGS - const ALL_FLAG_KEYS: FlagKey[] = [ - 'clipboardRead', - 'clipboardWrite', - 'systemKeyCombos', - ] + type FlagKey = keyof typeof DEFAULT_GRANT_FLAGS; + const ALL_FLAG_KEYS: FlagKey[] = ['clipboardRead', 'clipboardWrite', 'systemKeyCombos']; const requestedFlagKeys = useMemo( (): FlagKey[] => ALL_FLAG_KEYS.filter(k => request.requestedFlags[k]), [request.requestedFlags], - ) + ); const options = useMemo[]>( () => [ @@ -187,14 +152,14 @@ function ComputerUseAppListPanel({ }, ], [checked.size], - ) + ); function respond(allow: boolean): void { if (!allow) { - onDone(DENY_ALL_RESPONSE) - return + onDone(DENY_ALL_RESPONSE); + return; } - const now = Date.now() + const now = Date.now(); const granted = request.apps.flatMap(a => a.resolved && checked.has(a.resolved.bundleId) ? [ @@ -205,60 +170,52 @@ function ComputerUseAppListPanel({ }, ] : [], - ) + ); const denied = request.apps .filter(a => !a.resolved || !checked.has(a.resolved.bundleId)) .map(a => ({ bundleId: a.resolved?.bundleId ?? a.requestedName, - reason: a.resolved - ? ('user_denied' as const) - : ('not_installed' as const), - })) + reason: a.resolved ? ('user_denied' as const) : ('not_installed' as const), + })); // Grant all requested flags on allow — per-flag toggles are a follow-up. const flags = { ...DEFAULT_GRANT_FLAGS, ...Object.fromEntries(requestedFlagKeys.map(k => [k, true] as const)), - } - onDone({ granted, denied, flags }) + }; + onDone({ granted, denied, flags }); } return ( - respond(false)} - > + respond(false)}> {request.reason ? {request.reason} : null} {request.apps.map(a => { - const resolved = a.resolved + const resolved = a.resolved; if (!resolved) { return ( {' '} - {figures.circle} {a.requestedName}{' '} - (not installed) + {figures.circle} {a.requestedName} (not installed) - ) + ); } if (a.alreadyGranted) { return ( {' '} - {figures.tick} {resolved.displayName}{' '} - (already granted) + {figures.tick} {resolved.displayName} (already granted) - ) + ); } - const sentinel = getSentinelCategory(resolved.bundleId) - const isChecked = checked.has(resolved.bundleId) + const sentinel = getSentinelCategory(resolved.bundleId); + const isChecked = checked.has(resolved.bundleId); return ( {' '} - {isChecked ? figures.circleFilled : figures.circle}{' '} - {resolved.displayName} + {isChecked ? figures.circleFilled : figures.circle} {resolved.displayName} {sentinel ? ( @@ -267,7 +224,7 @@ function ComputerUseAppListPanel({ ) : null} - ) + ); })} @@ -284,18 +241,12 @@ function ComputerUseAppListPanel({ {request.willHide && request.willHide.length > 0 ? ( - {request.willHide.length} other{' '} - {plural(request.willHide.length, 'app')} will be hidden while Claude - works. + {request.willHide.length} other {plural(request.willHide.length, 'app')} will be hidden while Claude works. ) : null} - respond(v === 'allow_all')} onCancel={() => respond(false)} /> - ) + ); } diff --git a/src/components/permissions/EnterPlanModePermissionRequest/EnterPlanModePermissionRequest.tsx b/src/components/permissions/EnterPlanModePermissionRequest/EnterPlanModePermissionRequest.tsx index 4251891e0..25a56f7d7 100644 --- a/src/components/permissions/EnterPlanModePermissionRequest/EnterPlanModePermissionRequest.tsx +++ b/src/components/permissions/EnterPlanModePermissionRequest/EnterPlanModePermissionRequest.tsx @@ -1,15 +1,15 @@ -import React from 'react' -import { handlePlanModeTransition } from '../../../bootstrap/state.js' -import { Box, Text } from '../../../ink.js' +import React from 'react'; +import { handlePlanModeTransition } from '../../../bootstrap/state.js'; +import { Box, Text } from '../../../ink.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from '../../../services/analytics/index.js' -import { useAppState } from '../../../state/AppState.js' -import { isPlanModeInterviewPhaseEnabled } from '../../../utils/planModeV2.js' -import { Select } from '../../CustomSelect/index.js' -import { PermissionDialog } from '../PermissionDialog.js' -import type { PermissionRequestProps } from '../PermissionRequest.js' +} from '../../../services/analytics/index.js'; +import { useAppState } from '../../../state/AppState.js'; +import { isPlanModeInterviewPhaseEnabled } from '../../../utils/planModeV2.js'; +import { Select } from '../../CustomSelect/index.js'; +import { PermissionDialog } from '../PermissionDialog.js'; +import type { PermissionRequestProps } from '../PermissionRequest.js'; export function EnterPlanModePermissionRequest({ toolUseConfirm, @@ -17,40 +17,28 @@ export function EnterPlanModePermissionRequest({ onReject, workerBadge, }: PermissionRequestProps): React.ReactNode { - const toolPermissionContextMode = useAppState( - s => s.toolPermissionContext.mode, - ) + const toolPermissionContextMode = useAppState(s => s.toolPermissionContext.mode); function handleResponse(value: 'yes' | 'no'): void { if (value === 'yes') { logEvent('tengu_plan_enter', { interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), - entryMethod: - 'tool' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - handlePlanModeTransition(toolPermissionContextMode, 'plan') - onDone() - toolUseConfirm.onAllow({}, [ - { type: 'setMode', mode: 'plan', destination: 'session' }, - ]) + entryMethod: 'tool' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + handlePlanModeTransition(toolPermissionContextMode, 'plan'); + onDone(); + toolUseConfirm.onAllow({}, [{ type: 'setMode', mode: 'plan', destination: 'session' }]); } else { - onDone() - onReject() - toolUseConfirm.onReject() + onDone(); + onReject(); + toolUseConfirm.onReject(); } } return ( - + - - Claude wants to enter plan mode to explore and design an - implementation approach. - + Claude wants to enter plan mode to explore and design an implementation approach. In plan mode, Claude will: @@ -61,9 +49,7 @@ export function EnterPlanModePermissionRequest({ - - No code changes will be made until you approve the plan. - + No code changes will be made until you approve the plan. @@ -78,5 +64,5 @@ export function EnterPlanModePermissionRequest({ - ) + ); } diff --git a/src/components/permissions/ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.tsx b/src/components/permissions/ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.tsx index fddadaa7e..79a0d2240 100644 --- a/src/components/permissions/ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.tsx +++ b/src/components/permissions/ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.tsx @@ -1,24 +1,13 @@ -import { feature } from 'bun:bundle' -import type { UUID } from 'crypto' -import figures from 'figures' -import React, { - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from 'react' -import { useNotifications } from 'src/context/notifications.js' +import { feature } from 'bun:bundle'; +import type { UUID } from 'crypto'; +import figures from 'figures'; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { useNotifications } from 'src/context/notifications.js'; import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent, -} from 'src/services/analytics/index.js' -import { - useAppState, - useAppStateStore, - useSetAppState, -} from 'src/state/AppState.js' +} from 'src/services/analytics/index.js'; +import { useAppState, useAppStateStore, useSetAppState } from 'src/state/AppState.js'; import { getSdkBetas, getSessionId, @@ -26,82 +15,64 @@ import { setHasExitedPlanMode, setNeedsAutoModeExitAttachment, setNeedsPlanModeExitAttachment, -} from '../../../bootstrap/state.js' -import { generateSessionName } from '../../../commands/rename/generateSessionName.js' -import { launchUltraplan } from '../../../commands/ultraplan.js' -import type { KeyboardEvent } from '../../../ink/events/keyboard-event.js' -import { Box, Text } from '../../../ink.js' -import type { AppState } from '../../../state/AppStateStore.js' -import { AGENT_TOOL_NAME } from '../../../tools/AgentTool/constants.js' -import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../../../tools/ExitPlanModeTool/constants.js' -import type { AllowedPrompt } from '../../../tools/ExitPlanModeTool/ExitPlanModeV2Tool.js' -import { TEAM_CREATE_TOOL_NAME } from '../../../tools/TeamCreateTool/constants.js' -import { isAgentSwarmsEnabled } from '../../../utils/agentSwarmsEnabled.js' -import { - calculateContextPercentages, - getContextWindowForModel, -} from '../../../utils/context.js' -import { getExternalEditor } from '../../../utils/editor.js' -import { getDisplayPath } from '../../../utils/file.js' -import { toIDEDisplayName } from '../../../utils/ide.js' -import { logError } from '../../../utils/log.js' -import { enqueuePendingNotification } from '../../../utils/messageQueueManager.js' -import { createUserMessage } from '../../../utils/messages.js' -import { - getMainLoopModel, - getRuntimeMainLoopModel, -} from '../../../utils/model/model.js' +} from '../../../bootstrap/state.js'; +import { generateSessionName } from '../../../commands/rename/generateSessionName.js'; +import { launchUltraplan } from '../../../commands/ultraplan.js'; +import type { KeyboardEvent } from '../../../ink/events/keyboard-event.js'; +import { Box, Text } from '../../../ink.js'; +import type { AppState } from '../../../state/AppStateStore.js'; +import { AGENT_TOOL_NAME } from '../../../tools/AgentTool/constants.js'; +import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../../../tools/ExitPlanModeTool/constants.js'; +import type { AllowedPrompt } from '../../../tools/ExitPlanModeTool/ExitPlanModeV2Tool.js'; +import { TEAM_CREATE_TOOL_NAME } from '../../../tools/TeamCreateTool/constants.js'; +import { isAgentSwarmsEnabled } from '../../../utils/agentSwarmsEnabled.js'; +import { calculateContextPercentages, getContextWindowForModel } from '../../../utils/context.js'; +import { getExternalEditor } from '../../../utils/editor.js'; +import { getDisplayPath } from '../../../utils/file.js'; +import { toIDEDisplayName } from '../../../utils/ide.js'; +import { logError } from '../../../utils/log.js'; +import { enqueuePendingNotification } from '../../../utils/messageQueueManager.js'; +import { createUserMessage } from '../../../utils/messages.js'; +import { getMainLoopModel, getRuntimeMainLoopModel } from '../../../utils/model/model.js'; import { createPromptRuleContent, isClassifierPermissionsEnabled, PROMPT_PREFIX, -} from '../../../utils/permissions/bashClassifier.js' -import { - type PermissionMode, - toExternalPermissionMode, -} from '../../../utils/permissions/PermissionMode.js' -import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js' +} from '../../../utils/permissions/bashClassifier.js'; +import { type PermissionMode, toExternalPermissionMode } from '../../../utils/permissions/PermissionMode.js'; +import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js'; import { isAutoModeGateEnabled, restoreDangerousPermissions, stripDangerousPermissionsForAutoMode, -} from '../../../utils/permissions/permissionSetup.js' -import { - getPewterLedgerVariant, - isPlanModeInterviewPhaseEnabled, -} from '../../../utils/planModeV2.js' -import { getPlan, getPlanFilePath } from '../../../utils/plans.js' -import { - editFileInEditor, - editPromptInEditor, -} from '../../../utils/promptEditor.js' +} from '../../../utils/permissions/permissionSetup.js'; +import { getPewterLedgerVariant, isPlanModeInterviewPhaseEnabled } from '../../../utils/planModeV2.js'; +import { getPlan, getPlanFilePath } from '../../../utils/plans.js'; +import { editFileInEditor, editPromptInEditor } from '../../../utils/promptEditor.js'; import { getCurrentSessionTitle, getTranscriptPath, saveAgentName, saveCustomTitle, -} from '../../../utils/sessionStorage.js' -import { getSettings_DEPRECATED } from '../../../utils/settings/settings.js' -import { type OptionWithDescription, Select } from '../../CustomSelect/index.js' -import { Markdown } from '../../Markdown.js' -import { PermissionDialog } from '../PermissionDialog.js' -import type { PermissionRequestProps } from '../PermissionRequest.js' -import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js' +} from '../../../utils/sessionStorage.js'; +import { getSettings_DEPRECATED } from '../../../utils/settings/settings.js'; +import { type OptionWithDescription, Select } from '../../CustomSelect/index.js'; +import { Markdown } from '../../Markdown.js'; +import { PermissionDialog } from '../PermissionDialog.js'; +import type { PermissionRequestProps } from '../PermissionRequest.js'; +import { PermissionRuleExplanation } from '../PermissionRuleExplanation.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 + : null; -import type { - Base64ImageSource, - ImageBlockParam, -} from '@anthropic-ai/sdk/resources/messages.mjs' +import type { Base64ImageSource, ImageBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'; /* eslint-enable @typescript-eslint/no-require-imports */ -import type { PastedContent } from '../../../utils/config.js' -import type { ImageDimensions } from '../../../utils/imageResizer.js' -import { maybeResizeAndDownsampleImageBlock } from '../../../utils/imageResizer.js' -import { cacheImagePath, storeImage } from '../../../utils/imageStore.js' +import type { PastedContent } from '../../../utils/config.js'; +import type { ImageDimensions } from '../../../utils/imageResizer.js'; +import { maybeResizeAndDownsampleImageBlock } from '../../../utils/imageResizer.js'; +import { cacheImagePath, storeImage } from '../../../utils/imageStore.js'; type ResponseValue = | 'yes-bypass-permissions' @@ -111,30 +82,23 @@ type ResponseValue = | 'yes-resume-auto-mode' | 'yes-auto-clear-context' | 'ultraplan' - | 'no' + | 'no'; /** * Build permission updates for plan approval, including prompt-based rules if provided. * Prompt-based rules are only added when classifier permissions are enabled (Ant-only). */ -export function buildPermissionUpdates( - mode: PermissionMode, - allowedPrompts?: AllowedPrompt[], -): PermissionUpdate[] { +export function buildPermissionUpdates(mode: PermissionMode, allowedPrompts?: AllowedPrompt[]): PermissionUpdate[] { const updates: PermissionUpdate[] = [ { type: 'setMode', mode: toExternalPermissionMode(mode), destination: 'session', }, - ] + ]; // Add prompt-based permission rules if provided (Ant-only feature) - if ( - isClassifierPermissionsEnabled() && - allowedPrompts && - allowedPrompts.length > 0 - ) { + if (isClassifierPermissionsEnabled() && allowedPrompts && allowedPrompts.length > 0) { updates.push({ type: 'addRules', rules: allowedPrompts.map(p => ({ @@ -143,10 +107,10 @@ export function buildPermissionUpdates( })), behavior: 'allow', destination: 'session', - }) + }); } - return updates + return updates; } /** @@ -159,16 +123,13 @@ export function autoNameSessionFromPlan( setAppState: (updater: (prev: AppState) => AppState) => void, isClearContext: boolean, ): void { - if ( - isSessionPersistenceDisabled() || - getSettings_DEPRECATED()?.cleanupPeriodDays === 0 - ) { - return + if (isSessionPersistenceDisabled() || getSettings_DEPRECATED()?.cleanupPeriodDays === 0) { + return; } // On clear-context, the current session is about to be abandoned — its // title (which may have been set by a PRIOR auto-name) is irrelevant. // Checking it would make the feature self-defeating after first use. - if (!isClearContext && getCurrentSessionTitle(getSessionId())) return + if (!isClearContext && getCurrentSessionTitle(getSessionId())) return; void generateSessionName( // generateSessionName tail-slices to the last 1000 chars (correct for // conversations, where recency matters). Plans front-load the goal and @@ -180,20 +141,20 @@ export function autoNameSessionFromPlan( // On clear-context acceptance, regenerateSessionId() has run by now — // this intentionally names the NEW execution session. Do not "fix" by // capturing sessionId once; that would name the abandoned planning session. - if (!name || getCurrentSessionTitle(getSessionId())) return - const sessionId = getSessionId() as UUID - const fullPath = getTranscriptPath() - await saveCustomTitle(sessionId, name, fullPath, 'auto') - await saveAgentName(sessionId, name, fullPath, 'auto') + if (!name || getCurrentSessionTitle(getSessionId())) return; + const sessionId = getSessionId() as UUID; + const fullPath = getTranscriptPath(); + await saveCustomTitle(sessionId, name, fullPath, 'auto'); + await saveAgentName(sessionId, name, fullPath, 'auto'); setAppState(prev => { - if (prev.standaloneAgentContext?.name === name) return prev + if (prev.standaloneAgentContext?.name === name) return prev; return { ...prev, standaloneAgentContext: { ...prev.standaloneAgentContext, name }, - } - }) + }; + }); }) - .catch(logError) + .catch(logError); } export function ExitPlanModePermissionRequest({ @@ -203,54 +164,39 @@ export function ExitPlanModePermissionRequest({ workerBadge, setStickyFooter, }: PermissionRequestProps): React.ReactNode { - const toolPermissionContext = useAppState(s => s.toolPermissionContext) - const setAppState = useSetAppState() - const store = useAppStateStore() - const { addNotification } = useNotifications() + const toolPermissionContext = useAppState(s => s.toolPermissionContext); + const setAppState = useSetAppState(); + const store = useAppStateStore(); + const { addNotification } = useNotifications(); // Feedback text from the 'No' option's input. Threaded through onAllow as // acceptFeedback when the user approves — lets users annotate the plan // ("also update the README") without a reject+re-plan round-trip. - const [planFeedback, setPlanFeedback] = useState('') - const [pastedContents, setPastedContents] = useState< - Record - >({}) - const nextPasteIdRef = useRef(0) + const [planFeedback, setPlanFeedback] = useState(''); + const [pastedContents, setPastedContents] = useState>({}); + const nextPasteIdRef = useRef(0); - const showClearContext = - useAppState(s => s.settings.showClearContextOnPlanAccept) ?? false - const ultraplanSessionUrl = useAppState(s => s.ultraplanSessionUrl) - const ultraplanLaunching = useAppState(s => s.ultraplanLaunching) + const showClearContext = useAppState(s => s.settings.showClearContextOnPlanAccept) ?? false; + const ultraplanSessionUrl = useAppState(s => s.ultraplanSessionUrl); + const ultraplanLaunching = useAppState(s => s.ultraplanLaunching); // Hide the Ultraplan button while a session is active or launching — // selecting it would dismiss the dialog and reject locally before // launchUltraplan can notice the session exists and return "already polling". // feature() must sit directly in an if/ternary (bun:bundle DCE constraint). - const showUltraplan = feature('ULTRAPLAN') - ? !ultraplanSessionUrl && !ultraplanLaunching - : false - const usage = toolUseConfirm.assistantMessage.message.usage - const { mode, isAutoModeAvailable, isBypassPermissionsModeAvailable } = - toolPermissionContext + const showUltraplan = feature('ULTRAPLAN') ? !ultraplanSessionUrl && !ultraplanLaunching : false; + const usage = toolUseConfirm.assistantMessage.message.usage; + const { mode, isAutoModeAvailable, isBypassPermissionsModeAvailable } = toolPermissionContext; const options = useMemo( () => buildPlanApprovalOptions({ showClearContext, showUltraplan, - usedPercent: showClearContext - ? getContextUsedPercent(usage, mode) - : null, + usedPercent: showClearContext ? getContextUsedPercent(usage, mode) : null, isAutoModeAvailable, isBypassPermissionsModeAvailable, onFeedbackChange: setPlanFeedback, }), - [ - showClearContext, - showUltraplan, - usage, - mode, - isAutoModeAvailable, - isBypassPermissionsModeAvailable, - ], - ) + [showClearContext, showUltraplan, usage, mode, isAutoModeAvailable, isBypassPermissionsModeAvailable], + ); function onImagePaste( base64Image: string, @@ -259,7 +205,7 @@ export function ExitPlanModePermissionRequest({ dimensions?: ImageDimensions, _sourcePath?: string, ) { - const pasteId = nextPasteIdRef.current++ + const pasteId = nextPasteIdRef.current++; const newContent: PastedContent = { id: pasteId, type: 'image', @@ -267,129 +213,117 @@ export function ExitPlanModePermissionRequest({ mediaType: mediaType || 'image/png', filename: filename || 'Pasted image', dimensions, - } - cacheImagePath(newContent) - void storeImage(newContent) - setPastedContents(prev => ({ ...prev, [pasteId]: newContent })) + }; + cacheImagePath(newContent); + void storeImage(newContent); + setPastedContents(prev => ({ ...prev, [pasteId]: newContent })); } const onRemoveImage = useCallback((id: number) => { setPastedContents(prev => { - const next = { ...prev } - delete next[id] - return next - }) - }, []) + const next = { ...prev }; + delete next[id]; + return next; + }); + }, []); - const imageAttachments = Object.values(pastedContents).filter( - c => c.type === 'image', - ) - const hasImages = imageAttachments.length > 0 + const imageAttachments = Object.values(pastedContents).filter(c => c.type === 'image'); + const hasImages = imageAttachments.length > 0; // TODO: Delete the branch after moving to V2 // Use tool name to detect V2 instead of checking input.plan, because PR #10394 // injects plan content into input.plan for hooks/SDK, which broke the old detection // (see issue #10878) - const isV2 = toolUseConfirm.tool.name === EXIT_PLAN_MODE_V2_TOOL_NAME - const inputPlan = isV2 - ? undefined - : (toolUseConfirm.input.plan as string | undefined) - const planFilePath = isV2 ? getPlanFilePath() : undefined + const isV2 = toolUseConfirm.tool.name === EXIT_PLAN_MODE_V2_TOOL_NAME; + const inputPlan = isV2 ? undefined : (toolUseConfirm.input.plan as string | undefined); + const planFilePath = isV2 ? getPlanFilePath() : undefined; // Extract allowed prompts requested by the plan (Ant-only feature) - const allowedPrompts = toolUseConfirm.input.allowedPrompts as - | AllowedPrompt[] - | undefined + const allowedPrompts = toolUseConfirm.input.allowedPrompts as AllowedPrompt[] | undefined; // Get the raw plan to check if it's empty - const rawPlan = inputPlan ?? getPlan() - const isEmpty = !rawPlan || rawPlan.trim() === '' + const rawPlan = inputPlan ?? getPlan(); + const isEmpty = !rawPlan || rawPlan.trim() === ''; // Capture the variant once on mount. GrowthBook reads from a disk cache // so the value is stable across a single planning session. undefined = // control arm. The variant is a fixed 3-value enum of short literals, // not user input. const [planStructureVariant] = useState( - () => - (getPewterLedgerVariant() ?? - undefined) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - ) + () => (getPewterLedgerVariant() ?? undefined) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + ); const [currentPlan, setCurrentPlan] = useState(() => { - if (inputPlan) return inputPlan - const plan = getPlan() - return ( - plan ?? 'No plan found. Please write your plan to the plan file first.' - ) - }) - const [showSaveMessage, setShowSaveMessage] = useState(false) + if (inputPlan) return inputPlan; + const plan = getPlan(); + return plan ?? 'No plan found. Please write your plan to the plan file first.'; + }); + const [showSaveMessage, setShowSaveMessage] = useState(false); // Track Ctrl+G local edits so updatedInput can include the plan (the tool // only echoes the plan in tool_result when input.plan is set — otherwise // the model already has it in context from writing the plan file). - const [planEditedLocally, setPlanEditedLocally] = useState(false) + const [planEditedLocally, setPlanEditedLocally] = useState(false); // Auto-hide save message after 5 seconds useEffect(() => { if (showSaveMessage) { - const timer = setTimeout(setShowSaveMessage, 5000, false) - return () => clearTimeout(timer) + const timer = setTimeout(setShowSaveMessage, 5000, false); + return () => clearTimeout(timer); } - }, [showSaveMessage]) + }, [showSaveMessage]); // Handle Ctrl+G to edit plan in $EDITOR, Shift+Tab for auto-accept edits const handleKeyDown = (e: KeyboardEvent): void => { if (e.ctrl && e.key === 'g') { - e.preventDefault() - logEvent('tengu_plan_external_editor_used', {}) + e.preventDefault(); + logEvent('tengu_plan_external_editor_used', {}); void (async () => { if (isV2 && planFilePath) { - const result = await editFileInEditor(planFilePath) + const result = await editFileInEditor(planFilePath); if (result.error) { addNotification({ key: 'external-editor-error', text: result.error, color: 'warning', priority: 'high', - }) + }); } if (result.content !== null) { - if (result.content !== currentPlan) setPlanEditedLocally(true) - setCurrentPlan(result.content) - setShowSaveMessage(true) + if (result.content !== currentPlan) setPlanEditedLocally(true); + setCurrentPlan(result.content); + setShowSaveMessage(true); } } else { - const result = await editPromptInEditor(currentPlan) + const result = await editPromptInEditor(currentPlan); if (result.error) { addNotification({ key: 'external-editor-error', text: result.error, color: 'warning', priority: 'high', - }) + }); } if (result.content !== null && result.content !== currentPlan) { - setCurrentPlan(result.content) - setShowSaveMessage(true) + setCurrentPlan(result.content); + setShowSaveMessage(true); } } - })() - return + })(); + return; } // Shift+Tab immediately selects "auto-accept edits" if (e.shift && e.key === 'tab') { - e.preventDefault() - void handleResponse( - showClearContext ? 'yes-accept-edits' : 'yes-accept-edits-keep-context', - ) - return + e.preventDefault(); + void handleResponse(showClearContext ? 'yes-accept-edits' : 'yes-accept-edits-keep-context'); + return; } - } + }; async function handleResponse(value: ResponseValue): Promise { - const trimmedFeedback = planFeedback.trim() - const acceptFeedback = trimmedFeedback || undefined + const trimmedFeedback = planFeedback.trim(); + const acceptFeedback = trimmedFeedback || undefined; // Ultraplan: reject locally, teleport the plan to CCR as a seed draft. // Dialog dismisses immediately so the query loop unblocks; the teleport @@ -397,16 +331,13 @@ export function ExitPlanModePermissionRequest({ if (value === 'ultraplan') { logEvent('tengu_plan_exit', { planLengthChars: currentPlan.length, - outcome: - 'ultraplan' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + outcome: 'ultraplan' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), planStructureVariant, - }) - onDone() - onReject() - toolUseConfirm.onReject( - 'Plan being refined via Ultraplan — please wait for the result.', - ) + }); + onDone(); + onReject(); + toolUseConfirm.onReject('Plan being refined via Ultraplan — please wait for the result.'); void launchUltraplan({ blurb: '', seedPlan: currentPlan, @@ -414,86 +345,72 @@ export function ExitPlanModePermissionRequest({ setAppState: store.setState, signal: new AbortController().signal, }) - .then(msg => - enqueuePendingNotification({ value: msg, mode: 'task-notification' }), - ) - .catch(logError) - return + .then(msg => enqueuePendingNotification({ value: msg, mode: 'task-notification' })) + .catch(logError); + return; } // V1: pass plan in input. V2: plan is on disk, but if the user edited it // via Ctrl+G we pass it through so the tool echoes the edit in tool_result // (otherwise the model never sees the user's changes). - const updatedInput = isV2 && !planEditedLocally ? {} : { plan: currentPlan } + const updatedInput = isV2 && !planEditedLocally ? {} : { plan: currentPlan }; // If auto was active during plan (from auto mode or opt-in) and NOT going // to auto, deactivate auto + restore permissions + fire exit attachment. if (feature('TRANSCRIPT_CLASSIFIER')) { const goingToAuto = - (value === 'yes-resume-auto-mode' || - value === 'yes-auto-clear-context') && - isAutoModeGateEnabled() + (value === 'yes-resume-auto-mode' || value === 'yes-auto-clear-context') && isAutoModeGateEnabled(); // isAutoModeActive() is the authoritative signal — prePlanMode/ // strippedDangerousRules are stale after transitionPlanAutoMode // deactivates mid-plan (would cause duplicate exit attachment). - const autoWasUsedDuringPlan = - autoModeStateModule?.isAutoModeActive() ?? false + const autoWasUsedDuringPlan = autoModeStateModule?.isAutoModeActive() ?? false; if (value !== 'no' && !goingToAuto && autoWasUsedDuringPlan) { - autoModeStateModule?.setAutoModeActive(false) - setNeedsAutoModeExitAttachment(true) + autoModeStateModule?.setAutoModeActive(false); + setNeedsAutoModeExitAttachment(true); setAppState(prev => ({ ...prev, toolPermissionContext: { ...restoreDangerousPermissions(prev.toolPermissionContext), prePlanMode: undefined, }, - })) + })); } } // Clear-context options: set pending plan implementation and reject the dialog // The REPL will handle context clear and trigger a fresh query // Keep-context options skip this block and go through the normal flow below - const isResumeAutoOption = feature('TRANSCRIPT_CLASSIFIER') - ? value === 'yes-resume-auto-mode' - : false + const isResumeAutoOption = feature('TRANSCRIPT_CLASSIFIER') ? value === 'yes-resume-auto-mode' : false; const isKeepContextOption = - value === 'yes-accept-edits-keep-context' || - value === 'yes-default-keep-context' || - isResumeAutoOption + value === 'yes-accept-edits-keep-context' || value === 'yes-default-keep-context' || isResumeAutoOption; if (value !== 'no') { - autoNameSessionFromPlan(currentPlan, setAppState, !isKeepContextOption) + autoNameSessionFromPlan(currentPlan, setAppState, !isKeepContextOption); } if (value !== 'no' && !isKeepContextOption) { // Determine the permission mode based on the selected option - let mode: PermissionMode = 'default' + let mode: PermissionMode = 'default'; if (value === 'yes-bypass-permissions') { - mode = 'bypassPermissions' + mode = 'bypassPermissions'; } else if (value === 'yes-accept-edits') { - mode = 'acceptEdits' - } else if ( - feature('TRANSCRIPT_CLASSIFIER') && - value === 'yes-auto-clear-context' && - isAutoModeGateEnabled() - ) { + mode = 'acceptEdits'; + } else if (feature('TRANSCRIPT_CLASSIFIER') && value === 'yes-auto-clear-context' && isAutoModeGateEnabled()) { // REPL's processInitialMessage handles stripDangerousPermissions + mode, // but does NOT set autoModeActive. Gate-off falls through to 'default'. - mode = 'auto' - autoModeStateModule?.setAutoModeActive(true) + mode = 'auto'; + autoModeStateModule?.setAutoModeActive(true); } // Log plan exit event logEvent('tengu_plan_exit', { planLengthChars: currentPlan.length, - outcome: - value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + outcome: value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, clearContext: true, interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), planStructureVariant, hasFeedback: !!acceptFeedback, - }) + }); // Set initial message - REPL will handle context clear and fresh query // Add verification instruction if the feature is enabled @@ -501,19 +418,17 @@ export function ExitPlanModePermissionRequest({ const verificationInstruction = undefined === 'true' ? `\n\nIMPORTANT: When you have finished implementing the plan, you MUST call the "VerifyPlanExecution" tool directly (NOT the ${AGENT_TOOL_NAME} tool or an agent) to trigger background verification.` - : '' + : ''; // Capture the transcript path before context is cleared (session ID will be regenerated) - const transcriptPath = getTranscriptPath() - const transcriptHint = `\n\nIf you need specific details from before exiting plan mode (like exact code snippets, error messages, or content you generated), read the full transcript at: ${transcriptPath}` + const transcriptPath = getTranscriptPath(); + const transcriptHint = `\n\nIf you need specific details from before exiting plan mode (like exact code snippets, error messages, or content you generated), read the full transcript at: ${transcriptPath}`; const teamHint = isAgentSwarmsEnabled() ? `\n\nIf this plan can be broken down into multiple independent tasks, consider using the ${TEAM_CREATE_TOOL_NAME} tool to create a team and parallelize the work.` - : '' + : ''; - const feedbackSuffix = acceptFeedback - ? `\n\nUser feedback on this plan: ${acceptFeedback}` - : '' + const feedbackSuffix = acceptFeedback ? `\n\nUser feedback on this plan: ${acceptFeedback}` : ''; setAppState(prev => ({ ...prev, @@ -528,37 +443,32 @@ export function ExitPlanModePermissionRequest({ mode, allowedPrompts, }, - })) + })); - setHasExitedPlanMode(true) - onDone() - onReject() + setHasExitedPlanMode(true); + onDone(); + onReject(); // Reject the tool use to unblock the query loop // The REPL will see pendingInitialQuery and trigger fresh query - toolUseConfirm.onReject() - return + toolUseConfirm.onReject(); + return; } // Handle auto keep-context option — needs special handling because // buildPermissionUpdates maps auto to 'default' via toExternalPermissionMode. // We set the mode directly via setAppState and sync the bootstrap state. - if ( - feature('TRANSCRIPT_CLASSIFIER') && - value === 'yes-resume-auto-mode' && - isAutoModeGateEnabled() - ) { + if (feature('TRANSCRIPT_CLASSIFIER') && value === 'yes-resume-auto-mode' && isAutoModeGateEnabled()) { logEvent('tengu_plan_exit', { planLengthChars: currentPlan.length, - outcome: - value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + outcome: value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, clearContext: false, interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), planStructureVariant, hasFeedback: !!acceptFeedback, - }) - setHasExitedPlanMode(true) - setNeedsPlanModeExitAttachment(true) - autoModeStateModule?.setAutoModeActive(true) + }); + setHasExitedPlanMode(true); + setNeedsPlanModeExitAttachment(true); + autoModeStateModule?.setAutoModeActive(true); setAppState(prev => ({ ...prev, toolPermissionContext: stripDangerousPermissionsForAutoMode({ @@ -566,10 +476,10 @@ export function ExitPlanModePermissionRequest({ mode: 'auto', prePlanMode: undefined, }), - })) - onDone() - toolUseConfirm.onAllow(updatedInput, [], acceptFeedback) - return + })); + onDone(); + toolUseConfirm.onAllow(updatedInput, [], acceptFeedback); + return; } // Handle keep-context options (goes through normal onAllow flow) @@ -578,80 +488,66 @@ export function ExitPlanModePermissionRequest({ // Without this fallback the function would return without resolving the // dialog, leaving the query loop blocked and safety state corrupted. const keepContextModes: Record = { - 'yes-accept-edits-keep-context': - toolPermissionContext.isBypassPermissionsModeAvailable - ? 'bypassPermissions' - : 'acceptEdits', + 'yes-accept-edits-keep-context': toolPermissionContext.isBypassPermissionsModeAvailable + ? 'bypassPermissions' + : 'acceptEdits', 'yes-default-keep-context': 'default', - ...(feature('TRANSCRIPT_CLASSIFIER') - ? { 'yes-resume-auto-mode': 'default' as const } - : {}), - } - const keepContextMode = keepContextModes[value] + ...(feature('TRANSCRIPT_CLASSIFIER') ? { 'yes-resume-auto-mode': 'default' as const } : {}), + }; + const keepContextMode = keepContextModes[value]; if (keepContextMode) { logEvent('tengu_plan_exit', { planLengthChars: currentPlan.length, - outcome: - value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + outcome: value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, clearContext: false, interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), planStructureVariant, hasFeedback: !!acceptFeedback, - }) - setHasExitedPlanMode(true) - setNeedsPlanModeExitAttachment(true) - onDone() - toolUseConfirm.onAllow( - updatedInput, - buildPermissionUpdates(keepContextMode, allowedPrompts), - acceptFeedback, - ) - return + }); + setHasExitedPlanMode(true); + setNeedsPlanModeExitAttachment(true); + onDone(); + toolUseConfirm.onAllow(updatedInput, buildPermissionUpdates(keepContextMode, allowedPrompts), acceptFeedback); + return; } // Handle standard approval options const standardModes: Record = { 'yes-bypass-permissions': 'bypassPermissions', 'yes-accept-edits': 'acceptEdits', - } - const standardMode = standardModes[value] + }; + const standardMode = standardModes[value]; if (standardMode) { logEvent('tengu_plan_exit', { planLengthChars: currentPlan.length, - outcome: - value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + outcome: value as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), planStructureVariant, hasFeedback: !!acceptFeedback, - }) - setHasExitedPlanMode(true) - setNeedsPlanModeExitAttachment(true) - onDone() - toolUseConfirm.onAllow( - updatedInput, - buildPermissionUpdates(standardMode, allowedPrompts), - acceptFeedback, - ) - return + }); + setHasExitedPlanMode(true); + setNeedsPlanModeExitAttachment(true); + onDone(); + toolUseConfirm.onAllow(updatedInput, buildPermissionUpdates(standardMode, allowedPrompts), acceptFeedback); + return; } // Handle 'no' - stay in plan mode if (value === 'no') { if (!trimmedFeedback && !hasImages) { // No feedback yet - user is still on the input field - return + return; } logEvent('tengu_plan_exit', { planLengthChars: currentPlan.length, - outcome: - 'no' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + outcome: 'no' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), planStructureVariant, - }) + }); // Convert pasted images to ImageBlockParam[] with resizing - let imageBlocks: ImageBlockParam[] | undefined + let imageBlocks: ImageBlockParam[] | undefined; if (hasImages) { imageBlocks = await Promise.all( imageAttachments.map(async img => { @@ -659,28 +555,27 @@ export function ExitPlanModePermissionRequest({ type: 'image', source: { type: 'base64', - media_type: (img.mediaType || - 'image/png') as Base64ImageSource['media_type'], + media_type: (img.mediaType || 'image/png') as Base64ImageSource['media_type'], data: img.content, }, - } - const resized = await maybeResizeAndDownsampleImageBlock(block) - return resized.block + }; + const resized = await maybeResizeAndDownsampleImageBlock(block); + return resized.block; }), - ) + ); } - onDone() - onReject() + onDone(); + onReject(); toolUseConfirm.onReject( trimmedFeedback || (hasImages ? '(See attached image)' : undefined), imageBlocks && imageBlocks.length > 0 ? imageBlocks : undefined, - ) + ); } } - const editor = getExternalEditor() - const editorName = editor ? toIDEDisplayName(editor) : null + const editor = getExternalEditor(); + const editorName = editor ? toIDEDisplayName(editor) : null; // Sticky footer: when setStickyFooter is provided (fullscreen mode), the // Select options render in FullscreenLayout's `bottom` slot so they stay @@ -688,24 +583,23 @@ export function ExitPlanModePermissionRequest({ // wrapped in a ref so the JSX (set once per options/images change) can call // the latest closure without re-registering on every keystroke. React // reconciles the sticky-footer Select by type, preserving focus/input state. - const handleResponseRef = useRef(handleResponse) - handleResponseRef.current = handleResponse - const handleCancelRef = useRef<() => void>(undefined) + const handleResponseRef = useRef(handleResponse); + handleResponseRef.current = handleResponse; + const handleCancelRef = useRef<() => void>(undefined); handleCancelRef.current = () => { logEvent('tengu_plan_exit', { planLengthChars: currentPlan.length, - outcome: - 'no' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + outcome: 'no' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), planStructureVariant, - }) - onDone() - onReject() - toolUseConfirm.onReject() - } - const useStickyFooter = !isEmpty && !!setStickyFooter + }); + onDone(); + onReject(); + toolUseConfirm.onReject(); + }; + const useStickyFooter = !isEmpty && !!setStickyFooter; useLayoutEffect(() => { - if (!useStickyFooter) return + if (!useStickyFooter) return; setStickyFooter( {editorName} - {isV2 && planFilePath && ( - · {getDisplayPath(planFilePath)} - )} + {isV2 && planFilePath && · {getDisplayPath(planFilePath)}} {showSaveMessage && ( <> {' · '} @@ -745,20 +637,11 @@ export function ExitPlanModePermissionRequest({ )} , - ) - return () => setStickyFooter(null) + ); + return () => setStickyFooter(null); // onImagePaste/onRemoveImage are stable (useCallback/useRef-backed above) // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ - useStickyFooter, - setStickyFooter, - options, - pastedContents, - editorName, - isV2, - planFilePath, - showSaveMessage, - ]) + }, [useStickyFooter, setStickyFooter, options, pastedContents, editorName, isV2, planFilePath, showSaveMessage]); // Simplified UI for empty plans if (isEmpty) { @@ -766,52 +649,43 @@ export function ExitPlanModePermissionRequest({ if (value === 'yes') { logEvent('tengu_plan_exit', { planLengthChars: 0, - outcome: - 'yes-default' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + outcome: 'yes-default' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), planStructureVariant, - }) + }); if (feature('TRANSCRIPT_CLASSIFIER')) { - const autoWasUsedDuringPlan = - autoModeStateModule?.isAutoModeActive() ?? false + const autoWasUsedDuringPlan = autoModeStateModule?.isAutoModeActive() ?? false; if (autoWasUsedDuringPlan) { - autoModeStateModule?.setAutoModeActive(false) - setNeedsAutoModeExitAttachment(true) + autoModeStateModule?.setAutoModeActive(false); + setNeedsAutoModeExitAttachment(true); setAppState(prev => ({ ...prev, toolPermissionContext: { ...restoreDangerousPermissions(prev.toolPermissionContext), prePlanMode: undefined, }, - })) + })); } } - setHasExitedPlanMode(true) - setNeedsPlanModeExitAttachment(true) - onDone() - toolUseConfirm.onAllow({}, [ - { type: 'setMode', mode: 'default', destination: 'session' }, - ]) + setHasExitedPlanMode(true); + setNeedsPlanModeExitAttachment(true); + onDone(); + toolUseConfirm.onAllow({}, [{ type: 'setMode', mode: 'default', destination: 'session' }]); } else { logEvent('tengu_plan_exit', { planLengthChars: 0, - outcome: - 'no' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + outcome: 'no' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), planStructureVariant, - }) - onDone() - onReject() - toolUseConfirm.onReject() + }); + onDone(); + onReject(); + toolUseConfirm.onReject(); } } return ( - + Claude wants to exit plan mode @@ -824,35 +698,24 @@ export function ExitPlanModePermissionRequest({ onCancel={() => { logEvent('tengu_plan_exit', { planLengthChars: 0, - outcome: - 'no' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + outcome: 'no' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), planStructureVariant, - }) - onDone() - onReject() - toolUseConfirm.onReject() + }); + onDone(); + onReject(); + toolUseConfirm.onReject(); }} /> - ) + ); } return ( - - + + Here is Claude's plan: @@ -871,28 +734,20 @@ export function ExitPlanModePermissionRequest({ {currentPlan} - - {isClassifierPermissionsEnabled() && - allowedPrompts && - allowedPrompts.length > 0 && ( - - Requested permissions: - {allowedPrompts.map((p, i) => ( - - {' '}· {p.tool}({PROMPT_PREFIX} {p.prompt}) - - ))} - - )} + + {isClassifierPermissionsEnabled() && allowedPrompts && allowedPrompts.length > 0 && ( + + Requested permissions: + {allowedPrompts.map((p, i) => ( + + {' '}· {p.tool}({PROMPT_PREFIX} {p.prompt}) + + ))} + + )} {!useStickyFooter && ( <> - - Claude has written up a plan and is ready to execute. Would - you like to proceed? - + Claude has written up a plan and is ready to execute. Would you like to proceed? = { - 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 +127,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 +197,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 953cca22b..f7312494a 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 '../../ink.js' -import type { Theme } from '../../utils/theme.js' -import type { WorkerBadgeProps } from './WorkerBadge.js' +import * as React from 'react'; +import { Box, Text } from '../../ink.js'; +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 406f7e3b8..0a84c27eb 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 '../../ink.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 ThemedText from '../design-system/ThemedText.js' +import { feature } from 'bun:bundle'; +import chalk from 'chalk'; +import React from 'react'; +import { Ansi, Box, Text } from '../../ink.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 ThemedText from '../design-system/ThemedText.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 5dcd0e488..e7dd4f8eb 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 '../../../ink.js' -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 '../../../ink.js'; +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 '../../../tools/PowerShellTool/destructiveCommandWarning.js' -import { PowerShellTool } from '../../../tools/PowerShellTool/PowerShellTool.js' -import { isAllowlistedCommand } from '../../../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 '../../../tools/PowerShellTool/destructiveCommandWarning.js'; +import { PowerShellTool } from '../../../tools/PowerShellTool/PowerShellTool.js'; +import { isAllowlistedCommand } from '../../../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 2ad089efe..6edc212b6 100644 --- a/src/components/permissions/PowerShellPermissionRequest/powershellToolUseOptions.tsx +++ b/src/components/permissions/PowerShellPermissionRequest/powershellToolUseOptions.tsx @@ -1,14 +1,10 @@ -import { POWERSHELL_TOOL_NAME } from '../../../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 '../../../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.ts b/src/components/permissions/ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.ts index a812ebe9d..d87b88dde 100644 --- a/src/components/permissions/ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.ts +++ b/src/components/permissions/ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.ts @@ -1,3 +1,5 @@ // Auto-generated stub — replace with real implementation -export {}; -export const ReviewArtifactPermissionRequest: (props: Record) => null = () => null; +export {} +export const ReviewArtifactPermissionRequest: ( + props: Record, +) => null = () => null diff --git a/src/components/permissions/SandboxPermissionRequest.tsx b/src/components/permissions/SandboxPermissionRequest.tsx index 9dc4d6629..9a65599e2 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 'src/ink.js' -import { - type NetworkHostPattern, - shouldAllowManagedSandboxDomainsOnly, -} from 'src/utils/sandbox/sandbox-adapter.js' +import * as React from 'react'; +import { Box, Text } from 'src/ink.js'; +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 209cd08f4..13810d6aa 100644 --- a/src/components/permissions/SedEditPermissionRequest/SedEditPermissionRequest.tsx +++ b/src/components/permissions/SedEditPermissionRequest/SedEditPermissionRequest.tsx @@ -1,30 +1,24 @@ -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 '../../../ink.js' -import { BashTool } from '../../../tools/BashTool/BashTool.js' -import { - applySedSubstitution, - type SedEditInfo, -} from '../../../tools/BashTool/sedEditParser.js' -import { FilePermissionDialog } from '../FilePermissionDialog/FilePermissionDialog.js' -import type { PermissionRequestProps } from '../PermissionRequest.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 '../../../ink.js'; +import { BashTool } from '../../../tools/BashTool/BashTool.js'; +import { applySedSubstitution, type SedEditInfo } from '../../../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 +29,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 +54,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 +75,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 +124,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 799c88705..4d811600d 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 '../../../ink.js' -import { sanitizeToolNameForAnalytics } from '../../../services/analytics/metadata.js' -import { SKILL_TOOL_NAME } from '../../../tools/SkillTool/constants.js' -import { SkillTool } from '../../../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' - -export function SkillPermissionRequest( - props: PermissionRequestProps, -): React.ReactNode { - const { - toolUseConfirm, - onDone, - onReject, - verbose: _verbose, - workerBadge, - } = props +import React, { useCallback, useMemo } from 'react'; +import { logError } from 'src/utils/log.js'; +import { getOriginalCwd } from '../../../bootstrap/state.js'; +import { Box, Text } from '../../../ink.js'; +import { sanitizeToolNameForAnalytics } from '../../../services/analytics/metadata.js'; +import { SKILL_TOOL_NAME } from '../../../tools/SkillTool/constants.js'; +import { SkillTool } from '../../../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'; + +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 da2498885..bfe2fe139 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 '../../../ink.js' -import { WebFetchTool } from '../../../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 '../../../ink.js'; +import { WebFetchTool } from '../../../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 61d5873ab..cc92ade7f 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 '../../ink.js' -import { toInkColor } from '../../utils/ink.js' +import * as React from 'react'; +import { BLACK_CIRCLE } from '../../constants/figures.js'; +import { Box, Text } from '../../ink.js'; +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 06aab0334..5ac76efa7 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 '../../ink.js' -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 '../../ink.js'; +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 6e48e1dcb..4fd30f779 100644 --- a/src/components/permissions/rules/AddPermissionRules.tsx +++ b/src/components/permissions/rules/AddPermissionRules.tsx @@ -1,66 +1,55 @@ -import * as React from 'react' -import { useCallback } from 'react' -import { Select } from '../../../components/CustomSelect/select.js' -import { Box, Text } from '../../../ink.js' -import type { ToolPermissionContext } from '../../../Tool.js' +import * as React from 'react'; +import { useCallback } from 'react'; +import { Select } from '../../../components/CustomSelect/select.js'; +import { Box, Text } from '../../../ink.js'; +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 { Dialog } from '../../design-system/Dialog.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 { Dialog } from '../../design-system/Dialog.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, @@ -70,22 +59,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({ @@ -93,59 +82,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)} @@ -154,12 +127,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 0dab0c7d0..f2bda84a8 100644 --- a/src/components/permissions/rules/WorkspaceTab.tsx +++ b/src/components/permissions/rules/WorkspaceTab.tsx @@ -1,29 +1,26 @@ -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 } from '../../../ink.js' -import type { ToolPermissionContext } from '../../../Tool.js' -import { useTabHeaderFocus } from '../../design-system/Tabs.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 } from '../../../ink.js'; +import type { ToolPermissionContext } from '../../../Tool.js'; +import { useTabHeaderFocus } from '../../design-system/Tabs.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, @@ -32,57 +29,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 ( @@ -101,5 +91,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 2c7a2db95..4db2485f0 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 '../../ink.js' -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 '../../ink.js'; +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 58bfba688..7ad014033 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 '../../ink.js' -import { - SandboxManager, - shouldAllowManagedSandboxDomainsOnly, -} from '../../utils/sandbox/sandbox-adapter.js' +import * as React from 'react'; +import { Box, Text } from '../../ink.js'; +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 75091910d..fc16dbe36 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 '../../ink.js' -import { getPlatform } from '../../utils/platform.js' -import type { SandboxDependencyCheck } from '../../utils/sandbox/sandbox-adapter.js' +import React from 'react'; +import { Box, Text } from '../../ink.js'; +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 5e7198c38..cc12fd4f8 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 '../../ink.js' -import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' +import React from 'react'; +import { Box, Text } from '../../ink.js'; +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 74c6d224b..aa2924899 100644 --- a/src/components/sandbox/SandboxOverridesTab.tsx +++ b/src/components/sandbox/SandboxOverridesTab.tsx @@ -1,102 +1,79 @@ -import React from 'react' -import { Box, color, Link, Text, useTheme } from '../../ink.js' -import type { CommandResultDisplay } from '../../types/command.js' -import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' -import { Select } from '../CustomSelect/select.js' -import { useTabHeaderFocus } from '../design-system/Tabs.js' +import React from 'react'; +import { Box, color, Link, Text, useTheme } from '../../ink.js'; +import type { CommandResultDisplay } from '../../types/command.js'; +import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'; +import { Select } from '../CustomSelect/select.js'; +import { useTabHeaderFocus } from '../design-system/Tabs.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 ( @@ -116,16 +93,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:{' '} @@ -135,5 +111,5 @@ function OverridesSelect({ - ) + ); } diff --git a/src/components/sandbox/SandboxSettings.tsx b/src/components/sandbox/SandboxSettings.tsx index 05998577b..e4afe5531 100644 --- a/src/components/sandbox/SandboxSettings.tsx +++ b/src/components/sandbox/SandboxSettings.tsx @@ -1,49 +1,43 @@ -import React from 'react' -import { Box, color, Link, Text, useTheme } from '../../ink.js' -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 { Pane } from '../design-system/Pane.js' -import { Tab, Tabs, useTabHeaderFocus } from '../design-system/Tabs.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 } from '../../ink.js'; +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 { Pane } from '../design-system/Pane.js'; +import { Tab, Tabs, useTabHeaderFocus } from '../design-system/Tabs.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 -} - -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 + onComplete: (result?: string, options?: { display?: CommandResultDisplay }) => void; + depCheck: SandboxDependencyCheck; +}; + +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; // 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 = [ { @@ -61,39 +55,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; } } @@ -102,7 +93,7 @@ export function SandboxSettings({ 'confirm:no': () => onComplete(undefined, { display: 'skip' }), }, { context: 'Settings' }, - ) + ); const modeTab = ( @@ -113,21 +104,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 @@ -148,7 +139,7 @@ export function SandboxSettings({ : []), overridesTab, configTab, - ] + ]; return ( @@ -156,7 +147,7 @@ export function SandboxSettings({ {tabs} - ) + ); } function SandboxModeTab({ @@ -165,19 +156,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) )} @@ -195,17 +184,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 cf72760db..93493fc75 100644 --- a/src/components/shell/OutputLine.tsx +++ b/src/components/shell/OutputLine.tsx @@ -1,53 +1,53 @@ -import * as React from 'react' -import { useMemo } from 'react' -import { useTerminalSize } from '../../hooks/useTerminalSize.js' -import { Ansi, Text } from '../../ink.js' -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 * as React from 'react'; +import { useMemo } from 'react'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import { Ansi, Text } from '../../ink.js'; +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'; 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({ @@ -57,34 +57,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 ( @@ -92,7 +90,7 @@ export function OutputLine({ {formattedContent} - ) + ); } /** @@ -107,5 +105,5 @@ export function stripUnderlineAnsi(content: string): string { // eslint-disable-next-line no-control-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 99da5ac3b..d1de9611b 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 '../../ink.js' -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 '../../ink.js'; +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 7e619dfba..ae7aa0ecd 100644 --- a/src/components/shell/ShellTimeDisplay.tsx +++ b/src/components/shell/ShellTimeDisplay.tsx @@ -1,28 +1,23 @@ -import React from 'react' -import { Text } from '../../ink.js' -import { formatDuration } from '../../utils/format.js' +import React from 'react'; +import { Text } from '../../ink.js'; +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 f8f2896a6..5e908d7eb 100644 --- a/src/components/skills/SkillsMenu.tsx +++ b/src/components/skills/SkillsMenu.tsx @@ -1,55 +1,43 @@ -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 '../../ink.js' -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 '../design-system/Dialog.js' +} from '../../commands.js'; +import { Box, Text } from '../../ink.js'; +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 '../design-system/Dialog.js'; // 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') { @@ -57,21 +45,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 { @@ -84,8 +68,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 = { @@ -96,74 +80,58 @@ 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 renderSkill = (skill: SkillCommand) => { - const estimatedTokens = estimateSkillFrontmatterTokens(skill) - const tokenDisplay = `~${formatTokens(estimatedTokens)}` - const pluginName = - skill.source === 'plugin' - ? skill.pluginInfo?.pluginManifest.name - : undefined + const estimatedTokens = estimateSkillFrontmatterTokens(skill); + const tokenDisplay = `~${formatTokens(estimatedTokens)}`; + const pluginName = skill.source === 'plugin' ? skill.pluginInfo?.pluginManifest.name : undefined; return ( {getCommandName(skill)} - {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 ( @@ -175,8 +143,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 4174d4fa5..6d98d286a 100644 --- a/src/components/tasks/AsyncAgentDetailDialog.tsx +++ b/src/components/tasks/AsyncAgentDetailDialog.tsx @@ -1,45 +1,35 @@ -import React, { useMemo } from 'react' -import type { DeepImmutable } from 'src/types/utils.js' -import { useElapsedTime } from '../../hooks/useElapsedTime.js' -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' -import { Box, Text, useTheme } from '../../ink.js' -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 } from '../design-system/Byline.js' -import { Dialog } from '../design-system/Dialog.js' -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' -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 } from '../../ink/events/keyboard-event.js'; +import { Box, Text, useTheme } from '../../ink.js'; +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 } from '../design-system/Byline.js'; +import { Dialog } from '../design-system/Dialog.js'; +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; +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. @@ -48,7 +38,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 @@ -57,36 +47,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 = ( @@ -94,19 +79,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 && ( <> {' '} @@ -115,15 +94,10 @@ export function AsyncAgentDetailDialog({ )} - ) + ); return ( - + {onBack && } - {agent.status === 'running' && onKillAgent && ( - - )} + {agent.status === 'running' && onKillAgent && } ) } @@ -153,14 +125,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)} ))} @@ -196,5 +162,5 @@ export function AsyncAgentDetailDialog({ - ) + ); } diff --git a/src/components/tasks/BackgroundTask.tsx b/src/components/tasks/BackgroundTask.tsx index fd48d09e7..5cedbc77c 100644 --- a/src/components/tasks/BackgroundTask.tsx +++ b/src/components/tasks/BackgroundTask.tsx @@ -1,37 +1,30 @@ -import * as React from 'react' -import { Text } from 'src/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 { toInkColor } from 'src/utils/ink.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 * as React from 'react'; +import { Text } from 'src/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 { toInkColor } from 'src/utils/ink.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. @@ -40,9 +33,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} @@ -50,7 +43,7 @@ export function BackgroundTask({ · - ) + ); } case 'local_agent': return ( @@ -59,33 +52,23 @@ 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': return ( - {truncate( - task.workflowName ?? task.summary ?? task.description, - activityLimit, - true, - )}{' '} + {truncate(task.workflowName ?? task.summary ?? task.description, activityLimit, true)}{' '} - ) + ); case 'monitor_mcp': return ( @@ -110,20 +89,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}{' '} @@ -133,14 +108,10 @@ export function BackgroundTask({ - ) + ); } } } diff --git a/src/components/tasks/BackgroundTaskStatus.tsx b/src/components/tasks/BackgroundTaskStatus.tsx index 26d46cf98..89773663d 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 'src/ink/stringWidth.js' -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 '../../ink.js' +import figures from 'figures'; +import * as React from 'react'; +import { useMemo, useState } from 'react'; +import { useTerminalSize } from 'src/hooks/useTerminalSize.js'; +import { stringWidth } from 'src/ink/stringWidth.js'; +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 '../../ink.js'; import { AGENT_COLOR_TO_THEME_COLOR, AGENT_COLORS, type AgentColorName, -} from '../../tools/AgentTool/agentColorManager.js' -import type { Theme } from '../../utils/theme.js' -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' -import { shouldHideTasksFooter } from './taskStatusUtils.js' +} from '../../tools/AgentTool/agentColorManager.js'; +import type { Theme } from '../../utils/theme.js'; +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; +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 d9f119cf1..3f7bd02e3 100644 --- a/src/components/tasks/BackgroundTasksDialog.tsx +++ b/src/components/tasks/BackgroundTasksDialog.tsx @@ -1,160 +1,133 @@ -import { feature } from 'bun:bundle' -import figures from 'figures' -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 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 } from '../../ink/events/keyboard-event.js' -import { Box, Text } from '../../ink.js' -import { useKeybindings } from '../../keybindings/useKeybinding.js' -import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js' -import { count } from '../../utils/array.js' -import { Byline } from '../design-system/Byline.js' -import { Dialog } from '../design-system/Dialog.js' -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' -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 } +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 } from '../../ink/events/keyboard-event.js'; +import { Box, Text } from '../../ink.js'; +import { useKeybindings } from '../../keybindings/useKeybinding.js'; +import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js'; +import { count } from '../../utils/array.js'; +import { Byline } from '../design-system/Byline.js'; +import { Dialog } from '../design-system/Dialog.js'; +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; +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 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) @@ -162,53 +135,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 { @@ -222,32 +182,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 @@ -259,7 +213,7 @@ export function BackgroundTasksDialog({ status: 'running', }, ] - : [] + : []; return { bashTasks: bash, remoteSessions: remote, @@ -281,167 +235,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 @@ -449,18 +371,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 @@ -474,7 +396,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 ( killWorkflowTask(task.id, setAppState) - : undefined + task.status === 'running' && killWorkflowTask ? () => killWorkflowTask(task.id, setAppState) : undefined } onSkipAgent={ task.status === 'running' && skipWorkflowAgent @@ -549,21 +464,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' || @@ -646,34 +543,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}} @@ -687,64 +572,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 => ( - + ))} @@ -753,25 +614,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 => ( - + ))} @@ -794,11 +644,7 @@ export function BackgroundTasksDialog({ {agentTasks.map(item => ( - + ))} @@ -822,11 +668,7 @@ export function BackgroundTasksDialog({ {workflowTasks.map(item => ( - + ))} @@ -848,11 +690,7 @@ export function BackgroundTasksDialog({ > {dreamTasks.map(item => ( - + ))} @@ -861,7 +699,7 @@ export function BackgroundTasksDialog({ )} - ) + ); } function toListItem(task: BackgroundTaskState): ListItem { @@ -873,7 +711,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, @@ -881,7 +719,7 @@ function toListItem(task: BackgroundTaskState): ListItem { label: task.title, status: task.status, task, - } + }; case 'local_agent': return { id: task.id, @@ -889,7 +727,7 @@ function toListItem(task: BackgroundTaskState): ListItem { label: task.description, status: task.status, task, - } + }; case 'in_process_teammate': return { id: task.id, @@ -897,7 +735,7 @@ function toListItem(task: BackgroundTaskState): ListItem { label: `@${task.identity.agentName}`, status: task.status, task, - } + }; case 'local_workflow': return { id: task.id, @@ -905,7 +743,7 @@ function toListItem(task: BackgroundTaskState): ListItem { label: task.summary ?? task.description, status: task.status, task, - } + }; case 'monitor_mcp': return { id: task.id, @@ -913,7 +751,7 @@ function toListItem(task: BackgroundTaskState): ListItem { label: task.description, status: task.status, task, - } + }; case 'dream': return { id: task.id, @@ -921,69 +759,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 ( @@ -991,22 +816,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 bea310946..30bad5ea3 100644 --- a/src/components/tasks/DreamDetailDialog.tsx +++ b/src/components/tasks/DreamDetailDialog.tsx @@ -1,78 +1,61 @@ -import React from 'react' -import type { DeepImmutable } from 'src/types/utils.js' -import { useElapsedTime } from '../../hooks/useElapsedTime.js' -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' -import { Box, Text } from '../../ink.js' -import { useKeybindings } from '../../keybindings/useKeybinding.js' -import type { DreamTaskState } from '../../tasks/DreamTask/DreamTask.js' -import { plural } from '../../utils/stringUtils.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 type { KeyboardEvent } from '../../ink/events/keyboard-event.js'; +import { Box, Text } from '../../ink.js'; +import { useKeybindings } from '../../keybindings/useKeybinding.js'; +import type { DreamTaskState } from '../../tasks/DreamTask/DreamTask.js'; +import { plural } from '../../utils/stringUtils.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 - 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 )} @@ -86,9 +69,7 @@ export function DreamDetailDialog({ {onBack && } - {task.status === 'running' && onKill && ( - - )} + {task.status === 'running' && onKill && } ) } @@ -106,9 +87,7 @@ export function DreamDetailDialog({ {shown.length === 0 ? ( - - {task.status === 'running' ? 'Starting…' : '(no text output)'} - + {task.status === 'running' ? 'Starting…' : '(no text output)'} ) : ( <> {hidden > 0 && ( @@ -121,8 +100,7 @@ export function DreamDetailDialog({ {turn.text} {turn.toolUseCount > 0 && ( - {' '}({turn.toolUseCount}{' '} - {plural(turn.toolUseCount, 'tool')}) + {' '}({turn.toolUseCount} {plural(turn.toolUseCount, 'tool')}) )} @@ -132,5 +110,5 @@ export function DreamDetailDialog({ - ) + ); } diff --git a/src/components/tasks/InProcessTeammateDetailDialog.tsx b/src/components/tasks/InProcessTeammateDetailDialog.tsx index b59bbbd5e..47417e5d0 100644 --- a/src/components/tasks/InProcessTeammateDetailDialog.tsx +++ b/src/components/tasks/InProcessTeammateDetailDialog.tsx @@ -1,27 +1,27 @@ -import React, { useMemo } from 'react' -import type { DeepImmutable } from 'src/types/utils.js' -import { useElapsedTime } from '../../hooks/useElapsedTime.js' -import type { KeyboardEvent } from '../../ink/events/keyboard-event.js' -import { Box, Text, useTheme } from '../../ink.js' -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 { toInkColor } from '../../utils/ink.js' -import { Byline } from '../design-system/Byline.js' -import { Dialog } from '../design-system/Dialog.js' -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' -import { renderToolActivity } from './renderToolActivity.js' -import { describeTeammateActivity } 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 } from '../../ink/events/keyboard-event.js'; +import { Box, Text, useTheme } from '../../ink.js'; +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 { toInkColor } from '../../utils/ink.js'; +import { Byline } from '../design-system/Byline.js'; +import { Dialog } from '../design-system/Dialog.js'; +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.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, @@ -29,15 +29,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( @@ -45,67 +45,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 && ( <> {' '} @@ -114,15 +96,10 @@ export function InProcessTeammateDetailDialog({ )} - ) + ); return ( - + {onBack && } - {teammate.status === 'running' && onKill && ( - - )} + {teammate.status === 'running' && onKill && } {teammate.status === 'running' && onForeground && ( )} @@ -154,14 +129,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)} ))} @@ -189,5 +158,5 @@ export function InProcessTeammateDetailDialog({ )} - ) + ); } diff --git a/src/components/tasks/MonitorMcpDetailDialog.ts b/src/components/tasks/MonitorMcpDetailDialog.ts index fca5d572b..4bbd06ccf 100644 --- a/src/components/tasks/MonitorMcpDetailDialog.ts +++ b/src/components/tasks/MonitorMcpDetailDialog.ts @@ -1,3 +1,4 @@ // Auto-generated stub — replace with real implementation -export {}; -export const MonitorMcpDetailDialog: (props: Record) => null = () => null; +export {} +export const MonitorMcpDetailDialog: (props: Record) => null = + () => null diff --git a/src/components/tasks/RemoteSessionDetailDialog.tsx b/src/components/tasks/RemoteSessionDetailDialog.tsx index 55c897fd9..0163664dd 100644 --- a/src/components/tasks/RemoteSessionDetailDialog.tsx +++ b/src/components/tasks/RemoteSessionDetailDialog.tsx @@ -1,48 +1,39 @@ -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 } from '../../ink/events/keyboard-event.js' -import { Box, Link, Text } from '../../ink.js' -import type { RemoteAgentTaskState } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js' -import { getRemoteTaskSessionUrl } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js' -import { - AGENT_TOOL_NAME, - LEGACY_AGENT_TOOL_NAME, -} from '../../tools/AgentTool/constants.js' -import { ASK_USER_QUESTION_TOOL_NAME } from '../../tools/AskUserQuestionTool/prompt.js' -import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../../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 } from '../design-system/Byline.js' -import { Dialog } from '../design-system/Dialog.js' -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' -import { Message } from '../Message.js' -import { - formatReviewStageCounts, - RemoteSessionProgress, -} from './RemoteSessionProgress.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 } from '../../ink/events/keyboard-event.js'; +import { Box, Link, Text } from '../../ink.js'; +import type { RemoteAgentTaskState } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js'; +import { getRemoteTaskSessionUrl } from '../../tasks/RemoteAgentTask/RemoteAgentTask.js'; +import { AGENT_TOOL_NAME, LEGACY_AGENT_TOOL_NAME } from '../../tools/AgentTool/constants.js'; +import { ASK_USER_QUESTION_TOOL_NAME } from '../../tools/AskUserQuestionTool/prompt.js'; +import { EXIT_PLAN_MODE_V2_TOOL_NAME } from '../../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 } from '../design-system/Byline.js'; +import { Dialog } from '../design-system/Dialog.js'; +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; +import { Message } from '../Message.js'; +import { formatReviewStageCounts, RemoteSessionProgress } from './RemoteSessionProgress.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). @@ -51,118 +42,88 @@ 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 - -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, - ) +} 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); // 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 + if (msg.type !== 'assistant') continue; for (const block of msg.message.content) { - if (block.type !== 'tool_use') continue - calls++ - lastBlock = block - if ( - block.name === AGENT_TOOL_NAME || - block.name === LEGACY_AGENT_TOOL_NAME - ) { - spawns++ + 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 @@ -386,37 +314,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 {' · '} @@ -456,18 +380,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 dc058df0e..c25091350 100644 --- a/src/hooks/useChromeExtensionNotification.tsx +++ b/src/hooks/useChromeExtensionNotification.tsx @@ -1,56 +1,45 @@ -import * as React from 'react' -import { Text } from '../ink.js' -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 '../ink.js'; +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 ("external" !== 'ant' && !isClaudeAISubscriber()) { + if ('external' !== '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 9e9aa1cf3..3048eeb15 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.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.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/useGlobalKeybindings.tsx b/src/hooks/useGlobalKeybindings.tsx index a41b1b6a5..d26f2774d 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 '../ink/instances.js' -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 '../ink/instances.js'; +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,45 +48,38 @@ 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. @@ -94,7 +87,7 @@ export function GlobalKeybindingHandlers({ feature('KAIROS') || feature('KAIROS_BRIEF') ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant useAppState(s => s.isBriefOnly) - : false + : false; const handleToggleTranscript = useCallback(() => { if (feature('KAIROS') || feature('KAIROS_BRIEF')) { // Escape hatch: GB kill-switch while defaultView=chat was persisted @@ -104,30 +97,30 @@ export function GlobalKeybindingHandlers({ // isBriefOnly (Messages.tsx filter is gated on !isTranscriptMode). /* eslint-disable @typescript-eslint/no-require-imports */ const { isBriefEnabled } = - require('../tools/BriefTool/BriefTool.js') as typeof import('../tools/BriefTool/BriefTool.js') + require('../tools/BriefTool/BriefTool.js') as typeof import('../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 +132,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 +164,34 @@ export function GlobalKeybindingHandlers({ if (feature('KAIROS') || feature('KAIROS_BRIEF')) { /* eslint-disable @typescript-eslint/no-require-imports */ const { isBriefEnabled } = - require('../tools/BriefTool/BriefTool.js') as typeof import('../tools/BriefTool/BriefTool.js') + require('../tools/BriefTool/BriefTool.js') as typeof import('../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 +201,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 +244,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/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 5029f45c1..e5fcf06bf 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/useOfficialMarketplaceNotification.tsx b/src/hooks/useOfficialMarketplaceNotification.tsx index 25cf62254..dc548b1bf 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 '../ink.js' -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 '../ink.js'; +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/usePluginRecommendationBase.tsx b/src/hooks/usePluginRecommendationBase.tsx index 23930fba4..08269f7c1 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 '../ink.js' -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 '../ink.js'; +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 be7fa8363..6effc7f4e 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 ("external" !== 'ant') { - return + if ('external' !== 'ant') { + 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 35477e526..455beaa59 100644 --- a/src/hooks/useRemoteSession.ts +++ b/src/hooks/useRemoteSession.ts @@ -156,9 +156,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 +251,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 +295,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 522202891..cc6ab8f4d 100644 --- a/src/hooks/useReplBridge.tsx +++ b/src/hooks/useReplBridge.tsx @@ -1,52 +1,42 @@ -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 '../ink.js' -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 '../ink.js'; +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' +} from '../utils/permissions/permissionSetup.js'; +import { getLeaderToolUseConfirmQueue } from '../utils/swarm/leaderPermissionBridge.js'; /** 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 @@ -57,7 +47,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 @@ -77,45 +67,43 @@ 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 + : false; const replBridgeConnected = feature('BRIDGE_MODE') ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant useAppState(s => s.replBridgeConnected) - : false + : false; const replBridgeOutboundOnly = feature('BRIDGE_MODE') ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant useAppState(s => s.replBridgeOutboundOnly) - : false + : false; const replBridgeInitialName = feature('BRIDGE_MODE') ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant useAppState(s => s.replBridgeInitialName) - : undefined + : undefined; // Initialize/teardown bridge when enabled state changes. // Passes current messages as initialMessages so the remote session @@ -125,11 +113,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: ( @@ -139,33 +127,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 { @@ -174,22 +161,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 @@ -200,10 +181,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. @@ -216,32 +197,25 @@ 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' - ) - let sanitized = fields.content + const { resolveAndPrepend } = await import('../bridge/inboundAttachments.js'); + let sanitized = fields.content; 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 */ - sanitized = sanitizeInboundWebhookContent(fields.content) + sanitized = sanitizeInboundWebhookContent(fields.content); } - const content = await resolveAndPrepend(msg, sanitized) + const content = await resolveAndPrepend(msg, sanitized); - 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, @@ -253,59 +227,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 && @@ -315,7 +275,7 @@ export function useReplBridge( prev.replBridgeEnvironmentId === envId && prev.replBridgeSessionId === sessionId ) { - return prev + return prev; } return { ...prev, @@ -327,37 +287,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: @@ -371,94 +326,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); } } @@ -468,22 +411,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 — @@ -502,57 +445,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, @@ -560,62 +492,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, @@ -623,24 +550,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, @@ -650,15 +567,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: { @@ -666,41 +581,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, @@ -710,17 +616,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( @@ -729,11 +633,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. @@ -742,61 +644,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, @@ -809,18 +704,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), @@ -828,10 +717,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 @@ -839,36 +728,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 1453231f0..39b57c5ef 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' 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/useTypeahead.tsx b/src/hooks/useTypeahead.tsx index 3e2dbd220..b3533f995 100644 --- a/src/hooks/useTypeahead.tsx +++ b/src/hooks/useTypeahead.tsx @@ -1,99 +1,69 @@ -import * as React from 'react' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { useNotifications } from 'src/context/notifications.js' -import { Text } from 'src/ink.js' -import { logEvent } from 'src/services/analytics/index.js' -import { useDebounceCallback } from 'usehooks-ts' -import { type Command, getCommandName } from '../commands.js' -import { - getModeFromInput, - getValueFromInput, -} from '../components/PromptInput/inputModes.js' -import type { - SuggestionItem, - SuggestionType, -} from '../components/PromptInput/PromptInputFooterSuggestions.js' -import { - useIsModalOverlayActive, - useRegisterOverlay, -} from '../context/overlayContext.js' -import { KeyboardEvent } from '../ink/events/keyboard-event.js' +import * as React from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useNotifications } from 'src/context/notifications.js'; +import { Text } from 'src/ink.js'; +import { logEvent } from 'src/services/analytics/index.js'; +import { useDebounceCallback } from 'usehooks-ts'; +import { type Command, getCommandName } from '../commands.js'; +import { getModeFromInput, getValueFromInput } from '../components/PromptInput/inputModes.js'; +import type { SuggestionItem, SuggestionType } from '../components/PromptInput/PromptInputFooterSuggestions.js'; +import { useIsModalOverlayActive, useRegisterOverlay } from '../context/overlayContext.js'; +import { KeyboardEvent } from '../ink/events/keyboard-event.js'; // eslint-disable-next-line custom-rules/prefer-use-keybindings -- backward-compat bridge until consumers wire handleKeyDown to -import { useInput } from '../ink.js' -import { - useOptionalKeybindingContext, - useRegisterKeybindingContext, -} from '../keybindings/KeybindingContext.js' -import { useKeybindings } from '../keybindings/useKeybinding.js' -import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js' -import { useAppState, useAppStateStore } from '../state/AppState.js' -import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js' -import type { - InlineGhostText, - PromptInputMode, -} from '../types/textInputTypes.js' -import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js' -import { - generateProgressiveArgumentHint, - parseArguments, -} from '../utils/argumentSubstitution.js' -import { - getShellCompletions, - type ShellCompletionType, -} from '../utils/bash/shellCompletion.js' -import { formatLogMetadata } from '../utils/format.js' -import { - getSessionIdFromLog, - searchSessionsByCustomTitle, -} from '../utils/sessionStorage.js' +import { useInput } from '../ink.js'; +import { useOptionalKeybindingContext, useRegisterKeybindingContext } from '../keybindings/KeybindingContext.js'; +import { useKeybindings } from '../keybindings/useKeybinding.js'; +import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'; +import { useAppState, useAppStateStore } from '../state/AppState.js'; +import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js'; +import type { InlineGhostText, PromptInputMode } from '../types/textInputTypes.js'; +import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js'; +import { generateProgressiveArgumentHint, parseArguments } from '../utils/argumentSubstitution.js'; +import { getShellCompletions, type ShellCompletionType } from '../utils/bash/shellCompletion.js'; +import { formatLogMetadata } from '../utils/format.js'; +import { getSessionIdFromLog, searchSessionsByCustomTitle } from '../utils/sessionStorage.js'; import { applyCommandSuggestion, findMidInputSlashCommand, generateCommandSuggestions, getBestCommandMatch, isCommandInput, -} from '../utils/suggestions/commandSuggestions.js' +} from '../utils/suggestions/commandSuggestions.js'; import { getDirectoryCompletions, getPathCompletions, isPathLikeToken, -} from '../utils/suggestions/directoryCompletion.js' -import { getShellHistoryCompletion } from '../utils/suggestions/shellHistoryCompletion.js' -import { - getSlackChannelSuggestions, - hasSlackMcpServer, -} from '../utils/suggestions/slackChannelSuggestions.js' -import { TEAM_LEAD_NAME } from '../utils/swarm/constants.js' +} from '../utils/suggestions/directoryCompletion.js'; +import { getShellHistoryCompletion } from '../utils/suggestions/shellHistoryCompletion.js'; +import { getSlackChannelSuggestions, hasSlackMcpServer } from '../utils/suggestions/slackChannelSuggestions.js'; +import { TEAM_LEAD_NAME } from '../utils/swarm/constants.js'; import { applyFileSuggestion, findLongestCommonPrefix, onIndexBuildComplete, startBackgroundCacheRefresh, -} from './fileSuggestions.js' -import { generateUnifiedSuggestions } from './unifiedSuggestions.js' +} from './fileSuggestions.js'; +import { generateUnifiedSuggestions } from './unifiedSuggestions.js'; // Unicode-aware character class for file path tokens: // \p{L} = letters (CJK, Latin, Cyrillic, etc.) // \p{N} = numbers (incl. fullwidth) // \p{M} = combining marks (macOS NFD accents, Devanagari vowel signs) -const AT_TOKEN_HEAD_RE = /^@[\p{L}\p{N}\p{M}_\-./\\()[\]~:]*/u -const PATH_CHAR_HEAD_RE = /^[\p{L}\p{N}\p{M}_\-./\\()[\]~:]+/u -const TOKEN_WITH_AT_RE = - /(@[\p{L}\p{N}\p{M}_\-./\\()[\]~:]*|[\p{L}\p{N}\p{M}_\-./\\()[\]~:]+)$/u -const TOKEN_WITHOUT_AT_RE = /[\p{L}\p{N}\p{M}_\-./\\()[\]~:]+$/u -const HAS_AT_SYMBOL_RE = /(^|\s)@([\p{L}\p{N}\p{M}_\-./\\()[\]~:]*|"[^"]*"?)$/u -const HASH_CHANNEL_RE = /(^|\s)#([a-z0-9][a-z0-9_-]*)$/ +const AT_TOKEN_HEAD_RE = /^@[\p{L}\p{N}\p{M}_\-./\\()[\]~:]*/u; +const PATH_CHAR_HEAD_RE = /^[\p{L}\p{N}\p{M}_\-./\\()[\]~:]+/u; +const TOKEN_WITH_AT_RE = /(@[\p{L}\p{N}\p{M}_\-./\\()[\]~:]*|[\p{L}\p{N}\p{M}_\-./\\()[\]~:]+)$/u; +const TOKEN_WITHOUT_AT_RE = /[\p{L}\p{N}\p{M}_\-./\\()[\]~:]+$/u; +const HAS_AT_SYMBOL_RE = /(^|\s)@([\p{L}\p{N}\p{M}_\-./\\()[\]~:]*|"[^"]*"?)$/u; +const HASH_CHANNEL_RE = /(^|\s)#([a-z0-9][a-z0-9_-]*)$/; // Type guard for path completion metadata -function isPathMetadata( - metadata: unknown, -): metadata is { type: 'directory' | 'file' } { +function isPathMetadata(metadata: unknown): metadata is { type: 'directory' | 'file' } { return ( typeof metadata === 'object' && metadata !== null && 'type' in metadata && (metadata.type === 'directory' || metadata.type === 'file') - ) + ); } // Helper to determine selectedSuggestion when updating suggestions @@ -104,92 +74,85 @@ function getPreservedSelection( ): number { // No new suggestions if (newSuggestions.length === 0) { - return -1 + return -1; } // No previous selection if (prevSelection < 0) { - return 0 + return 0; } // Get the previously selected item - const prevSelectedItem = prevSuggestions[prevSelection] + const prevSelectedItem = prevSuggestions[prevSelection]; if (!prevSelectedItem) { - return 0 + return 0; } // Try to find the same item in the new list by ID - const newIndex = newSuggestions.findIndex( - item => item.id === prevSelectedItem.id, - ) + const newIndex = newSuggestions.findIndex(item => item.id === prevSelectedItem.id); // Return the new index if found, otherwise default to 0 - return newIndex >= 0 ? newIndex : 0 + return newIndex >= 0 ? newIndex : 0; } function buildResumeInputFromSuggestion(suggestion: SuggestionItem): string { - const metadata = suggestion.metadata as { sessionId: string } | undefined - return metadata?.sessionId - ? `/resume ${metadata.sessionId}` - : `/resume ${suggestion.displayText}` + const metadata = suggestion.metadata as { sessionId: string } | undefined; + return metadata?.sessionId ? `/resume ${metadata.sessionId}` : `/resume ${suggestion.displayText}`; } type Props = { - onInputChange: (value: string) => void - onSubmit: (value: string, isSubmittingSlashCommand?: boolean) => void - setCursorOffset: (offset: number) => void - input: string - cursorOffset: number - commands: Command[] - mode: string - agents: AgentDefinition[] + onInputChange: (value: string) => void; + onSubmit: (value: string, isSubmittingSlashCommand?: boolean) => void; + setCursorOffset: (offset: number) => void; + input: string; + cursorOffset: number; + commands: Command[]; + mode: string; + agents: AgentDefinition[]; setSuggestionsState: ( f: (previousSuggestionsState: { - suggestions: SuggestionItem[] - selectedSuggestion: number - commandArgumentHint?: string + suggestions: SuggestionItem[]; + selectedSuggestion: number; + commandArgumentHint?: string; }) => { - suggestions: SuggestionItem[] - selectedSuggestion: number - commandArgumentHint?: string + suggestions: SuggestionItem[]; + selectedSuggestion: number; + commandArgumentHint?: string; }, - ) => void + ) => void; suggestionsState: { - suggestions: SuggestionItem[] - selectedSuggestion: number - commandArgumentHint?: string - } - suppressSuggestions?: boolean - markAccepted: () => void - onModeChange?: (mode: PromptInputMode) => void -} + suggestions: SuggestionItem[]; + selectedSuggestion: number; + commandArgumentHint?: string; + }; + suppressSuggestions?: boolean; + markAccepted: () => void; + onModeChange?: (mode: PromptInputMode) => void; +}; type UseTypeaheadResult = { - suggestions: SuggestionItem[] - selectedSuggestion: number - suggestionType: SuggestionType - maxColumnWidth?: number - commandArgumentHint?: string - inlineGhostText?: InlineGhostText - handleKeyDown: (e: KeyboardEvent) => void -} + suggestions: SuggestionItem[]; + selectedSuggestion: number; + suggestionType: SuggestionType; + maxColumnWidth?: number; + commandArgumentHint?: string; + inlineGhostText?: InlineGhostText; + handleKeyDown: (e: KeyboardEvent) => void; +}; /** * Extract search token from a completion token by removing @ prefix and quotes * @param completionToken The completion token * @returns The search token with @ and quotes removed */ -export function extractSearchToken(completionToken: { - token: string - isQuoted?: boolean -}): string { +export function extractSearchToken(completionToken: { token: string; isQuoted?: boolean }): string { if (completionToken.isQuoted) { // Remove @" prefix and optional closing " - return completionToken.token.slice(2).replace(/"$/, '') + return completionToken.token.slice(2).replace(/"$/, ''); } else if (completionToken.token.startsWith('@')) { - return completionToken.token.substring(1) + return completionToken.token.substring(1); } else { - return completionToken.token + return completionToken.token; } } @@ -205,28 +168,23 @@ export function extractSearchToken(completionToken: { * @returns The formatted replacement value */ export function formatReplacementValue(options: { - displayText: string - mode: string - hasAtPrefix: boolean - needsQuotes: boolean - isQuoted?: boolean - isComplete: boolean + displayText: string; + mode: string; + hasAtPrefix: boolean; + needsQuotes: boolean; + isQuoted?: boolean; + isComplete: boolean; }): string { - const { displayText, mode, hasAtPrefix, needsQuotes, isQuoted, isComplete } = - options - const space = isComplete ? ' ' : '' + const { displayText, mode, hasAtPrefix, needsQuotes, isQuoted, isComplete } = options; + const space = isComplete ? ' ' : ''; if (isQuoted || needsQuotes) { // Use quoted format - return mode === 'bash' - ? `"${displayText}"${space}` - : `@"${displayText}"${space}` + return mode === 'bash' ? `"${displayText}"${space}` : `@"${displayText}"${space}`; } else if (hasAtPrefix) { - return mode === 'bash' - ? `${displayText}${space}` - : `@${displayText}${space}` + return mode === 'bash' ? `${displayText}${space}` : `@${displayText}${space}`; } else { - return displayText + return displayText; } } @@ -241,28 +199,27 @@ export function applyShellSuggestion( setCursorOffset: (offset: number) => void, completionType: ShellCompletionType | undefined, ): void { - const beforeCursor = input.slice(0, cursorOffset) - const lastSpaceIndex = beforeCursor.lastIndexOf(' ') - const wordStart = lastSpaceIndex + 1 + const beforeCursor = input.slice(0, cursorOffset); + const lastSpaceIndex = beforeCursor.lastIndexOf(' '); + const wordStart = lastSpaceIndex + 1; // Prepare the replacement text based on completion type - let replacementText: string + let replacementText: string; if (completionType === 'variable') { - replacementText = '$' + suggestion.displayText + ' ' + replacementText = '$' + suggestion.displayText + ' '; } else if (completionType === 'command') { - replacementText = suggestion.displayText + ' ' + replacementText = suggestion.displayText + ' '; } else { - replacementText = suggestion.displayText + replacementText = suggestion.displayText; } - const newInput = - input.slice(0, wordStart) + replacementText + input.slice(cursorOffset) + const newInput = input.slice(0, wordStart) + replacementText + input.slice(cursorOffset); - onInputChange(newInput) - setCursorOffset(wordStart + replacementText.length) + onInputChange(newInput); + setCursorOffset(wordStart + replacementText.length); } -const DM_MEMBER_RE = /(^|\s)@[\w-]*$/ +const DM_MEMBER_RE = /(^|\s)@[\w-]*$/; function applyTriggerSuggestion( suggestion: SuggestionItem, @@ -272,42 +229,34 @@ function applyTriggerSuggestion( onInputChange: (value: string) => void, setCursorOffset: (offset: number) => void, ): void { - const m = input.slice(0, cursorOffset).match(triggerRe) - if (!m || m.index === undefined) return - const prefixStart = m.index + (m[1]?.length ?? 0) - const before = input.slice(0, prefixStart) - const newInput = - before + suggestion.displayText + ' ' + input.slice(cursorOffset) - onInputChange(newInput) - setCursorOffset(before.length + suggestion.displayText.length + 1) + const m = input.slice(0, cursorOffset).match(triggerRe); + if (!m || m.index === undefined) return; + const prefixStart = m.index + (m[1]?.length ?? 0); + const before = input.slice(0, prefixStart); + const newInput = before + suggestion.displayText + ' ' + input.slice(cursorOffset); + onInputChange(newInput); + setCursorOffset(before.length + suggestion.displayText.length + 1); } -let currentShellCompletionAbortController: AbortController | null = null +let currentShellCompletionAbortController: AbortController | null = null; /** * Generate bash shell completion suggestions */ -async function generateBashSuggestions( - input: string, - cursorOffset: number, -): Promise { +async function generateBashSuggestions(input: string, cursorOffset: number): Promise { try { if (currentShellCompletionAbortController) { - currentShellCompletionAbortController.abort() + currentShellCompletionAbortController.abort(); } - currentShellCompletionAbortController = new AbortController() - const suggestions = await getShellCompletions( - input, - cursorOffset, - currentShellCompletionAbortController.signal, - ) + currentShellCompletionAbortController = new AbortController(); + const suggestions = await getShellCompletions(input, cursorOffset, currentShellCompletionAbortController.signal); - return suggestions + return suggestions; } catch { // Silent failure - don't break UX - logEvent('tengu_shell_completion_failed', {}) - return [] + logEvent('tengu_shell_completion_failed', {}); + return []; } } @@ -329,18 +278,18 @@ export function applyDirectorySuggestion( tokenLength: number, isDirectory: boolean, ): { newInput: string; cursorPos: number } { - const suffix = isDirectory ? '/' : ' ' - const before = input.slice(0, tokenStartPos) - const after = input.slice(tokenStartPos + tokenLength) + const suffix = isDirectory ? '/' : ' '; + const before = input.slice(0, tokenStartPos); + const after = input.slice(tokenStartPos + tokenLength); // Always add @ prefix - if token already has it, we're replacing // the whole token (including @) with @suggestion.id - const replacement = '@' + suggestionId + suffix - const newInput = before + replacement + after + const replacement = '@' + suggestionId + suffix; + const newInput = before + replacement + after; return { newInput, cursorPos: before.length + replacement.length, - } + }; } /** @@ -356,98 +305,92 @@ export function extractCompletionToken( includeAtSymbol = false, ): { token: string; startPos: number; isQuoted?: boolean } | null { // Empty input check - if (!text) return null + if (!text) return null; // Get text up to cursor - const textBeforeCursor = text.substring(0, cursorPos) + const textBeforeCursor = text.substring(0, cursorPos); // Check for quoted @ mention first (e.g., @"my file with spaces") if (includeAtSymbol) { - const quotedAtRegex = /@"([^"]*)"?$/ - const quotedMatch = textBeforeCursor.match(quotedAtRegex) + const quotedAtRegex = /@"([^"]*)"?$/; + const quotedMatch = textBeforeCursor.match(quotedAtRegex); if (quotedMatch && quotedMatch.index !== undefined) { // Include any remaining quoted content after cursor until closing quote or end - const textAfterCursor = text.substring(cursorPos) - const afterQuotedMatch = textAfterCursor.match(/^[^"]*"?/) - const quotedSuffix = afterQuotedMatch ? afterQuotedMatch[0] : '' + const textAfterCursor = text.substring(cursorPos); + const afterQuotedMatch = textAfterCursor.match(/^[^"]*"?/); + const quotedSuffix = afterQuotedMatch ? afterQuotedMatch[0] : ''; return { token: quotedMatch[0] + quotedSuffix, startPos: quotedMatch.index, isQuoted: true, - } + }; } } // Fast path for @ tokens: use lastIndexOf to avoid expensive $ anchor scan if (includeAtSymbol) { - const atIdx = textBeforeCursor.lastIndexOf('@') - if ( - atIdx >= 0 && - (atIdx === 0 || /\s/.test(textBeforeCursor[atIdx - 1]!)) - ) { - const fromAt = textBeforeCursor.substring(atIdx) - const atHeadMatch = fromAt.match(AT_TOKEN_HEAD_RE) + const atIdx = textBeforeCursor.lastIndexOf('@'); + if (atIdx >= 0 && (atIdx === 0 || /\s/.test(textBeforeCursor[atIdx - 1]!))) { + const fromAt = textBeforeCursor.substring(atIdx); + const atHeadMatch = fromAt.match(AT_TOKEN_HEAD_RE); if (atHeadMatch && atHeadMatch[0].length === fromAt.length) { - const textAfterCursor = text.substring(cursorPos) - const afterMatch = textAfterCursor.match(PATH_CHAR_HEAD_RE) - const tokenSuffix = afterMatch ? afterMatch[0] : '' + const textAfterCursor = text.substring(cursorPos); + const afterMatch = textAfterCursor.match(PATH_CHAR_HEAD_RE); + const tokenSuffix = afterMatch ? afterMatch[0] : ''; return { token: atHeadMatch[0] + tokenSuffix, startPos: atIdx, isQuoted: false, - } + }; } } } // Non-@ token or cursor outside @ token — use $ anchor on (short) tail - const tokenRegex = includeAtSymbol ? TOKEN_WITH_AT_RE : TOKEN_WITHOUT_AT_RE - const match = textBeforeCursor.match(tokenRegex) + const tokenRegex = includeAtSymbol ? TOKEN_WITH_AT_RE : TOKEN_WITHOUT_AT_RE; + const match = textBeforeCursor.match(tokenRegex); if (!match || match.index === undefined) { - return null + return null; } // Check if cursor is in the MIDDLE of a token (more word characters after cursor) // If so, extend the token to include all characters until whitespace or end of string - const textAfterCursor = text.substring(cursorPos) - const afterMatch = textAfterCursor.match(PATH_CHAR_HEAD_RE) - const tokenSuffix = afterMatch ? afterMatch[0] : '' + const textAfterCursor = text.substring(cursorPos); + const afterMatch = textAfterCursor.match(PATH_CHAR_HEAD_RE); + const tokenSuffix = afterMatch ? afterMatch[0] : ''; return { token: match[0] + tokenSuffix, startPos: match.index, isQuoted: false, - } + }; } function extractCommandNameAndArgs(value: string): { - commandName: string - args: string + commandName: string; + args: string; } | null { if (isCommandInput(value)) { - const spaceIndex = value.indexOf(' ') + const spaceIndex = value.indexOf(' '); if (spaceIndex === -1) return { commandName: value.slice(1), args: '', - } + }; return { commandName: value.slice(1, spaceIndex), args: value.slice(spaceIndex + 1), - } + }; } - return null + return null; } -function hasCommandWithArguments( - isAtEndWithWhitespace: boolean, - value: string, -) { +function hasCommandWithArguments(isAtEndWithWhitespace: boolean, value: string) { // If value.endsWith(' ') but the user is not at the end, then the user has // potentially gone back to the command in an effort to edit the command name // (but preserve the arguments). - return !isAtEndWithWhitespace && value.includes(' ') && !value.endsWith(' ') + return !isAtEndWithWhitespace && value.includes(' ') && !value.endsWith(' '); } /** @@ -468,87 +411,76 @@ export function useTypeahead({ markAccepted, onModeChange, }: Props): UseTypeaheadResult { - const { addNotification } = useNotifications() - const thinkingToggleShortcut = useShortcutDisplay( - 'chat:thinkingToggle', - 'Chat', - 'alt+t', - ) - const [suggestionType, setSuggestionType] = useState('none') + const { addNotification } = useNotifications(); + const thinkingToggleShortcut = useShortcutDisplay('chat:thinkingToggle', 'Chat', 'alt+t'); + const [suggestionType, setSuggestionType] = useState('none'); // Compute max column width from ALL commands once (not filtered results) // This prevents layout shift when filtering const allCommandsMaxWidth = useMemo(() => { - const visibleCommands = commands.filter(cmd => !cmd.isHidden) - if (visibleCommands.length === 0) return undefined - const maxLen = Math.max( - ...visibleCommands.map(cmd => getCommandName(cmd).length), - ) - return maxLen + 6 // +1 for "/" prefix, +5 for padding - }, [commands]) - - const [maxColumnWidth, setMaxColumnWidth] = useState( - undefined, - ) - const mcpResources = useAppState(s => s.mcp.resources) - const store = useAppStateStore() - const promptSuggestion = useAppState(s => s.promptSuggestion) + const visibleCommands = commands.filter(cmd => !cmd.isHidden); + if (visibleCommands.length === 0) return undefined; + const maxLen = Math.max(...visibleCommands.map(cmd => getCommandName(cmd).length)); + return maxLen + 6; // +1 for "/" prefix, +5 for padding + }, [commands]); + + const [maxColumnWidth, setMaxColumnWidth] = useState(undefined); + const mcpResources = useAppState(s => s.mcp.resources); + const store = useAppStateStore(); + const promptSuggestion = useAppState(s => s.promptSuggestion); // PromptInput hides suggestion ghost text in teammate view — mirror that // gate here so Tab/rightArrow can't accept what isn't displayed. - const isViewingTeammate = useAppState(s => !!s.viewingAgentTaskId) + const isViewingTeammate = useAppState(s => !!s.viewingAgentTaskId); // Access keybinding context to check for pending chord sequences - const keybindingContext = useOptionalKeybindingContext() + const keybindingContext = useOptionalKeybindingContext(); // State for inline ghost text (bash history completion - async) - const [inlineGhostText, setInlineGhostText] = useState< - InlineGhostText | undefined - >(undefined) + const [inlineGhostText, setInlineGhostText] = useState(undefined); // Synchronous ghost text for prompt mode mid-input slash commands. // Computed during render via useMemo to eliminate the one-frame flicker // that occurs when using useState + useEffect (effect runs after render). const syncPromptGhostText = useMemo((): InlineGhostText | undefined => { - if (mode !== 'prompt' || suppressSuggestions) return undefined - const midInputCommand = findMidInputSlashCommand(input, cursorOffset) - if (!midInputCommand) return undefined - const match = getBestCommandMatch(midInputCommand.partialCommand, commands) - if (!match) return undefined + if (mode !== 'prompt' || suppressSuggestions) return undefined; + const midInputCommand = findMidInputSlashCommand(input, cursorOffset); + if (!midInputCommand) return undefined; + const match = getBestCommandMatch(midInputCommand.partialCommand, commands); + if (!match) return undefined; return { text: match.suffix, fullCommand: match.fullCommand, - insertPosition: - midInputCommand.startPos + 1 + midInputCommand.partialCommand.length, - } - }, [input, cursorOffset, mode, commands, suppressSuggestions]) + insertPosition: midInputCommand.startPos + 1 + midInputCommand.partialCommand.length, + }; + }, [input, cursorOffset, mode, commands, suppressSuggestions]); // Merged ghost text: prompt mode uses synchronous useMemo, bash mode uses async useState const effectiveGhostText = suppressSuggestions ? undefined : mode === 'prompt' ? syncPromptGhostText - : inlineGhostText + : inlineGhostText; // Use a ref for cursorOffset to avoid re-triggering suggestions on cursor movement alone // We only want to re-fetch suggestions when the actual search token changes - const cursorOffsetRef = useRef(cursorOffset) - cursorOffsetRef.current = cursorOffset + const cursorOffsetRef = useRef(cursorOffset); + cursorOffsetRef.current = cursorOffset; // Track the latest search token to discard stale results from slow async operations - const latestSearchTokenRef = useRef(null) + const latestSearchTokenRef = useRef(null); // Track previous input to detect actual text changes vs. callback recreations - const prevInputRef = useRef('') + const prevInputRef = useRef(''); // Track the latest path token to discard stale results from path completion - const latestPathTokenRef = useRef('') + const latestPathTokenRef = useRef(''); // Track the latest bash input to discard stale results from history completion - const latestBashInputRef = useRef('') + const latestBashInputRef = useRef(''); // Track the latest slack channel token to discard stale results from MCP - const latestSlackTokenRef = useRef('') + const latestSlackTokenRef = useRef(''); // Track suggestions via ref to avoid updateSuggestions being recreated on selection changes - const suggestionsRef = useRef(suggestions) - suggestionsRef.current = suggestions + const suggestionsRef = useRef(suggestions); + suggestionsRef.current = suggestions; // Track the input value when suggestions were manually dismissed to prevent re-triggering - const dismissedForInputRef = useRef(null) + const dismissedForInputRef = useRef(null); // Clear all suggestions const clearSuggestions = useCallback(() => { @@ -556,25 +488,20 @@ export function useTypeahead({ commandArgumentHint: undefined, suggestions: [], selectedSuggestion: -1, - })) - setSuggestionType('none') - setMaxColumnWidth(undefined) - setInlineGhostText(undefined) - }, [setSuggestionsState]) + })); + setSuggestionType('none'); + setMaxColumnWidth(undefined); + setInlineGhostText(undefined); + }, [setSuggestionsState]); // Expensive async operation to fetch file/resource suggestions const fetchFileSuggestions = useCallback( async (searchToken: string, isAtSymbol = false): Promise => { - latestSearchTokenRef.current = searchToken - const combinedItems = await generateUnifiedSuggestions( - searchToken, - mcpResources, - agents, - isAtSymbol, - ) + latestSearchTokenRef.current = searchToken; + const combinedItems = await generateUnifiedSuggestions(searchToken, mcpResources, agents, isAtSymbol); // Discard stale results if a newer query was initiated while waiting if (latestSearchTokenRef.current !== searchToken) { - return + return; } if (combinedItems.length === 0) { // Inline clearSuggestions logic to avoid needing debouncedFetchFileSuggestions @@ -582,31 +509,21 @@ export function useTypeahead({ commandArgumentHint: undefined, suggestions: [], selectedSuggestion: -1, - })) - setSuggestionType('none') - setMaxColumnWidth(undefined) - return + })); + setSuggestionType('none'); + setMaxColumnWidth(undefined); + return; } setSuggestionsState(prev => ({ commandArgumentHint: undefined, suggestions: combinedItems, - selectedSuggestion: getPreservedSelection( - prev.suggestions, - prev.selectedSuggestion, - combinedItems, - ), - })) - setSuggestionType(combinedItems.length > 0 ? 'file' : 'none') - setMaxColumnWidth(undefined) // No fixed width for file suggestions + selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, combinedItems), + })); + setSuggestionType(combinedItems.length > 0 ? 'file' : 'none'); + setMaxColumnWidth(undefined); // No fixed width for file suggestions }, - [ - mcpResources, - setSuggestionsState, - setSuggestionType, - setMaxColumnWidth, - agents, - ], - ) + [mcpResources, setSuggestionsState, setSuggestionType, setMaxColumnWidth, agents], + ); // Pre-warm the file index on mount so the first @-mention doesn't block. // The build runs in background with ~4ms event-loop yields, so it doesn't @@ -623,68 +540,55 @@ export function useTypeahead({ // subsequent tests in the shard. The subscriber still registers so // fileSuggestions tests that trigger a refresh directly work correctly. useEffect(() => { - if ("production" !== 'test') { - startBackgroundCacheRefresh() + if ('production' !== 'test') { + startBackgroundCacheRefresh(); } return onIndexBuildComplete(() => { - const token = latestSearchTokenRef.current + const token = latestSearchTokenRef.current; if (token !== null) { - latestSearchTokenRef.current = null - void fetchFileSuggestions(token, token === '') + latestSearchTokenRef.current = null; + void fetchFileSuggestions(token, token === ''); } - }) - }, [fetchFileSuggestions]) + }); + }, [fetchFileSuggestions]); // Debounce the file fetch operation. 50ms sits just above macOS default // key-repeat (~33ms) so held-delete/backspace coalesces into one search // instead of stuttering on each repeated key. The search itself is ~8–15ms // on a 270k-file index. - const debouncedFetchFileSuggestions = useDebounceCallback( - fetchFileSuggestions, - 50, - ) + const debouncedFetchFileSuggestions = useDebounceCallback(fetchFileSuggestions, 50); const fetchSlackChannels = useCallback( async (partial: string): Promise => { - latestSlackTokenRef.current = partial - const channels = await getSlackChannelSuggestions( - store.getState().mcp.clients, - partial, - ) - if (latestSlackTokenRef.current !== partial) return + latestSlackTokenRef.current = partial; + const channels = await getSlackChannelSuggestions(store.getState().mcp.clients, partial); + if (latestSlackTokenRef.current !== partial) return; setSuggestionsState(prev => ({ commandArgumentHint: undefined, suggestions: channels, - selectedSuggestion: getPreservedSelection( - prev.suggestions, - prev.selectedSuggestion, - channels, - ), - })) - setSuggestionType(channels.length > 0 ? 'slack-channel' : 'none') - setMaxColumnWidth(undefined) + selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, channels), + })); + setSuggestionType(channels.length > 0 ? 'slack-channel' : 'none'); + setMaxColumnWidth(undefined); }, // eslint-disable-next-line react-hooks/exhaustive-deps -- store is a stable context ref [setSuggestionsState], - ) + ); // First keystroke after # needs the MCP round-trip; subsequent keystrokes // that share the same first-word segment hit the cache synchronously. - const debouncedFetchSlackChannels = useDebounceCallback( - fetchSlackChannels, - 150, - ) + 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) - const effectiveCursorOffset = inputCursorOffset ?? cursorOffsetRef.current + const effectiveCursorOffset = inputCursorOffset ?? cursorOffsetRef.current; if (suppressSuggestions) { - debouncedFetchFileSuggestions.cancel() - clearSuggestions() - return + debouncedFetchFileSuggestions.cancel(); + clearSuggestions(); + return; } // Check for mid-input slash command (e.g., "help me /com") @@ -692,133 +596,116 @@ export function useTypeahead({ // Note: ghost text for prompt mode is computed synchronously via syncPromptGhostText useMemo. // We only need to clear dropdown suggestions here when ghost text is active. if (mode === 'prompt') { - const midInputCommand = findMidInputSlashCommand( - value, - effectiveCursorOffset, - ) + const midInputCommand = findMidInputSlashCommand(value, effectiveCursorOffset); if (midInputCommand) { - const match = getBestCommandMatch( - midInputCommand.partialCommand, - commands, - ) + const match = getBestCommandMatch(midInputCommand.partialCommand, commands); if (match) { // Clear dropdown suggestions when showing ghost text setSuggestionsState(() => ({ commandArgumentHint: undefined, suggestions: [], selectedSuggestion: -1, - })) - setSuggestionType('none') - setMaxColumnWidth(undefined) - return + })); + setSuggestionType('none'); + setMaxColumnWidth(undefined); + return; } } } // Bash mode: check for history-based ghost text completion if (mode === 'bash' && value.trim()) { - latestBashInputRef.current = value - const historyMatch = await getShellHistoryCompletion(value) + latestBashInputRef.current = value; + const historyMatch = await getShellHistoryCompletion(value); // Discard stale results if input changed while waiting if (latestBashInputRef.current !== value) { - return + return; } if (historyMatch) { setInlineGhostText({ text: historyMatch.suffix, fullCommand: historyMatch.fullCommand, insertPosition: value.length, - }) + }); // Clear dropdown suggestions when showing ghost text setSuggestionsState(() => ({ commandArgumentHint: undefined, suggestions: [], selectedSuggestion: -1, - })) - setSuggestionType('none') - setMaxColumnWidth(undefined) - return + })); + setSuggestionType('none'); + setMaxColumnWidth(undefined); + return; } else { // No history match, clear ghost text - setInlineGhostText(undefined) + setInlineGhostText(undefined); } } // Check for @ to trigger team member / named subagent suggestions // Must check before @ file symbol to prevent conflict // Skip in bash mode - @ has no special meaning in shell commands - const atMatch = - mode !== 'bash' - ? value.substring(0, effectiveCursorOffset).match(/(^|\s)@([\w-]*)$/) - : null + const atMatch = mode !== 'bash' ? value.substring(0, effectiveCursorOffset).match(/(^|\s)@([\w-]*)$/) : null; if (atMatch) { - const partialName = (atMatch[2] ?? '').toLowerCase() + const partialName = (atMatch[2] ?? '').toLowerCase(); // Imperative read — reading at call-time fixes staleness for // teammates/subagents added mid-session. - const state = store.getState() - const members: SuggestionItem[] = [] - const seen = new Set() + const state = store.getState(); + const members: SuggestionItem[] = []; + const seen = new Set(); if (isAgentSwarmsEnabled() && state.teamContext) { for (const t of Object.values(state.teamContext.teammates ?? {})) { - if (t.name === TEAM_LEAD_NAME) continue - if (!t.name.toLowerCase().startsWith(partialName)) continue - seen.add(t.name) + if (t.name === TEAM_LEAD_NAME) continue; + if (!t.name.toLowerCase().startsWith(partialName)) continue; + seen.add(t.name); members.push({ id: `dm-${t.name}`, displayText: `@${t.name}`, description: 'send message', - }) + }); } } for (const [name, agentId] of state.agentNameRegistry) { - if (seen.has(name)) continue - if (!name.toLowerCase().startsWith(partialName)) continue - const status = state.tasks[agentId]?.status + if (seen.has(name)) continue; + if (!name.toLowerCase().startsWith(partialName)) continue; + const status = state.tasks[agentId]?.status; members.push({ id: `dm-${name}`, displayText: `@${name}`, description: status ? `send message · ${status}` : 'send message', - }) + }); } if (members.length > 0) { - debouncedFetchFileSuggestions.cancel() + debouncedFetchFileSuggestions.cancel(); setSuggestionsState(prev => ({ commandArgumentHint: undefined, suggestions: members, - selectedSuggestion: getPreservedSelection( - prev.suggestions, - prev.selectedSuggestion, - members, - ), - })) - setSuggestionType('agent') - setMaxColumnWidth(undefined) - return + selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, members), + })); + setSuggestionType('agent'); + setMaxColumnWidth(undefined); + return; } } // Check for # to trigger Slack channel suggestions (requires Slack MCP server) if (mode === 'prompt') { - const hashMatch = value - .substring(0, effectiveCursorOffset) - .match(HASH_CHANNEL_RE) + const hashMatch = value.substring(0, effectiveCursorOffset).match(HASH_CHANNEL_RE); if (hashMatch && hasSlackMcpServer(store.getState().mcp.clients)) { - debouncedFetchSlackChannels(hashMatch[2]!) - return + debouncedFetchSlackChannels(hashMatch[2]!); + return; } else if (suggestionType === 'slack-channel') { - debouncedFetchSlackChannels.cancel() - clearSuggestions() + debouncedFetchSlackChannels.cancel(); + clearSuggestions(); } } // Check for @ symbol to trigger file suggestions (including quoted paths) // Includes colon for MCP resources (e.g., server:resource/path) - const hasAtSymbol = value - .substring(0, effectiveCursorOffset) - .match(HAS_AT_SYMBOL_RE) + const hasAtSymbol = value.substring(0, effectiveCursorOffset).match(HAS_AT_SYMBOL_RE); // First, check for slash command suggestions (higher priority than @ symbol) // Only show slash command selector if cursor is not on the "/" character itself @@ -828,49 +715,37 @@ export function useTypeahead({ effectiveCursorOffset === value.length && effectiveCursorOffset > 0 && value.length > 0 && - value[effectiveCursorOffset - 1] === ' ' + value[effectiveCursorOffset - 1] === ' '; // Handle directory completion for commands - if ( - mode === 'prompt' && - isCommandInput(value) && - effectiveCursorOffset > 0 - ) { - const parsedCommand = extractCommandNameAndArgs(value) + if (mode === 'prompt' && isCommandInput(value) && effectiveCursorOffset > 0) { + const parsedCommand = extractCommandNameAndArgs(value); - if ( - parsedCommand && - parsedCommand.commandName === 'add-dir' && - parsedCommand.args - ) { - const { args } = parsedCommand + if (parsedCommand && parsedCommand.commandName === 'add-dir' && parsedCommand.args) { + const { args } = parsedCommand; // Clear suggestions if args end with whitespace (user is done with path) if (args.match(/\s+$/)) { - debouncedFetchFileSuggestions.cancel() - clearSuggestions() - return + debouncedFetchFileSuggestions.cancel(); + clearSuggestions(); + return; } - const dirSuggestions = await getDirectoryCompletions(args) + const dirSuggestions = await getDirectoryCompletions(args); if (dirSuggestions.length > 0) { setSuggestionsState(prev => ({ suggestions: dirSuggestions, - selectedSuggestion: getPreservedSelection( - prev.suggestions, - prev.selectedSuggestion, - dirSuggestions, - ), + selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, dirSuggestions), commandArgumentHint: undefined, - })) - setSuggestionType('directory') - return + })); + setSuggestionType('directory'); + return; } // No suggestions found - clear and return - debouncedFetchFileSuggestions.cancel() - clearSuggestions() - return + debouncedFetchFileSuggestions.cancel(); + clearSuggestions(); + return; } // Handle custom title completion for /resume command @@ -880,40 +755,36 @@ export function useTypeahead({ parsedCommand.args !== undefined && value.includes(' ') ) { - const { args } = parsedCommand + const { args } = parsedCommand; // Get custom title suggestions using partial match const matches = await searchSessionsByCustomTitle(args, { limit: 10, - }) + }); const suggestions = matches.map(log => { - const sessionId = getSessionIdFromLog(log) + const sessionId = getSessionIdFromLog(log); return { id: `resume-title-${sessionId}`, displayText: log.customTitle!, description: formatLogMetadata(log), metadata: { sessionId }, - } - }) + }; + }); if (suggestions.length > 0) { setSuggestionsState(prev => ({ suggestions, - selectedSuggestion: getPreservedSelection( - prev.suggestions, - prev.selectedSuggestion, - suggestions, - ), + selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, suggestions), commandArgumentHint: undefined, - })) - setSuggestionType('custom-title') - return + })); + setSuggestionType('custom-title'); + return; } // No suggestions found - clear and return - clearSuggestions() - return + clearSuggestions(); + return; } } @@ -924,56 +795,44 @@ export function useTypeahead({ effectiveCursorOffset > 0 && !hasCommandWithArguments(isAtEndWithWhitespace, value) ) { - let commandArgumentHint: string | undefined = undefined + let commandArgumentHint: string | undefined = undefined; if (value.length > 1) { // We have a partial or complete command without arguments // Check if it matches a command exactly and has an argument hint // Extract command name: everything after / until the first space (or end) - const spaceIndex = value.indexOf(' ') - const commandName = - spaceIndex === -1 ? value.slice(1) : value.slice(1, spaceIndex) + const spaceIndex = value.indexOf(' '); + const commandName = spaceIndex === -1 ? value.slice(1) : value.slice(1, spaceIndex); // Check if there are real arguments (non-whitespace after the command) - const hasRealArguments = - spaceIndex !== -1 && value.slice(spaceIndex + 1).trim().length > 0 + const hasRealArguments = spaceIndex !== -1 && value.slice(spaceIndex + 1).trim().length > 0; // Check if input is exactly "command + single space" (ready for arguments) - const hasExactlyOneTrailingSpace = - spaceIndex !== -1 && value.length === spaceIndex + 1 + const hasExactlyOneTrailingSpace = spaceIndex !== -1 && value.length === spaceIndex + 1; // If input has a space after the command, don't show suggestions // This prevents Enter from selecting a different command after Tab completion if (spaceIndex !== -1) { - const exactMatch = commands.find( - cmd => getCommandName(cmd) === commandName, - ) + const exactMatch = commands.find(cmd => getCommandName(cmd) === commandName); if (exactMatch || hasRealArguments) { // Priority 1: Static argumentHint (only on first trailing space for backwards compat) if (exactMatch?.argumentHint && hasExactlyOneTrailingSpace) { - commandArgumentHint = exactMatch.argumentHint + commandArgumentHint = exactMatch.argumentHint; } // Priority 2: Progressive hint from argNames (show when trailing space) - else if ( - exactMatch?.type === 'prompt' && - exactMatch.argNames?.length && - value.endsWith(' ') - ) { - const argsText = value.slice(spaceIndex + 1) - const typedArgs = parseArguments(argsText) - commandArgumentHint = generateProgressiveArgumentHint( - exactMatch.argNames, - typedArgs, - ) + else if (exactMatch?.type === 'prompt' && exactMatch.argNames?.length && value.endsWith(' ')) { + const argsText = value.slice(spaceIndex + 1); + const typedArgs = parseArguments(argsText); + commandArgumentHint = generateProgressiveArgumentHint(exactMatch.argNames, typedArgs); } setSuggestionsState(() => ({ commandArgumentHint, suggestions: [], selectedSuggestion: -1, - })) - setSuggestionType('none') - setMaxColumnWidth(undefined) - return + })); + setSuggestionType('none'); + setMaxColumnWidth(undefined); + return; } } @@ -981,59 +840,45 @@ export function useTypeahead({ // (set above when hasExactlyOneTrailingSpace is true) } - const commandItems = generateCommandSuggestions(value, commands) + const commandItems = generateCommandSuggestions(value, commands); setSuggestionsState(() => ({ commandArgumentHint, suggestions: commandItems, selectedSuggestion: commandItems.length > 0 ? 0 : -1, - })) - setSuggestionType(commandItems.length > 0 ? 'command' : 'none') + })); + setSuggestionType(commandItems.length > 0 ? 'command' : 'none'); // Use stable width from all commands (prevents layout shift when filtering) if (commandItems.length > 0) { - setMaxColumnWidth(allCommandsMaxWidth) + setMaxColumnWidth(allCommandsMaxWidth); } - return + return; } if (suggestionType === 'command') { // If we had command suggestions but the input no longer starts with '/' // we need to clear the suggestions. However, we should not return // because there may be relevant @ symbol and file suggestions. - debouncedFetchFileSuggestions.cancel() - clearSuggestions() - } else if ( - isCommandInput(value) && - hasCommandWithArguments(isAtEndWithWhitespace, value) - ) { + debouncedFetchFileSuggestions.cancel(); + clearSuggestions(); + } else if (isCommandInput(value) && hasCommandWithArguments(isAtEndWithWhitespace, value)) { // If we have a command with arguments (no trailing space), clear any stale hint // This prevents the hint from flashing when transitioning between states - setSuggestionsState(prev => - prev.commandArgumentHint - ? { ...prev, commandArgumentHint: undefined } - : prev, - ) + setSuggestionsState(prev => (prev.commandArgumentHint ? { ...prev, commandArgumentHint: undefined } : prev)); } if (suggestionType === 'custom-title') { // If we had custom-title suggestions but the input is no longer /resume // we need to clear the suggestions. - clearSuggestions() + clearSuggestions(); } - if ( - suggestionType === 'agent' && - suggestionsRef.current.some((s: SuggestionItem) => - s.id?.startsWith('dm-'), - ) - ) { + if (suggestionType === 'agent' && suggestionsRef.current.some((s: SuggestionItem) => s.id?.startsWith('dm-'))) { // If we had team member suggestions but the input no longer has @ // we need to clear the suggestions. - const hasAt = value - .substring(0, effectiveCursorOffset) - .match(/(^|\s)@([\w-]*)$/) + const hasAt = value.substring(0, effectiveCursorOffset).match(/(^|\s)@([\w-]*)$/); if (!hasAt) { - clearSuggestions() + clearSuggestions(); } } @@ -1041,80 +886,66 @@ export function useTypeahead({ // Skip @ autocomplete in bash mode - @ has no special meaning in shell commands if (hasAtSymbol && mode !== 'bash') { // Get the @ token (including the @ symbol) - const completionToken = extractCompletionToken( - value, - effectiveCursorOffset, - true, - ) + const completionToken = extractCompletionToken(value, effectiveCursorOffset, true); if (completionToken && completionToken.token.startsWith('@')) { - const searchToken = extractSearchToken(completionToken) + const searchToken = extractSearchToken(completionToken); // If the token after @ is path-like, use path completion instead of fuzzy search // This handles cases like @~/path, @./path, @/path for directory traversal if (isPathLikeToken(searchToken)) { - latestPathTokenRef.current = searchToken + latestPathTokenRef.current = searchToken; const pathSuggestions = await getPathCompletions(searchToken, { maxResults: 10, - }) + }); // Discard stale results if a newer query was initiated while waiting if (latestPathTokenRef.current !== searchToken) { - return + return; } if (pathSuggestions.length > 0) { setSuggestionsState(prev => ({ suggestions: pathSuggestions, - selectedSuggestion: getPreservedSelection( - prev.suggestions, - prev.selectedSuggestion, - pathSuggestions, - ), + selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, pathSuggestions), commandArgumentHint: undefined, - })) - setSuggestionType('directory') - return + })); + setSuggestionType('directory'); + return; } } // Skip if we already fetched for this exact token (prevents loop from // suggestions dependency causing updateSuggestions to be recreated) if (latestSearchTokenRef.current === searchToken) { - return + return; } - void debouncedFetchFileSuggestions(searchToken, true) - return + void debouncedFetchFileSuggestions(searchToken, true); + return; } } // If we have active file suggestions or the input changed, check for file suggestions if (suggestionType === 'file') { - const completionToken = extractCompletionToken( - value, - effectiveCursorOffset, - true, - ) + const completionToken = extractCompletionToken(value, effectiveCursorOffset, true); if (completionToken) { - const searchToken = extractSearchToken(completionToken) + const searchToken = extractSearchToken(completionToken); // Skip if we already fetched for this exact token if (latestSearchTokenRef.current === searchToken) { - return + return; } - void debouncedFetchFileSuggestions(searchToken, false) + void debouncedFetchFileSuggestions(searchToken, false); } else { // If we had file suggestions but now there's no completion token - debouncedFetchFileSuggestions.cancel() - clearSuggestions() + debouncedFetchFileSuggestions.cancel(); + clearSuggestions(); } } // Clear shell suggestions if not in bash mode OR if input has changed if (suggestionType === 'shell') { - const inputSnapshot = ( - suggestionsRef.current[0]?.metadata as { inputSnapshot?: string } - )?.inputSnapshot + const inputSnapshot = (suggestionsRef.current[0]?.metadata as { inputSnapshot?: string })?.inputSnapshot; if (mode !== 'bash' || value !== inputSnapshot) { - debouncedFetchFileSuggestions.cancel() - clearSuggestions() + debouncedFetchFileSuggestions.cancel(); + clearSuggestions(); } } }, @@ -1131,7 +962,7 @@ export function useTypeahead({ // this callback when only selectedSuggestion changes (not the suggestions list) allCommandsMaxWidth, ], - ) + ); // Update suggestions when input changes // Note: We intentionally don't depend on cursorOffset here - cursor movement alone @@ -1140,19 +971,19 @@ export function useTypeahead({ useEffect(() => { // If suggestions were dismissed for this exact input, don't re-trigger if (dismissedForInputRef.current === input) { - return + return; } // When the actual input text changes (not just updateSuggestions being recreated), // reset the search token ref so the same query can be re-fetched. // This fixes: type @readme.md, clear, retype @readme.md → no suggestions. if (prevInputRef.current !== input) { - prevInputRef.current = input - latestSearchTokenRef.current = null + prevInputRef.current = input; + latestSearchTokenRef.current = null; } // Clear the dismissed state when input changes - dismissedForInputRef.current = null - void updateSuggestions(input) - }, [input, updateSuggestions]) + dismissedForInputRef.current = null; + void updateSuggestions(input); + }, [input, updateSuggestions]); // Handle tab key press - complete suggestions or trigger file suggestions const handleTab = useCallback(async () => { @@ -1161,42 +992,35 @@ export function useTypeahead({ // Check for bash mode history completion first if (mode === 'bash') { // Replace the input with the full command from history - onInputChange(effectiveGhostText.fullCommand) - setCursorOffset(effectiveGhostText.fullCommand.length) - setInlineGhostText(undefined) - return + onInputChange(effectiveGhostText.fullCommand); + setCursorOffset(effectiveGhostText.fullCommand.length); + setInlineGhostText(undefined); + return; } // Find the mid-input command to get its position (for prompt mode) - const midInputCommand = findMidInputSlashCommand(input, cursorOffset) + const midInputCommand = findMidInputSlashCommand(input, cursorOffset); if (midInputCommand) { // Replace the partial command with the full command + space - const before = input.slice(0, midInputCommand.startPos) - const after = input.slice( - midInputCommand.startPos + midInputCommand.token.length, - ) - const newInput = - before + '/' + effectiveGhostText.fullCommand + ' ' + after - const newCursorOffset = - midInputCommand.startPos + - 1 + - effectiveGhostText.fullCommand.length + - 1 - - onInputChange(newInput) - setCursorOffset(newCursorOffset) - return + const before = input.slice(0, midInputCommand.startPos); + const after = input.slice(midInputCommand.startPos + midInputCommand.token.length); + const newInput = before + '/' + effectiveGhostText.fullCommand + ' ' + after; + const newCursorOffset = midInputCommand.startPos + 1 + effectiveGhostText.fullCommand.length + 1; + + onInputChange(newInput); + setCursorOffset(newCursorOffset); + return; } } // If we have active suggestions, select one if (suggestions.length > 0) { // Cancel any pending debounced fetches to prevent flicker when accepting - debouncedFetchFileSuggestions.cancel() - debouncedFetchSlackChannels.cancel() + debouncedFetchFileSuggestions.cancel(); + debouncedFetchSlackChannels.cancel(); - const index = selectedSuggestion === -1 ? 0 : selectedSuggestion - const suggestion = suggestions[index] + const index = selectedSuggestion === -1 ? 0 : selectedSuggestion; + const suggestion = suggestions[index]; if (suggestionType === 'command' && index < suggestions.length) { if (suggestion) { @@ -1207,103 +1031,87 @@ export function useTypeahead({ onInputChange, setCursorOffset, onSubmit, - ) - clearSuggestions() + ); + clearSuggestions(); } } else if (suggestionType === 'custom-title' && suggestions.length > 0) { // Apply custom title to /resume command with sessionId if (suggestion) { - const newInput = buildResumeInputFromSuggestion(suggestion) - onInputChange(newInput) - setCursorOffset(newInput.length) - clearSuggestions() + const newInput = buildResumeInputFromSuggestion(suggestion); + onInputChange(newInput); + setCursorOffset(newInput.length); + clearSuggestions(); } } else if (suggestionType === 'directory' && suggestions.length > 0) { - const suggestion = suggestions[index] + const suggestion = suggestions[index]; if (suggestion) { // Check if this is a command context (e.g., /add-dir) or general path completion - const isInCommandContext = isCommandInput(input) + const isInCommandContext = isCommandInput(input); - let newInput: string + let newInput: string; if (isInCommandContext) { // Command context: replace just the argument portion - const spaceIndex = input.indexOf(' ') - const commandPart = input.slice(0, spaceIndex + 1) // Include the space + const spaceIndex = input.indexOf(' '); + const commandPart = input.slice(0, spaceIndex + 1); // Include the space const cmdSuffix = - isPathMetadata(suggestion.metadata) && - suggestion.metadata.type === 'directory' - ? '/' - : ' ' - newInput = commandPart + suggestion.id + cmdSuffix - - onInputChange(newInput) - setCursorOffset(newInput.length) - - if ( - isPathMetadata(suggestion.metadata) && - suggestion.metadata.type === 'directory' - ) { + isPathMetadata(suggestion.metadata) && suggestion.metadata.type === 'directory' ? '/' : ' '; + newInput = commandPart + suggestion.id + cmdSuffix; + + onInputChange(newInput); + setCursorOffset(newInput.length); + + if (isPathMetadata(suggestion.metadata) && suggestion.metadata.type === 'directory') { // For directories, fetch new suggestions for the updated path setSuggestionsState(prev => ({ ...prev, commandArgumentHint: undefined, - })) - void updateSuggestions(newInput, newInput.length) + })); + void updateSuggestions(newInput, newInput.length); } else { - clearSuggestions() + clearSuggestions(); } } else { // General path completion: replace the path token in input with @-prefixed path // Try to get token with @ prefix first to check if already prefixed - const completionTokenWithAt = extractCompletionToken( - input, - cursorOffset, - true, - ) - const completionToken = - completionTokenWithAt ?? - extractCompletionToken(input, cursorOffset, false) + const completionTokenWithAt = extractCompletionToken(input, cursorOffset, true); + const completionToken = completionTokenWithAt ?? extractCompletionToken(input, cursorOffset, false); if (completionToken) { - const isDir = - isPathMetadata(suggestion.metadata) && - suggestion.metadata.type === 'directory' + const isDir = isPathMetadata(suggestion.metadata) && suggestion.metadata.type === 'directory'; const result = applyDirectorySuggestion( input, suggestion.id, completionToken.startPos, completionToken.token.length, isDir, - ) - newInput = result.newInput + ); + newInput = result.newInput; - onInputChange(newInput) - setCursorOffset(result.cursorPos) + onInputChange(newInput); + setCursorOffset(result.cursorPos); if (isDir) { // For directories, fetch new suggestions for the updated path setSuggestionsState(prev => ({ ...prev, commandArgumentHint: undefined, - })) - void updateSuggestions(newInput, result.cursorPos) + })); + void updateSuggestions(newInput, result.cursorPos); } else { // For files, clear suggestions - clearSuggestions() + clearSuggestions(); } } else { // No completion token found (e.g., cursor after space) - just clear suggestions // without modifying input to avoid data loss - clearSuggestions() + clearSuggestions(); } } } } else if (suggestionType === 'shell' && suggestions.length > 0) { - const suggestion = suggestions[index] + const suggestion = suggestions[index]; if (suggestion) { - const metadata = suggestion.metadata as - | { completionType: ShellCompletionType } - | undefined + const metadata = suggestion.metadata as { completionType: ShellCompletionType } | undefined; applyShellSuggestion( suggestion, input, @@ -1311,66 +1119,42 @@ export function useTypeahead({ onInputChange, setCursorOffset, metadata?.completionType, - ) - clearSuggestions() + ); + clearSuggestions(); } - } else if ( - suggestionType === 'agent' && - suggestions.length > 0 && - suggestions[index]?.id?.startsWith('dm-') - ) { - const suggestion = suggestions[index] + } else if (suggestionType === 'agent' && suggestions.length > 0 && suggestions[index]?.id?.startsWith('dm-')) { + const suggestion = suggestions[index]; if (suggestion) { - applyTriggerSuggestion( - suggestion, - input, - cursorOffset, - DM_MEMBER_RE, - onInputChange, - setCursorOffset, - ) - clearSuggestions() + applyTriggerSuggestion(suggestion, input, cursorOffset, DM_MEMBER_RE, onInputChange, setCursorOffset); + clearSuggestions(); } } else if (suggestionType === 'slack-channel' && suggestions.length > 0) { - const suggestion = suggestions[index] + const suggestion = suggestions[index]; if (suggestion) { - applyTriggerSuggestion( - suggestion, - input, - cursorOffset, - HASH_CHANNEL_RE, - onInputChange, - setCursorOffset, - ) - clearSuggestions() + applyTriggerSuggestion(suggestion, input, cursorOffset, HASH_CHANNEL_RE, onInputChange, setCursorOffset); + clearSuggestions(); } } else if (suggestionType === 'file' && suggestions.length > 0) { - const completionToken = extractCompletionToken( - input, - cursorOffset, - true, - ) + const completionToken = extractCompletionToken(input, cursorOffset, true); if (!completionToken) { - clearSuggestions() - return + clearSuggestions(); + return; } // Check if all suggestions share a common prefix longer than the current input - const commonPrefix = findLongestCommonPrefix(suggestions) + const commonPrefix = findLongestCommonPrefix(suggestions); // Determine if token starts with @ to preserve it during replacement - const hasAtPrefix = completionToken.token.startsWith('@') + const hasAtPrefix = completionToken.token.startsWith('@'); // The effective token length excludes the @ and quotes if present - let effectiveTokenLength: number + let effectiveTokenLength: number; if (completionToken.isQuoted) { // Remove @" prefix and optional closing " to get effective length - effectiveTokenLength = completionToken.token - .slice(2) - .replace(/"$/, '').length + effectiveTokenLength = completionToken.token.slice(2).replace(/"$/, '').length; } else if (hasAtPrefix) { - effectiveTokenLength = completionToken.token.length - 1 + effectiveTokenLength = completionToken.token.length - 1; } else { - effectiveTokenLength = completionToken.token.length + effectiveTokenLength = completionToken.token.length; } // If there's a common prefix longer than what the user has typed, @@ -1383,7 +1167,7 @@ export function useTypeahead({ needsQuotes: false, // common prefix doesn't need quotes unless already quoted isQuoted: completionToken.isQuoted, isComplete: false, // partial completion - }) + }); applyFileSuggestion( replacementValue, @@ -1392,18 +1176,15 @@ export function useTypeahead({ completionToken.startPos, onInputChange, setCursorOffset, - ) + ); // Don't clear suggestions so user can continue typing or select a specific option // Instead, update for the new prefix - void updateSuggestions( - input.replace(completionToken.token, replacementValue), - cursorOffset, - ) + void updateSuggestions(input.replace(completionToken.token, replacementValue), cursorOffset); } else if (index < suggestions.length) { // Otherwise, apply the selected suggestion - const suggestion = suggestions[index] + const suggestion = suggestions[index]; if (suggestion) { - const needsQuotes = suggestion.displayText.includes(' ') + const needsQuotes = suggestion.displayText.includes(' '); const replacementValue = formatReplacementValue({ displayText: suggestion.displayText, mode, @@ -1411,7 +1192,7 @@ export function useTypeahead({ needsQuotes, isQuoted: completionToken.isQuoted, isComplete: true, // complete suggestion - }) + }); applyFileSuggestion( replacementValue, @@ -1420,29 +1201,24 @@ export function useTypeahead({ completionToken.startPos, onInputChange, setCursorOffset, - ) - clearSuggestions() + ); + clearSuggestions(); } } } } else if (input.trim() !== '') { - let suggestionType: SuggestionType - let suggestionItems: SuggestionItem[] + let suggestionType: SuggestionType; + let suggestionItems: SuggestionItem[]; if (mode === 'bash') { - suggestionType = 'shell' + suggestionType = 'shell'; // This should be very fast, taking <10ms - const bashSuggestions = await generateBashSuggestions( - input, - cursorOffset, - ) + const bashSuggestions = await generateBashSuggestions(input, cursorOffset); if (bashSuggestions.length === 1) { // If single suggestion, apply it immediately - const suggestion = bashSuggestions[0] + const suggestion = bashSuggestions[0]; if (suggestion) { - const metadata = suggestion.metadata as - | { completionType: ShellCompletionType } - | undefined + const metadata = suggestion.metadata as { completionType: ShellCompletionType } | undefined; applyShellSuggestion( suggestion, input, @@ -1450,31 +1226,24 @@ export function useTypeahead({ onInputChange, setCursorOffset, metadata?.completionType, - ) + ); } - suggestionItems = [] + suggestionItems = []; } else { - suggestionItems = bashSuggestions + suggestionItems = bashSuggestions; } } else { - suggestionType = 'file' + suggestionType = 'file'; // If no suggestions, fetch file and MCP resource suggestions - const completionInfo = extractCompletionToken(input, cursorOffset, true) + const completionInfo = extractCompletionToken(input, cursorOffset, true); if (completionInfo) { // If token starts with @, search without the @ prefix - const isAtSymbol = completionInfo.token.startsWith('@') - const searchToken = isAtSymbol - ? completionInfo.token.substring(1) - : completionInfo.token - - suggestionItems = await generateUnifiedSuggestions( - searchToken, - mcpResources, - agents, - isAtSymbol, - ) + const isAtSymbol = completionInfo.token.startsWith('@'); + const searchToken = isAtSymbol ? completionInfo.token.substring(1) : completionInfo.token; + + suggestionItems = await generateUnifiedSuggestions(searchToken, mcpResources, agents, isAtSymbol); } else { - suggestionItems = [] + suggestionItems = []; } } @@ -1483,14 +1252,10 @@ export function useTypeahead({ setSuggestionsState(prev => ({ commandArgumentHint: undefined, suggestions: suggestionItems, - selectedSuggestion: getPreservedSelection( - prev.suggestions, - prev.selectedSuggestion, - suggestionItems, - ), - })) - setSuggestionType(suggestionType) - setMaxColumnWidth(undefined) + selectedSuggestion: getPreservedSelection(prev.suggestions, prev.selectedSuggestion, suggestionItems), + })); + setSuggestionType(suggestionType); + setMaxColumnWidth(undefined); } } }, [ @@ -1512,18 +1277,15 @@ export function useTypeahead({ debouncedFetchFileSuggestions, debouncedFetchSlackChannels, effectiveGhostText, - ]) + ]); // Handle enter key press - apply and execute suggestions const handleEnter = useCallback(() => { - if (selectedSuggestion < 0 || suggestions.length === 0) return + if (selectedSuggestion < 0 || suggestions.length === 0) return; - const suggestion = suggestions[selectedSuggestion] + const suggestion = suggestions[selectedSuggestion]; - if ( - suggestionType === 'command' && - selectedSuggestion < suggestions.length - ) { + if (suggestionType === 'command' && selectedSuggestion < suggestions.length) { if (suggestion) { applyCommandSuggestion( suggestion, @@ -1532,84 +1294,49 @@ export function useTypeahead({ onInputChange, setCursorOffset, onSubmit, - ) - debouncedFetchFileSuggestions.cancel() - clearSuggestions() + ); + debouncedFetchFileSuggestions.cancel(); + clearSuggestions(); } - } else if ( - suggestionType === 'custom-title' && - selectedSuggestion < suggestions.length - ) { + } else if (suggestionType === 'custom-title' && selectedSuggestion < suggestions.length) { // Apply custom title and execute /resume command with sessionId if (suggestion) { - const newInput = buildResumeInputFromSuggestion(suggestion) - onInputChange(newInput) - setCursorOffset(newInput.length) - onSubmit(newInput, /* isSubmittingSlashCommand */ true) - debouncedFetchFileSuggestions.cancel() - clearSuggestions() + const newInput = buildResumeInputFromSuggestion(suggestion); + onInputChange(newInput); + setCursorOffset(newInput.length); + onSubmit(newInput, /* isSubmittingSlashCommand */ true); + debouncedFetchFileSuggestions.cancel(); + clearSuggestions(); } - } else if ( - suggestionType === 'shell' && - selectedSuggestion < suggestions.length - ) { - const suggestion = suggestions[selectedSuggestion] + } else if (suggestionType === 'shell' && selectedSuggestion < suggestions.length) { + const suggestion = suggestions[selectedSuggestion]; if (suggestion) { - const metadata = suggestion.metadata as - | { completionType: ShellCompletionType } - | undefined - applyShellSuggestion( - suggestion, - input, - cursorOffset, - onInputChange, - setCursorOffset, - metadata?.completionType, - ) - debouncedFetchFileSuggestions.cancel() - clearSuggestions() + const metadata = suggestion.metadata as { completionType: ShellCompletionType } | undefined; + applyShellSuggestion(suggestion, input, cursorOffset, onInputChange, setCursorOffset, metadata?.completionType); + debouncedFetchFileSuggestions.cancel(); + clearSuggestions(); } } else if ( suggestionType === 'agent' && selectedSuggestion < suggestions.length && suggestion?.id?.startsWith('dm-') ) { - applyTriggerSuggestion( - suggestion, - input, - cursorOffset, - DM_MEMBER_RE, - onInputChange, - setCursorOffset, - ) - debouncedFetchFileSuggestions.cancel() - clearSuggestions() - } else if ( - suggestionType === 'slack-channel' && - selectedSuggestion < suggestions.length - ) { + applyTriggerSuggestion(suggestion, input, cursorOffset, DM_MEMBER_RE, onInputChange, setCursorOffset); + debouncedFetchFileSuggestions.cancel(); + clearSuggestions(); + } else if (suggestionType === 'slack-channel' && selectedSuggestion < suggestions.length) { if (suggestion) { - applyTriggerSuggestion( - suggestion, - input, - cursorOffset, - HASH_CHANNEL_RE, - onInputChange, - setCursorOffset, - ) - debouncedFetchSlackChannels.cancel() - clearSuggestions() + applyTriggerSuggestion(suggestion, input, cursorOffset, HASH_CHANNEL_RE, onInputChange, setCursorOffset); + debouncedFetchSlackChannels.cancel(); + clearSuggestions(); } - } else if ( - suggestionType === 'file' && - selectedSuggestion < suggestions.length - ) { + } else if (suggestionType === 'file' && selectedSuggestion < suggestions.length) { // Extract completion token directly when needed - const completionInfo = extractCompletionToken(input, cursorOffset, true) + const completionInfo = extractCompletionToken(input, cursorOffset, true); if (completionInfo) { if (suggestion) { - const hasAtPrefix = completionInfo.token.startsWith('@') - const needsQuotes = suggestion.displayText.includes(' ') + const hasAtPrefix = completionInfo.token.startsWith('@'); + const needsQuotes = suggestion.displayText.includes(' '); const replacementValue = formatReplacementValue({ displayText: suggestion.displayText, mode, @@ -1617,7 +1344,7 @@ export function useTypeahead({ needsQuotes, isQuoted: completionInfo.isQuoted, isComplete: true, // complete suggestion - }) + }); applyFileSuggestion( replacementValue, @@ -1626,54 +1353,43 @@ export function useTypeahead({ completionInfo.startPos, onInputChange, setCursorOffset, - ) - debouncedFetchFileSuggestions.cancel() - clearSuggestions() + ); + debouncedFetchFileSuggestions.cancel(); + clearSuggestions(); } } - } else if ( - suggestionType === 'directory' && - selectedSuggestion < suggestions.length - ) { + } else if (suggestionType === 'directory' && selectedSuggestion < suggestions.length) { if (suggestion) { // In command context (e.g., /add-dir), Enter submits the command // rather than applying the directory suggestion. Just clear // suggestions and let the submit handler process the current input. if (isCommandInput(input)) { - debouncedFetchFileSuggestions.cancel() - clearSuggestions() - return + debouncedFetchFileSuggestions.cancel(); + clearSuggestions(); + return; } // General path completion: replace the path token - const completionTokenWithAt = extractCompletionToken( - input, - cursorOffset, - true, - ) - const completionToken = - completionTokenWithAt ?? - extractCompletionToken(input, cursorOffset, false) + const completionTokenWithAt = extractCompletionToken(input, cursorOffset, true); + const completionToken = completionTokenWithAt ?? extractCompletionToken(input, cursorOffset, false); if (completionToken) { - const isDir = - isPathMetadata(suggestion.metadata) && - suggestion.metadata.type === 'directory' + const isDir = isPathMetadata(suggestion.metadata) && suggestion.metadata.type === 'directory'; const result = applyDirectorySuggestion( input, suggestion.id, completionToken.startPos, completionToken.token.length, isDir, - ) - onInputChange(result.newInput) - setCursorOffset(result.cursorPos) + ); + onInputChange(result.newInput); + setCursorOffset(result.cursorPos); } // If no completion token found (e.g., cursor after space), don't modify input // to avoid data loss - just clear suggestions - debouncedFetchFileSuggestions.cancel() - clearSuggestions() + debouncedFetchFileSuggestions.cancel(); + clearSuggestions(); } } }, [ @@ -1690,48 +1406,37 @@ export function useTypeahead({ clearSuggestions, debouncedFetchFileSuggestions, debouncedFetchSlackChannels, - ]) + ]); // Handler for autocomplete:accept - accepts current suggestion via Tab or Right Arrow const handleAutocompleteAccept = useCallback(() => { - void handleTab() - }, [handleTab]) + void handleTab(); + }, [handleTab]); // Handler for autocomplete:dismiss - clears suggestions and prevents re-triggering const handleAutocompleteDismiss = useCallback(() => { - debouncedFetchFileSuggestions.cancel() - debouncedFetchSlackChannels.cancel() - clearSuggestions() + debouncedFetchFileSuggestions.cancel(); + debouncedFetchSlackChannels.cancel(); + clearSuggestions(); // Remember the input when dismissed to prevent immediate re-triggering - dismissedForInputRef.current = input - }, [ - debouncedFetchFileSuggestions, - debouncedFetchSlackChannels, - clearSuggestions, - input, - ]) + dismissedForInputRef.current = input; + }, [debouncedFetchFileSuggestions, debouncedFetchSlackChannels, clearSuggestions, input]); // Handler for autocomplete:previous - selects previous suggestion const handleAutocompletePrevious = useCallback(() => { setSuggestionsState(prev => ({ ...prev, - selectedSuggestion: - prev.selectedSuggestion <= 0 - ? suggestions.length - 1 - : prev.selectedSuggestion - 1, - })) - }, [suggestions.length, setSuggestionsState]) + selectedSuggestion: prev.selectedSuggestion <= 0 ? suggestions.length - 1 : prev.selectedSuggestion - 1, + })); + }, [suggestions.length, setSuggestionsState]); // Handler for autocomplete:next - selects next suggestion const handleAutocompleteNext = useCallback(() => { setSuggestionsState(prev => ({ ...prev, - selectedSuggestion: - prev.selectedSuggestion >= suggestions.length - 1 - ? 0 - : prev.selectedSuggestion + 1, - })) - }, [suggestions.length, setSuggestionsState]) + selectedSuggestion: prev.selectedSuggestion >= suggestions.length - 1 ? 0 : prev.selectedSuggestion + 1, + })); + }, [suggestions.length, setSuggestionsState]); // Autocomplete context keybindings - only active when suggestions are visible const autocompleteHandlers = useMemo( @@ -1741,40 +1446,35 @@ export function useTypeahead({ 'autocomplete:previous': handleAutocompletePrevious, 'autocomplete:next': handleAutocompleteNext, }), - [ - handleAutocompleteAccept, - handleAutocompleteDismiss, - handleAutocompletePrevious, - handleAutocompleteNext, - ], - ) + [handleAutocompleteAccept, handleAutocompleteDismiss, handleAutocompletePrevious, handleAutocompleteNext], + ); // Register autocomplete as an overlay so CancelRequestHandler defers ESC handling // This ensures ESC dismisses autocomplete before canceling running tasks - const isAutocompleteActive = suggestions.length > 0 || !!effectiveGhostText - const isModalOverlayActive = useIsModalOverlayActive() - useRegisterOverlay('autocomplete', isAutocompleteActive) + const isAutocompleteActive = suggestions.length > 0 || !!effectiveGhostText; + const isModalOverlayActive = useIsModalOverlayActive(); + useRegisterOverlay('autocomplete', isAutocompleteActive); // Register Autocomplete context so it appears in activeContexts for other handlers. // This allows Chat's resolver to see Autocomplete and defer to its bindings for up/down. - useRegisterKeybindingContext('Autocomplete', isAutocompleteActive) + useRegisterKeybindingContext('Autocomplete', isAutocompleteActive); // Disable autocomplete keybindings when a modal overlay (e.g., DiffDialog) is active, // so escape reaches the overlay's handler instead of dismissing autocomplete useKeybindings(autocompleteHandlers, { context: 'Autocomplete', isActive: isAutocompleteActive && !isModalOverlayActive, - }) + }); function acceptSuggestionText(text: string): void { - const detectedMode = getModeFromInput(text) + const detectedMode = getModeFromInput(text); if (detectedMode !== 'prompt' && onModeChange) { - onModeChange(detectedMode) - const stripped = getValueFromInput(text) - onInputChange(stripped) - setCursorOffset(stripped.length) + onModeChange(detectedMode); + const stripped = getValueFromInput(text); + onInputChange(stripped); + setCursorOffset(stripped.length); } else { - onInputChange(text) - setCursorOffset(text.length) + onInputChange(text); + setCursorOffset(text.length); } } @@ -1782,13 +1482,13 @@ export function useTypeahead({ const handleKeyDown = (e: KeyboardEvent): void => { // Handle right arrow to accept prompt suggestion ghost text if (e.key === 'right' && !isViewingTeammate) { - const suggestionText = promptSuggestion.text - const suggestionShownAt = promptSuggestion.shownAt + const suggestionText = promptSuggestion.text; + const suggestionShownAt = promptSuggestion.shownAt; if (suggestionText && suggestionShownAt > 0 && input === '') { - markAccepted() - acceptSuggestionText(suggestionText) - e.stopImmediatePropagation() - return + markAccepted(); + acceptSuggestionText(suggestionText); + e.stopImmediatePropagation(); + return; } } @@ -1797,77 +1497,68 @@ export function useTypeahead({ if (e.key === 'tab' && !e.shift) { // Skip if autocomplete is handling this (suggestions or ghost text exist) if (suggestions.length > 0 || effectiveGhostText) { - return + return; } // Accept prompt suggestion if it exists in AppState - const suggestionText = promptSuggestion.text - const suggestionShownAt = promptSuggestion.shownAt - if ( - suggestionText && - suggestionShownAt > 0 && - input === '' && - !isViewingTeammate - ) { - e.preventDefault() - markAccepted() - acceptSuggestionText(suggestionText) - return + const suggestionText = promptSuggestion.text; + const suggestionShownAt = promptSuggestion.shownAt; + if (suggestionText && suggestionShownAt > 0 && input === '' && !isViewingTeammate) { + e.preventDefault(); + markAccepted(); + acceptSuggestionText(suggestionText); + return; } // Remind user about thinking toggle shortcut if empty input if (input.trim() === '') { - e.preventDefault() + e.preventDefault(); addNotification({ key: 'thinking-toggle-hint', - jsx: ( - - Use {thinkingToggleShortcut} to toggle thinking - - ), + jsx: Use {thinkingToggleShortcut} to toggle thinking, priority: 'immediate', timeoutMs: 3000, - }) + }); } - return + return; } // Only continue with navigation if we have suggestions - if (suggestions.length === 0) return + if (suggestions.length === 0) return; // Handle Ctrl-N/P for navigation (arrows handled by keybindings) // Skip if we're in the middle of a chord sequence to allow chords like ctrl+f n - const hasPendingChord = keybindingContext?.pendingChord != null + const hasPendingChord = keybindingContext?.pendingChord != null; if (e.ctrl && e.key === 'n' && !hasPendingChord) { - e.preventDefault() - handleAutocompleteNext() - return + e.preventDefault(); + handleAutocompleteNext(); + return; } if (e.ctrl && e.key === 'p' && !hasPendingChord) { - e.preventDefault() - handleAutocompletePrevious() - return + e.preventDefault(); + handleAutocompletePrevious(); + return; } // Handle selection and execution via return/enter // Shift+Enter and Meta+Enter insert newlines (handled by useTextInput), // so don't accept the suggestion for those. if (e.key === 'return' && !e.shift && !e.meta) { - e.preventDefault() - handleEnter() + e.preventDefault(); + handleEnter(); } - } + }; // Backward-compat bridge: PromptInput doesn't yet wire handleKeyDown to // . Subscribe via useInput and adapt InputEvent → // KeyboardEvent until the consumer is migrated (separate PR). // TODO(onKeyDown-migration): remove once PromptInput passes handleKeyDown. useInput((_input, _key, event) => { - const kbEvent = new KeyboardEvent(event.keypress) - handleKeyDown(kbEvent) + const kbEvent = new KeyboardEvent(event.keypress); + handleKeyDown(kbEvent); if (kbEvent.didStopImmediatePropagation()) { - event.stopImmediatePropagation() + event.stopImmediatePropagation(); } - }) + }); return { suggestions, @@ -1877,5 +1568,5 @@ export function useTypeahead({ commandArgumentHint, inlineGhostText: effectiveGhostText, handleKeyDown, - } + }; } diff --git a/src/hooks/useVoiceIntegration.tsx b/src/hooks/useVoiceIntegration.tsx index 7cedb1c0f..aaf9c452d 100644 --- a/src/hooks/useVoiceIntegration.tsx +++ b/src/hooks/useVoiceIntegration.tsx @@ -1,85 +1,70 @@ -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 } from '../ink/events/keyboard-event.js' +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 } from '../ink/events/keyboard-event.js'; // eslint-disable-next-line custom-rules/prefer-use-keybindings -- backward-compat bridge until REPL wires handleKeyDown to -import { useInput } from '../ink.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' +import { useInput } from '../ink.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. @@ -93,60 +78,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 @@ -161,29 +146,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 @@ -193,26 +171,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 @@ -221,123 +199,116 @@ 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) + : ('idle' as const); const voiceInterimTranscript = feature('VOICE_MODE') ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant 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, @@ -348,34 +319,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, - } + }; } /** @@ -408,21 +376,21 @@ 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() + 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 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' + : 'idle'; // Find the configured key for voice:pushToTalk from keybinding context. // Forward iteration with last-wins (matching the resolver): if a later @@ -434,22 +402,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"), @@ -466,9 +434,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 @@ -477,15 +445,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 @@ -493,19 +461,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). @@ -515,37 +483,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 @@ -555,22 +518,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. @@ -580,12 +543,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 @@ -595,37 +558,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 @@ -634,10 +597,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) ── @@ -650,43 +613,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 → @@ -694,30 +657,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/ink/Ansi.tsx b/src/ink/Ansi.tsx index f6ff7f7de..6d3beea75 100644 --- a/src/ink/Ansi.tsx +++ b/src/ink/Ansi.tsx @@ -1,31 +1,26 @@ -import React from 'react' -import Link from './components/Link.js' -import Text from './components/Text.js' -import type { Color } from './styles.js' -import { - type NamedColor, - Parser, - type Color as TermioColor, - type TextStyle, -} from './termio.js' +import React from 'react'; +import Link from './components/Link.js'; +import Text from './components/Text.js'; +import type { Color } from './styles.js'; +import { type NamedColor, Parser, type Color as TermioColor, type TextStyle } from './termio.js'; type Props = { - children: string + children: string; /** When true, force all text to be rendered with dim styling */ - dimColor?: boolean -} + dimColor?: boolean; +}; type SpanProps = { - color?: Color - backgroundColor?: Color - dim?: boolean - bold?: boolean - italic?: boolean - underline?: boolean - strikethrough?: boolean - inverse?: boolean - hyperlink?: string -} + color?: Color; + backgroundColor?: Color; + dim?: boolean; + bold?: boolean; + italic?: boolean; + underline?: boolean; + strikethrough?: boolean; + inverse?: boolean; + hyperlink?: string; +}; /** * Component that parses ANSI escape codes and renders them using Text components. @@ -35,43 +30,32 @@ type SpanProps = { * * Memoized to prevent re-renders when parent changes but children string is the same. */ -export const Ansi = React.memo(function Ansi({ - children, - dimColor, -}: Props): React.ReactNode { +export const Ansi = React.memo(function Ansi({ children, dimColor }: Props): React.ReactNode { if (typeof children !== 'string') { - return dimColor ? ( - {String(children)} - ) : ( - {String(children)} - ) + return dimColor ? {String(children)} : {String(children)}; } if (children === '') { - return null + return null; } - const spans = parseToSpans(children) + const spans = parseToSpans(children); if (spans.length === 0) { - return null + return null; } if (spans.length === 1 && !hasAnyProps(spans[0]!.props)) { - return dimColor ? ( - {spans[0]!.text} - ) : ( - {spans[0]!.text} - ) + return dimColor ? {spans[0]!.text} : {spans[0]!.text}; } const content = spans.map((span, i) => { - const hyperlink = span.props.hyperlink + const hyperlink = span.props.hyperlink; // When dimColor is forced, override the span's dim prop if (dimColor) { - span.props.dim = true + span.props.dim = true; } - const hasTextProps = hasAnyTextProps(span.props) + const hasTextProps = hasAnyTextProps(span.props); if (hyperlink) { return hasTextProps ? ( @@ -93,7 +77,7 @@ export const Ansi = React.memo(function Ansi({ {span.text} - ) + ); } return hasTextProps ? ( @@ -112,79 +96,79 @@ export const Ansi = React.memo(function Ansi({ ) : ( span.text - ) - }) + ); + }); - return dimColor ? {content} : {content} -}) + return dimColor ? {content} : {content}; +}); type Span = { - text: string - props: SpanProps -} + text: string; + props: SpanProps; +}; /** * Parse an ANSI string into spans using the termio parser. */ function parseToSpans(input: string): Span[] { - const parser = new Parser() - const actions = parser.feed(input) - const spans: Span[] = [] + const parser = new Parser(); + const actions = parser.feed(input); + const spans: Span[] = []; - let currentHyperlink: string | undefined + let currentHyperlink: string | undefined; for (const action of actions) { if (action.type === 'link') { if (action.action.type === 'start') { - currentHyperlink = action.action.url + currentHyperlink = action.action.url; } else { - currentHyperlink = undefined + currentHyperlink = undefined; } - continue + continue; } if (action.type === 'text') { - const text = action.graphemes.map(g => g.value).join('') - if (!text) continue + const text = action.graphemes.map(g => g.value).join(''); + if (!text) continue; - const props = textStyleToSpanProps(action.style) + const props = textStyleToSpanProps(action.style); if (currentHyperlink) { - props.hyperlink = currentHyperlink + props.hyperlink = currentHyperlink; } // Try to merge with previous span if props match - const lastSpan = spans[spans.length - 1] + const lastSpan = spans[spans.length - 1]; if (lastSpan && propsEqual(lastSpan.props, props)) { - lastSpan.text += text + lastSpan.text += text; } else { - spans.push({ text, props }) + spans.push({ text, props }); } } } - return spans + return spans; } /** * Convert termio's TextStyle to SpanProps. */ function textStyleToSpanProps(style: TextStyle): SpanProps { - const props: SpanProps = {} + const props: SpanProps = {}; - if (style.bold) props.bold = true - if (style.dim) props.dim = true - if (style.italic) props.italic = true - if (style.underline !== 'none') props.underline = true - if (style.strikethrough) props.strikethrough = true - if (style.inverse) props.inverse = true + if (style.bold) props.bold = true; + if (style.dim) props.dim = true; + if (style.italic) props.italic = true; + if (style.underline !== 'none') props.underline = true; + if (style.strikethrough) props.strikethrough = true; + if (style.inverse) props.inverse = true; - const fgColor = colorToString(style.fg) - if (fgColor) props.color = fgColor + const fgColor = colorToString(style.fg); + if (fgColor) props.color = fgColor; - const bgColor = colorToString(style.bg) - if (bgColor) props.backgroundColor = bgColor + const bgColor = colorToString(style.bg); + if (bgColor) props.backgroundColor = bgColor; - return props + return props; } // Map termio named colors to the ansi: format @@ -205,7 +189,7 @@ const NAMED_COLOR_MAP: Record = { brightMagenta: 'ansi:magentaBright', brightCyan: 'ansi:cyanBright', brightWhite: 'ansi:whiteBright', -} +}; /** * Convert termio's Color to the string format used by Ink. @@ -213,13 +197,13 @@ const NAMED_COLOR_MAP: Record = { function colorToString(color: TermioColor): Color | undefined { switch (color.type) { case 'named': - return NAMED_COLOR_MAP[color.name] as Color + return NAMED_COLOR_MAP[color.name] as Color; case 'indexed': - return `ansi256(${color.index})` as Color + return `ansi256(${color.index})` as Color; case 'rgb': - return `rgb(${color.r},${color.g},${color.b})` as Color + return `rgb(${color.r},${color.g},${color.b})` as Color; case 'default': - return undefined + return undefined; } } @@ -237,7 +221,7 @@ function propsEqual(a: SpanProps, b: SpanProps): boolean { a.strikethrough === b.strikethrough && a.inverse === b.inverse && a.hyperlink === b.hyperlink - ) + ); } function hasAnyProps(props: SpanProps): boolean { @@ -251,7 +235,7 @@ function hasAnyProps(props: SpanProps): boolean { props.strikethrough === true || props.inverse === true || props.hyperlink !== undefined - ) + ); } function hasAnyTextProps(props: SpanProps): boolean { @@ -264,18 +248,18 @@ function hasAnyTextProps(props: SpanProps): boolean { props.underline === true || props.strikethrough === true || props.inverse === true - ) + ); } // Text style props without weight (bold/dim) - these are handled separately type BaseTextStyleProps = { - color?: Color - backgroundColor?: Color - italic?: boolean - underline?: boolean - strikethrough?: boolean - inverse?: boolean -} + color?: Color; + backgroundColor?: Color; + italic?: boolean; + underline?: boolean; + strikethrough?: boolean; + inverse?: boolean; +}; // Wrapper component that handles bold/dim mutual exclusivity for Text function StyledText({ @@ -284,9 +268,9 @@ function StyledText({ children, ...rest }: BaseTextStyleProps & { - bold?: boolean - dim?: boolean - children: string + bold?: boolean; + dim?: boolean; + children: string; }): React.ReactNode { // dim takes precedence over bold when both are set (terminals treat them as mutually exclusive) if (dim) { @@ -294,14 +278,14 @@ function StyledText({ {children} - ) + ); } if (bold) { return ( {children} - ) + ); } - return {children} + return {children}; } diff --git a/src/ink/components/AlternateScreen.tsx b/src/ink/components/AlternateScreen.tsx index eeeb1152e..69de5e648 100644 --- a/src/ink/components/AlternateScreen.tsx +++ b/src/ink/components/AlternateScreen.tsx @@ -1,23 +1,14 @@ -import React, { - type PropsWithChildren, - useContext, - useInsertionEffect, -} from 'react' -import instances from '../instances.js' -import { - DISABLE_MOUSE_TRACKING, - ENABLE_MOUSE_TRACKING, - ENTER_ALT_SCREEN, - EXIT_ALT_SCREEN, -} from '../termio/dec.js' -import { TerminalWriteContext } from '../useTerminalNotification.js' -import Box from './Box.js' -import { TerminalSizeContext } from './TerminalSizeContext.js' +import React, { type PropsWithChildren, useContext, useInsertionEffect } from 'react'; +import instances from '../instances.js'; +import { DISABLE_MOUSE_TRACKING, ENABLE_MOUSE_TRACKING, ENTER_ALT_SCREEN, EXIT_ALT_SCREEN } from '../termio/dec.js'; +import { TerminalWriteContext } from '../useTerminalNotification.js'; +import Box from './Box.js'; +import { TerminalSizeContext } from './TerminalSizeContext.js'; type Props = PropsWithChildren<{ /** Enable SGR mouse tracking (wheel + click/drag). Default true. */ - mouseTracking?: boolean -}> + mouseTracking?: boolean; +}>; /** * Run children in the terminal's alternate screen buffer, constrained to @@ -39,12 +30,9 @@ type Props = PropsWithChildren<{ * from scrolling content) and so signal-exit cleanup can exit the alt * screen if the component's own unmount doesn't run. */ -export function AlternateScreen({ - children, - mouseTracking = true, -}: Props): React.ReactNode { - const size = useContext(TerminalSizeContext) - const writeRaw = useContext(TerminalWriteContext) +export function AlternateScreen({ children, mouseTracking = true }: Props): React.ReactNode { + const size = useContext(TerminalSizeContext); + const writeRaw = useContext(TerminalWriteContext); // useInsertionEffect (not useLayoutEffect): react-reconciler calls // resetAfterCommit between the mutation and layout commit phases, and @@ -57,31 +45,22 @@ export function AlternateScreen({ // Cleanup timing is unchanged: both insertion and layout effect cleanup // run in the mutation phase on unmount, before resetAfterCommit. useInsertionEffect(() => { - const ink = instances.get(process.stdout) - if (!writeRaw) return + const ink = instances.get(process.stdout); + if (!writeRaw) return; - writeRaw( - ENTER_ALT_SCREEN + - '\x1b[2J\x1b[H' + - (mouseTracking ? ENABLE_MOUSE_TRACKING : ''), - ) - ink?.setAltScreenActive(true, mouseTracking) + writeRaw(ENTER_ALT_SCREEN + '\x1b[2J\x1b[H' + (mouseTracking ? ENABLE_MOUSE_TRACKING : '')); + ink?.setAltScreenActive(true, mouseTracking); return () => { - ink?.setAltScreenActive(false) - ink?.clearTextSelection() - writeRaw((mouseTracking ? DISABLE_MOUSE_TRACKING : '') + EXIT_ALT_SCREEN) - } - }, [writeRaw, mouseTracking]) + ink?.setAltScreenActive(false); + ink?.clearTextSelection(); + writeRaw((mouseTracking ? DISABLE_MOUSE_TRACKING : '') + EXIT_ALT_SCREEN); + }; + }, [writeRaw, mouseTracking]); return ( - + {children} - ) + ); } diff --git a/src/ink/components/App.tsx b/src/ink/components/App.tsx index 9bbb0c06a..8a405bd6a 100644 --- a/src/ink/components/App.tsx +++ b/src/ink/components/App.tsx @@ -1,37 +1,25 @@ -import React, { PureComponent, type ReactNode } from 'react' -import { updateLastInteractionTime } from '../../bootstrap/state.js' -import { logForDebugging } from '../../utils/debug.js' -import { stopCapturingEarlyInput } from '../../utils/earlyInput.js' -import { isEnvTruthy } from '../../utils/envUtils.js' -import { isMouseClicksDisabled } from '../../utils/fullscreen.js' -import { logError } from '../../utils/log.js' -import { EventEmitter } from '../events/emitter.js' -import { InputEvent } from '../events/input-event.js' -import { TerminalFocusEvent } from '../events/terminal-focus-event.js' +import React, { PureComponent, type ReactNode } from 'react'; +import { updateLastInteractionTime } from '../../bootstrap/state.js'; +import { logForDebugging } from '../../utils/debug.js'; +import { stopCapturingEarlyInput } from '../../utils/earlyInput.js'; +import { isEnvTruthy } from '../../utils/envUtils.js'; +import { isMouseClicksDisabled } from '../../utils/fullscreen.js'; +import { logError } from '../../utils/log.js'; +import { EventEmitter } from '../events/emitter.js'; +import { InputEvent } from '../events/input-event.js'; +import { TerminalFocusEvent } from '../events/terminal-focus-event.js'; import { INITIAL_STATE, type ParsedInput, type ParsedKey, type ParsedMouse, parseMultipleKeypresses, -} from '../parse-keypress.js' -import reconciler from '../reconciler.js' -import { - finishSelection, - hasSelection, - type SelectionState, - startSelection, -} from '../selection.js' -import { - isXtermJs, - setXtversionName, - supportsExtendedKeys, -} from '../terminal.js' -import { - getTerminalFocused, - setTerminalFocused, -} from '../terminal-focus-state.js' -import { TerminalQuerier, xtversion } from '../terminal-querier.js' +} from '../parse-keypress.js'; +import reconciler from '../reconciler.js'; +import { finishSelection, hasSelection, type SelectionState, startSelection } from '../selection.js'; +import { isXtermJs, setXtversionName, supportsExtendedKeys } from '../terminal.js'; +import { getTerminalFocused, setTerminalFocused } from '../terminal-focus-state.js'; +import { TerminalQuerier, xtversion } from '../terminal-querier.js'; import { DISABLE_KITTY_KEYBOARD, DISABLE_MODIFY_OTHER_KEYS, @@ -39,155 +27,145 @@ import { ENABLE_MODIFY_OTHER_KEYS, FOCUS_IN, FOCUS_OUT, -} from '../termio/csi.js' -import { - DBP, - DFE, - DISABLE_MOUSE_TRACKING, - EBP, - EFE, - HIDE_CURSOR, - SHOW_CURSOR, -} from '../termio/dec.js' -import AppContext from './AppContext.js' -import { ClockProvider } from './ClockContext.js' -import CursorDeclarationContext, { - type CursorDeclarationSetter, -} from './CursorDeclarationContext.js' -import ErrorOverview from './ErrorOverview.js' -import StdinContext from './StdinContext.js' -import { TerminalFocusProvider } from './TerminalFocusContext.js' -import { TerminalSizeContext } from './TerminalSizeContext.js' +} from '../termio/csi.js'; +import { DBP, DFE, DISABLE_MOUSE_TRACKING, EBP, EFE, HIDE_CURSOR, SHOW_CURSOR } from '../termio/dec.js'; +import AppContext from './AppContext.js'; +import { ClockProvider } from './ClockContext.js'; +import CursorDeclarationContext, { type CursorDeclarationSetter } from './CursorDeclarationContext.js'; +import ErrorOverview from './ErrorOverview.js'; +import StdinContext from './StdinContext.js'; +import { TerminalFocusProvider } from './TerminalFocusContext.js'; +import { TerminalSizeContext } from './TerminalSizeContext.js'; // Platforms that support Unix-style process suspension (SIGSTOP/SIGCONT) -const SUPPORTS_SUSPEND = process.platform !== 'win32' +const SUPPORTS_SUSPEND = process.platform !== 'win32'; // After this many milliseconds of stdin silence, the next chunk triggers // a terminal mode re-assert (mouse tracking). Catches tmux detach→attach, // ssh reconnect, and laptop wake — the terminal resets DEC private modes // but no signal reaches us. 5s is well above normal inter-keystroke gaps // but short enough that the first scroll after reattach works. -const STDIN_RESUME_GAP_MS = 5000 +const STDIN_RESUME_GAP_MS = 5000; type Props = { - readonly children: ReactNode - readonly stdin: NodeJS.ReadStream - readonly stdout: NodeJS.WriteStream - readonly stderr: NodeJS.WriteStream - readonly exitOnCtrlC: boolean - readonly onExit: (error?: Error) => void - readonly terminalColumns: number - readonly terminalRows: number + readonly children: ReactNode; + readonly stdin: NodeJS.ReadStream; + readonly stdout: NodeJS.WriteStream; + readonly stderr: NodeJS.WriteStream; + readonly exitOnCtrlC: boolean; + readonly onExit: (error?: Error) => void; + readonly terminalColumns: number; + readonly terminalRows: number; // Text selection state. App mutates this directly from mouse events // and calls onSelectionChange to trigger a repaint. Mouse events only // arrive when (or similar) enables mouse tracking, // so the handler is always wired but dormant until tracking is on. - readonly selection: SelectionState - readonly onSelectionChange: () => void + readonly selection: SelectionState; + readonly onSelectionChange: () => void; // Dispatch a click at (col, row) — hit-tests the DOM tree and bubbles // onClick handlers. Returns true if a DOM handler consumed the click. // No-op (returns false) outside fullscreen mode (Ink.dispatchClick // gates on altScreenActive). - readonly onClickAt: (col: number, row: number) => boolean + readonly onClickAt: (col: number, row: number) => boolean; // Dispatch hover (onMouseEnter/onMouseLeave) as the pointer moves over // DOM elements. Called for mode-1003 motion events with no button held. // No-op outside fullscreen (Ink.dispatchHover gates on altScreenActive). - readonly onHoverAt: (col: number, row: number) => void + readonly onHoverAt: (col: number, row: number) => void; // Look up the OSC 8 hyperlink at (col, row) synchronously at click // time. Returns the URL or undefined. The browser-open is deferred by // MULTI_CLICK_TIMEOUT_MS so double-click can cancel it. - readonly getHyperlinkAt: (col: number, row: number) => string | undefined + readonly getHyperlinkAt: (col: number, row: number) => string | undefined; // Open a hyperlink URL in the browser. Called after the timer fires. - readonly onOpenHyperlink: (url: string) => void + readonly onOpenHyperlink: (url: string) => void; // Called on double/triple-click PRESS at (col, row). count=2 selects // the word under the cursor; count=3 selects the line. Ink reads the // screen buffer to find word/line boundaries and mutates selection, // setting isDragging=true so a subsequent drag extends by word/line. - readonly onMultiClick: (col: number, row: number, count: 2 | 3) => void + readonly onMultiClick: (col: number, row: number, count: 2 | 3) => void; // Called on drag-motion. Mode-aware: char mode updates focus to the // exact cell; word/line mode snaps to word/line boundaries. Needs // screen-buffer access (word boundaries) so lives on Ink, not here. - readonly onSelectionDrag: (col: number, row: number) => void + readonly onSelectionDrag: (col: number, row: number) => void; // Called when stdin data arrives after a >STDIN_RESUME_GAP_MS gap. // Ink re-asserts terminal modes: extended key reporting, and (when in // fullscreen) re-enters alt-screen + mouse tracking. Idempotent on the // terminal side. Optional so testing.tsx doesn't need to stub it. - readonly onStdinResume?: () => void + readonly onStdinResume?: () => void; // Receives the declared native-cursor position from useDeclaredCursor // so ink.tsx can park the terminal cursor there after each frame. // Enables IME composition at the input caret and lets screen readers / // magnifiers track the input. Optional so testing.tsx doesn't stub it. - readonly onCursorDeclaration?: CursorDeclarationSetter + readonly onCursorDeclaration?: CursorDeclarationSetter; // Dispatch a keyboard event through the DOM tree. Called for each // parsed key alongside the legacy EventEmitter path. - readonly dispatchKeyboardEvent: (parsedKey: ParsedKey) => void -} + readonly dispatchKeyboardEvent: (parsedKey: ParsedKey) => void; +}; // Multi-click detection thresholds. 500ms is the macOS default; a small // position tolerance allows for trackpad jitter between clicks. -const MULTI_CLICK_TIMEOUT_MS = 500 -const MULTI_CLICK_DISTANCE = 1 +const MULTI_CLICK_TIMEOUT_MS = 500; +const MULTI_CLICK_DISTANCE = 1; type State = { - readonly error?: Error -} + readonly error?: Error; +}; // Root component for all Ink apps // It renders stdin and stdout contexts, so that children can access them if needed // It also handles Ctrl+C exiting and cursor visibility export default class App extends PureComponent { - static displayName = 'InternalApp' + static displayName = 'InternalApp'; static getDerivedStateFromError(error: Error) { - return { error } + return { error }; } override state = { error: undefined, - } + }; // Count how many components enabled raw mode to avoid disabling // raw mode until all components don't need it anymore - rawModeEnabledCount = 0 + rawModeEnabledCount = 0; - internal_eventEmitter = new EventEmitter() - keyParseState = INITIAL_STATE + internal_eventEmitter = new EventEmitter(); + keyParseState = INITIAL_STATE; // Timer for flushing incomplete escape sequences - incompleteEscapeTimer: NodeJS.Timeout | null = null + incompleteEscapeTimer: NodeJS.Timeout | null = null; // Timeout durations for incomplete sequences (ms) - readonly NORMAL_TIMEOUT = 50 // Short timeout for regular esc sequences - readonly PASTE_TIMEOUT = 500 // Longer timeout for paste operations + readonly NORMAL_TIMEOUT = 50; // Short timeout for regular esc sequences + readonly PASTE_TIMEOUT = 500; // Longer timeout for paste operations // Terminal query/response dispatch. Responses arrive on stdin (parsed // out by parse-keypress) and are routed to pending promise resolvers. - querier = new TerminalQuerier(this.props.stdout) + querier = new TerminalQuerier(this.props.stdout); // Multi-click tracking for double/triple-click text selection. A click // within MULTI_CLICK_TIMEOUT_MS and MULTI_CLICK_DISTANCE of the previous // click increments clickCount; otherwise it resets to 1. - lastClickTime = 0 - lastClickCol = -1 - lastClickRow = -1 - clickCount = 0 + lastClickTime = 0; + lastClickCol = -1; + lastClickRow = -1; + clickCount = 0; // Deferred hyperlink-open timer — cancelled if a second click arrives // within MULTI_CLICK_TIMEOUT_MS (so double-clicking a hyperlink selects // the word without also opening the browser). DOM onClick dispatch is // NOT deferred — it returns true from onClickAt and skips this timer. - pendingHyperlinkTimer: ReturnType | null = null + pendingHyperlinkTimer: ReturnType | null = null; // Last mode-1003 motion position. Terminals already dedupe to cell // granularity but this also lets us skip dispatchHover entirely on // repeat events (drag-then-release at same cell, etc.). - lastHoverCol = -1 - lastHoverRow = -1 + lastHoverCol = -1; + lastHoverRow = -1; // Timestamp of last stdin chunk. Used to detect long gaps (tmux attach, // ssh reconnect, laptop wake) and trigger terminal mode re-assert. // Initialized to now so startup doesn't false-trigger. - lastStdinTime = Date.now() + lastStdinTime = Date.now(); // Determines if TTY is supported on the provided stdin isRawModeSupported(): boolean { - return this.props.stdin.isTTY + return this.props.stdin.isTTY; } override render() { @@ -217,73 +195,64 @@ export default class App extends PureComponent { > - {})} - > - {this.state.error ? ( - - ) : ( - this.props.children - )} + {})}> + {this.state.error ? : this.props.children} - ) + ); } override componentDidMount() { // In accessibility mode, keep the native cursor visible for screen magnifiers and other tools - if ( - this.props.stdout.isTTY && - !isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY) - ) { - this.props.stdout.write(HIDE_CURSOR) + if (this.props.stdout.isTTY && !isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY)) { + this.props.stdout.write(HIDE_CURSOR); } } override componentWillUnmount() { if (this.props.stdout.isTTY) { - this.props.stdout.write(SHOW_CURSOR) + this.props.stdout.write(SHOW_CURSOR); } // Clear any pending timers if (this.incompleteEscapeTimer) { - clearTimeout(this.incompleteEscapeTimer) - this.incompleteEscapeTimer = null + clearTimeout(this.incompleteEscapeTimer); + this.incompleteEscapeTimer = null; } if (this.pendingHyperlinkTimer) { - clearTimeout(this.pendingHyperlinkTimer) - this.pendingHyperlinkTimer = null + clearTimeout(this.pendingHyperlinkTimer); + this.pendingHyperlinkTimer = null; } // ignore calling setRawMode on an handle stdin it cannot be called if (this.isRawModeSupported()) { - this.handleSetRawMode(false) + this.handleSetRawMode(false); } } override componentDidCatch(error: Error) { - this.handleExit(error) + this.handleExit(error); } handleSetRawMode = (isEnabled: boolean): void => { - const { stdin } = this.props + const { stdin } = this.props; if (!this.isRawModeSupported()) { if (stdin === process.stdin) { throw new Error( 'Raw mode is not supported on the current process.stdin, which Ink uses as input stream by default.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported', - ) + ); } else { throw new Error( 'Raw mode is not supported on the stdin provided to Ink.\nRead about how to prevent this error on https://github.com/vadimdemedes/ink/#israwmodesupported', - ) + ); } } - stdin.setEncoding('utf8') + stdin.setEncoding('utf8'); if (isEnabled) { // Ensure raw mode is enabled only once @@ -292,22 +261,22 @@ export default class App extends PureComponent { // Both use the same stdin 'readable' + read() pattern, so they can't // coexist -- our handler would drain stdin before Ink's can see it. // The buffered text is preserved for REPL.tsx via consumeEarlyInput(). - stopCapturingEarlyInput() - stdin.ref() - stdin.setRawMode(true) - stdin.addListener('readable', this.handleReadable) + stopCapturingEarlyInput(); + stdin.ref(); + stdin.setRawMode(true); + stdin.addListener('readable', this.handleReadable); // Enable bracketed paste mode - this.props.stdout.write(EBP) + this.props.stdout.write(EBP); // Enable terminal focus reporting (DECSET 1004) - this.props.stdout.write(EFE) + this.props.stdout.write(EFE); // Enable extended key reporting so ctrl+shift+ is // distinguishable from ctrl+. We write both the kitty stack // push (CSI >1u) and xterm modifyOtherKeys level 2 (CSI >4;2m) — // terminals honor whichever they implement (tmux only accepts the // latter). if (supportsExtendedKeys()) { - this.props.stdout.write(ENABLE_KITTY_KEYBOARD) - this.props.stdout.write(ENABLE_MODIFY_OTHER_KEYS) + this.props.stdout.write(ENABLE_KITTY_KEYBOARD); + this.props.stdout.write(ENABLE_MODIFY_OTHER_KEYS); } // Probe terminal identity. XTVERSION survives SSH (query/reply goes // through the pty), unlike TERM_PROGRAM. Used for wheel-scroll base @@ -318,45 +287,42 @@ export default class App extends PureComponent { // init sequence completes — avoids interleaving with alt-screen/mouse // tracking enable writes that may happen in the same render cycle. setImmediate(() => { - void Promise.all([ - this.querier.send(xtversion()), - this.querier.flush(), - ]).then(([r]) => { + void Promise.all([this.querier.send(xtversion()), this.querier.flush()]).then(([r]) => { if (r) { - setXtversionName(r.name) - logForDebugging(`XTVERSION: terminal identified as "${r.name}"`) + setXtversionName(r.name); + logForDebugging(`XTVERSION: terminal identified as "${r.name}"`); } else { - logForDebugging('XTVERSION: no reply (terminal ignored query)') + logForDebugging('XTVERSION: no reply (terminal ignored query)'); } - }) - }) + }); + }); } - this.rawModeEnabledCount++ - return + this.rawModeEnabledCount++; + return; } // Disable raw mode only when no components left that are using it if (--this.rawModeEnabledCount === 0) { - this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS) - this.props.stdout.write(DISABLE_KITTY_KEYBOARD) + this.props.stdout.write(DISABLE_MODIFY_OTHER_KEYS); + this.props.stdout.write(DISABLE_KITTY_KEYBOARD); // Disable terminal focus reporting (DECSET 1004) - this.props.stdout.write(DFE) + this.props.stdout.write(DFE); // Disable bracketed paste mode - this.props.stdout.write(DBP) - stdin.setRawMode(false) - stdin.removeListener('readable', this.handleReadable) - stdin.unref() + this.props.stdout.write(DBP); + stdin.setRawMode(false); + stdin.removeListener('readable', this.handleReadable); + stdin.unref(); } - } + }; // Helper to flush incomplete escape sequences flushIncomplete = (): void => { // Clear the timer reference - this.incompleteEscapeTimer = null + this.incompleteEscapeTimer = null; // Only proceed if we have incomplete sequences - if (!this.keyParseState.incomplete) return + if (!this.keyParseState.incomplete) return; // Fullscreen: if stdin has data waiting, it's almost certainly the // continuation of the buffered sequence (e.g. `[<64;74;16M` after a @@ -367,23 +333,20 @@ export default class App extends PureComponent { // drain stdin next and clear this timer. Prevents both the spurious // Escape key and the lost scroll event. if (this.props.stdin.readableLength > 0) { - this.incompleteEscapeTimer = setTimeout( - this.flushIncomplete, - this.NORMAL_TIMEOUT, - ) - return + this.incompleteEscapeTimer = setTimeout(this.flushIncomplete, this.NORMAL_TIMEOUT); + return; } // Process incomplete as a flush operation (input=null) // This reuses all existing parsing logic - this.processInput(null) - } + this.processInput(null); + }; // Process input through the parser and handle the results processInput = (input: string | Buffer | null): void => { // Parse input using our state machine - const [keys, newState] = parseMultipleKeypresses(this.keyParseState, input) - this.keyParseState = newState + const [keys, newState] = parseMultipleKeypresses(this.keyParseState, input); + this.keyParseState = newState; // Process ALL keys in a SINGLE discreteUpdates call to prevent // "Maximum update depth exceeded" error when many keys arrive at once @@ -391,106 +354,92 @@ export default class App extends PureComponent { // This batches all state updates from handleInput and all useInput // listeners together within one high-priority update context. if (keys.length > 0) { - reconciler.discreteUpdates( - processKeysInBatch, - this, - keys, - undefined, - undefined, - ) + reconciler.discreteUpdates(processKeysInBatch, this, keys, undefined, undefined); } // If we have incomplete escape sequences, set a timer to flush them if (this.keyParseState.incomplete) { // Cancel any existing timer first if (this.incompleteEscapeTimer) { - clearTimeout(this.incompleteEscapeTimer) + clearTimeout(this.incompleteEscapeTimer); } this.incompleteEscapeTimer = setTimeout( this.flushIncomplete, - this.keyParseState.mode === 'IN_PASTE' - ? this.PASTE_TIMEOUT - : this.NORMAL_TIMEOUT, - ) + this.keyParseState.mode === 'IN_PASTE' ? this.PASTE_TIMEOUT : this.NORMAL_TIMEOUT, + ); } - } + }; handleReadable = (): void => { // Detect long stdin gaps (tmux attach, ssh reconnect, laptop wake). // The terminal may have reset DEC private modes; re-assert mouse // tracking. Checked before the read loop so one Date.now() covers // all chunks in this readable event. - const now = Date.now() + const now = Date.now(); if (now - this.lastStdinTime > STDIN_RESUME_GAP_MS) { - this.props.onStdinResume?.() + this.props.onStdinResume?.(); } - this.lastStdinTime = now + this.lastStdinTime = now; try { - let chunk + let chunk; while ((chunk = this.props.stdin.read() as string | null) !== null) { // Process the input chunk - this.processInput(chunk) + this.processInput(chunk); } } catch (error) { // In Bun, an uncaught throw inside a stream 'readable' handler can // permanently wedge the stream: data stays buffered and 'readable' // never re-emits. Catching here ensures the stream stays healthy so // subsequent keystrokes are still delivered. - logError(error) + logError(error); // Re-attach the listener in case the exception detached it. // Bun may remove the listener after an error; without this, // the session freezes permanently (stdin reader dead, event loop alive). - const { stdin } = this.props - if ( - this.rawModeEnabledCount > 0 && - !stdin.listeners('readable').includes(this.handleReadable) - ) { - logForDebugging( - 'handleReadable: re-attaching stdin readable listener after error recovery', - { level: 'warn' }, - ) - stdin.addListener('readable', this.handleReadable) + const { stdin } = this.props; + if (this.rawModeEnabledCount > 0 && !stdin.listeners('readable').includes(this.handleReadable)) { + logForDebugging('handleReadable: re-attaching stdin readable listener after error recovery', { level: 'warn' }); + stdin.addListener('readable', this.handleReadable); } } - } + }; handleInput = (input: string | undefined): void => { // Exit on Ctrl+C if (input === '\x03' && this.props.exitOnCtrlC) { - this.handleExit() + this.handleExit(); } // Note: Ctrl+Z (suspend) is now handled in processKeysInBatch using the // parsed key to support both raw (\x1a) and CSI u format from Kitty // keyboard protocol terminals (Ghostty, iTerm2, kitty, WezTerm) - } + }; handleExit = (error?: Error): void => { if (this.isRawModeSupported()) { - this.handleSetRawMode(false) + this.handleSetRawMode(false); } - this.props.onExit(error) - } + this.props.onExit(error); + }; handleTerminalFocus = (isFocused: boolean): void => { // setTerminalFocused notifies subscribers: TerminalFocusProvider (context) // and Clock (interval speed) — no App setState needed. - setTerminalFocused(isFocused) - } + setTerminalFocused(isFocused); + }; handleSuspend = (): void => { if (!this.isRawModeSupported()) { - return + return; } // Store the exact raw mode count to restore it properly - const rawModeCountBeforeSuspend = this.rawModeEnabledCount + const rawModeCountBeforeSuspend = this.rawModeEnabledCount; // Completely disable raw mode before suspending while (this.rawModeEnabledCount > 0) { - this.handleSetRawMode(false) + this.handleSetRawMode(false); } // Show cursor, disable focus reporting, and disable mouse tracking @@ -499,49 +448,44 @@ export default class App extends PureComponent { // it, SGR mouse sequences would appear as garbled text at the // shell prompt while suspended. if (this.props.stdout.isTTY) { - this.props.stdout.write(SHOW_CURSOR + DFE + DISABLE_MOUSE_TRACKING) + this.props.stdout.write(SHOW_CURSOR + DFE + DISABLE_MOUSE_TRACKING); } // Emit suspend event for Claude Code to handle. Mostly just has a notification - this.internal_eventEmitter.emit('suspend') + this.internal_eventEmitter.emit('suspend'); // Set up resume handler const resumeHandler = () => { // Restore raw mode to exact previous state for (let i = 0; i < rawModeCountBeforeSuspend; i++) { if (this.isRawModeSupported()) { - this.handleSetRawMode(true) + this.handleSetRawMode(true); } } // Hide cursor (unless in accessibility mode) and re-enable focus reporting after resuming if (this.props.stdout.isTTY) { if (!isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY)) { - this.props.stdout.write(HIDE_CURSOR) + this.props.stdout.write(HIDE_CURSOR); } // Re-enable focus reporting to restore terminal state - this.props.stdout.write(EFE) + this.props.stdout.write(EFE); } // Emit resume event for Claude Code to handle - this.internal_eventEmitter.emit('resume') + this.internal_eventEmitter.emit('resume'); - process.removeListener('SIGCONT', resumeHandler) - } + process.removeListener('SIGCONT', resumeHandler); + }; - process.on('SIGCONT', resumeHandler) - process.kill(process.pid, 'SIGSTOP') - } + process.on('SIGCONT', resumeHandler); + process.kill(process.pid, 'SIGSTOP'); + }; } // Helper to process all keys within a single discrete update context. // discreteUpdates expects (fn, a, b, c, d) -> fn(a, b, c, d) -function processKeysInBatch( - app: App, - items: ParsedInput[], - _unused1: undefined, - _unused2: undefined, -): void { +function processKeysInBatch(app: App, items: ParsedInput[], _unused1: undefined, _unused2: undefined): void { // Update interaction time for notification timeout tracking. // This is called from the central input handler to avoid having multiple // stdin listeners that can cause race conditions and dropped input. @@ -549,75 +493,70 @@ function processKeysInBatch( // Mode-1003 no-button motion is also excluded — passive cursor drift is // not engagement (would suppress idle notifications + defer housekeeping). if ( - items.some( - i => - i.kind === 'key' || - (i.kind === 'mouse' && - !((i.button & 0x20) !== 0 && (i.button & 0x03) === 3)), - ) + items.some(i => i.kind === 'key' || (i.kind === 'mouse' && !((i.button & 0x20) !== 0 && (i.button & 0x03) === 3))) ) { - updateLastInteractionTime() + updateLastInteractionTime(); } for (const item of items) { // Terminal responses (DECRPM, DA1, OSC replies, etc.) are not user // input — route them to the querier to resolve pending promises. if (item.kind === 'response') { - app.querier.onResponse(item.response) - continue + app.querier.onResponse(item.response); + continue; } // Mouse click/drag events update selection state (fullscreen only). // Terminal sends 1-indexed col/row; convert to 0-indexed for the // screen buffer. Button bit 0x20 = drag (motion while button held). if (item.kind === 'mouse') { - handleMouseEvent(app, item) - continue + handleMouseEvent(app, item); + continue; } - const sequence = item.sequence + const sequence = item.sequence; // Handle terminal focus events (DECSET 1004) if (sequence === FOCUS_IN) { - app.handleTerminalFocus(true) - const event = new TerminalFocusEvent('terminalfocus') - app.internal_eventEmitter.emit('terminalfocus', event) - continue + app.handleTerminalFocus(true); + const event = new TerminalFocusEvent('terminalfocus'); + app.internal_eventEmitter.emit('terminalfocus', event); + continue; } if (sequence === FOCUS_OUT) { - app.handleTerminalFocus(false) + app.handleTerminalFocus(false); // Defensive: if we lost the release event (mouse released outside // terminal window — some emulators drop it rather than capturing the // pointer), focus-out is the next observable signal that the drag is // over. Without this, drag-to-scroll's timer runs until the scroll // boundary is hit. if (app.props.selection.isDragging) { - finishSelection(app.props.selection) - app.props.onSelectionChange() + finishSelection(app.props.selection); + app.props.onSelectionChange(); } - const event = new TerminalFocusEvent('terminalblur') - app.internal_eventEmitter.emit('terminalblur', event) - continue + const event = new TerminalFocusEvent('terminalblur'); + app.internal_eventEmitter.emit('terminalblur', event); + continue; } // Failsafe: if we receive input, the terminal must be focused if (!getTerminalFocused()) { - setTerminalFocused(true) + setTerminalFocused(true); } // Handle Ctrl+Z (suspend) using parsed key to support both raw (\x1a) and // CSI u format (\x1b[122;5u) from Kitty keyboard protocol terminals if (item.name === 'z' && item.ctrl && SUPPORTS_SUSPEND) { - app.handleSuspend() - continue + app.handleSuspend(); + continue; } - app.handleInput(sequence) - const event = new InputEvent(item) - app.internal_eventEmitter.emit('input', event) + app.handleInput(sequence); + const event = new InputEvent(item); + app.internal_eventEmitter.emit('input', event); // Also dispatch through the DOM tree so onKeyDown handlers fire. - app.props.dispatchKeyboardEvent(item) + app.props.dispatchKeyboardEvent(item); } } @@ -625,13 +564,13 @@ function processKeysInBatch( export function handleMouseEvent(app: App, m: ParsedMouse): void { // Allow disabling click handling while keeping wheel scroll (which goes // through the keybinding system as 'wheelup'/'wheeldown', not here). - if (isMouseClicksDisabled()) return + if (isMouseClicksDisabled()) return; - const sel = app.props.selection + const sel = app.props.selection; // Terminal coords are 1-indexed; screen buffer is 0-indexed - const col = m.col - 1 - const row = m.row - 1 - const baseButton = m.button & 0x03 + const col = m.col - 1; + const row = m.row - 1; + const baseButton = m.button & 0x03; if (m.action === 'press') { if ((m.button & 0x20) !== 0 && baseButton === 3) { @@ -645,25 +584,25 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void { // past the edge, came back" — and tmux drops focus events unless // `focus-events on` is set, so this is the more reliable signal. if (sel.isDragging) { - finishSelection(sel) - app.props.onSelectionChange() + finishSelection(sel); + app.props.onSelectionChange(); } - if (col === app.lastHoverCol && row === app.lastHoverRow) return - app.lastHoverCol = col - app.lastHoverRow = row - app.props.onHoverAt(col, row) - return + if (col === app.lastHoverCol && row === app.lastHoverRow) return; + app.lastHoverCol = col; + app.lastHoverRow = row; + app.props.onHoverAt(col, row); + return; } if (baseButton !== 0) { // Non-left press breaks the multi-click chain. - app.clickCount = 0 - return + app.clickCount = 0; + return; } if ((m.button & 0x20) !== 0) { // Drag motion: mode-aware extension (char/word/line). onSelectionDrag // calls notifySelectionChange internally — no extra onSelectionChange. - app.props.onSelectionDrag(col, row) - return + app.props.onSelectionDrag(col, row); + return; } // Lost-release fallback for mode-1002-only terminals: a fresh press // while isDragging=true means the previous release was dropped (cursor @@ -671,43 +610,43 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void { // before startSelection/onMultiClick clobbers it. Mode-1003 terminals // hit the no-button-motion recovery above instead, so this is rare. if (sel.isDragging) { - finishSelection(sel) - app.props.onSelectionChange() + finishSelection(sel); + app.props.onSelectionChange(); } // Fresh left press. Detect multi-click HERE (not on release) so the // word/line highlight appears immediately and a subsequent drag can // extend by word/line like native macOS. Previously detected on // release, which meant (a) visible latency before the word highlights // and (b) double-click+drag fell through to char-mode selection. - const now = Date.now() + const now = Date.now(); const nearLast = now - app.lastClickTime < MULTI_CLICK_TIMEOUT_MS && Math.abs(col - app.lastClickCol) <= MULTI_CLICK_DISTANCE && - Math.abs(row - app.lastClickRow) <= MULTI_CLICK_DISTANCE - app.clickCount = nearLast ? app.clickCount + 1 : 1 - app.lastClickTime = now - app.lastClickCol = col - app.lastClickRow = row + Math.abs(row - app.lastClickRow) <= MULTI_CLICK_DISTANCE; + app.clickCount = nearLast ? app.clickCount + 1 : 1; + app.lastClickTime = now; + app.lastClickCol = col; + app.lastClickRow = row; if (app.clickCount >= 2) { // Cancel any pending hyperlink-open from the first click — this is // a double-click, not a single-click on a link. if (app.pendingHyperlinkTimer) { - clearTimeout(app.pendingHyperlinkTimer) - app.pendingHyperlinkTimer = null + clearTimeout(app.pendingHyperlinkTimer); + app.pendingHyperlinkTimer = null; } // Cap at 3 (line select) for quadruple+ clicks. - const count = app.clickCount === 2 ? 2 : 3 - app.props.onMultiClick(col, row, count) - return + const count = app.clickCount === 2 ? 2 : 3; + app.props.onMultiClick(col, row, count); + return; } - startSelection(sel, col, row) + startSelection(sel, col, row); // SGR bit 0x08 = alt (xterm.js wires altKey here, not metaKey — see // comment at the hyperlink-open guard below). On macOS xterm.js, // receiving alt means macOptionClickForcesSelection is OFF (otherwise // xterm.js would have consumed the event for native selection). - sel.lastPressHadAlt = (m.button & 0x08) !== 0 - app.props.onSelectionChange() - return + sel.lastPressHadAlt = (m.button & 0x08) !== 0; + app.props.onSelectionChange(); + return; } // Release: end the drag even for non-zero button codes. Some terminals @@ -717,12 +656,12 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void { // scroll boundary. Only act on non-left releases when we ARE dragging // (so an unrelated middle/right click-release doesn't touch selection). if (baseButton !== 0) { - if (!sel.isDragging) return - finishSelection(sel) - app.props.onSelectionChange() - return + if (!sel.isDragging) return; + finishSelection(sel); + app.props.onSelectionChange(); + return; } - finishSelection(sel) + finishSelection(sel); // NOTE: unlike the old release-based detection we do NOT reset clickCount // on release-after-drag. This aligns with NSEvent.clickCount semantics: // an intervening drag doesn't break the click chain. Practical upside: @@ -743,7 +682,7 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void { // Resolve the hyperlink URL synchronously while the screen buffer // still reflects what the user clicked — deferring only the // browser-open so double-click can cancel it. - const url = app.props.getHyperlinkAt(col, row) + const url = app.props.getHyperlinkAt(col, row); // xterm.js (VS Code, Cursor, Windsurf, etc.) has its own OSC 8 link // handler that fires on Cmd+click *without consuming the mouse event* // (Linkifier._handleMouseUp calls link.activate() but never @@ -759,19 +698,19 @@ export function handleMouseEvent(app: App, m: ParsedMouse): void { // Clear any prior pending timer — clicking a second link // supersedes the first (only the latest click opens). if (app.pendingHyperlinkTimer) { - clearTimeout(app.pendingHyperlinkTimer) + clearTimeout(app.pendingHyperlinkTimer); } app.pendingHyperlinkTimer = setTimeout( (app, url) => { - app.pendingHyperlinkTimer = null - app.props.onOpenHyperlink(url) + app.pendingHyperlinkTimer = null; + app.props.onOpenHyperlink(url); }, MULTI_CLICK_TIMEOUT_MS, app, url, - ) + ); } } } - app.props.onSelectionChange() + app.props.onSelectionChange(); } diff --git a/src/ink/components/Box.tsx b/src/ink/components/Box.tsx index 42785f523..6a37a2cb6 100644 --- a/src/ink/components/Box.tsx +++ b/src/ink/components/Box.tsx @@ -1,48 +1,48 @@ -import React, { type PropsWithChildren, type Ref } from 'react' -import type { Except } from 'type-fest' -import type { DOMElement } from '../dom.js' -import type { ClickEvent } from '../events/click-event.js' -import type { FocusEvent } from '../events/focus-event.js' -import type { KeyboardEvent } from '../events/keyboard-event.js' -import type { Styles } from '../styles.js' -import * as warn from '../warn.js' +import React, { type PropsWithChildren, type Ref } from 'react'; +import type { Except } from 'type-fest'; +import type { DOMElement } from '../dom.js'; +import type { ClickEvent } from '../events/click-event.js'; +import type { FocusEvent } from '../events/focus-event.js'; +import type { KeyboardEvent } from '../events/keyboard-event.js'; +import type { Styles } from '../styles.js'; +import * as warn from '../warn.js'; export type Props = Except & { - ref?: Ref + ref?: Ref; /** * Tab order index. Nodes with `tabIndex >= 0` participate in * Tab/Shift+Tab cycling; `-1` means programmatically focusable only. */ - tabIndex?: number + tabIndex?: number; /** * Focus this element when it mounts. Like the HTML `autofocus` * attribute — the FocusManager calls `focus(node)` during the * reconciler's `commitMount` phase. */ - autoFocus?: boolean + autoFocus?: boolean; /** * Fired on left-button click (press + release without drag). Only works * inside `` where mouse tracking is enabled — no-op * otherwise. The event bubbles from the deepest hit Box up through * ancestors; call `event.stopImmediatePropagation()` to stop bubbling. */ - onClick?: (event: ClickEvent) => void - onFocus?: (event: FocusEvent) => void - onFocusCapture?: (event: FocusEvent) => void - onBlur?: (event: FocusEvent) => void - onBlurCapture?: (event: FocusEvent) => void - onKeyDown?: (event: KeyboardEvent) => void - onKeyDownCapture?: (event: KeyboardEvent) => void + onClick?: (event: ClickEvent) => void; + onFocus?: (event: FocusEvent) => void; + onFocusCapture?: (event: FocusEvent) => void; + onBlur?: (event: FocusEvent) => void; + onBlurCapture?: (event: FocusEvent) => void; + onKeyDown?: (event: KeyboardEvent) => void; + onKeyDownCapture?: (event: KeyboardEvent) => void; /** * Fired when the mouse moves into this Box's rendered rect. Like DOM * `mouseenter`, does NOT bubble — moving between children does not * re-fire on the parent. Only works inside `` where * mode-1003 mouse tracking is enabled. */ - onMouseEnter?: () => void + onMouseEnter?: () => void; /** Fired when the mouse moves out of this Box's rendered rect. */ - onMouseLeave?: () => void -} + onMouseLeave?: () => void; +}; /** * `` is an essential Ink component to build your layout. It's like `
` in the browser. @@ -68,23 +68,23 @@ function Box({ ...style }: PropsWithChildren): React.ReactNode { // Warn if spacing values are not integers to prevent fractional layout dimensions - warn.ifNotInteger(style.margin, 'margin') - warn.ifNotInteger(style.marginX, 'marginX') - warn.ifNotInteger(style.marginY, 'marginY') - warn.ifNotInteger(style.marginTop, 'marginTop') - warn.ifNotInteger(style.marginBottom, 'marginBottom') - warn.ifNotInteger(style.marginLeft, 'marginLeft') - warn.ifNotInteger(style.marginRight, 'marginRight') - warn.ifNotInteger(style.padding, 'padding') - warn.ifNotInteger(style.paddingX, 'paddingX') - warn.ifNotInteger(style.paddingY, 'paddingY') - warn.ifNotInteger(style.paddingTop, 'paddingTop') - warn.ifNotInteger(style.paddingBottom, 'paddingBottom') - warn.ifNotInteger(style.paddingLeft, 'paddingLeft') - warn.ifNotInteger(style.paddingRight, 'paddingRight') - warn.ifNotInteger(style.gap, 'gap') - warn.ifNotInteger(style.columnGap, 'columnGap') - warn.ifNotInteger(style.rowGap, 'rowGap') + warn.ifNotInteger(style.margin, 'margin'); + warn.ifNotInteger(style.marginX, 'marginX'); + warn.ifNotInteger(style.marginY, 'marginY'); + warn.ifNotInteger(style.marginTop, 'marginTop'); + warn.ifNotInteger(style.marginBottom, 'marginBottom'); + warn.ifNotInteger(style.marginLeft, 'marginLeft'); + warn.ifNotInteger(style.marginRight, 'marginRight'); + warn.ifNotInteger(style.padding, 'padding'); + warn.ifNotInteger(style.paddingX, 'paddingX'); + warn.ifNotInteger(style.paddingY, 'paddingY'); + warn.ifNotInteger(style.paddingTop, 'paddingTop'); + warn.ifNotInteger(style.paddingBottom, 'paddingBottom'); + warn.ifNotInteger(style.paddingLeft, 'paddingLeft'); + warn.ifNotInteger(style.paddingRight, 'paddingRight'); + warn.ifNotInteger(style.gap, 'gap'); + warn.ifNotInteger(style.columnGap, 'columnGap'); + warn.ifNotInteger(style.rowGap, 'rowGap'); return ( {children} - ) + ); } -export default Box +export default Box; diff --git a/src/ink/components/Button.tsx b/src/ink/components/Button.tsx index 0095d9c59..f4c1b9876 100644 --- a/src/ink/components/Button.tsx +++ b/src/ink/components/Button.tsx @@ -1,39 +1,33 @@ -import React, { - type Ref, - useCallback, - useEffect, - useRef, - useState, -} from 'react' -import type { Except } from 'type-fest' -import type { DOMElement } from '../dom.js' -import type { ClickEvent } from '../events/click-event.js' -import type { FocusEvent } from '../events/focus-event.js' -import type { KeyboardEvent } from '../events/keyboard-event.js' -import type { Styles } from '../styles.js' -import Box from './Box.js' +import React, { type Ref, useCallback, useEffect, useRef, useState } from 'react'; +import type { Except } from 'type-fest'; +import type { DOMElement } from '../dom.js'; +import type { ClickEvent } from '../events/click-event.js'; +import type { FocusEvent } from '../events/focus-event.js'; +import type { KeyboardEvent } from '../events/keyboard-event.js'; +import type { Styles } from '../styles.js'; +import Box from './Box.js'; type ButtonState = { - focused: boolean - hovered: boolean - active: boolean -} + focused: boolean; + hovered: boolean; + active: boolean; +}; export type Props = Except & { - ref?: Ref + ref?: Ref; /** * Called when the button is activated via Enter, Space, or click. */ - onAction: () => void + onAction: () => void; /** * Tab order index. Defaults to 0 (in tab order). * Set to -1 for programmatically focusable only. */ - tabIndex?: number + tabIndex?: number; /** * Focus this button when it mounts. */ - autoFocus?: boolean + autoFocus?: boolean; /** * Render prop receiving the interactive state. Use this to * style children based on focus/hover/active — Button itself @@ -41,64 +35,53 @@ export type Props = Except & { * * If not provided, children render as-is (no state-dependent styling). */ - children: ((state: ButtonState) => React.ReactNode) | React.ReactNode -} + children: ((state: ButtonState) => React.ReactNode) | React.ReactNode; +}; -function Button({ - onAction, - tabIndex = 0, - autoFocus, - children, - ref, - ...style -}: Props): React.ReactNode { - const [isFocused, setIsFocused] = useState(false) - const [isHovered, setIsHovered] = useState(false) - const [isActive, setIsActive] = useState(false) +function Button({ onAction, tabIndex = 0, autoFocus, children, ref, ...style }: Props): React.ReactNode { + const [isFocused, setIsFocused] = useState(false); + const [isHovered, setIsHovered] = useState(false); + const [isActive, setIsActive] = useState(false); - const activeTimer = useRef | null>(null) + const activeTimer = useRef | null>(null); useEffect(() => { return () => { - if (activeTimer.current) clearTimeout(activeTimer.current) - } - }, []) + if (activeTimer.current) clearTimeout(activeTimer.current); + }; + }, []); const handleKeyDown = useCallback( (e: KeyboardEvent) => { if (e.key === 'return' || e.key === ' ') { - e.preventDefault() - setIsActive(true) - onAction() - if (activeTimer.current) clearTimeout(activeTimer.current) - activeTimer.current = setTimeout( - setter => setter(false), - 100, - setIsActive, - ) + e.preventDefault(); + setIsActive(true); + onAction(); + if (activeTimer.current) clearTimeout(activeTimer.current); + activeTimer.current = setTimeout(setter => setter(false), 100, setIsActive); } }, [onAction], - ) + ); const handleClick = useCallback( (_e: ClickEvent) => { - onAction() + onAction(); }, [onAction], - ) + ); - const handleFocus = useCallback((_e: FocusEvent) => setIsFocused(true), []) - const handleBlur = useCallback((_e: FocusEvent) => setIsFocused(false), []) - const handleMouseEnter = useCallback(() => setIsHovered(true), []) - const handleMouseLeave = useCallback(() => setIsHovered(false), []) + const handleFocus = useCallback((_e: FocusEvent) => setIsFocused(true), []); + const handleBlur = useCallback((_e: FocusEvent) => setIsFocused(false), []); + const handleMouseEnter = useCallback(() => setIsHovered(true), []); + const handleMouseLeave = useCallback(() => setIsHovered(false), []); const state: ButtonState = { focused: isFocused, hovered: isHovered, active: isActive, - } - const content = typeof children === 'function' ? children(state) : children + }; + const content = typeof children === 'function' ? children(state) : children; return ( {content} - ) + ); } -export default Button -export type { ButtonState } +export default Button; +export type { ButtonState }; diff --git a/src/ink/components/ClockContext.tsx b/src/ink/components/ClockContext.tsx index 32a8b9a28..8ec60f11f 100644 --- a/src/ink/components/ClockContext.tsx +++ b/src/ink/components/ClockContext.tsx @@ -1,99 +1,93 @@ -import React, { createContext, useEffect, useState } from 'react' -import { FRAME_INTERVAL_MS } from '../constants.js' -import { useTerminalFocus } from '../hooks/use-terminal-focus.js' +import React, { createContext, useEffect, useState } from 'react'; +import { FRAME_INTERVAL_MS } from '../constants.js'; +import { useTerminalFocus } from '../hooks/use-terminal-focus.js'; export type Clock = { - subscribe: (onChange: () => void, keepAlive: boolean) => () => void - now: () => number - setTickInterval: (ms: number) => void -} + subscribe: (onChange: () => void, keepAlive: boolean) => () => void; + now: () => number; + setTickInterval: (ms: number) => void; +}; export function createClock(tickIntervalMs: number): Clock { - const subscribers = new Map<() => void, boolean>() - let interval: ReturnType | null = null - let currentTickIntervalMs = tickIntervalMs - let startTime = 0 + const subscribers = new Map<() => void, boolean>(); + let interval: ReturnType | null = null; + let currentTickIntervalMs = tickIntervalMs; + let startTime = 0; // Snapshot of the current tick's time, ensuring all subscribers in the same // tick see the same value (keeps animations synchronized) - let tickTime = 0 + let tickTime = 0; function tick(): void { - tickTime = Date.now() - startTime + tickTime = Date.now() - startTime; for (const onChange of subscribers.keys()) { - onChange() + onChange(); } } function updateInterval(): void { - const anyKeepAlive = [...subscribers.values()].some(Boolean) + const anyKeepAlive = [...subscribers.values()].some(Boolean); if (anyKeepAlive) { if (interval) { - clearInterval(interval) - interval = null + clearInterval(interval); + interval = null; } if (startTime === 0) { - startTime = Date.now() + startTime = Date.now(); } - interval = setInterval(tick, currentTickIntervalMs) + interval = setInterval(tick, currentTickIntervalMs); } else if (interval) { - clearInterval(interval) - interval = null + clearInterval(interval); + interval = null; } } return { subscribe(onChange, keepAlive) { - subscribers.set(onChange, keepAlive) - updateInterval() + subscribers.set(onChange, keepAlive); + updateInterval(); return () => { - subscribers.delete(onChange) - updateInterval() - } + subscribers.delete(onChange); + updateInterval(); + }; }, now() { if (startTime === 0) { - startTime = Date.now() + startTime = Date.now(); } // When the clock interval is running, return the synchronized tickTime // so all subscribers in the same tick see the same value. // When paused (no keepAlive subscribers), return real-time to avoid // returning a stale tickTime from the last tick before the pause. if (interval && tickTime) { - return tickTime + return tickTime; } - return Date.now() - startTime + return Date.now() - startTime; }, setTickInterval(ms) { - if (ms === currentTickIntervalMs) return - currentTickIntervalMs = ms - updateInterval() + if (ms === currentTickIntervalMs) return; + currentTickIntervalMs = ms; + updateInterval(); }, - } + }; } -export const ClockContext = createContext(null) +export const ClockContext = createContext(null); -const BLURRED_TICK_INTERVAL_MS = FRAME_INTERVAL_MS * 2 +const BLURRED_TICK_INTERVAL_MS = FRAME_INTERVAL_MS * 2; // Own component so App.tsx doesn't re-render when the clock is created. // The clock value is stable (created once via useState), so the provider // never causes consumer re-renders on its own. -export function ClockProvider({ - children, -}: { - children: React.ReactNode -}): React.ReactNode { - const [clock] = useState(() => createClock(FRAME_INTERVAL_MS)) - const focused = useTerminalFocus() +export function ClockProvider({ children }: { children: React.ReactNode }): React.ReactNode { + const [clock] = useState(() => createClock(FRAME_INTERVAL_MS)); + const focused = useTerminalFocus(); useEffect(() => { - clock.setTickInterval( - focused ? FRAME_INTERVAL_MS : BLURRED_TICK_INTERVAL_MS, - ) - }, [clock, focused]) + clock.setTickInterval(focused ? FRAME_INTERVAL_MS : BLURRED_TICK_INTERVAL_MS); + }, [clock, focused]); - return {children} + return {children}; } diff --git a/src/ink/components/ErrorOverview.tsx b/src/ink/components/ErrorOverview.tsx index 3effc4217..5f215536b 100644 --- a/src/ink/components/ErrorOverview.tsx +++ b/src/ink/components/ErrorOverview.tsx @@ -1,48 +1,48 @@ -import codeExcerpt, { type CodeExcerpt } from 'code-excerpt' -import { readFileSync } from 'fs' -import React from 'react' -import StackUtils from 'stack-utils' -import Box from './Box.js' -import Text from './Text.js' +import codeExcerpt, { type CodeExcerpt } from 'code-excerpt'; +import { readFileSync } from 'fs'; +import React from 'react'; +import StackUtils from 'stack-utils'; +import Box from './Box.js'; +import Text from './Text.js'; /* eslint-disable custom-rules/no-process-cwd -- stack trace file:// paths are relative to the real OS cwd, not the virtual cwd */ // Error's source file is reported as file:///home/user/file.js // This function removes the file://[cwd] part const cleanupPath = (path: string | undefined): string | undefined => { - return path?.replace(`file://${process.cwd()}/`, '') -} + return path?.replace(`file://${process.cwd()}/`, ''); +}; -let stackUtils: StackUtils | undefined +let stackUtils: StackUtils | undefined; function getStackUtils(): StackUtils { return (stackUtils ??= new StackUtils({ cwd: process.cwd(), internals: StackUtils.nodeInternals(), - })) + })); } /* eslint-enable custom-rules/no-process-cwd */ type Props = { - readonly error: Error -} + readonly error: Error; +}; export default function ErrorOverview({ error }: Props) { - const stack = error.stack ? error.stack.split('\n').slice(1) : undefined - const origin = stack ? getStackUtils().parseLine(stack[0]!) : undefined - const filePath = cleanupPath(origin?.file) - let excerpt: CodeExcerpt[] | undefined - let lineWidth = 0 + const stack = error.stack ? error.stack.split('\n').slice(1) : undefined; + const origin = stack ? getStackUtils().parseLine(stack[0]!) : undefined; + const filePath = cleanupPath(origin?.file); + let excerpt: CodeExcerpt[] | undefined; + let lineWidth = 0; if (filePath && origin?.line) { try { // eslint-disable-next-line custom-rules/no-sync-fs -- sync render path; error overlay can't go async without suspense restructuring - const sourceCode = readFileSync(filePath, 'utf8') - excerpt = codeExcerpt(sourceCode, origin.line) + const sourceCode = readFileSync(filePath, 'utf8'); + excerpt = codeExcerpt(sourceCode, origin.line); if (excerpt) { for (const { line } of excerpt) { - lineWidth = Math.max(lineWidth, String(line).length) + lineWidth = Math.max(lineWidth, String(line).length); } } } catch { @@ -76,9 +76,7 @@ export default function ErrorOverview({ error }: Props) { {String(line).padStart(lineWidth, ' ')}: @@ -103,7 +101,7 @@ export default function ErrorOverview({ error }: Props) { .split('\n') .slice(1) .map(line => { - const parsedLine = getStackUtils().parseLine(line) + const parsedLine = getStackUtils().parseLine(line); // If the line from the stack cannot be parsed, we print out the unparsed line. if (!parsedLine) { @@ -112,7 +110,7 @@ export default function ErrorOverview({ error }: Props) { - {line} - ) + ); } return ( @@ -121,14 +119,13 @@ export default function ErrorOverview({ error }: Props) { {parsedLine.function} {' '} - ({cleanupPath(parsedLine.file) ?? ''}:{parsedLine.line}: - {parsedLine.column}) + ({cleanupPath(parsedLine.file) ?? ''}:{parsedLine.line}:{parsedLine.column}) - ) + ); })} )} - ) + ); } diff --git a/src/ink/components/Link.tsx b/src/ink/components/Link.tsx index ee7f04d14..01feaf755 100644 --- a/src/ink/components/Link.tsx +++ b/src/ink/components/Link.tsx @@ -1,21 +1,17 @@ -import type { ReactNode } from 'react' -import React from 'react' -import { supportsHyperlinks } from '../supports-hyperlinks.js' -import Text from './Text.js' +import type { ReactNode } from 'react'; +import React from 'react'; +import { supportsHyperlinks } from '../supports-hyperlinks.js'; +import Text from './Text.js'; export type Props = { - readonly children?: ReactNode - readonly url: string - readonly fallback?: ReactNode -} + readonly children?: ReactNode; + readonly url: string; + readonly fallback?: ReactNode; +}; -export default function Link({ - children, - url, - fallback, -}: Props): React.ReactNode { +export default function Link({ children, url, fallback }: Props): React.ReactNode { // Use children if provided, otherwise display the URL - const content = children ?? url + const content = children ?? url; if (supportsHyperlinks()) { // Wrap in Text to ensure we're in a text context @@ -24,8 +20,8 @@ export default function Link({ {content} - ) + ); } - return {fallback ?? content} + return {fallback ?? content}; } diff --git a/src/ink/components/Newline.tsx b/src/ink/components/Newline.tsx index b8d6a88a2..9340685f9 100644 --- a/src/ink/components/Newline.tsx +++ b/src/ink/components/Newline.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React from 'react'; export type Props = { /** @@ -6,12 +6,12 @@ export type Props = { * * @default 1 */ - readonly count?: number -} + readonly count?: number; +}; /** * Adds one or more newline (\n) characters. Must be used within components. */ export default function Newline({ count = 1 }: Props) { - return {'\n'.repeat(count)} + return {'\n'.repeat(count)}; } diff --git a/src/ink/components/NoSelect.tsx b/src/ink/components/NoSelect.tsx index 882097608..790b77225 100644 --- a/src/ink/components/NoSelect.tsx +++ b/src/ink/components/NoSelect.tsx @@ -1,5 +1,5 @@ -import React, { type PropsWithChildren } from 'react' -import Box, { type Props as BoxProps } from './Box.js' +import React, { type PropsWithChildren } from 'react'; +import Box, { type Props as BoxProps } from './Box.js'; type Props = Omit & { /** @@ -11,8 +11,8 @@ type Props = Omit & { * * @default false */ - fromLeftEdge?: boolean -} + fromLeftEdge?: boolean; +}; /** * Marks its contents as non-selectable in fullscreen text selection. @@ -32,14 +32,10 @@ type Props = Omit & { * tracking). No-op in the main-screen scrollback render where the * terminal's native selection is used instead. */ -export function NoSelect({ - children, - fromLeftEdge, - ...boxProps -}: PropsWithChildren): React.ReactNode { +export function NoSelect({ children, fromLeftEdge, ...boxProps }: PropsWithChildren): React.ReactNode { return ( {children} - ) + ); } diff --git a/src/ink/components/RawAnsi.tsx b/src/ink/components/RawAnsi.tsx index a1a23ab4b..ac548101b 100644 --- a/src/ink/components/RawAnsi.tsx +++ b/src/ink/components/RawAnsi.tsx @@ -1,14 +1,14 @@ -import React from 'react' +import React from 'react'; type Props = { /** * Pre-rendered ANSI lines. Each element must be exactly one terminal row * (already wrapped to `width` by the producer) with ANSI escape codes inline. */ - lines: string[] + lines: string[]; /** Column width the producer wrapped to. Sent to Yoga as the fixed leaf width. */ - width: number -} + width: number; +}; /** * Bypass the → React tree → Yoga → squash → re-serialize roundtrip for @@ -27,13 +27,7 @@ type Props = { */ export function RawAnsi({ lines, width }: Props): React.ReactNode { if (lines.length === 0) { - return null + return null; } - return ( - - ) + return ; } diff --git a/src/ink/components/ScrollBox.tsx b/src/ink/components/ScrollBox.tsx index c2d432be2..8592bc00d 100644 --- a/src/ink/components/ScrollBox.tsx +++ b/src/ink/components/ScrollBox.tsx @@ -1,21 +1,15 @@ -import React, { - type PropsWithChildren, - type Ref, - useImperativeHandle, - useRef, - useState, -} from 'react' -import type { Except } from 'type-fest' -import { markScrollActivity } from '../../bootstrap/state.js' -import type { DOMElement } from '../dom.js' -import { markDirty, scheduleRenderFrom } from '../dom.js' -import { markCommitStart } from '../reconciler.js' -import type { Styles } from '../styles.js' -import Box from './Box.js' +import React, { type PropsWithChildren, type Ref, useImperativeHandle, useRef, useState } from 'react'; +import type { Except } from 'type-fest'; +import { markScrollActivity } from '../../bootstrap/state.js'; +import type { DOMElement } from '../dom.js'; +import { markDirty, scheduleRenderFrom } from '../dom.js'; +import { markCommitStart } from '../reconciler.js'; +import type { Styles } from '../styles.js'; +import Box from './Box.js'; export type ScrollBoxHandle = { - scrollTo: (y: number) => void - scrollBy: (dy: number) => void + scrollTo: (y: number) => void; + scrollBy: (dy: number) => void; /** * Scroll so `el`'s top is at the viewport top (plus `offset`). Unlike * scrollTo which bakes a number that's stale by the time the throttled @@ -23,24 +17,24 @@ export type ScrollBoxHandle = { * render-node-to-output reads `el.yogaNode.getComputedTop()` in the * SAME Yoga pass that computes scrollHeight. Deterministic. One-shot. */ - scrollToElement: (el: DOMElement, offset?: number) => void - scrollToBottom: () => void - getScrollTop: () => number - getPendingDelta: () => number - getScrollHeight: () => number + scrollToElement: (el: DOMElement, offset?: number) => void; + scrollToBottom: () => void; + getScrollTop: () => number; + getPendingDelta: () => number; + getScrollHeight: () => number; /** * Like getScrollHeight, but reads Yoga directly instead of the cached * value written by render-node-to-output (throttled, up to 16ms stale). * Use when you need a fresh value in useLayoutEffect after a React commit * that grew content. Slightly more expensive (native Yoga call). */ - getFreshScrollHeight: () => number - getViewportHeight: () => number + getFreshScrollHeight: () => number; + getViewportHeight: () => number; /** * Absolute screen-buffer row of the first visible content line (inside * padding). Used for drag-to-scroll edge detection. */ - getViewportTop: () => number + getViewportTop: () => number; /** * True when scroll is pinned to the bottom. Set by scrollToBottom, the * initial stickyScroll attribute, and by the renderer when positional @@ -48,14 +42,14 @@ export type ScrollBoxHandle = { * scrollTo/scrollBy. Stable signal for "at bottom" that doesn't depend on * layout values (unlike scrollTop+viewportH >= scrollHeight). */ - isSticky: () => boolean + isSticky: () => boolean; /** * Subscribe to imperative scroll changes (scrollTo/scrollBy/scrollToBottom). * Does NOT fire for stickyScroll updates done by the Ink renderer — those * happen during Ink's render phase after React has committed. Callers that * care about the sticky case should treat "at bottom" as a fallback. */ - subscribe: (listener: () => void) => () => void + subscribe: (listener: () => void) => () => void; /** * Set the render-time scrollTop clamp to the currently-mounted children's * coverage span. Called by useVirtualScroll after computing its range; @@ -64,20 +58,17 @@ export type ScrollBoxHandle = { * content instead of blank spacer. Pass undefined to disable (sticky, * cold start). */ - setClampBounds: (min: number | undefined, max: number | undefined) => void -} + setClampBounds: (min: number | undefined, max: number | undefined) => void; +}; -export type ScrollBoxProps = Except< - Styles, - 'textWrap' | 'overflow' | 'overflowX' | 'overflowY' -> & { - ref?: Ref +export type ScrollBoxProps = Except & { + ref?: Ref; /** * When true, automatically pins scroll position to the bottom when content * grows. Unset manually via scrollTo/scrollBy to break the stickiness. */ - stickyScroll?: boolean -} + stickyScroll?: boolean; +}; /** * A Box with `overflow: scroll` and an imperative scroll API. @@ -89,13 +80,8 @@ export type ScrollBoxProps = Except< * * Works best inside a fullscreen (constrained-height root) Ink tree. */ -function ScrollBox({ - children, - ref, - stickyScroll, - ...style -}: PropsWithChildren): React.ReactNode { - const domRef = useRef(null) +function ScrollBox({ children, ref, stickyScroll, ...style }: PropsWithChildren): React.ReactNode { + const domRef = useRef(null); // scrollTo/scrollBy bypass React: they mutate scrollTop on the DOM node, // mark it dirty, and call the root's throttled scheduleRender directly. // The Ink renderer reads scrollTop from the node — no React state needed, @@ -104,113 +90,109 @@ function ScrollBox({ // render — otherwise scheduleRender's leading edge fires on the FIRST // event before subsequent events mutate scrollTop. scrollToBottom still // forces a React render: sticky is attribute-observed, no DOM-only path. - const [, forceRender] = useState(0) - const listenersRef = useRef(new Set<() => void>()) - const renderQueuedRef = useRef(false) + const [, forceRender] = useState(0); + const listenersRef = useRef(new Set<() => void>()); + const renderQueuedRef = useRef(false); const notify = () => { - for (const l of listenersRef.current) l() - } + for (const l of listenersRef.current) l(); + }; function scrollMutated(el: DOMElement): void { // Signal background intervals (IDE poll, LSP poll, GCS fetch, orphan // check) to skip their next tick — they compete for the event loop and // contributed to 1402ms max frame gaps during scroll drain. - markScrollActivity() - markDirty(el) - markCommitStart() - notify() - if (renderQueuedRef.current) return - renderQueuedRef.current = true + markScrollActivity(); + markDirty(el); + markCommitStart(); + notify(); + if (renderQueuedRef.current) return; + renderQueuedRef.current = true; queueMicrotask(() => { - renderQueuedRef.current = false - scheduleRenderFrom(el) - }) + renderQueuedRef.current = false; + scheduleRenderFrom(el); + }); } useImperativeHandle( ref, (): ScrollBoxHandle => ({ scrollTo(y: number) { - const el = domRef.current - if (!el) return + const el = domRef.current; + if (!el) return; // Explicit false overrides the DOM attribute so manual scroll // breaks stickiness. Render code checks ?? precedence. - el.stickyScroll = false - el.pendingScrollDelta = undefined - el.scrollAnchor = undefined - el.scrollTop = Math.max(0, Math.floor(y)) - scrollMutated(el) + el.stickyScroll = false; + el.pendingScrollDelta = undefined; + el.scrollAnchor = undefined; + el.scrollTop = Math.max(0, Math.floor(y)); + scrollMutated(el); }, scrollToElement(el: DOMElement, offset = 0) { - const box = domRef.current - if (!box) return - box.stickyScroll = false - box.pendingScrollDelta = undefined - box.scrollAnchor = { el, offset } - scrollMutated(box) + const box = domRef.current; + if (!box) return; + box.stickyScroll = false; + box.pendingScrollDelta = undefined; + box.scrollAnchor = { el, offset }; + scrollMutated(box); }, scrollBy(dy: number) { - const el = domRef.current - if (!el) return - el.stickyScroll = false + const el = domRef.current; + if (!el) return; + el.stickyScroll = false; // Wheel input cancels any in-flight anchor seek — user override. - el.scrollAnchor = undefined + el.scrollAnchor = undefined; // Accumulate in pendingScrollDelta; renderer drains it at a capped // rate so fast flicks show intermediate frames. Pure accumulator: // scroll-up followed by scroll-down naturally cancels. - el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + Math.floor(dy) - scrollMutated(el) + el.pendingScrollDelta = (el.pendingScrollDelta ?? 0) + Math.floor(dy); + scrollMutated(el); }, scrollToBottom() { - const el = domRef.current - if (!el) return - el.pendingScrollDelta = undefined - el.stickyScroll = true - markDirty(el) - notify() - forceRender(n => n + 1) + const el = domRef.current; + if (!el) return; + el.pendingScrollDelta = undefined; + el.stickyScroll = true; + markDirty(el); + notify(); + forceRender(n => n + 1); }, getScrollTop() { - return domRef.current?.scrollTop ?? 0 + return domRef.current?.scrollTop ?? 0; }, getPendingDelta() { // Accumulated-but-not-yet-drained delta. useVirtualScroll needs // this to mount the union [committed, committed+pending] range — // otherwise intermediate drain frames find no children (blank). - return domRef.current?.pendingScrollDelta ?? 0 + return domRef.current?.pendingScrollDelta ?? 0; }, getScrollHeight() { - return domRef.current?.scrollHeight ?? 0 + return domRef.current?.scrollHeight ?? 0; }, getFreshScrollHeight() { - const content = domRef.current?.childNodes[0] as DOMElement | undefined - return ( - content?.yogaNode?.getComputedHeight() ?? - domRef.current?.scrollHeight ?? - 0 - ) + const content = domRef.current?.childNodes[0] as DOMElement | undefined; + return content?.yogaNode?.getComputedHeight() ?? domRef.current?.scrollHeight ?? 0; }, getViewportHeight() { - return domRef.current?.scrollViewportHeight ?? 0 + return domRef.current?.scrollViewportHeight ?? 0; }, getViewportTop() { - return domRef.current?.scrollViewportTop ?? 0 + return domRef.current?.scrollViewportTop ?? 0; }, isSticky() { - const el = domRef.current - if (!el) return false - return el.stickyScroll ?? Boolean(el.attributes['stickyScroll']) + const el = domRef.current; + if (!el) return false; + return el.stickyScroll ?? Boolean(el.attributes['stickyScroll']); }, subscribe(listener: () => void) { - listenersRef.current.add(listener) - return () => listenersRef.current.delete(listener) + listenersRef.current.add(listener); + return () => listenersRef.current.delete(listener); }, setClampBounds(min, max) { - const el = domRef.current - if (!el) return - el.scrollClampMin = min - el.scrollClampMax = max + const el = domRef.current; + if (!el) return; + el.scrollClampMin = min; + el.scrollClampMax = max; }, }), // notify/scrollMutated are inline (no useCallback) but only close over @@ -218,7 +200,7 @@ function ScrollBox({ // every render (which re-registers the ref = churn). // eslint-disable-next-line react-hooks/exhaustive-deps [], - ) + ); // Structure: outer viewport (overflow:scroll, constrained height) > // inner content (flexGrow:1, flexShrink:0 — fills at least the viewport @@ -234,8 +216,8 @@ function ScrollBox({ return ( { - domRef.current = el - if (el) el.scrollTop ??= 0 + domRef.current = el; + if (el) el.scrollTop ??= 0; }} style={{ flexWrap: 'nowrap', @@ -252,7 +234,7 @@ function ScrollBox({ {children} - ) + ); } -export default ScrollBox +export default ScrollBox; diff --git a/src/ink/components/Spacer.tsx b/src/ink/components/Spacer.tsx index eb55fa9e4..749f9ad0a 100644 --- a/src/ink/components/Spacer.tsx +++ b/src/ink/components/Spacer.tsx @@ -1,10 +1,10 @@ -import React from 'react' -import Box from './Box.js' +import React from 'react'; +import Box from './Box.js'; /** * A flexible space that expands along the major axis of its containing layout. * It's useful as a shortcut for filling all the available spaces between elements. */ export default function Spacer() { - return + return ; } diff --git a/src/ink/components/TerminalFocusContext.tsx b/src/ink/components/TerminalFocusContext.tsx index 81dbaf60b..d51bdaa9c 100644 --- a/src/ink/components/TerminalFocusContext.tsx +++ b/src/ink/components/TerminalFocusContext.tsx @@ -1,53 +1,36 @@ -import React, { createContext, useMemo, useSyncExternalStore } from 'react' +import React, { createContext, useMemo, useSyncExternalStore } from 'react'; import { getTerminalFocused, getTerminalFocusState, subscribeTerminalFocus, type TerminalFocusState, -} from '../terminal-focus-state.js' +} from '../terminal-focus-state.js'; -export type { TerminalFocusState } +export type { TerminalFocusState }; export type TerminalFocusContextProps = { - readonly isTerminalFocused: boolean - readonly terminalFocusState: TerminalFocusState -} + readonly isTerminalFocused: boolean; + readonly terminalFocusState: TerminalFocusState; +}; const TerminalFocusContext = createContext({ isTerminalFocused: true, terminalFocusState: 'unknown', -}) +}); // eslint-disable-next-line custom-rules/no-top-level-side-effects -TerminalFocusContext.displayName = 'TerminalFocusContext' +TerminalFocusContext.displayName = 'TerminalFocusContext'; // Separate component so App.tsx doesn't re-render on focus changes. // Children are a stable prop reference, so they don't re-render either — // only components that consume the context will re-render. -export function TerminalFocusProvider({ - children, -}: { - children: React.ReactNode -}): React.ReactNode { - const isTerminalFocused = useSyncExternalStore( - subscribeTerminalFocus, - getTerminalFocused, - ) - const terminalFocusState = useSyncExternalStore( - subscribeTerminalFocus, - getTerminalFocusState, - ) - - const value = useMemo( - () => ({ isTerminalFocused, terminalFocusState }), - [isTerminalFocused, terminalFocusState], - ) - - return ( - - {children} - - ) +export function TerminalFocusProvider({ children }: { children: React.ReactNode }): React.ReactNode { + const isTerminalFocused = useSyncExternalStore(subscribeTerminalFocus, getTerminalFocused); + const terminalFocusState = useSyncExternalStore(subscribeTerminalFocus, getTerminalFocusState); + + const value = useMemo(() => ({ isTerminalFocused, terminalFocusState }), [isTerminalFocused, terminalFocusState]); + + return {children}; } -export default TerminalFocusContext +export default TerminalFocusContext; diff --git a/src/ink/components/TerminalSizeContext.tsx b/src/ink/components/TerminalSizeContext.tsx index cdf139c57..bf5a19d2b 100644 --- a/src/ink/components/TerminalSizeContext.tsx +++ b/src/ink/components/TerminalSizeContext.tsx @@ -1,8 +1,8 @@ -import { createContext } from 'react' +import { createContext } from 'react'; export type TerminalSize = { - columns: number - rows: number -} + columns: number; + rows: number; +}; -export const TerminalSizeContext = createContext(null) +export const TerminalSizeContext = createContext(null); diff --git a/src/ink/components/Text.tsx b/src/ink/components/Text.tsx index f2e2bdb77..1b2712067 100644 --- a/src/ink/components/Text.tsx +++ b/src/ink/components/Text.tsx @@ -1,58 +1,55 @@ -import type { ReactNode } from 'react' -import React from 'react' -import type { Color, Styles, TextStyles } from '../styles.js' +import type { ReactNode } from 'react'; +import React from 'react'; +import type { Color, Styles, TextStyles } from '../styles.js'; type BaseProps = { /** * Change text color. Accepts a raw color value (rgb, hex, ansi). */ - readonly color?: Color + readonly color?: Color; /** * Same as `color`, but for background. */ - readonly backgroundColor?: Color + readonly backgroundColor?: Color; /** * Make the text italic. */ - readonly italic?: boolean + readonly italic?: boolean; /** * Make the text underlined. */ - readonly underline?: boolean + readonly underline?: boolean; /** * Make the text crossed with a line. */ - readonly strikethrough?: boolean + readonly strikethrough?: boolean; /** * Inverse background and foreground colors. */ - readonly inverse?: boolean + readonly inverse?: boolean; /** * This property tells Ink to wrap or truncate text if its width is larger than container. * If `wrap` is passed (by default), Ink will wrap text and split it into multiple lines. * If `truncate-*` is passed, Ink will truncate text instead, which will result in one line of text with the rest cut off. */ - readonly wrap?: Styles['textWrap'] + readonly wrap?: Styles['textWrap']; - readonly children?: ReactNode -} + readonly children?: ReactNode; +}; /** * Bold and dim are mutually exclusive in terminals. * This type ensures you can use one or the other, but not both. */ -type WeightProps = - | { bold?: never; dim?: never } - | { bold: boolean; dim?: never } - | { dim: boolean; bold?: never } +type WeightProps = { bold?: never; dim?: never } | { bold: boolean; dim?: never } | { dim: boolean; bold?: never }; -export type Props = BaseProps & WeightProps +export type Props = BaseProps & WeightProps; const memoizedStylesForWrap: Record, Styles> = { wrap: { @@ -103,7 +100,7 @@ const memoizedStylesForWrap: Record, Styles> = { flexDirection: 'row', textWrap: 'truncate-start', }, -} as const +} as const; /** * This component can display text, and change its style to make it colorful, bold, underline, italic or strikethrough. @@ -121,7 +118,7 @@ export default function Text({ children, }: Props): React.ReactNode { if (children === undefined || children === null) { - return null + return null; } // Build textStyles object with only the properties that are set @@ -134,11 +131,11 @@ export default function Text({ ...(underline && { underline }), ...(strikethrough && { strikethrough }), ...(inverse && { inverse }), - } + }; return ( {children} - ) + ); } diff --git a/src/ink/cursor.ts b/src/ink/cursor.ts index dc58a0eec..2e1c4005e 100644 --- a/src/ink/cursor.ts +++ b/src/ink/cursor.ts @@ -1,2 +1,2 @@ // Auto-generated stub — replace with real implementation -export type Cursor = any; +export type Cursor = any diff --git a/src/ink/devtools.ts b/src/ink/devtools.ts index 655d5da82..64c2cfba7 100644 --- a/src/ink/devtools.ts +++ b/src/ink/devtools.ts @@ -1,2 +1,2 @@ // Auto-generated stub — replace with real implementation -export {}; +export {} diff --git a/src/ink/events/paste-event.ts b/src/ink/events/paste-event.ts index 14136e76a..d933430f8 100644 --- a/src/ink/events/paste-event.ts +++ b/src/ink/events/paste-event.ts @@ -1,2 +1,2 @@ // Auto-generated stub — replace with real implementation -export type PasteEvent = any; +export type PasteEvent = any diff --git a/src/ink/events/resize-event.ts b/src/ink/events/resize-event.ts index 99d596988..bae2915bd 100644 --- a/src/ink/events/resize-event.ts +++ b/src/ink/events/resize-event.ts @@ -1,2 +1,2 @@ // Auto-generated stub — replace with real implementation -export type ResizeEvent = any; +export type ResizeEvent = any diff --git a/src/ink/ink.tsx b/src/ink/ink.tsx index 65bf32bd3..2a51fc8f6 100644 --- a/src/ink/ink.tsx +++ b/src/ink/ink.tsx @@ -1,40 +1,31 @@ -import autoBind from 'auto-bind' -import { - closeSync, - constants as fsConstants, - openSync, - readSync, - writeSync, -} from 'fs' -import noop from 'lodash-es/noop.js' -import throttle from 'lodash-es/throttle.js' -import React, { type ReactNode } from 'react' -import type { FiberRoot } from 'react-reconciler' -import { ConcurrentRoot } from 'react-reconciler/constants.js' -import { onExit } from 'signal-exit' -import { flushInteractionTime } from 'src/bootstrap/state.js' -import { getYogaCounters } from 'src/native-ts/yoga-layout/index.js' -import { logForDebugging } from 'src/utils/debug.js' -import { logError } from 'src/utils/log.js' -import { format } from 'util' -import { colorize } from './colorize.js' -import App from './components/App.js' -import type { - CursorDeclaration, - CursorDeclarationSetter, -} from './components/CursorDeclarationContext.js' -import { FRAME_INTERVAL_MS } from './constants.js' -import * as dom from './dom.js' -import { KeyboardEvent } from './events/keyboard-event.js' -import { FocusManager } from './focus.js' -import { emptyFrame, type Frame, type FrameEvent } from './frame.js' -import { dispatchClick, dispatchHover } from './hit-test.js' -import instances from './instances.js' -import { LogUpdate } from './log-update.js' -import { nodeCache } from './node-cache.js' -import { optimize } from './optimizer.js' -import Output from './output.js' -import type { ParsedKey } from './parse-keypress.js' +import autoBind from 'auto-bind'; +import { closeSync, constants as fsConstants, openSync, readSync, writeSync } from 'fs'; +import noop from 'lodash-es/noop.js'; +import throttle from 'lodash-es/throttle.js'; +import React, { type ReactNode } from 'react'; +import type { FiberRoot } from 'react-reconciler'; +import { ConcurrentRoot } from 'react-reconciler/constants.js'; +import { onExit } from 'signal-exit'; +import { flushInteractionTime } from 'src/bootstrap/state.js'; +import { getYogaCounters } from 'src/native-ts/yoga-layout/index.js'; +import { logForDebugging } from 'src/utils/debug.js'; +import { logError } from 'src/utils/log.js'; +import { format } from 'util'; +import { colorize } from './colorize.js'; +import App from './components/App.js'; +import type { CursorDeclaration, CursorDeclarationSetter } from './components/CursorDeclarationContext.js'; +import { FRAME_INTERVAL_MS } from './constants.js'; +import * as dom from './dom.js'; +import { KeyboardEvent } from './events/keyboard-event.js'; +import { FocusManager } from './focus.js'; +import { emptyFrame, type Frame, type FrameEvent } from './frame.js'; +import { dispatchClick, dispatchHover } from './hit-test.js'; +import instances from './instances.js'; +import { LogUpdate } from './log-update.js'; +import { nodeCache } from './node-cache.js'; +import { optimize } from './optimizer.js'; +import Output from './output.js'; +import type { ParsedKey } from './parse-keypress.js'; import reconciler, { dispatcher, getLastCommitMs, @@ -42,17 +33,10 @@ import reconciler, { isDebugRepaintsEnabled, recordYogaMs, resetProfileCounters, -} from './reconciler.js' -import renderNodeToOutput, { - consumeFollowScroll, - didLayoutShift, -} from './render-node-to-output.js' -import { - applyPositionedHighlight, - type MatchPosition, - scanPositions, -} from './render-to-screen.js' -import createRenderer, { type Renderer } from './renderer.js' +} from './reconciler.js'; +import renderNodeToOutput, { consumeFollowScroll, didLayoutShift } from './render-node-to-output.js'; +import { applyPositionedHighlight, type MatchPosition, scanPositions } from './render-to-screen.js'; +import createRenderer, { type Renderer } from './renderer.js'; import { CellWidth, CharPool, @@ -62,8 +46,8 @@ import { isEmptyCellAt, migrateScreenPools, StylePool, -} from './screen.js' -import { applySearchHighlight } from './searchHighlight.js' +} from './screen.js'; +import { applySearchHighlight } from './searchHighlight.js'; import { applySelectionOverlay, captureScrolledRows, @@ -83,13 +67,8 @@ import { shiftSelectionForFollow, startSelection, updateSelection, -} from './selection.js' -import { - SYNC_OUTPUT_SUPPORTED, - supportsExtendedKeys, - type Terminal, - writeDiffToTerminal, -} from './terminal.js' +} from './selection.js'; +import { SYNC_OUTPUT_SUPPORTED, supportsExtendedKeys, type Terminal, writeDiffToTerminal } from './terminal.js'; import { CURSOR_HOME, cursorMove, @@ -99,7 +78,7 @@ import { ENABLE_KITTY_KEYBOARD, ENABLE_MODIFY_OTHER_KEYS, ERASE_SCREEN, -} from './termio/csi.js' +} from './termio/csi.js'; import { DBP, DFE, @@ -108,28 +87,28 @@ import { ENTER_ALT_SCREEN, EXIT_ALT_SCREEN, SHOW_CURSOR, -} from './termio/dec.js' +} from './termio/dec.js'; import { CLEAR_ITERM2_PROGRESS, CLEAR_TAB_STATUS, setClipboard, supportsTabStatus, wrapForMultiplexer, -} from './termio/osc.js' -import { TerminalWriteProvider } from './useTerminalNotification.js' +} from './termio/osc.js'; +import { TerminalWriteProvider } from './useTerminalNotification.js'; // Alt-screen: renderer.ts sets cursor.visible = !isTTY || screen.height===0, // which is always false in alt-screen (TTY + content fills screen). // Reusing a frozen object saves 1 allocation per frame. -const ALT_SCREEN_ANCHOR_CURSOR = Object.freeze({ x: 0, y: 0, visible: false }) +const ALT_SCREEN_ANCHOR_CURSOR = Object.freeze({ x: 0, y: 0, visible: false }); const CURSOR_HOME_PATCH = Object.freeze({ type: 'stdout' as const, content: CURSOR_HOME, -}) +}); const ERASE_THEN_HOME_PATCH = Object.freeze({ type: 'stdout' as const, content: ERASE_SCREEN + CURSOR_HOME, -}) +}); // Cached per-Ink-instance, invalidated on resize. frame.cursor.y for // alt-screen is always terminalRows - 1 (renderer.ts). @@ -137,59 +116,59 @@ function makeAltScreenParkPatch(terminalRows: number) { return Object.freeze({ type: 'stdout' as const, content: cursorPosition(terminalRows, 1), - }) + }); } export type Options = { - stdout: NodeJS.WriteStream - stdin: NodeJS.ReadStream - stderr: NodeJS.WriteStream - exitOnCtrlC: boolean - patchConsole: boolean - waitUntilExit?: () => Promise - onFrame?: (event: FrameEvent) => void -} + stdout: NodeJS.WriteStream; + stdin: NodeJS.ReadStream; + stderr: NodeJS.WriteStream; + exitOnCtrlC: boolean; + patchConsole: boolean; + waitUntilExit?: () => Promise; + onFrame?: (event: FrameEvent) => void; +}; export default class Ink { - private readonly log: LogUpdate - private readonly terminal: Terminal - private scheduleRender: (() => void) & { cancel?: () => void } + private readonly log: LogUpdate; + private readonly terminal: Terminal; + private scheduleRender: (() => void) & { cancel?: () => void }; // Ignore last render after unmounting a tree to prevent empty output before exit - private isUnmounted = false - private isPaused = false - private readonly container: FiberRoot - private rootNode: dom.DOMElement - readonly focusManager: FocusManager - private renderer: Renderer - private readonly stylePool: StylePool - private charPool: CharPool - private hyperlinkPool: HyperlinkPool - private exitPromise?: Promise - private restoreConsole?: () => void - private restoreStderr?: () => void - private readonly unsubscribeTTYHandlers?: () => void - private terminalColumns: number - private terminalRows: number - private currentNode: ReactNode = null - private frontFrame: Frame - private backFrame: Frame - private lastPoolResetTime = performance.now() - private drainTimer: ReturnType | null = null + private isUnmounted = false; + private isPaused = false; + private readonly container: FiberRoot; + private rootNode: dom.DOMElement; + readonly focusManager: FocusManager; + private renderer: Renderer; + private readonly stylePool: StylePool; + private charPool: CharPool; + private hyperlinkPool: HyperlinkPool; + private exitPromise?: Promise; + private restoreConsole?: () => void; + private restoreStderr?: () => void; + private readonly unsubscribeTTYHandlers?: () => void; + private terminalColumns: number; + private terminalRows: number; + private currentNode: ReactNode = null; + private frontFrame: Frame; + private backFrame: Frame; + private lastPoolResetTime = performance.now(); + private drainTimer: ReturnType | null = null; private lastYogaCounters: { - ms: number - visited: number - measured: number - cacheHits: number - live: number - } = { ms: 0, visited: 0, measured: 0, cacheHits: 0, live: 0 } - private altScreenParkPatch: Readonly<{ type: 'stdout'; content: string }> + ms: number; + visited: number; + measured: number; + cacheHits: number; + live: number; + } = { ms: 0, visited: 0, measured: 0, cacheHits: 0, live: 0 }; + private altScreenParkPatch: Readonly<{ type: 'stdout'; content: string }>; // Text selection state (alt-screen only). Owned here so the overlay // pass in onRender can read it and App.tsx can update it from mouse // events. Public so instances.get() callers can access. - readonly selection: SelectionState = createSelectionState() + readonly selection: SelectionState = createSelectionState(); // Search highlight query (alt-screen only). Setter below triggers // scheduleRender; applySearchHighlight in onRender inverts matching cells. - private searchHighlightQuery = '' + private searchHighlightQuery = ''; // Position-based highlight. VML scans positions ONCE (via // scanElementSubtree, when the target message is mounted), stores them // message-relative, sets this for every-frame apply. rowOffset = @@ -197,88 +176,88 @@ export default class Ink { // "current" (yellow). null clears. Positions are known upfront — // navigation is index arithmetic, no scan-feedback loop. private searchPositions: { - positions: MatchPosition[] - rowOffset: number - currentIdx: number - } | null = null + positions: MatchPosition[]; + rowOffset: number; + currentIdx: number; + } | null = null; // React-land subscribers for selection state changes (useHasSelection). // Fired alongside the terminal repaint whenever the selection mutates // so UI (e.g. footer hints) can react to selection appearing/clearing. - private readonly selectionListeners = new Set<() => void>() + private readonly selectionListeners = new Set<() => void>(); // DOM nodes currently under the pointer (mode-1003 motion). Held here // so App.tsx's handleMouseEvent is stateless — dispatchHover diffs // against this set and mutates it in place. - private readonly hoveredNodes = new Set() + private readonly hoveredNodes = new Set(); // Set by via setAltScreenActive(). Controls the // renderer's cursor.y clamping (keeps cursor in-viewport to avoid // LF-induced scroll when screen.height === terminalRows) and gates // alt-screen-aware SIGCONT/resize/unmount handling. - private altScreenActive = false + private altScreenActive = false; // Set alongside altScreenActive so SIGCONT resume knows whether to // re-enable mouse tracking (not all uses want it). - private altScreenMouseTracking = false + private altScreenMouseTracking = false; // True when the previous frame's screen buffer cannot be trusted for // blit — selection overlay mutated it, resetFramesForAltScreen() // replaced it with blanks, or forceRedraw() reset it to 0×0. Forces // one full-render frame; steady-state frames after clear it and regain // the blit + narrow-damage fast path. - private prevFrameContaminated = false + private prevFrameContaminated = false; // Set by handleResize: prepend ERASE_SCREEN to the next onRender's patches // INSIDE the BSU/ESU block so clear+paint is atomic. Writing ERASE_SCREEN // synchronously in handleResize would leave the screen blank for the ~80ms // render() takes; deferring into the atomic block means old content stays // visible until the new frame is fully ready. - private needsEraseBeforePaint = false + private needsEraseBeforePaint = false; // Native cursor positioning: a component (via useDeclaredCursor) declares // where the terminal cursor should be parked after each frame. Terminal // emulators render IME preedit text at the physical cursor position, and // screen readers / screen magnifiers track it — so parking at the text // input's caret makes CJK input appear inline and lets a11y tools follow. - private cursorDeclaration: CursorDeclaration | null = null + private cursorDeclaration: CursorDeclaration | null = null; // Main-screen: physical cursor position after the declared-cursor move, // tracked separately from frame.cursor (which must stay at content-bottom // for log-update's relative-move invariants). Alt-screen doesn't need // this — every frame begins with CSI H. null = no move emitted last frame. - private displayCursor: { x: number; y: number } | null = null + private displayCursor: { x: number; y: number } | null = null; constructor(private readonly options: Options) { - autoBind(this) + autoBind(this); if (this.options.patchConsole) { - this.restoreConsole = this.patchConsole() - this.restoreStderr = this.patchStderr() + this.restoreConsole = this.patchConsole(); + this.restoreStderr = this.patchStderr(); } this.terminal = { stdout: options.stdout, stderr: options.stderr, - } - - this.terminalColumns = options.stdout.columns || 80 - this.terminalRows = options.stdout.rows || 24 - this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows) - this.stylePool = new StylePool() - this.charPool = new CharPool() - this.hyperlinkPool = new HyperlinkPool() + }; + + this.terminalColumns = options.stdout.columns || 80; + this.terminalRows = options.stdout.rows || 24; + this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows); + this.stylePool = new StylePool(); + this.charPool = new CharPool(); + this.hyperlinkPool = new HyperlinkPool(); this.frontFrame = emptyFrame( this.terminalRows, this.terminalColumns, this.stylePool, this.charPool, this.hyperlinkPool, - ) + ); this.backFrame = emptyFrame( this.terminalRows, this.terminalColumns, this.stylePool, this.charPool, this.hyperlinkPool, - ) + ); this.log = new LogUpdate({ isTTY: (options.stdout.isTTY as boolean | undefined) || false, stylePool: this.stylePool, - }) + }); // scheduleRender is called from the reconciler's resetAfterCommit, which // runs BEFORE React's layout phase (ref attach + useLayoutEffect). Any @@ -289,54 +268,52 @@ export default class Ink { // a one-keystroke lag. Same event-loop tick, so throughput is unchanged. // Test env uses onImmediateRender (direct onRender, no throttle) so // existing synchronous lastFrame() tests are unaffected. - const deferredRender = (): void => queueMicrotask(this.onRender) + const deferredRender = (): void => queueMicrotask(this.onRender); this.scheduleRender = throttle(deferredRender, FRAME_INTERVAL_MS, { leading: true, trailing: true, - }) + }); // Ignore last render after unmounting a tree to prevent empty output before exit - this.isUnmounted = false + this.isUnmounted = false; // Unmount when process exits - this.unsubscribeExit = onExit(this.unmount, { alwaysLast: false }) + this.unsubscribeExit = onExit(this.unmount, { alwaysLast: false }); if (options.stdout.isTTY) { - options.stdout.on('resize', this.handleResize) - process.on('SIGCONT', this.handleResume) + options.stdout.on('resize', this.handleResize); + process.on('SIGCONT', this.handleResume); this.unsubscribeTTYHandlers = () => { - options.stdout.off('resize', this.handleResize) - process.off('SIGCONT', this.handleResume) - } + options.stdout.off('resize', this.handleResize); + process.off('SIGCONT', this.handleResume); + }; } - this.rootNode = dom.createNode('ink-root') - this.focusManager = new FocusManager((target, event) => - dispatcher.dispatchDiscrete(target, event), - ) - this.rootNode.focusManager = this.focusManager - this.renderer = createRenderer(this.rootNode, this.stylePool) - this.rootNode.onRender = this.scheduleRender - this.rootNode.onImmediateRender = this.onRender + this.rootNode = dom.createNode('ink-root'); + this.focusManager = new FocusManager((target, event) => dispatcher.dispatchDiscrete(target, event)); + this.rootNode.focusManager = this.focusManager; + this.renderer = createRenderer(this.rootNode, this.stylePool); + this.rootNode.onRender = this.scheduleRender; + this.rootNode.onImmediateRender = this.onRender; this.rootNode.onComputeLayout = () => { // Calculate layout during React's commit phase so useLayoutEffect hooks // have access to fresh layout data // Guard against accessing freed Yoga nodes after unmount if (this.isUnmounted) { - return + return; } if (this.rootNode.yogaNode) { - const t0 = performance.now() - this.rootNode.yogaNode.setWidth(this.terminalColumns) - this.rootNode.yogaNode.calculateLayout(this.terminalColumns) - const ms = performance.now() - t0 - recordYogaMs(ms) - const c = getYogaCounters() - this.lastYogaCounters = { ms, ...c } + const t0 = performance.now(); + this.rootNode.yogaNode.setWidth(this.terminalColumns); + this.rootNode.yogaNode.calculateLayout(this.terminalColumns); + const ms = performance.now() - t0; + recordYogaMs(ms); + const c = getYogaCounters(); + this.lastYogaCounters = { ms, ...c }; } - } + }; // @ts-expect-error @types/react-reconciler@0.32.3 declares 11 args with transitionCallbacks, // but react-reconciler 0.33.0 source only accepts 10 args (no transitionCallbacks) @@ -351,30 +328,30 @@ export default class Ink { noop, // onCaughtError noop, // onRecoverableError noop, // onDefaultTransitionIndicator - ) + ); - if ("production" === 'development') { + if ('production' === 'development') { reconciler.injectIntoDevTools({ bundleType: 0, // Reporting React DOM's version, not Ink's // See https://github.com/facebook/react/issues/16666#issuecomment-532639905 version: '16.13.1', rendererPackageName: 'ink', - }) + }); } } private handleResume = () => { if (!this.options.stdout.isTTY) { - return + return; } // Alt screen: after SIGCONT, content is stale (shell may have written // to main screen, switching focus away) and mouse tracking was // disabled by handleSuspend. if (this.altScreenActive) { - this.reenterAltScreen() - return + this.reenterAltScreen(); + return; } // Main screen: start fresh to prevent clobbering terminal content @@ -384,20 +361,20 @@ export default class Ink { this.stylePool, this.charPool, this.hyperlinkPool, - ) + ); this.backFrame = emptyFrame( this.backFrame.viewport.height, this.backFrame.viewport.width, this.stylePool, this.charPool, this.hyperlinkPool, - ) - this.log.reset() + ); + this.log.reset(); // Physical cursor position is unknown after the shell took over during // suspend. Clear displayCursor so the next frame's cursor preamble // doesn't emit a relative move from a stale park position. - this.displayCursor = null - } + this.displayCursor = null; + }; // NOT debounced. A debounce opens a window where stdout.columns is NEW // but this.terminalColumns/Yoga are OLD — any scheduleRender during that @@ -406,15 +383,15 @@ export default class Ink { // blank→paint flicker). useVirtualScroll's height scaling already bounds // the per-resize cost; synchronous handling keeps dimensions consistent. private handleResize = () => { - const cols = this.options.stdout.columns || 80 - const rows = this.options.stdout.rows || 24 + const cols = this.options.stdout.columns || 80; + const rows = this.options.stdout.rows || 24; // Terminals often emit 2+ resize events for one user action (window // settling). Same-dimension events are no-ops; skip to avoid redundant // frame resets and renders. - if (cols === this.terminalColumns && rows === this.terminalRows) return - this.terminalColumns = cols - this.terminalRows = rows - this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows) + if (cols === this.terminalColumns && rows === this.terminalRows) return; + this.terminalColumns = cols; + this.terminalRows = rows; + this.altScreenParkPatch = makeAltScreenParkPatch(this.terminalRows); // Alt screen: reset frame buffers so the next render repaints from // scratch (prevFrameContaminated → every cell written, wrapped in @@ -428,10 +405,10 @@ export default class Ink { // can take ~80ms; erasing first leaves the screen blank that whole time. if (this.altScreenActive && !this.isPaused && this.options.stdout.isTTY) { if (this.altScreenMouseTracking) { - this.options.stdout.write(ENABLE_MOUSE_TRACKING) + this.options.stdout.write(ENABLE_MOUSE_TRACKING); } - this.resetFramesForAltScreen() - this.needsEraseBeforePaint = true + this.resetFramesForAltScreen(); + this.needsEraseBeforePaint = true; } // Re-render the React tree with updated props so the context value changes. @@ -440,13 +417,13 @@ export default class Ink { // We don't call scheduleRender() here because that would render before the // layout is updated, causing a mismatch between viewport and content dimensions. if (this.currentNode !== null) { - this.render(this.currentNode) + this.render(this.currentNode); } - } + }; - resolveExitPromise: () => void = () => {} - rejectExitPromise: (reason?: Error) => void = () => {} - unsubscribeExit: () => void = () => {} + resolveExitPromise: () => void = () => {}; + rejectExitPromise: (reason?: Error) => void = () => {}; + unsubscribeExit: () => void = () => {}; /** * Pause Ink and hand the terminal over to an external TUI (e.g. git @@ -455,8 +432,8 @@ export default class Ink { * Call `exitAlternateScreen()` when done to restore Ink. */ enterAlternateScreen(): void { - this.pause() - this.suspendStdin() + this.pause(); + this.suspendStdin(); this.options.stdout.write( // Disable extended key reporting first — editors that don't speak // CSI-u (e.g. nano) show "Unknown sequence" for every Ctrl- if @@ -470,7 +447,7 @@ export default class Ink { '\x1b[?25h' + // show cursor '\x1b[2J' + // clear screen '\x1b[H', // cursor home - ) + ); } /** @@ -493,14 +470,14 @@ export default class Ink { (this.altScreenMouseTracking ? ENABLE_MOUSE_TRACKING : '') + // re-enable mouse (skip if CLAUDE_CODE_DISABLE_MOUSE) (this.altScreenActive ? '' : '\x1b[?1049l') + // exit alt (non-fullscreen only) '\x1b[?25l', // hide cursor (Ink manages) - ) - this.resumeStdin() + ); + this.resumeStdin(); if (this.altScreenActive) { - this.resetFramesForAltScreen() + this.resetFramesForAltScreen(); } else { - this.repaint() + this.repaint(); } - this.resume() + this.resume(); // Re-enable focus reporting and extended key reporting — terminal // editors (vim, nano, etc.) write their own modifyOtherKeys level on // entry and reset it on exit, leaving us unable to distinguish @@ -509,35 +486,31 @@ export default class Ink { // without the pop we'd accumulate depth on each editor round-trip). this.options.stdout.write( '\x1b[?1004h' + - (supportsExtendedKeys() - ? DISABLE_KITTY_KEYBOARD + - ENABLE_KITTY_KEYBOARD + - ENABLE_MODIFY_OTHER_KEYS - : ''), - ) + (supportsExtendedKeys() ? DISABLE_KITTY_KEYBOARD + ENABLE_KITTY_KEYBOARD + ENABLE_MODIFY_OTHER_KEYS : ''), + ); } onRender() { if (this.isUnmounted || this.isPaused) { - return + return; } // Entering a render cancels any pending drain tick — this render will // handle the drain (and re-schedule below if needed). Prevents a // wheel-event-triggered render AND a drain-timer render both firing. if (this.drainTimer !== null) { - clearTimeout(this.drainTimer) - this.drainTimer = null + clearTimeout(this.drainTimer); + this.drainTimer = null; } // Flush deferred interaction-time update before rendering so we call // Date.now() at most once per frame instead of once per keypress. // Done before the render to avoid dirtying state that would trigger // an extra React re-render cycle. - flushInteractionTime() + flushInteractionTime(); - const renderStart = performance.now() - const terminalWidth = this.options.stdout.columns || 80 - const terminalRows = this.options.stdout.rows || 24 + const renderStart = performance.now(); + const terminalWidth = this.options.stdout.columns || 80; + const terminalRows = this.options.stdout.rows || 24; const frame = this.renderer({ frontFrame: this.frontFrame, @@ -547,8 +520,8 @@ export default class Ink { terminalRows, altScreen: this.altScreenActive, prevFrameContaminated: this.prevFrameContaminated, - }) - const rendererMs = performance.now() - renderStart + }); + const rendererMs = performance.now() - renderStart; // Sticky/auto-follow scrolled the ScrollBox this frame. Translate the // selection by the same delta so the highlight stays anchored to the @@ -561,7 +534,7 @@ export default class Ink { // (screen-local) so only anchor shifts — selection grows toward the // mouse as the anchor walks up. After release, both ends are text- // anchored and move as a block. - const follow = consumeFollowScroll() + const follow = consumeFollowScroll(); if ( follow && this.selection.anchor && @@ -574,7 +547,7 @@ export default class Ink { this.selection.anchor.row >= follow.viewportTop && this.selection.anchor.row <= follow.viewportBottom ) { - const { delta, viewportTop, viewportBottom } = follow + const { delta, viewportTop, viewportBottom } = follow; // captureScrolledRows and shift* are a pair: capture grabs rows about // to scroll off, shift moves the selection endpoint so the same rows // won't intersect again next frame. Capturing without shifting leaves @@ -584,15 +557,9 @@ export default class Ink { // each shift branch so the pairing can't be broken by a new guard. if (this.selection.isDragging) { if (hasSelection(this.selection)) { - captureScrolledRows( - this.selection, - this.frontFrame.screen, - viewportTop, - viewportTop + delta - 1, - 'above', - ) + captureScrolledRows(this.selection, this.frontFrame.screen, viewportTop, viewportTop + delta - 1, 'above'); } - shiftAnchor(this.selection, -delta, viewportTop, viewportBottom) + shiftAnchor(this.selection, -delta, viewportTop, viewportBottom); } else if ( // Flag-3 guard: the anchor check above only proves ONE endpoint is // on scrollbox content. A drag from row 3 (scrollbox) into the @@ -607,30 +574,18 @@ export default class Ink { // shiftAnchor ignores focus, and the anchor DOES shift (so capture // is correct there even when focus is in the footer). !this.selection.focus || - (this.selection.focus.row >= viewportTop && - this.selection.focus.row <= viewportBottom) + (this.selection.focus.row >= viewportTop && this.selection.focus.row <= viewportBottom) ) { if (hasSelection(this.selection)) { - captureScrolledRows( - this.selection, - this.frontFrame.screen, - viewportTop, - viewportTop + delta - 1, - 'above', - ) + captureScrolledRows(this.selection, this.frontFrame.screen, viewportTop, viewportTop + delta - 1, 'above'); } - const cleared = shiftSelectionForFollow( - this.selection, - -delta, - viewportTop, - viewportBottom, - ) + const cleared = shiftSelectionForFollow(this.selection, -delta, viewportTop, viewportBottom); // Auto-clear (both ends overshot minRow) must notify React-land // so useHasSelection re-renders and the footer copy/escape hint // disappears. notifySelectionChange() would recurse into onRender; // fire the listeners directly — they schedule a React update for // LATER, they don't re-enter this frame. - if (cleared) for (const cb of this.selectionListeners) cb() + if (cleared) for (const cb of this.selectionListeners) cb(); } } @@ -653,33 +608,29 @@ export default class Ink { // which doesn't track damage, and prev-frame overlay cells need to be // compared when selection moves/clears. prevFrameContaminated covers // the frame-after-selection-clears case. - let selActive = false - let hlActive = false + let selActive = false; + let hlActive = false; if (this.altScreenActive) { - selActive = hasSelection(this.selection) + selActive = hasSelection(this.selection); if (selActive) { - applySelectionOverlay(frame.screen, this.selection, this.stylePool) + applySelectionOverlay(frame.screen, this.selection, this.stylePool); } // Scan-highlight: inverse on ALL visible matches (less/vim style). // Position-highlight (below) overlays CURRENT (yellow) on top. - hlActive = applySearchHighlight( - frame.screen, - this.searchHighlightQuery, - this.stylePool, - ) + hlActive = applySearchHighlight(frame.screen, this.searchHighlightQuery, this.stylePool); // Position-based CURRENT: write yellow at positions[currentIdx] + // rowOffset. No scanning — positions came from a prior scan when // the message first mounted. Message-relative + rowOffset = screen. if (this.searchPositions) { - const sp = this.searchPositions + const sp = this.searchPositions; const posApplied = applyPositionedHighlight( frame.screen, this.stylePool, sp.positions, sp.rowOffset, sp.currentIdx, - ) - hlActive = hlActive || posApplied + ); + hlActive = hlActive || posApplied; } } @@ -688,18 +639,13 @@ export default class Ink { // cells at sibling boundaries that per-node damage tracking misses. // Selection/highlight overlays write via setCellStyleId which doesn't // track damage. prevFrameContaminated covers the cleanup frame. - if ( - didLayoutShift() || - selActive || - hlActive || - this.prevFrameContaminated - ) { + if (didLayoutShift() || selActive || hlActive || this.prevFrameContaminated) { frame.screen.damage = { x: 0, y: 0, width: frame.screen.width, height: frame.screen.height, - } + }; } // Alt-screen: anchor the physical cursor to (0,0) before every diff. @@ -712,12 +658,12 @@ export default class Ink { // can't do this — cursor.y tracks scrollback rows CSI H can't reach. // The CSI H write is deferred until after the diff is computed so we // can skip it for empty diffs (no writes → physical cursor unused). - let prevFrame = this.frontFrame + let prevFrame = this.frontFrame; if (this.altScreenActive) { - prevFrame = { ...this.frontFrame, cursor: ALT_SCREEN_ANCHOR_CURSOR } + prevFrame = { ...this.frontFrame, cursor: ALT_SCREEN_ANCHOR_CURSOR }; } - const tDiff = performance.now() + const tDiff = performance.now(); const diff = this.log.render( prevFrame, frame, @@ -727,48 +673,45 @@ export default class Ink { // tmux is the main case (re-emits DECSTBM with its own timing and // doesn't implement DEC 2026, so SYNC_OUTPUT_SUPPORTED is false). SYNC_OUTPUT_SUPPORTED, - ) - const diffMs = performance.now() - tDiff + ); + const diffMs = performance.now() - tDiff; // Swap buffers - this.backFrame = this.frontFrame - this.frontFrame = frame + this.backFrame = this.frontFrame; + this.frontFrame = frame; // Periodically reset char/hyperlink pools to prevent unbounded growth // during long sessions. 5 minutes is infrequent enough that the O(cells) // migration cost is negligible. Reuses renderStart to avoid extra clock call. if (renderStart - this.lastPoolResetTime > 5 * 60 * 1000) { - this.resetPools() - this.lastPoolResetTime = renderStart + this.resetPools(); + this.lastPoolResetTime = renderStart; } - const flickers: FrameEvent['flickers'] = [] + const flickers: FrameEvent['flickers'] = []; for (const patch of diff) { if (patch.type === 'clearTerminal') { flickers.push({ desiredHeight: frame.screen.height, availableHeight: frame.viewport.height, reason: patch.reason, - }) + }); if (isDebugRepaintsEnabled() && patch.debug) { - const chain = dom.findOwnerChainAtRow( - this.rootNode, - patch.debug.triggerY, - ) + const chain = dom.findOwnerChainAtRow(this.rootNode, patch.debug.triggerY); logForDebugging( `[REPAINT] full reset · ${patch.reason} · row ${patch.debug.triggerY}\n` + ` prev: "${patch.debug.prevLine}"\n` + ` next: "${patch.debug.nextLine}"\n` + ` culprit: ${chain.length ? chain.join(' < ') : '(no owner chain captured)'}`, { level: 'warn' }, - ) + ); } } } - const tOptimize = performance.now() - const optimized = optimize(diff) - const optimizeMs = performance.now() - tOptimize - const hasDiff = optimized.length > 0 + const tOptimize = performance.now(); + const optimized = optimize(diff); + const optimizeMs = performance.now() - tOptimize; + const hasDiff = optimized.length > 0; if (this.altScreenActive && hasDiff) { // Prepend CSI H to anchor the physical cursor to (0,0) so // log-update's relative moves compute from a known spot (self-healing @@ -790,12 +733,12 @@ export default class Ink { // synchronously in handleResize would blank the screen for the ~80ms // render() takes. if (this.needsEraseBeforePaint) { - this.needsEraseBeforePaint = false - optimized.unshift(ERASE_THEN_HOME_PATCH) + this.needsEraseBeforePaint = false; + optimized.unshift(ERASE_THEN_HOME_PATCH); } else { - optimized.unshift(CURSOR_HOME_PATCH) + optimized.unshift(CURSOR_HOME_PATCH); } - optimized.push(this.altScreenParkPatch) + optimized.push(this.altScreenParkPatch); } // Native cursor positioning: park the terminal cursor at the declared @@ -805,29 +748,25 @@ export default class Ink { // translation) — if the declared node didn't render (stale declaration // after remount, or scrolled out of view), it won't be in the cache // and no move is emitted. - const decl = this.cursorDeclaration - const rect = decl !== null ? nodeCache.get(decl.node) : undefined + const decl = this.cursorDeclaration; + const rect = decl !== null ? nodeCache.get(decl.node) : undefined; const target = - decl !== null && rect !== undefined - ? { x: rect.x + decl.relativeX, y: rect.y + decl.relativeY } - : null - const parked = this.displayCursor + decl !== null && rect !== undefined ? { x: rect.x + decl.relativeX, y: rect.y + decl.relativeY } : null; + const parked = this.displayCursor; // Preserve the empty-diff zero-write fast path: skip all cursor writes // when nothing rendered AND the park target is unchanged. - const targetMoved = - target !== null && - (parked === null || parked.x !== target.x || parked.y !== target.y) + const targetMoved = target !== null && (parked === null || parked.x !== target.x || parked.y !== target.y); if (hasDiff || targetMoved || (target === null && parked !== null)) { // Main-screen preamble: log-update's relative moves assume the // physical cursor is at prevFrame.cursor. If last frame parked it // elsewhere, move back before the diff runs. Alt-screen's CSI H // already resets to (0,0) so no preamble needed. if (parked !== null && !this.altScreenActive && hasDiff) { - const pdx = prevFrame.cursor.x - parked.x - const pdy = prevFrame.cursor.y - parked.y + const pdx = prevFrame.cursor.x - parked.x; + const pdy = prevFrame.cursor.y - parked.y; if (pdx !== 0 || pdy !== 0) { - optimized.unshift({ type: 'stdout', content: cursorMove(pdx, pdy) }) + optimized.unshift({ type: 'stdout', content: cursorMove(pdx, pdy) }); } } @@ -835,24 +774,21 @@ export default class Ink { if (this.altScreenActive) { // Absolute CUP (1-indexed); next frame's CSI H resets regardless. // Emitted after altScreenParkPatch so the declared position wins. - const row = Math.min(Math.max(target.y + 1, 1), terminalRows) - const col = Math.min(Math.max(target.x + 1, 1), terminalWidth) - optimized.push({ type: 'stdout', content: cursorPosition(row, col) }) + const row = Math.min(Math.max(target.y + 1, 1), terminalRows); + const col = Math.min(Math.max(target.x + 1, 1), terminalWidth); + optimized.push({ type: 'stdout', content: cursorPosition(row, col) }); } else { // After the diff (or preamble), cursor is at frame.cursor. If no // diff AND previously parked, it's still at the old park position // (log-update wrote nothing). Otherwise it's at frame.cursor. - const from = - !hasDiff && parked !== null - ? parked - : { x: frame.cursor.x, y: frame.cursor.y } - const dx = target.x - from.x - const dy = target.y - from.y + const from = !hasDiff && parked !== null ? parked : { x: frame.cursor.x, y: frame.cursor.y }; + const dx = target.x - from.x; + const dy = target.y - from.y; if (dx !== 0 || dy !== 0) { - optimized.push({ type: 'stdout', content: cursorMove(dx, dy) }) + optimized.push({ type: 'stdout', content: cursorMove(dx, dy) }); } } - this.displayCursor = target + this.displayCursor = target; } else { // Declaration cleared (input blur, unmount). Restore physical cursor // to frame.cursor before forgetting the park position — otherwise @@ -862,29 +798,25 @@ export default class Ink { // !hasDiff (e.g. accessibility mode where blur doesn't change // renderedValue since invert is identity). if (parked !== null && !this.altScreenActive && !hasDiff) { - const rdx = frame.cursor.x - parked.x - const rdy = frame.cursor.y - parked.y + const rdx = frame.cursor.x - parked.x; + const rdy = frame.cursor.y - parked.y; if (rdx !== 0 || rdy !== 0) { - optimized.push({ type: 'stdout', content: cursorMove(rdx, rdy) }) + optimized.push({ type: 'stdout', content: cursorMove(rdx, rdy) }); } } - this.displayCursor = null + this.displayCursor = null; } } - const tWrite = performance.now() - writeDiffToTerminal( - this.terminal, - optimized, - this.altScreenActive && !SYNC_OUTPUT_SUPPORTED, - ) - const writeMs = performance.now() - tWrite + const tWrite = performance.now(); + writeDiffToTerminal(this.terminal, optimized, this.altScreenActive && !SYNC_OUTPUT_SUPPORTED); + const writeMs = performance.now() - tWrite; // Update blit safety for the NEXT frame. The frame just rendered // becomes frontFrame (= next frame's prevScreen). If we applied the // selection overlay, that buffer has inverted cells. selActive/hlActive // are only ever true in alt-screen; in main-screen this is false→false. - this.prevFrameContaminated = selActive || hlActive + this.prevFrameContaminated = selActive || hlActive; // A ScrollBox has pendingScrollDelta left to drain — schedule the next // frame. MUST NOT call this.scheduleRender() here: we're inside a @@ -899,24 +831,21 @@ export default class Ink { // quarter interval (~250fps, setTimeout practical floor) for max scroll // speed. Regular renders stay at FRAME_INTERVAL_MS via the throttle. if (frame.scrollDrainPending) { - this.drainTimer = setTimeout( - () => this.onRender(), - FRAME_INTERVAL_MS >> 2, - ) + this.drainTimer = setTimeout(() => this.onRender(), FRAME_INTERVAL_MS >> 2); } - const yogaMs = getLastYogaMs() - const commitMs = getLastCommitMs() - const yc = this.lastYogaCounters + const yogaMs = getLastYogaMs(); + const commitMs = getLastCommitMs(); + const yc = this.lastYogaCounters; // Reset so drain-only frames (no React commit) don't repeat stale values. - resetProfileCounters() + resetProfileCounters(); this.lastYogaCounters = { ms: 0, visited: 0, measured: 0, cacheHits: 0, live: 0, - } + }; this.options.onFrame?.({ durationMs: performance.now() - renderStart, phases: { @@ -933,21 +862,21 @@ export default class Ink { yogaLive: yc.live, }, flickers, - }) + }); } pause(): void { // Flush pending React updates and render before pausing. // @ts-expect-error flushSyncFromReconciler exists in react-reconciler 0.31 but not in @types/react-reconciler - reconciler.flushSyncFromReconciler() - this.onRender() + reconciler.flushSyncFromReconciler(); + this.onRender(); - this.isPaused = true + this.isPaused = true; } resume(): void { - this.isPaused = false - this.onRender() + this.isPaused = false; + this.onRender(); } /** @@ -962,19 +891,19 @@ export default class Ink { this.stylePool, this.charPool, this.hyperlinkPool, - ) + ); this.backFrame = emptyFrame( this.backFrame.viewport.height, this.backFrame.viewport.width, this.stylePool, this.charPool, this.hyperlinkPool, - ) - this.log.reset() + ); + this.log.reset(); // Physical cursor position is unknown after external terminal corruption. // Clear displayCursor so the cursor preamble doesn't emit a stale // relative move from where we last parked it. - this.displayCursor = null + this.displayCursor = null; } /** @@ -986,18 +915,18 @@ export default class Ink { * unchanged cells don't need repainting. Scrollback is preserved. */ forceRedraw(): void { - if (!this.options.stdout.isTTY || this.isUnmounted || this.isPaused) return - this.options.stdout.write(ERASE_SCREEN + CURSOR_HOME) + if (!this.options.stdout.isTTY || this.isUnmounted || this.isPaused) return; + this.options.stdout.write(ERASE_SCREEN + CURSOR_HOME); if (this.altScreenActive) { - this.resetFramesForAltScreen() + this.resetFramesForAltScreen(); } else { - this.repaint() + this.repaint(); // repaint() resets frontFrame to 0×0. Without this flag the next // frame's blit optimization copies from that empty screen and the // diff sees no content. onRender resets the flag at frame end. - this.prevFrameContaminated = true + this.prevFrameContaminated = true; } - this.onRender() + this.onRender(); } /** @@ -1011,7 +940,7 @@ export default class Ink { * onRender resets the flag at frame end so it's one-shot. */ invalidatePrevFrame(): void { - this.prevFrameContaminated = true + this.prevFrameContaminated = true; } /** @@ -1022,18 +951,18 @@ export default class Ink { * a full redraw with no stale diff state. */ setAltScreenActive(active: boolean, mouseTracking = false): void { - if (this.altScreenActive === active) return - this.altScreenActive = active - this.altScreenMouseTracking = active && mouseTracking + if (this.altScreenActive === active) return; + this.altScreenActive = active; + this.altScreenMouseTracking = active && mouseTracking; if (active) { - this.resetFramesForAltScreen() + this.resetFramesForAltScreen(); } else { - this.repaint() + this.repaint(); } } get isAltScreenActive(): boolean { - return this.altScreenActive + return this.altScreenActive; } /** @@ -1058,33 +987,29 @@ export default class Ink { * handleResize. */ reassertTerminalModes = (includeAltScreen = false): void => { - if (!this.options.stdout.isTTY) return + if (!this.options.stdout.isTTY) return; // Don't touch the terminal during an editor handoff — re-enabling kitty // keyboard here would undo enterAlternateScreen's disable and nano would // start seeing CSI-u sequences again. - if (this.isPaused) return + if (this.isPaused) return; // Extended keys — re-assert if enabled (App.tsx enables these on // allowlisted terminals at raw-mode entry; a terminal reset clears them). // Pop-before-push keeps Kitty stack depth at 1 instead of accumulating // on each call. if (supportsExtendedKeys()) { - this.options.stdout.write( - DISABLE_KITTY_KEYBOARD + - ENABLE_KITTY_KEYBOARD + - ENABLE_MODIFY_OTHER_KEYS, - ) + this.options.stdout.write(DISABLE_KITTY_KEYBOARD + ENABLE_KITTY_KEYBOARD + ENABLE_MODIFY_OTHER_KEYS); } - if (!this.altScreenActive) return + if (!this.altScreenActive) return; // Mouse tracking — idempotent, safe to re-assert on every stdin gap. if (this.altScreenMouseTracking) { - this.options.stdout.write(ENABLE_MOUSE_TRACKING) + this.options.stdout.write(ENABLE_MOUSE_TRACKING); } // Alt-screen re-entry — destructive (ERASE_SCREEN). Only for callers that // have a strong signal the terminal actually dropped mode 1049. if (includeAltScreen) { - this.reenterAltScreen() + this.reenterAltScreen(); } - } + }; /** * Mark this instance as unmounted so future unmount() calls early-return. @@ -1098,28 +1023,28 @@ export default class Ink { * as restoring the saved cursor position — clobbering the resume hint. */ detachForShutdown(): void { - this.isUnmounted = true + this.isUnmounted = true; // Cancel any pending throttled render so it doesn't fire between // cleanupTerminalModes() and process.exit() and write to main screen. - this.scheduleRender.cancel?.() + this.scheduleRender.cancel?.(); // Restore stdin from raw mode. unmount() used to do this via React // unmount (App.componentWillUnmount → handleSetRawMode(false)) but we're // short-circuiting that path. Must use this.options.stdin — NOT // process.stdin — because getStdinOverride() may have opened /dev/tty // when stdin is piped. const stdin = this.options.stdin as NodeJS.ReadStream & { - isRaw?: boolean - setRawMode?: (m: boolean) => void - } - this.drainStdin() + isRaw?: boolean; + setRawMode?: (m: boolean) => void; + }; + this.drainStdin(); if (stdin.isTTY && stdin.isRaw && stdin.setRawMode) { - stdin.setRawMode(false) + stdin.setRawMode(false); } } /** @see drainStdin */ drainStdin(): void { - drainStdin(this.options.stdin) + drainStdin(this.options.stdin); } /** @@ -1131,12 +1056,9 @@ export default class Ink { */ private reenterAltScreen(): void { this.options.stdout.write( - ENTER_ALT_SCREEN + - ERASE_SCREEN + - CURSOR_HOME + - (this.altScreenMouseTracking ? ENABLE_MOUSE_TRACKING : ''), - ) - this.resetFramesForAltScreen() + ENTER_ALT_SCREEN + ERASE_SCREEN + CURSOR_HOME + (this.altScreenMouseTracking ? ENABLE_MOUSE_TRACKING : ''), + ); + this.resetFramesForAltScreen(); } /** @@ -1155,29 +1077,23 @@ export default class Ink { * matches the physical cursor after ENTER_ALT_SCREEN + CSI H (home). */ private resetFramesForAltScreen(): void { - const rows = this.terminalRows - const cols = this.terminalColumns + const rows = this.terminalRows; + const cols = this.terminalColumns; const blank = (): Frame => ({ - screen: createScreen( - cols, - rows, - this.stylePool, - this.charPool, - this.hyperlinkPool, - ), + screen: createScreen(cols, rows, this.stylePool, this.charPool, this.hyperlinkPool), viewport: { width: cols, height: rows + 1 }, cursor: { x: 0, y: 0, visible: true }, - }) - this.frontFrame = blank() - this.backFrame = blank() - this.log.reset() + }); + this.frontFrame = blank(); + this.backFrame = blank(); + this.log.reset(); // Defense-in-depth: alt-screen skips the cursor preamble anyway (CSI H // resets), but a stale displayCursor would be misleading if we later // exit to main-screen without an intervening render. - this.displayCursor = null + this.displayCursor = null; // Fresh frontFrame is blank rows×cols — blitting from it would copy // blanks over content. Next alt-screen frame must full-render. - this.prevFrameContaminated = true + this.prevFrameContaminated = true; } /** @@ -1186,16 +1102,16 @@ export default class Ink { * region stays visible after the automatic copy. */ copySelectionNoClear(): string { - if (!hasSelection(this.selection)) return '' - const text = getSelectedText(this.selection, this.frontFrame.screen) + if (!hasSelection(this.selection)) return ''; + const text = getSelectedText(this.selection, this.frontFrame.screen); if (text) { // Raw OSC 52, or DCS-passthrough-wrapped OSC 52 inside tmux (tmux // drops it silently unless allow-passthrough is on — no regression). void setClipboard(text).then(raw => { - if (raw) this.options.stdout.write(raw) - }) + if (raw) this.options.stdout.write(raw); + }); } - return text + return text; } /** @@ -1203,18 +1119,18 @@ export default class Ink { * and clear the selection. Returns the copied text (empty if no selection). */ copySelection(): string { - if (!hasSelection(this.selection)) return '' - const text = this.copySelectionNoClear() - clearSelection(this.selection) - this.notifySelectionChange() - return text + if (!hasSelection(this.selection)) return ''; + const text = this.copySelectionNoClear(); + clearSelection(this.selection); + this.notifySelectionChange(); + return text; } /** Clear the current text selection without copying. */ clearTextSelection(): void { - if (!hasSelection(this.selection)) return - clearSelection(this.selection) - this.notifySelectionChange() + if (!hasSelection(this.selection)) return; + clearSelection(this.selection); + this.notifySelectionChange(); } /** @@ -1225,9 +1141,9 @@ export default class Ink { * damage, so the overlay forces full-frame damage while active. */ setSearchHighlight(query: string): void { - if (this.searchHighlightQuery === query) return - this.searchHighlightQuery = query - this.scheduleRender() + if (this.searchHighlightQuery === query) return; + this.searchHighlightQuery = query; + this.scheduleRender(); } /** Paint an EXISTING DOM subtree to a fresh Screen at its natural @@ -1241,39 +1157,33 @@ export default class Ink { * * ~1-2ms (paint only, no reconcile — the DOM is already built). */ scanElementSubtree(el: dom.DOMElement): MatchPosition[] { - if (!this.searchHighlightQuery || !el.yogaNode) return [] - const width = Math.ceil(el.yogaNode.getComputedWidth()) - const height = Math.ceil(el.yogaNode.getComputedHeight()) - if (width <= 0 || height <= 0) return [] + if (!this.searchHighlightQuery || !el.yogaNode) return []; + const width = Math.ceil(el.yogaNode.getComputedWidth()); + const height = Math.ceil(el.yogaNode.getComputedHeight()); + if (width <= 0 || height <= 0) return []; // renderNodeToOutput adds el's OWN computedLeft/Top to offsetX/Y. // Passing -elLeft/-elTop nets to 0 → paints at (0,0) in our buffer. - const elLeft = el.yogaNode.getComputedLeft() - const elTop = el.yogaNode.getComputedTop() - const screen = createScreen( - width, - height, - this.stylePool, - this.charPool, - this.hyperlinkPool, - ) + const elLeft = el.yogaNode.getComputedLeft(); + const elTop = el.yogaNode.getComputedTop(); + const screen = createScreen(width, height, this.stylePool, this.charPool, this.hyperlinkPool); const output = new Output({ width, height, stylePool: this.stylePool, screen, - }) + }); renderNodeToOutput(el, output, { offsetX: -elLeft, offsetY: -elTop, prevScreen: undefined, - }) - const rendered = output.get() + }); + const rendered = output.get(); // renderNodeToOutput wrote our offset positions to nodeCache — // corrupts the main render (it'd blit from wrong coords). Mark the // subtree dirty so the next main render repaints + re-caches // correctly. One extra paint of this message, but correct > fast. - dom.markDirty(el) - const positions = scanPositions(rendered, this.searchHighlightQuery) + dom.markDirty(el); + const positions = scanPositions(rendered, this.searchHighlightQuery); logForDebugging( `scanElementSubtree: q='${this.searchHighlightQuery}' ` + `el=${width}x${height}@(${elLeft},${elTop}) n=${positions.length} ` + @@ -1282,8 +1192,8 @@ export default class Ink { .map(p => `${p.row}:${p.col}`) .join(',')}` + `${positions.length > 10 ? ',…' : ''}]`, - ) - return positions + ); + return positions; } /** Set the position-based highlight state. Every frame, writes CURRENT @@ -1293,13 +1203,13 @@ export default class Ink { * screen-top); positions stay stable (message-relative). */ setSearchPositions( state: { - positions: MatchPosition[] - rowOffset: number - currentIdx: number + positions: MatchPosition[]; + rowOffset: number; + currentIdx: number; } | null, ): void { - this.searchPositions = state - this.scheduleRender() + this.searchPositions = state; + this.scheduleRender(); } /** @@ -1320,17 +1230,17 @@ export default class Ink { // Wrap a NUL marker, then split on it to extract the open/close SGR. // colorize returns the input unchanged if the color string is bad — // no NUL-split then, so fall through to null (inverse fallback). - const wrapped = colorize('\0', color, 'background') - const nul = wrapped.indexOf('\0') + const wrapped = colorize('\0', color, 'background'); + const nul = wrapped.indexOf('\0'); if (nul <= 0 || nul === wrapped.length - 1) { - this.stylePool.setSelectionBg(null) - return + this.stylePool.setSelectionBg(null); + return; } this.stylePool.setSelectionBg({ type: 'ansi', code: wrapped.slice(0, nul), endCode: wrapped.slice(nul + 1), // always \x1b[49m for bg - }) + }); // No scheduleRender: this is called from a React effect that already // runs inside the render cycle, and the bg only matters once a // selection exists (which itself triggers a full-damage frame). @@ -1342,18 +1252,8 @@ export default class Ink { * screen buffer still holds the outgoing content. Accumulated into * the selection state and joined back in by getSelectedText. */ - captureScrolledRows( - firstRow: number, - lastRow: number, - side: 'above' | 'below', - ): void { - captureScrolledRows( - this.selection, - this.frontFrame.screen, - firstRow, - lastRow, - side, - ) + captureScrolledRows(firstRow: number, lastRow: number, side: 'above' | 'below'): void { + captureScrolledRows(this.selection, this.frontFrame.screen, firstRow, lastRow, side); } /** @@ -1364,20 +1264,14 @@ export default class Ink { * edge. Supplies screen.width for the col-reset-on-clamp boundary. */ shiftSelectionForScroll(dRow: number, minRow: number, maxRow: number): void { - const hadSel = hasSelection(this.selection) - shiftSelection( - this.selection, - dRow, - minRow, - maxRow, - this.frontFrame.screen.width, - ) + const hadSel = hasSelection(this.selection); + shiftSelection(this.selection, dRow, minRow, maxRow, this.frontFrame.screen.width); // shiftSelection clears when both endpoints overshoot the same edge // (Home/g/End/G page-jump past the selection). Notify subscribers so // useHasSelection updates. Safe to call notifySelectionChange here — // this runs from keyboard handlers, not inside onRender(). if (hadSel && !hasSelection(this.selection)) { - this.notifySelectionChange() + this.notifySelectionChange(); } } @@ -1390,49 +1284,49 @@ export default class Ink { * char mode. No-op outside alt-screen or without an active selection. */ moveSelectionFocus(move: FocusMove): void { - if (!this.altScreenActive) return - const { focus } = this.selection - if (!focus) return - const { width, height } = this.frontFrame.screen - const maxCol = width - 1 - const maxRow = height - 1 - let { col, row } = focus + if (!this.altScreenActive) return; + const { focus } = this.selection; + if (!focus) return; + const { width, height } = this.frontFrame.screen; + const maxCol = width - 1; + const maxRow = height - 1; + let { col, row } = focus; switch (move) { case 'left': - if (col > 0) col-- + if (col > 0) col--; else if (row > 0) { - col = maxCol - row-- + col = maxCol; + row--; } - break + break; case 'right': - if (col < maxCol) col++ + if (col < maxCol) col++; else if (row < maxRow) { - col = 0 - row++ + col = 0; + row++; } - break + break; case 'up': - if (row > 0) row-- - break + if (row > 0) row--; + break; case 'down': - if (row < maxRow) row++ - break + if (row < maxRow) row++; + break; case 'lineStart': - col = 0 - break + col = 0; + break; case 'lineEnd': - col = maxCol - break + col = maxCol; + break; } - if (col === focus.col && row === focus.row) return - moveFocus(this.selection, col, row) - this.notifySelectionChange() + if (col === focus.col && row === focus.row) return; + moveFocus(this.selection, col, row); + this.notifySelectionChange(); } /** Whether there is an active text selection. */ hasTextSelection(): boolean { - return hasSelection(this.selection) + return hasSelection(this.selection); } /** @@ -1440,13 +1334,13 @@ export default class Ink { * is started, updated, cleared, or copied. Returns an unsubscribe fn. */ subscribeToSelectionChange(cb: () => void): () => void { - this.selectionListeners.add(cb) - return () => this.selectionListeners.delete(cb) + this.selectionListeners.add(cb); + return () => this.selectionListeners.delete(cb); } private notifySelectionChange(): void { - this.onRender() - for (const cb of this.selectionListeners) cb() + this.onRender(); + for (const cb of this.selectionListeners) cb(); } /** @@ -1457,33 +1351,28 @@ export default class Ink { * nodeCache rects map 1:1 to terminal cells (no scrollback offset). */ dispatchClick(col: number, row: number): boolean { - if (!this.altScreenActive) return false - const blank = isEmptyCellAt(this.frontFrame.screen, col, row) - return dispatchClick(this.rootNode, col, row, blank) + if (!this.altScreenActive) return false; + const blank = isEmptyCellAt(this.frontFrame.screen, col, row); + return dispatchClick(this.rootNode, col, row, blank); } dispatchHover(col: number, row: number): void { - if (!this.altScreenActive) return - dispatchHover(this.rootNode, col, row, this.hoveredNodes) + if (!this.altScreenActive) return; + dispatchHover(this.rootNode, col, row, this.hoveredNodes); } dispatchKeyboardEvent(parsedKey: ParsedKey): void { - const target = this.focusManager.activeElement ?? this.rootNode - const event = new KeyboardEvent(parsedKey) - dispatcher.dispatchDiscrete(target, event) + const target = this.focusManager.activeElement ?? this.rootNode; + const event = new KeyboardEvent(parsedKey); + dispatcher.dispatchDiscrete(target, event); // Tab cycling is the default action — only fires if no handler // called preventDefault(). Mirrors browser behavior. - if ( - !event.defaultPrevented && - parsedKey.name === 'tab' && - !parsedKey.ctrl && - !parsedKey.meta - ) { + if (!event.defaultPrevented && parsedKey.name === 'tab' && !parsedKey.ctrl && !parsedKey.meta) { if (parsedKey.shift) { - this.focusManager.focusPrevious(this.rootNode) + this.focusManager.focusPrevious(this.rootNode); } else { - this.focusManager.focusNext(this.rootNode) + this.focusManager.focusNext(this.rootNode); } } } @@ -1497,23 +1386,23 @@ export default class Ink { * the browser-open action via a timer. */ getHyperlinkAt(col: number, row: number): string | undefined { - if (!this.altScreenActive) return undefined - const screen = this.frontFrame.screen - const cell = cellAt(screen, col, row) - let url = cell?.hyperlink + if (!this.altScreenActive) return undefined; + const screen = this.frontFrame.screen; + const cell = cellAt(screen, col, row); + let url = cell?.hyperlink; // SpacerTail cells (right half of wide/CJK/emoji chars) store the // hyperlink on the head cell at col-1. if (!url && cell?.width === CellWidth.SpacerTail && col > 0) { - url = cellAt(screen, col - 1, row)?.hyperlink + url = cellAt(screen, col - 1, row)?.hyperlink; } - return url ?? findPlainTextUrlAt(screen, col, row) + return url ?? findPlainTextUrlAt(screen, col, row); } /** * Optional callback fired when clicking an OSC 8 hyperlink in fullscreen * mode. Set by FullscreenLayout via useLayoutEffect. */ - onHyperlinkClick: ((url: string) => void) | undefined + onHyperlinkClick: ((url: string) => void) | undefined; /** * Stable prototype wrapper for onHyperlinkClick. Passed to as @@ -1521,7 +1410,7 @@ export default class Ink { * the mutable field at call time — not the undefined-at-render value. */ openHyperlink(url: string): void { - this.onHyperlinkClick?.(url) + this.onHyperlinkClick?.(url); } /** @@ -1532,18 +1421,18 @@ export default class Ink { * char-mode startSelection if the click lands on a noSelect cell. */ handleMultiClick(col: number, row: number, count: 2 | 3): void { - if (!this.altScreenActive) return - const screen = this.frontFrame.screen + if (!this.altScreenActive) return; + const screen = this.frontFrame.screen; // selectWordAt/selectLineAt no-op on noSelect/out-of-bounds. Seed with // a char-mode selection so the press still starts a drag even if the // word/line scan finds nothing selectable. - startSelection(this.selection, col, row) - if (count === 2) selectWordAt(this.selection, screen, col, row) - else selectLineAt(this.selection, screen, row) + startSelection(this.selection, col, row); + if (count === 2) selectWordAt(this.selection, screen, col, row); + else selectLineAt(this.selection, screen, row); // Ensure hasSelection is true so release doesn't re-dispatch onClickAt. // selectWordAt no-ops on noSelect; selectLineAt no-ops out-of-bounds. - if (!this.selection.focus) this.selection.focus = this.selection.anchor - this.notifySelectionChange() + if (!this.selection.focus) this.selection.focus = this.selection.anchor; + this.notifySelectionChange(); } /** @@ -1553,85 +1442,84 @@ export default class Ink { * altScreenActive for the same reason as dispatchClick. */ handleSelectionDrag(col: number, row: number): void { - if (!this.altScreenActive) return - const sel = this.selection + if (!this.altScreenActive) return; + const sel = this.selection; if (sel.anchorSpan) { - extendSelection(sel, this.frontFrame.screen, col, row) + extendSelection(sel, this.frontFrame.screen, col, row); } else { - updateSelection(sel, col, row) + updateSelection(sel, col, row); } - this.notifySelectionChange() + this.notifySelectionChange(); } // Methods to properly suspend stdin for external editor usage // This is needed to prevent Ink from swallowing keystrokes when an external editor is active private stdinListeners: Array<{ - event: string - listener: (...args: unknown[]) => void - }> = [] - private wasRawMode = false + event: string; + listener: (...args: unknown[]) => void; + }> = []; + private wasRawMode = false; suspendStdin(): void { - const stdin = this.options.stdin + const stdin = this.options.stdin; if (!stdin.isTTY) { - return + return; } // Store and remove all 'readable' event listeners temporarily // This prevents Ink from consuming stdin while the editor is active - const readableListeners = stdin.listeners('readable') + const readableListeners = stdin.listeners('readable'); logForDebugging( `[stdin] suspendStdin: removing ${readableListeners.length} readable listener(s), wasRawMode=${(stdin as NodeJS.ReadStream & { isRaw?: boolean }).isRaw ?? false}`, - ) + ); readableListeners.forEach(listener => { this.stdinListeners.push({ event: 'readable', listener: listener as (...args: unknown[]) => void, - }) - stdin.removeListener('readable', listener as (...args: unknown[]) => void) - }) + }); + stdin.removeListener('readable', listener as (...args: unknown[]) => void); + }); // If raw mode is enabled, disable it temporarily const stdinWithRaw = stdin as NodeJS.ReadStream & { - isRaw?: boolean - setRawMode?: (mode: boolean) => void - } + isRaw?: boolean; + setRawMode?: (mode: boolean) => void; + }; if (stdinWithRaw.isRaw && stdinWithRaw.setRawMode) { - stdinWithRaw.setRawMode(false) - this.wasRawMode = true + stdinWithRaw.setRawMode(false); + this.wasRawMode = true; } } resumeStdin(): void { - const stdin = this.options.stdin + const stdin = this.options.stdin; if (!stdin.isTTY) { - return + return; } // Re-attach all the stored listeners if (this.stdinListeners.length === 0 && !this.wasRawMode) { - logForDebugging( - '[stdin] resumeStdin: called with no stored listeners and wasRawMode=false (possible desync)', - { level: 'warn' }, - ) + logForDebugging('[stdin] resumeStdin: called with no stored listeners and wasRawMode=false (possible desync)', { + level: 'warn', + }); } logForDebugging( `[stdin] resumeStdin: re-attaching ${this.stdinListeners.length} listener(s), wasRawMode=${this.wasRawMode}`, - ) + ); this.stdinListeners.forEach(({ event, listener }) => { - stdin.addListener(event, listener) - }) - this.stdinListeners = [] + stdin.addListener(event, listener); + }); + this.stdinListeners = []; // Re-enable raw mode if it was enabled before if (this.wasRawMode) { const stdinWithRaw = stdin as NodeJS.ReadStream & { - setRawMode?: (mode: boolean) => void - } + setRawMode?: (mode: boolean) => void; + }; if (stdinWithRaw.setRawMode) { - stdinWithRaw.setRawMode(true) + stdinWithRaw.setRawMode(true); } - this.wasRawMode = false + this.wasRawMode = false; } } @@ -1640,25 +1528,18 @@ export default class Ink { // cascades through useContext → 's useLayoutEffect dep // array → spurious exit+re-enter of the alt screen on every SIGWINCH. private writeRaw(data: string): void { - this.options.stdout.write(data) + this.options.stdout.write(data); } - private setCursorDeclaration: CursorDeclarationSetter = ( - decl, - clearIfNode, - ) => { - if ( - decl === null && - clearIfNode !== undefined && - this.cursorDeclaration?.node !== clearIfNode - ) { - return + private setCursorDeclaration: CursorDeclarationSetter = (decl, clearIfNode) => { + if (decl === null && clearIfNode !== undefined && this.cursorDeclaration?.node !== clearIfNode) { + return; } - this.cursorDeclaration = decl - } + this.cursorDeclaration = decl; + }; render(node: ReactNode): void { - this.currentNode = node + this.currentNode = node; const tree = ( - - {node} - + {node} - ) + ); // @ts-expect-error updateContainerSync exists in react-reconciler but not in @types/react-reconciler - reconciler.updateContainerSync(tree, this.container, null, noop) + reconciler.updateContainerSync(tree, this.container, null, noop); // @ts-expect-error flushSyncWork exists in react-reconciler but not in @types/react-reconciler - reconciler.flushSyncWork() + reconciler.flushSyncWork(); } unmount(error?: Error | number | null): void { if (this.isUnmounted) { - return + return; } - this.onRender() - this.unsubscribeExit() + this.onRender(); + this.unsubscribeExit(); if (typeof this.restoreConsole === 'function') { - this.restoreConsole() + this.restoreConsole(); } - this.restoreStderr?.() + this.restoreStderr?.(); - this.unsubscribeTTYHandlers?.() + this.unsubscribeTTYHandlers?.(); // Non-TTY environments don't handle erasing ansi escapes well, so it's better to // only render last frame of non-static output - const diff = this.log.renderPreviousOutput_DEPRECATED(this.frontFrame) - writeDiffToTerminal(this.terminal, optimize(diff)) + const diff = this.log.renderPreviousOutput_DEPRECATED(this.frontFrame); + writeDiffToTerminal(this.terminal, optimize(diff)); // Clean up terminal modes synchronously before process exit. // React's componentWillUnmount won't run in time when process.exit() is called, @@ -1725,83 +1604,82 @@ export default class Ink { if (this.altScreenActive) { // 's unmount effect won't run during signal-exit. // Exit alt screen FIRST so other cleanup sequences go to the main screen. - writeSync(1, EXIT_ALT_SCREEN) + writeSync(1, EXIT_ALT_SCREEN); } // Disable mouse tracking — unconditional because altScreenActive can be // stale if AlternateScreen's unmount (which flips the flag) raced a // blocked event loop + SIGINT. No-op if tracking was never enabled. - writeSync(1, DISABLE_MOUSE_TRACKING) + writeSync(1, DISABLE_MOUSE_TRACKING); // Drain stdin so in-flight mouse events don't leak to the shell - this.drainStdin() + this.drainStdin(); // Disable extended key reporting (both kitty and modifyOtherKeys) - writeSync(1, DISABLE_MODIFY_OTHER_KEYS) - writeSync(1, DISABLE_KITTY_KEYBOARD) + writeSync(1, DISABLE_MODIFY_OTHER_KEYS); + writeSync(1, DISABLE_KITTY_KEYBOARD); // Disable focus events (DECSET 1004) - writeSync(1, DFE) + writeSync(1, DFE); // Disable bracketed paste mode - writeSync(1, DBP) + writeSync(1, DBP); // Show cursor - writeSync(1, SHOW_CURSOR) + writeSync(1, SHOW_CURSOR); // Clear iTerm2 progress bar - writeSync(1, CLEAR_ITERM2_PROGRESS) + writeSync(1, CLEAR_ITERM2_PROGRESS); // Clear tab status (OSC 21337) so a stale dot doesn't linger - if (supportsTabStatus()) - writeSync(1, wrapForMultiplexer(CLEAR_TAB_STATUS)) + if (supportsTabStatus()) writeSync(1, wrapForMultiplexer(CLEAR_TAB_STATUS)); } /* eslint-enable custom-rules/no-sync-fs */ - this.isUnmounted = true + this.isUnmounted = true; // Cancel any pending throttled renders to prevent accessing freed Yoga nodes - this.scheduleRender.cancel?.() + this.scheduleRender.cancel?.(); if (this.drainTimer !== null) { - clearTimeout(this.drainTimer) - this.drainTimer = null + clearTimeout(this.drainTimer); + this.drainTimer = null; } // @ts-expect-error updateContainerSync exists in react-reconciler but not in @types/react-reconciler - reconciler.updateContainerSync(null, this.container, null, noop) + reconciler.updateContainerSync(null, this.container, null, noop); // @ts-expect-error flushSyncWork exists in react-reconciler but not in @types/react-reconciler - reconciler.flushSyncWork() - instances.delete(this.options.stdout) + reconciler.flushSyncWork(); + instances.delete(this.options.stdout); // Free the root yoga node, then clear its reference. Children are already // freed by the reconciler's removeChildFromContainer; using .free() (not // .freeRecursive()) avoids double-freeing them. - this.rootNode.yogaNode?.free() - this.rootNode.yogaNode = undefined + this.rootNode.yogaNode?.free(); + this.rootNode.yogaNode = undefined; if (error instanceof Error) { - this.rejectExitPromise(error) + this.rejectExitPromise(error); } else { - this.resolveExitPromise() + this.resolveExitPromise(); } } async waitUntilExit(): Promise { this.exitPromise ||= new Promise((resolve, reject) => { - this.resolveExitPromise = resolve - this.rejectExitPromise = reject - }) + this.resolveExitPromise = resolve; + this.rejectExitPromise = reject; + }); - return this.exitPromise + return this.exitPromise; } resetLineCount(): void { if (this.options.stdout.isTTY) { // Swap so old front becomes back (for screen reuse), then reset front - this.backFrame = this.frontFrame + this.backFrame = this.frontFrame; this.frontFrame = emptyFrame( this.frontFrame.viewport.height, this.frontFrame.viewport.width, this.stylePool, this.charPool, this.hyperlinkPool, - ) - this.log.reset() + ); + this.log.reset(); // frontFrame is reset, so frame.cursor on the next render is (0,0). // Clear displayCursor so the preamble doesn't compute a stale delta. - this.displayCursor = null + this.displayCursor = null; } } @@ -1814,41 +1692,35 @@ export default class Ink { * Call between conversation turns or periodically. */ resetPools(): void { - this.charPool = new CharPool() - this.hyperlinkPool = new HyperlinkPool() - migrateScreenPools( - this.frontFrame.screen, - this.charPool, - this.hyperlinkPool, - ) + this.charPool = new CharPool(); + this.hyperlinkPool = new HyperlinkPool(); + migrateScreenPools(this.frontFrame.screen, this.charPool, this.hyperlinkPool); // Back frame's data is zeroed by resetScreen before reads, but its pool // references are used by the renderer to intern new characters. Point // them at the new pools so the next frame's IDs are comparable. - this.backFrame.screen.charPool = this.charPool - this.backFrame.screen.hyperlinkPool = this.hyperlinkPool + this.backFrame.screen.charPool = this.charPool; + this.backFrame.screen.hyperlinkPool = this.hyperlinkPool; } patchConsole(): () => void { // biome-ignore lint/suspicious/noConsole: intentionally patching global console - const con = console - const originals: Partial> = {} - const toDebug = (...args: unknown[]) => - logForDebugging(`console.log: ${format(...args)}`) - const toError = (...args: unknown[]) => - logError(new Error(`console.error: ${format(...args)}`)) + const con = console; + const originals: Partial> = {}; + const toDebug = (...args: unknown[]) => logForDebugging(`console.log: ${format(...args)}`); + const toError = (...args: unknown[]) => logError(new Error(`console.error: ${format(...args)}`)); for (const m of CONSOLE_STDOUT_METHODS) { - originals[m] = con[m] - con[m] = toDebug + originals[m] = con[m]; + con[m] = toDebug; } for (const m of CONSOLE_STDERR_METHODS) { - originals[m] = con[m] - con[m] = toError + originals[m] = con[m]; + con[m] = toError; } - originals.assert = con.assert + originals.assert = con.assert; con.assert = (condition: unknown, ...args: unknown[]) => { - if (!condition) toError(...args) - } - return () => Object.assign(con, originals) + if (!condition) toError(...args); + }; + return () => Object.assign(con, originals); } /** @@ -1864,46 +1736,42 @@ export default class Ink { * process.stdout — Ink itself writes there. */ private patchStderr(): () => void { - const stderr = process.stderr - const originalWrite = stderr.write - let reentered = false + const stderr = process.stderr; + const originalWrite = stderr.write; + let reentered = false; const intercept = ( chunk: Uint8Array | string, encodingOrCb?: BufferEncoding | ((err?: Error) => void), cb?: (err?: Error) => void, ): boolean => { - const callback = typeof encodingOrCb === 'function' ? encodingOrCb : cb + const callback = typeof encodingOrCb === 'function' ? encodingOrCb : cb; // Reentrancy guard: logForDebugging → writeToStderr → here. Pass // through to the original so --debug-to-stderr still works and we // don't stack-overflow. if (reentered) { - const encoding = - typeof encodingOrCb === 'string' ? encodingOrCb : undefined - return originalWrite.call(stderr, chunk, encoding, callback) + const encoding = typeof encodingOrCb === 'string' ? encodingOrCb : undefined; + return originalWrite.call(stderr, chunk, encoding, callback); } - reentered = true + reentered = true; try { - const text = - typeof chunk === 'string' - ? chunk - : Buffer.from(chunk).toString('utf8') - logForDebugging(`[stderr] ${text}`, { level: 'warn' }) + const text = typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'); + logForDebugging(`[stderr] ${text}`, { level: 'warn' }); if (this.altScreenActive && !this.isUnmounted && !this.isPaused) { - this.prevFrameContaminated = true - this.scheduleRender() + this.prevFrameContaminated = true; + this.scheduleRender(); } } finally { - reentered = false - callback?.() + reentered = false; + callback?.(); } - return true - } - stderr.write = intercept + return true; + }; + stderr.write = intercept; return () => { if (stderr.write === intercept) { - stderr.write = originalWrite + stderr.write = originalWrite; } - } + }; } } @@ -1930,7 +1798,7 @@ export default class Ink { */ /* eslint-disable custom-rules/no-sync-fs -- must be sync; called from signal handler / unmount */ export function drainStdin(stdin: NodeJS.ReadStream = process.stdin): void { - if (!stdin.isTTY) return + if (!stdin.isTTY) return; // Drain Node's stream buffer (bytes libuv already pulled in). read() // returns null when empty — never blocks. try { @@ -1942,27 +1810,27 @@ export function drainStdin(stdin: NodeJS.ReadStream = process.stdin): void { } // No /dev/tty on Windows; CONIN$ doesn't support O_NONBLOCK semantics. // Windows Terminal also doesn't buffer mouse reports the same way. - if (process.platform === 'win32') return + if (process.platform === 'win32') return; // termios is per-device: flip stdin to raw so canonical-mode line // buffering doesn't hide partial input from the non-blocking read. // Restored in the finally block. const tty = stdin as NodeJS.ReadStream & { - isRaw?: boolean - setRawMode?: (raw: boolean) => void - } - const wasRaw = tty.isRaw === true + isRaw?: boolean; + setRawMode?: (raw: boolean) => void; + }; + const wasRaw = tty.isRaw === true; // Drain the kernel TTY buffer via a fresh O_NONBLOCK fd. Bounded at 64 // reads (64KB) — a real mouse burst is a few hundred bytes; the cap // guards against a terminal that ignores O_NONBLOCK. - let fd = -1 + let fd = -1; try { // setRawMode inside try: on revoked TTY (SIGHUP/SSH disconnect) the // ioctl throws EBADF — same recovery path as openSync/readSync below. - if (!wasRaw) tty.setRawMode?.(true) - fd = openSync('/dev/tty', fsConstants.O_RDONLY | fsConstants.O_NONBLOCK) - const buf = Buffer.alloc(1024) + if (!wasRaw) tty.setRawMode?.(true); + fd = openSync('/dev/tty', fsConstants.O_RDONLY | fsConstants.O_NONBLOCK); + const buf = Buffer.alloc(1024); for (let i = 0; i < 64; i++) { - if (readSync(fd, buf, 0, buf.length, null) <= 0) break + if (readSync(fd, buf, 0, buf.length, null) <= 0) break; } } catch { // EAGAIN (buffer empty — expected), ENXIO/ENOENT (no controlling tty), @@ -1970,14 +1838,14 @@ export function drainStdin(stdin: NodeJS.ReadStream = process.stdin): void { } finally { if (fd >= 0) { try { - closeSync(fd) + closeSync(fd); } catch { /* ignore */ } } if (!wasRaw) { try { - tty.setRawMode?.(false) + tty.setRawMode?.(false); } catch { /* TTY may be gone */ } @@ -2001,5 +1869,5 @@ const CONSOLE_STDOUT_METHODS = [ 'time', 'timeEnd', 'timeLog', -] as const -const CONSOLE_STDERR_METHODS = ['warn', 'error', 'trace'] as const +] as const; +const CONSOLE_STDERR_METHODS = ['warn', 'error', 'trace'] as const; diff --git a/src/ink/reconciler.ts b/src/ink/reconciler.ts index 831366f7a..5cee81cc0 100644 --- a/src/ink/reconciler.ts +++ b/src/ink/reconciler.ts @@ -36,7 +36,10 @@ if (process.env.NODE_ENV === 'development') { void import('./devtools.js') // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: unknown) { - if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ERR_MODULE_NOT_FOUND') { + if ( + error instanceof Error && + (error as NodeJS.ErrnoException).code === 'ERR_MODULE_NOT_FOUND' + ) { // biome-ignore lint/suspicious/noConsole: intentional warning console.warn( ` diff --git a/src/ink/src/bootstrap/state.ts b/src/ink/src/bootstrap/state.ts index 875ce2bdc..409680a7c 100644 --- a/src/ink/src/bootstrap/state.ts +++ b/src/ink/src/bootstrap/state.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type flushInteractionTime = any; +export type flushInteractionTime = any diff --git a/src/ink/src/native-ts/yoga-layout/index.ts b/src/ink/src/native-ts/yoga-layout/index.ts index c75a2b090..aeaddee6a 100644 --- a/src/ink/src/native-ts/yoga-layout/index.ts +++ b/src/ink/src/native-ts/yoga-layout/index.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getYogaCounters = any; +export type getYogaCounters = any diff --git a/src/ink/src/utils/debug.ts b/src/ink/src/utils/debug.ts index c64d5960c..5a0f1d515 100644 --- a/src/ink/src/utils/debug.ts +++ b/src/ink/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/ink/src/utils/log.ts b/src/ink/src/utils/log.ts index cf30e90da..30df9cbcb 100644 --- a/src/ink/src/utils/log.ts +++ b/src/ink/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/interactiveHelpers.tsx b/src/interactiveHelpers.tsx index 72bf5e7be..b34c92afe 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 './ink/terminal.js' -import type { RenderOptions, Root, TextProps } from './ink.js' -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 './ink/terminal.js'; +import type { RenderOptions, Root, TextProps } from './ink.js'; +import { KeybindingSetup } from './keybindings/KeybindingProviderSetup.js'; +import { startDeferredPrefetches } from './main.js'; import { checkGate_CACHED_OR_BLOCKING, initializeGrowthBook, resetGrowthBook, -} from './services/analytics/growthbook.js' -import { isQualifiedForGrove } from './services/api/grove.js' -import { handleMcpjsonServerApprovals } from './services/mcpServerApproval.js' -import { AppStateProvider } from './state/AppState.js' -import { onChangeAppState } from './state/onChangeAppState.js' -import { normalizeApiKeyForConfig } from './utils/authPortable.js' +} 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('./ink.js') - const color = options?.color - const exitCode = options?.exitCode ?? 1 - root.render( - color ? {message} : {message}, - ) - root.unmount() - await options?.beforeExit?.() + const { Text } = await import('./ink.js'); + 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( @@ -153,33 +135,33 @@ export async function showSetupScreens( devChannels?: ChannelEntry[], ): Promise { if ( - "production" === 'test' || + 'production' === 'test' || isEnvTruthy(false) || process.env.IS_DEMO // Skip onboarding in demo mode ) { - return false + return false; } - const config = getGlobalConfig() - let onboardingShown = false + const config = getGlobalConfig(); + 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/jobs/classifier.ts b/src/jobs/classifier.ts index 8cd26637e..e27c5bcc0 100644 --- a/src/jobs/classifier.ts +++ b/src/jobs/classifier.ts @@ -1,3 +1,4 @@ // Auto-generated stub — replace with real implementation -export {}; -export const classifyAndWriteState: (...args: unknown[]) => Promise = () => Promise.resolve(); +export {} +export const classifyAndWriteState: (...args: unknown[]) => Promise = + () => Promise.resolve() diff --git a/src/keybindings/KeybindingContext.tsx b/src/keybindings/KeybindingContext.tsx index bc85e81c0..96d45f2f4 100644 --- a/src/keybindings/KeybindingContext.tsx +++ b/src/keybindings/KeybindingContext.tsx @@ -1,84 +1,63 @@ -import React, { - createContext, - type RefObject, - useContext, - useLayoutEffect, - useMemo, -} from 'react' -import type { Key } from '../ink.js' -import { - type ChordResolveResult, - getBindingDisplayText, - resolveKeyWithChordState, -} from './resolver.js' -import type { - KeybindingContextName, - ParsedBinding, - ParsedKeystroke, -} from './types.js' +import React, { createContext, type RefObject, useContext, useLayoutEffect, useMemo } from 'react'; +import type { Key } from '../ink.js'; +import { type ChordResolveResult, getBindingDisplayText, resolveKeyWithChordState } from './resolver.js'; +import type { KeybindingContextName, ParsedBinding, ParsedKeystroke } from './types.js'; /** Handler registration for action callbacks */ type HandlerRegistration = { - action: string - context: KeybindingContextName - handler: () => void -} + action: string; + context: KeybindingContextName; + handler: () => void; +}; type KeybindingContextValue = { /** Resolve a key input to an action name (with chord support) */ - resolve: ( - input: string, - key: Key, - activeContexts: KeybindingContextName[], - ) => ChordResolveResult + resolve: (input: string, key: Key, activeContexts: KeybindingContextName[]) => ChordResolveResult; /** Update the pending chord state */ - setPendingChord: (pending: ParsedKeystroke[] | null) => void + setPendingChord: (pending: ParsedKeystroke[] | null) => void; /** Get display text for an action (e.g., "ctrl+t") */ - getDisplayText: ( - action: string, - context: KeybindingContextName, - ) => string | undefined + getDisplayText: (action: string, context: KeybindingContextName) => string | undefined; /** All parsed bindings (for help display) */ - bindings: ParsedBinding[] + bindings: ParsedBinding[]; /** Current pending chord keystrokes (null if not in a chord) */ - pendingChord: ParsedKeystroke[] | null + pendingChord: ParsedKeystroke[] | null; /** Currently active keybinding contexts (for priority resolution) */ - activeContexts: Set + activeContexts: Set; /** Register a context as active (call on mount) */ - registerActiveContext: (context: KeybindingContextName) => void + registerActiveContext: (context: KeybindingContextName) => void; /** Unregister a context (call on unmount) */ - unregisterActiveContext: (context: KeybindingContextName) => void + unregisterActiveContext: (context: KeybindingContextName) => void; /** Register a handler for an action (used by useKeybinding) */ - registerHandler: (registration: HandlerRegistration) => () => void + registerHandler: (registration: HandlerRegistration) => () => void; /** Invoke all handlers for an action (used by ChordInterceptor) */ - invokeAction: (action: string) => boolean -} + invokeAction: (action: string) => boolean; +}; -const KeybindingContext = createContext(null) +const KeybindingContext = createContext(null); type ProviderProps = { - bindings: ParsedBinding[] + bindings: ParsedBinding[]; /** Ref for immediate access to pending chord (avoids React state delay) */ - pendingChordRef: RefObject + pendingChordRef: RefObject; /** State value for re-renders (UI updates) */ - pendingChord: ParsedKeystroke[] | null - setPendingChord: (pending: ParsedKeystroke[] | null) => void - activeContexts: Set - registerActiveContext: (context: KeybindingContextName) => void - unregisterActiveContext: (context: KeybindingContextName) => void + pendingChord: ParsedKeystroke[] | null; + setPendingChord: (pending: ParsedKeystroke[] | null) => void; + activeContexts: Set; + registerActiveContext: (context: KeybindingContextName) => void; + unregisterActiveContext: (context: KeybindingContextName) => void; /** Ref to handler registry (used by ChordInterceptor) */ - handlerRegistryRef: RefObject>> - children: React.ReactNode -} + handlerRegistryRef: RefObject>>; + children: React.ReactNode; +}; export function KeybindingProvider({ bindings, @@ -93,60 +72,54 @@ export function KeybindingProvider({ }: ProviderProps): React.ReactNode { const value = useMemo(() => { const getDisplay = (action: string, context: KeybindingContextName) => - getBindingDisplayText(action, context, bindings) + getBindingDisplayText(action, context, bindings); // Register a handler for an action const registerHandler = (registration: HandlerRegistration) => { - const registry = handlerRegistryRef.current - if (!registry) return () => {} + const registry = handlerRegistryRef.current; + if (!registry) return () => {}; if (!registry.has(registration.action)) { - registry.set(registration.action, new Set()) + registry.set(registration.action, new Set()); } - registry.get(registration.action)!.add(registration) + registry.get(registration.action)!.add(registration); // Return unregister function return () => { - const handlers = registry.get(registration.action) + const handlers = registry.get(registration.action); if (handlers) { - handlers.delete(registration) + handlers.delete(registration); if (handlers.size === 0) { - registry.delete(registration.action) + registry.delete(registration.action); } } - } - } + }; + }; // Invoke all handlers for an action const invokeAction = (action: string): boolean => { - const registry = handlerRegistryRef.current - if (!registry) return false + const registry = handlerRegistryRef.current; + if (!registry) return false; - const handlers = registry.get(action) - if (!handlers || handlers.size === 0) return false + const handlers = registry.get(action); + if (!handlers || handlers.size === 0) return false; // Find handlers whose context is active for (const registration of handlers) { if (activeContexts.has(registration.context)) { - registration.handler() - return true + registration.handler(); + return true; } } - return false - } + return false; + }; return { // Use ref for immediate access to pending chord, avoiding React state delay // This is critical for chord sequences where the second key might be pressed // before React re-renders with the updated pendingChord state resolve: (input, key, contexts) => - resolveKeyWithChordState( - input, - key, - contexts, - bindings, - pendingChordRef.current, - ), + resolveKeyWithChordState(input, key, contexts, bindings, pendingChordRef.current), setPendingChord, getDisplayText: getDisplay, bindings, @@ -156,7 +129,7 @@ export function KeybindingProvider({ unregisterActiveContext, registerHandler, invokeAction, - } + }; }, [ bindings, pendingChordRef, @@ -166,23 +139,17 @@ export function KeybindingProvider({ registerActiveContext, unregisterActiveContext, handlerRegistryRef, - ]) + ]); - return ( - - {children} - - ) + return {children}; } export function useKeybindingContext(): KeybindingContextValue { - const ctx = useContext(KeybindingContext) + const ctx = useContext(KeybindingContext); if (!ctx) { - throw new Error( - 'useKeybindingContext must be used within KeybindingProvider', - ) + throw new Error('useKeybindingContext must be used within KeybindingProvider'); } - return ctx + return ctx; } /** @@ -190,7 +157,7 @@ export function useKeybindingContext(): KeybindingContextValue { * Useful for components that may render before provider is available. */ export function useOptionalKeybindingContext(): KeybindingContextValue | null { - return useContext(KeybindingContext) + return useContext(KeybindingContext); } /** @@ -208,18 +175,15 @@ export function useOptionalKeybindingContext(): KeybindingContextValue | null { * } * ``` */ -export function useRegisterKeybindingContext( - context: KeybindingContextName, - isActive: boolean = true, -): void { - const keybindingContext = useOptionalKeybindingContext() +export function useRegisterKeybindingContext(context: KeybindingContextName, isActive: boolean = true): void { + const keybindingContext = useOptionalKeybindingContext(); useLayoutEffect(() => { - if (!keybindingContext || !isActive) return + if (!keybindingContext || !isActive) return; - keybindingContext.registerActiveContext(context) + keybindingContext.registerActiveContext(context); return () => { - keybindingContext.unregisterActiveContext(context) - } - }, [context, keybindingContext, isActive]) + keybindingContext.unregisterActiveContext(context); + }; + }, [context, keybindingContext, isActive]); } diff --git a/src/keybindings/KeybindingProviderSetup.tsx b/src/keybindings/KeybindingProviderSetup.tsx index 2397468c8..46a8837ac 100644 --- a/src/keybindings/KeybindingProviderSetup.tsx +++ b/src/keybindings/KeybindingProviderSetup.tsx @@ -6,40 +6,36 @@ * user-defined bindings from ~/.claude/keybindings.json, with hot-reload * support when the file changes. */ -import React, { useCallback, useEffect, useRef, useState } from 'react' -import { useNotifications } from '../context/notifications.js' -import type { InputEvent } from '../ink/events/input-event.js' +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useNotifications } from '../context/notifications.js'; +import type { InputEvent } from '../ink/events/input-event.js'; // ChordInterceptor intentionally uses useInput to intercept all keystrokes before // other handlers process them - this is required for chord sequence support // eslint-disable-next-line custom-rules/prefer-use-keybindings -import { type Key, useInput } from '../ink.js' -import { count } from '../utils/array.js' -import { logForDebugging } from '../utils/debug.js' -import { plural } from '../utils/stringUtils.js' -import { KeybindingProvider } from './KeybindingContext.js' +import { type Key, useInput } from '../ink.js'; +import { count } from '../utils/array.js'; +import { logForDebugging } from '../utils/debug.js'; +import { plural } from '../utils/stringUtils.js'; +import { KeybindingProvider } from './KeybindingContext.js'; import { initializeKeybindingWatcher, type KeybindingsLoadResult, loadKeybindingsSyncWithWarnings, subscribeToKeybindingChanges, -} from './loadUserBindings.js' -import { resolveKeyWithChordState } from './resolver.js' -import type { - KeybindingContextName, - ParsedBinding, - ParsedKeystroke, -} from './types.js' -import type { KeybindingWarning } from './validate.js' +} from './loadUserBindings.js'; +import { resolveKeyWithChordState } from './resolver.js'; +import type { KeybindingContextName, ParsedBinding, ParsedKeystroke } from './types.js'; +import type { KeybindingWarning } from './validate.js'; /** * Timeout for chord sequences in milliseconds. * If the user doesn't complete the chord within this time, it's cancelled. */ -const CHORD_TIMEOUT_MS = 1000 +const CHORD_TIMEOUT_MS = 1000; type Props = { - children: React.ReactNode -} + children: React.ReactNode; +}; /** * Keybinding provider with default + user bindings and hot-reload support. @@ -65,32 +61,29 @@ type Props = { * Display keybinding warnings to the user via notifications. * Shows a brief message pointing to /doctor for details. */ -function useKeybindingWarnings( - warnings: KeybindingWarning[], - isReload: boolean, -): void { - const { addNotification, removeNotification } = useNotifications() +function useKeybindingWarnings(warnings: KeybindingWarning[], isReload: boolean): void { + const { addNotification, removeNotification } = useNotifications(); useEffect(() => { - 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, @@ -99,123 +92,112 @@ function useKeybindingWarnings( priority: errorCount > 0 ? 'immediate' : 'high', // Keep visible for 60 seconds like settings errors timeoutMs: 60000, - }) - }, [warnings, isReload, addNotification, removeNotification]) + }); + }, [warnings, isReload, addNotification, removeNotification]); } export function KeybindingSetup({ children }: Props): React.ReactNode { // Load bindings synchronously for initial render - const [{ bindings, warnings }, setLoadResult] = - useState(() => { - const result = loadKeybindingsSyncWithWarnings() - logForDebugging( - `[keybindings] KeybindingSetup initialized with ${result.bindings.length} bindings, ${result.warnings.length} warnings`, - ) - return result - }) + const [{ bindings, warnings }, setLoadResult] = useState(() => { + const result = loadKeybindingsSyncWithWarnings(); + logForDebugging( + `[keybindings] KeybindingSetup initialized with ${result.bindings.length} bindings, ${result.warnings.length} warnings`, + ); + return result; + }); // Track if this is a reload (not initial load) - const [isReload, setIsReload] = useState(false) + const [isReload, setIsReload] = useState(false); // Display warnings via notifications - useKeybindingWarnings(warnings, isReload) + useKeybindingWarnings(warnings, isReload); // Chord state management - use ref for immediate access, state for re-renders // The ref is used by resolve() to get the current value without waiting for re-render // The state is used to trigger re-renders when needed (e.g., for UI updates) - const pendingChordRef = useRef(null) - const [pendingChord, setPendingChordState] = useState< - ParsedKeystroke[] | null - >(null) - const chordTimeoutRef = useRef(null) + const pendingChordRef = useRef(null); + const [pendingChord, setPendingChordState] = useState(null); + const chordTimeoutRef = useRef(null); // Handler registry for action callbacks (used by ChordInterceptor to invoke handlers) const handlerRegistryRef = useRef( new Map< string, Set<{ - action: string - context: KeybindingContextName - handler: () => void + action: string; + context: KeybindingContextName; + handler: () => void; }> >(), - ) + ); // Active context tracking for keybinding priority resolution // Using a ref instead of state for synchronous updates - input handlers need // to see the current value immediately, not after a React render cycle. - const activeContextsRef = useRef>(new Set()) + const activeContextsRef = useRef>(new Set()); - const registerActiveContext = useCallback( - (context: KeybindingContextName) => { - activeContextsRef.current.add(context) - }, - [], - ) + const registerActiveContext = useCallback((context: KeybindingContextName) => { + activeContextsRef.current.add(context); + }, []); - const unregisterActiveContext = useCallback( - (context: KeybindingContextName) => { - activeContextsRef.current.delete(context) - }, - [], - ) + const unregisterActiveContext = useCallback((context: KeybindingContextName) => { + activeContextsRef.current.delete(context); + }, []); // Clear chord timeout when component unmounts or chord changes const clearChordTimeout = useCallback(() => { if (chordTimeoutRef.current) { - clearTimeout(chordTimeoutRef.current) - chordTimeoutRef.current = null + clearTimeout(chordTimeoutRef.current); + chordTimeoutRef.current = null; } - }, []) + }, []); // Wrapper for setPendingChord that manages timeout and syncs ref+state const setPendingChord = useCallback( (pending: ParsedKeystroke[] | null) => { - clearChordTimeout() + clearChordTimeout(); if (pending !== null) { // Set timeout to cancel chord if not completed chordTimeoutRef.current = setTimeout( (pendingChordRef, setPendingChordState) => { - logForDebugging('[keybindings] Chord timeout - cancelling') - pendingChordRef.current = null - setPendingChordState(null) + logForDebugging('[keybindings] Chord timeout - cancelling'); + pendingChordRef.current = null; + setPendingChordState(null); }, CHORD_TIMEOUT_MS, pendingChordRef, setPendingChordState, - ) + ); } // Update ref immediately for synchronous access in resolve() - pendingChordRef.current = pending + pendingChordRef.current = pending; // Update state to trigger re-renders for UI updates - setPendingChordState(pending) + setPendingChordState(pending); }, [clearChordTimeout], - ) + ); useEffect(() => { // Initialize file watcher (idempotent - only runs once) - void initializeKeybindingWatcher() + void initializeKeybindingWatcher(); // Subscribe to changes const unsubscribe = subscribeToKeybindingChanges(result => { // Any callback invocation is a reload since initial load happens // synchronously in useState, not via this subscription - setIsReload(true) + setIsReload(true); - setLoadResult(result) - logForDebugging( - `[keybindings] Reloaded: ${result.bindings.length} bindings, ${result.warnings.length} warnings`, - ) - }) + setLoadResult(result); + logForDebugging(`[keybindings] Reloaded: ${result.bindings.length} bindings, ${result.warnings.length} warnings`); + }); return () => { - unsubscribe() - clearChordTimeout() - } - }, [clearChordTimeout]) + unsubscribe(); + clearChordTimeout(); + }; + }, [clearChordTimeout]); return ( {children} - ) + ); } /** @@ -251,10 +233,10 @@ export function KeybindingSetup({ children }: Props): React.ReactNode { * system could recognize it as completing a chord. */ type HandlerRegistration = { - action: string - context: KeybindingContextName - handler: () => void -} + action: string; + context: KeybindingContextName; + handler: () => void; +}; function ChordInterceptor({ bindings, @@ -263,11 +245,11 @@ function ChordInterceptor({ activeContexts, handlerRegistryRef, }: { - bindings: ParsedBinding[] - pendingChordRef: React.RefObject - setPendingChord: (pending: ParsedKeystroke[] | null) => void - activeContexts: Set - handlerRegistryRef: React.RefObject>> + bindings: ParsedBinding[]; + pendingChordRef: React.RefObject; + setPendingChord: (pending: ParsedKeystroke[] | null) => void; + activeContexts: Set; + handlerRegistryRef: React.RefObject>>; }): null { const handleInput = useCallback( (input: string, key: Key, event: InputEvent) => { @@ -276,48 +258,38 @@ function ChordInterceptor({ // here. Skip the registry scan. Mid-chord wheel still falls through so // scrolling cancels the pending chord like any other non-matching key. if ((key.wheelUp || key.wheelDown) && pendingChordRef.current === null) { - return + return; } // Build context list from registered handlers + activeContexts + Global // This ensures we can resolve chords for all contexts that have handlers - const registry = handlerRegistryRef.current - const handlerContexts = new Set() + const registry = handlerRegistryRef.current; + const handlerContexts = new Set(); if (registry) { for (const handlers of registry.values()) { for (const registration of handlers) { - handlerContexts.add(registration.context) + handlerContexts.add(registration.context); } } } - const contexts: KeybindingContextName[] = [ - ...handlerContexts, - ...activeContexts, - 'Global', - ] + const contexts: KeybindingContextName[] = [...handlerContexts, ...activeContexts, 'Global']; // Track whether we're completing a chord (pending was non-null) - const wasInChord = pendingChordRef.current !== null + const wasInChord = pendingChordRef.current !== null; // Check if this keystroke is part of a chord sequence - const result = resolveKeyWithChordState( - input, - key, - contexts, - bindings, - pendingChordRef.current, - ) + const result = resolveKeyWithChordState(input, key, contexts, bindings, pendingChordRef.current); switch (result.type) { case 'chord_started': // This key starts a chord - store pending state and stop propagation - setPendingChord(result.pending) - event.stopImmediatePropagation() - break + setPendingChord(result.pending); + event.stopImmediatePropagation(); + break; case 'match': { // Clear pending state - setPendingChord(null) + setPendingChord(null); // Only invoke handlers and stop propagation for chord completions // (multi-keystroke sequences). Single-keystroke matches should propagate @@ -328,54 +300,48 @@ function ChordInterceptor({ // Find and invoke the handler for this action // We need to check that the handler's context is in our resolved contexts // (which includes handlerContexts + activeContexts + Global) - const contextsSet = new Set(contexts) + const contextsSet = new Set(contexts); if (registry) { - const handlers = registry.get(result.action) + const handlers = registry.get(result.action); if (handlers && handlers.size > 0) { // Find handlers whose context is in our resolved contexts for (const registration of handlers) { if (contextsSet.has(registration.context)) { - registration.handler() - event.stopImmediatePropagation() - break // Only invoke the first matching handler + registration.handler(); + event.stopImmediatePropagation(); + break; // Only invoke the first matching handler } } } } } - break + break; } case 'chord_cancelled': // Invalid key during chord - clear pending state and swallow the // keystroke so it doesn't propagate as a standalone action // (e.g., ctrl+x ctrl+c should not fire app:interrupt). - setPendingChord(null) - event.stopImmediatePropagation() - break + setPendingChord(null); + event.stopImmediatePropagation(); + break; case 'unbound': // Key is explicitly unbound - clear pending state and swallow // the keystroke (it was part of a chord sequence). - setPendingChord(null) - event.stopImmediatePropagation() - break + setPendingChord(null); + event.stopImmediatePropagation(); + break; case 'none': // No chord involvement - let other handlers process - break + break; } }, - [ - bindings, - pendingChordRef, - setPendingChord, - activeContexts, - handlerRegistryRef, - ], - ) - - useInput(handleInput) - - return null + [bindings, pendingChordRef, setPendingChord, activeContexts, handlerRegistryRef], + ); + + useInput(handleInput); + + return null; } 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/keybindings/types.ts b/src/keybindings/types.ts index e3284422f..8c21df474 100644 --- a/src/keybindings/types.ts +++ b/src/keybindings/types.ts @@ -1,7 +1,7 @@ // Auto-generated stub — replace with real implementation -export type ParsedBinding = any; -export type ParsedKeystroke = any; -export type KeybindingContextName = any; -export type KeybindingBlock = any; -export type Chord = any; -export type KeybindingAction = any; +export type ParsedBinding = any +export type ParsedKeystroke = any +export type KeybindingContextName = any +export type KeybindingBlock = any +export type Chord = any +export type KeybindingAction = any diff --git a/src/main.tsx b/src/main.tsx index b382aee15..977bdd05a 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,154 +6,137 @@ // 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() +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 { - 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 './ink.js' -import { launchRepl } from './replLauncher.js' +startKeychainPrefetch(); + +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 './ink.js'; +import { launchRepl } from './replLauncher.js'; import { hasGrowthBookEnvOverride, initializeGrowthBook, refreshGrowthBookAfterAuthChange, -} from './services/analytics/growthbook.js' -import { fetchBootstrapData } from './services/api/bootstrap.js' +} from './services/analytics/growthbook.js'; +import { fetchBootstrapData } from './services/api/bootstrap.js'; import { type DownloadResult, downloadSessionFiles, type FilesApiConfig, parseFileSpecs, -} from './services/api/filesApi.js' -import { prefetchPassesEligibility } from './services/api/referral.js' -import { prefetchOfficialMcpUrls } from './services/mcp/officialRegistry.js' -import type { - McpSdkServerConfig, - McpServerConfig, - ScopedMcpServerConfig, -} from './services/mcp/types.js' +} from './services/api/filesApi.js'; +import { prefetchPassesEligibility } from './services/api/referral.js'; +import { prefetchOfficialMcpUrls } from './services/mcp/officialRegistry.js'; +import type { McpSdkServerConfig, McpServerConfig, ScopedMcpServerConfig } from './services/mcp/types.js'; import { isPolicyAllowed, loadPolicyLimits, refreshPolicyLimits, waitForPolicyLimitsToLoad, -} from './services/policyLimits/index.js' -import { - loadRemoteManagedSettings, - refreshRemoteManagedSettings, -} from './services/remoteManagedSettings/index.js' -import type { ToolInputJSONSchema } from './Tool.js' +} from './services/policyLimits/index.js'; +import { loadRemoteManagedSettings, refreshRemoteManagedSettings } from './services/remoteManagedSettings/index.js'; +import type { ToolInputJSONSchema } from './Tool.js'; import { createSyntheticOutputTool, isSyntheticOutputToolEnabled, -} from './tools/SyntheticOutputTool/SyntheticOutputTool.js' -import { getTools } from './tools.js' +} from './tools/SyntheticOutputTool/SyntheticOutputTool.js'; +import { getTools } from './tools.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' +} from './utils/advisor.js'; +import { isAgentSwarmsEnabled } from './utils/agentSwarmsEnabled.js'; +import { count, uniq } from './utils/array.js'; +import { installAsciicastRecorder } from './utils/asciicast.js'; import { getSubscriptionType, isClaudeAISubscriber, prefetchAwsCredentialsAndBedRockInfoIfSafe, prefetchGcpCredentialsIfSafe, validateForceLoginOrg, -} from './utils/auth.js' +} 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' +} 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' +} 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 + : 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 - -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' + : 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 { 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, @@ -161,9 +144,9 @@ import { setMainLoopModelOverride, setMainThreadAgentType, setTeleportedSessionInfo, -} from './bootstrap/state.js' -import { filterCommandsForRemoteMode, getCommands } from './commands.js' -import type { StatsStore } from './context/stats.js' +} from './bootstrap/state.js'; +import { filterCommandsForRemoteMode, getCommands } from './commands.js'; +import type { StatsStore } from './context/stats.js'; import { launchAssistantInstallWizard, launchAssistantSessionChooser, @@ -172,77 +155,61 @@ import { launchSnapshotUpdateDialog, launchTeleportRepoMismatchDialog, launchTeleportResumeWrapper, -} from './dialogLaunchers.js' -import { SHOW_CURSOR } from './ink/termio/dec.js' +} from './dialogLaunchers.js'; +import { SHOW_CURSOR } from './ink/termio/dec.js'; import { exitWithError, exitWithMessage, getRenderContext, renderAndRun, showSetupScreens, -} from './interactiveHelpers.js' -import { initBuiltinPlugins } from './plugins/bundled/index.js' +} from './interactiveHelpers.js'; +import { initBuiltinPlugins } from './plugins/bundled/index.js'; /* eslint-enable @typescript-eslint/no-require-imports */ -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 './tools/AgentTool/agentColorManager.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 './tools/AgentTool/agentColorManager.js'; import { getActiveAgentsFromList, getAgentDefinitionsWithOverrides, isBuiltInAgent, isCustomAgent, parseAgentsFromJson, -} from './tools/AgentTool/loadAgentsDir.js' -import type { LogOption } from './types/logs.js' -import type { Message as MessageType } from './types/message.js' -import { assertMinVersion } from './utils/autoUpdater.js' +} from './tools/AgentTool/loadAgentsDir.js'; +import type { LogOption } from './types/logs.js'; +import type { Message as MessageType } from './types/message.js'; +import { assertMinVersion } from './utils/autoUpdater.js'; import { CLAUDE_IN_CHROME_SKILL_HINT, CLAUDE_IN_CHROME_SKILL_HINT_WITH_WEBBROWSER, -} from './utils/claudeInChrome/prompt.js' +} from './utils/claudeInChrome/prompt.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' -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' +} 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 { getDefaultMainLoopModel, getUserSpecifiedModelSetting, normalizeModelStringForAPI, parseUserSpecifiedModel, -} from './utils/model/model.js' -import { ensureModelStringsInitialized } from './utils/model/modelStrings.js' -import { PERMISSION_MODES } from './utils/permissions/PermissionMode.js' +} from './utils/model/model.js'; +import { ensureModelStringsInitialized } from './utils/model/modelStrings.js'; +import { PERMISSION_MODES } from './utils/permissions/PermissionMode.js'; import { checkAndDisableBypassPermissions, getAutoModeEnabledStateIfCached, @@ -253,17 +220,14 @@ import { 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' +} 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, @@ -272,34 +236,28 @@ import { saveMode, searchSessionsByCustomTitle, sessionIdExists, -} from './utils/sessionStorage.js' -import { ensureMdmSettingsLoaded } from './utils/settings/mdm/settings.js' +} 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' +} 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, @@ -309,50 +267,28 @@ import { 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' +} 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 { type ChannelEntry, getInitialMainLoopModel, @@ -379,76 +315,56 @@ import { setSessionSource, setUserMsgOptIn, switchSession, -} from './bootstrap/state.js' +} 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 + : null; // TeleportRepoMismatchDialog, TeleportResumeWrapper dynamically imported at call sites -import { migrateAutoUpdatesToSettings } from './migrations/migrateAutoUpdatesToSettings.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' +import { migrateAutoUpdatesToSettings } from './migrations/migrateAutoUpdatesToSettings.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' +} 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. @@ -457,15 +373,13 @@ profileCheckpoint('main_tsx_imports_loaded') */ function logManagedSettings(): void { try { - const policySettings = getSettingsForSource('policySettings') + const policySettings = getSettingsForSource('policySettings'); if (policySettings) { - const allKeys = getManagedSettingsKeysForLogging(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, - }) + keys: allKeys.join(',') as unknown as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); } } catch { // Silently ignore errors - this is just for analytics @@ -474,7 +388,7 @@ function logManagedSettings(): void { // 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 => { @@ -483,37 +397,35 @@ function isBeingDebugged() { // 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) + return /--inspect(-brk)?/.test(arg); } else { // In Node.js, check for both --inspect and legacy --debug flags - return /--inspect(-brk)?|--debug(-brk)?/.test(arg) + 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) + 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 + 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 + return hasInspectArg || hasInspectEnv; } } // Exit if we detect node debugging or inspection -if ("external" !== 'ant' && isBeingDebugged()) { +if ('external' !== 'ant' && isBeingDebugged()) { // Use process.exit directly here since we're in the top-level code before imports // and gracefulShutdown is not yet available // eslint-disable-next-line custom-rules/no-top-level-side-effects - process.exit(1) + process.exit(1); } /** @@ -523,90 +435,81 @@ if ("external" !== 'ant' && 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())) + 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) + const managedNames = getManagedPluginNames(); + logPluginsEnabledForSession(enabled, managedNames, getPluginSeedDirs()); + logPluginLoadErrors(errors, managedNames); }) - .catch(err => logError(err)) + .catch(err => logError(err)); } function getCertEnvVarTelemetry(): Record { - const result: Record = {} + const result: Record = {}; if (process.env.NODE_EXTRA_CA_CERTS) { - result.has_node_extra_ca_certs = true + result.has_node_extra_ca_certs = true; } if (process.env.CLAUDE_CODE_CLIENT_CERT) { - result.has_client_cert = true + result.has_client_cert = true; } if (hasNodeOption('--use-system-ca')) { - result.has_use_system_ca = true + result.has_use_system_ca = true; } if (hasNodeOption('--use-openssl-ca')) { - result.has_use_openssl_ca = true + result.has_use_openssl_ca = true; } - return result + 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, + 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(), + 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 +const CURRENT_MIGRATION_VERSION = 11; function runMigrations(): void { if (getGlobalConfig().migrationVersion !== CURRENT_MIGRATION_VERSION) { - migrateAutoUpdatesToSettings() - migrateBypassPermissionsAcceptedToSettings() - migrateEnableAllProjectMcpServersToSettings() - resetProToOpusDefault() - migrateSonnet1mToSonnet45() - migrateLegacyOpusToCurrent() - migrateSonnet45ToSonnet46() - migrateOpusToOpus1m() - migrateReplBridgeEnabledToRemoteControlAtStartup() + migrateAutoUpdatesToSettings(); + migrateBypassPermissionsAcceptedToSettings(); + migrateEnableAllProjectMcpServersToSettings(); + resetProToOpusDefault(); + migrateSonnet1mToSonnet45(); + migrateLegacyOpusToCurrent(); + migrateSonnet45ToSonnet46(); + migrateOpusToOpus1m(); + migrateReplBridgeEnabledToRemoteControlAtStartup(); if (feature('TRANSCRIPT_CLASSIFIER')) { - resetAutoModeOptInForDefaultOffer() + resetAutoModeOptInForDefaultOffer(); } if (process.env.USER_TYPE === 'ant') { - migrateFennecToOpus() + 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 - }) + }); } /** @@ -616,23 +519,23 @@ 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 + logForDiagnosticsNoPII('info', 'prefetch_system_context_non_interactive'); + void getSystemContext(); + return; } // In interactive mode, only prefetch if trust has already been established - const hasTrust = checkHasTrustDialogAccepted() + const hasTrust = checkHasTrustDialogAccepted(); if (hasTrust) { - logForDiagnosticsNoPII('info', 'prefetch_system_context_has_trust') - void getSystemContext() + logForDiagnosticsNoPII('info', 'prefetch_system_context_has_trust'); + void getSystemContext(); } else { - logForDiagnosticsNoPII('info', 'prefetch_system_context_skipped_no_trust') + logForDiagnosticsNoPII('info', 'prefetch_system_context_skipped_no_trust'); } // Otherwise, don't prefetch - wait for trust to be established first } @@ -657,64 +560,53 @@ export function startDeferredPrefetches(): void { // the critical path. isBareMode() ) { - return + 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() + 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() + if (isEnvTruthy(process.env.CLAUDE_CODE_USE_VERTEX) && !isEnvTruthy(process.env.CLAUDE_CODE_SKIP_VERTEX_AUTH)) { + void prefetchGcpCredentialsIfSafe(); } - void countFilesRoundedRg(getCwd(), AbortSignal.timeout(3000), []) + void countFilesRoundedRg(getCwd(), AbortSignal.timeout(3000), []); // Analytics and feature flag initialization - void initializeAnalyticsGates() - void prefetchOfficialMcpUrls() + void initializeAnalyticsGates(); + void prefetchOfficialMcpUrls(); - void refreshModelCapabilities() + void refreshModelCapabilities(); // File change detectors deferred from init() to unblock first render - void settingsChangeDetector.initialize() + void settingsChangeDetector.initialize(); if (!isBareMode()) { - void skillChangeDetector.initialize() + 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(), - ) + 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('}') + 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) + const parsedJson = safeParseJSON(trimmedSettings); if (!parsedJson) { - process.stderr.write( - chalk.red('Error: Invalid JSON provided to --settings\n'), - ) - process.exit(1) + 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. @@ -728,56 +620,45 @@ function loadSettingsFromFlag(settingsFile: string): void { // across process boundaries (each SDK query() spawns a new process). settingsPath = generateTempFilePath('claude-settings', '.json', { contentHash: trimmedSettings, - }) - writeFileSync_DEPRECATED(settingsPath, trimmedSettings, 'utf8') + }); + writeFileSync_DEPRECATED(settingsPath, trimmedSettings, 'utf8'); } else { // It's a file path - resolve and validate by attempting to read - const { resolvedPath: resolvedSettingsPath } = safeResolvePath( - getFsImplementation(), - settingsFile, - ) + const { resolvedPath: resolvedSettingsPath } = safeResolvePath(getFsImplementation(), settingsFile); try { - readFileSync(resolvedSettingsPath, 'utf8') + readFileSync(resolvedSettingsPath, 'utf8'); } catch (e) { if (isENOENT(e)) { - process.stderr.write( - chalk.red( - `Error: Settings file not found: ${resolvedSettingsPath}\n`, - ), - ) - process.exit(1) + process.stderr.write(chalk.red(`Error: Settings file not found: ${resolvedSettingsPath}\n`)); + process.exit(1); } - throw e + throw e; } - settingsPath = resolvedSettingsPath + settingsPath = resolvedSettingsPath; } - setFlagSettingsPath(settingsPath) - resetSettingsCache() + setFlagSettingsPath(settingsPath); + resetSettingsCache(); } catch (error) { if (error instanceof Error) { - logError(error) + logError(error); } - process.stderr.write( - chalk.red(`Error processing settings: ${errorMessage(error)}\n`), - ) - process.exit(1) + 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() + const sources = parseSettingSourcesFlag(settingSourcesArg); + setAllowedSettingSources(sources); + resetSettingsCache(); } catch (error) { if (error instanceof Error) { - logError(error) + logError(error); } - process.stderr.write( - chalk.red(`Error processing --setting-sources: ${errorMessage(error)}\n`), - ) - process.exit(1) + process.stderr.write(chalk.red(`Error processing --setting-sources: ${errorMessage(error)}\n`)); + process.exit(1); } } @@ -786,79 +667,77 @@ function loadSettingSourcesFromFlag(settingSourcesArg: string): void { * This ensures settings are filtered from the start of initialization */ function eagerLoadSettings(): void { - profileCheckpoint('eagerLoadSettings_start') + profileCheckpoint('eagerLoadSettings_start'); // Parse --settings flag early to ensure settings are loaded before init() - const settingsFile = eagerParseCliFlag('--settings') + const settingsFile = eagerParseCliFlag('--settings'); if (settingsFile) { - loadSettingsFromFlag(settingsFile) + loadSettingsFromFlag(settingsFile); } // Parse --setting-sources flag early to control which sources are loaded - const settingSourcesArg = eagerParseCliFlag('--setting-sources') + const settingSourcesArg = eagerParseCliFlag('--setting-sources'); if (settingSourcesArg !== undefined) { - loadSettingSourcesFromFlag(settingSourcesArg) + loadSettingSourcesFromFlag(settingSourcesArg); } - profileCheckpoint('eagerLoadSettings_end') + 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 + 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') + const mcpIndex = cliArgs.indexOf('mcp'); if (mcpIndex !== -1 && cliArgs[mcpIndex + 1] === 'serve') { - process.env.CLAUDE_CODE_ENTRYPOINT = 'mcp' - return + process.env.CLAUDE_CODE_ENTRYPOINT = 'mcp'; + return; } if (isEnvTruthy(process.env.CLAUDE_CODE_ACTION)) { - process.env.CLAUDE_CODE_ENTRYPOINT = 'claude-code-github-action' - return + 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) // Set based on interactive status - process.env.CLAUDE_CODE_ENTRYPOINT = isNonInteractive ? 'sdk-cli' : 'cli' + 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 + : undefined; // Set by early argv processing when `claude assistant [sessionId]` is detected -type PendingAssistantChat = { sessionId?: string; discover: boolean } -const _pendingAssistantChat: PendingAssistantChat | undefined = feature( - 'KAIROS', -) +type PendingAssistantChat = { sessionId?: string; discover: boolean }; +const _pendingAssistantChat: PendingAssistantChat | undefined = feature('KAIROS') ? { sessionId: undefined, discover: false } - : undefined + : 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 + 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 + local: boolean; /** Extra CLI args to forward to the remote CLI on initial spawn (--resume, -c). */ - extraCliArgs: string[] -} + extraCliArgs: string[]; +}; const _pendingSSH: PendingSSH | undefined = feature('SSH_REMOTE') ? { host: undefined, @@ -868,73 +747,63 @@ const _pendingSSH: PendingSSH | undefined = feature('SSH_REMOTE') local: false, extraCliArgs: [], } - : undefined + : 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' + process.env.NoDefaultCurrentDirectoryInExePath = '1'; // Initialize warning handler early to catch warnings - initializeWarningHandler() + initializeWarningHandler(); process.on('exit', () => { - resetCursor() - }) + 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 + return; } - process.exit(0) - }) - profileCheckpoint('main_warning_handler_initialized') + 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://'), - ) + 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', - ) + 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') + const stripped = rawCliArgs.filter((_, i) => i !== ccIdx); + const dspIdx = stripped.indexOf('--dangerously-skip-permissions'); if (dspIdx !== -1) { - stripped.splice(dspIdx, 1) + stripped.splice(dspIdx, 1); } - process.argv = [ - process.argv[0]!, - process.argv[1]!, - 'open', - ccUrl, - ...stripped, - ] + 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') + _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) + stripped.splice(dspIdx, 1); } - process.argv = [process.argv[0]!, process.argv[1]!, ...stripped] + process.argv = [process.argv[0]!, process.argv[1]!, ...stripped]; } } } @@ -943,34 +812,26 @@ export async function main() { // 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') + 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) + 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) + 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); } } @@ -981,17 +842,17 @@ export async function main() { // (e.g. `--debug assistant`) falls through to the stub, which // prints usage. if (feature('KAIROS') && _pendingAssistantChat) { - const rawArgs = process.argv.slice(2) + const rawArgs = process.argv.slice(2); if (rawArgs[0] === 'assistant') { - const nextArg = rawArgs[1] + 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] + _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] + _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 } @@ -1002,7 +863,7 @@ export async function main() { // ~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) + 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 @@ -1010,149 +871,123 @@ export async function main() { // --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') + const localIdx = rawCliArgs.indexOf('--local'); if (localIdx !== -1) { - _pendingSSH.local = true - rawCliArgs.splice(localIdx, 1) + _pendingSSH.local = true; + rawCliArgs.splice(localIdx, 1); } - const dspIdx = rawCliArgs.indexOf('--dangerously-skip-permissions') + const dspIdx = rawCliArgs.indexOf('--dangerously-skip-permissions'); if (dspIdx !== -1) { - _pendingSSH.dangerouslySkipPermissions = true - rawCliArgs.splice(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 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='), - ) + const pmEqIdx = rawCliArgs.findIndex(a => a.startsWith('--permission-mode=')); if (pmEqIdx !== -1) { - _pendingSSH.permissionMode = rawCliArgs[pmEqIdx]!.split('=')[1] - rawCliArgs.splice(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) + 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] + _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) + _pendingSSH.extraCliArgs.push(val); + rawCliArgs.splice(i, 2); } else { - rawCliArgs.splice(i, 1) + rawCliArgs.splice(i, 1); } } - const eqI = rawCliArgs.findIndex(a => a.startsWith(`${flag}=`)) + 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) + _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 }) + }; + 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] + if (rawCliArgs[0] === 'ssh' && rawCliArgs[1] && !rawCliArgs[1].startsWith('-')) { + _pendingSSH.host = rawCliArgs[1]; // Optional positional cwd. - let consumed = 2 + let consumed = 2; if (rawCliArgs[2] && !rawCliArgs[2].startsWith('-')) { - _pendingSSH.cwd = rawCliArgs[2] - consumed = 3 + _pendingSSH.cwd = rawCliArgs[2]; + consumed = 3; } - const rest = rawCliArgs.slice(consumed) + 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 + 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] + 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 isNonInteractive = - hasPrintFlag || hasInitOnlyFlag || hasSdkUrl || !process.stdout.isTTY + 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 isNonInteractive = hasPrintFlag || hasInitOnlyFlag || hasSdkUrl || !process.stdout.isTTY; // Stop capturing early input for non-interactive modes if (isNonInteractive) { - stopCapturingEarlyInput() + stopCapturingEarlyInput(); } // Set simplified tracking fields - const isInteractive = !isNonInteractive - setIsInteractive(isInteractive) + const isInteractive = !isNonInteractive; + setIsInteractive(isInteractive); // Initialize entrypoint based on mode - needs to be set before any event is logged - initializeEntrypoint(isNonInteractive) + 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' + 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' + 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 + const previewFormat = process.env.CLAUDE_CODE_QUESTION_PREVIEW_FORMAT; if (previewFormat === 'markdown' || previewFormat === 'html') { - setQuestionPreviewFormat(previewFormat) + setQuestionPreviewFormat(previewFormat); } else if ( !clientType.startsWith('sdk-') && // Desktop and CCR pass previewFormat via toolConfig; when the feature is @@ -1161,23 +996,23 @@ export async function main() { clientType !== 'local-agent' && clientType !== 'remote' ) { - setQuestionPreviewFormat('markdown') + 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') + setSessionSource('remote-control'); } - profileCheckpoint('main_client_type_determined') + profileCheckpoint('main_client_type_determined'); // Parse and load settings flags early, before init() - eagerLoadSettings() + eagerLoadSettings(); - profileCheckpoint('main_before_run') + profileCheckpoint('main_before_run'); - await run() - profileCheckpoint('main_after_run') + await run(); + profileCheckpoint('main_after_run'); } async function getInputPrompt( @@ -1190,79 +1025,70 @@ async function getInputPrompt( !process.argv.includes('mcp') ) { if (inputFormat === 'stream-json') { - return process.stdin + return process.stdin; } - process.stdin.setEncoding('utf8') - let data = '' + process.stdin.setEncoding('utf8'); + let data = ''; const onData = (chunk: string) => { - data += chunk - } - process.stdin.on('data', onData) + 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) + 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, data].filter(Boolean).join('\n'); } - return prompt + return prompt; } async function run(): Promise { - profileCheckpoint('run_function_start') + 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 + 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)), - }, - ) + 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') + 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') + 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') + 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' + process.title = 'claude'; } // Attach logging sinks so subcommand handlers can use logEvent/logError. @@ -1270,9 +1096,9 @@ async function run(): Promise { // 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') + 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 @@ -1282,44 +1108,36 @@ async function run(): Promise { // 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') + 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') + 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() + void loadRemoteManagedSettings(); + void loadPolicyLimits(); - profileCheckpoint('preAction_after_remote_settings') + 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(), - ) + void import('./services/settingsSync/index.js').then(m => m.uploadUserSettingsInBackground()); } - profileCheckpoint('preAction_after_settings_sync') - }) + profileCheckpoint('preAction_after_settings_sync'); + }); program .name('claude') - .description( - `Claude Code - starts an interactive session by default, use -p/--print for non-interactive output`, - ) + .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. @@ -1331,24 +1149,16 @@ async function run(): Promise { // 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 + return true; }, ) - .addOption( - new Option('--debug-to-stderr', 'Enable debug mode (to stderr)') - .argParser(Boolean) - .hideHelp(), - ) + .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('--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.', @@ -1359,24 +1169,9 @@ async function run(): Promise { '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('--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 ', @@ -1422,10 +1217,7 @@ async function run(): Promise { () => true, ) .addOption( - new Option( - '--thinking ', - 'Thinking mode: enabled (equivalent to adaptive), disabled', - ) + new Option('--thinking ', 'Thinking mode: enabled (equivalent to adaptive), disabled') .choices(['enabled', 'adaptive', 'disabled']) .hideHelp(), ) @@ -1450,26 +1242,21 @@ async function run(): Promise { '--max-budget-usd ', 'Maximum dollar amount to spend on API calls (only works with --print)', ).argParser(value => { - const amount = Number(value) + const amount = Number(value); if (isNaN(amount) || amount <= 0) { - throw new Error( - '--max-budget-usd must be a positive number greater than 0', - ) + throw new Error('--max-budget-usd must be a positive number greater than 0'); } - return amount + return amount; }), ) .addOption( - new Option( - '--task-budget ', - 'API-side task budget in tokens (output_config.task_budget)', - ) + new Option('--task-budget ', 'API-side task budget in tokens (output_config.task_budget)') .argParser(value => { - const tokens = Number(value) + const tokens = Number(value); if (isNaN(tokens) || tokens <= 0 || !Number.isInteger(tokens)) { - throw new Error('--task-budget must be a positive integer') + throw new Error('--task-budget must be a positive integer'); } - return tokens + return tokens; }) .hideHelp(), ) @@ -1478,14 +1265,7 @@ async function run(): Promise { '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(), - ) + .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")', @@ -1498,37 +1278,18 @@ async function run(): Promise { '--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), - ) + .option('--mcp-config ', 'Load MCP servers from JSON files or strings (space-separated)') .addOption( - new Option( - '--system-prompt-file ', - 'Read system prompt from a file', - ) + 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), + new Option('--append-system-prompt ', 'Append a system prompt to the default system prompt').argParser( + String, + ), ) .addOption( new Option( @@ -1539,18 +1300,11 @@ async function run(): Promise { .hideHelp(), ) .addOption( - new Option( - '--permission-mode ', - 'Permission mode to use for the session', - ) + 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('-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', @@ -1561,18 +1315,8 @@ async function run(): Promise { '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('--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 ', @@ -1580,13 +1324,10 @@ async function run(): Promise { ).hideHelp(), ) .addOption( - new Option( - '--deep-link-last-fetch ', - 'FETCH_HEAD mtime in epoch ms, precomputed by the deep link trampoline', - ) + 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 + const n = Number(v); + return Number.isFinite(n) ? n : undefined; }) .hideHelp(), ) @@ -1619,28 +1360,19 @@ async function run(): Promise { `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)', + 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)', @@ -1655,36 +1387,20 @@ async function run(): Promise { '--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('--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('--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).', - ) + .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 @@ -1704,33 +1420,26 @@ async function run(): Promise { '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') + 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' + 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', {}) + 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 + 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 }) + 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 @@ -1748,23 +1457,15 @@ async function run(): Promise { // 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 kairosEnabled = false; let assistantTeamContext: - | Awaited< - ReturnType< - NonNullable['initializeAssistantTeam'] - > - > - | undefined - if ( - feature('KAIROS') && - (options as { assistant?: boolean }).assistant && - assistantModule - ) { + | 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() + assistantModule.markAssistantForced(); } if ( feature('KAIROS') && @@ -1780,28 +1481,23 @@ async function run(): Promise { 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.', - ), - ) + 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()) + kairosEnabled = assistantModule.isAssistantForced() || (await kairosGate.isKairosEnabled()); if (kairosEnabled) { - const opts = options as { brief?: boolean } - opts.brief = true - setKairosActive(true) + 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() + assistantTeamContext = await assistantModule.initializeAssistantTeam(); } } } @@ -1823,19 +1519,19 @@ async function run(): Promise { sessionId, includeHookEvents, includePartialMessages, - } = options + } = options; if (options.prefill) { - seedEarlyInput(options.prefill) + seedEarlyInput(options.prefill); } // Promise for file downloads - started early, awaited before REPL renders - let fileDownloadPromise: Promise | undefined + let fileDownloadPromise: Promise | undefined; - const agentsJson = options.agents - const agentCli = options.agent + const agentsJson = options.agents; + const agentCli = options.agent; if (feature('BG_SESSIONS') && agentCli) { - process.env.CLAUDE_CODE_AGENT = agentCli + process.env.CLAUDE_CODE_AGENT = agentCli; } // NOTE: LSP manager initialization is intentionally deferred until after @@ -1843,109 +1539,87 @@ async function run(): Promise { // 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 + 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 + 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 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 + : undefined; if (process.env.USER_TYPE === 'ant' && taskListId) { - process.env.CLAUDE_CODE_TASK_LIST_ID = 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 + : 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 + let worktreePRNumber: number | undefined; if (worktreeName) { - const prNum = parsePRReference(worktreeName) + const prNum = parsePRReference(worktreeName); if (prNum !== null) { - worktreePRNumber = prNum - worktreeName = undefined // slug will be generated in setup() + worktreePRNumber = prNum; + worktreeName = undefined; // slug will be generated in setup() } } // Extract tmux option (requires --worktree) - const tmuxEnabled = - isWorktreeModeEnabled() && (options as { tmux?: boolean }).tmux === true + 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) + 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) + 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) + 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 + 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 + 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 + 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) + 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 - ) { + if (teammateOpts.agentId && teammateOpts.agentName && teammateOpts.teamName) { getTeammateUtils().setDynamicTeamContext?.({ agentId: teammateOpts.agentId, agentName: teammateOpts.agentName, @@ -1953,72 +1627,64 @@ async function run(): Promise { 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, - ) + getTeammateModeSnapshot().setCliTeammateModeOverride?.(teammateOpts.teammateMode); } } // Extract remote sdk options - const sdkUrl = (options as { sdkUrl?: string }).sdkUrl ?? undefined + 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) + 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) + 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' + inputFormat = 'stream-json'; } if (!outputFormat) { - outputFormat = 'stream-json' + outputFormat = 'stream-json'; } // Auto-enable verbose mode unless explicitly disabled or already set if (options.verbose === undefined) { - verbose = true + verbose = true; } // Auto-enable print mode unless explicitly disabled if (!options.print) { - print = true + print = true; } } // Extract teleport option - const teleport = - (options as { teleport?: string | true }).teleport ?? null + 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) + 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 + (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 + let remoteControl = false; const remoteControlName = - typeof remoteControlOption === 'string' && - remoteControlOption.length > 0 - ? remoteControlOption - : undefined + typeof remoteControlOption === 'string' && remoteControlOption.length > 0 ? remoteControlOption : undefined; // Validate session ID if provided if (sessionId) { @@ -2030,70 +1696,62 @@ async function run(): Promise { chalk.red( 'Error: --session-id can only be used with --continue or --resume if --fork-session is also specified.\n', ), - ) - process.exit(1) + ); + 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) + const validatedSessionId = validateUuid(sessionId); if (!validatedSessionId) { - process.stderr.write( - chalk.red('Error: Invalid session ID. Must be a valid UUID.\n'), - ) - process.exit(1) + 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) + 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 + 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() + 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) + ); + 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 fileSessionId = process.env.CLAUDE_CODE_REMOTE_SESSION_ID || getSessionId(); - const files = parseFileSpecs(fileSpecs) + 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, + 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) + fileDownloadPromise = downloadSessionFiles(files, config); } } // Get isNonInteractiveSession from state (was set before init()) - const isNonInteractiveSession = getIsNonInteractiveSession() + const isNonInteractiveSession = getIsNonInteractiveSession(); // Validate that fallback model is different from main model if (fallbackModel && options.model && fallbackModel === options.model) { @@ -2101,75 +1759,61 @@ async function run(): Promise { 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) + ); + process.exit(1); } // Handle system prompt options - let systemPrompt = options.systemPrompt + 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) + 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') + const filePath = resolve(options.systemPromptFile); + systemPrompt = readFileSync(filePath, 'utf8'); } catch (error) { - const code = getErrnoCode(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) + 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) + 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 + 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) + ); + process.exit(1); } try { - const filePath = resolve(options.appendSystemPromptFile) - appendSystemPrompt = readFileSync(filePath, 'utf8') + const filePath = resolve(options.appendSystemPromptFile); + appendSystemPrompt = readFileSync(filePath, 'utf8'); } catch (error) { - const code = getErrnoCode(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) + 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) + process.stderr.write(chalk.red(`Error reading append system prompt file: ${errorMessage(error)}\n`)); + process.exit(1); } } @@ -2180,21 +1824,17 @@ async function run(): Promise { storedTeammateOpts?.agentName && storedTeammateOpts?.teamName ) { - const addendum = - getTeammatePromptAddendum().TEAMMATE_SYSTEM_PROMPT_ADDENDUM - appendSystemPrompt = appendSystemPrompt - ? `${appendSystemPrompt}\n\n${addendum}` - : addendum + const addendum = getTeammatePromptAddendum().TEAMMATE_SYSTEM_PROMPT_ADDENDUM; + appendSystemPrompt = appendSystemPrompt ? `${appendSystemPrompt}\n\n${addendum}` : addendum; } - const { mode: permissionMode, notification: permissionModeNotification } = - initialPermissionModeFromCLI({ - permissionModeCli, - dangerouslySkipPermissions, - }) + const { mode: permissionMode, notification: permissionModeNotification } = initialPermissionModeFromCLI({ + permissionModeCli, + dangerouslySkipPermissions, + }); // Store session bypass permissions mode for trust dialog check - setSessionBypassPermissionsMode(permissionMode === 'bypassPermissions') + 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 @@ -2208,75 +1848,68 @@ async function run(): Promise { permissionMode === 'auto' || (!permissionModeCli && isDefaultPermissionModeAuto()) ) { - autoModeStateModule?.setAutoModeFlagCli(true) + autoModeStateModule?.setAutoModeFlagCli(true); } } // Parse the MCP config files/strings if provided - let dynamicMcpConfig: Record = {} + let dynamicMcpConfig: Record = {}; if (mcpConfig && mcpConfig.length > 0) { // Process mcpConfig array - const processedConfigs = mcpConfig - .map(config => config.trim()) - .filter(config => config.length > 0) + const processedConfigs = mcpConfig.map(config => config.trim()).filter(config => config.length > 0); - let allConfigs: Record = {} - const allErrors: ValidationError[] = [] + let allConfigs: Record = {}; + const allErrors: ValidationError[] = []; for (const configItem of processedConfigs) { - let configs: Record | null = null - let errors: ValidationError[] = [] + let configs: Record | null = null; + let errors: ValidationError[] = []; // First try to parse as JSON string - const parsedJson = safeParseJSON(configItem) + 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 + configs = result.config.mcpServers; } else { - errors = result.errors + errors = result.errors; } } else { // Try as file path - const configPath = resolve(configItem) + const configPath = resolve(configItem); const result = parseMcpConfigFromFilePath({ filePath: configPath, expandVars: true, scope: 'dynamic', - }) + }); if (result.config) { - configs = result.config.mcpServers + configs = result.config.mcpServers; } else { - errors = result.errors + errors = result.errors; } } if (errors.length > 0) { - allErrors.push(...errors) + allErrors.push(...errors); } else if (configs) { // Merge configs, later ones override earlier ones - allConfigs = { ...allConfigs, ...configs } + 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) + 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) { @@ -2284,23 +1917,24 @@ async function run(): Promise { // built-in names — skip reserved-name checks for type:'sdk'. const nonSdkConfigNames = Object.entries(allConfigs) .filter(([, config]) => config.type !== 'sdk') - .map(([name]) => name) + .map(([name]) => name); - let reservedNameError: string | null = null + let reservedNameError: string | null = null; if (nonSdkConfigNames.some(isClaudeInChromeMCPServer)) { - reservedNameError = `Invalid MCP configuration: "${CLAUDE_IN_CHROME_MCP_SERVER_NAME}" is a reserved MCP name.` + 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') + 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.` + 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) + process.stderr.write(`Error: ${reservedNameError}\n`); + process.exit(1); } // Add dynamic scope to all configs. type:'sdk' entries pass through @@ -2313,7 +1947,7 @@ async function run(): Promise { 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 @@ -2321,103 +1955,88 @@ async function run(): Promise { // 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) + 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 } + dynamicMcpConfig = { ...dynamicMcpConfig, ...allowed }; } } // Extract Claude in Chrome option and enforce claude.ai subscriber check (unless user is ant) - const chromeOpts = options as { chrome?: boolean } + const chromeOpts = options as { chrome?: boolean }; // Store the explicit CLI flag so teammates can inherit it - setChromeFlagOverride(chromeOpts.chrome) + setChromeFlagOverride(chromeOpts.chrome); const enableClaudeInChrome = - shouldEnableClaudeInChrome(chromeOpts.chrome) && - (process.env.USER_TYPE === 'ant' || isClaudeAISubscriber()) - const autoEnableClaudeInChrome = - !enableClaudeInChrome && shouldAutoEnableClaudeInChrome() + shouldEnableClaudeInChrome(chromeOpts.chrome) && (process.env.USER_TYPE === 'ant' || isClaudeAISubscriber()); + const autoEnableClaudeInChrome = !enableClaudeInChrome && shouldAutoEnableClaudeInChrome(); if (enableClaudeInChrome) { - const platform = getPlatform() + const platform = getPlatform(); try { logEvent('tengu_claude_in_chrome_setup', { - platform: - platform as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + 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) + } = setupClaudeInChrome(); + dynamicMcpConfig = { ...dynamicMcpConfig, ...chromeMcpConfig }; + allowedTools.push(...chromeMcpTools); if (chromeSystemPrompt) { appendSystemPrompt = appendSystemPrompt ? `${chromeSystemPrompt}\n\n${appendSystemPrompt}` - : chromeSystemPrompt + : 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) + 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) + 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 { mcpConfig: chromeMcpConfig } = setupClaudeInChrome(); + dynamicMcpConfig = { ...dynamicMcpConfig, ...chromeMcpConfig }; const hint = - feature('WEB_BROWSER_TOOL') && - typeof Bun !== 'undefined' && - 'WebView' in Bun + 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 + : 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}`) + logForDebugging(`[Claude in Chrome] Error (auto-enable): ${error}`); } } // Extract strict MCP config flag - const strictMcpConfig = options.strictMcpConfig || false + 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) + 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) - ) { + 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) + chalk.red('You cannot dynamically configure MCP servers when an enterprise MCP config is present'), + ); + process.exit(1); } } @@ -2432,32 +2051,22 @@ async function run(): Promise { // `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() === 'macos' && - !getIsNonInteractiveSession() - ) { + if (feature('CHICAGO_MCP') && getPlatform() === 'macos' && !getIsNonInteractiveSession()) { try { - const { getChicagoEnabled } = await import( - 'src/utils/computerUse/gates.js' - ) + 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) + 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)}`, - ) + logForDebugging(`[Computer Use MCP] Setup failed: ${errorMessage(error)}`); } } // Store additional directories for CLAUDE.md loading (controlled by env var) - setAdditionalDirectoriesForClaudeMd(addDir) + setAdditionalDirectoriesForClaudeMd(addDir); // Channel server allowlist from --channels flag — servers whose // inbound push notifications should register this session. The option @@ -2465,7 +2074,7 @@ async function run(): Promise { // 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 + 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 @@ -2474,29 +2083,26 @@ async function run(): Promise { // 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[] = [] + 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('@') + const rest = c.slice(7); + const at = rest.indexOf('@'); if (at <= 0 || at === rest.length - 1) { - bad.push(c) + 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) }) + entries.push({ kind: 'server', name: c.slice(7) }); } else { - bad.push(c) + bad.push(c); } } if (bad.length > 0) { @@ -2506,34 +2112,31 @@ async function run(): Promise { ` plugin:@ — plugin-provided channel (allowlist enforced)\n` + ` server: — manually configured MCP server\n`, ), - ) - process.exit(1) + ); + process.exit(1); } - return entries - } + return entries; + }; const channelOpts = options as { - channels?: string[] - dangerouslyLoadDevelopmentChannels?: string[] - } - const rawChannels = channelOpts.channels - const rawDev = channelOpts.dangerouslyLoadDevelopmentChannels + 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[] = [] + let channelEntries: ChannelEntry[] = []; if (rawChannels && rawChannels.length > 0) { - channelEntries = parseChannelEntries(rawChannels, '--channels') - setAllowedChannels(channelEntries) + channelEntries = parseChannelEntries(rawChannels, '--channels'); + setAllowedChannels(channelEntries); } if (!isNonInteractiveSession) { if (rawDev && rawDev.length > 0) { - devChannels = parseChannelEntries( - rawDev, - '--dangerously-load-development-channels', - ) + devChannels = parseChannelEntries(rawDev, '--dangerously-load-development-channels'); } } // Flag-usage telemetry. Plugin identifiers are logged (same tier as @@ -2544,23 +2147,17 @@ async function run(): Promise { // 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}`] : [], - ) + 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 - } + ? (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 ?? []), - }) + }); } } @@ -2570,23 +2167,16 @@ async function run(): Promise { // 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 - ) { + 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('./tools/BriefTool/prompt.js') as typeof import('./tools/BriefTool/prompt.js') + require('./tools/BriefTool/prompt.js') as typeof import('./tools/BriefTool/prompt.js'); const { isBriefEntitled } = - require('./tools/BriefTool/BriefTool.js') as typeof import('./tools/BriefTool/BriefTool.js') + require('./tools/BriefTool/BriefTool.js') as typeof import('./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) + const parsed = parseToolListFromCLI(baseTools); + if ((parsed.includes(BRIEF_TOOL_NAME) || parsed.includes(LEGACY_BRIEF_TOOL_NAME)) && isBriefEntitled()) { + setUserMsgOptIn(true); } } @@ -2600,48 +2190,37 @@ async function run(): Promise { permissionMode, allowDangerouslySkipPermissions, addDirs: addDir, - }) - let toolPermissionContext = initResult.toolPermissionContext - const { warnings, dangerousPermissions, overlyBroadBashPermissions } = - initResult + }); + 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 - ) { + 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, - ) + toolPermissionContext = removeDangerousPermissions(toolPermissionContext, overlyBroadBashPermissions); } if (feature('TRANSCRIPT_CLASSIFIER') && dangerousPermissions.length > 0) { - toolPermissionContext = stripDangerousPermissionsForAutoMode( - toolPermissionContext, - ) + toolPermissionContext = stripDangerousPermissionsForAutoMode(toolPermissionContext); } // Print any warnings from initialization warnings.forEach(warning => { // biome-ignore lint/suspicious/noConsole:: intentional console output - console.error(warning) - }) + console.error(warning); + }); - void assertMinVersion() + void assertMinVersion(); // 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 - > = + const claudeaiConfigPromise: Promise> = isNonInteractiveSession && !strictMcpConfig && !doesEnterpriseMcpConfigExist() && @@ -2650,23 +2229,23 @@ async function run(): Promise { // that need MCP pass --mcp-config explicitly. !isBareMode() ? fetchClaudeAIMcpConfigsIfEligible().then(configs => { - const { allowed, blocked } = filterMcpServersByPolicy(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 + return allowed; }) - : Promise.resolve({}) + : 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 + 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. @@ -2677,37 +2256,29 @@ async function run(): Promise { }) : getClaudeCodeMcpConfigs(dynamicMcpConfig) ).then(result => { - mcpConfigResolvedMs = Date.now() - mcpConfigStart - return 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' - ) { + 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) + 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) + 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) + console.error(`Error: --sdk-url requires both --input-format=stream-json and --output-format=stream-json.`); + process.exit(1); } } @@ -2717,111 +2288,93 @@ async function run(): Promise { // 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) + ); + 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) + 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) + 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') + 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) + maybeActivateProactive(options); - let tools = getTools(toolPermissionContext) + 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) + 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') + profileCheckpoint('action_tools_loaded'); - let jsonSchema: ToolInputJSONSchema | undefined - if ( - isSyntheticOutputToolEnabled({ isNonInteractiveSession }) && - options.jsonSchema - ) { - jsonSchema = jsonParse(options.jsonSchema) as ToolInputJSONSchema + let jsonSchema: ToolInputJSONSchema | undefined; + if (isSyntheticOutputToolEnabled({ isNonInteractiveSession }) && options.jsonSchema) { + jsonSchema = jsonParse(options.jsonSchema) as ToolInputJSONSchema; } if (jsonSchema) { - const syntheticOutputResult = createSyntheticOutputTool(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] + tools = [...tools, syntheticOutputResult.tool]; logEvent('tengu_structured_output_enabled', { - schema_property_count: Object.keys( - (jsonSchema.properties as Record) || {}, - ) + 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, - }) + 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') + 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 + : 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() + 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() + initBuiltinPlugins(); + initBundledSkills(); } const setupPromise = setup( preSetupCwd, @@ -2833,20 +2386,16 @@ async function run(): Promise { sessionId ? validateUuid(sessionId) : undefined, worktreePRNumber, messagingSocketPath, - ) - const commandsPromise = worktreeEnabled ? null : getCommands(preSetupCwd) - const agentDefsPromise = worktreeEnabled - ? null - : getAgentDefinitionsWithOverrides(preSetupCwd) + ); + 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') + 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 @@ -2854,12 +2403,10 @@ async function run(): Promise { // 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 + let effectiveReplayUserMessages = !!options.replayUserMessages; if (feature('UDS_INBOX')) { if (!effectiveReplayUserMessages && outputFormat === 'stream-json') { - effectiveReplayUserMessages = !!( - options as { messagingSocketPath?: string } - ).messagingSocketPath + effectiveReplayUserMessages = !!(options as { messagingSocketPath?: string }).messagingSocketPath; } } @@ -2876,7 +2423,7 @@ async function run(): Promise { // applySafeConfigEnvironmentVariables in init() called // getSettings_DEPRECATED at managedEnv.ts:86 which merges all enabled // sources including projectSettings/localSettings. - applyConfigEnvironmentVariables() + applyConfigEnvironmentVariables(); // Spawn git status/log/branch now so the subprocess execution overlaps // with the getCommands await below and startDeferredPrefetches. After @@ -2888,28 +2435,28 @@ async function run(): Promise { // 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() + 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() + 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() + 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() + const sessionNameArg = options.name?.trim(); if (sessionNameArg) { - cacheSessionTitle(sessionNameArg) + cacheSessionTitle(sessionNameArg); } // Ant model aliases (capybara-fast etc.) resolve via the @@ -2923,83 +2470,73 @@ async function run(): Promise { // - 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 + 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 + getGlobalConfig().cachedGrowthBookFeatures?.['tengu_ant_model_override'] == null ) { - await initializeGrowthBook() + 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 + 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() + 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') + ]); + 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 = [] + let cliAgents: typeof agentDefinitionsResult.activeAgents = []; if (agentsJson) { try { - const parsedAgents = safeParseJSON(agentsJson) + const parsedAgents = safeParseJSON(agentsJson); if (parsedAgents) { - cliAgents = parseAgentsFromJson(parsedAgents, 'flagSettings') + cliAgents = parseAgentsFromJson(parsedAgents, 'flagSettings'); } } catch (error) { - logError(error) + logError(error); } } // Merge CLI agents with existing ones - const allAgents = [...agentDefinitionsResult.allAgents, ...cliAgents] + 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 + const agentSetting = agentCli ?? getInitialSettings().agent; + let mainThreadAgentDefinition: (typeof agentDefinitions.activeAgents)[number] | undefined; if (agentSetting) { - mainThreadAgentDefinition = agentDefinitions.activeAgents.find( - agent => agent.agentType === 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) + setMainThreadAgentType(mainThreadAgentDefinition?.agentType); // Log agent flag usage — only log agent name for built-in agents to avoid leaking custom agent names if (mainThreadAgentDefinition) { @@ -3008,15 +2545,14 @@ async function run(): Promise { ? (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, + 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) + saveAgentSetting(mainThreadAgentDefinition.agentType); } // Apply the agent's system prompt for non-interactive sessions @@ -3027,9 +2563,9 @@ async function run(): Promise { !systemPrompt && !isBuiltInAgent(mainThreadAgentDefinition) ) { - const agentSystemPrompt = mainThreadAgentDefinition.getSystemPrompt() + const agentSystemPrompt = mainThreadAgentDefinition.getSystemPrompt(); if (agentSystemPrompt) { - systemPrompt = agentSystemPrompt + systemPrompt = agentSystemPrompt; } } @@ -3043,66 +2579,46 @@ async function run(): Promise { if (typeof inputPrompt === 'string') { inputPrompt = inputPrompt ? `${mainThreadAgentDefinition.initialPrompt}\n\n${inputPrompt}` - : mainThreadAgentDefinition.initialPrompt + : mainThreadAgentDefinition.initialPrompt; } else if (!inputPrompt) { - inputPrompt = mainThreadAgentDefinition.initialPrompt + 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, - ) + let effectiveModel = userSpecifiedModel; + if (!effectiveModel && mainThreadAgentDefinition?.model && mainThreadAgentDefinition.model !== 'inherit') { + effectiveModel = parseUserSpecifiedModel(mainThreadAgentDefinition.model); } - setMainLoopModelOverride(effectiveModel) + setMainLoopModelOverride(effectiveModel); // Compute resolved model for hooks (use user-specified model at launch) - setInitialMainLoopModel(getUserSpecifiedModelSetting() || null) - const initialMainLoopModel = getInitialMainLoopModel() - const resolvedInitialModel = parseUserSpecifiedModel( - initialMainLoopModel ?? getDefaultMainLoopModel(), - ) + setInitialMainLoopModel(getUserSpecifiedModelSetting() || null); + const initialMainLoopModel = getInitialMainLoopModel(); + const resolvedInitialModel = parseUserSpecifiedModel(initialMainLoopModel ?? getDefaultMainLoopModel()); - let advisorModel: string | undefined + let advisorModel: string | undefined; if (isAdvisorEnabled()) { - const advisorOption = canUserConfigureAdvisor() - ? (options as { advisor?: string }).advisor - : undefined + const advisorOption = canUserConfigureAdvisor() ? (options as { advisor?: string }).advisor : undefined; if (advisorOption) { - logForDebugging(`[AdvisorTool] --advisor ${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) + chalk.red(`Error: The model "${resolvedInitialModel}" does not support the advisor tool.\n`), + ); + process.exit(1); } - const normalizedAdvisorModel = normalizeModelStringForAPI( - parseUserSpecifiedModel(advisorOption), - ) + 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) + process.stderr.write(chalk.red(`Error: The model "${advisorOption}" cannot be used as an advisor.\n`)); + process.exit(1); } } - advisorModel = canUserConfigureAdvisor() - ? (advisorOption ?? getInitialAdvisorSetting()) - : advisorOption + advisorModel = canUserConfigureAdvisor() ? (advisorOption ?? getInitialAdvisorSetting()) : advisorOption; if (advisorModel) { - logForDebugging(`[AdvisorTool] Advisor model: ${advisorModel}`) + logForDebugging(`[AdvisorTool] Advisor model: ${advisorModel}`); } } @@ -3115,51 +2631,44 @@ async function run(): Promise { storedTeammateOpts?.agentType ) { // Look up the custom agent definition - const customAgent = agentDefinitions.activeAgents.find( - a => a.agentType === storedTeammateOpts.agentType, - ) + 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 + 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() + 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, + 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, - }) + 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}` + const customInstructions = `\n# Custom Agent Instructions\n${customPrompt}`; appendSystemPrompt = appendSystemPrompt ? `${appendSystemPrompt}\n\n${customInstructions}` - : customInstructions + : customInstructions; } } else { - logForDebugging( - `[teammate] Custom agent ${storedTeammateOpts.agentType} not found in available agents`, - ) + logForDebugging(`[teammate] Custom agent ${storedTeammateOpts.agentType} not found in available agents`); } } - maybeActivateBrief(options) + 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 @@ -3177,10 +2686,10 @@ async function run(): Promise { ) { /* eslint-disable @typescript-eslint/no-require-imports */ const { isBriefEntitled } = - require('./tools/BriefTool/BriefTool.js') as typeof import('./tools/BriefTool/BriefTool.js') + require('./tools/BriefTool/BriefTool.js') as typeof import('./tools/BriefTool/BriefTool.js'); /* eslint-enable @typescript-eslint/no-require-imports */ if (isBriefEntitled()) { - setUserMsgOptIn(true) + setUserMsgOptIn(true); } } // Coordinator mode has its own system prompt and filters out Sleep, so @@ -3188,8 +2697,7 @@ async function run(): Promise { // access and conflict with delegation instructions. if ( (feature('PROACTIVE') || feature('KAIROS')) && - ((options as { proactive?: boolean }).proactive || - isEnvTruthy(process.env.CLAUDE_CODE_PROACTIVE)) && + ((options as { proactive?: boolean }).proactive || isEnvTruthy(process.env.CLAUDE_CODE_PROACTIVE)) && !coordinatorModeModule?.isCoordinatorMode() ) { /* eslint-disable @typescript-eslint/no-require-imports */ @@ -3200,53 +2708,47 @@ async function run(): Promise { ).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.' + : '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 + 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 + 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 + 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 + 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() + installAsciicastRecorder(); } - const { createRoot } = await import('./ink.js') - root = await createRoot(ctx.renderOptions) + const { createRoot } = await import('./ink.js'); + 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, + 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() + logForDebugging('[STARTUP] Running showSetupScreens()...'); + const setupScreensStart = Date.now(); const onboardingShown = await showSetupScreens( root, permissionMode, @@ -3254,23 +2756,17 @@ async function run(): Promise { commands, enableClaudeInChrome, devChannels, - ) - logForDebugging( - `[STARTUP] showSetupScreens() completed in ${Date.now() - setupScreensStart}ms`, - ) + ); + 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 + 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`), - ) + process.stderr.write(chalk.yellow(`${disabledReason}\n--rc flag ignored.\n`)); } } @@ -3282,59 +2778,51 @@ async function run(): Promise { mainThreadAgentDefinition.memory && mainThreadAgentDefinition.pendingSnapshotUpdate ) { - const agentDef = mainThreadAgentDefinition + const agentDef = mainThreadAgentDefinition; const choice = await launchSnapshotUpdateDialog(root, { agentType: agentDef.agentType, scope: agentDef.memory!, - snapshotTimestamp: - agentDef.pendingSnapshotUpdate!.snapshotTimestamp, - }) + 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 + 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 + agentDef.pendingSnapshotUpdate = undefined; } // Skip executing /login if we just completed onboarding for it if (onboardingShown && prompt?.trim().toLowerCase() === '/login') { - prompt = '' + 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() + void refreshRemoteManagedSettings(); + 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 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() - }) + 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() + const orgValidation = await validateForceLoginOrg(); if (!orgValidation.valid) { - await exitWithError(root, orgValidation.message) + await exitWithError(root, orgValidation.message); } } @@ -3343,28 +2831,26 @@ async function run(): Promise { // 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 + 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() + 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) + const { errors } = getSettingsWithErrors(); + const nonMcpErrors = errors.filter(e => !e.mcpErrorMetadata); if (nonMcpErrors.length > 0) { await launchInvalidSettingsDialog(root, { settingsErrors: nonMcpErrors, onExit: () => gracefulShutdownSync(1), - }) + }); } } @@ -3374,82 +2860,71 @@ async function run(): Promise { // --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 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) + 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}`, - ) + lastPrefetched > 0 ? ` last ran ${Math.round((Date.now() - lastPrefetched) / 1000)}s ago` : ''; + logForDebugging(`Starting background startup prefetches${lastPrefetchedInfo}`); - checkQuotaStatus().catch(error => logError(error)) + checkQuotaStatus().catch(error => logError(error)); // Fetch bootstrap data from the server and update all cache values. - void fetchBootstrapData() + 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() + 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() + 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() + resolveFastModeStatusFromCache(); } if (!isNonInteractiveSession) { - void refreshExampleCommands() // Pre-fetch example commands (runs git log, no API call) + 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 + 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 } + const allMcpConfigs = { ...existingMcpConfigs, ...dynamicMcpConfig }; // Separate SDK configs from regular MCP configs - const sdkMcpConfigs: Record = {} - const regularMcpConfigs: Record = {} + const sdkMcpConfigs: Record = {}; + const regularMcpConfigs: Record = {}; for (const [name, config] of Object.entries(allMcpConfigs)) { - const typedConfig = config as ScopedMcpServerConfig | McpSdkServerConfig + const typedConfig = config as ScopedMcpServerConfig | McpSdkServerConfig; if (typedConfig.type === 'sdk') { - sdkMcpConfigs[name] = typedConfig as McpSdkServerConfig + sdkMcpConfigs[name] = typedConfig as McpSdkServerConfig; } else { - regularMcpConfigs[name] = typedConfig as ScopedMcpServerConfig + regularMcpConfigs[name] = typedConfig as ScopedMcpServerConfig; } } - profileCheckpoint('action_mcp_configs_loaded') + 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 @@ -3457,26 +2932,23 @@ async function run(): Promise { // and one slow server doesn't block the batch. const localMcpPromise = isNonInteractiveSession ? Promise.resolve({ clients: [], tools: [], commands: [] }) - : prefetchAllMcpResources(regularMcpConfigs) + : 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]) => ({ + 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 @@ -3484,17 +2956,12 @@ async function run(): Promise { // 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 + 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 @@ -3503,39 +2970,38 @@ async function run(): Promise { // 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> = [] + const hookMessages: Awaited> = []; // Suppress transient unhandledRejection — the prefetch warms the // memoized connectToServer cache but nobody awaits it in interactive. - mcpPromise.catch(() => {}) + mcpPromise.catch(() => {}); - const mcpClients: Awaited['clients'] = [] - const mcpTools: Awaited['tools'] = [] - const mcpCommands: Awaited['commands'] = [] + const mcpClients: Awaited['clients'] = []; + const mcpTools: Awaited['tools'] = []; + const mcpCommands: Awaited['commands'] = []; - let thinkingEnabled = shouldEnableThinkingByDefault() - let thinkingConfig: ThinkingConfig = - thinkingEnabled !== false ? { type: 'adaptive' } : { type: 'disabled' } + let thinkingEnabled = shouldEnableThinkingByDefault(); + let thinkingConfig: ThinkingConfig = thinkingEnabled !== false ? { type: 'adaptive' } : { type: 'disabled' }; if (options.thinking === 'adaptive' || options.thinking === 'enabled') { - thinkingEnabled = true - thinkingConfig = { type: 'adaptive' } + thinkingEnabled = true; + thinkingConfig = { type: 'adaptive' }; } else if (options.thinking === 'disabled') { - thinkingEnabled = false - thinkingConfig = { type: 'disabled' } + thinkingEnabled = false; + thinkingConfig = { type: 'disabled' }; } else { const maxThinkingTokens = process.env.MAX_THINKING_TOKENS ? parseInt(process.env.MAX_THINKING_TOKENS, 10) - : options.maxThinkingTokens + : options.maxThinkingTokens; if (maxThinkingTokens !== undefined) { if (maxThinkingTokens > 0) { - thinkingEnabled = true + thinkingEnabled = true; thinkingConfig = { type: 'enabled', budgetTokens: maxThinkingTokens, - } + }; } else if (maxThinkingTokens === 0) { - thinkingEnabled = false - thinkingConfig = { type: 'disabled' } + thinkingEnabled = false; + thinkingConfig = { type: 'disabled' }; } } } @@ -3543,11 +3009,11 @@ async function run(): Promise { logForDiagnosticsNoPII('info', 'started', { version: MACRO.VERSION, is_native_binary: isInBundledMode(), - }) + }); registerCleanup(async () => { - logForDiagnosticsNoPII('info', 'exited') - }) + logForDiagnosticsNoPII('info', 'exited'); + }); void logTenguInit({ hasInitialPrompt: Boolean(prompt), @@ -3568,45 +3034,35 @@ async function run(): Promise { permissionMode, modeIsBypass: permissionMode === 'bypassPermissions', allowDangerouslySkipPermissionsPassed: allowDangerouslySkipPermissions, - systemPromptFlag: systemPrompt - ? options.systemPromptFile - ? 'file' - : 'flag' - : undefined, - appendSystemPromptFlag: appendSystemPrompt - ? options.appendSystemPromptFile - ? 'file' - : 'flag' - : undefined, + systemPromptFlag: systemPrompt ? (options.systemPromptFile ? 'file' : 'flag') : undefined, + appendSystemPromptFlag: appendSystemPrompt ? (options.appendSystemPromptFile ? 'file' : 'flag') : undefined, thinkingConfig, assistantActivationPath: - feature('KAIROS') && kairosEnabled - ? assistantModule?.getAssistantActivationPath() - : undefined, - }) + feature('KAIROS') && kairosEnabled ? assistantModule?.getAssistantActivationPath() : undefined, + }); // Log context metrics once at initialization - void logContextMetrics(regularMcpConfigs, toolPermissionContext) + void logContextMetrics(regularMcpConfigs, toolPermissionContext); - void logPermissionContextForAnts(null, 'initialization') + void logPermissionContextForAnts(null, 'initialization'); - logManagedSettings() + 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 (!registered) return; if (sessionNameArg) { - void updateSessionName(sessionNameArg) + void updateSessionName(sessionNameArg); } void countConcurrentSessions().then(count => { if (count >= 2) { - logEvent('tengu_concurrent_sessions', { num_sessions: count }) + 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. @@ -3623,45 +3079,42 @@ async function run(): Promise { // 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(), - ) + 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() - }) + profileCheckpoint('action_after_plugins_init'); + await cleanupOrphanedPluginVersionsInBackground(); + void getGlobExclusionsForPluginCache(); + }); } - const setupTrigger = - initOnly || init ? 'init' : maintenance ? 'maintenance' : null + const setupTrigger = initOnly || init ? 'init' : maintenance ? 'maintenance' : null; if (initOnly) { - applyConfigEnvironmentVariables() - await processSetupHooks('init', { forceSyncExecution: true }) - await processSessionStartHooks('startup', { forceSyncExecution: true }) - gracefulShutdownSync(0) - return + 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) + 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() + applyConfigEnvironmentVariables(); // Initialize telemetry after env vars are applied so OTEL endpoint env vars and // otelHeadersHelper (which requires trust to execute) are available. - initializeTelemetryAfterTrust() + initializeTelemetryAfterTrust(); // Kick SessionStart hooks now so the subprocess spawn overlaps with // MCP connect + plugin init + print.ts import below. loadInitialMessages @@ -3674,18 +3127,18 @@ async function run(): Promise { const sessionStartHooksPromise = options.continue || options.resume || teleport || setupTrigger ? undefined - : processSessionStartHooks('startup') + : 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(() => {}) + sessionStartHooksPromise?.catch(() => {}); - profileCheckpoint('before_validateForceLoginOrg') + profileCheckpoint('before_validateForceLoginOrg'); // Validate org restriction for non-interactive sessions - const orgValidation = await validateForceLoginOrg() + const orgValidation = await validateForceLoginOrg(); if (!orgValidation.valid) { - process.stderr.write(orgValidation.message + '\n') - process.exit(1) + process.stderr.write(orgValidation.message + '\n'); + process.exit(1); } // Headless mode supports all prompt commands and some local commands @@ -3696,9 +3149,9 @@ async function run(): Promise { command => (command.type === 'prompt' && !command.disableNonInteractive) || (command.type === 'local' && command.supportsNonInteractive), - ) + ); - const defaultState = getDefaultAppState() + const defaultState = getDefaultAppState(); const headlessInitialState: AppState = { ...defaultState, mcp: { @@ -3708,8 +3161,7 @@ async function run(): Promise { tools: mcpTools, }, toolPermissionContext, - effortValue: - parseEffortValue(options.effort) ?? getInitialEffortSetting(), + effortValue: parseEffortValue(options.effort) ?? getInitialEffortSetting(), ...(isFastModeEnabled() && { fastMode: getInitialFastModeSetting(effectiveModel ?? null), }), @@ -3722,56 +3174,46 @@ async function run(): Promise { // 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, - ) + 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) + 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 } - }) - }) + 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) + 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)) + 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() + const connectMcpBatch = (configs: Record, label: string): Promise => { + if (Object.keys(configs).length === 0) return Promise.resolve(); headlessStore.setState(prev => ({ ...prev, mcp: { @@ -3785,28 +3227,21 @@ async function run(): Promise { })), ], }, - })) - 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}`), - ) - } + })); + 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. @@ -3815,9 +3250,9 @@ async function run(): Promise { // (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') + 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 @@ -3825,48 +3260,46 @@ async function run(): Promise { // 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 CLAUDE_AI_MCP_TIMEOUT_MS = 5_000; const claudeaiConnect = claudeaiConfigPromise.then(claudeaiConfigs => { if (Object.keys(claudeaiConfigs).length > 0) { - const claudeaiSigs = new Set() + const claudeaiSigs = new Set(); for (const config of Object.values(claudeaiConfigs)) { - const sig = getMcpServerSignature(config) - if (sig) claudeaiSigs.add(sig) + const sig = getMcpServerSignature(config); + if (sig) claudeaiSigs.add(sig); } - const suppressed = new Set() + 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 (!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(() => {}) + 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), - ) + 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) + 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 @@ -3875,34 +3308,24 @@ async function run(): Promise { // 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 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, - ) + claudeaiTimer = setTimeout(r => r(true), CLAUDE_AI_MCP_TIMEOUT_MS, resolve); }), - ]) - if (claudeaiTimer) clearTimeout(claudeaiTimer) + ]); + 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') + profileCheckpoint('after_connectMcp_claudeai'); // In headless mode, start deferred prefetches immediately (no user typing delay) // --bare / SIMPLE: startDeferredPrefetches early-returns internally. @@ -3910,21 +3333,17 @@ async function run(): Promise { // 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(), - ) + 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(), - ) + 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') + logSessionTelemetry(); + profileCheckpoint('before_print_import'); + const { runHeadless } = await import('src/cli/print.js'); + profileCheckpoint('after_print_import'); void runHeadless( inputPrompt, () => headlessStore.getState(), @@ -3944,9 +3363,7 @@ async function run(): Promise { thinkingConfig, maxTurns: options.maxTurns, maxBudgetUsd: options.maxBudgetUsd, - taskBudget: options.taskBudget - ? { total: options.taskBudget } - : undefined, + taskBudget: options.taskBudget ? { total: options.taskBudget } : undefined, systemPrompt, appendSystemPrompt, userSpecifiedModel: effectiveModel, @@ -3964,41 +3381,35 @@ async function run(): Promise { setupTrigger: setupTrigger ?? undefined, sessionStartHooksPromise, }, - ) - return + ); + 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, - }) + 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) + const deprecationWarning = getModelDeprecationWarning(resolvedInitialModel); // Build initial notification queue const initialNotifications: Array<{ - key: string - text: string - color?: 'warning' - priority: 'high' - }> = [] + key: string; + text: string; + color?: 'warning'; + priority: 'high'; + }> = []; if (permissionModeNotification) { initialNotifications.push({ key: 'permission-mode-notification', text: permissionModeNotification, priority: 'high', - }) + }); } if (deprecationWarning) { initialNotifications.push({ @@ -4006,23 +3417,19 @@ async function run(): Promise { 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 + 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 = { @@ -4031,20 +3438,18 @@ async function run(): Promise { 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 + 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') + require('./bridge/bridgeEnabled.js') as typeof import('./bridge/bridgeEnabled.js'); /* eslint-enable @typescript-eslint/no-require-imports */ - ccrMirrorEnabled = isCcrMirrorEnabled() + ccrMirrorEnabled = isCcrMirrorEnabled(); } const initialState: AppState = { @@ -4144,11 +3549,8 @@ async function run(): Promise { pendingWorkerRequest: null, pendingSandboxRequest: null, authVersion: 0, - initialMessage: inputPrompt - ? { message: createUserMessage({ content: String(inputPrompt) }) } - : null, - effortValue: - parseEffortValue(options.effort) ?? getInitialEffortSetting(), + initialMessage: inputPrompt ? { message: createUserMessage({ content: String(inputPrompt) }) } : null, + effortValue: parseEffortValue(options.effort) ?? getInitialEffortSetting(), activeOverlays: new Set(), fastMode: getInitialFastModeSetting(resolvedInitialModel), ...(isAdvisorEnabled() && advisorModel && { advisorModel }), @@ -4160,14 +3562,14 @@ async function run(): Promise { teamContext: feature('KAIROS') ? (assistantTeamContext ?? computeInitialTeamContext?.()) : computeInitialTeamContext?.(), - } + }; // Add CLI initial prompt to history if (inputPrompt) { - addToHistory(String(inputPrompt)) + addToHistory(String(inputPrompt)); } - const initialTools = mcpTools + const initialTools = mcpTools; // Increment numStartups synchronously — first-render readers like // shouldShowEffortCallout (via useState initializer) need the updated @@ -4175,11 +3577,11 @@ async function run(): Promise { saveGlobalConfig(current => ({ ...current, numStartups: (current.numStartups ?? 0) + 1, - })) + })); setImmediate(() => { - void logStartupTelemetry() - logSessionTelemetry() - }) + 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 @@ -4189,20 +3591,15 @@ async function run(): Promise { // - 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 + 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 + ? sessionUploaderPromise.then(mod => mod.createSessionTurnUploader()).catch(() => null) + : null; const sessionConfig = { debug: debug || debugToStderr, @@ -4220,10 +3617,10 @@ async function run(): Promise { thinkingConfig, ...(uploaderReady && { onTurnComplete: (messages: MessageType[]) => { - void uploaderReady.then(uploader => uploader?.(messages)) + void uploaderReady.then(uploader => uploader?.(messages)); }, }), - } + }; // Shared context for processResumedConversation calls const resumeContext = { @@ -4233,32 +3630,24 @@ async function run(): Promise { currentCwd, cliAgents, initialState, - } + }; if (options.continue) { // Continue the most recent conversation directly - let resumeSucceeded = false + let resumeSucceeded = false; try { - const resumeStart = performance.now() + 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 */, - ) + 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', - ) + }); + return await exitWithError(root, 'No conversation found to continue'); } const loaded = await processResumedConversation( @@ -4269,28 +3658,27 @@ async function run(): Promise { transcriptPath: result.fullPath, }, resumeContext, - ) + ); if (loaded.restoredAgentDef) { - mainThreadAgentDefinition = loaded.restoredAgentDef + mainThreadAgentDefinition = loaded.restoredAgentDef; } - maybeActivateProactive(options) - maybeActivateBrief(options) + maybeActivateProactive(options); + maybeActivateBrief(options); logEvent('tengu_continue', { success: true, resume_duration_ms: Math.round(performance.now() - resumeStart), - }) - resumeSucceeded = true + }); + resumeSucceeded = true; await launchRepl( root, { getFpsMetrics, stats, initialState: loaded.initialState }, { ...sessionConfig, - mainThreadAgentDefinition: - loaded.restoredAgentDef ?? mainThreadAgentDefinition, + mainThreadAgentDefinition: loaded.restoredAgentDef ?? mainThreadAgentDefinition, initialMessages: loaded.messages, initialFileHistorySnapshots: loaded.fileHistorySnapshots, initialContentReplacements: loaded.contentReplacements, @@ -4298,45 +3686,42 @@ async function run(): Promise { initialAgentColor: loaded.agentColor, }, renderAndRun, - ) + ); } catch (error) { if (!resumeSucceeded) { logEvent('tengu_continue', { success: false, - }) + }); } - logError(error) - process.exit(1) + logError(error); + process.exit(1); } } else if (feature('DIRECT_CONNECT') && _pendingConnect?.url) { // `claude connect ` — full interactive TUI connected to a remote server - let directConnectConfig + let directConnectConfig; try { const session = await createDirectConnectSession({ serverUrl: _pendingConnect.url, authToken: _pendingConnect.authToken, cwd: getOriginalCwd(), - dangerouslySkipPermissions: - _pendingConnect.dangerouslySkipPermissions, - }) + dangerouslySkipPermissions: _pendingConnect.dangerouslySkipPermissions, + }); if (session.workDir) { - setOriginalCwd(session.workDir) - setCwdState(session.workDir) + setOriginalCwd(session.workDir); + setCwdState(session.workDir); } - setDirectConnectServerUrl(_pendingConnect.url) - directConnectConfig = session.config + setDirectConnectServerUrl(_pendingConnect.url); + directConnectConfig = session.config; } catch (err) { - return await exitWithError( - root, - err instanceof DirectConnectError ? err.message : String(err), - () => gracefulShutdown(1), - ) + 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, @@ -4354,65 +3739,58 @@ async function run(): Promise { thinkingConfig, }, renderAndRun, - ) - return + ); + 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 + const { createSSHSession, createLocalSSHSession, SSHSessionError } = await import('./ssh/createSSHSession.js'); + let sshSession; try { if (_pendingSSH.local) { - process.stderr.write('Starting local ssh-proxy test session...\n') + process.stderr.write('Starting local ssh-proxy test session...\n'); sshSession = createLocalSSHSession({ cwd: _pendingSSH.cwd, permissionMode: _pendingSSH.permissionMode, - dangerouslySkipPermissions: - _pendingSSH.dangerouslySkipPermissions, - }) + dangerouslySkipPermissions: _pendingSSH.dangerouslySkipPermissions, + }); } else { - process.stderr.write(`Connecting to ${_pendingSSH.host}…\n`) + 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 + 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, + dangerouslySkipPermissions: _pendingSSH.dangerouslySkipPermissions, extraCliArgs: _pendingSSH.extraCliArgs, }, isTTY ? { onProgress: msg => { - hadProgress = true - process.stderr.write(`\r ${msg}\x1b[K`) + hadProgress = true; + process.stderr.write(`\r ${msg}\x1b[K`); }, } : {}, - ) - if (hadProgress) process.stderr.write('\n') + ); + if (hadProgress) process.stderr.write('\n'); } - setOriginalCwd(sshSession.remoteCwd) - setCwdState(sshSession.remoteCwd) - setDirectConnectServerUrl( - _pendingSSH.local ? 'local' : _pendingSSH.host, - ) + 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), - ) + return await exitWithError(root, err instanceof SSHSessionError ? err.message : String(err), () => + gracefulShutdown(1), + ); } const sshInfoMessage = createSystemMessage( @@ -4420,7 +3798,7 @@ async function run(): Promise { ? `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, @@ -4438,8 +3816,8 @@ async function run(): Promise { thinkingConfig, }, renderAndRun, - ) - return + ); + return; } else if ( feature('KAIROS') && _pendingAssistantChat && @@ -4449,38 +3827,34 @@ async function run(): Promise { // 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' - ) + const { discoverAssistantSessions } = await import('./assistant/sessionDiscovery.js'); - let targetSessionId = _pendingAssistantChat.sessionId + let targetSessionId = _pendingAssistantChat.sessionId; // Discovery flow — list bridge environments, filter sessions if (!targetSessionId) { - let sessions + let sessions; try { - sessions = await discoverAssistantSessions() + sessions = await discoverAssistantSessions(); } catch (e) { - return await exitWithError( - root, - `Failed to discover sessions: ${e instanceof Error ? e.message : e}`, - () => gracefulShutdown(1), - ) + return await exitWithError(root, `Failed to discover sessions: ${e instanceof Error ? e.message : e}`, () => + gracefulShutdown(1), + ); } if (sessions.length === 0) { - let installedDir: string | null + let installedDir: string | null; try { - installedDir = await launchAssistantInstallWizard(root) + 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) + 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. @@ -4488,45 +3862,41 @@ async function run(): Promise { 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 + targetSessionId = sessions[0]!.id; } else { const picked = await launchAssistantSessionChooser(root, { sessions, - }) + }); if (!picked) { - await gracefulShutdown(0) - process.exit(0) + await gracefulShutdown(0); + process.exit(0); } - targetSessionId = picked + 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 + const { checkAndRefreshOAuthTokenIfNeeded, getClaudeAIOAuthTokens } = await import('./utils/auth.js'); + await checkAndRefreshOAuthTokenIfNeeded(); + let apiCreds; try { - apiCreds = await prepareApiRequest() + apiCreds = await prepareApiRequest(); } catch (e) { - return await exitWithError( - root, - `Error: ${e instanceof Error ? e.message : 'Failed to authenticate'}`, - () => gracefulShutdown(1), - ) + return await exitWithError(root, `Error: ${e instanceof Error ? e.message : 'Failed to authenticate'}`, () => + gracefulShutdown(1), + ); } - const getAccessToken = (): string => - getClaudeAIOAuthTokens()?.accessToken ?? apiCreds.accessToken + 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) + setKairosActive(true); + setUserMsgOptIn(true); + setIsRemoteMode(true); const remoteSessionConfig = createRemoteSessionConfig( targetSessionId, @@ -4534,21 +3904,21 @@ async function run(): Promise { 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) + const remoteCommands = filterCommandsForRemoteMode(commands); await launchRepl( root, { getFpsMetrics, stats, initialState: assistantInitialState }, @@ -4565,62 +3935,51 @@ async function run(): Promise { thinkingConfig, }, renderAndRun, - ) - return - } else if ( - options.resume || - options.fromPr || - teleport || - remote !== null - ) { + ); + 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() + const { clearSessionCaches } = await import('./commands/clear/caches.js'); + clearSessionCaches(); - let messages: MessageType[] | null = null - let processedResume: ProcessedResume | undefined = undefined + let messages: MessageType[] | null = null; + let processedResume: ProcessedResume | undefined = undefined; - let maybeSessionId = validateUuid(options.resume) - let searchTerm: string | undefined = undefined + let maybeSessionId = validateUuid(options.resume); + let searchTerm: string | undefined = undefined; // Store full LogOption when found by custom title (for cross-worktree resume) - let matchedLog: LogOption | null = null + let matchedLog: LogOption | null = null; // PR filter for --from-pr flag - let filterByPr: boolean | number | string | undefined = undefined + let filterByPr: boolean | number | string | undefined = undefined; // Handle --from-pr flag if (options.fromPr) { if (options.fromPr === true) { // Show all sessions with linked PRs - filterByPr = true + filterByPr = true; } else if (typeof options.fromPr === 'string') { // Could be a PR number or URL - filterByPr = options.fromPr + 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 (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 + matchedLog = matches[0]!; + maybeSessionId = getSessionIdFromLog(matchedLog) ?? null; } else { // No match or multiple matches - use as search term for picker - searchTerm = trimmedValue + searchTerm = trimmedValue; } } } @@ -4628,131 +3987,105 @@ async function run(): Promise { // --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() + await waitForPolicyLimitsToLoad(); if (!isPolicyAllowed('allow_remote_sessions')) { - return await exitWithError( - root, - "Error: Remote sessions are disabled by your organization's policy.", - () => gracefulShutdown(1), - ) + 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 + 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, - ) + 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, - }) + 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 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), - ) + 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, - }) + 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) + 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)) + setIsRemoteMode(true); + switchSession(asSessionId(createdSession.id)); // Get OAuth credentials for remote session - let apiCreds: { accessToken: string; orgUUID: string } + let apiCreds: { accessToken: string; orgUUID: string }; try { - apiCreds = await prepareApiRequest() + apiCreds = await prepareApiRequest(); } catch (error) { - logError(toError(error)) - return await exitWithError( - root, - `Error: ${errorMessage(error) || 'Failed to authenticate'}`, - () => gracefulShutdown(1), - ) + 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 { 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 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 + 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) + const remoteCommands = filterCommandsForRemoteMode(commands); await launchRepl( root, { getFpsMetrics, stats, initialState: remoteInitialState }, @@ -4760,9 +4093,7 @@ async function run(): Promise { debug: debug || debugToStderr, commands: remoteCommands, initialTools: [], - initialMessages: initialUserMessage - ? [remoteInfoMessage, initialUserMessage] - : [remoteInfoMessage], + initialMessages: initialUserMessage ? [remoteInfoMessage, initialUserMessage] : [remoteInfoMessage], mcpClients: [], autoConnectIdeFlag: ide, mainThreadAgentDefinition, @@ -4771,67 +4102,53 @@ async function run(): Promise { thinkingConfig, }, renderAndRun, - ) - return + ); + 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) + 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) + await gracefulShutdown(0); + process.exit(0); } - const { branchError } = await checkOutTeleportedSessionBranch( - teleportResult.branch, - ) - messages = processMessagesForTeleportResume( - teleportResult.log, - branchError, - ) + 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) + 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 (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) + 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, - }, - ) + const selectedPath = await launchTeleportRepoMismatchDialog(root, { + targetRepo: sessionRepo, + initialPaths: existingPaths, + }); if (selectedPath) { // Change to the selected directory - process.chdir(selectedPath) - setCwd(selectedPath) - setOriginalCwd(selectedPath) + process.chdir(selectedPath); + setCwd(selectedPath); + setOriginalCwd(selectedPath); } else { // User cancelled - await gracefulShutdown(0) + await gracefulShutdown(0); } } else { // No known paths - show original error @@ -4840,60 +4157,45 @@ async function run(): Promise { 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`, - ), - ) + chalk.red(`Error: ${repoValidation.errorMessage || 'Failed to validate session'}\n`), + ); } - await validateGitState() + await validateGitState(); // Use progress UI for teleport - const { teleportWithProgress } = await import( - './components/TeleportProgress.js' - ) - const result = await teleportWithProgress(root, 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 + setTeleportedSessionInfo({ sessionId: teleport }); + messages = result.messages; } catch (error) { if (error instanceof TeleportOperationError) { - process.stderr.write(error.formattedMessage + '\n') + process.stderr.write(error.formattedMessage + '\n'); } else { - logError(error) - process.stderr.write( - chalk.red(`Error: ${errorMessage(error)}\n`), - ) + logError(error); + process.stderr.write(chalk.red(`Error: ${errorMessage(error)}\n`)); } - await gracefulShutdown(1) + await gracefulShutdown(1); } } } if (process.env.USER_TYPE === 'ant') { - if ( - options.resume && - typeof options.resume === 'string' && - !maybeSessionId - ) { + 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) + 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, - ) + const resumeStart = performance.now(); + const logOption = await loadCcshare(ccshareId); + const result = await loadConversationForResume(logOption, undefined); if (result) { processedResume = await processResumedConversation( result, @@ -4902,55 +4204,45 @@ async function run(): Promise { transcriptPath: result.fullPath, }, resumeContext, - ) + ); if (processedResume.restoredAgentDef) { - mainThreadAgentDefinition = processedResume.restoredAgentDef + mainThreadAgentDefinition = processedResume.restoredAgentDef; } logEvent('tengu_session_resumed', { - entrypoint: - 'ccshare' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + entrypoint: 'ccshare' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, success: true, - resume_duration_ms: Math.round( - performance.now() - resumeStart, - ), - }) + 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, + 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, + 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), - ) + }); + logError(error); + await exitWithError(root, `Unable to resume from ccshare: ${errorMessage(error)}`, () => + gracefulShutdown(1), + ); } } else { - const resolvedPath = resolve(options.resume) + const resolvedPath = resolve(options.resume); try { - const resumeStart = performance.now() - let logOption + 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) + logOption = await loadTranscriptFromFile(resolvedPath); } catch (error) { - if (!isENOENT(error)) throw 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 */, - ) + const result = await loadConversationForResume(logOption, undefined /* sourceFile */); if (result) { processedResume = await processResumedConversation( result, @@ -4959,39 +4251,31 @@ async function run(): Promise { transcriptPath: result.fullPath, }, resumeContext, - ) + ); if (processedResume.restoredAgentDef) { - mainThreadAgentDefinition = - processedResume.restoredAgentDef + mainThreadAgentDefinition = processedResume.restoredAgentDef; } logEvent('tengu_session_resumed', { - entrypoint: - 'file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + entrypoint: 'file' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, success: true, - resume_duration_ms: Math.round( - performance.now() - resumeStart, - ), - }) + 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, + 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, + 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), - ) + }); + logError(error); + await exitWithError(root, `Unable to load transcript from file: ${options.resume}`, () => + gracefulShutdown(1), + ); } } } @@ -5000,29 +4284,22 @@ async function run(): Promise { // If not loaded as a file, try as session ID if (maybeSessionId) { // Resume specific session by ID - const sessionId = maybeSessionId + const sessionId = maybeSessionId; try { - const resumeStart = performance.now() + 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, - ) + 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, + 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}`, - ) + }); + return await exitWithError(root, `No conversation found with session ID: ${sessionId}`); } - const fullPath = matchedLog?.fullPath ?? result.fullPath + const fullPath = matchedLog?.fullPath ?? result.fullPath; processedResume = await processResumedConversation( result, { @@ -5031,45 +4308,38 @@ async function run(): Promise { transcriptPath: fullPath, }, resumeContext, - ) + ); if (processedResume.restoredAgentDef) { - mainThreadAgentDefinition = processedResume.restoredAgentDef + mainThreadAgentDefinition = processedResume.restoredAgentDef; } logEvent('tengu_session_resumed', { - entrypoint: - 'cli_flag' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + 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, + 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}`) + }); + 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) + 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`, - ), - ) + chalk.yellow(`Warning: ${failedCount}/${results.length} file(s) failed to download.\n`), + ); } } catch (error) { - return await exitWithError( - root, - `Error downloading files: ${errorMessage(error)}`, - ) + return await exitWithError(root, `Error downloading files: ${errorMessage(error)}`); } } @@ -5086,18 +4356,17 @@ async function run(): Promise { initialState, contentReplacements: undefined, } - : undefined) + : undefined); if (resumeData) { - maybeActivateProactive(options) - maybeActivateBrief(options) + maybeActivateProactive(options); + maybeActivateBrief(options); await launchRepl( root, { getFpsMetrics, stats, initialState: resumeData.initialState }, { ...sessionConfig, - mainThreadAgentDefinition: - resumeData.restoredAgentDef ?? mainThreadAgentDefinition, + mainThreadAgentDefinition: resumeData.restoredAgentDef ?? mainThreadAgentDefinition, initialMessages: resumeData.messages, initialFileHistorySnapshots: resumeData.fileHistorySnapshots, initialContentReplacements: resumeData.contentReplacements, @@ -5105,40 +4374,30 @@ async function run(): Promise { 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, - }, - ) + 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 + const pendingHookMessages = hooksPromise && hookMessages.length === 0 ? hooksPromise : undefined; - profileCheckpoint('action_after_hooks') - maybeActivateProactive(options) - maybeActivateBrief(options) + 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', - ) + saveMode(coordinatorModeModule?.isCoordinatorMode() ? 'coordinator' : 'normal'); } // If launched via a deep link, show a provenance banner so the user @@ -5147,37 +4406,34 @@ async function run(): Promise { // 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 + 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, + 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 + : undefined; await launchRepl( root, @@ -5188,24 +4444,17 @@ async function run(): Promise { pendingHookMessages, }, renderAndRun, - ) + ); } }) - .version( - `${MACRO.VERSION} (Claude Code)`, - '-v, --version', - 'Output the version number', - ) + .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('-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( @@ -5213,16 +4462,15 @@ async function run(): Promise { '--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' }), - ) + new Option('--delegate-permissions', '[ANT-ONLY] Alias for --permission-mode auto.').implies({ + permissionMode: 'auto', + }), + ); program.addOption( new Option( '--dangerously-skip-permissions-with-classifiers', @@ -5230,15 +4478,12 @@ async function run(): Promise { ) .hideHelp() .implies({ permissionMode: 'auto' }), - ) + ); program.addOption( - new Option( - '--afk', - '[ANT-ONLY] Deprecated alias for --permission-mode auto.', - ) + new Option('--afk', '[ANT-ONLY] Deprecated alias for --permission-mode auto.') .hideHelp() .implies({ permissionMode: 'auto' }), - ) + ); program.addOption( new Option( '--tasks [id]', @@ -5246,24 +4491,16 @@ async function run(): Promise { ) .argParser(String) .hideHelp(), - ) - program.option( - '--agent-teams', - '[ANT-ONLY] Force Claude to use multi-agent mode for solving problems', - () => true, - ) + ); + 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(), - ) + 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'), - ) + program.addOption(new Option('--proactive', 'Start in proactive autonomous mode')); } if (feature('UDS_INBOX')) { @@ -5272,24 +4509,14 @@ async function run(): Promise { '--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', - ), - ) + 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(), - ) + program.addOption(new Option('--assistant', 'Force assistant mode (Agent SDK daemon use)').hideHelp()); } if (feature('KAIROS') || feature('KAIROS_CHANNELS')) { program.addOption( @@ -5297,58 +4524,29 @@ async function run(): Promise { '--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('--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"', - ) + 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(), - ) + ); + 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( @@ -5356,21 +4554,15 @@ async function run(): Promise { '--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(), - ) + 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(), - ) + new Option('--remote [description]', 'Create a remote session with the given description').hideHelp(), + ); if (feature('BRIDGE_MODE')) { program.addOption( new Option( @@ -5379,24 +4571,17 @@ async function run(): Promise { ) .argParser(value => value || true) .hideHelp(), - ) + ); program.addOption( - new Option('--rc [name]', 'Alias for --remote-control') - .argParser(value => value || true) - .hideHelp(), - ) + 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(), - ) + program.addOption(new Option('--hard-fail', 'Crash on logError calls instead of silently logging').hideHelp()); } - profileCheckpoint('run_main_options_built') + profileCheckpoint('run_main_options_built'); // -p/--print mode: skip subcommand registration. The 52 subcommands // (mcp, auth, plugin, skill, task, config, doctor, update, etc.) are @@ -5406,16 +4591,13 @@ async function run(): Promise { // + 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://'), - ) + 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 + profileCheckpoint('run_before_parse'); + await program.parseAsync(process.argv); + profileCheckpoint('run_after_parse'); + return program; } // claude mcp @@ -5424,29 +4606,23 @@ async function run(): Promise { .command('mcp') .description('Configure and manage MCP servers') .configureHelp(createSortedHelpConfig()) - .enablePositionalOptions() + .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 }) - }, - ) + .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) + registerMcpAddCommand(mcp); if (isXaaEnabled()) { - registerMcpXaaIdpCommand(mcp) + registerMcpXaaIdpCommand(mcp); } mcp @@ -5457,9 +4633,9 @@ async function run(): Promise { '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) - }) + const { mcpRemoveHandler } = await import('./cli/handlers/mcp.js'); + await mcpRemoveHandler(name, options); + }); mcp .command('list') @@ -5467,9 +4643,9 @@ async function run(): Promise { '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() - }) + const { mcpListHandler } = await import('./cli/handlers/mcp.js'); + await mcpListHandler(); + }); mcp .command('get ') @@ -5477,55 +4653,36 @@ async function run(): Promise { '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) - }) + 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) - }, - ) + .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', - ) + .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) - }) + 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', - ) + .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() - }) + const { mcpResetChoicesHandler } = await import('./cli/handlers/mcp.js'); + await mcpResetChoicesHandler(); + }); // claude server if (feature('DIRECT_CONNECT')) { @@ -5536,52 +4693,34 @@ async function run(): Promise { .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', - ) + .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 + 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() + 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) + 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 authToken = opts.authToken ?? `sk-ant-cc-${randomBytes(16).toString('base64url')}`; const config = { port: parseInt(opts.port, 10), @@ -5591,43 +4730,41 @@ async function run(): Promise { workspace: opts.workspace, idleTimeoutMs: parseInt(opts.idleTimeout, 10), maxSessions: parseInt(opts.maxSessions, 10), - } + }; - const backend = new DangerousBackend() + const backend = new DangerousBackend(); const sessionManager = new SessionManager(backend, { idleTimeoutMs: config.idleTimeoutMs, maxSessions: config.maxSessions, - }) - const logger = createServerLogger() + }); + const logger = createServerLogger(); - const server = startServer(config, sessionManager, logger) - const actualPort = server.port ?? config.port - printBanner(config, authToken, actualPort) + 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}`, + httpUrl: config.unix ? `unix:${config.unix}` : `http://${config.host}:${actualPort}`, startedAt: Date.now(), - }) + }); - let shuttingDown = false + let shuttingDown = false; const shutdown = async () => { - if (shuttingDown) return - shuttingDown = true + 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()) + 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. @@ -5642,14 +4779,8 @@ async function run(): Promise { '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('--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). ' + @@ -5664,9 +4795,9 @@ async function run(): Promise { "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) - }) + ); + process.exit(1); + }); } // claude connect — subcommand only handles -p (headless) mode. @@ -5675,83 +4806,59 @@ async function run(): Promise { if (feature('DIRECT_CONNECT')) { program .command('open ') - .description( - 'Connect to a Claude Code server (internal — use cc:// URLs)', - ) + .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', - ) + .option('--output-format ', 'Output format: text, json, stream-json', 'text') .action( async ( ccUrl: string, opts: { - print?: string | boolean - outputFormat: string + print?: string | boolean; + outputFormat: string; }, ) => { - const { parseConnectUrl } = await import( - './server/parseConnectUrl.js' - ) - const { serverUrl, authToken } = parseConnectUrl(ccUrl) + const { parseConnectUrl } = await import('./server/parseConnectUrl.js'); + const { serverUrl, authToken } = parseConnectUrl(ccUrl); - let connectConfig + let connectConfig; try { const session = await createDirectConnectSession({ serverUrl, authToken, cwd: getOriginalCwd(), - dangerouslySkipPermissions: - _pendingConnect?.dangerouslySkipPermissions, - }) + dangerouslySkipPermissions: _pendingConnect?.dangerouslySkipPermissions, + }); if (session.workDir) { - setOriginalCwd(session.workDir) - setCwdState(session.workDir) + setOriginalCwd(session.workDir); + setCwdState(session.workDir); } - setDirectConnectServerUrl(serverUrl) - connectConfig = session.config + 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) + 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, - ) + 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()) + 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('--console', 'Use Anthropic Console (API usage billing) instead of Claude subscription') .option('--claudeai', 'Use Claude subscription (default)') .action( async ({ @@ -5760,15 +4867,15 @@ async function run(): Promise { console: useConsole, claudeai, }: { - email?: string - sso?: boolean - console?: boolean - claudeai?: boolean + email?: string; + sso?: boolean; + console?: boolean; + claudeai?: boolean; }) => { - const { authLogin } = await import('./cli/handlers/auth.js') - await authLogin({ email, sso, console: useConsole, claudeai }) + const { authLogin } = await import('./cli/handlers/auth.js'); + await authLogin({ email, sso, console: useConsole, claudeai }); }, - ) + ); auth .command('status') @@ -5776,17 +4883,17 @@ async function run(): Promise { .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) - }) + 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() - }) + const { authLogout } = await import('./cli/handlers/auth.js'); + await authLogout(); + }); /** * Helper function to handle marketplace command errors consistently. @@ -5795,53 +4902,41 @@ async function run(): Promise { * @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() + 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()) + .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) - }) + 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)', - ) + .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) - }, - ) + .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()) + .configureHelp(createSortedHelpConfig()); marketplaceCmd .command('add ') @@ -5851,21 +4946,11 @@ async function run(): Promise { '--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) - }, - ) + .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') @@ -5873,11 +4958,9 @@ async function run(): Promise { .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) - }) + const { marketplaceListHandler } = await import('./cli/handlers/plugins.js'); + await marketplaceListHandler(options); + }); marketplaceCmd .command('remove ') @@ -5885,46 +4968,30 @@ async function run(): Promise { .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) - }) + 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', - ) + .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) - }) + 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', - ) + .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) - }, - ) + .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 @@ -5932,163 +4999,106 @@ async function run(): Promise { .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}/)", - ) + .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) - }, - ) + .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)`, - ) + .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) - }, - ) + .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)`, - ) + .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) - }, - ) + .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)`, - ) + .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) - }, - ) + .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('./ink.js'), - ]) - 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).', - ) + .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) - }) + 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') + 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', - ) + .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) - }) + 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', - ) + .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) - }) + 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() - }) + const { autoModeCritiqueHandler } = await import('./cli/handlers/autoMode.js'); + await autoModeCritiqueHandler(options); + process.exit(); + }); } } @@ -6104,15 +5114,13 @@ async function run(): Promise { program .command('remote-control', { hidden: true }) .alias('rc') - .description( - 'Connect your local environment for remote-control sessions via claude.ai/code', - ) + .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)) - }) + const { bridgeMain } = await import('./bridge/bridgeMain.js'); + await bridgeMain(process.argv.slice(3)); + }); } if (feature('KAIROS')) { @@ -6130,9 +5138,9 @@ async function run(): Promise { '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) - }) + ); + process.exit(1); + }); } // Doctor command - check installation health @@ -6145,10 +5153,10 @@ async function run(): Promise { const [{ doctorHandler }, { createRoot }] = await Promise.all([ import('./cli/handlers/util.js'), import('./ink.js'), - ]) - const root = await createRoot(getBaseRenderOptions(false)) - await doctorHandler(root) - }) + ]); + const root = await createRoot(getBaseRenderOptions(false)); + await doctorHandler(root); + }); // claude update // @@ -6161,9 +5169,9 @@ async function run(): Promise { .alias('upgrade') .description('Check for updates and install if available') .action(async () => { - const { update } = await import('src/cli/update.js') - await update() - }) + const { update } = await import('src/cli/update.js'); + await update(); + }); // claude up — run the project's CLAUDE.md "# claude up" setup instructions. if (process.env.USER_TYPE === 'ant') { @@ -6173,9 +5181,9 @@ async function run(): Promise { '[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() - }) + const { up } = await import('src/cli/up.js'); + await up(); + }); } // claude rollback (ant-only) @@ -6188,19 +5196,11 @@ async function run(): Promise { ) .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) - }, - ) + .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 @@ -6210,20 +5210,18 @@ async function run(): Promise { '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) - }, - ) + .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) - } + const maybeSessionId = validateUuid(value); + if (maybeSessionId) return maybeSessionId; + return Number(value); + }; // claude log program .command('log') @@ -6234,9 +5232,9 @@ async function run(): Promise { validateLogId, ) .action(async (logId: string | number | undefined) => { - const { logHandler } = await import('./cli/handlers/ant.js') - await logHandler(logId) - }) + const { logHandler } = await import('./cli/handlers/ant.js'); + await logHandler(logId); + }); // claude error program @@ -6244,25 +5242,18 @@ async function run(): Promise { .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, - ) + .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) - }) + 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('', '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', @@ -6274,29 +5265,22 @@ Examples: $ 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) - }) + 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') + 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) - }, - ) + .action(async (subject: string, opts: { description?: string; list?: string }) => { + const { taskCreateHandler } = await import('./cli/handlers/ant.js'); + await taskCreateHandler(subject, opts); + }); taskCmd .command('list') @@ -6304,34 +5288,25 @@ Examples: .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) - }, - ) + .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) - }) + 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('-s, --status ', `Set status (${TASK_STATUSES.join(', ')})`) .option('--subject ', 'Update subject') .option('-d, --description ', 'Update description') .option('--owner ', 'Set owner') @@ -6340,54 +5315,51 @@ Examples: async ( id: string, opts: { - list?: string - status?: string - subject?: string - description?: string - owner?: string - clearOwner?: boolean + list?: string; + status?: string; + subject?: string; + description?: string; + owner?: string; + clearOwner?: boolean; }, ) => { - const { taskUpdateHandler } = await import('./cli/handlers/ant.js') - await taskUpdateHandler(id, opts) + 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) - }) + 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', - ) + .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) - }) + 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') + profileCheckpoint('main_after_run'); // Log startup perf to Statsig (sampled) and output detailed report if enabled - profileReport() + profileReport(); - return program + return program; } async function logTenguInit({ @@ -6414,118 +5386,103 @@ async function logTenguInit({ 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, + 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, + 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, + 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, + 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, + 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, + 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, + 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, + is_coordinator: feature('COORDINATOR_MODE') && coordinatorModeModule?.isCoordinatorMode() ? true : undefined, ...(assistantActivationPath && { - assistantActivationPath: - assistantActivationPath as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + 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 + 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, + relativeProjectPath: rp as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, } - : {} + : {}; })() : {}), - }) + }); } catch (error) { - logError(error) + logError(error); } } function maybeActivateProactive(options: unknown): void { if ( (feature('PROACTIVE') || feature('KAIROS')) && - ((options as { proactive?: boolean }).proactive || - isEnvTruthy(process.env.CLAUDE_CODE_PROACTIVE)) + ((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') + const proactiveModule = require('./proactive/index.js'); if (!proactiveModule.isProactiveActive()) { - proactiveModule.activateProactive('command') + 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 + 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 @@ -6535,69 +5492,52 @@ function maybeActivateBrief(options: unknown): void { // into external builds via BriefTool.ts → prompt.ts. /* eslint-disable @typescript-eslint/no-require-imports */ const { isBriefEntitled } = - require('./tools/BriefTool/BriefTool.js') as typeof import('./tools/BriefTool/BriefTool.js') + require('./tools/BriefTool/BriefTool.js') as typeof import('./tools/BriefTool/BriefTool.js'); /* eslint-enable @typescript-eslint/no-require-imports */ - const entitled = isBriefEntitled() + const entitled = isBriefEntitled(); if (entitled) { - setUserMsgOptIn(true) + 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, - }) + 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 {} + return {}; } - const opts = options as Record - const teammateMode = opts.teammateMode + 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, + 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, + 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..1544d3130 100644 --- a/src/native-ts/file-index/index.ts +++ b/src/native-ts/file-index/index.ts @@ -241,7 +241,7 @@ export class FileIndex { prevCode === 45 || // - prevCode === 95 || // _ prevCode === 46 || // . - prevCode === 32 // space + prevCode === 32 // space ) { startPositions[startCount++] = bp } @@ -260,7 +260,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/proactive/index.ts b/src/proactive/index.ts index e4c87a4ed..ec9d7e96e 100644 --- a/src/proactive/index.ts +++ b/src/proactive/index.ts @@ -1,6 +1,6 @@ // Auto-generated stub — replace with real implementation -export {}; -export const isProactiveActive: () => boolean = () => false; -export const activateProactive: (source?: string) => void = () => {}; -export const isProactivePaused: () => boolean = () => false; -export const deactivateProactive: () => void = () => {}; +export {} +export const isProactiveActive: () => boolean = () => false +export const activateProactive: (source?: string) => void = () => {} +export const isProactivePaused: () => boolean = () => false +export const deactivateProactive: () => void = () => {} diff --git a/src/query.ts b/src/query.ts index fec85afed..9da827fd8 100644 --- a/src/query.ts +++ b/src/query.ts @@ -126,7 +126,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[] @@ -747,7 +751,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]! @@ -783,7 +794,10 @@ async function* queryLoop( if (clonedContent) { yieldMessage = { ...message, - message: { ...(assistantMsg.message ?? {}), content: clonedContent }, + message: { + ...(assistantMsg.message ?? {}), + content: clonedContent, + }, } as typeof message } } @@ -829,7 +843,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) { @@ -962,7 +980,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, @@ -1365,7 +1386,6 @@ async function* queryLoop( queryCheckpoint('query_tool_execution_start') - if (streamingToolExecutor) { logEvent('tengu_streaming_tool_execution_used', { tool_count: toolUseBlocks.length, @@ -1425,9 +1445,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) { @@ -1616,7 +1641,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 b6cba6b96..91a2fe305 100644 --- a/src/query/stopHooks.ts +++ b/src/query/stopHooks.ts @@ -223,7 +223,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 cdfceb46a..f98109a03 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 664e95839..0d2fcb53d 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 './ink.js' -import type { Props as REPLProps } from './screens/REPL.js' -import type { AppState } from './state/AppStateStore.js' -import type { FpsMetrics } from './utils/fpsTracker.js' +import React from 'react'; +import type { StatsStore } from './context/stats.js'; +import type { Root } from './ink.js'; +import type { Props as REPLProps } from './screens/REPL.js'; +import type { AppState } from './state/AppStateStore.js'; +import type { FpsMetrics } from './utils/fpsTracker.js'; type AppWrapperProps = { - getFpsMetrics: () => FpsMetrics | undefined - stats?: StatsStore - initialState: AppState -} + 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/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 d8de3714a..a44c14404 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 '../components/design-system/Pane.js' -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 '../ink.js' -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 '../components/design-system/Pane.js'; +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 '../ink.js'; +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 -} - -function DistTagsDisplay({ - promise, -}: { - promise: Promise -}): React.ReactNode { - const distTags = use(promise) + enabled: boolean; + locks: LockInfo[]; + locksDir: string; + staleLocksCleaned: number; +}; + +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/REPL.tsx b/src/screens/REPL.tsx index fc512d8a9..e3e52b667 100644 --- a/src/screens/REPL.tsx +++ b/src/screens/REPL.tsx @@ -1,40 +1,32 @@ // biome-ignore-all assist/source/organizeImports: ANT-ONLY import markers must not be reordered -import { feature } from 'bun:bundle' -import { spawnSync } from 'child_process' +import { feature } from 'bun:bundle'; +import { spawnSync } from 'child_process'; import { snapshotOutputTokensForTurn, getCurrentTurnTokenBudget, getTurnOutputTokens, getBudgetContinuationCount, getTotalInputTokens, -} from '../bootstrap/state.js' -import { parseTokenBudget } from '../utils/tokenBudget.js' -import { count } from '../utils/array.js' -import { dirname, join } from 'path' -import { tmpdir } from 'os' -import figures from 'figures' +} from '../bootstrap/state.js'; +import { parseTokenBudget } from '../utils/tokenBudget.js'; +import { count } from '../utils/array.js'; +import { dirname, join } from 'path'; +import { tmpdir } from 'os'; +import figures from 'figures'; // eslint-disable-next-line custom-rules/prefer-use-keybindings -- / n N Esc [ v are bare letters in transcript modal context, same class as g/G/j/k in ScrollKeybindingHandler -import { useInput } from '../ink.js' -import { useSearchInput } from '../hooks/useSearchInput.js' -import { useTerminalSize } from '../hooks/useTerminalSize.js' -import { useSearchHighlight } from '../ink/hooks/use-search-highlight.js' -import type { JumpHandle } from '../components/VirtualMessageList.js' -import { renderMessagesToPlainText } from '../utils/exportRenderer.js' -import { openFileInExternalEditor } from '../utils/editor.js' -import { writeFile } from 'fs/promises' -import { - Box, - Text, - useStdin, - useTheme, - useTerminalFocus, - useTerminalTitle, - useTabStatus, -} from '../ink.js' -import type { TabStatusKind } from '../ink/hooks/use-tab-status.js' -import { CostThresholdDialog } from '../components/CostThresholdDialog.js' -import { IdleReturnDialog } from '../components/IdleReturnDialog.js' -import * as React from 'react' +import { useInput } from '../ink.js'; +import { useSearchInput } from '../hooks/useSearchInput.js'; +import { useTerminalSize } from '../hooks/useTerminalSize.js'; +import { useSearchHighlight } from '../ink/hooks/use-search-highlight.js'; +import type { JumpHandle } from '../components/VirtualMessageList.js'; +import { renderMessagesToPlainText } from '../utils/exportRenderer.js'; +import { openFileInExternalEditor } from '../utils/editor.js'; +import { writeFile } from 'fs/promises'; +import { Box, Text, useStdin, useTheme, useTerminalFocus, useTerminalTitle, useTabStatus } from '../ink.js'; +import type { TabStatusKind } from '../ink/hooks/use-tab-status.js'; +import { CostThresholdDialog } from '../components/CostThresholdDialog.js'; +import { IdleReturnDialog } from '../components/IdleReturnDialog.js'; +import * as React from 'react'; import { useEffect, useMemo, @@ -44,20 +36,17 @@ import { useDeferredValue, useLayoutEffect, type RefObject, -} from 'react' -import { useNotifications } from '../context/notifications.js' -import { sendNotification } from '../services/notifier.js' -import { - startPreventSleep, - stopPreventSleep, -} from '../services/preventSleep.js' -import { useTerminalNotification } from '../ink/useTerminalNotification.js' -import { hasCursorUpViewportYankBug } from '../ink/terminal.js' +} from 'react'; +import { useNotifications } from '../context/notifications.js'; +import { sendNotification } from '../services/notifier.js'; +import { startPreventSleep, stopPreventSleep } from '../services/preventSleep.js'; +import { useTerminalNotification } from '../ink/useTerminalNotification.js'; +import { hasCursorUpViewportYankBug } from '../ink/terminal.js'; import { createFileStateCacheWithSizeLimit, mergeFileStateCaches, READ_FILE_STATE_CACHE_SIZE, -} from '../utils/fileStateCache.js' +} from '../utils/fileStateCache.js'; import { updateLastInteractionTime, getLastInteractionTime, @@ -75,185 +64,155 @@ import { getTurnClassifierDurationMs, getTurnClassifierCount, resetTurnClassifierDuration, -} from '../bootstrap/state.js' -import { asSessionId, asAgentId } from '../types/ids.js' -import { logForDebugging } from '../utils/debug.js' -import { QueryGuard } from '../utils/QueryGuard.js' -import { isEnvTruthy } from '../utils/envUtils.js' -import { formatTokens, truncateToWidth } from '../utils/format.js' -import { consumeEarlyInput } from '../utils/earlyInput.js' - -import { setMemberActive } from '../utils/swarm/teamHelpers.js' +} from '../bootstrap/state.js'; +import { asSessionId, asAgentId } from '../types/ids.js'; +import { logForDebugging } from '../utils/debug.js'; +import { QueryGuard } from '../utils/QueryGuard.js'; +import { isEnvTruthy } from '../utils/envUtils.js'; +import { formatTokens, truncateToWidth } from '../utils/format.js'; +import { consumeEarlyInput } from '../utils/earlyInput.js'; + +import { setMemberActive } from '../utils/swarm/teamHelpers.js'; import { isSwarmWorker, generateSandboxRequestId, sendSandboxPermissionRequestViaMailbox, sendSandboxPermissionResponseViaMailbox, -} from '../utils/swarm/permissionSync.js' -import { registerSandboxPermissionCallback } from '../hooks/useSwarmPermissionPoller.js' -import { getTeamName, getAgentName } from '../utils/teammate.js' -import { WorkerPendingPermission } from '../components/permissions/WorkerPendingPermission.js' +} from '../utils/swarm/permissionSync.js'; +import { registerSandboxPermissionCallback } from '../hooks/useSwarmPermissionPoller.js'; +import { getTeamName, getAgentName } from '../utils/teammate.js'; +import { WorkerPendingPermission } from '../components/permissions/WorkerPendingPermission.js'; import { injectUserMessageToTeammate, getAllInProcessTeammateTasks, -} from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js' +} from '../tasks/InProcessTeammateTask/InProcessTeammateTask.js'; import { isLocalAgentTask, queuePendingMessage, appendMessageToLocalAgent, type LocalAgentTaskState, -} from '../tasks/LocalAgentTask/LocalAgentTask.js' +} from '../tasks/LocalAgentTask/LocalAgentTask.js'; import { registerLeaderToolUseConfirmQueue, unregisterLeaderToolUseConfirmQueue, registerLeaderSetToolPermissionContext, unregisterLeaderSetToolPermissionContext, -} from '../utils/swarm/leaderPermissionBridge.js' -import { endInteractionSpan } from '../utils/telemetry/sessionTracing.js' -import { useLogMessages } from '../hooks/useLogMessages.js' -import { useReplBridge } from '../hooks/useReplBridge.js' +} from '../utils/swarm/leaderPermissionBridge.js'; +import { endInteractionSpan } from '../utils/telemetry/sessionTracing.js'; +import { useLogMessages } from '../hooks/useLogMessages.js'; +import { useReplBridge } from '../hooks/useReplBridge.js'; import { type Command, type CommandResultDisplay, type ResumeEntrypoint, getCommandName, isCommandEnabled, -} from '../commands.js' -import type { - PromptInputMode, - QueuedCommand, - VimMode, -} from '../types/textInputTypes.js' +} from '../commands.js'; +import type { PromptInputMode, QueuedCommand, VimMode } from '../types/textInputTypes.js'; import { MessageSelector, selectableUserMessagesFilter, messagesAfterAreOnlySynthetic, -} from '../components/MessageSelector.js' -import { useIdeLogging } from '../hooks/useIdeLogging.js' -import { - PermissionRequest, - type ToolUseConfirm, -} from '../components/permissions/PermissionRequest.js' -import { ElicitationDialog } from '../components/mcp/ElicitationDialog.js' -import { PromptDialog } from '../components/hooks/PromptDialog.js' -import type { PromptRequest, PromptResponse } from '../types/hooks.js' -import PromptInput from '../components/PromptInput/PromptInput.js' -import { PromptInputQueuedCommands } from '../components/PromptInput/PromptInputQueuedCommands.js' -import { useRemoteSession } from '../hooks/useRemoteSession.js' -import { useDirectConnect } from '../hooks/useDirectConnect.js' -import type { DirectConnectConfig } from '../server/directConnectManager.js' -import { useSSHSession } from '../hooks/useSSHSession.js' -import { useAssistantHistory } from '../hooks/useAssistantHistory.js' -import type { SSHSession } from '../ssh/createSSHSession.js' -import { SkillImprovementSurvey } from '../components/SkillImprovementSurvey.js' -import { useSkillImprovementSurvey } from '../hooks/useSkillImprovementSurvey.js' -import { useMoreRight } from '../moreright/useMoreRight.js' -import { - SpinnerWithVerb, - BriefIdleStatus, - type SpinnerMode, -} from '../components/Spinner.js' -import { getSystemPrompt } from '../constants/prompts.js' -import { buildEffectiveSystemPrompt } from '../utils/systemPrompt.js' -import { getSystemContext, getUserContext } from '../context.js' -import { getMemoryFiles } from '../utils/claudemd.js' -import { startBackgroundHousekeeping } from '../utils/backgroundHousekeeping.js' -import { - getTotalCost, - saveCurrentSessionCosts, - resetCostState, - getStoredSessionCosts, -} from '../cost-tracker.js' -import { useCostSummary } from '../costHook.js' -import { useFpsMetrics } from '../context/fpsMetrics.js' -import { useAfterFirstRender } from '../hooks/useAfterFirstRender.js' -import { useDeferredHookMessages } from '../hooks/useDeferredHookMessages.js' -import { - addToHistory, - removeLastFromHistory, - expandPastedTextRefs, - parseReferences, -} from '../history.js' -import { prependModeCharacterToInput } from '../components/PromptInput/inputModes.js' -import { prependToShellHistoryCache } from '../utils/suggestions/shellHistoryCompletion.js' -import { useApiKeyVerification } from '../hooks/useApiKeyVerification.js' -import { GlobalKeybindingHandlers } from '../hooks/useGlobalKeybindings.js' -import { CommandKeybindingHandlers } from '../hooks/useCommandKeybindings.js' -import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js' -import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js' -import { getShortcutDisplay } from '../keybindings/shortcutFormat.js' -import { CancelRequestHandler } from '../hooks/useCancelRequest.js' -import { useBackgroundTaskNavigation } from '../hooks/useBackgroundTaskNavigation.js' -import { useSwarmInitialization } from '../hooks/useSwarmInitialization.js' -import { useTeammateViewAutoExit } from '../hooks/useTeammateViewAutoExit.js' -import { errorMessage } from '../utils/errors.js' -import { isHumanTurn } from '../utils/messagePredicates.js' -import { logError } from '../utils/log.js' +} from '../components/MessageSelector.js'; +import { useIdeLogging } from '../hooks/useIdeLogging.js'; +import { PermissionRequest, type ToolUseConfirm } from '../components/permissions/PermissionRequest.js'; +import { ElicitationDialog } from '../components/mcp/ElicitationDialog.js'; +import { PromptDialog } from '../components/hooks/PromptDialog.js'; +import type { PromptRequest, PromptResponse } from '../types/hooks.js'; +import PromptInput from '../components/PromptInput/PromptInput.js'; +import { PromptInputQueuedCommands } from '../components/PromptInput/PromptInputQueuedCommands.js'; +import { useRemoteSession } from '../hooks/useRemoteSession.js'; +import { useDirectConnect } from '../hooks/useDirectConnect.js'; +import type { DirectConnectConfig } from '../server/directConnectManager.js'; +import { useSSHSession } from '../hooks/useSSHSession.js'; +import { useAssistantHistory } from '../hooks/useAssistantHistory.js'; +import type { SSHSession } from '../ssh/createSSHSession.js'; +import { SkillImprovementSurvey } from '../components/SkillImprovementSurvey.js'; +import { useSkillImprovementSurvey } from '../hooks/useSkillImprovementSurvey.js'; +import { useMoreRight } from '../moreright/useMoreRight.js'; +import { SpinnerWithVerb, BriefIdleStatus, type SpinnerMode } from '../components/Spinner.js'; +import { getSystemPrompt } from '../constants/prompts.js'; +import { buildEffectiveSystemPrompt } from '../utils/systemPrompt.js'; +import { getSystemContext, getUserContext } from '../context.js'; +import { getMemoryFiles } from '../utils/claudemd.js'; +import { startBackgroundHousekeeping } from '../utils/backgroundHousekeeping.js'; +import { getTotalCost, saveCurrentSessionCosts, resetCostState, getStoredSessionCosts } from '../cost-tracker.js'; +import { useCostSummary } from '../costHook.js'; +import { useFpsMetrics } from '../context/fpsMetrics.js'; +import { useAfterFirstRender } from '../hooks/useAfterFirstRender.js'; +import { useDeferredHookMessages } from '../hooks/useDeferredHookMessages.js'; +import { addToHistory, removeLastFromHistory, expandPastedTextRefs, parseReferences } from '../history.js'; +import { prependModeCharacterToInput } from '../components/PromptInput/inputModes.js'; +import { prependToShellHistoryCache } from '../utils/suggestions/shellHistoryCompletion.js'; +import { useApiKeyVerification } from '../hooks/useApiKeyVerification.js'; +import { GlobalKeybindingHandlers } from '../hooks/useGlobalKeybindings.js'; +import { CommandKeybindingHandlers } from '../hooks/useCommandKeybindings.js'; +import { KeybindingSetup } from '../keybindings/KeybindingProviderSetup.js'; +import { useShortcutDisplay } from '../keybindings/useShortcutDisplay.js'; +import { getShortcutDisplay } from '../keybindings/shortcutFormat.js'; +import { CancelRequestHandler } from '../hooks/useCancelRequest.js'; +import { useBackgroundTaskNavigation } from '../hooks/useBackgroundTaskNavigation.js'; +import { useSwarmInitialization } from '../hooks/useSwarmInitialization.js'; +import { useTeammateViewAutoExit } from '../hooks/useTeammateViewAutoExit.js'; +import { errorMessage } from '../utils/errors.js'; +import { isHumanTurn } from '../utils/messagePredicates.js'; +import { logError } from '../utils/log.js'; // Dead code elimination: conditional imports /* eslint-disable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */ -const useVoiceIntegration: typeof import('../hooks/useVoiceIntegration.js').useVoiceIntegration = - feature('VOICE_MODE') - ? require('../hooks/useVoiceIntegration.js').useVoiceIntegration - : () => ({ - stripTrailing: () => 0, - handleKeyEvent: () => {}, - resetAnchor: () => {}, - }) -const VoiceKeybindingHandler: typeof import('../hooks/useVoiceIntegration.js').VoiceKeybindingHandler = - feature('VOICE_MODE') - ? require('../hooks/useVoiceIntegration.js').VoiceKeybindingHandler - : () => null +const useVoiceIntegration: typeof import('../hooks/useVoiceIntegration.js').useVoiceIntegration = feature('VOICE_MODE') + ? require('../hooks/useVoiceIntegration.js').useVoiceIntegration + : () => ({ + stripTrailing: () => 0, + handleKeyEvent: () => {}, + resetAnchor: () => {}, + }); +const VoiceKeybindingHandler: typeof import('../hooks/useVoiceIntegration.js').VoiceKeybindingHandler = feature( + 'VOICE_MODE', +) + ? require('../hooks/useVoiceIntegration.js').VoiceKeybindingHandler + : () => null; // Frustration detection is ant-only (dogfooding). Conditional require so external // builds eliminate the module entirely (including its two O(n) useMemos that run // on every messages change, plus the GrowthBook fetch). const useFrustrationDetection: typeof import('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection = process.env.USER_TYPE === 'ant' - ? require('../components/FeedbackSurvey/useFrustrationDetection.js') - .useFrustrationDetection - : () => ({ state: 'closed', handleTranscriptSelect: () => {} }) + ? require('../components/FeedbackSurvey/useFrustrationDetection.js').useFrustrationDetection + : () => ({ state: 'closed', handleTranscriptSelect: () => {} }); // Ant-only org warning. Conditional require so the org UUID list is // eliminated from external builds (one UUID is on excluded-strings). const useAntOrgWarningNotification: typeof import('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification = process.env.USER_TYPE === 'ant' - ? require('../hooks/notifs/useAntOrgWarningNotification.js') - .useAntOrgWarningNotification - : () => {} + ? require('../hooks/notifs/useAntOrgWarningNotification.js').useAntOrgWarningNotification + : () => {}; // Dead code elimination: conditional import for coordinator mode const getCoordinatorUserContext: ( mcpClients: ReadonlyArray<{ name: string }>, scratchpadDir?: string, ) => { [k: string]: string } = feature('COORDINATOR_MODE') ? require('../coordinator/coordinatorMode.js').getCoordinatorUserContext - : () => ({}) + : () => ({}); /* eslint-enable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */ -import useCanUseTool from '../hooks/useCanUseTool.js' -import type { ToolPermissionContext, Tool } from '../Tool.js' +import useCanUseTool from '../hooks/useCanUseTool.js'; +import type { ToolPermissionContext, Tool } from '../Tool.js'; import { applyPermissionUpdate, applyPermissionUpdates, persistPermissionUpdate, -} from '../utils/permissions/PermissionUpdate.js' -import { buildPermissionUpdates } from '../components/permissions/ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.js' -import { stripDangerousPermissionsForAutoMode } from '../utils/permissions/permissionSetup.js' -import { - getScratchpadDir, - isScratchpadEnabled, -} from '../utils/permissions/filesystem.js' -import { WEB_FETCH_TOOL_NAME } from '../tools/WebFetchTool/prompt.js' -import { SLEEP_TOOL_NAME } from '../tools/SleepTool/prompt.js' -import { clearSpeculativeChecks } from '../tools/BashTool/bashPermissions.js' -import type { AutoUpdaterResult } from '../utils/autoUpdater.js' -import { - getGlobalConfig, - saveGlobalConfig, - getGlobalConfigWriteCount, -} from '../utils/config.js' -import { hasConsoleBillingAccess } from '../utils/billing.js' +} from '../utils/permissions/PermissionUpdate.js'; +import { buildPermissionUpdates } from '../components/permissions/ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.js'; +import { stripDangerousPermissionsForAutoMode } from '../utils/permissions/permissionSetup.js'; +import { getScratchpadDir, isScratchpadEnabled } from '../utils/permissions/filesystem.js'; +import { WEB_FETCH_TOOL_NAME } from '../tools/WebFetchTool/prompt.js'; +import { SLEEP_TOOL_NAME } from '../tools/SleepTool/prompt.js'; +import { clearSpeculativeChecks } from '../tools/BashTool/bashPermissions.js'; +import type { AutoUpdaterResult } from '../utils/autoUpdater.js'; +import { getGlobalConfig, saveGlobalConfig, getGlobalConfigWriteCount } from '../utils/config.js'; +import { hasConsoleBillingAccess } from '../utils/billing.js'; import { logEvent, type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, -} from 'src/services/analytics/index.js' -import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js' +} from 'src/services/analytics/index.js'; +import { getFeatureValue_CACHED_MAY_BE_STALE } from 'src/services/analytics/growthbook.js'; import { textForResubmit, handleMessageFromStream, @@ -270,78 +229,52 @@ import { createSystemMessage, createCommandInputMessage, formatCommandInputTags, -} from '../utils/messages.js' -import { generateSessionTitle } from '../utils/sessionTitle.js' -import { - BASH_INPUT_TAG, - COMMAND_MESSAGE_TAG, - COMMAND_NAME_TAG, - LOCAL_COMMAND_STDOUT_TAG, -} from '../constants/xml.js' -import { escapeXml } from '../utils/xml.js' -import type { ThinkingConfig } from '../utils/thinking.js' -import { gracefulShutdownSync } from '../utils/gracefulShutdown.js' -import { - handlePromptSubmit, - type PromptInputHelpers, -} from '../utils/handlePromptSubmit.js' -import { useQueueProcessor } from '../hooks/useQueueProcessor.js' -import { useMailboxBridge } from '../hooks/useMailboxBridge.js' -import { - queryCheckpoint, - logQueryProfileReport, -} from '../utils/queryProfiler.js' +} from '../utils/messages.js'; +import { generateSessionTitle } from '../utils/sessionTitle.js'; +import { BASH_INPUT_TAG, COMMAND_MESSAGE_TAG, COMMAND_NAME_TAG, LOCAL_COMMAND_STDOUT_TAG } from '../constants/xml.js'; +import { escapeXml } from '../utils/xml.js'; +import type { ThinkingConfig } from '../utils/thinking.js'; +import { gracefulShutdownSync } from '../utils/gracefulShutdown.js'; +import { handlePromptSubmit, type PromptInputHelpers } from '../utils/handlePromptSubmit.js'; +import { useQueueProcessor } from '../hooks/useQueueProcessor.js'; +import { useMailboxBridge } from '../hooks/useMailboxBridge.js'; +import { queryCheckpoint, logQueryProfileReport } from '../utils/queryProfiler.js'; import type { Message as MessageType, UserMessage, ProgressMessage, HookResultMessage, PartialCompactDirection, -} from '../types/message.js' -import { query } from '../query.js' -import { mergeClients, useMergedClients } from '../hooks/useMergedClients.js' -import { getQuerySourceForREPL } from '../utils/promptCategory.js' -import { useMergedTools } from '../hooks/useMergedTools.js' -import { mergeAndFilterTools } from '../utils/toolPool.js' -import { useMergedCommands } from '../hooks/useMergedCommands.js' -import { useSkillsChange } from '../hooks/useSkillsChange.js' -import { useManagePlugins } from '../hooks/useManagePlugins.js' -import { Messages } from '../components/Messages.js' -import { TaskListV2 } from '../components/TaskListV2.js' -import { TeammateViewHeader } from '../components/TeammateViewHeader.js' -import { useTasksV2WithCollapseEffect } from '../hooks/useTasksV2.js' -import { maybeMarkProjectOnboardingComplete } from '../projectOnboardingState.js' -import type { MCPServerConnection } from '../services/mcp/types.js' -import type { ScopedMcpServerConfig } from '../services/mcp/types.js' -import { randomUUID, type UUID } from 'crypto' -import { processSessionStartHooks } from '../utils/sessionStart.js' -import { - executeSessionEndHooks, - getSessionEndHookTimeoutMs, -} from '../utils/hooks.js' -import { type IDESelection, useIdeSelection } from '../hooks/useIdeSelection.js' -import { getTools, assembleToolPool } from '../tools.js' -import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js' -import { resolveAgentTools } from '../tools/AgentTool/agentToolUtils.js' -import { resumeAgentBackground } from '../tools/AgentTool/resumeAgent.js' -import { useMainLoopModel } from '../hooks/useMainLoopModel.js' -import { - useAppState, - useSetAppState, - useAppStateStore, -} from '../state/AppState.js' -import type { - ContentBlockParam, - ImageBlockParam, -} from '@anthropic-ai/sdk/resources/messages.mjs' -import type { ProcessUserInputContext } from '../utils/processUserInput/processUserInput.js' -import type { PastedContent } from '../utils/config.js' -import { - copyPlanForFork, - copyPlanForResume, - getPlanSlug, - setPlanSlug, -} from '../utils/plans.js' +} from '../types/message.js'; +import { query } from '../query.js'; +import { mergeClients, useMergedClients } from '../hooks/useMergedClients.js'; +import { getQuerySourceForREPL } from '../utils/promptCategory.js'; +import { useMergedTools } from '../hooks/useMergedTools.js'; +import { mergeAndFilterTools } from '../utils/toolPool.js'; +import { useMergedCommands } from '../hooks/useMergedCommands.js'; +import { useSkillsChange } from '../hooks/useSkillsChange.js'; +import { useManagePlugins } from '../hooks/useManagePlugins.js'; +import { Messages } from '../components/Messages.js'; +import { TaskListV2 } from '../components/TaskListV2.js'; +import { TeammateViewHeader } from '../components/TeammateViewHeader.js'; +import { useTasksV2WithCollapseEffect } from '../hooks/useTasksV2.js'; +import { maybeMarkProjectOnboardingComplete } from '../projectOnboardingState.js'; +import type { MCPServerConnection } from '../services/mcp/types.js'; +import type { ScopedMcpServerConfig } from '../services/mcp/types.js'; +import { randomUUID, type UUID } from 'crypto'; +import { processSessionStartHooks } from '../utils/sessionStart.js'; +import { executeSessionEndHooks, getSessionEndHookTimeoutMs } from '../utils/hooks.js'; +import { type IDESelection, useIdeSelection } from '../hooks/useIdeSelection.js'; +import { getTools, assembleToolPool } from '../tools.js'; +import type { AgentDefinition } from '../tools/AgentTool/loadAgentsDir.js'; +import { resolveAgentTools } from '../tools/AgentTool/agentToolUtils.js'; +import { resumeAgentBackground } from '../tools/AgentTool/resumeAgent.js'; +import { useMainLoopModel } from '../hooks/useMainLoopModel.js'; +import { useAppState, useSetAppState, useAppStateStore } from '../state/AppState.js'; +import type { ContentBlockParam, ImageBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'; +import type { ProcessUserInputContext } from '../utils/processUserInput/processUserInput.js'; +import type { PastedContent } from '../utils/config.js'; +import { copyPlanForFork, copyPlanForResume, getPlanSlug, setPlanSlug } from '../utils/plans.js'; import { clearSessionMetadata, resetSessionFilePointer, @@ -353,22 +286,19 @@ import { isLoggableMessage, saveWorktreeState, getAgentTranscript, -} from '../utils/sessionStorage.js' -import { deserializeMessages } from '../utils/conversationRecovery.js' -import { - extractReadFilesFromMessages, - extractBashToolsFromMessages, -} from '../utils/queryHelpers.js' -import { resetMicrocompactState } from '../services/compact/microCompact.js' -import { runPostCompactCleanup } from '../services/compact/postCompactCleanup.js' +} from '../utils/sessionStorage.js'; +import { deserializeMessages } from '../utils/conversationRecovery.js'; +import { extractReadFilesFromMessages, extractBashToolsFromMessages } from '../utils/queryHelpers.js'; +import { resetMicrocompactState } from '../services/compact/microCompact.js'; +import { runPostCompactCleanup } from '../services/compact/postCompactCleanup.js'; import { provisionContentReplacementState, reconstructContentReplacementState, type ContentReplacementRecord, -} from '../utils/toolResultStorage.js' -import { partialCompactConversation } from '../services/compact/compact.js' -import type { LogOption } from '../types/logs.js' -import type { AgentColorName } from '../tools/AgentTool/agentColorManager.js' +} from '../utils/toolResultStorage.js'; +import { partialCompactConversation } from '../services/compact/compact.js'; +import type { LogOption } from '../types/logs.js'; +import type { AgentColorName } from '../tools/AgentTool/agentColorManager.js'; import { fileHistoryMakeSnapshot, type FileHistoryState, @@ -377,64 +307,44 @@ import { copyFileHistoryForResume, fileHistoryEnabled, fileHistoryHasAnyChanges, -} from '../utils/fileHistory.js' -import { - type AttributionState, - incrementPromptCount, -} from '../utils/commitAttribution.js' -import { recordAttributionSnapshot } from '../utils/sessionStorage.js' +} from '../utils/fileHistory.js'; +import { type AttributionState, incrementPromptCount } from '../utils/commitAttribution.js'; +import { recordAttributionSnapshot } from '../utils/sessionStorage.js'; import { computeStandaloneAgentContext, restoreAgentFromSession, restoreSessionStateFromLog, restoreWorktreeForResume, exitRestoredWorktree, -} from '../utils/sessionRestore.js' -import { - isBgSession, - updateSessionName, - updateSessionActivity, -} from '../utils/concurrentSessions.js' -import { - isInProcessTeammateTask, - type InProcessTeammateTaskState, -} from '../tasks/InProcessTeammateTask/types.js' -import { restoreRemoteAgentTasks } from '../tasks/RemoteAgentTask/RemoteAgentTask.js' -import { useInboxPoller } from '../hooks/useInboxPoller.js' +} from '../utils/sessionRestore.js'; +import { isBgSession, updateSessionName, updateSessionActivity } from '../utils/concurrentSessions.js'; +import { isInProcessTeammateTask, type InProcessTeammateTaskState } from '../tasks/InProcessTeammateTask/types.js'; +import { restoreRemoteAgentTasks } from '../tasks/RemoteAgentTask/RemoteAgentTask.js'; +import { useInboxPoller } from '../hooks/useInboxPoller.js'; // Dead code elimination: conditional import for loop mode /* eslint-disable @typescript-eslint/no-require-imports */ -const proactiveModule = - feature('PROACTIVE') || feature('KAIROS') - ? require('../proactive/index.js') - : null -const PROACTIVE_NO_OP_SUBSCRIBE = (_cb: () => void) => () => {} -const PROACTIVE_FALSE = () => false -const SUGGEST_BG_PR_NOOP = (_p: string, _n: string): boolean => false +const proactiveModule = feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/index.js') : null; +const PROACTIVE_NO_OP_SUBSCRIBE = (_cb: () => void) => () => {}; +const PROACTIVE_FALSE = () => false; +const SUGGEST_BG_PR_NOOP = (_p: string, _n: string): boolean => false; const useProactive = - feature('PROACTIVE') || feature('KAIROS') - ? require('../proactive/useProactive.js').useProactive - : null -const useScheduledTasks = feature('AGENT_TRIGGERS') - ? require('../hooks/useScheduledTasks.js').useScheduledTasks - : null + feature('PROACTIVE') || feature('KAIROS') ? require('../proactive/useProactive.js').useProactive : null; +const useScheduledTasks = feature('AGENT_TRIGGERS') ? require('../hooks/useScheduledTasks.js').useScheduledTasks : null; /* eslint-enable @typescript-eslint/no-require-imports */ -import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js' -import { useTaskListWatcher } from '../hooks/useTaskListWatcher.js' -import type { - SandboxAskCallback, - NetworkHostPattern, -} from '../utils/sandbox/sandbox-adapter.js' +import { isAgentSwarmsEnabled } from '../utils/agentSwarmsEnabled.js'; +import { useTaskListWatcher } from '../hooks/useTaskListWatcher.js'; +import type { SandboxAskCallback, NetworkHostPattern } from '../utils/sandbox/sandbox-adapter.js'; import { type IDEExtensionInstallationStatus, closeOpenDiffs, getConnectedIdeClient, type IdeType, -} from '../utils/ide.js' -import { useIDEIntegration } from '../hooks/useIDEIntegration.js' -import exit from '../commands/exit/index.js' -import { ExitFlow } from '../components/ExitFlow.js' -import { getCurrentWorktreeSession } from '../utils/worktree.js' +} from '../utils/ide.js'; +import { useIDEIntegration } from '../hooks/useIDEIntegration.js'; +import exit from '../commands/exit/index.js'; +import { ExitFlow } from '../components/ExitFlow.js'; +import { getCurrentWorktreeSession } from '../utils/worktree.js'; import { popAllEditable, enqueue, @@ -442,130 +352,104 @@ import { getCommandQueue, getCommandQueueLength, removeByFilter, -} from '../utils/messageQueueManager.js' -import { useCommandQueue } from '../hooks/useCommandQueue.js' -import { SessionBackgroundHint } from '../components/SessionBackgroundHint.js' -import { startBackgroundSession } from '../tasks/LocalMainSessionTask.js' -import { useSessionBackgrounding } from '../hooks/useSessionBackgrounding.js' -import { diagnosticTracker } from '../services/diagnosticTracking.js' -import { - handleSpeculationAccept, - type ActiveSpeculationState, -} from '../services/PromptSuggestion/speculation.js' -import { IdeOnboardingDialog } from '../components/IdeOnboardingDialog.js' -import { - EffortCallout, - shouldShowEffortCallout, -} from '../components/EffortCallout.js' -import type { EffortValue } from '../utils/effort.js' -import { RemoteCallout } from '../components/RemoteCallout.js' +} from '../utils/messageQueueManager.js'; +import { useCommandQueue } from '../hooks/useCommandQueue.js'; +import { SessionBackgroundHint } from '../components/SessionBackgroundHint.js'; +import { startBackgroundSession } from '../tasks/LocalMainSessionTask.js'; +import { useSessionBackgrounding } from '../hooks/useSessionBackgrounding.js'; +import { diagnosticTracker } from '../services/diagnosticTracking.js'; +import { handleSpeculationAccept, type ActiveSpeculationState } from '../services/PromptSuggestion/speculation.js'; +import { IdeOnboardingDialog } from '../components/IdeOnboardingDialog.js'; +import { EffortCallout, shouldShowEffortCallout } from '../components/EffortCallout.js'; +import type { EffortValue } from '../utils/effort.js'; +import { RemoteCallout } from '../components/RemoteCallout.js'; /* eslint-disable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */ const AntModelSwitchCallout = - process.env.USER_TYPE === 'ant' - ? require('../components/AntModelSwitchCallout.js').AntModelSwitchCallout - : null + process.env.USER_TYPE === 'ant' ? require('../components/AntModelSwitchCallout.js').AntModelSwitchCallout : null; const shouldShowAntModelSwitch = process.env.USER_TYPE === 'ant' - ? require('../components/AntModelSwitchCallout.js') - .shouldShowModelSwitchCallout - : (): boolean => false + ? require('../components/AntModelSwitchCallout.js').shouldShowModelSwitchCallout + : (): boolean => false; const UndercoverAutoCallout = - process.env.USER_TYPE === 'ant' - ? require('../components/UndercoverAutoCallout.js').UndercoverAutoCallout - : null + process.env.USER_TYPE === 'ant' ? require('../components/UndercoverAutoCallout.js').UndercoverAutoCallout : null; /* eslint-enable custom-rules/no-process-env-top-level, @typescript-eslint/no-require-imports */ -import { activityManager } from '../utils/activityManager.js' -import { createAbortController } from '../utils/abortController.js' -import { MCPConnectionManager } from 'src/services/mcp/MCPConnectionManager.js' -import { useFeedbackSurvey } from 'src/components/FeedbackSurvey/useFeedbackSurvey.js' -import { useMemorySurvey } from 'src/components/FeedbackSurvey/useMemorySurvey.js' -import { usePostCompactSurvey } from 'src/components/FeedbackSurvey/usePostCompactSurvey.js' -import { FeedbackSurvey } from 'src/components/FeedbackSurvey/FeedbackSurvey.js' -import { useInstallMessages } from 'src/hooks/notifs/useInstallMessages.js' -import { useAwaySummary } from 'src/hooks/useAwaySummary.js' -import { useChromeExtensionNotification } from 'src/hooks/useChromeExtensionNotification.js' -import { useOfficialMarketplaceNotification } from 'src/hooks/useOfficialMarketplaceNotification.js' -import { usePromptsFromClaudeInChrome } from 'src/hooks/usePromptsFromClaudeInChrome.js' -import { - getTipToShowOnSpinner, - recordShownTip, -} from 'src/services/tips/tipScheduler.js' -import type { Theme } from 'src/utils/theme.js' +import { activityManager } from '../utils/activityManager.js'; +import { createAbortController } from '../utils/abortController.js'; +import { MCPConnectionManager } from 'src/services/mcp/MCPConnectionManager.js'; +import { useFeedbackSurvey } from 'src/components/FeedbackSurvey/useFeedbackSurvey.js'; +import { useMemorySurvey } from 'src/components/FeedbackSurvey/useMemorySurvey.js'; +import { usePostCompactSurvey } from 'src/components/FeedbackSurvey/usePostCompactSurvey.js'; +import { FeedbackSurvey } from 'src/components/FeedbackSurvey/FeedbackSurvey.js'; +import { useInstallMessages } from 'src/hooks/notifs/useInstallMessages.js'; +import { useAwaySummary } from 'src/hooks/useAwaySummary.js'; +import { useChromeExtensionNotification } from 'src/hooks/useChromeExtensionNotification.js'; +import { useOfficialMarketplaceNotification } from 'src/hooks/useOfficialMarketplaceNotification.js'; +import { usePromptsFromClaudeInChrome } from 'src/hooks/usePromptsFromClaudeInChrome.js'; +import { getTipToShowOnSpinner, recordShownTip } from 'src/services/tips/tipScheduler.js'; +import type { Theme } from 'src/utils/theme.js'; import { checkAndDisableBypassPermissionsIfNeeded, checkAndDisableAutoModeIfNeeded, useKickOffCheckAndDisableBypassPermissionsIfNeeded, useKickOffCheckAndDisableAutoModeIfNeeded, -} from 'src/utils/permissions/bypassPermissionsKillswitch.js' -import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js' -import { SANDBOX_NETWORK_ACCESS_TOOL_NAME } from 'src/cli/structuredIO.js' -import { useFileHistorySnapshotInit } from 'src/hooks/useFileHistorySnapshotInit.js' -import { SandboxPermissionRequest } from 'src/components/permissions/SandboxPermissionRequest.js' -import { SandboxViolationExpandedView } from 'src/components/SandboxViolationExpandedView.js' -import { useSettingsErrors } from 'src/hooks/notifs/useSettingsErrors.js' -import { useMcpConnectivityStatus } from 'src/hooks/notifs/useMcpConnectivityStatus.js' -import { useAutoModeUnavailableNotification } from 'src/hooks/notifs/useAutoModeUnavailableNotification.js' -import { AUTO_MODE_DESCRIPTION } from 'src/components/AutoModeOptInDialog.js' -import { useLspInitializationNotification } from 'src/hooks/notifs/useLspInitializationNotification.js' -import { useLspPluginRecommendation } from 'src/hooks/useLspPluginRecommendation.js' -import { LspRecommendationMenu } from 'src/components/LspRecommendation/LspRecommendationMenu.js' -import { useClaudeCodeHintRecommendation } from 'src/hooks/useClaudeCodeHintRecommendation.js' -import { PluginHintMenu } from 'src/components/ClaudeCodeHint/PluginHintMenu.js' +} from 'src/utils/permissions/bypassPermissionsKillswitch.js'; +import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js'; +import { SANDBOX_NETWORK_ACCESS_TOOL_NAME } from 'src/cli/structuredIO.js'; +import { useFileHistorySnapshotInit } from 'src/hooks/useFileHistorySnapshotInit.js'; +import { SandboxPermissionRequest } from 'src/components/permissions/SandboxPermissionRequest.js'; +import { SandboxViolationExpandedView } from 'src/components/SandboxViolationExpandedView.js'; +import { useSettingsErrors } from 'src/hooks/notifs/useSettingsErrors.js'; +import { useMcpConnectivityStatus } from 'src/hooks/notifs/useMcpConnectivityStatus.js'; +import { useAutoModeUnavailableNotification } from 'src/hooks/notifs/useAutoModeUnavailableNotification.js'; +import { AUTO_MODE_DESCRIPTION } from 'src/components/AutoModeOptInDialog.js'; +import { useLspInitializationNotification } from 'src/hooks/notifs/useLspInitializationNotification.js'; +import { useLspPluginRecommendation } from 'src/hooks/useLspPluginRecommendation.js'; +import { LspRecommendationMenu } from 'src/components/LspRecommendation/LspRecommendationMenu.js'; +import { useClaudeCodeHintRecommendation } from 'src/hooks/useClaudeCodeHintRecommendation.js'; +import { PluginHintMenu } from 'src/components/ClaudeCodeHint/PluginHintMenu.js'; import { DesktopUpsellStartup, shouldShowDesktopUpsellStartup, -} from 'src/components/DesktopUpsell/DesktopUpsellStartup.js' -import { usePluginInstallationStatus } from 'src/hooks/notifs/usePluginInstallationStatus.js' -import { usePluginAutoupdateNotification } from 'src/hooks/notifs/usePluginAutoupdateNotification.js' -import { performStartupChecks } from 'src/utils/plugins/performStartupChecks.js' -import { UserTextMessage } from 'src/components/messages/UserTextMessage.js' -import { AwsAuthStatusBox } from '../components/AwsAuthStatusBox.js' -import { useRateLimitWarningNotification } from 'src/hooks/notifs/useRateLimitWarningNotification.js' -import { useDeprecationWarningNotification } from 'src/hooks/notifs/useDeprecationWarningNotification.js' -import { useNpmDeprecationNotification } from 'src/hooks/notifs/useNpmDeprecationNotification.js' -import { useIDEStatusIndicator } from 'src/hooks/notifs/useIDEStatusIndicator.js' -import { useModelMigrationNotifications } from 'src/hooks/notifs/useModelMigrationNotifications.js' -import { useCanSwitchToExistingSubscription } from 'src/hooks/notifs/useCanSwitchToExistingSubscription.js' -import { useTeammateLifecycleNotification } from 'src/hooks/notifs/useTeammateShutdownNotification.js' -import { useFastModeNotification } from 'src/hooks/notifs/useFastModeNotification.js' +} from 'src/components/DesktopUpsell/DesktopUpsellStartup.js'; +import { usePluginInstallationStatus } from 'src/hooks/notifs/usePluginInstallationStatus.js'; +import { usePluginAutoupdateNotification } from 'src/hooks/notifs/usePluginAutoupdateNotification.js'; +import { performStartupChecks } from 'src/utils/plugins/performStartupChecks.js'; +import { UserTextMessage } from 'src/components/messages/UserTextMessage.js'; +import { AwsAuthStatusBox } from '../components/AwsAuthStatusBox.js'; +import { useRateLimitWarningNotification } from 'src/hooks/notifs/useRateLimitWarningNotification.js'; +import { useDeprecationWarningNotification } from 'src/hooks/notifs/useDeprecationWarningNotification.js'; +import { useNpmDeprecationNotification } from 'src/hooks/notifs/useNpmDeprecationNotification.js'; +import { useIDEStatusIndicator } from 'src/hooks/notifs/useIDEStatusIndicator.js'; +import { useModelMigrationNotifications } from 'src/hooks/notifs/useModelMigrationNotifications.js'; +import { useCanSwitchToExistingSubscription } from 'src/hooks/notifs/useCanSwitchToExistingSubscription.js'; +import { useTeammateLifecycleNotification } from 'src/hooks/notifs/useTeammateShutdownNotification.js'; +import { useFastModeNotification } from 'src/hooks/notifs/useFastModeNotification.js'; import { AutoRunIssueNotification, shouldAutoRunIssue, getAutoRunIssueReasonText, getAutoRunCommand, type AutoRunIssueReason, -} from '../utils/autoRunIssue.js' -import type { HookProgress } from '../types/hooks.js' -import { TungstenLiveMonitor } from '../tools/TungstenTool/TungstenLiveMonitor.js' +} from '../utils/autoRunIssue.js'; +import type { HookProgress } from '../types/hooks.js'; +import { TungstenLiveMonitor } from '../tools/TungstenTool/TungstenLiveMonitor.js'; /* eslint-disable @typescript-eslint/no-require-imports */ const WebBrowserPanelModule = feature('WEB_BROWSER_TOOL') ? (require('../tools/WebBrowserTool/WebBrowserPanel.js') as typeof import('../tools/WebBrowserTool/WebBrowserPanel.js')) - : null + : null; /* eslint-enable @typescript-eslint/no-require-imports */ -import { IssueFlagBanner } from '../components/PromptInput/IssueFlagBanner.js' -import { useIssueFlagBanner } from '../hooks/useIssueFlagBanner.js' -import { - CompanionSprite, - CompanionFloatingBubble, - MIN_COLS_FOR_FULL_SPRITE, -} from '../buddy/CompanionSprite.js' -import { DevBar } from '../components/DevBar.js' +import { IssueFlagBanner } from '../components/PromptInput/IssueFlagBanner.js'; +import { useIssueFlagBanner } from '../hooks/useIssueFlagBanner.js'; +import { CompanionSprite, CompanionFloatingBubble, MIN_COLS_FOR_FULL_SPRITE } from '../buddy/CompanionSprite.js'; +import { DevBar } from '../components/DevBar.js'; // Session manager removed - using AppState now -import type { RemoteSessionConfig } from '../remote/RemoteSessionManager.js' -import { REMOTE_SAFE_COMMANDS } from '../commands.js' -import type { RemoteMessageContent } from '../utils/teleport/api.js' -import { - FullscreenLayout, - useUnseenDivider, - computeUnseenDivider, -} from '../components/FullscreenLayout.js' -import { - isFullscreenEnvEnabled, - maybeGetTmuxMouseHint, - isMouseTrackingEnabled, -} from '../utils/fullscreen.js' -import { AlternateScreen } from '../ink/components/AlternateScreen.js' -import { ScrollKeybindingHandler } from '../components/ScrollKeybindingHandler.js' +import type { RemoteSessionConfig } from '../remote/RemoteSessionManager.js'; +import { REMOTE_SAFE_COMMANDS } from '../commands.js'; +import type { RemoteMessageContent } from '../utils/teleport/api.js'; +import { FullscreenLayout, useUnseenDivider, computeUnseenDivider } from '../components/FullscreenLayout.js'; +import { isFullscreenEnvEnabled, maybeGetTmuxMouseHint, isMouseTrackingEnabled } from '../utils/fullscreen.js'; +import { AlternateScreen } from '../ink/components/AlternateScreen.js'; +import { ScrollKeybindingHandler } from '../components/ScrollKeybindingHandler.js'; import { useMessageActions, MessageActionsKeybindings, @@ -573,38 +457,33 @@ import { type MessageActionsState, type MessageActionsNav, type MessageActionCaps, -} from '../components/messageActions.js' -import { setClipboard } from '../ink/termio/osc.js' -import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js' -import { - createAttachmentMessage, - getQueuedCommandAttachments, -} from '../utils/attachments.js' +} from '../components/messageActions.js'; +import { setClipboard } from '../ink/termio/osc.js'; +import type { ScrollBoxHandle } from '../ink/components/ScrollBox.js'; +import { createAttachmentMessage, getQueuedCommandAttachments } from '../utils/attachments.js'; // Stable empty array for hooks that accept MCPServerConnection[] — avoids // creating a new [] literal on every render in remote mode, which would // cause useEffect dependency changes and infinite re-render loops. -const EMPTY_MCP_CLIENTS: MCPServerConnection[] = [] +const EMPTY_MCP_CLIENTS: MCPServerConnection[] = []; // Stable stub for useAssistantHistory's non-KAIROS branch — avoids a new // function identity each render, which would break composedOnScroll's memo. -const HISTORY_STUB = { maybeLoadOlder: (_: ScrollBoxHandle) => {} } +const HISTORY_STUB = { maybeLoadOlder: (_: ScrollBoxHandle) => {} }; // Window after a user-initiated scroll during which type-into-empty does NOT // repin to bottom. Josh Rosen's workflow: Claude emits long output → scroll // up to read the start → start typing → before this fix, snapped to bottom. // https://anthropic.slack.com/archives/C07VBSHV7EV/p1773545449871739 -const RECENT_SCROLL_REPIN_WINDOW_MS = 3000 +const RECENT_SCROLL_REPIN_WINDOW_MS = 3000; // Use LRU cache to prevent unbounded memory growth // 100 files should be sufficient for most coding sessions while preventing // memory issues when working across many files in large projects function median(values: number[]): number { - const sorted = [...values].sort((a, b) => a - b) - const mid = Math.floor(sorted.length / 2) - return sorted.length % 2 === 0 - ? Math.round((sorted[mid - 1]! + sorted[mid]!) / 2) - : sorted[mid]! + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? Math.round((sorted[mid - 1]! + sorted[mid]!) / 2) : sorted[mid]!; } /** @@ -618,32 +497,24 @@ function TranscriptModeFooter({ suppressShowAll = false, status, }: { - showAllInTranscript: boolean - virtualScroll: boolean + showAllInTranscript: boolean; + virtualScroll: boolean; /** Minimap while navigating a closed-bar search. Shows n/N hints + * right-aligned count instead of scroll hints. */ - searchBadge?: { current: number; count: number } + searchBadge?: { current: number; count: number }; /** Hide the ctrl+e hint. The [ dump path shares this footer with * env-opted dump (CLAUDE_CODE_NO_FLICKER=0 / DISABLE_VIRTUAL_SCROLL=1), * but ctrl+e only works in the env case — useGlobalKeybindings.tsx * gates on !virtualScrollActive which is env-derived, doesn't know * [ happened. */ - suppressShowAll?: boolean + suppressShowAll?: boolean; /** Transient status (v-for-editor progress). Notifications render inside * PromptInput which isn't mounted in transcript — addNotification queues * but nothing draws it. */ - status?: string + status?: string; }): React.ReactNode { - const toggleShortcut = useShortcutDisplay( - 'app:toggleTranscript', - 'Global', - 'ctrl+o', - ) - const showAllShortcut = useShortcutDisplay( - 'transcript:toggleShowAll', - 'Transcript', - 'ctrl+e', - ) + const toggleShortcut = useShortcutDisplay('app:toggleTranscript', 'Global', 'ctrl+o'); + const showAllShortcut = useShortcutDisplay('transcript:toggleShowAll', 'Transcript', 'ctrl+e'); return ( ) : null} - ) + ); } /** less-style / bar. 1-row, same border-top styling as TranscriptModeFooter @@ -704,25 +575,25 @@ function TranscriptSearchBar({ setHighlight, initialQuery, }: { - jumpRef: RefObject - count: number - current: number + jumpRef: RefObject; + count: number; + current: number; /** Enter — commit. Query persists for n/N. */ - onClose: (lastQuery: string) => void + onClose: (lastQuery: string) => void; /** Esc/ctrl+c/ctrl+g — undo to pre-/ state. */ - onCancel: () => void - setHighlight: (query: string) => void + onCancel: () => void; + setHighlight: (query: string) => void; // Seed with the previous query (less: / shows last pattern). Mount-fire // of the effect re-scans with the same query — idempotent (same matches, // nearest-ptr, same highlights). User can edit or clear. - initialQuery: string + initialQuery: string; }): React.ReactNode { const { query, cursorOffset } = useSearchInput({ isActive: true, initialQuery, onExit: () => onClose(query), onCancel, - }) + }); // Index warm-up runs before the query effect so it measures the real // cost — otherwise setSearchQuery fills the cache first and warm // reports ~0ms while the user felt the actual lag. @@ -734,43 +605,41 @@ function TranscriptSearchBar({ // null initial, warmDone would be true on mount → [query] fires → // setSearchQuery fills cache → warm reports ~0ms while the user felt // the real lag. - const [indexStatus, setIndexStatus] = React.useState< - 'building' | { ms: number } | null - >('building') + const [indexStatus, setIndexStatus] = React.useState<'building' | { ms: number } | null>('building'); React.useEffect(() => { - let alive = true - const warm = jumpRef.current?.warmSearchIndex + let alive = true; + const warm = jumpRef.current?.warmSearchIndex; if (!warm) { - setIndexStatus(null) // VML not mounted yet — rare, skip indicator - return + setIndexStatus(null); // VML not mounted yet — rare, skip indicator + return; } - setIndexStatus('building') + setIndexStatus('building'); warm().then(ms => { - if (!alive) return + if (!alive) return; // <20ms = imperceptible. No point showing "indexed in 3ms". if (ms < 20) { - setIndexStatus(null) + setIndexStatus(null); } else { - setIndexStatus({ ms }) - setTimeout(() => alive && setIndexStatus(null), 2000) + setIndexStatus({ ms }); + setTimeout(() => alive && setIndexStatus(null), 2000); } - }) + }); return () => { - alive = false - } + alive = false; + }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) // mount-only: bar opens once per / + }, []); // mount-only: bar opens once per / // Gate the query effect on warm completion. setHighlight stays instant // (screen-space overlay, no indexing). setSearchQuery (the scan) waits. - const warmDone = indexStatus !== 'building' + const warmDone = indexStatus !== 'building'; useEffect(() => { - if (!warmDone) return - jumpRef.current?.setSearchQuery(query) - setHighlight(query) + if (!warmDone) return; + jumpRef.current?.setSearchQuery(query); + setHighlight(query); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [query, warmDone]) - const off = cursorOffset - const cursorChar = off < query.length ? query[off] : ' ' + }, [query, warmDone]); + const off = cursorOffset; + const cursorChar = off < query.length ? query[off] : ' '; return ( ) : null} - ) + ); } -const TITLE_ANIMATION_FRAMES = ['⠂', '⠐'] -const TITLE_STATIC_PREFIX = '✳' -const TITLE_ANIMATION_INTERVAL_MS = 960 +const TITLE_ANIMATION_FRAMES = ['⠂', '⠐']; +const TITLE_STATIC_PREFIX = '✳'; +const TITLE_ANIMATION_INTERVAL_MS = 960; /** * Sets the terminal tab title, with an animated prefix glyph while a query @@ -831,79 +700,74 @@ function AnimatedTerminalTitle({ disabled, noPrefix, }: { - isAnimating: boolean - title: string - disabled: boolean - noPrefix: boolean + isAnimating: boolean; + title: string; + disabled: boolean; + noPrefix: boolean; }): null { - const terminalFocused = useTerminalFocus() - const [frame, setFrame] = useState(0) + const terminalFocused = useTerminalFocus(); + const [frame, setFrame] = useState(0); useEffect(() => { - if (disabled || noPrefix || !isAnimating || !terminalFocused) return + if (disabled || noPrefix || !isAnimating || !terminalFocused) return; const interval = setInterval( setFrame => setFrame(f => (f + 1) % TITLE_ANIMATION_FRAMES.length), TITLE_ANIMATION_INTERVAL_MS, setFrame, - ) - return () => clearInterval(interval) - }, [disabled, noPrefix, isAnimating, terminalFocused]) - const prefix = isAnimating - ? (TITLE_ANIMATION_FRAMES[frame] ?? TITLE_STATIC_PREFIX) - : TITLE_STATIC_PREFIX - useTerminalTitle(disabled ? null : noPrefix ? title : `${prefix} ${title}`) - return null + ); + return () => clearInterval(interval); + }, [disabled, noPrefix, isAnimating, terminalFocused]); + const prefix = isAnimating ? (TITLE_ANIMATION_FRAMES[frame] ?? TITLE_STATIC_PREFIX) : TITLE_STATIC_PREFIX; + useTerminalTitle(disabled ? null : noPrefix ? title : `${prefix} ${title}`); + return null; } export type Props = { - commands: Command[] - debug: boolean - initialTools: Tool[] + commands: Command[]; + debug: boolean; + initialTools: Tool[]; // Initial messages to populate the REPL with - initialMessages?: MessageType[] + initialMessages?: MessageType[]; // Deferred hook messages promise — REPL renders immediately and injects // hook messages when they resolve. Awaited before the first API call. - pendingHookMessages?: Promise - initialFileHistorySnapshots?: FileHistorySnapshot[] + pendingHookMessages?: Promise; + initialFileHistorySnapshots?: FileHistorySnapshot[]; // Content-replacement records from a resumed session's transcript — used to // reconstruct contentReplacementState so the same results are re-replaced - initialContentReplacements?: ContentReplacementRecord[] + initialContentReplacements?: ContentReplacementRecord[]; // Initial agent context for session resume (name/color set via /rename or /color) - initialAgentName?: string - initialAgentColor?: AgentColorName - mcpClients?: MCPServerConnection[] - dynamicMcpConfig?: Record - autoConnectIdeFlag?: boolean - strictMcpConfig?: boolean - systemPrompt?: string - appendSystemPrompt?: string + initialAgentName?: string; + initialAgentColor?: AgentColorName; + mcpClients?: MCPServerConnection[]; + dynamicMcpConfig?: Record; + autoConnectIdeFlag?: boolean; + strictMcpConfig?: boolean; + systemPrompt?: string; + appendSystemPrompt?: string; // Optional callback invoked before query execution // Called after user message is added to conversation but before API call // Return false to prevent query execution - onBeforeQuery?: ( - input: string, - newMessages: MessageType[], - ) => Promise + onBeforeQuery?: (input: string, newMessages: MessageType[]) => Promise; // Optional callback when a turn completes (model finishes responding) - onTurnComplete?: (messages: MessageType[]) => void | Promise + onTurnComplete?: (messages: MessageType[]) => void | Promise; // When true, disables REPL input (hides prompt and prevents message selector) - disabled?: boolean + disabled?: boolean; // Optional agent definition to use for the main thread - mainThreadAgentDefinition?: AgentDefinition + mainThreadAgentDefinition?: AgentDefinition; // When true, disables all slash commands - disableSlashCommands?: boolean + disableSlashCommands?: boolean; // Task list id: when set, enables tasks mode that watches a task list and auto-processes tasks. - taskListId?: string + taskListId?: string; // Remote session config for --remote mode (uses CCR as execution engine) - remoteSessionConfig?: RemoteSessionConfig + remoteSessionConfig?: RemoteSessionConfig; // Direct connect config for `claude connect` mode (connects to a claude server) - directConnectConfig?: DirectConnectConfig + directConnectConfig?: DirectConnectConfig; // SSH session for `claude ssh` mode (local REPL, remote tools over ssh) - sshSession?: SSHSession + sshSession?: SSHSession; // Thinking configuration to use when thinking is enabled - thinkingConfig: ThinkingConfig -} + thinkingConfig: ThinkingConfig; +}; -export type Screen = 'prompt' | 'transcript' +export type Screen = 'prompt' | 'transcript'; export function REPL({ commands: initialCommands, @@ -932,90 +796,70 @@ export function REPL({ sshSession, thinkingConfig, }: Props): React.ReactNode { - const isRemoteSession = !!remoteSessionConfig + const isRemoteSession = !!remoteSessionConfig; // Env-var gates hoisted to mount-time — isEnvTruthy does toLowerCase+trim+ // includes, and these were on the render path (hot during PageUp spam). - const titleDisabled = useMemo( - () => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_TERMINAL_TITLE), - [], - ) + const titleDisabled = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_TERMINAL_TITLE), []); const moreRightEnabled = useMemo( - () => - process.env.USER_TYPE === 'ant' && - isEnvTruthy(process.env.CLAUDE_MORERIGHT), - [], - ) - const disableVirtualScroll = useMemo( - () => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL), + () => process.env.USER_TYPE === 'ant' && isEnvTruthy(process.env.CLAUDE_MORERIGHT), [], - ) + ); + const disableVirtualScroll = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_VIRTUAL_SCROLL), []); const disableMessageActions = feature('MESSAGE_ACTIONS') ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useMemo( - () => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_MESSAGE_ACTIONS), - [], - ) - : false + useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_MESSAGE_ACTIONS), []) + : false; // Log REPL mount/unmount lifecycle useEffect(() => { - logForDebugging(`[REPL:mount] REPL mounted, disabled=${disabled}`) - return () => logForDebugging(`[REPL:unmount] REPL unmounting`) - }, [disabled]) + logForDebugging(`[REPL:mount] REPL mounted, disabled=${disabled}`); + return () => logForDebugging(`[REPL:unmount] REPL unmounting`); + }, [disabled]); // Agent definition is state so /resume can update it mid-session - const [mainThreadAgentDefinition, setMainThreadAgentDefinition] = useState( - initialMainThreadAgentDefinition, - ) - - const toolPermissionContext = useAppState(s => s.toolPermissionContext) - const verbose = useAppState(s => s.verbose) - const mcp = useAppState(s => s.mcp) - const plugins = useAppState(s => s.plugins) - const agentDefinitions = useAppState(s => s.agentDefinitions) - const fileHistory = useAppState(s => s.fileHistory) - const initialMessage = useAppState(s => s.initialMessage) - const queuedCommands = useCommandQueue() + const [mainThreadAgentDefinition, setMainThreadAgentDefinition] = useState(initialMainThreadAgentDefinition); + + const toolPermissionContext = useAppState(s => s.toolPermissionContext); + const verbose = useAppState(s => s.verbose); + const mcp = useAppState(s => s.mcp); + const plugins = useAppState(s => s.plugins); + const agentDefinitions = useAppState(s => s.agentDefinitions); + const fileHistory = useAppState(s => s.fileHistory); + const initialMessage = useAppState(s => s.initialMessage); + const queuedCommands = useCommandQueue(); // feature() is a build-time constant — dead code elimination removes the hook // call entirely in external builds, so this is safe despite looking conditional. // These fields contain excluded strings that must not appear in external builds. - const spinnerTip = useAppState(s => s.spinnerTip) - const showExpandedTodos = useAppState(s => s.expandedView) === 'tasks' - const pendingWorkerRequest = useAppState(s => s.pendingWorkerRequest) - const pendingSandboxRequest = useAppState(s => s.pendingSandboxRequest) - const teamContext = useAppState(s => s.teamContext) - const tasks = useAppState(s => s.tasks) - const workerSandboxPermissions = useAppState(s => s.workerSandboxPermissions) - const elicitation = useAppState(s => s.elicitation) - const ultraplanPendingChoice = useAppState(s => s.ultraplanPendingChoice) - const ultraplanLaunchPending = useAppState(s => s.ultraplanLaunchPending) - const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId) - const setAppState = useSetAppState() + const spinnerTip = useAppState(s => s.spinnerTip); + const showExpandedTodos = useAppState(s => s.expandedView) === 'tasks'; + const pendingWorkerRequest = useAppState(s => s.pendingWorkerRequest); + const pendingSandboxRequest = useAppState(s => s.pendingSandboxRequest); + const teamContext = useAppState(s => s.teamContext); + const tasks = useAppState(s => s.tasks); + const workerSandboxPermissions = useAppState(s => s.workerSandboxPermissions); + const elicitation = useAppState(s => s.elicitation); + const ultraplanPendingChoice = useAppState(s => s.ultraplanPendingChoice); + const ultraplanLaunchPending = useAppState(s => s.ultraplanLaunchPending); + const viewingAgentTaskId = useAppState(s => s.viewingAgentTaskId); + const setAppState = useSetAppState(); // Bootstrap: retained local_agent that hasn't loaded disk yet → read // sidechain JSONL and UUID-merge with whatever stream has appended so far. // Stream appends immediately on retain (no defer); bootstrap fills the // prefix. Disk-write-before-yield means live is always a suffix of disk. - const viewedLocalAgent = viewingAgentTaskId - ? tasks[viewingAgentTaskId] - : undefined - const needsBootstrap = - isLocalAgentTask(viewedLocalAgent) && - viewedLocalAgent.retain && - !viewedLocalAgent.diskLoaded + const viewedLocalAgent = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined; + const needsBootstrap = isLocalAgentTask(viewedLocalAgent) && viewedLocalAgent.retain && !viewedLocalAgent.diskLoaded; useEffect(() => { - if (!viewingAgentTaskId || !needsBootstrap) return - const taskId = viewingAgentTaskId + if (!viewingAgentTaskId || !needsBootstrap) return; + const taskId = viewingAgentTaskId; void getAgentTranscript(asAgentId(taskId)).then(result => { setAppState(prev => { - const t = prev.tasks[taskId] - if (!isLocalAgentTask(t) || t.diskLoaded || !t.retain) return prev - const live = t.messages ?? [] - const liveUuids = new Set(live.map(m => m.uuid)) - const diskOnly = result - ? result.messages.filter(m => !liveUuids.has(m.uuid)) - : [] + const t = prev.tasks[taskId]; + if (!isLocalAgentTask(t) || t.diskLoaded || !t.retain) return prev; + const live = t.messages ?? []; + const liveUuids = new Set(live.map(m => m.uuid)); + const diskOnly = result ? result.messages.filter(m => !liveUuids.has(m.uuid)) : []; return { ...prev, tasks: { @@ -1026,33 +870,30 @@ export function REPL({ diskLoaded: true, }, }, - } - }) - }) - }, [viewingAgentTaskId, needsBootstrap, setAppState]) + }; + }); + }); + }, [viewingAgentTaskId, needsBootstrap, setAppState]); - const store = useAppStateStore() - const terminal = useTerminalNotification() - const mainLoopModel = useMainLoopModel() + const store = useAppStateStore(); + const terminal = useTerminalNotification(); + const mainLoopModel = useMainLoopModel(); // Note: standaloneAgentContext is initialized in main.tsx (via initialState) or // ResumeConversation.tsx (via setAppState before rendering REPL) to avoid // useEffect-based state initialization on mount (per CLAUDE.md guidelines) // Local state for commands (hot-reloadable when skill files change) - const [localCommands, setLocalCommands] = useState(initialCommands) + const [localCommands, setLocalCommands] = useState(initialCommands); // Watch for skill file changes and reload all commands - useSkillsChange( - isRemoteSession ? undefined : getProjectRoot(), - setLocalCommands, - ) + useSkillsChange(isRemoteSession ? undefined : getProjectRoot(), setLocalCommands); // Track proactive mode for tools dependency - SleepTool filters by proactive state const proactiveActive = React.useSyncExternalStore( proactiveModule?.subscribeToProactiveChanges ?? PROACTIVE_NO_OP_SUBSCRIBE, proactiveModule?.isProactiveActive ?? PROACTIVE_FALSE, - ) + ); // BriefTool.isEnabled() reads getUserMsgOptIn() from bootstrap state, which // /brief flips mid-session alongside isBriefOnly. The memo below needs a @@ -1060,113 +901,97 @@ export function REPL({ // the AppState mirror that triggers the re-render. Without this, toggling // /brief mid-session leaves the stale tool list (no SendUserMessage) and // the model emits plain text the brief filter hides. - const isBriefOnly = useAppState(s => s.isBriefOnly) + const isBriefOnly = useAppState(s => s.isBriefOnly); const localTools = useMemo( () => getTools(toolPermissionContext), [toolPermissionContext, proactiveActive, isBriefOnly], - ) + ); - useKickOffCheckAndDisableBypassPermissionsIfNeeded() - useKickOffCheckAndDisableAutoModeIfNeeded() + useKickOffCheckAndDisableBypassPermissionsIfNeeded(); + useKickOffCheckAndDisableAutoModeIfNeeded(); - const [dynamicMcpConfig, setDynamicMcpConfig] = useState< - Record | undefined - >(initialDynamicMcpConfig) + const [dynamicMcpConfig, setDynamicMcpConfig] = useState | undefined>( + initialDynamicMcpConfig, + ); const onChangeDynamicMcpConfig = useCallback( (config: Record) => { - setDynamicMcpConfig(config) + setDynamicMcpConfig(config); }, [setDynamicMcpConfig], - ) + ); - const [screen, setScreen] = useState('prompt') - const [showAllInTranscript, setShowAllInTranscript] = useState(false) + const [screen, setScreen] = useState('prompt'); + const [showAllInTranscript, setShowAllInTranscript] = useState(false); // [ forces the dump-to-scrollback path inside transcript mode. Separate // from CLAUDE_CODE_NO_FLICKER=0 (which is process-lifetime) — this is // ephemeral, reset on transcript exit. Diagnostic escape hatch so // terminal/tmux native cmd-F can search the full flat render. - const [dumpMode, setDumpMode] = useState(false) + const [dumpMode, setDumpMode] = useState(false); // v-for-editor render progress. Inline in the footer — notifications // render inside PromptInput which isn't mounted in transcript. - const [editorStatus, setEditorStatus] = useState('') + const [editorStatus, setEditorStatus] = useState(''); // Incremented on transcript exit. Async v-render captures this at start; // each status write no-ops if stale (user left transcript mid-render — // the stable setState would otherwise stamp a ghost toast into the next // session). Also clears any pending 4s auto-clear. - const editorGenRef = useRef(0) - const editorTimerRef = useRef | undefined>( - undefined, - ) - const editorRenderingRef = useRef(false) - const { addNotification, removeNotification } = useNotifications() + const editorGenRef = useRef(0); + const editorTimerRef = useRef | undefined>(undefined); + const editorRenderingRef = useRef(false); + const { addNotification, removeNotification } = useNotifications(); // eslint-disable-next-line prefer-const - let trySuggestBgPRIntercept = SUGGEST_BG_PR_NOOP + let trySuggestBgPRIntercept = SUGGEST_BG_PR_NOOP; - const mcpClients = useMergedClients(initialMcpClients, mcp.clients) + const mcpClients = useMergedClients(initialMcpClients, mcp.clients); // IDE integration - const [ideSelection, setIDESelection] = useState( - undefined, - ) - const [ideToInstallExtension, setIDEToInstallExtension] = - useState(null) - const [ideInstallationStatus, setIDEInstallationStatus] = - useState(null) - const [showIdeOnboarding, setShowIdeOnboarding] = useState(false) + const [ideSelection, setIDESelection] = useState(undefined); + const [ideToInstallExtension, setIDEToInstallExtension] = useState(null); + const [ideInstallationStatus, setIDEInstallationStatus] = useState(null); + const [showIdeOnboarding, setShowIdeOnboarding] = useState(false); // Dead code elimination: model switch callout state (ant-only) const [showModelSwitchCallout, setShowModelSwitchCallout] = useState(() => { if (process.env.USER_TYPE === 'ant') { - return shouldShowAntModelSwitch() + return shouldShowAntModelSwitch(); } - return false - }) - const [showEffortCallout, setShowEffortCallout] = useState(() => - shouldShowEffortCallout(mainLoopModel), - ) - const showRemoteCallout = useAppState(s => s.showRemoteCallout) - const [showDesktopUpsellStartup, setShowDesktopUpsellStartup] = useState(() => - shouldShowDesktopUpsellStartup(), - ) + return false; + }); + const [showEffortCallout, setShowEffortCallout] = useState(() => shouldShowEffortCallout(mainLoopModel)); + const showRemoteCallout = useAppState(s => s.showRemoteCallout); + const [showDesktopUpsellStartup, setShowDesktopUpsellStartup] = useState(() => shouldShowDesktopUpsellStartup()); // notifications - useModelMigrationNotifications() - useCanSwitchToExistingSubscription() - useIDEStatusIndicator({ ideSelection, mcpClients, ideInstallationStatus }) - useMcpConnectivityStatus({ mcpClients }) - useAutoModeUnavailableNotification() - usePluginInstallationStatus() - usePluginAutoupdateNotification() - useSettingsErrors() - useRateLimitWarningNotification(mainLoopModel) - useFastModeNotification() - useDeprecationWarningNotification(mainLoopModel) - useNpmDeprecationNotification() - useAntOrgWarningNotification() - useInstallMessages() - useChromeExtensionNotification() - useOfficialMarketplaceNotification() - useLspInitializationNotification() - useTeammateLifecycleNotification() - const { - recommendation: lspRecommendation, - handleResponse: handleLspResponse, - } = useLspPluginRecommendation() - const { - recommendation: hintRecommendation, - handleResponse: handleHintResponse, - } = useClaudeCodeHintRecommendation() + useModelMigrationNotifications(); + useCanSwitchToExistingSubscription(); + useIDEStatusIndicator({ ideSelection, mcpClients, ideInstallationStatus }); + useMcpConnectivityStatus({ mcpClients }); + useAutoModeUnavailableNotification(); + usePluginInstallationStatus(); + usePluginAutoupdateNotification(); + useSettingsErrors(); + useRateLimitWarningNotification(mainLoopModel); + useFastModeNotification(); + useDeprecationWarningNotification(mainLoopModel); + useNpmDeprecationNotification(); + useAntOrgWarningNotification(); + useInstallMessages(); + useChromeExtensionNotification(); + useOfficialMarketplaceNotification(); + useLspInitializationNotification(); + useTeammateLifecycleNotification(); + const { recommendation: lspRecommendation, handleResponse: handleLspResponse } = useLspPluginRecommendation(); + const { recommendation: hintRecommendation, handleResponse: handleHintResponse } = useClaudeCodeHintRecommendation(); // Memoize the combined initial tools array to prevent reference changes const combinedInitialTools = useMemo(() => { - return [...localTools, ...initialTools] - }, [localTools, initialTools]) + return [...localTools, ...initialTools]; + }, [localTools, initialTools]); // Initialize plugin management - useManagePlugins({ enabled: !isRemoteSession }) + useManagePlugins({ enabled: !isRemoteSession }); - const tasksV2 = useTasksV2WithCollapseEffect() + const tasksV2 = useTasksV2WithCollapseEffect(); // Start background plugin installations @@ -1177,28 +1002,21 @@ export function REPL({ // This ensures that plugin installations from repository and user settings only // happen after explicit user consent to trust the current working directory. useEffect(() => { - if (isRemoteSession) return - void performStartupChecks(setAppState) - }, [setAppState, isRemoteSession]) + if (isRemoteSession) return; + void performStartupChecks(setAppState); + }, [setAppState, isRemoteSession]); // Allow Claude in Chrome MCP to send prompts through MCP notifications // and sync permission mode changes to the Chrome extension - usePromptsFromClaudeInChrome( - isRemoteSession ? EMPTY_MCP_CLIENTS : mcpClients, - toolPermissionContext.mode, - ) + usePromptsFromClaudeInChrome(isRemoteSession ? EMPTY_MCP_CLIENTS : mcpClients, toolPermissionContext.mode); // Initialize swarm features: teammate hooks and context // Handles both fresh spawns and resumed teammate sessions useSwarmInitialization(setAppState, initialMessages, { enabled: !isRemoteSession, - }) + }); - const mergedTools = useMergedTools( - combinedInitialTools, - mcp.tools, - toolPermissionContext, - ) + const mergedTools = useMergedTools(combinedInitialTools, mcp.tools, toolPermissionContext); // Apply agent tool restrictions if mainThreadAgentDefinition is set const { tools, allowedAgentTypes } = useMemo(() => { @@ -1206,42 +1024,25 @@ export function REPL({ return { tools: mergedTools, allowedAgentTypes: undefined as string[] | undefined, - } + }; } - const resolved = resolveAgentTools( - mainThreadAgentDefinition, - mergedTools, - false, - true, - ) + const resolved = resolveAgentTools(mainThreadAgentDefinition, mergedTools, false, true); return { tools: resolved.resolvedTools, allowedAgentTypes: resolved.allowedAgentTypes, - } - }, [mainThreadAgentDefinition, mergedTools]) + }; + }, [mainThreadAgentDefinition, mergedTools]); // Merge commands from local state, plugins, and MCP - const commandsWithPlugins = useMergedCommands( - localCommands, - plugins.commands as Command[], - ) - const mergedCommands = useMergedCommands( - commandsWithPlugins, - mcp.commands as Command[], - ) + const commandsWithPlugins = useMergedCommands(localCommands, plugins.commands as Command[]); + const mergedCommands = useMergedCommands(commandsWithPlugins, mcp.commands as Command[]); // Filter out all commands if disableSlashCommands is true - const commands = useMemo( - () => (disableSlashCommands ? [] : mergedCommands), - [disableSlashCommands, mergedCommands], - ) - - useIdeLogging(isRemoteSession ? EMPTY_MCP_CLIENTS : mcp.clients) - useIdeSelection( - isRemoteSession ? EMPTY_MCP_CLIENTS : mcp.clients, - setIDESelection, - ) - - const [streamMode, setStreamMode] = useState('responding') + const commands = useMemo(() => (disableSlashCommands ? [] : mergedCommands), [disableSlashCommands, mergedCommands]); + + useIdeLogging(isRemoteSession ? EMPTY_MCP_CLIENTS : mcp.clients); + useIdeSelection(isRemoteSession ? EMPTY_MCP_CLIENTS : mcp.clients, setIDESelection); + + const [streamMode, setStreamMode] = useState('responding'); // Ref mirror so onSubmit can read the latest value without adding // streamMode to its deps. streamMode flips between // requesting/responding/tool-use ~10x per turn during streaming; having it @@ -1250,115 +1051,100 @@ export function REPL({ // invalidation. The only consumers inside callbacks are debug logging and // telemetry (handlePromptSubmit.ts), so a stale-by-one-render value is // harmless — but ref mirrors sync on every render anyway so it's fresh. - const streamModeRef = useRef(streamMode) - streamModeRef.current = streamMode - const [streamingToolUses, setStreamingToolUses] = useState< - StreamingToolUse[] - >([]) - const [streamingThinking, setStreamingThinking] = - useState(null) + const streamModeRef = useRef(streamMode); + streamModeRef.current = streamMode; + const [streamingToolUses, setStreamingToolUses] = useState([]); + const [streamingThinking, setStreamingThinking] = useState(null); // Auto-hide streaming thinking after 30 seconds of being completed useEffect(() => { - if ( - streamingThinking && - !streamingThinking.isStreaming && - streamingThinking.streamingEndedAt - ) { - const elapsed = Date.now() - streamingThinking.streamingEndedAt - const remaining = 30000 - elapsed + if (streamingThinking && !streamingThinking.isStreaming && streamingThinking.streamingEndedAt) { + const elapsed = Date.now() - streamingThinking.streamingEndedAt; + const remaining = 30000 - elapsed; if (remaining > 0) { - const timer = setTimeout(setStreamingThinking, remaining, null) - return () => clearTimeout(timer) + const timer = setTimeout(setStreamingThinking, remaining, null); + return () => clearTimeout(timer); } else { - setStreamingThinking(null) + setStreamingThinking(null); } } - }, [streamingThinking]) + }, [streamingThinking]); - const [abortController, setAbortController] = - useState(null) + const [abortController, setAbortController] = useState(null); // Ref that always points to the current abort controller, used by the // REPL bridge to abort the active query when a remote interrupt arrives. - const abortControllerRef = useRef(null) - abortControllerRef.current = abortController + const abortControllerRef = useRef(null); + abortControllerRef.current = abortController; // Ref for the bridge result callback — set after useReplBridge initializes, // read in the onQuery finally block to notify mobile clients that a turn ended. - const sendBridgeResultRef = useRef<() => void>(() => {}) + const sendBridgeResultRef = useRef<() => void>(() => {}); // Ref for the synchronous restore callback — set after restoreMessageSync is // defined, read in the onQuery finally block for auto-restore on interrupt. - const restoreMessageSyncRef = useRef<(m: UserMessage) => void>(() => {}) + const restoreMessageSyncRef = useRef<(m: UserMessage) => void>(() => {}); // Ref to the fullscreen layout's scroll box for keyboard scrolling. // Null when fullscreen mode is disabled (ref never attached). - const scrollRef = useRef(null) + const scrollRef = useRef(null); // Separate ref for the modal slot's inner ScrollBox — passed through // FullscreenLayout → ModalContext so Tabs can attach it to its own // ScrollBox for tall content (e.g. /status's MCP-server list). NOT // keyboard-driven — ScrollKeybindingHandler stays on the outer ref so // PgUp/PgDn/wheel always scroll the transcript behind the modal. // Plumbing kept for future modal-scroll wiring. - const modalScrollRef = useRef(null) + const modalScrollRef = useRef(null); // Timestamp of the last user-initiated scroll (wheel, PgUp/PgDn, ctrl+u, // End/Home, G, drag-to-scroll). Stamped in composedOnScroll — the single // chokepoint ScrollKeybindingHandler calls for every user scroll action. // Programmatic scrolls (repinScroll's scrollToBottom, sticky auto-follow) // do NOT go through composedOnScroll, so they don't stamp this. Ref not // state: no re-render on every wheel tick. - const lastUserScrollTsRef = useRef(0) + const lastUserScrollTsRef = useRef(0); // Synchronous state machine for the query lifecycle. Replaces the // error-prone dual-state pattern where isLoading (React state, async // batched) and isQueryRunning (ref, sync) could desync. See QueryGuard.ts. - const queryGuard = React.useRef(new QueryGuard()).current + const queryGuard = React.useRef(new QueryGuard()).current; // Subscribe to the guard — true during dispatching or running. // This is the single source of truth for "is a local query in flight". - const isQueryActive = React.useSyncExternalStore( - queryGuard.subscribe, - queryGuard.getSnapshot, - ) + const isQueryActive = React.useSyncExternalStore(queryGuard.subscribe, queryGuard.getSnapshot); // Separate loading flag for operations outside the local query guard: // remote sessions (useRemoteSession / useDirectConnect) and foregrounded // background tasks (useSessionBackgrounding). These don't route through // onQuery / queryGuard, so they need their own spinner-visibility state. // Initialize true if remote mode with initial prompt (CCR processing it). - const [isExternalLoading, setIsExternalLoadingRaw] = React.useState( - remoteSessionConfig?.hasInitialPrompt ?? false, - ) + const [isExternalLoading, setIsExternalLoadingRaw] = React.useState(remoteSessionConfig?.hasInitialPrompt ?? false); // Derived: any loading source active. Read-only — no setter. Local query // loading is driven by queryGuard (reserve/tryStart/end/cancelReservation), // external loading by setIsExternalLoading. - const isLoading = isQueryActive || isExternalLoading + const isLoading = isQueryActive || isExternalLoading; // Elapsed time is computed by SpinnerWithVerb from these refs on each // animation frame, avoiding a useInterval that re-renders the entire REPL. - const [userInputOnProcessing, setUserInputOnProcessingRaw] = React.useState< - string | undefined - >(undefined) + const [userInputOnProcessing, setUserInputOnProcessingRaw] = React.useState(undefined); // messagesRef.current.length at the moment userInputOnProcessing was set. // The placeholder hides once displayedMessages grows past this — i.e. the // real user message has landed in the visible transcript. - const userInputBaselineRef = React.useRef(0) + const userInputBaselineRef = React.useRef(0); // True while the submitted prompt is being processed but its user message // hasn't reached setMessages yet. setMessages uses this to keep the // baseline in sync when unrelated async messages (bridge status, hook // results, scheduled tasks) land during that window. - const userMessagePendingRef = React.useRef(false) + const userMessagePendingRef = React.useRef(false); // Wall-clock time tracking refs for accurate elapsed time calculation - const loadingStartTimeRef = React.useRef(0) - const totalPausedMsRef = React.useRef(0) - const pauseStartTimeRef = React.useRef(null) + const loadingStartTimeRef = React.useRef(0); + const totalPausedMsRef = React.useRef(0); + const pauseStartTimeRef = React.useRef(null); const resetTimingRefs = React.useCallback(() => { - loadingStartTimeRef.current = Date.now() - totalPausedMsRef.current = 0 - pauseStartTimeRef.current = null - }, []) + loadingStartTimeRef.current = Date.now(); + totalPausedMsRef.current = 0; + pauseStartTimeRef.current = null; + }, []); // Reset timing refs inline when isQueryActive transitions false→true. // queryGuard.reserve() (in executeUserInput) fires BEFORE processUserInput's @@ -1368,11 +1154,11 @@ export function REPL({ // first render where isQueryActive is observed true — the same render that // first shows the spinner — so the ref is correct by the time the spinner // reads it. See INC-4549. - const wasQueryActiveRef = React.useRef(false) + const wasQueryActiveRef = React.useRef(false); if (isQueryActive && !wasQueryActiveRef.current) { - resetTimingRefs() + resetTimingRefs(); } - wasQueryActiveRef.current = isQueryActive + wasQueryActiveRef.current = isQueryActive; // Wrapper for setIsExternalLoading that resets timing refs on transition // to true — SpinnerWithVerb reads these for elapsed time, so they must be @@ -1381,32 +1167,28 @@ export function REPL({ // session would show ~56 years elapsed (Date.now() - 0). const setIsExternalLoading = React.useCallback( (value: boolean) => { - setIsExternalLoadingRaw(value) - if (value) resetTimingRefs() + setIsExternalLoadingRaw(value); + if (value) resetTimingRefs(); }, [resetTimingRefs], - ) + ); // Start time of the first turn that had swarm teammates running // Used to compute total elapsed time (including teammate execution) for the deferred message - const swarmStartTimeRef = React.useRef(null) - const swarmBudgetInfoRef = React.useRef< - { tokens: number; limit: number; nudges: number } | undefined - >(undefined) + const swarmStartTimeRef = React.useRef(null); + const swarmBudgetInfoRef = React.useRef<{ tokens: number; limit: number; nudges: number } | undefined>(undefined); // Ref to track current focusedInputDialog for use in callbacks // This avoids stale closures when checking dialog state in timer callbacks - const focusedInputDialogRef = - React.useRef>(undefined) + const focusedInputDialogRef = React.useRef>(undefined); // How long after the last keystroke before deferred dialogs are shown - const PROMPT_SUPPRESSION_MS = 1500 + const PROMPT_SUPPRESSION_MS = 1500; // True when user is actively typing — defers interrupt dialogs so keystrokes // don't accidentally dismiss or answer a permission prompt the user hasn't read yet. - const [isPromptInputActive, setIsPromptInputActive] = React.useState(false) + const [isPromptInputActive, setIsPromptInputActive] = React.useState(false); - const [autoUpdaterResult, setAutoUpdaterResult] = - useState(null) + const [autoUpdaterResult, setAutoUpdaterResult] = useState(null); useEffect(() => { if (autoUpdaterResult?.notifications) { @@ -1415,10 +1197,10 @@ export function REPL({ key: 'auto-updater-notification', text: notification, priority: 'low', - }) - }) + }); + }); } - }, [autoUpdaterResult, addNotification]) + }, [autoUpdaterResult, addNotification]); // tmux + fullscreen + `mouse off`: one-time hint that wheel won't scroll. // We no longer mutate tmux's session-scoped mouse option (it poisoned @@ -1431,51 +1213,47 @@ export function REPL({ key: 'tmux-mouse-hint', text: hint, priority: 'low', - }) + }); } - }) + }); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, []); - const [showUndercoverCallout, setShowUndercoverCallout] = useState(false) + const [showUndercoverCallout, setShowUndercoverCallout] = useState(false); useEffect(() => { if (process.env.USER_TYPE === 'ant') { void (async () => { // Wait for repo classification to settle (memoized, no-op if primed). - const { isInternalModelRepo } = await import( - '../utils/commitAttribution.js' - ) - await isInternalModelRepo() - const { shouldShowUndercoverAutoNotice } = await import( - '../utils/undercover.js' - ) + const { isInternalModelRepo } = await import('../utils/commitAttribution.js'); + await isInternalModelRepo(); + const { shouldShowUndercoverAutoNotice } = await import('../utils/undercover.js'); if (shouldShowUndercoverAutoNotice()) { - setShowUndercoverCallout(true) + setShowUndercoverCallout(true); } - })() + })(); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, []); const [toolJSX, setToolJSXInternal] = useState<{ - jsx: React.ReactNode | null - shouldHidePromptInput: boolean - shouldContinueAnimation?: true - showSpinner?: boolean - isLocalJSXCommand?: boolean - isImmediate?: boolean - } | null>(null) + jsx: React.ReactNode | null; + shouldHidePromptInput: boolean; + shouldContinueAnimation?: true; + showSpinner?: boolean; + isLocalJSXCommand?: boolean; + isImmediate?: boolean; + } | null>(null); // Track local JSX commands separately so tools can't overwrite them. // This enables "immediate" commands (like /btw) to persist while Claude is processing. const localJSXCommandRef = useRef<{ - jsx: React.ReactNode | null - shouldHidePromptInput: boolean - shouldContinueAnimation?: true - showSpinner?: boolean - isLocalJSXCommand: true - } | null>(null) + jsx: React.ReactNode | null; + shouldHidePromptInput: boolean; + shouldContinueAnimation?: true; + showSpinner?: boolean; + isLocalJSXCommand: true; + } | null>(null); // Wrapper for setToolJSX that preserves local JSX commands (like /btw). // When a local JSX command is active, we ignore updates from tools @@ -1489,105 +1267,90 @@ export function REPL({ const setToolJSX = useCallback( ( args: { - jsx: React.ReactNode | null - shouldHidePromptInput: boolean - shouldContinueAnimation?: true - showSpinner?: boolean - isLocalJSXCommand?: boolean - clearLocalJSX?: boolean + jsx: React.ReactNode | null; + shouldHidePromptInput: boolean; + shouldContinueAnimation?: true; + showSpinner?: boolean; + isLocalJSXCommand?: boolean; + clearLocalJSX?: boolean; } | null, ) => { // If setting a local JSX command, store it in the ref if (args?.isLocalJSXCommand) { - const { clearLocalJSX: _, ...rest } = args - localJSXCommandRef.current = { ...rest, isLocalJSXCommand: true } - setToolJSXInternal(rest) - return + const { clearLocalJSX: _, ...rest } = args; + localJSXCommandRef.current = { ...rest, isLocalJSXCommand: true }; + setToolJSXInternal(rest); + return; } // If there's an active local JSX command in the ref if (localJSXCommandRef.current) { // Allow clearing only if explicitly requested (from onDone callbacks) if (args?.clearLocalJSX) { - localJSXCommandRef.current = null - setToolJSXInternal(null) - return + localJSXCommandRef.current = null; + setToolJSXInternal(null); + return; } // Otherwise, keep the local JSX command visible - ignore tool updates - return + return; } // No active local JSX command, allow any update if (args?.clearLocalJSX) { - setToolJSXInternal(null) - return + setToolJSXInternal(null); + return; } - setToolJSXInternal(args) + setToolJSXInternal(args); }, [], - ) - const [toolUseConfirmQueue, setToolUseConfirmQueue] = useState< - ToolUseConfirm[] - >([]) + ); + const [toolUseConfirmQueue, setToolUseConfirmQueue] = useState([]); // Sticky footer JSX registered by permission request components (currently // only ExitPlanModePermissionRequest). Renders in FullscreenLayout's `bottom` // slot so response options stay visible while the user scrolls a long plan. - const [permissionStickyFooter, setPermissionStickyFooter] = - useState(null) - const [sandboxPermissionRequestQueue, setSandboxPermissionRequestQueue] = - useState< - Array<{ - hostPattern: NetworkHostPattern - resolvePromise: (allowConnection: boolean) => void - }> - >([]) + const [permissionStickyFooter, setPermissionStickyFooter] = useState(null); + const [sandboxPermissionRequestQueue, setSandboxPermissionRequestQueue] = useState< + Array<{ + hostPattern: NetworkHostPattern; + resolvePromise: (allowConnection: boolean) => void; + }> + >([]); const [promptQueue, setPromptQueue] = useState< Array<{ - request: PromptRequest - title: string - toolInputSummary?: string | null - resolve: (response: PromptResponse) => void - reject: (error: Error) => void + request: PromptRequest; + title: string; + toolInputSummary?: string | null; + resolve: (response: PromptResponse) => void; + reject: (error: Error) => void; }> - >([]) + >([]); // Track bridge cleanup functions for sandbox permission requests so the // local dialog handler can cancel the remote prompt when the local user // responds first. Keyed by host to support concurrent same-host requests. - const sandboxBridgeCleanupRef = useRef void>>>( - new Map(), - ) + const sandboxBridgeCleanupRef = useRef void>>>(new Map()); // -- Terminal title management // Session title (set via /rename or restored on resume) wins over // the agent name, which wins over the Haiku-extracted topic; // all fall back to the product name. - const terminalTitleFromRename = - useAppState(s => s.settings.terminalTitleFromRename) !== false - const sessionTitle = terminalTitleFromRename - ? getCurrentSessionTitle(getSessionId()) - : undefined - const [haikuTitle, setHaikuTitle] = useState() + const terminalTitleFromRename = useAppState(s => s.settings.terminalTitleFromRename) !== false; + const sessionTitle = terminalTitleFromRename ? getCurrentSessionTitle(getSessionId()) : undefined; + const [haikuTitle, setHaikuTitle] = useState(); // Gates the one-shot Haiku call that generates the tab title. Seeded true // on resume (initialMessages present) so we don't re-title a resumed // session from mid-conversation context. - const haikuTitleAttemptedRef = useRef((initialMessages?.length ?? 0) > 0) - const agentTitle = mainThreadAgentDefinition?.agentType - const terminalTitle = - sessionTitle ?? agentTitle ?? haikuTitle ?? 'Claude Code' + const haikuTitleAttemptedRef = useRef((initialMessages?.length ?? 0) > 0); + const agentTitle = mainThreadAgentDefinition?.agentType; + const terminalTitle = sessionTitle ?? agentTitle ?? haikuTitle ?? 'Claude Code'; const isWaitingForApproval = - toolUseConfirmQueue.length > 0 || - promptQueue.length > 0 || - pendingWorkerRequest || - pendingSandboxRequest + toolUseConfirmQueue.length > 0 || promptQueue.length > 0 || pendingWorkerRequest || pendingSandboxRequest; // Local-jsx commands (like /plugin, /config) show user-facing dialogs that // wait for input. Require jsx != null — if the flag is stuck true but jsx // is null, treat as not-showing so TextInput focus and queue processor // aren't deadlocked by a phantom overlay. - const isShowingLocalJSXCommand = - toolJSX?.isLocalJSXCommand === true && toolJSX?.jsx != null - const titleIsAnimating = - isLoading && !isWaitingForApproval && !isShowingLocalJSXCommand + const isShowingLocalJSXCommand = toolJSX?.isLocalJSXCommand === true && toolJSX?.jsx != null; + const titleIsAnimating = isLoading && !isWaitingForApproval && !isShowingLocalJSXCommand; // Title animation state lives in so the 960ms tick // doesn't re-render REPL. titleDisabled/terminalTitle are still computed // here because onQueryImpl reads them (background session description, @@ -1596,17 +1359,13 @@ export function REPL({ // Prevent macOS from sleeping while Claude is working useEffect(() => { if (isLoading && !isWaitingForApproval && !isShowingLocalJSXCommand) { - startPreventSleep() - return () => stopPreventSleep() + startPreventSleep(); + return () => stopPreventSleep(); } - }, [isLoading, isWaitingForApproval, isShowingLocalJSXCommand]) + }, [isLoading, isWaitingForApproval, isShowingLocalJSXCommand]); const sessionStatus: TabStatusKind = - isWaitingForApproval || isShowingLocalJSXCommand - ? 'waiting' - : isLoading - ? 'busy' - : 'idle' + isWaitingForApproval || isShowingLocalJSXCommand ? 'waiting' : isLoading ? 'busy' : 'idle'; const waitingFor = sessionStatus !== 'waiting' @@ -1619,43 +1378,37 @@ export function REPL({ ? 'sandbox request' : isShowingLocalJSXCommand ? 'dialog open' - : 'input needed' + : 'input needed'; // Push status to the PID file for `claude ps`. Fire-and-forget; ps falls // back to transcript-tail derivation when this is missing/stale. useEffect(() => { if (feature('BG_SESSIONS')) { - void updateSessionActivity({ status: sessionStatus, waitingFor }) + void updateSessionActivity({ status: sessionStatus, waitingFor }); } - }, [sessionStatus, waitingFor]) + }, [sessionStatus, waitingFor]); // 3P default: off — OSC 21337 is ant-only while the spec stabilizes. // Gated so we can roll back if the sidebar indicator conflicts with // the title spinner in terminals that render both. When the flag is // on, the user-facing config setting controls whether it's active. - const tabStatusGateEnabled = getFeatureValue_CACHED_MAY_BE_STALE( - 'tengu_terminal_sidebar', - false, - ) - const showStatusInTerminalTab = - tabStatusGateEnabled && (getGlobalConfig().showStatusInTerminalTab ?? false) - useTabStatus(titleDisabled || !showStatusInTerminalTab ? null : sessionStatus) + const tabStatusGateEnabled = getFeatureValue_CACHED_MAY_BE_STALE('tengu_terminal_sidebar', false); + const showStatusInTerminalTab = tabStatusGateEnabled && (getGlobalConfig().showStatusInTerminalTab ?? false); + useTabStatus(titleDisabled || !showStatusInTerminalTab ? null : sessionStatus); // Register the leader's setToolUseConfirmQueue for in-process teammates useEffect(() => { - registerLeaderToolUseConfirmQueue(setToolUseConfirmQueue) - return () => unregisterLeaderToolUseConfirmQueue() - }, [setToolUseConfirmQueue]) - - const [messages, rawSetMessages] = useState( - initialMessages ?? [], - ) - const messagesRef = useRef(messages) + registerLeaderToolUseConfirmQueue(setToolUseConfirmQueue); + return () => unregisterLeaderToolUseConfirmQueue(); + }, [setToolUseConfirmQueue]); + + const [messages, rawSetMessages] = useState(initialMessages ?? []); + const messagesRef = useRef(messages); // Stores the willowMode variant that was shown (or false if no hint shown). // Captured at hint_shown time so hint_converted telemetry reports the same // variant — the GrowthBook value shouldn't change mid-session, but reading // it once guarantees consistency between the paired events. - const idleHintShownRef = useRef(false) + const idleHintShownRef = useRef(false); // Wrap setMessages so messagesRef is always current the instant the // call returns — not when React later processes the batch. Apply the // updater eagerly against the ref, then hand React the computed value @@ -1665,94 +1418,82 @@ export function REPL({ // truth, React state is the render projection. Without this, paths // that queue functional updaters then synchronously read the ref // (e.g. handleSpeculationAccept → onQuery) see stale data. - const setMessages = useCallback( - (action: React.SetStateAction) => { - const prev = messagesRef.current - const next = - typeof action === 'function' ? action(messagesRef.current) : action - messagesRef.current = next - if (next.length < userInputBaselineRef.current) { - // Shrank (compact/rewind/clear) — clamp so placeholderText's length - // check can't go stale. - userInputBaselineRef.current = 0 - } else if (next.length > prev.length && userMessagePendingRef.current) { - // Grew while the submitted user message hasn't landed yet. If the - // added messages don't include it (bridge status, hook results, - // scheduled tasks landing async during processUserInputBase), bump - // baseline so the placeholder stays visible. Once the user message - // lands, stop tracking — later additions (assistant stream) should - // not re-show the placeholder. - const delta = next.length - prev.length - const added = - prev.length === 0 || next[0] === prev[0] - ? next.slice(-delta) - : next.slice(0, delta) - if (added.some(isHumanTurn)) { - userMessagePendingRef.current = false - } else { - userInputBaselineRef.current = next.length - } + const setMessages = useCallback((action: React.SetStateAction) => { + const prev = messagesRef.current; + const next = typeof action === 'function' ? action(messagesRef.current) : action; + messagesRef.current = next; + if (next.length < userInputBaselineRef.current) { + // Shrank (compact/rewind/clear) — clamp so placeholderText's length + // check can't go stale. + userInputBaselineRef.current = 0; + } else if (next.length > prev.length && userMessagePendingRef.current) { + // Grew while the submitted user message hasn't landed yet. If the + // added messages don't include it (bridge status, hook results, + // scheduled tasks landing async during processUserInputBase), bump + // baseline so the placeholder stays visible. Once the user message + // lands, stop tracking — later additions (assistant stream) should + // not re-show the placeholder. + const delta = next.length - prev.length; + const added = prev.length === 0 || next[0] === prev[0] ? next.slice(-delta) : next.slice(0, delta); + if (added.some(isHumanTurn)) { + userMessagePendingRef.current = false; + } else { + userInputBaselineRef.current = next.length; } - rawSetMessages(next) - }, - [], - ) + } + rawSetMessages(next); + }, []); // Capture the baseline message count alongside the placeholder text so // the render can hide it once displayedMessages grows past the baseline. const setUserInputOnProcessing = useCallback((input: string | undefined) => { if (input !== undefined) { - userInputBaselineRef.current = messagesRef.current.length - userMessagePendingRef.current = true + userInputBaselineRef.current = messagesRef.current.length; + userMessagePendingRef.current = true; } else { - userMessagePendingRef.current = false + userMessagePendingRef.current = false; } - setUserInputOnProcessingRaw(input) - }, []) + setUserInputOnProcessingRaw(input); + }, []); // Fullscreen: track the unseen-divider position. dividerIndex changes // only ~twice/scroll-session (first scroll-away + repin). pillVisible // and stickyPrompt now live in FullscreenLayout — they subscribe to // ScrollBox directly so per-frame scroll never re-renders REPL. - const { - dividerIndex, - dividerYRef, - onScrollAway, - onRepin, - jumpToNew, - shiftDivider, - } = useUnseenDivider(messages.length) + const { dividerIndex, dividerYRef, onScrollAway, onRepin, jumpToNew, shiftDivider } = useUnseenDivider( + messages.length, + ); if (feature('AWAY_SUMMARY')) { // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAwaySummary(messages, setMessages, isLoading) + useAwaySummary(messages, setMessages, isLoading); } - const [cursor, setCursor] = useState(null) - const cursorNavRef = useRef(null) + const [cursor, setCursor] = useState(null); + const cursorNavRef = useRef(null); // Memoized so Messages' React.memo holds. const unseenDivider = useMemo( () => computeUnseenDivider(messages, dividerIndex), // eslint-disable-next-line react-hooks/exhaustive-deps -- length change covers appends; useUnseenDivider's count-drop guard clears dividerIndex on replace/rewind [dividerIndex, messages.length], - ) + ); // Re-pin scroll to bottom and clear the unseen-messages baseline. Called // on any user-driven return-to-live action (submit, type-into-empty, // overlay appear/dismiss). const repinScroll = useCallback(() => { - scrollRef.current?.scrollToBottom() - onRepin() - setCursor(null) - }, [onRepin, setCursor]) + scrollRef.current?.scrollToBottom(); + onRepin(); + setCursor(null); + }, [onRepin, setCursor]); // Backstop for the submit-handler repin at onSubmit. If a buffered stdin // event (wheel/drag) races between handler-fire and state-commit, the // handler's scrollToBottom can be undone. This effect fires on the render // where the user's message actually lands — tied to React's commit cycle, // so it can't race with stdin. Keyed on lastMsg identity (not messages.length) // so useAssistantHistory's prepends don't spuriously repin. - const lastMsg = messages.at(-1) - const lastMsgIsHuman = lastMsg != null && isHumanTurn(lastMsg) + const lastMsg = messages.at(-1); + const lastMsgIsHuman = lastMsg != null && isHumanTurn(lastMsg); useEffect(() => { if (lastMsgIsHuman) { - repinScroll() + repinScroll(); } - }, [lastMsgIsHuman, lastMsg, repinScroll]) + }, [lastMsgIsHuman, lastMsg, repinScroll]); // Assistant-chat: lazy-load remote history on scroll-up. No-op unless // KAIROS build + config.viewerOnly. feature() is build-time constant so // the branch is dead-code-eliminated in non-KAIROS builds (same pattern @@ -1765,64 +1506,59 @@ export function REPL({ scrollRef, onPrepend: shiftDivider, }) - : HISTORY_STUB + : HISTORY_STUB; // Compose useUnseenDivider's callbacks with the lazy-load trigger. const composedOnScroll = useCallback( (sticky: boolean, handle: ScrollBoxHandle) => { - lastUserScrollTsRef.current = Date.now() + lastUserScrollTsRef.current = Date.now(); if (sticky) { - onRepin() + onRepin(); } else { - onScrollAway(handle) - if (feature('KAIROS')) maybeLoadOlder(handle) + onScrollAway(handle); + if (feature('KAIROS')) maybeLoadOlder(handle); // Dismiss the companion bubble on scroll — it's absolute-positioned // at bottom-right and covers transcript content. Scrolling = user is // trying to read something under it. if (feature('BUDDY')) { setAppState(prev => - prev.companionReaction === undefined - ? prev - : { ...prev, companionReaction: undefined }, - ) + prev.companionReaction === undefined ? prev : { ...prev, companionReaction: undefined }, + ); } } }, [onRepin, onScrollAway, maybeLoadOlder, setAppState], - ) + ); // Deferred SessionStart hook messages — REPL renders immediately and // hook messages are injected when they resolve. awaitPendingHooks() // must be called before the first API call so the model sees hook context. - const awaitPendingHooks = useDeferredHookMessages( - pendingHookMessages, - setMessages, - ) + const awaitPendingHooks = useDeferredHookMessages(pendingHookMessages, setMessages); // Deferred messages for the Messages component — renders at transition // priority so the reconciler yields every 5ms, keeping input responsive // while the expensive message processing pipeline runs. - const deferredMessages = useDeferredValue(messages) - const deferredBehind = messages.length - deferredMessages.length + const deferredMessages = useDeferredValue(messages); + const deferredBehind = messages.length - deferredMessages.length; if (deferredBehind > 0) { logForDebugging( `[useDeferredValue] Messages deferred by ${deferredBehind} (${deferredMessages.length}→${messages.length})`, - ) + ); } // Frozen state for transcript mode - stores lengths instead of cloning arrays for memory efficiency const [frozenTranscriptState, setFrozenTranscriptState] = useState<{ - messagesLength: number - streamingToolUsesLength: number - } | null>(null) + messagesLength: number; + streamingToolUsesLength: number; + } | null>(null); // Initialize input with any early input that was captured before REPL was ready. // Using lazy initialization ensures cursor offset is set correctly in PromptInput. - const [inputValue, setInputValueRaw] = useState(() => consumeEarlyInput()) - const inputValueRef = useRef(inputValue) - inputValueRef.current = inputValue + const [inputValue, setInputValueRaw] = useState(() => consumeEarlyInput()); + const inputValueRef = useRef(inputValue); + inputValueRef.current = inputValue; const insertTextRef = useRef<{ - insert: (text: string) => void - setInputWithCursor: (value: string, cursor: number) => void - cursorOffset: number - } | null>(null) + insert: (text: string) => void; + setInputWithCursor: (value: string, cursor: number) => void; + cursorOffset: number; + } | null>(null); // Wrap setInputValue to co-locate suppression state updates. // Both setState calls happen in the same synchronous context so React @@ -1830,7 +1566,7 @@ export function REPL({ // the previous useEffect → setState pattern caused. const setInputValue = useCallback( (value: string) => { - if (trySuggestBgPRIntercept(inputValueRef.current, value)) return + if (trySuggestBgPRIntercept(inputValueRef.current, value)) return; // In fullscreen mode, typing into an empty prompt re-pins scroll to // bottom. Only fires on empty→non-empty so scrolling up to reference // something while composing a message doesn't yank the view back on @@ -1842,62 +1578,50 @@ export function REPL({ if ( inputValueRef.current === '' && value !== '' && - Date.now() - lastUserScrollTsRef.current >= - RECENT_SCROLL_REPIN_WINDOW_MS + Date.now() - lastUserScrollTsRef.current >= RECENT_SCROLL_REPIN_WINDOW_MS ) { - repinScroll() + repinScroll(); } // Sync ref immediately (like setMessages) so callers that read // inputValueRef before React commits — e.g. the auto-restore finally // block's `=== ''` guard — see the fresh value, not the stale render. - inputValueRef.current = value - setInputValueRaw(value) - setIsPromptInputActive(value.trim().length > 0) + inputValueRef.current = value; + setInputValueRaw(value); + setIsPromptInputActive(value.trim().length > 0); }, [setIsPromptInputActive, repinScroll, trySuggestBgPRIntercept], - ) + ); // Schedule a timeout to stop suppressing dialogs after the user stops typing. // Only manages the timeout — the immediate activation is handled by setInputValue above. useEffect(() => { - if (inputValue.trim().length === 0) return - const timer = setTimeout( - setIsPromptInputActive, - PROMPT_SUPPRESSION_MS, - false, - ) - return () => clearTimeout(timer) - }, [inputValue]) - - const [inputMode, setInputMode] = useState('prompt') + if (inputValue.trim().length === 0) return; + const timer = setTimeout(setIsPromptInputActive, PROMPT_SUPPRESSION_MS, false); + return () => clearTimeout(timer); + }, [inputValue]); + + const [inputMode, setInputMode] = useState('prompt'); const [stashedPrompt, setStashedPrompt] = useState< | { - text: string - cursorOffset: number - pastedContents: Record + text: string; + cursorOffset: number; + pastedContents: Record; } | undefined - >() + >(); // Callback to filter commands based on CCR's available slash commands const handleRemoteInit = useCallback( (remoteSlashCommands: string[]) => { - const remoteCommandSet = new Set(remoteSlashCommands) + const remoteCommandSet = new Set(remoteSlashCommands); // Keep commands that CCR lists OR that are in the local-safe set - setLocalCommands(prev => - prev.filter( - cmd => - remoteCommandSet.has(cmd.name) || REMOTE_SAFE_COMMANDS.has(cmd), - ), - ) + setLocalCommands(prev => prev.filter(cmd => remoteCommandSet.has(cmd.name) || REMOTE_SAFE_COMMANDS.has(cmd))); }, [setLocalCommands], - ) + ); - const [inProgressToolUseIDs, setInProgressToolUseIDs] = useState>( - new Set(), - ) - const hasInterruptibleToolInProgressRef = useRef(false) + const [inProgressToolUseIDs, setInProgressToolUseIDs] = useState>(new Set()); + const hasInterruptibleToolInProgressRef = useRef(false); // Remote session hook - manages WebSocket connection and message handling for --remote mode const remoteSession = useRemoteSession({ @@ -1910,7 +1634,7 @@ export function REPL({ setStreamingToolUses, setStreamMode, setInProgressToolUseIDs, - }) + }); // Direct connect hook - manages WebSocket to a claude server for `claude connect` mode const directConnect = useDirectConnect({ @@ -1919,7 +1643,7 @@ export function REPL({ setIsLoading: setIsExternalLoading, setToolUseConfirmQueue, tools: combinedInitialTools, - }) + }); // SSH session hook - manages ssh child process for `claude ssh` mode. // Same callback shape as useDirectConnect; only the transport under the @@ -1930,100 +1654,86 @@ export function REPL({ setIsLoading: setIsExternalLoading, setToolUseConfirmQueue, tools: combinedInitialTools, - }) + }); // Use whichever remote mode is active - const activeRemote = sshRemote.isRemoteMode - ? sshRemote - : directConnect.isRemoteMode - ? directConnect - : remoteSession - - const [pastedContents, setPastedContents] = useState< - Record - >({}) - const [submitCount, setSubmitCount] = useState(0) + const activeRemote = sshRemote.isRemoteMode ? sshRemote : directConnect.isRemoteMode ? directConnect : remoteSession; + + const [pastedContents, setPastedContents] = useState>({}); + const [submitCount, setSubmitCount] = useState(0); // Ref instead of state to avoid triggering React re-renders on every // streaming text_delta. The spinner reads this via its animation timer. - const responseLengthRef = useRef(0) + const responseLengthRef = useRef(0); // API performance metrics ref for ant-only spinner display (TTFT/OTPS). // Accumulates metrics from all API requests in a turn for P50 aggregation. const apiMetricsRef = useRef< Array<{ - ttftMs: number - firstTokenTime: number - lastTokenTime: number - responseLengthBaseline: number + ttftMs: number; + firstTokenTime: number; + lastTokenTime: number; + responseLengthBaseline: number; // Tracks responseLengthRef at the time of the last content addition. // Updated by both streaming deltas and subagent message content. // lastTokenTime is also updated at the same time, so the OTPS // denominator correctly includes subagent processing time. - endResponseLength: number + endResponseLength: number; }> - >([]) + >([]); const setResponseLength = useCallback((f: (prev: number) => number) => { - const prev = responseLengthRef.current - responseLengthRef.current = f(prev) + const prev = responseLengthRef.current; + responseLengthRef.current = f(prev); // When content is added (not a compaction reset), update the latest // metrics entry so OTPS reflects all content generation activity. // Updating lastTokenTime here ensures the denominator includes both // streaming time AND subagent execution time, preventing inflation. if (responseLengthRef.current > prev) { - const entries = apiMetricsRef.current + const entries = apiMetricsRef.current; if (entries.length > 0) { - const lastEntry = entries.at(-1)! - lastEntry.lastTokenTime = Date.now() - lastEntry.endResponseLength = responseLengthRef.current + const lastEntry = entries.at(-1)!; + lastEntry.lastTokenTime = Date.now(); + lastEntry.endResponseLength = responseLengthRef.current; } } - }, []) + }, []); // Streaming text display: set state directly per delta (Ink's 16ms render // throttle batches rapid updates). Cleared on message arrival (messages.ts) // so displayedMessages switches from deferredMessages to messages atomically. - const [streamingText, setStreamingText] = useState(null) - const reducedMotion = - useAppState(s => s.settings.prefersReducedMotion) ?? false - const showStreamingText = !reducedMotion && !hasCursorUpViewportYankBug() + const [streamingText, setStreamingText] = useState(null); + const reducedMotion = useAppState(s => s.settings.prefersReducedMotion) ?? false; + const showStreamingText = !reducedMotion && !hasCursorUpViewportYankBug(); const onStreamingText = useCallback( (f: (current: string | null) => string | null) => { - if (!showStreamingText) return - setStreamingText(f) + if (!showStreamingText) return; + setStreamingText(f); }, [showStreamingText], - ) + ); // Hide the in-progress source line so text streams line-by-line, not // char-by-char. lastIndexOf returns -1 when no newline, giving '' → null. // Guard on showStreamingText so toggling reducedMotion mid-stream // immediately hides the streaming preview. const visibleStreamingText = - streamingText && showStreamingText - ? streamingText.substring(0, streamingText.lastIndexOf('\n') + 1) || null - : null - - const [lastQueryCompletionTime, setLastQueryCompletionTime] = useState(0) - const [spinnerMessage, setSpinnerMessage] = useState(null) - const [spinnerColor, setSpinnerColor] = useState(null) - const [spinnerShimmerColor, setSpinnerShimmerColor] = useState< - keyof Theme | null - >(null) - const [isMessageSelectorVisible, setIsMessageSelectorVisible] = - useState(false) - const [messageSelectorPreselect, setMessageSelectorPreselect] = useState< - UserMessage | undefined - >(undefined) - const [showCostDialog, setShowCostDialog] = useState(false) - const [conversationId, setConversationId] = useState(randomUUID()) + streamingText && showStreamingText ? streamingText.substring(0, streamingText.lastIndexOf('\n') + 1) || null : null; + + const [lastQueryCompletionTime, setLastQueryCompletionTime] = useState(0); + const [spinnerMessage, setSpinnerMessage] = useState(null); + const [spinnerColor, setSpinnerColor] = useState(null); + const [spinnerShimmerColor, setSpinnerShimmerColor] = useState(null); + const [isMessageSelectorVisible, setIsMessageSelectorVisible] = useState(false); + const [messageSelectorPreselect, setMessageSelectorPreselect] = useState(undefined); + const [showCostDialog, setShowCostDialog] = useState(false); + const [conversationId, setConversationId] = useState(randomUUID()); // Idle-return dialog: shown when user submits after a long idle gap const [idleReturnPending, setIdleReturnPending] = useState<{ - input: string - idleMinutes: number - } | null>(null) - const skipIdleCheckRef = useRef(false) - const lastQueryCompletionTimeRef = useRef(lastQueryCompletionTime) - lastQueryCompletionTimeRef.current = lastQueryCompletionTime + input: string; + idleMinutes: number; + } | null>(null); + const skipIdleCheckRef = useRef(false); + const lastQueryCompletionTimeRef = useRef(lastQueryCompletionTime); + lastQueryCompletionTimeRef.current = lastQueryCompletionTime; // Aggregate tool result budget: per-conversation decision tracking. // When the GrowthBook flag is on, query.ts enforces the budget; when @@ -2037,21 +1747,14 @@ export function REPL({ // For large resumed sessions, reconstruction does O(messages × blocks) // work; we only want that once. const [contentReplacementStateRef] = useState(() => ({ - current: provisionContentReplacementState( - initialMessages, - initialContentReplacements, - ), - })) - - const [haveShownCostDialog, setHaveShownCostDialog] = useState( - getGlobalConfig().hasAcknowledgedCostThreshold, - ) - const [vimMode, setVimMode] = useState('INSERT') - const [showBashesDialog, setShowBashesDialog] = useState( - false, - ) - const [isSearchingHistory, setIsSearchingHistory] = useState(false) - const [isHelpOpen, setIsHelpOpen] = useState(false) + current: provisionContentReplacementState(initialMessages, initialContentReplacements), + })); + + const [haveShownCostDialog, setHaveShownCostDialog] = useState(getGlobalConfig().hasAcknowledgedCostThreshold); + const [vimMode, setVimMode] = useState('INSERT'); + const [showBashesDialog, setShowBashesDialog] = useState(false); + const [isSearchingHistory, setIsSearchingHistory] = useState(false); + const [isHelpOpen, setIsHelpOpen] = useState(false); // showBashesDialog is REPL-level so it survives PromptInput unmounting. // When ultraplan approval fires while the pill dialog is open, PromptInput @@ -2060,48 +1763,48 @@ export function REPL({ // (the completed ultraplan task has been filtered out). Close it here. useEffect(() => { if (ultraplanPendingChoice && showBashesDialog) { - setShowBashesDialog(false) + setShowBashesDialog(false); } - }, [ultraplanPendingChoice, showBashesDialog]) + }, [ultraplanPendingChoice, showBashesDialog]); - const isTerminalFocused = useTerminalFocus() - const terminalFocusRef = useRef(isTerminalFocused) - terminalFocusRef.current = isTerminalFocused + const isTerminalFocused = useTerminalFocus(); + const terminalFocusRef = useRef(isTerminalFocused); + terminalFocusRef.current = isTerminalFocused; - const [theme] = useTheme() + const [theme] = useTheme(); // resetLoadingState runs twice per turn (onQueryImpl tail + onQuery finally). // Without this guard, both calls pick a tip → two recordShownTip → two // saveGlobalConfig writes back-to-back. Reset at submit in onSubmit. - const tipPickedThisTurnRef = React.useRef(false) + const tipPickedThisTurnRef = React.useRef(false); const pickNewSpinnerTip = useCallback(() => { - if (tipPickedThisTurnRef.current) return - tipPickedThisTurnRef.current = true - const newMessages = messagesRef.current.slice(bashToolsProcessedIdx.current) + if (tipPickedThisTurnRef.current) return; + tipPickedThisTurnRef.current = true; + const newMessages = messagesRef.current.slice(bashToolsProcessedIdx.current); for (const tool of extractBashToolsFromMessages(newMessages)) { - bashTools.current.add(tool) + bashTools.current.add(tool); } - bashToolsProcessedIdx.current = messagesRef.current.length + bashToolsProcessedIdx.current = messagesRef.current.length; void getTipToShowOnSpinner({ theme, readFileState: readFileState.current, bashTools: bashTools.current, }).then(async tip => { if (tip) { - const content = await tip.content({ theme }) + const content = await tip.content({ theme }); setAppState(prev => ({ ...prev, spinnerTip: content, - })) - recordShownTip(tip) + })); + recordShownTip(tip); } else { setAppState(prev => { - if (prev.spinnerTip === undefined) return prev - return { ...prev, spinnerTip: undefined } - }) + if (prev.spinnerTip === undefined) return prev; + return { ...prev, spinnerTip: undefined }; + }); } - }) - }, [setAppState, theme]) + }); + }, [setAppState, theme]); // Resets UI loading state. Does NOT call onTurnComplete - that should be // called explicitly only when a query turn actually completes. @@ -2110,37 +1813,37 @@ export function REPL({ // queryGuard.end() (onQuery finally) or cancelReservation() (executeUserInput // finally) have already transitioned the guard to idle by the time this runs. // External loading (remote/backgrounding) is reset separately by those hooks. - setIsExternalLoading(false) - setUserInputOnProcessing(undefined) - responseLengthRef.current = 0 - apiMetricsRef.current = [] - setStreamingText(null) - setStreamingToolUses([]) - setSpinnerMessage(null) - setSpinnerColor(null) - setSpinnerShimmerColor(null) - pickNewSpinnerTip() - endInteractionSpan() + setIsExternalLoading(false); + setUserInputOnProcessing(undefined); + responseLengthRef.current = 0; + apiMetricsRef.current = []; + setStreamingText(null); + setStreamingToolUses([]); + setSpinnerMessage(null); + setSpinnerColor(null); + setSpinnerShimmerColor(null); + pickNewSpinnerTip(); + endInteractionSpan(); // Speculative bash classifier checks are only valid for the current // turn's commands — clear after each turn to avoid accumulating // Promise chains for unconsumed checks (denied/aborted paths). - clearSpeculativeChecks() - }, [pickNewSpinnerTip]) + clearSpeculativeChecks(); + }, [pickNewSpinnerTip]); // Session backgrounding — hook is below, after getToolUseContext const hasRunningTeammates = useMemo( () => getAllInProcessTeammateTasks(tasks).some(t => t.status === 'running'), [tasks], - ) + ); // Show deferred turn duration message once all swarm teammates finish useEffect(() => { if (!hasRunningTeammates && swarmStartTimeRef.current !== null) { - const totalMs = Date.now() - swarmStartTimeRef.current - const deferredBudget = swarmBudgetInfoRef.current - swarmStartTimeRef.current = null - swarmBudgetInfoRef.current = undefined + const totalMs = Date.now() - swarmStartTimeRef.current; + const deferredBudget = swarmBudgetInfoRef.current; + swarmStartTimeRef.current = null; + swarmBudgetInfoRef.current = undefined; setMessages(prev => [ ...prev, createTurnDurationMessage( @@ -2153,82 +1856,77 @@ export function REPL({ // every turn that ran a progress-emitting tool. count(prev, isLoggableMessage), ), - ]) + ]); } - }, [hasRunningTeammates, setMessages]) + }, [hasRunningTeammates, setMessages]); // Show auto permissions warning when entering auto mode // (either via Shift+Tab toggle or on startup). Debounced to avoid // flashing when the user is cycling through modes quickly. // Only shown 3 times total across sessions. - const safeYoloMessageShownRef = useRef(false) + const safeYoloMessageShownRef = useRef(false); useEffect(() => { if (feature('TRANSCRIPT_CLASSIFIER')) { if (toolPermissionContext.mode !== 'auto') { - safeYoloMessageShownRef.current = false - return + safeYoloMessageShownRef.current = false; + return; } - if (safeYoloMessageShownRef.current) return - const config = getGlobalConfig() - const count = config.autoPermissionsNotificationCount ?? 0 - if (count >= 3) return + if (safeYoloMessageShownRef.current) return; + const config = getGlobalConfig(); + const count = config.autoPermissionsNotificationCount ?? 0; + if (count >= 3) return; const timer = setTimeout( (ref, setMessages) => { - ref.current = true + ref.current = true; saveGlobalConfig(prev => { - const prevCount = prev.autoPermissionsNotificationCount ?? 0 - if (prevCount >= 3) return prev + const prevCount = prev.autoPermissionsNotificationCount ?? 0; + if (prevCount >= 3) return prev; return { ...prev, autoPermissionsNotificationCount: prevCount + 1, - } - }) - setMessages(prev => [ - ...prev, - createSystemMessage(AUTO_MODE_DESCRIPTION, 'warning'), - ]) + }; + }); + setMessages(prev => [...prev, createSystemMessage(AUTO_MODE_DESCRIPTION, 'warning')]); }, 800, safeYoloMessageShownRef, setMessages, - ) - return () => clearTimeout(timer) + ); + return () => clearTimeout(timer); } - }, [toolPermissionContext.mode, setMessages]) + }, [toolPermissionContext.mode, setMessages]); // If worktree creation was slow and sparse-checkout isn't configured, // nudge the user toward settings.worktree.sparsePaths. - const worktreeTipShownRef = useRef(false) + const worktreeTipShownRef = useRef(false); useEffect(() => { - if (worktreeTipShownRef.current) return - const wt = getCurrentWorktreeSession() - if (!wt?.creationDurationMs || wt.usedSparsePaths) return - if (wt.creationDurationMs < 15_000) return - worktreeTipShownRef.current = true - const secs = Math.round(wt.creationDurationMs / 1000) + if (worktreeTipShownRef.current) return; + const wt = getCurrentWorktreeSession(); + if (!wt?.creationDurationMs || wt.usedSparsePaths) return; + if (wt.creationDurationMs < 15_000) return; + worktreeTipShownRef.current = true; + const secs = Math.round(wt.creationDurationMs / 1000); setMessages(prev => [ ...prev, createSystemMessage( `Worktree creation took ${secs}s. For large repos, set \`worktree.sparsePaths\` in .claude/settings.json to check out only the directories you need — e.g. \`{"worktree": {"sparsePaths": ["src", "packages/foo"]}}\`.`, 'info', ), - ]) - }, [setMessages]) + ]); + }, [setMessages]); // Hide spinner when the only in-progress tool is Sleep const onlySleepToolActive = useMemo(() => { - const lastAssistant = messages.findLast(m => m.type === 'assistant') - if (lastAssistant?.type !== 'assistant') return false + const lastAssistant = messages.findLast(m => m.type === 'assistant'); + if (lastAssistant?.type !== 'assistant') return false; const inProgressToolUses = lastAssistant.message.content.filter( b => b.type === 'tool_use' && inProgressToolUseIDs.has(b.id), - ) + ); return ( inProgressToolUses.length > 0 && - inProgressToolUses.every( - b => b.type === 'tool_use' && b.name === SLEEP_TOOL_NAME, - ) - ) - }, [messages, inProgressToolUseIDs]) + inProgressToolUses.every(b => b.type === 'tool_use' && b.name === SLEEP_TOOL_NAME) + ); + }, [messages, inProgressToolUseIDs]); const { onBeforeQuery: mrOnBeforeQuery, @@ -2240,7 +1938,7 @@ export function REPL({ inputValue, setInputValue, setToolJSX, - }) + }); const showSpinner = (!toolJSX || toolJSX.showSpinner === true) && @@ -2261,7 +1959,7 @@ export function REPL({ !onlySleepToolActive && // Hide spinner when streaming text is visible (the text IS the feedback), // but keep it when isBriefOnly suppresses the streaming text display - (!visibleStreamingText || isBriefOnly) + (!visibleStreamingText || isBriefOnly); // Check if any permission or ask question prompt is currently visible // This is used to prevent the survey from opening while prompts are active @@ -2270,19 +1968,13 @@ export function REPL({ promptQueue.length > 0 || sandboxPermissionRequestQueue.length > 0 || elicitation.queue.length > 0 || - workerSandboxPermissions.queue.length > 0 + workerSandboxPermissions.queue.length > 0; - const feedbackSurveyOriginal = useFeedbackSurvey( - messages, - isLoading, - submitCount, - 'session', - hasActivePrompt, - ) + const feedbackSurveyOriginal = useFeedbackSurvey(messages, isLoading, submitCount, 'session', hasActivePrompt); - const skillImprovementSurvey = useSkillImprovementSurvey(setMessages) + const skillImprovementSurvey = useSkillImprovementSurvey(setMessages); - const showIssueFlagBanner = useIssueFlagBanner(messages, submitCount) + const showIssueFlagBanner = useIssueFlagBanner(messages, submitCount); // Wrap feedback survey handler to trigger auto-run /issue const feedbackSurvey = useMemo( @@ -2290,46 +1982,34 @@ export function REPL({ ...feedbackSurveyOriginal, handleSelect: (selected: 'dismissed' | 'bad' | 'fine' | 'good') => { // Reset the ref when a new survey response comes in - didAutoRunIssueRef.current = false - const showedTranscriptPrompt = - feedbackSurveyOriginal.handleSelect(selected) + didAutoRunIssueRef.current = false; + const showedTranscriptPrompt = feedbackSurveyOriginal.handleSelect(selected); // Auto-run /issue for "bad" if transcript prompt wasn't shown - if ( - selected === 'bad' && - !showedTranscriptPrompt && - shouldAutoRunIssue('feedback_survey_bad') - ) { - setAutoRunIssueReason('feedback_survey_bad') - didAutoRunIssueRef.current = true + if (selected === 'bad' && !showedTranscriptPrompt && shouldAutoRunIssue('feedback_survey_bad')) { + setAutoRunIssueReason('feedback_survey_bad'); + didAutoRunIssueRef.current = true; } }, }), [feedbackSurveyOriginal], - ) + ); // Post-compact survey: shown after compaction if feature gate is enabled - const postCompactSurvey = usePostCompactSurvey( - messages, - isLoading, - hasActivePrompt, - { enabled: !isRemoteSession }, - ) + const postCompactSurvey = usePostCompactSurvey(messages, isLoading, hasActivePrompt, { enabled: !isRemoteSession }); // Memory survey: shown when the assistant mentions memory and a memory file // was read this conversation const memorySurvey = useMemorySurvey(messages, isLoading, hasActivePrompt, { enabled: !isRemoteSession, - }) + }); // Frustration detection: show transcript sharing prompt after detecting frustrated messages const frustrationDetection = useFrustrationDetection( messages, isLoading, hasActivePrompt, - feedbackSurvey.state !== 'closed' || - postCompactSurvey.state !== 'closed' || - memorySurvey.state !== 'closed', - ) + feedbackSurvey.state !== 'closed' || postCompactSurvey.state !== 'closed' || memorySurvey.state !== 'closed', + ); // Initialize IDE integration useIDEIntegration({ @@ -2338,47 +2018,39 @@ export function REPL({ setDynamicMcpConfig, setShowIdeOnboarding, setIDEInstallationState: setIDEInstallationStatus, - }) + }); - useFileHistorySnapshotInit( - initialFileHistorySnapshots, - fileHistory, - fileHistoryState => - setAppState(prev => ({ - ...prev, - fileHistory: fileHistoryState, - })), - ) + useFileHistorySnapshotInit(initialFileHistorySnapshots, fileHistory, fileHistoryState => + setAppState(prev => ({ + ...prev, + fileHistory: fileHistoryState, + })), + ); const resume = useCallback( async (sessionId: UUID, log: LogOption, entrypoint: ResumeEntrypoint) => { - const resumeStart = performance.now() + const resumeStart = performance.now(); try { // Deserialize messages to properly clean up the conversation // This filters unresolved tool uses and adds a synthetic assistant message if needed - const messages = deserializeMessages(log.messages) + const messages = deserializeMessages(log.messages); // Match coordinator/normal mode to the resumed session 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(log.mode) + const warning = coordinatorModule.matchSessionMode(log.mode); if (warning) { // Re-derive agent definitions after mode switch so built-in agents // reflect the new coordinator/normal mode /* eslint-disable @typescript-eslint/no-require-imports */ - const { - getAgentDefinitionsWithOverrides, - getActiveAgentsFromList, - } = - require('../tools/AgentTool/loadAgentsDir.js') as typeof import('../tools/AgentTool/loadAgentsDir.js') + const { getAgentDefinitionsWithOverrides, getActiveAgentsFromList } = + require('../tools/AgentTool/loadAgentsDir.js') as typeof import('../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, @@ -2387,43 +2059,43 @@ export function REPL({ allAgents: freshAgentDefs.allAgents, activeAgents: getActiveAgentsFromList(freshAgentDefs.allAgents), }, - })) - messages.push(createSystemMessage(warning, 'warning')) + })); + messages.push(createSystemMessage(warning, 'warning')); } } // Fire SessionEnd hooks for the current session before starting the // resumed one, mirroring the /clear flow in conversation.ts. - const sessionEndTimeoutMs = getSessionEndHookTimeoutMs() + const sessionEndTimeoutMs = getSessionEndHookTimeoutMs(); await executeSessionEndHooks('resume', { getAppState: () => store.getState(), setAppState, signal: AbortSignal.timeout(sessionEndTimeoutMs), timeoutMs: sessionEndTimeoutMs, - }) + }); // Process session start hooks for resume const hookMessages = await processSessionStartHooks('resume', { sessionId, agentType: mainThreadAgentDefinition?.agentType, model: mainLoopModel, - }) + }); // Append hook messages to the conversation - messages.push(...hookMessages) + messages.push(...hookMessages); // For forks, generate a new plan slug and copy the plan content so the // original and forked sessions don't clobber each other's plan files. // For regular resumes, reuse the original session's plan slug. if (entrypoint === 'fork') { - void copyPlanForFork(log, asSessionId(sessionId)) + void copyPlanForFork(log, asSessionId(sessionId)); } else { - void copyPlanForResume(log, asSessionId(sessionId)) + void copyPlanForResume(log, asSessionId(sessionId)); } // Restore file history and attribution state from the resumed conversation - restoreSessionStateFromLog(log, setAppState) + restoreSessionStateFromLog(log, setAppState); if (log.fileHistorySnapshots) { - void copyFileHistoryForResume(log) + void copyFileHistoryForResume(log); } // Restore agent setting from the resumed conversation @@ -2433,66 +2105,58 @@ export function REPL({ log.agentSetting, initialMainThreadAgentDefinition, agentDefinitions, - ) - setMainThreadAgentDefinition(restoredAgent) - setAppState(prev => ({ ...prev, agent: restoredAgent?.agentType })) + ); + setMainThreadAgentDefinition(restoredAgent); + setAppState(prev => ({ ...prev, agent: restoredAgent?.agentType })); // Restore standalone agent context from the resumed conversation // Always reset to the new session's values (or clear if none) setAppState(prev => ({ ...prev, - standaloneAgentContext: computeStandaloneAgentContext( - log.agentName, - log.agentColor, - ), - })) - void updateSessionName(log.agentName) + standaloneAgentContext: computeStandaloneAgentContext(log.agentName, log.agentColor), + })); + void updateSessionName(log.agentName); // Restore read file state from the message history - restoreReadFileState(messages, log.projectPath ?? getOriginalCwd()) + restoreReadFileState(messages, log.projectPath ?? getOriginalCwd()); // Clear any active loading state (no queryId since we're not in a query) - resetLoadingState() - setAbortController(null) + resetLoadingState(); + setAbortController(null); - setConversationId(sessionId) + setConversationId(sessionId); // Get target session's costs BEFORE saving current session // (saveCurrentSessionCosts overwrites the config, so we need to read first) - const targetSessionCosts = getStoredSessionCosts(sessionId) + const targetSessionCosts = getStoredSessionCosts(sessionId); // Save current session's costs before switching to avoid losing accumulated costs - saveCurrentSessionCosts() + saveCurrentSessionCosts(); // Reset cost state for clean slate before restoring target session - resetCostState() + resetCostState(); // Switch session (id + project dir atomically). fullPath may point to // a different project (cross-worktree, /branch); null derives from // current originalCwd. - switchSession( - asSessionId(sessionId), - log.fullPath ? dirname(log.fullPath) : null, - ) + switchSession(asSessionId(sessionId), log.fullPath ? dirname(log.fullPath) : null); // Rename asciicast recording to match the resumed session ID - const { renameRecordingForSession } = await import( - '../utils/asciicast.js' - ) - await renameRecordingForSession() - await resetSessionFilePointer() + const { renameRecordingForSession } = await import('../utils/asciicast.js'); + await renameRecordingForSession(); + await resetSessionFilePointer(); // Clear then restore session metadata so it's re-appended on exit via // reAppendSessionMetadata. clearSessionMetadata must be called first: // restoreSessionMetadata only sets-if-truthy, so without the clear, // a session without an agent name would inherit the previous session's // cached name and write it to the wrong transcript on first message. - clearSessionMetadata() - restoreSessionMetadata(log) + clearSessionMetadata(); + restoreSessionMetadata(log); // Resumed sessions shouldn't re-title from mid-conversation context // (same reasoning as the useRef seed), and the previous session's // Haiku title shouldn't carry over. - haikuTitleAttemptedRef.current = true - setHaikuTitle(undefined) + haikuTitleAttemptedRef.current = true; + setHaikuTitle(undefined); // Exit any worktree a prior /resume entered, then cd into the one // this session was in. Without the exit, resuming from worktree B @@ -2505,35 +2169,35 @@ export function REPL({ // in. Same fork skip as processResumedConversation for the adopt — // fork materializes its own file via recordTranscript on REPL mount. if (entrypoint !== 'fork') { - exitRestoredWorktree() - restoreWorktreeForResume(log.worktreeSession) - adoptResumedSessionFile() + exitRestoredWorktree(); + restoreWorktreeForResume(log.worktreeSession); + adoptResumedSessionFile(); void restoreRemoteAgentTasks({ abortController: new AbortController(), getAppState: () => store.getState(), setAppState, - }) + }); } else { // Fork: same re-persist as /clear (conversation.ts). The clear // above wiped currentSessionWorktree, forkLog doesn't carry it, // and the process is still in the same worktree. - const ws = getCurrentWorktreeSession() - if (ws) saveWorktreeState(ws) + const ws = getCurrentWorktreeSession(); + if (ws) saveWorktreeState(ws); } // Persist the current mode so future resumes know what mode this session was in 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'); } // Restore target session's costs from the data we read earlier if (targetSessionCosts) { - setCostStateForRestore(targetSessionCosts) + setCostStateForRestore(targetSessionCosts); } // Reconstruct replacement state for the resumed session. Runs after @@ -2548,114 +2212,98 @@ export function REPL({ // createFork() does write content-replacement entries to the forked // JSONL with the fork's sessionId, so `claude -r {forkId}` also works. if (contentReplacementStateRef.current && entrypoint !== 'fork') { - contentReplacementStateRef.current = - reconstructContentReplacementState( - messages, - log.contentReplacements ?? [], - ) + contentReplacementStateRef.current = reconstructContentReplacementState( + messages, + log.contentReplacements ?? [], + ); } // Reset messages to the provided initial messages // Use a callback to ensure we're not dependent on stale state - setMessages(() => messages) + setMessages(() => messages); // Clear any active tool JSX - setToolJSX(null) + setToolJSX(null); // Clear input to ensure no residual state - setInputValue('') + setInputValue(''); logEvent('tengu_session_resumed', { - entrypoint: - entrypoint as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + entrypoint: entrypoint 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: - entrypoint as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + entrypoint: entrypoint as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, success: false, - }) - throw error + }); + throw error; } }, [resetLoadingState, setAppState], - ) + ); // Lazy init: useRef(createX()) would call createX on every render and // discard the result. LRUCache construction inside FileStateCache is // expensive (~170ms), so we use useState's lazy initializer to create // it exactly once, then feed that stable reference into useRef. - const [initialReadFileState] = useState(() => - createFileStateCacheWithSizeLimit(READ_FILE_STATE_CACHE_SIZE), - ) - const readFileState = useRef(initialReadFileState) - const bashTools = useRef(new Set()) - const bashToolsProcessedIdx = useRef(0) + const [initialReadFileState] = useState(() => createFileStateCacheWithSizeLimit(READ_FILE_STATE_CACHE_SIZE)); + const readFileState = useRef(initialReadFileState); + const bashTools = useRef(new Set()); + const bashToolsProcessedIdx = useRef(0); // Session-scoped skill discovery tracking (feeds was_discovered on // tengu_skill_tool_invocation). Must persist across getToolUseContext // rebuilds within a session: turn-0 discovery writes via processUserInput // before onQuery builds its own context, and discovery on turn N must // still attribute a SkillTool call on turn N+k. Cleared in clearConversation. - const discoveredSkillNamesRef = useRef(new Set()) + const discoveredSkillNamesRef = useRef(new Set()); // Session-level dedup for nested_memory CLAUDE.md attachments. // readFileState is a 100-entry LRU; once it evicts a CLAUDE.md path, // the next discovery cycle re-injects it. Cleared in clearConversation. - const loadedNestedMemoryPathsRef = useRef(new Set()) + const loadedNestedMemoryPathsRef = useRef(new Set()); // Helper to restore read file state from messages (used for resume flows) // This allows Claude to edit files that were read in previous sessions - const restoreReadFileState = useCallback( - (messages: MessageType[], cwd: string) => { - const extracted = extractReadFilesFromMessages( - messages, - cwd, - READ_FILE_STATE_CACHE_SIZE, - ) - readFileState.current = mergeFileStateCaches( - readFileState.current, - extracted, - ) - for (const tool of extractBashToolsFromMessages(messages)) { - bashTools.current.add(tool) - } - }, - [], - ) + const restoreReadFileState = useCallback((messages: MessageType[], cwd: string) => { + const extracted = extractReadFilesFromMessages(messages, cwd, READ_FILE_STATE_CACHE_SIZE); + readFileState.current = mergeFileStateCaches(readFileState.current, extracted); + for (const tool of extractBashToolsFromMessages(messages)) { + bashTools.current.add(tool); + } + }, []); // Extract read file state from initialMessages on mount // This handles CLI flag resume (--resume-session) and ResumeConversation screen // where messages are passed as props rather than through the resume callback useEffect(() => { if (initialMessages && initialMessages.length > 0) { - restoreReadFileState(initialMessages, getOriginalCwd()) + restoreReadFileState(initialMessages, getOriginalCwd()); void restoreRemoteAgentTasks({ abortController: new AbortController(), getAppState: () => store.getState(), setAppState, - }) + }); } // Only run on mount - initialMessages shouldn't change during component lifetime // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, []); - const { status: apiKeyStatus, reverify } = useApiKeyVerification() + const { status: apiKeyStatus, reverify } = useApiKeyVerification(); // Auto-run /issue state - const [autoRunIssueReason, setAutoRunIssueReason] = - useState(null) + const [autoRunIssueReason, setAutoRunIssueReason] = useState(null); // Ref to track if autoRunIssue was triggered this survey cycle, // so we can suppress the [1] follow-up prompt even after // autoRunIssueReason is cleared. - const didAutoRunIssueRef = useRef(false) + const didAutoRunIssueRef = useRef(false); // State for exit feedback flow - const [exitFlow, setExitFlow] = useState(null) - const [isExiting, setIsExiting] = useState(false) + const [exitFlow, setExitFlow] = useState(null); + const [isExiting, setIsExiting] = useState(false); // Calculate if cost dialog should be shown - const showingCostDialog = !isLoading && showCostDialog + const showingCostDialog = !isLoading && showCostDialog; // Determine which dialog should have focus (if any) // Permission and interactive dialogs can show even when toolJSX is set, @@ -2683,86 +2331,62 @@ export function REPL({ | 'ultraplan-launch' | undefined { // Exit states always take precedence - if (isExiting || exitFlow) return undefined + if (isExiting || exitFlow) return undefined; // High priority dialogs (always show regardless of typing) - if (isMessageSelectorVisible) return 'message-selector' + if (isMessageSelectorVisible) return 'message-selector'; // Suppress interrupt dialogs while user is actively typing - if (isPromptInputActive) return undefined + if (isPromptInputActive) return undefined; - if (sandboxPermissionRequestQueue[0]) return 'sandbox-permission' + if (sandboxPermissionRequestQueue[0]) return 'sandbox-permission'; // Permission/interactive dialogs (show unless blocked by toolJSX) - const allowDialogsWithAnimation = - !toolJSX || toolJSX.shouldContinueAnimation + const allowDialogsWithAnimation = !toolJSX || toolJSX.shouldContinueAnimation; - if (allowDialogsWithAnimation && toolUseConfirmQueue[0]) - return 'tool-permission' - if (allowDialogsWithAnimation && promptQueue[0]) return 'prompt' + if (allowDialogsWithAnimation && toolUseConfirmQueue[0]) return 'tool-permission'; + if (allowDialogsWithAnimation && promptQueue[0]) return 'prompt'; // Worker sandbox permission prompts (network access) from swarm workers - if (allowDialogsWithAnimation && workerSandboxPermissions.queue[0]) - return 'worker-sandbox-permission' - if (allowDialogsWithAnimation && elicitation.queue[0]) return 'elicitation' - if (allowDialogsWithAnimation && showingCostDialog) return 'cost' - if (allowDialogsWithAnimation && idleReturnPending) return 'idle-return' - - if ( - feature('ULTRAPLAN') && - allowDialogsWithAnimation && - !isLoading && - ultraplanPendingChoice - ) - return 'ultraplan-choice' - - if ( - feature('ULTRAPLAN') && - allowDialogsWithAnimation && - !isLoading && - ultraplanLaunchPending - ) - return 'ultraplan-launch' + if (allowDialogsWithAnimation && workerSandboxPermissions.queue[0]) return 'worker-sandbox-permission'; + if (allowDialogsWithAnimation && elicitation.queue[0]) return 'elicitation'; + if (allowDialogsWithAnimation && showingCostDialog) return 'cost'; + if (allowDialogsWithAnimation && idleReturnPending) return 'idle-return'; + + if (feature('ULTRAPLAN') && allowDialogsWithAnimation && !isLoading && ultraplanPendingChoice) + return 'ultraplan-choice'; + + if (feature('ULTRAPLAN') && allowDialogsWithAnimation && !isLoading && ultraplanLaunchPending) + return 'ultraplan-launch'; // Onboarding dialogs (special conditions) - if (allowDialogsWithAnimation && showIdeOnboarding) return 'ide-onboarding' + if (allowDialogsWithAnimation && showIdeOnboarding) return 'ide-onboarding'; // Model switch callout (ant-only, eliminated from external builds) - if ( - process.env.USER_TYPE === 'ant' && - allowDialogsWithAnimation && - showModelSwitchCallout - ) - return 'model-switch' + if (process.env.USER_TYPE === 'ant' && allowDialogsWithAnimation && showModelSwitchCallout) return 'model-switch'; // Undercover auto-enable explainer (ant-only, eliminated from external builds) - if ( - process.env.USER_TYPE === 'ant' && - allowDialogsWithAnimation && - showUndercoverCallout - ) - return 'undercover-callout' + if (process.env.USER_TYPE === 'ant' && allowDialogsWithAnimation && showUndercoverCallout) + return 'undercover-callout'; // Effort callout (shown once for Opus 4.6 users when effort is enabled) - if (allowDialogsWithAnimation && showEffortCallout) return 'effort-callout' + if (allowDialogsWithAnimation && showEffortCallout) return 'effort-callout'; // Remote callout (shown once before first bridge enable) - if (allowDialogsWithAnimation && showRemoteCallout) return 'remote-callout' + if (allowDialogsWithAnimation && showRemoteCallout) return 'remote-callout'; // LSP plugin recommendation (lowest priority - non-blocking suggestion) - if (allowDialogsWithAnimation && lspRecommendation) - return 'lsp-recommendation' + if (allowDialogsWithAnimation && lspRecommendation) return 'lsp-recommendation'; // Plugin hint from CLI/SDK stderr (same priority band as LSP rec) - if (allowDialogsWithAnimation && hintRecommendation) return 'plugin-hint' + if (allowDialogsWithAnimation && hintRecommendation) return 'plugin-hint'; // Desktop app upsell (max 3 launches, lowest priority) - if (allowDialogsWithAnimation && showDesktopUpsellStartup) - return 'desktop-upsell' + if (allowDialogsWithAnimation && showDesktopUpsellStartup) return 'desktop-upsell'; - return undefined + return undefined; } - const focusedInputDialog = getFocusedInputDialog() + const focusedInputDialog = getFocusedInputDialog(); // True when permission prompts exist but are hidden because the user is typing const hasSuppressedDialogs = @@ -2772,29 +2396,29 @@ export function REPL({ promptQueue[0] || workerSandboxPermissions.queue[0] || elicitation.queue[0] || - showingCostDialog) + showingCostDialog); // Keep ref in sync so timer callbacks can read the current value - focusedInputDialogRef.current = focusedInputDialog + focusedInputDialogRef.current = focusedInputDialog; // Immediately capture pause/resume when focusedInputDialog changes // This ensures accurate timing even under high system load, rather than // relying on the 100ms polling interval to detect state changes useEffect(() => { - if (!isLoading) return + if (!isLoading) return; - const isPaused = focusedInputDialog === 'tool-permission' - const now = Date.now() + const isPaused = focusedInputDialog === 'tool-permission'; + const now = Date.now(); if (isPaused && pauseStartTimeRef.current === null) { // Just entered pause state - record the exact moment - pauseStartTimeRef.current = now + pauseStartTimeRef.current = now; } else if (!isPaused && pauseStartTimeRef.current !== null) { // Just exited pause state - accumulate paused time immediately - totalPausedMsRef.current += now - pauseStartTimeRef.current - pauseStartTimeRef.current = null + totalPausedMsRef.current += now - pauseStartTimeRef.current; + pauseStartTimeRef.current = null; } - }, [focusedInputDialog, isLoading]) + }, [focusedInputDialog, isLoading]); // Re-pin scroll to bottom whenever the permission overlay appears or // dismisses. Overlay now renders below messages inside the same @@ -2805,105 +2429,99 @@ export function REPL({ // overlay, and onScroll was suppressed so the pill state is stale // useLayoutEffect so the re-pin commits before the Ink frame renders — // no 1-frame flash of the wrong scroll position. - const prevDialogRef = useRef(focusedInputDialog) + const prevDialogRef = useRef(focusedInputDialog); useLayoutEffect(() => { - const was = prevDialogRef.current === 'tool-permission' - const now = focusedInputDialog === 'tool-permission' - if (was !== now) repinScroll() - prevDialogRef.current = focusedInputDialog - }, [focusedInputDialog, repinScroll]) + const was = prevDialogRef.current === 'tool-permission'; + const now = focusedInputDialog === 'tool-permission'; + if (was !== now) repinScroll(); + prevDialogRef.current = focusedInputDialog; + }, [focusedInputDialog, repinScroll]); function onCancel() { if (focusedInputDialog === 'elicitation') { // Elicitation dialog handles its own Escape, and closing it shouldn't affect any loading state. - return + return; } - logForDebugging( - `[onCancel] focusedInputDialog=${focusedInputDialog} streamMode=${streamMode}`, - ) + logForDebugging(`[onCancel] focusedInputDialog=${focusedInputDialog} streamMode=${streamMode}`); // Pause proactive mode so the user gets control back. // It will resume when they submit their next input (see onSubmit). if (feature('PROACTIVE') || feature('KAIROS')) { - proactiveModule?.pauseProactive() + proactiveModule?.pauseProactive(); } - queryGuard.forceEnd() - skipIdleCheckRef.current = false + queryGuard.forceEnd(); + skipIdleCheckRef.current = false; // Preserve partially-streamed text so the user can read what was // generated before pressing Esc. Pushed before resetLoadingState clears // streamingText, and before query.ts yields the async interrupt marker, // giving final order [user, partial-assistant, [Request interrupted by user]]. if (streamingText?.trim()) { - setMessages(prev => [ - ...prev, - createAssistantMessage({ content: streamingText }), - ]) + setMessages(prev => [...prev, createAssistantMessage({ content: streamingText })]); } - resetLoadingState() + resetLoadingState(); // Clear any active token budget so the backstop doesn't fire on // a stale budget if the query generator hasn't exited yet. if (feature('TOKEN_BUDGET')) { - snapshotOutputTokensForTurn(null) + snapshotOutputTokensForTurn(null); } if (focusedInputDialog === 'tool-permission') { // Tool use confirm handles the abort signal itself - toolUseConfirmQueue[0]?.onAbort() - setToolUseConfirmQueue([]) + toolUseConfirmQueue[0]?.onAbort(); + setToolUseConfirmQueue([]); } else if (focusedInputDialog === 'prompt') { // Reject all pending prompts and clear the queue for (const item of promptQueue) { - item.reject(new Error('Prompt cancelled by user')) + item.reject(new Error('Prompt cancelled by user')); } - setPromptQueue([]) - abortController?.abort('user-cancel') + setPromptQueue([]); + abortController?.abort('user-cancel'); } else if (activeRemote.isRemoteMode) { // Remote mode: send interrupt signal to CCR - activeRemote.cancelRequest() + activeRemote.cancelRequest(); } else { - abortController?.abort('user-cancel') + abortController?.abort('user-cancel'); } // Clear the controller so subsequent Escape presses don't see a stale // aborted signal. Without this, canCancelRunningTask is false (signal // defined but .aborted === true), so isActive becomes false if no other // activating conditions hold — leaving the Escape keybinding inactive. - setAbortController(null) + setAbortController(null); // forceEnd() skips the finally path — fire directly (aborted=true). - void mrOnTurnComplete(messagesRef.current, true) + void mrOnTurnComplete(messagesRef.current, true); } // Function to handle queued command when canceling a permission request const handleQueuedCommandOnCancel = useCallback(() => { - const result = popAllEditable(inputValue, 0) - if (!result) return - setInputValue(result.text) - setInputMode('prompt') + const result = popAllEditable(inputValue, 0); + if (!result) return; + setInputValue(result.text); + setInputMode('prompt'); // 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; + }); } - }, [setInputValue, setInputMode, inputValue, setPastedContents]) + }, [setInputValue, setInputMode, inputValue, setPastedContents]); // CancelRequestHandler props - rendered inside KeybindingSetup const cancelRequestProps = { setToolUseConfirmQueue, onCancel, - onAgentsKilled: () => - setMessages(prev => [...prev, createAgentsKilledMessage()]), + onAgentsKilled: () => setMessages(prev => [...prev, createAgentsKilledMessage()]), isMessageSelectorVisible: isMessageSelectorVisible || !!showBashesDialog, screen, abortSignal: abortController?.signal, @@ -2915,33 +2533,30 @@ export function REPL({ inputMode, inputValue, streamMode, - } + }; useEffect(() => { - const totalCost = getTotalCost() + const totalCost = getTotalCost(); if (totalCost >= 5 /* $5 */ && !showCostDialog && !haveShownCostDialog) { - logEvent('tengu_cost_threshold_reached', {}) + logEvent('tengu_cost_threshold_reached', {}); // Mark as shown even if the dialog won't render (no console billing // access). Otherwise this effect re-fires on every message change for // the rest of the session — 200k+ spurious events observed. - setHaveShownCostDialog(true) + setHaveShownCostDialog(true); if (hasConsoleBillingAccess()) { - setShowCostDialog(true) + setShowCostDialog(true); } } - }, [messages, showCostDialog, haveShownCostDialog]) + }, [messages, showCostDialog, haveShownCostDialog]); const sandboxAskCallback: SandboxAskCallback = useCallback( async (hostPattern: NetworkHostPattern) => { // If running as a swarm worker, forward the request to the leader via mailbox if (isAgentSwarmsEnabled() && isSwarmWorker()) { - const requestId = generateSandboxRequestId() + const requestId = generateSandboxRequestId(); // Send the request to the leader via mailbox - const sent = await sendSandboxPermissionRequestViaMailbox( - hostPattern.host, - requestId, - ) + const sent = await sendSandboxPermissionRequestViaMailbox(hostPattern.host, requestId); return new Promise(resolveShouldAllowHost => { if (!sent) { @@ -2952,8 +2567,8 @@ export function REPL({ hostPattern, resolvePromise: resolveShouldAllowHost, }, - ]) - return + ]); + return; } // Register the callback for when the leader responds @@ -2961,7 +2576,7 @@ export function REPL({ requestId, host: hostPattern.host, resolve: resolveShouldAllowHost, - }) + }); // Update AppState to show pending indicator setAppState(prev => ({ @@ -2970,18 +2585,18 @@ export function REPL({ requestId, host: hostPattern.host, }, - })) - }) + })); + }); } // Normal flow for non-workers: show local UI and optionally race // against the REPL bridge (Remote Control) if connected. return new Promise(resolveShouldAllowHost => { - let resolved = false + let resolved = false; function resolveOnce(allow: boolean): void { - if (resolved) return - resolved = true - resolveShouldAllowHost(allow) + if (resolved) return; + resolved = true; + resolveShouldAllowHost(allow); } // Queue the local sandbox permission dialog @@ -2991,69 +2606,61 @@ export function REPL({ hostPattern, resolvePromise: resolveOnce, }, - ]) + ]); // When the REPL bridge is connected, also forward the sandbox // permission request as a can_use_tool control_request so the // remote user (e.g. on claude.ai) can approve it too. if (feature('BRIDGE_MODE')) { - const bridgeCallbacks = store.getState().replBridgePermissionCallbacks + const bridgeCallbacks = store.getState().replBridgePermissionCallbacks; if (bridgeCallbacks) { - const bridgeRequestId = randomUUID() + const bridgeRequestId = randomUUID(); bridgeCallbacks.sendRequest( bridgeRequestId, SANDBOX_NETWORK_ACCESS_TOOL_NAME, { host: hostPattern.host }, randomUUID(), `Allow network connection to ${hostPattern.host}?`, - ) - - const unsubscribe = bridgeCallbacks.onResponse( - bridgeRequestId, - response => { - unsubscribe() - const allow = response.behavior === 'allow' - // Resolve ALL pending requests for the same host, not just - // this one — mirrors the local dialog handler pattern. - setSandboxPermissionRequestQueue(queue => { - queue - .filter(item => item.hostPattern.host === hostPattern.host) - .forEach(item => item.resolvePromise(allow)) - return queue.filter( - item => item.hostPattern.host !== hostPattern.host, - ) - }) - // Clean up all sibling bridge subscriptions for this host - // (other concurrent same-host requests) before deleting. - const siblingCleanups = sandboxBridgeCleanupRef.current.get( - hostPattern.host, - ) - if (siblingCleanups) { - for (const fn of siblingCleanups) { - fn() - } - sandboxBridgeCleanupRef.current.delete(hostPattern.host) + ); + + const unsubscribe = bridgeCallbacks.onResponse(bridgeRequestId, response => { + unsubscribe(); + const allow = response.behavior === 'allow'; + // Resolve ALL pending requests for the same host, not just + // this one — mirrors the local dialog handler pattern. + setSandboxPermissionRequestQueue(queue => { + queue + .filter(item => item.hostPattern.host === hostPattern.host) + .forEach(item => item.resolvePromise(allow)); + return queue.filter(item => item.hostPattern.host !== hostPattern.host); + }); + // Clean up all sibling bridge subscriptions for this host + // (other concurrent same-host requests) before deleting. + const siblingCleanups = sandboxBridgeCleanupRef.current.get(hostPattern.host); + if (siblingCleanups) { + for (const fn of siblingCleanups) { + fn(); } - }, - ) + sandboxBridgeCleanupRef.current.delete(hostPattern.host); + } + }); // Register cleanup so the local dialog handler can cancel // the remote prompt and unsubscribe when the local user // responds first. const cleanup = () => { - unsubscribe() - bridgeCallbacks.cancelRequest(bridgeRequestId) - } - const existing = - sandboxBridgeCleanupRef.current.get(hostPattern.host) ?? [] - existing.push(cleanup) - sandboxBridgeCleanupRef.current.set(hostPattern.host, existing) + unsubscribe(); + bridgeCallbacks.cancelRequest(bridgeRequestId); + }; + const existing = sandboxBridgeCleanupRef.current.get(hostPattern.host) ?? []; + existing.push(cleanup); + sandboxBridgeCleanupRef.current.set(hostPattern.host, existing); } } - }) + }); }, [setAppState, store], - ) + ); // #34044: if user explicitly set sandbox.enabled=true but deps are missing, // isSandboxingEnabled() returns false silently. Surface the reason once at @@ -3061,17 +2668,17 @@ export function REPL({ // reason goes to debug log; notification points to /sandbox for details. // addNotification is stable (useCallback) so the effect fires once. useEffect(() => { - const reason = SandboxManager.getSandboxUnavailableReason() - if (!reason) return + const reason = SandboxManager.getSandboxUnavailableReason(); + if (!reason) return; if (SandboxManager.isSandboxRequired()) { process.stderr.write( `\nError: sandbox required but unavailable: ${reason}\n` + ` sandbox.failIfUnavailable is set — refusing to start without a working sandbox.\n\n`, - ) - gracefulShutdownSync(1, 'other') - return + ); + gracefulShutdownSync(1, 'other'); + return; } - logForDebugging(`sandbox disabled: ${reason}`, { level: 'warn' }) + logForDebugging(`sandbox disabled: ${reason}`, { level: 'warn' }); addNotification({ key: 'sandbox-unavailable', jsx: ( @@ -3081,16 +2688,16 @@ export function REPL({ ), priority: 'medium', - }) - }, [addNotification]) + }); + }, [addNotification]); if (SandboxManager.isSandboxingEnabled()) { // If sandboxing is enabled (setting.sandbox is defined, initialise the manager) SandboxManager.initialize(sandboxAskCallback).catch(err => { // Initialization/validation failed - display error and exit - process.stderr.write(`\n❌ Sandbox Error: ${errorMessage(err)}\n`) - gracefulShutdownSync(1, 'other') - }) + process.stderr.write(`\n❌ Sandbox Error: ${errorMessage(err)}\n`); + gracefulShutdownSync(1, 'other'); + }); } const setToolPermissionContext = useCallback( @@ -3105,11 +2712,9 @@ export function REPL({ // state via permission-rule updates — those call sites pass // { preserveMode: true }. User-initiated mode changes (e.g., // selecting "allow all edits") must NOT be overridden. - mode: options?.preserveMode - ? prev.toolPermissionContext.mode - : context.mode, + mode: options?.preserveMode ? prev.toolPermissionContext.mode : context.mode, }, - })) + })); // When permission context changes, recheck all queued items // This handles the case where approving item1 with "don't ask again" @@ -3119,37 +2724,31 @@ export function REPL({ // instead of capturing it in the closure, to avoid stale closure issues setToolUseConfirmQueue(currentQueue => { currentQueue.forEach(item => { - void item.recheckPermission() - }) - return currentQueue - }) - }, setToolUseConfirmQueue) + void item.recheckPermission(); + }); + return currentQueue; + }); + }, setToolUseConfirmQueue); }, [setAppState, setToolUseConfirmQueue], - ) + ); // Register the leader's setToolPermissionContext for in-process teammates useEffect(() => { - registerLeaderSetToolPermissionContext(setToolPermissionContext) - return () => unregisterLeaderSetToolPermissionContext() - }, [setToolPermissionContext]) + registerLeaderSetToolPermissionContext(setToolPermissionContext); + return () => unregisterLeaderSetToolPermissionContext(); + }, [setToolPermissionContext]); - const canUseTool = useCanUseTool( - setToolUseConfirmQueue, - setToolPermissionContext, - ) + const canUseTool = useCanUseTool(setToolUseConfirmQueue, setToolPermissionContext); const requestPrompt = useCallback( (title: string, toolInputSummary?: string | null) => (request: PromptRequest): Promise => new Promise((resolve, reject) => { - setPromptQueue(prev => [ - ...prev, - { request, title, toolInputSummary, resolve, reject }, - ]) + setPromptQueue(prev => [...prev, { request, title, toolInputSummary, resolve, reject }]); }), [], - ) + ); const getToolUseContext = useCallback( ( @@ -3162,7 +2761,7 @@ export function REPL({ // useAppState() snapshots. Same values today (closure is refreshed by the // render between turns); decouples freshness from React's render cycle for // a future headless conversation loop. Same pattern refreshTools() uses. - const s = store.getState() + const s = store.getState(); // Compute tools fresh from store.getState() rather than the closure- // captured `tools`. useManageMCPConnections populates appState.mcp @@ -3170,20 +2769,12 @@ export function REPL({ // the closure captured at render time. Also doubles as refreshTools() // for mid-query tool list updates. const computeTools = () => { - const state = store.getState() - const assembled = assembleToolPool( - state.toolPermissionContext, - state.mcp.tools, - ) - const merged = mergeAndFilterTools( - combinedInitialTools, - assembled, - state.toolPermissionContext.mode, - ) - if (!mainThreadAgentDefinition) return merged - return resolveAgentTools(mainThreadAgentDefinition, merged, false, true) - .resolvedTools - } + const state = store.getState(); + const assembled = assembleToolPool(state.toolPermissionContext, state.mcp.tools); + const merged = mergeAndFilterTools(combinedInitialTools, assembled, state.toolPermissionContext.mode); + if (!mainThreadAgentDefinition) return merged; + return resolveAgentTools(mainThreadAgentDefinition, merged, false, true).resolvedTools; + }; return { abortController, @@ -3193,8 +2784,7 @@ export function REPL({ debug, verbose: s.verbose, mainLoopModel, - thinkingConfig: - s.thinkingEnabled !== false ? thinkingConfig : { type: 'disabled' }, + thinkingConfig: s.thinkingEnabled !== false ? thinkingConfig : { type: 'disabled' }, // Merge fresh from store rather than closing over useMergedClients' // memoized output. initialMcpClients is a prop (session-constant). mcpClients: mergeClients(initialMcpClients, s.mcp.clients), @@ -3203,9 +2793,7 @@ export function REPL({ isNonInteractiveSession: false, dynamicMcpConfig, theme, - agentDefinitions: allowedAgentTypes - ? { ...s.agentDefinitions, allowedAgentTypes } - : s.agentDefinitions, + agentDefinitions: allowedAgentTypes ? { ...s.agentDefinitions, allowedAgentTypes } : s.agentDefinitions, customSystemPrompt, appendSystemPrompt, refreshTools: computeTools, @@ -3214,30 +2802,26 @@ export function REPL({ setAppState, messages, setMessages, - updateFileHistoryState( - updater: (prev: FileHistoryState) => FileHistoryState, - ) { + updateFileHistoryState(updater: (prev: FileHistoryState) => FileHistoryState) { // Perf: skip the setState when the updater returns the same reference // (e.g. fileHistoryTrackEdit returns `state` when the file is already // tracked). Otherwise every no-op call would notify all store listeners. setAppState(prev => { - const updated = updater(prev.fileHistory) - if (updated === prev.fileHistory) return prev - return { ...prev, fileHistory: updated } - }) + const updated = updater(prev.fileHistory); + if (updated === prev.fileHistory) return prev; + return { ...prev, fileHistory: updated }; + }); }, - updateAttributionState( - updater: (prev: AttributionState) => AttributionState, - ) { + updateAttributionState(updater: (prev: AttributionState) => AttributionState) { setAppState(prev => { - const updated = updater(prev.attribution) - if (updated === prev.attribution) return prev - return { ...prev, attribution: updated } - }) + const updated = updater(prev.attribution); + if (updated === prev.attribution) return prev; + return { ...prev, attribution: updated }; + }); }, openMessageSelector: () => { if (!disabled) { - setIsMessageSelectorVisible(true) + setIsMessageSelectorVisible(true); } }, onChangeAPIKey: reverify, @@ -3246,7 +2830,7 @@ export function REPL({ addNotification, appendSystemMessage: msg => setMessages(prev => [...prev, msg]), sendOSNotification: opts => { - void sendNotification(opts, terminal) + void sendNotification(opts, terminal); }, onChangeDynamicMcpConfig, onInstallIDEExtension: setIDEToInstallExtension, @@ -3258,50 +2842,50 @@ export function REPL({ pushApiMetricsEntry: process.env.USER_TYPE === 'ant' ? (ttftMs: number) => { - const now = Date.now() - const baseline = responseLengthRef.current + const now = Date.now(); + const baseline = responseLengthRef.current; apiMetricsRef.current.push({ ttftMs, firstTokenTime: now, lastTokenTime: now, responseLengthBaseline: baseline, endResponseLength: baseline, - }) + }); } : undefined, setStreamMode, onCompactProgress: event => { switch (event.type) { case 'hooks_start': - setSpinnerColor('claudeBlue_FOR_SYSTEM_SPINNER') - setSpinnerShimmerColor('claudeBlueShimmer_FOR_SYSTEM_SPINNER') + setSpinnerColor('claudeBlue_FOR_SYSTEM_SPINNER'); + setSpinnerShimmerColor('claudeBlueShimmer_FOR_SYSTEM_SPINNER'); setSpinnerMessage( event.hookType === 'pre_compact' ? 'Running PreCompact hooks\u2026' : event.hookType === 'post_compact' ? 'Running PostCompact hooks\u2026' : 'Running SessionStart hooks\u2026', - ) - break + ); + break; case 'compact_start': - setSpinnerMessage('Compacting conversation') - break + setSpinnerMessage('Compacting conversation'); + break; case 'compact_end': - setSpinnerMessage(null) - setSpinnerColor(null) - setSpinnerShimmerColor(null) - break + setSpinnerMessage(null); + setSpinnerColor(null); + setSpinnerShimmerColor(null); + break; } }, setInProgressToolUseIDs, setHasInterruptibleToolInProgress: (v: boolean) => { - hasInterruptibleToolInProgressRef.current = v + hasInterruptibleToolInProgressRef.current = v; }, resume, setConversationId, requestPrompt: feature('HOOK_PROMPTS') ? requestPrompt : undefined, contentReplacementState: contentReplacementStateRef.current, - } + }; }, [ commands, @@ -3326,40 +2910,30 @@ export function REPL({ appendSystemPrompt, setConversationId, ], - ) + ); // Session backgrounding (Ctrl+B to background/foreground) const handleBackgroundQuery = useCallback(() => { // Stop the foreground query so the background one takes over - abortController?.abort('background') + abortController?.abort('background'); // Aborting subagents may produce task-completed notifications. // Clear task notifications so the queue processor doesn't immediately // start a new foreground query; forward them to the background session. - const removedNotifications = removeByFilter( - cmd => cmd.mode === 'task-notification', - ) + const removedNotifications = removeByFilter(cmd => cmd.mode === 'task-notification'); void (async () => { - const toolUseContext = getToolUseContext( - messagesRef.current, - [], - new AbortController(), - mainLoopModel, - ) - - const [defaultSystemPrompt, userContext, systemContext] = - await Promise.all([ - getSystemPrompt( - toolUseContext.options.tools, - mainLoopModel, - Array.from( - toolPermissionContext.additionalWorkingDirectories.keys(), - ), - toolUseContext.options.mcpClients, - ), - getUserContext(), - getSystemContext(), - ]) + const toolUseContext = getToolUseContext(messagesRef.current, [], new AbortController(), mainLoopModel); + + const [defaultSystemPrompt, userContext, systemContext] = await Promise.all([ + getSystemPrompt( + toolUseContext.options.tools, + mainLoopModel, + Array.from(toolPermissionContext.additionalWorkingDirectories.keys()), + toolUseContext.options.mcpClients, + ), + getUserContext(), + getSystemContext(), + ]); const systemPrompt = buildEffectiveSystemPrompt({ mainThreadAgentDefinition, @@ -3367,22 +2941,18 @@ export function REPL({ customSystemPrompt, defaultSystemPrompt, appendSystemPrompt, - }) - toolUseContext.renderedSystemPrompt = systemPrompt + }); + toolUseContext.renderedSystemPrompt = systemPrompt; - const notificationAttachments = await getQueuedCommandAttachments( - removedNotifications, - ).catch(() => []) - const notificationMessages = notificationAttachments.map( - createAttachmentMessage, - ) + const notificationAttachments = await getQueuedCommandAttachments(removedNotifications).catch(() => []); + const notificationMessages = notificationAttachments.map(createAttachmentMessage); // Deduplicate: if the query loop already yielded a notification into // messagesRef before we removed it from the queue, skip duplicates. // We use prompt text for dedup because source_uuid is not set on // task-notification QueuedCommands (enqueuePendingNotification callers // don't pass uuid), so it would always be undefined. - const existingPrompts = new Set() + const existingPrompts = new Set(); for (const m of messagesRef.current) { if ( m.type === 'attachment' && @@ -3390,15 +2960,14 @@ export function REPL({ m.attachment.commandMode === 'task-notification' && typeof m.attachment.prompt === 'string' ) { - existingPrompts.add(m.attachment.prompt) + existingPrompts.add(m.attachment.prompt); } } const uniqueNotifications = notificationMessages.filter( m => m.attachment.type === 'queued_command' && - (typeof m.attachment.prompt !== 'string' || - !existingPrompts.has(m.attachment.prompt)), - ) + (typeof m.attachment.prompt !== 'string' || !existingPrompts.has(m.attachment.prompt)), + ); startBackgroundSession({ messages: [...messagesRef.current, ...uniqueNotifications], @@ -3413,8 +2982,8 @@ export function REPL({ description: terminalTitle, setAppState, agentDefinition: mainThreadAgentDefinition, - }) - })() + }); + })(); }, [ abortController, mainLoopModel, @@ -3425,7 +2994,7 @@ export function REPL({ appendSystemPrompt, canUseTool, setAppState, - ]) + ]); const { handleBackgroundSession } = useSessionBackgrounding({ setMessages, @@ -3433,7 +3002,7 @@ export function REPL({ resetLoadingState, setAbortController, onBackgroundQuery: handleBackgroundQuery, - }) + }); const onQueryEvent = useCallback( (event: Parameters[0]) => { @@ -3454,21 +3023,18 @@ export function REPL({ includeSnipped: true, }), newMessage, - ]) + ]); } else { - setMessages(() => [newMessage]) + setMessages(() => [newMessage]); } // Bump conversationId so Messages.tsx row keys change and // stale memoized rows remount with post-compact content. - setConversationId(randomUUID()) + setConversationId(randomUUID()); // Compaction succeeded — clear the context-blocked flag so ticks resume if (feature('PROACTIVE') || feature('KAIROS')) { - proactiveModule?.setContextBlocked(false) + proactiveModule?.setContextBlocked(false); } - } else if ( - newMessage.type === 'progress' && - isEphemeralToolProgress(newMessage.data.type) - ) { + } else if (newMessage.type === 'progress' && isEphemeralToolProgress(newMessage.data.type)) { // Replace the previous ephemeral progress tick for the same tool // call instead of appending. Sleep/Bash emit a tick per second and // only the last one is rendered; appending blows up the messages @@ -3480,33 +3046,29 @@ export function REPL({ // history). Replacing those leaves the AgentTool UI stuck at // "Initializing…" because it renders the full progress trail. setMessages(oldMessages => { - const last = oldMessages.at(-1) + const last = oldMessages.at(-1); if ( last?.type === 'progress' && last.parentToolUseID === newMessage.parentToolUseID && last.data.type === newMessage.data.type ) { - const copy = oldMessages.slice() - copy[copy.length - 1] = newMessage - return copy + const copy = oldMessages.slice(); + copy[copy.length - 1] = newMessage; + return copy; } - return [...oldMessages, newMessage] - }) + return [...oldMessages, newMessage]; + }); } else { - setMessages(oldMessages => [...oldMessages, newMessage]) + setMessages(oldMessages => [...oldMessages, newMessage]); } // Block ticks on API errors to prevent tick → error → tick // runaway loops (e.g., auth failure, rate limit, blocking limit). // Cleared on compact boundary (above) or successful response (below). if (feature('PROACTIVE') || feature('KAIROS')) { - if ( - newMessage.type === 'assistant' && - 'isApiErrorMessage' in newMessage && - newMessage.isApiErrorMessage - ) { - proactiveModule?.setContextBlocked(true) + if (newMessage.type === 'assistant' && 'isApiErrorMessage' in newMessage && newMessage.isApiErrorMessage) { + proactiveModule?.setContextBlocked(true); } else if (newMessage.type === 'assistant') { - proactiveModule?.setContextBlocked(false) + proactiveModule?.setContextBlocked(false); } } }, @@ -3514,40 +3076,31 @@ export function REPL({ // setResponseLength handles updating both responseLengthRef (for // spinner animation) and apiMetricsRef (endResponseLength/lastTokenTime // for OTPS). No separate metrics update needed here. - setResponseLength(length => length + newContent.length) + setResponseLength(length => length + newContent.length); }, setStreamMode, setStreamingToolUses, tombstonedMessage => { - setMessages(oldMessages => - oldMessages.filter(m => m !== tombstonedMessage), - ) - void removeTranscriptMessage(tombstonedMessage.uuid) + setMessages(oldMessages => oldMessages.filter(m => m !== tombstonedMessage)); + void removeTranscriptMessage(tombstonedMessage.uuid); }, setStreamingThinking, metrics => { - const now = Date.now() - const baseline = responseLengthRef.current + const now = Date.now(); + const baseline = responseLengthRef.current; apiMetricsRef.current.push({ ...metrics, firstTokenTime: now, lastTokenTime: now, responseLengthBaseline: baseline, endResponseLength: baseline, - }) + }); }, onStreamingText, - ) + ); }, - [ - setMessages, - setResponseLength, - setStreamMode, - setStreamingToolUses, - setStreamingThinking, - onStreamingText, - ], - ) + [setMessages, setResponseLength, setStreamMode, setStreamingToolUses, setStreamingThinking, onStreamingText], + ); const onQueryImpl = useCallback( async ( @@ -3563,19 +3116,16 @@ export function REPL({ // store — useManageMCPConnections may have populated it since the // render that captured this closure (same pattern as computeTools). if (shouldQuery) { - const freshClients = mergeClients( - initialMcpClients, - store.getState().mcp.clients, - ) - void diagnosticTracker.handleQueryStart(freshClients) - const ideClient = getConnectedIdeClient(freshClients) + const freshClients = mergeClients(initialMcpClients, store.getState().mcp.clients); + void diagnosticTracker.handleQueryStart(freshClients); + const ideClient = getConnectedIdeClient(freshClients); if (ideClient) { - void closeOpenDiffs(ideClient) + void closeOpenDiffs(ideClient); } } // Mark onboarding as complete when any user message is sent to Claude - void maybeMarkProjectOnboardingComplete() + void maybeMarkProjectOnboardingComplete(); // Extract a session title from the first real user message. One-shot // via ref (was tengu_birch_mist experiment: first-message-only to save @@ -3584,19 +3134,9 @@ export function REPL({ // useDeferredHookMessages) and attachment messages (appended by // processTextPrompt) — both pushed length past 1 on turn one, so the // title silently fell through to the "Claude Code" default. - if ( - !titleDisabled && - !sessionTitle && - !agentTitle && - !haikuTitleAttemptedRef.current - ) { - const firstUserMessage = newMessages.find( - m => m.type === 'user' && !m.isMeta, - ) - const text = - firstUserMessage?.type === 'user' - ? getContentText(firstUserMessage.message.content) - : null + if (!titleDisabled && !sessionTitle && !agentTitle && !haikuTitleAttemptedRef.current) { + const firstUserMessage = newMessages.find(m => m.type === 'user' && !m.isMeta); + const text = firstUserMessage?.type === 'user' ? getContentText(firstUserMessage.message.content) : null; // Skip synthetic breadcrumbs — slash-command output, prompt-skill // expansions (/commit → ), local-command headers // (/help → ), and bash-mode (!cmd → ). @@ -3608,16 +3148,16 @@ export function REPL({ !text.startsWith(`<${COMMAND_NAME_TAG}>`) && !text.startsWith(`<${BASH_INPUT_TAG}>`) ) { - haikuTitleAttemptedRef.current = true + haikuTitleAttemptedRef.current = true; void generateSessionTitle(text, new AbortController().signal).then( title => { - if (title) setHaikuTitle(title) - else haikuTitleAttemptedRef.current = false + if (title) setHaikuTitle(title); + else haikuTitleAttemptedRef.current = false; }, () => { - haikuTitleAttemptedRef.current = false + haikuTitleAttemptedRef.current = false; }, - ) + ); } } @@ -3632,13 +3172,12 @@ export function REPL({ // ephemeral contexts (permission dialog, BackgroundTasksDialog) from // accidentally clearing it mid-turn. store.setState(prev => { - const cur = prev.toolPermissionContext.alwaysAllowRules.command + const cur = prev.toolPermissionContext.alwaysAllowRules.command; if ( cur === additionalAllowedTools || - (cur?.length === additionalAllowedTools.length && - cur.every((v, i) => v === additionalAllowedTools[i])) + (cur?.length === additionalAllowedTools.length && cur.every((v, i) => v === additionalAllowedTools[i])) ) { - return prev + return prev; } return { ...prev, @@ -3649,8 +3188,8 @@ export function REPL({ command: additionalAllowedTools, }, }, - } - }) + }; + }); // The last message is an assistant message if the user input was a bash command, // or if the user input was an invalid slash command. @@ -3661,14 +3200,14 @@ export function REPL({ if (newMessages.some(isCompactBoundaryMessage)) { // Bump conversationId so Messages.tsx row keys change and // stale memoized rows remount with post-compact content. - setConversationId(randomUUID()) + setConversationId(randomUUID()); if (feature('PROACTIVE') || feature('KAIROS')) { - proactiveModule?.setContextBlocked(false) + proactiveModule?.setContextBlocked(false); } } - resetLoadingState() - setAbortController(null) - return + resetLoadingState(); + setAbortController(null); + return; } const toolUseContext = getToolUseContext( @@ -3676,69 +3215,54 @@ export function REPL({ newMessages, abortController, mainLoopModelParam, - ) + ); // getToolUseContext reads tools/mcpClients fresh from store.getState() // (via computeTools/mergeClients). Use those rather than the closure- // captured `tools`/`mcpClients` — useManageMCPConnections may have // flushed new MCP state between the render that captured this closure // and now. Turn 1 via processInitialMessage is the main beneficiary. - const { tools: freshTools, mcpClients: freshMcpClients } = - toolUseContext.options + const { tools: freshTools, mcpClients: freshMcpClients } = toolUseContext.options; // Scope the skill's effort override to this turn's context only — // wrapping getAppState keeps the override out of the global store so // background agents and UI subscribers (Spinner, LogoV2) never see it. if (effort !== undefined) { - const previousGetAppState = toolUseContext.getAppState + const previousGetAppState = toolUseContext.getAppState; toolUseContext.getAppState = () => ({ ...previousGetAppState(), effortValue: effort, - }) + }); } - queryCheckpoint('query_context_loading_start') - const [, , defaultSystemPrompt, baseUserContext, systemContext] = - await Promise.all([ - // IMPORTANT: do this after setMessages() above, to avoid UI jank - checkAndDisableBypassPermissionsIfNeeded( - toolPermissionContext, - setAppState, - ), - // Gated on TRANSCRIPT_CLASSIFIER so GrowthBook kill switch runs wherever auto mode is built in - feature('TRANSCRIPT_CLASSIFIER') - ? checkAndDisableAutoModeIfNeeded( - toolPermissionContext, - setAppState, - store.getState().fastMode, - ) - : undefined, - getSystemPrompt( - freshTools, - mainLoopModelParam, - Array.from( - toolPermissionContext.additionalWorkingDirectories.keys(), - ), - freshMcpClients, - ), - getUserContext(), - getSystemContext(), - ]) - const userContext = { - ...baseUserContext, - ...getCoordinatorUserContext( + queryCheckpoint('query_context_loading_start'); + const [, , defaultSystemPrompt, baseUserContext, systemContext] = await Promise.all([ + // IMPORTANT: do this after setMessages() above, to avoid UI jank + checkAndDisableBypassPermissionsIfNeeded(toolPermissionContext, setAppState), + // Gated on TRANSCRIPT_CLASSIFIER so GrowthBook kill switch runs wherever auto mode is built in + feature('TRANSCRIPT_CLASSIFIER') + ? checkAndDisableAutoModeIfNeeded(toolPermissionContext, setAppState, store.getState().fastMode) + : undefined, + getSystemPrompt( + freshTools, + mainLoopModelParam, + Array.from(toolPermissionContext.additionalWorkingDirectories.keys()), freshMcpClients, - isScratchpadEnabled() ? getScratchpadDir() : undefined, ), + getUserContext(), + getSystemContext(), + ]); + const userContext = { + ...baseUserContext, + ...getCoordinatorUserContext(freshMcpClients, isScratchpadEnabled() ? getScratchpadDir() : undefined), ...((feature('PROACTIVE') || feature('KAIROS')) && proactiveModule?.isProactiveActive() && !terminalFocusRef.current ? { - terminalFocus: - 'The terminal is unfocused \u2014 the user is not actively watching.', + terminalFocus: 'The terminal is unfocused \u2014 the user is not actively watching.', } : {}), - } - queryCheckpoint('query_context_loading_end') + }; + queryCheckpoint('query_context_loading_end'); const systemPrompt = buildEffectiveSystemPrompt({ mainThreadAgentDefinition, @@ -3746,13 +3270,13 @@ export function REPL({ customSystemPrompt, defaultSystemPrompt, appendSystemPrompt, - }) - toolUseContext.renderedSystemPrompt = systemPrompt + }); + toolUseContext.renderedSystemPrompt = systemPrompt; - queryCheckpoint('query_query_start') - resetTurnHookDuration() - resetTurnToolDuration() - resetTurnClassifierDuration() + queryCheckpoint('query_query_start'); + resetTurnHookDuration(); + resetTurnToolDuration(); + resetTurnClassifierDuration(); for await (const event of query({ messages: messagesIncludingNewMessages, @@ -3763,47 +3287,40 @@ export function REPL({ toolUseContext, querySource: getQuerySourceForREPL(), })) { - onQueryEvent(event) + onQueryEvent(event); } - if (feature('BUDDY') && typeof fireCompanionObserver === 'function') { void fireCompanionObserver(messagesRef.current, reaction => - setAppState(prev => - prev.companionReaction === reaction - ? prev - : { ...prev, companionReaction: reaction }, - ), - ) + setAppState(prev => (prev.companionReaction === reaction ? prev : { ...prev, companionReaction: reaction })), + ); } - queryCheckpoint('query_end') + queryCheckpoint('query_end'); // Capture ant-only API metrics before resetLoadingState clears the ref. // For multi-request turns (tool use loops), compute P50 across all requests. if (process.env.USER_TYPE === 'ant' && apiMetricsRef.current.length > 0) { - const entries = apiMetricsRef.current + const entries = apiMetricsRef.current; - const ttfts = entries.map(e => e.ttftMs) + const ttfts = entries.map(e => e.ttftMs); // Compute per-request OTPS using only active streaming time and // streaming-only content. endResponseLength tracks content added by // streaming deltas only, excluding subagent/compaction inflation. const otpsValues = entries.map(e => { - const delta = Math.round( - (e.endResponseLength - e.responseLengthBaseline) / 4, - ) - const samplingMs = e.lastTokenTime - e.firstTokenTime - return samplingMs > 0 ? Math.round(delta / (samplingMs / 1000)) : 0 - }) - - const isMultiRequest = entries.length > 1 - const hookMs = getTurnHookDurationMs() - const hookCount = getTurnHookCount() - const toolMs = getTurnToolDurationMs() - const toolCount = getTurnToolCount() - const classifierMs = getTurnClassifierDurationMs() - const classifierCount = getTurnClassifierCount() - const turnMs = Date.now() - loadingStartTimeRef.current + const delta = Math.round((e.endResponseLength - e.responseLengthBaseline) / 4); + const samplingMs = e.lastTokenTime - e.firstTokenTime; + return samplingMs > 0 ? Math.round(delta / (samplingMs / 1000)) : 0; + }); + + const isMultiRequest = entries.length > 1; + const hookMs = getTurnHookDurationMs(); + const hookCount = getTurnHookCount(); + const toolMs = getTurnToolDurationMs(); + const toolCount = getTurnToolCount(); + const classifierMs = getTurnClassifierDurationMs(); + const classifierCount = getTurnClassifierCount(); + const turnMs = Date.now() - loadingStartTimeRef.current; setMessages(prev => [ ...prev, createApiMetricsMessage({ @@ -3819,16 +3336,16 @@ export function REPL({ classifierCount: classifierCount > 0 ? classifierCount : undefined, configWriteCount: getGlobalConfigWriteCount(), }), - ]) + ]); } - resetLoadingState() + resetLoadingState(); // Log query profiling report if enabled - logQueryProfileReport() + logQueryProfileReport(); // Signal that a query turn has completed successfully - await onTurnComplete?.(messagesRef.current) + await onTurnComplete?.(messagesRef.current); }, [ initialMcpClients, @@ -3845,7 +3362,7 @@ export function REPL({ sessionTitle, titleDisabled, ], - ) + ); const onQuery = useCallback( async ( @@ -3854,29 +3371,26 @@ export function REPL({ shouldQuery: boolean, additionalAllowedTools: string[], mainLoopModelParam: string, - onBeforeQueryCallback?: ( - input: string, - newMessages: MessageType[], - ) => Promise, + onBeforeQueryCallback?: (input: string, newMessages: MessageType[]) => Promise, input?: string, effort?: EffortValue, ): Promise => { // If this is a teammate, mark them as active when starting a turn if (isAgentSwarmsEnabled()) { - const teamName = getTeamName() - const agentName = getAgentName() + const teamName = getTeamName(); + const agentName = getAgentName(); if (teamName && agentName) { // Fire and forget - turn starts immediately, write happens in background - void setMemberActive(teamName, agentName, true) + void setMemberActive(teamName, agentName, true); } } // Concurrent guard via state machine. tryStart() atomically checks // and transitions idle→running, returning the generation number. // Returns null if already running — no separate check-then-set. - const thisGeneration = queryGuard.tryStart() + const thisGeneration = queryGuard.tryStart(); if (thisGeneration === null) { - logEvent('tengu_concurrent_onquery_detected', {}) + logEvent('tengu_concurrent_onquery_detected', {}); // Extract and enqueue user message text, skipping meta messages // (e.g. expanded skill content, tick prompts) that should not be @@ -3886,49 +3400,44 @@ export function REPL({ .map(_ => getContentText(_.message.content)) .filter(_ => _ !== null) .forEach((msg, i) => { - enqueue({ value: msg, mode: 'prompt' }) + enqueue({ value: msg, mode: 'prompt' }); if (i === 0) { - logEvent('tengu_concurrent_onquery_enqueued', {}) + logEvent('tengu_concurrent_onquery_enqueued', {}); } - }) - return + }); + return; } try { // isLoading is derived from queryGuard — tryStart() above already // transitioned dispatching→running, so no setter call needed here. - resetTimingRefs() - setMessages(oldMessages => [...oldMessages, ...newMessages]) - responseLengthRef.current = 0 + resetTimingRefs(); + setMessages(oldMessages => [...oldMessages, ...newMessages]); + responseLengthRef.current = 0; if (feature('TOKEN_BUDGET')) { - const parsedBudget = input ? parseTokenBudget(input) : null - snapshotOutputTokensForTurn( - parsedBudget ?? getCurrentTurnTokenBudget(), - ) + const parsedBudget = input ? parseTokenBudget(input) : null; + snapshotOutputTokensForTurn(parsedBudget ?? getCurrentTurnTokenBudget()); } - apiMetricsRef.current = [] - setStreamingToolUses([]) - setStreamingText(null) + apiMetricsRef.current = []; + setStreamingToolUses([]); + setStreamingText(null); // messagesRef is updated synchronously by the setMessages wrapper // above, so it already includes newMessages from the append at the // top of this try block. No reconstruction needed, no waiting for // React's scheduler (previously cost 20-56ms per prompt; the 56ms // case was a GC pause caught during the await). - const latestMessages = messagesRef.current + const latestMessages = messagesRef.current; if (input) { - await mrOnBeforeQuery(input, latestMessages, newMessages.length) + await mrOnBeforeQuery(input, latestMessages, newMessages.length); } // Pass full conversation history to callback if (onBeforeQueryCallback && input) { - const shouldProceed = await onBeforeQueryCallback( - input, - latestMessages, - ) + const shouldProceed = await onBeforeQueryCallback(input, latestMessages); if (!shouldProceed) { - return + return; } } @@ -3940,27 +3449,24 @@ export function REPL({ additionalAllowedTools, mainLoopModelParam, effort, - ) + ); } finally { // queryGuard.end() atomically checks generation and transitions // running→idle. Returns false if a newer query owns the guard // (cancel+resubmit race where the stale finally fires as a microtask). if (queryGuard.end(thisGeneration)) { - setLastQueryCompletionTime(Date.now()) - skipIdleCheckRef.current = false + setLastQueryCompletionTime(Date.now()); + skipIdleCheckRef.current = false; // Always reset loading state in finally - this ensures cleanup even // if onQueryImpl throws. onTurnComplete is called separately in // onQueryImpl only on successful completion. - resetLoadingState() + resetLoadingState(); - await mrOnTurnComplete( - messagesRef.current, - abortController.signal.aborted, - ) + await mrOnTurnComplete(messagesRef.current, abortController.signal.aborted); // Notify bridge clients that the turn is complete so mobile apps // can stop the spark animation and show post-turn UI. - sendBridgeResultRef.current() + sendBridgeResultRef.current(); // Auto-hide tungsten panel content at turn end (ant-only), but keep // tungstenActiveSession set so the pill stays in the footer and the user @@ -3968,21 +3474,16 @@ export function REPL({ // minutes — wiping the session made the pill disappear entirely, forcing // the user to re-invoke Tmux just to peek. Skip on abort so the panel // stays open for inspection (matches the turn-duration guard below). - if ( - process.env.USER_TYPE === 'ant' && - !abortController.signal.aborted - ) { + if (process.env.USER_TYPE === 'ant' && !abortController.signal.aborted) { setAppState(prev => { - if (prev.tungstenActiveSession === undefined) return prev - if (prev.tungstenPanelAutoHidden === true) return prev - return { ...prev, tungstenPanelAutoHidden: true } - }) + if (prev.tungstenActiveSession === undefined) return prev; + if (prev.tungstenPanelAutoHidden === true) return prev; + return { ...prev, tungstenPanelAutoHidden: true }; + }); } // Capture budget info before clearing (ant-only) - let budgetInfo: - | { tokens: number; limit: number; nudges: number } - | undefined + let budgetInfo: { tokens: number; limit: number; nudges: number } | undefined; if (feature('TOKEN_BUDGET')) { if ( getCurrentTurnTokenBudget() !== null && @@ -3993,49 +3494,44 @@ export function REPL({ tokens: getTurnOutputTokens(), limit: getCurrentTurnTokenBudget()!, nudges: getBudgetContinuationCount(), - } + }; } - snapshotOutputTokensForTurn(null) + snapshotOutputTokensForTurn(null); } // Add turn duration message for turns longer than 30s or with a budget // Skip if user aborted or if in loop mode (too noisy between ticks) // Defer if swarm teammates are still running (show when they finish) - const turnDurationMs = - Date.now() - loadingStartTimeRef.current - totalPausedMsRef.current + const turnDurationMs = Date.now() - loadingStartTimeRef.current - totalPausedMsRef.current; if ( (turnDurationMs > 30000 || budgetInfo !== undefined) && !abortController.signal.aborted && !proactiveActive ) { - const hasRunningSwarmAgents = getAllInProcessTeammateTasks( - store.getState().tasks, - ).some(t => t.status === 'running') + const hasRunningSwarmAgents = getAllInProcessTeammateTasks(store.getState().tasks).some( + t => t.status === 'running', + ); if (hasRunningSwarmAgents) { // Only record start time on the first deferred turn if (swarmStartTimeRef.current === null) { - swarmStartTimeRef.current = loadingStartTimeRef.current + swarmStartTimeRef.current = loadingStartTimeRef.current; } // Always update budget — later turns may carry the actual budget if (budgetInfo) { - swarmBudgetInfoRef.current = budgetInfo + swarmBudgetInfoRef.current = budgetInfo; } } else { setMessages(prev => [ ...prev, - createTurnDurationMessage( - turnDurationMs, - budgetInfo, - count(prev, isLoggableMessage), - ), - ]) + createTurnDurationMessage(turnDurationMs, budgetInfo, count(prev, isLoggableMessage)), + ]); } } // Clear the controller so CancelRequestHandler's canCancelRunningTask // reads false at the idle prompt. Without this, the stale non-aborted // controller makes ctrl+c fire onCancel() (aborting nothing) instead of // propagating to the double-press exit flow. - setAbortController(null) + setAbortController(null); } // Auto-restore: if the user interrupted before any meaningful response @@ -4059,54 +3555,41 @@ export function REPL({ getCommandQueueLength() === 0 && !store.getState().viewingAgentTaskId ) { - const msgs = messagesRef.current - const lastUserMsg = msgs.findLast(selectableUserMessagesFilter) + const msgs = messagesRef.current; + const lastUserMsg = msgs.findLast(selectableUserMessagesFilter); if (lastUserMsg) { - const idx = msgs.lastIndexOf(lastUserMsg) + const idx = msgs.lastIndexOf(lastUserMsg); if (messagesAfterAreOnlySynthetic(msgs, idx)) { // The submit is being undone — undo its history entry too, // otherwise Up-arrow shows the restored text twice. - removeLastFromHistory() - restoreMessageSyncRef.current(lastUserMsg) + removeLastFromHistory(); + restoreMessageSyncRef.current(lastUserMsg); } } } } }, - [ - onQueryImpl, - setAppState, - resetLoadingState, - queryGuard, - mrOnBeforeQuery, - mrOnTurnComplete, - ], - ) + [onQueryImpl, setAppState, resetLoadingState, queryGuard, mrOnBeforeQuery, mrOnTurnComplete], + ); // Handle initial message (from CLI args or plan mode exit with context clear) // This effect runs when isLoading becomes false and there's a pending message - const initialMessageRef = useRef(false) + const initialMessageRef = useRef(false); useEffect(() => { - const pending = initialMessage - if (!pending || isLoading || initialMessageRef.current) return + const pending = initialMessage; + if (!pending || isLoading || initialMessageRef.current) return; // Mark as processing to prevent re-entry - initialMessageRef.current = true + initialMessageRef.current = true; - async function processInitialMessage( - initialMsg: NonNullable, - ) { + async function processInitialMessage(initialMsg: NonNullable) { // Clear context if requested (plan mode exit) if (initialMsg.clearContext) { // Preserve the plan slug before clearing context, so the new session // can access the same plan file after regenerateSessionId() - const oldPlanSlug = initialMsg.message.planContent - ? getPlanSlug() - : undefined + const oldPlanSlug = initialMsg.message.planContent ? getPlanSlug() : undefined; - const { clearConversation } = await import( - '../commands/clear/conversation.js' - ) + const { clearConversation } = await import('../commands/clear/conversation.js'); await clearConversation({ setMessages, readFileState: readFileState.current, @@ -4115,35 +3598,30 @@ export function REPL({ getAppState: () => store.getState(), setAppState, setConversationId, - }) - haikuTitleAttemptedRef.current = false - setHaikuTitle(undefined) - bashTools.current.clear() - bashToolsProcessedIdx.current = 0 + }); + haikuTitleAttemptedRef.current = false; + setHaikuTitle(undefined); + bashTools.current.clear(); + bashToolsProcessedIdx.current = 0; // Restore the plan slug for the new session so getPlan() finds the file if (oldPlanSlug) { - setPlanSlug(getSessionId(), oldPlanSlug) + setPlanSlug(getSessionId(), oldPlanSlug); } } // Atomically: clear initial message, set permission mode and rules, and store plan for verification const shouldStorePlanForVerification = - initialMsg.message.planContent && - process.env.USER_TYPE === 'ant' && - isEnvTruthy(undefined) + initialMsg.message.planContent && process.env.USER_TYPE === 'ant' && isEnvTruthy(undefined); setAppState(prev => { // Build and apply permission updates (mode + allowedPrompts rules) let updatedToolPermissionContext = initialMsg.mode ? applyPermissionUpdates( prev.toolPermissionContext, - buildPermissionUpdates( - initialMsg.mode, - initialMsg.allowedPrompts, - ), + buildPermissionUpdates(initialMsg.mode, initialMsg.allowedPrompts), ) - : prev.toolPermissionContext + : prev.toolPermissionContext; // For auto, override the mode (buildPermissionUpdates maps // it to 'default' via toExternalPermissionMode) and strip dangerous rules if (feature('TRANSCRIPT_CLASSIFIER') && initialMsg.mode === 'auto') { @@ -4151,7 +3629,7 @@ export function REPL({ ...updatedToolPermissionContext, mode: 'auto', prePlanMode: undefined, - }) + }); } return { @@ -4165,31 +3643,28 @@ export function REPL({ verificationCompleted: false, }, }), - } - }) + }; + }); // Create file history snapshot for code rewind if (fileHistoryEnabled()) { - void fileHistoryMakeSnapshot( - (updater: (prev: FileHistoryState) => FileHistoryState) => { - setAppState(prev => ({ - ...prev, - fileHistory: updater(prev.fileHistory), - })) - }, - initialMsg.message.uuid, - ) + void fileHistoryMakeSnapshot((updater: (prev: FileHistoryState) => FileHistoryState) => { + setAppState(prev => ({ + ...prev, + fileHistory: updater(prev.fileHistory), + })); + }, initialMsg.message.uuid); } // Ensure SessionStart hook context is available before the first API // call. onSubmit calls this internally but the onQuery path below // bypasses onSubmit — hoist here so both paths see hook messages. - await awaitPendingHooks() + await awaitPendingHooks(); // Route all initial prompts through onSubmit to ensure UserPromptSubmit hooks fire // TODO: Simplify by always routing through onSubmit once it supports // ContentBlockParam arrays (images) as input - const content = initialMsg.message.message.content + const content = initialMsg.message.message.content; // Route all string content through onSubmit to ensure hooks fire // For complex content (images, etc.), fall back to direct onQuery @@ -4200,13 +3675,13 @@ export function REPL({ setCursorOffset: () => {}, clearBuffer: () => {}, resetHistory: () => {}, - }) + }); } else { // Plan messages or complex content (images, etc.) - send directly to model // Plan messages use onQuery to preserve planContent metadata for rendering // TODO: Once onSubmit supports ContentBlockParam arrays, remove this branch - const newAbortController = createAbortController() - setAbortController(newAbortController) + const newAbortController = createAbortController(); + setAbortController(newAbortController); void onQuery( [initialMsg.message], @@ -4214,48 +3689,40 @@ export function REPL({ true, // shouldQuery [], // additionalAllowedTools mainLoopModel, - ) + ); } // Reset ref after a delay to allow new initial messages setTimeout( ref => { - ref.current = false + ref.current = false; }, 100, initialMessageRef, - ) + ); } - void processInitialMessage(pending) - }, [ - initialMessage, - isLoading, - setMessages, - setAppState, - onQuery, - mainLoopModel, - tools, - ]) + void processInitialMessage(pending); + }, [initialMessage, isLoading, setMessages, setAppState, onQuery, mainLoopModel, tools]); const onSubmit = useCallback( async ( input: string, helpers: PromptInputHelpers, speculationAccept?: { - state: ActiveSpeculationState - speculationSessionTimeSavedMs: number - setAppState: SetAppState + state: ActiveSpeculationState; + speculationSessionTimeSavedMs: number; + setAppState: SetAppState; }, options?: { fromKeybinding?: boolean }, ) => { // Re-pin scroll to bottom on submit so the user always sees the new // exchange (matches OpenCode's auto-scroll behavior). - repinScroll() + repinScroll(); // Resume loop mode if paused if (feature('PROACTIVE') || feature('KAIROS')) { - proactiveModule?.resumeProactive() + proactiveModule?.resumeProactive(); } // Handle immediate commands - these bypass the queue and execute right away @@ -4265,14 +3732,10 @@ export function REPL({ // Expand [Pasted text #N] refs so immediate commands (e.g. /btw) receive // the pasted content, not the placeholder. The non-immediate path gets // this expansion later in handlePromptSubmit. - const trimmedInput = expandPastedTextRefs(input, pastedContents).trim() - const spaceIndex = trimmedInput.indexOf(' ') - const commandName = - spaceIndex === -1 - ? trimmedInput.slice(1) - : trimmedInput.slice(1, spaceIndex) - const commandArgs = - spaceIndex === -1 ? '' : trimmedInput.slice(spaceIndex + 1).trim() + const trimmedInput = expandPastedTextRefs(input, pastedContents).trim(); + const spaceIndex = trimmedInput.indexOf(' '); + const commandName = spaceIndex === -1 ? trimmedInput.slice(1) : trimmedInput.slice(1, spaceIndex); + const commandArgs = spaceIndex === -1 ? '' : trimmedInput.slice(spaceIndex + 1).trim(); // Find matching command - treat as immediate if: // 1. Command has `immediate: true`, OR @@ -4280,82 +3743,67 @@ export function REPL({ const matchingCommand = commands.find( cmd => isCommandEnabled(cmd) && - (cmd.name === commandName || - cmd.aliases?.includes(commandName) || - getCommandName(cmd) === commandName), - ) + (cmd.name === commandName || cmd.aliases?.includes(commandName) || getCommandName(cmd) === commandName), + ); if (matchingCommand?.name === 'clear' && idleHintShownRef.current) { logEvent('tengu_idle_return_action', { - action: - 'hint_converted' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - variant: - idleHintShownRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - idleMinutes: Math.round( - (Date.now() - lastQueryCompletionTimeRef.current) / 60_000, - ), + action: 'hint_converted' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + variant: idleHintShownRef.current as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + idleMinutes: Math.round((Date.now() - lastQueryCompletionTimeRef.current) / 60_000), messageCount: messagesRef.current.length, totalInputTokens: getTotalInputTokens(), - }) - idleHintShownRef.current = false + }); + idleHintShownRef.current = false; } - const shouldTreatAsImmediate = - queryGuard.isActive && - (matchingCommand?.immediate || options?.fromKeybinding) + const shouldTreatAsImmediate = queryGuard.isActive && (matchingCommand?.immediate || options?.fromKeybinding); - if ( - matchingCommand && - shouldTreatAsImmediate && - matchingCommand.type === 'local-jsx' - ) { + if (matchingCommand && shouldTreatAsImmediate && matchingCommand.type === 'local-jsx') { // Only clear input if the submitted text matches what's in the prompt. // When a command keybinding fires, input is "/" but the actual // input value is the user's existing text - don't clear it in that case. if (input.trim() === inputValueRef.current.trim()) { - setInputValue('') - helpers.setCursorOffset(0) - helpers.clearBuffer() - setPastedContents({}) + setInputValue(''); + helpers.setCursorOffset(0); + helpers.clearBuffer(); + setPastedContents({}); } - const pastedTextRefs = parseReferences(input).filter( - r => pastedContents[r.id]?.type === 'text', - ) - const pastedTextCount = pastedTextRefs.length + const pastedTextRefs = parseReferences(input).filter(r => pastedContents[r.id]?.type === 'text'); + const pastedTextCount = pastedTextRefs.length; const pastedTextBytes = pastedTextRefs.reduce( (sum, r) => sum + (pastedContents[r.id]?.content.length ?? 0), 0, - ) - logEvent('tengu_paste_text', { pastedTextCount, pastedTextBytes }) + ); + logEvent('tengu_paste_text', { pastedTextCount, pastedTextBytes }); logEvent('tengu_immediate_command_executed', { - commandName: - matchingCommand.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + commandName: matchingCommand.name as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, fromKeybinding: options?.fromKeybinding ?? false, - }) + }); // Execute the command directly const executeImmediateCommand = async (): Promise => { - let doneWasCalled = false + let doneWasCalled = false; const onDone = ( result?: string, doneOptions?: { - display?: CommandResultDisplay - metaMessages?: string[] + display?: CommandResultDisplay; + metaMessages?: string[]; }, ): void => { - doneWasCalled = true + doneWasCalled = true; setToolJSX({ jsx: null, shouldHidePromptInput: false, clearLocalJSX: true, - }) - const newMessages: MessageType[] = [] + }); + const newMessages: MessageType[] = []; if (result && doneOptions?.display !== 'skip') { addNotification({ key: `immediate-${matchingCommand.name}`, text: result, priority: 'immediate', - }) + }); // In fullscreen the command just showed as a centered modal // pane — the notification above is enough feedback. Adding // "❯ /config" + "⎿ dismissed" to the transcript is clutter @@ -4365,53 +3813,41 @@ export function REPL({ // transcript entry stays so scrollback shows what ran. if (!isFullscreenEnvEnabled()) { newMessages.push( - createCommandInputMessage( - formatCommandInputTags( - getCommandName(matchingCommand), - commandArgs, - ), - ), + createCommandInputMessage(formatCommandInputTags(getCommandName(matchingCommand), commandArgs)), createCommandInputMessage( `<${LOCAL_COMMAND_STDOUT_TAG}>${escapeXml(result)}`, ), - ) + ); } } // Inject meta messages (model-visible, user-hidden) into the transcript if (doneOptions?.metaMessages?.length) { newMessages.push( - ...doneOptions.metaMessages.map(content => - createUserMessage({ content, isMeta: true }), - ), - ) + ...doneOptions.metaMessages.map(content => createUserMessage({ content, isMeta: true })), + ); } if (newMessages.length) { - setMessages(prev => [...prev, ...newMessages]) + setMessages(prev => [...prev, ...newMessages]); } // Restore stashed prompt after local-jsx command completes. // The normal stash restoration path (below) is skipped because // local-jsx commands return early from onSubmit. if (stashedPrompt !== undefined) { - setInputValue(stashedPrompt.text) - helpers.setCursorOffset(stashedPrompt.cursorOffset) - setPastedContents(stashedPrompt.pastedContents) - setStashedPrompt(undefined) + setInputValue(stashedPrompt.text); + helpers.setCursorOffset(stashedPrompt.cursorOffset); + setPastedContents(stashedPrompt.pastedContents); + setStashedPrompt(undefined); } - } + }; // Build context for the command (reuses existing getToolUseContext). // Read messages via ref to keep onSubmit stable across message // updates — matches the pattern at L2384/L2400/L2662 and avoids // pinning stale REPL render scopes in downstream closures. - const context = getToolUseContext( - messagesRef.current, - [], - createAbortController(), - mainLoopModel, - ) + const context = getToolUseContext(messagesRef.current, [], createAbortController(), mainLoopModel); - const mod = await matchingCommand.load() - const jsx = await mod.call(onDone, context, commandArgs) + const mod = await matchingCommand.load(); + const jsx = await mod.call(onDone, context, commandArgs); // Skip if onDone already fired — prevents stuck isLocalJSXCommand // (see processSlashCommand.tsx local-jsx case for full mechanism). @@ -4422,33 +3858,26 @@ export function REPL({ jsx, shouldHidePromptInput: false, isLocalJSXCommand: true, - }) + }); } - } - void executeImmediateCommand() - return // Always return early - don't add to history or queue + }; + void executeImmediateCommand(); + return; // Always return early - don't add to history or queue } } // Remote mode: skip empty input early before any state mutations if (activeRemote.isRemoteMode && !input.trim()) { - return + return; } // Idle-return: prompt returning users to start fresh when the // conversation is large and the cache is cold. tengu_willow_mode // controls treatment: "dialog" (blocking), "hint" (notification), "off". { - const willowMode = getFeatureValue_CACHED_MAY_BE_STALE( - 'tengu_willow_mode', - 'off', - ) - const idleThresholdMin = Number( - process.env.CLAUDE_CODE_IDLE_THRESHOLD_MINUTES ?? 75, - ) - const tokenThreshold = Number( - process.env.CLAUDE_CODE_IDLE_TOKEN_THRESHOLD ?? 100_000, - ) + const willowMode = getFeatureValue_CACHED_MAY_BE_STALE('tengu_willow_mode', 'off'); + const idleThresholdMin = Number(process.env.CLAUDE_CODE_IDLE_THRESHOLD_MINUTES ?? 75); + const tokenThreshold = Number(process.env.CLAUDE_CODE_IDLE_TOKEN_THRESHOLD ?? 100_000); if ( willowMode !== 'off' && !getGlobalConfig().idleReturnDismissed && @@ -4458,14 +3887,14 @@ export function REPL({ lastQueryCompletionTimeRef.current > 0 && getTotalInputTokens() >= tokenThreshold ) { - const idleMs = Date.now() - lastQueryCompletionTimeRef.current - const idleMinutes = idleMs / 60_000 + const idleMs = Date.now() - lastQueryCompletionTimeRef.current; + const idleMinutes = idleMs / 60_000; if (idleMinutes >= idleThresholdMin && willowMode === 'dialog') { - setIdleReturnPending({ input, idleMinutes }) - setInputValue('') - helpers.setCursorOffset(0) - helpers.clearBuffer() - return + setIdleReturnPending({ input, idleMinutes }); + setInputValue(''); + helpers.setCursorOffset(0); + helpers.clearBuffer(); + return; } } } @@ -4476,15 +3905,13 @@ export function REPL({ // Skip history for keybinding-triggered commands (user didn't type the command). if (!options?.fromKeybinding) { addToHistory({ - display: speculationAccept - ? input - : prependModeCharacterToInput(input, inputMode), + display: speculationAccept ? input : prependModeCharacterToInput(input, inputMode), pastedContents: speculationAccept ? {} : pastedContents, - }) + }); // Add the just-submitted command to the front of the ghost-text // cache so it's suggested immediately (not after the 60s TTL). if (inputMode === 'bash') { - prependToShellHistoryCache(input.trim()) + prependToShellHistoryCache(input.trim()); } } @@ -4499,49 +3926,43 @@ export function REPL({ // Remote mode is exempt: it sends via WebSocket and returns early without // calling handlePromptSubmit, so there's no clobbering risk — restore eagerly. // In both deferred cases, the stash is restored after await handlePromptSubmit. - const isSlashCommand = !speculationAccept && input.trim().startsWith('/') + const isSlashCommand = !speculationAccept && input.trim().startsWith('/'); // Submit runs "now" (not queued) when not already loading, or when // accepting speculation, or in remote mode (which sends via WS and // returns early without calling handlePromptSubmit). - const submitsNow = - !isLoading || speculationAccept || activeRemote.isRemoteMode + const submitsNow = !isLoading || speculationAccept || activeRemote.isRemoteMode; if (stashedPrompt !== undefined && !isSlashCommand && submitsNow) { - setInputValue(stashedPrompt.text) - helpers.setCursorOffset(stashedPrompt.cursorOffset) - setPastedContents(stashedPrompt.pastedContents) - setStashedPrompt(undefined) + setInputValue(stashedPrompt.text); + helpers.setCursorOffset(stashedPrompt.cursorOffset); + setPastedContents(stashedPrompt.pastedContents); + setStashedPrompt(undefined); } else if (submitsNow) { if (!options?.fromKeybinding) { // Clear input when not loading or accepting speculation. // Preserve input for keybinding-triggered commands. - setInputValue('') - helpers.setCursorOffset(0) + setInputValue(''); + helpers.setCursorOffset(0); } - setPastedContents({}) + setPastedContents({}); } if (submitsNow) { - setInputMode('prompt') - setIDESelection(undefined) - setSubmitCount(_ => _ + 1) - helpers.clearBuffer() - tipPickedThisTurnRef.current = false + setInputMode('prompt'); + setIDESelection(undefined); + setSubmitCount(_ => _ + 1); + helpers.clearBuffer(); + tipPickedThisTurnRef.current = false; // Show the placeholder in the same React batch as setInputValue(''). // Skip for slash/bash (they have their own echo), speculation and remote // mode (both setMessages directly with no gap to bridge). - if ( - !isSlashCommand && - inputMode === 'prompt' && - !speculationAccept && - !activeRemote.isRemoteMode - ) { - setUserInputOnProcessing(input) + if (!isSlashCommand && inputMode === 'prompt' && !speculationAccept && !activeRemote.isRemoteMode) { + setUserInputOnProcessing(input); // showSpinner includes userInputOnProcessing, so the spinner appears // on this render. Reset timing refs now (before queryGuard.reserve() // would) so elapsed time doesn't read as Date.now() - 0. The // isQueryActive transition above does the same reset — idempotent. - resetTimingRefs() + resetTimingRefs(); } // Increment prompt count for attribution tracking and save snapshot @@ -4551,12 +3972,10 @@ export function REPL({ ...prev, attribution: incrementPromptCount(prev.attribution, snapshot => { void recordAttributionSnapshot(snapshot).catch(error => { - logForDebugging( - `Attribution: Failed to save snapshot: ${error}`, - ) - }) + logForDebugging(`Attribution: Failed to save snapshot: ${error}`); + }); }), - })) + })); } } @@ -4572,13 +3991,13 @@ export function REPL({ readFileState, cwd: getOriginalCwd(), }, - ) + ); if (queryRequired) { - const newAbortController = createAbortController() - setAbortController(newAbortController) - void onQuery([], newAbortController, true, [], mainLoopModel) + const newAbortController = createAbortController(); + setAbortController(newAbortController); + void onQuery([], newAbortController, true, [], mainLoopModel); } - return + return; } // Remote mode: send input via stream-json instead of local query. @@ -4594,33 +4013,26 @@ export function REPL({ !( isSlashCommand && commands.find(c => { - const name = input.trim().slice(1).split(/\s/)[0] - return ( - isCommandEnabled(c) && - (c.name === name || - c.aliases?.includes(name!) || - getCommandName(c) === name) - ) + const name = input.trim().slice(1).split(/\s/)[0]; + return isCommandEnabled(c) && (c.name === name || c.aliases?.includes(name!) || getCommandName(c) === name); })?.type === 'local-jsx' ) ) { // Build content blocks when there are pasted attachments (images) - const pastedValues = Object.values(pastedContents) - const imageContents = pastedValues.filter(c => c.type === 'image') - const imagePasteIds = - imageContents.length > 0 ? imageContents.map(c => c.id) : undefined + const pastedValues = Object.values(pastedContents); + const imageContents = pastedValues.filter(c => c.type === 'image'); + const imagePasteIds = imageContents.length > 0 ? imageContents.map(c => c.id) : undefined; - let messageContent: string | ContentBlockParam[] = input.trim() - let remoteContent: RemoteMessageContent = input.trim() + let messageContent: string | ContentBlockParam[] = input.trim(); + let remoteContent: RemoteMessageContent = input.trim(); if (pastedValues.length > 0) { - const contentBlocks: ContentBlockParam[] = [] - const remoteBlocks: Array<{ type: string; [key: string]: unknown }> = - [] + const contentBlocks: ContentBlockParam[] = []; + const remoteBlocks: Array<{ type: string; [key: string]: unknown }> = []; - const trimmedInput = input.trim() + const trimmedInput = input.trim(); if (trimmedInput) { - contentBlocks.push({ type: 'text', text: trimmedInput }) - remoteBlocks.push({ type: 'text', text: trimmedInput }) + contentBlocks.push({ type: 'text', text: trimmedInput }); + remoteBlocks.push({ type: 'text', text: trimmedInput }); } for (const pasted of pastedValues) { @@ -4633,17 +4045,17 @@ export function REPL({ | 'image/gif' | 'image/webp', data: pasted.content, - } - contentBlocks.push({ type: 'image', source }) - remoteBlocks.push({ type: 'image', source }) + }; + contentBlocks.push({ type: 'image', source }); + remoteBlocks.push({ type: 'image', source }); } else { - contentBlocks.push({ type: 'text', text: pasted.content }) - remoteBlocks.push({ type: 'text', text: pasted.content }) + contentBlocks.push({ type: 'text', text: pasted.content }); + remoteBlocks.push({ type: 'text', text: pasted.content }); } } - messageContent = contentBlocks - remoteContent = remoteBlocks + messageContent = contentBlocks; + remoteContent = remoteBlocks; } // Create and add user message to UI @@ -4651,18 +4063,18 @@ export function REPL({ const userMessage = createUserMessage({ content: messageContent, imagePasteIds, - }) - setMessages(prev => [...prev, userMessage]) + }); + setMessages(prev => [...prev, userMessage]); // Send to remote session await activeRemote.sendMessage(remoteContent, { uuid: userMessage.uuid, - }) - return + }); + return; } // Ensure SessionStart hook context is available before the first API call. - await awaitPendingHooks() + await awaitPendingHooks(); await handlePromptSubmit({ input, @@ -4692,9 +4104,8 @@ export function REPL({ // Read via ref so streamMode can be dropped from onSubmit deps — // handlePromptSubmit only uses it for debug log + telemetry event. streamMode: streamModeRef.current, - hasInterruptibleToolInProgress: - hasInterruptibleToolInProgressRef.current, - }) + hasInterruptibleToolInProgress: hasInterruptibleToolInProgressRef.current, + }); // Restore stash that was deferred above. Two cases: // - Slash command: handlePromptSubmit awaited the full command execution @@ -4703,10 +4114,10 @@ export function REPL({ // - Loading (queued): handlePromptSubmit enqueued + cleared input, then // returned quickly. Restoring now places the stash back after the clear. if ((isSlashCommand || isLoading) && stashedPrompt !== undefined) { - setInputValue(stashedPrompt.text) - helpers.setCursorOffset(stashedPrompt.cursorOffset) - setPastedContents(stashedPrompt.pastedContents) - setStashedPrompt(undefined) + setInputValue(stashedPrompt.text); + helpers.setCursorOffset(stashedPrompt.cursorOffset); + setPastedContents(stashedPrompt.pastedContents); + setStashedPrompt(undefined); } }, [ @@ -4749,152 +4160,122 @@ export function REPL({ awaitPendingHooks, repinScroll, ], - ) + ); // Callback for when user submits input while viewing a teammate's transcript const onAgentSubmit = useCallback( - async ( - input: string, - task: InProcessTeammateTaskState | LocalAgentTaskState, - helpers: PromptInputHelpers, - ) => { + async (input: string, task: InProcessTeammateTaskState | LocalAgentTaskState, helpers: PromptInputHelpers) => { if (isLocalAgentTask(task)) { - appendMessageToLocalAgent( - task.id, - createUserMessage({ content: input }), - setAppState, - ) + appendMessageToLocalAgent(task.id, createUserMessage({ content: input }), setAppState); if (task.status === 'running') { - queuePendingMessage(task.id, input, setAppState) + queuePendingMessage(task.id, input, setAppState); } else { void resumeAgentBackground({ agentId: task.id, prompt: input, - toolUseContext: getToolUseContext( - messagesRef.current, - [], - new AbortController(), - mainLoopModel, - ), + toolUseContext: getToolUseContext(messagesRef.current, [], new AbortController(), mainLoopModel), canUseTool, }).catch(err => { - logForDebugging( - `resumeAgentBackground failed: ${errorMessage(err)}`, - ) + logForDebugging(`resumeAgentBackground failed: ${errorMessage(err)}`); addNotification({ key: `resume-agent-failed-${task.id}`, - jsx: ( - - Failed to resume agent: {errorMessage(err)} - - ), + jsx: Failed to resume agent: {errorMessage(err)}, priority: 'low', - }) - }) + }); + }); } } else { - injectUserMessageToTeammate(task.id, input, setAppState) + injectUserMessageToTeammate(task.id, input, setAppState); } - setInputValue('') - helpers.setCursorOffset(0) - helpers.clearBuffer() + setInputValue(''); + helpers.setCursorOffset(0); + helpers.clearBuffer(); }, - [ - setAppState, - setInputValue, - getToolUseContext, - canUseTool, - mainLoopModel, - addNotification, - ], - ) + [setAppState, setInputValue, getToolUseContext, canUseTool, mainLoopModel, addNotification], + ); // Handlers for auto-run /issue or /good-claude (defined after onSubmit) const handleAutoRunIssue = useCallback(() => { - const command = autoRunIssueReason - ? getAutoRunCommand(autoRunIssueReason) - : '/issue' - setAutoRunIssueReason(null) // Clear the state + const command = autoRunIssueReason ? getAutoRunCommand(autoRunIssueReason) : '/issue'; + setAutoRunIssueReason(null); // Clear the state onSubmit(command, { setCursorOffset: () => {}, clearBuffer: () => {}, resetHistory: () => {}, }).catch(err => { - logForDebugging(`Auto-run ${command} failed: ${errorMessage(err)}`) - }) - }, [onSubmit, autoRunIssueReason]) + logForDebugging(`Auto-run ${command} failed: ${errorMessage(err)}`); + }); + }, [onSubmit, autoRunIssueReason]); const handleCancelAutoRunIssue = useCallback(() => { - setAutoRunIssueReason(null) - }, []) + setAutoRunIssueReason(null); + }, []); // Handler for when user presses 1 on survey thanks screen to share details const handleSurveyRequestFeedback = useCallback(() => { - const command = process.env.USER_TYPE === 'ant' ? '/issue' : '/feedback' + const command = process.env.USER_TYPE === 'ant' ? '/issue' : '/feedback'; onSubmit(command, { setCursorOffset: () => {}, clearBuffer: () => {}, resetHistory: () => {}, }).catch(err => { - logForDebugging( - `Survey feedback request failed: ${err instanceof Error ? err.message : String(err)}`, - ) - }) - }, [onSubmit]) + logForDebugging(`Survey feedback request failed: ${err instanceof Error ? err.message : String(err)}`); + }); + }, [onSubmit]); // onSubmit is unstable (deps include `messages` which changes every turn). // `handleOpenRateLimitOptions` is prop-drilled to every MessageRow, and each // MessageRow fiber pins the closure (and transitively the entire REPL render // scope, ~1.8KB) at mount time. Using a ref keeps this callback stable so // old REPL scopes can be GC'd — saves ~35MB over a 1000-turn session. - const onSubmitRef = useRef(onSubmit) - onSubmitRef.current = onSubmit + const onSubmitRef = useRef(onSubmit); + onSubmitRef.current = onSubmit; const handleOpenRateLimitOptions = useCallback(() => { void onSubmitRef.current('/rate-limit-options', { setCursorOffset: () => {}, clearBuffer: () => {}, resetHistory: () => {}, - }) - }, []) + }); + }, []); const handleExit = useCallback(async () => { - setIsExiting(true) + setIsExiting(true); // In bg sessions, always detach instead of kill — even when a worktree is // active. Without this guard, the worktree branch below short-circuits into // ExitFlow (which calls gracefulShutdown) before exit.tsx is ever loaded. if (feature('BG_SESSIONS') && isBgSession()) { - spawnSync('tmux', ['detach-client'], { stdio: 'ignore' }) - setIsExiting(false) - return + spawnSync('tmux', ['detach-client'], { stdio: 'ignore' }); + setIsExiting(false); + return; } - const showWorktree = getCurrentWorktreeSession() !== null + const showWorktree = getCurrentWorktreeSession() !== null; if (showWorktree) { setExitFlow( {}} onCancel={() => { - setExitFlow(null) - setIsExiting(false) + setExitFlow(null); + setIsExiting(false); }} />, - ) - return + ); + return; } - const exitMod = await exit.load() - const exitFlowResult = await exitMod.call(() => {}) - setExitFlow(exitFlowResult) + const exitMod = await exit.load(); + const exitFlowResult = await exitMod.call(() => {}); + setExitFlow(exitFlowResult); // If call() returned without killing the process (bg session detach), // clear isExiting so the UI is usable on reattach. No-op on the normal // path — gracefulShutdown's process.exit() means we never get here. if (exitFlowResult === null) { - setIsExiting(false) + setIsExiting(false); } - }, []) + }, []); const handleShowMessageSelector = useCallback(() => { - setIsMessageSelectorVisible(prev => !prev) - }, []) + setIsMessageSelectorVisible(prev => !prev); + }, []); // Rewind conversation state to just before `message`: slice messages, // reset conversation ID, microcompact state, permission mode, prompt suggestion. @@ -4903,22 +4284,22 @@ export function REPL({ // stale closures. const rewindConversationTo = useCallback( (message: UserMessage) => { - const prev = messagesRef.current - const messageIndex = prev.lastIndexOf(message) - if (messageIndex === -1) return + const prev = messagesRef.current; + const messageIndex = prev.lastIndexOf(message); + if (messageIndex === -1) return; logEvent('tengu_conversation_rewind', { preRewindMessageCount: prev.length, postRewindMessageCount: messageIndex, messagesRemoved: prev.length - messageIndex, rewindToMessageIndex: messageIndex, - }) - setMessages(prev.slice(0, messageIndex)) + }); + setMessages(prev.slice(0, messageIndex)); // Careful, this has to happen after setMessages - setConversationId(randomUUID()) + setConversationId(randomUUID()); // Reset cached microcompact state so stale pinned cache edits // don't reference tool_use_ids from truncated messages - resetMicrocompactState() + resetMicrocompactState(); if (feature('CONTEXT_COLLAPSE')) { // Rewind truncates the REPL array. Commits whose archived span // was past the rewind point can't be projected anymore @@ -4927,9 +4308,9 @@ export function REPL({ // everything. The ctx-agent will re-stage on the next // threshold crossing. /* eslint-disable @typescript-eslint/no-require-imports */ - ;( + ( require('../services/contextCollapse/index.js') as typeof import('../services/contextCollapse/index.js') - ).resetContextCollapse() + ).resetContextCollapse(); /* eslint-enable @typescript-eslint/no-require-imports */ } @@ -4938,8 +4319,7 @@ export function REPL({ ...prev, // Restore permission mode from the message toolPermissionContext: - message.permissionMode && - prev.toolPermissionContext.mode !== message.permissionMode + message.permissionMode && prev.toolPermissionContext.mode !== message.permissionMode ? { ...prev.toolPermissionContext, mode: message.permissionMode, @@ -4953,77 +4333,69 @@ export function REPL({ acceptedAt: 0, generationRequestId: null, }, - })) + })); }, [setMessages, setAppState], - ) + ); // Synchronous rewind + input population. Used directly by auto-restore on // interrupt (so React batches with the abort's setMessages → single render, // no flicker). MessageSelector wraps this in setImmediate via handleRestoreMessage. const restoreMessageSync = useCallback( (message: UserMessage) => { - rewindConversationTo(message) + rewindConversationTo(message); - const r = textForResubmit(message) + const r = textForResubmit(message); if (r) { - setInputValue(r.text) - setInputMode(r.mode) + setInputValue(r.text); + setInputMode(r.mode); } // Restore pasted images - if ( - Array.isArray(message.message.content) && - message.message.content.some(block => block.type === 'image') - ) { - const imageBlocks: Array = - message.message.content.filter(block => block.type === 'image') + if (Array.isArray(message.message.content) && message.message.content.some(block => block.type === 'image')) { + const imageBlocks: Array = message.message.content.filter(block => block.type === 'image'); if (imageBlocks.length > 0) { - const newPastedContents: Record = {} + const newPastedContents: Record = {}; imageBlocks.forEach((block, index) => { if (block.source.type === 'base64') { - const id = message.imagePasteIds?.[index] ?? index + 1 + const id = message.imagePasteIds?.[index] ?? index + 1; newPastedContents[id] = { id, type: 'image', content: block.source.data, mediaType: block.source.media_type, - } + }; } - }) - setPastedContents(newPastedContents) + }); + setPastedContents(newPastedContents); } } }, [rewindConversationTo, setInputValue], - ) - restoreMessageSyncRef.current = restoreMessageSync + ); + restoreMessageSyncRef.current = restoreMessageSync; // MessageSelector path: defer via setImmediate so the "Interrupted" message // renders to static output before rewind — otherwise it remains vestigial // at the top of the screen. const handleRestoreMessage = useCallback( async (message: UserMessage) => { - setImmediate( - (restore, message) => restore(message), - restoreMessageSync, - message, - ) + setImmediate((restore, message) => restore(message), restoreMessageSync, message); }, [restoreMessageSync], - ) + ); // Not memoized — hook stores caps via ref, reads latest closure at dispatch. // 24-char prefix: deriveUUID preserves first 24, renderable uuid prefix-matches raw source. const findRawIndex = (uuid: string) => { - const prefix = uuid.slice(0, 24) - return messages.findIndex(m => m.uuid.slice(0, 24) === prefix) - } + const prefix = uuid.slice(0, 24); + return messages.findIndex(m => m.uuid.slice(0, 24) === prefix); + }; const messageActionCaps: MessageActionCaps = { copy: text => // setClipboard RETURNS OSC 52 — caller must stdout.write (tmux side-effects load-buffer, but that's tmux-only). void setClipboard(text).then(raw => { - if (raw) process.stdout.write(raw) + if (raw) process.stdout.write(raw); addNotification({ // Same key as text-selection copy — repeated copies replace toast, don't queue. key: 'selection-copied', @@ -5031,52 +4403,48 @@ export function REPL({ color: 'success', priority: 'immediate', timeoutMs: 2000, - }) + }); }), edit: async msg => { // Same skip-confirm check as /rewind: lossless → direct, else confirm dialog. - const rawIdx = findRawIndex(msg.uuid) - const raw = rawIdx >= 0 ? messages[rawIdx] : undefined - if (!raw || !selectableUserMessagesFilter(raw)) return - const noFileChanges = !(await fileHistoryHasAnyChanges( - fileHistory, - raw.uuid, - )) - const onlySynthetic = messagesAfterAreOnlySynthetic(messages, rawIdx) + const rawIdx = findRawIndex(msg.uuid); + const raw = rawIdx >= 0 ? messages[rawIdx] : undefined; + if (!raw || !selectableUserMessagesFilter(raw)) return; + const noFileChanges = !(await fileHistoryHasAnyChanges(fileHistory, raw.uuid)); + const onlySynthetic = messagesAfterAreOnlySynthetic(messages, rawIdx); if (noFileChanges && onlySynthetic) { // rewindConversationTo's setMessages races stream appends — cancel first (idempotent). - onCancel() + onCancel(); // handleRestoreMessage also restores pasted images. - void handleRestoreMessage(raw) + void handleRestoreMessage(raw); } else { // Dialog path: onPreRestore (= onCancel) fires when user CONFIRMS, not on nevermind. - setMessageSelectorPreselect(raw) - setIsMessageSelectorVisible(true) + setMessageSelectorPreselect(raw); + setIsMessageSelectorVisible(true); } }, - } - const { enter: enterMessageActions, handlers: messageActionHandlers } = - useMessageActions(cursor, setCursor, cursorNavRef, messageActionCaps) + }; + const { enter: enterMessageActions, handlers: messageActionHandlers } = useMessageActions( + cursor, + setCursor, + cursorNavRef, + messageActionCaps, + ); async function onInit() { // Always verify API key on startup, so we can show the user an error in the // bottom right corner of the screen if the API key is invalid. - void reverify() + void reverify(); // Populate readFileState with CLAUDE.md files at startup - const memoryFiles = await getMemoryFiles() + const memoryFiles = await getMemoryFiles(); if (memoryFiles.length > 0) { const fileList = memoryFiles - .map( - f => - ` [${f.type}] ${f.path} (${f.content.length} chars)${f.parent ? ` (included by ${f.parent})` : ''}`, - ) - .join('\n') - logForDebugging( - `Loaded ${memoryFiles.length} CLAUDE.md/rules files:\n${fileList}`, - ) + .map(f => ` [${f.type}] ${f.path} (${f.content.length} chars)${f.parent ? ` (included by ${f.parent})` : ''}`) + .join('\n'); + logForDebugging(`Loaded ${memoryFiles.length} CLAUDE.md/rules files:\n${fileList}`); } else { - logForDebugging('No CLAUDE.md/rules files found') + logForDebugging('No CLAUDE.md/rules files found'); } for (const file of memoryFiles) { // When the injected content doesn't match disk (stripped HTML comments, @@ -5084,40 +4452,32 @@ export function REPL({ // with isPartialView so Edit/Write require a real Read first while // getChangedFiles + nested_memory dedup still work. readFileState.current.set(file.path, { - content: file.contentDiffersFromDisk - ? (file.rawContent ?? file.content) - : file.content, + content: file.contentDiffersFromDisk ? (file.rawContent ?? file.content) : file.content, timestamp: Date.now(), offset: undefined, limit: undefined, isPartialView: file.contentDiffersFromDisk, - }) + }); } // Initial message handling is done via the initialMessage effect } // Register cost summary tracker - useCostSummary(useFpsMetrics()) + useCostSummary(useFpsMetrics()); // Record transcripts locally, for debugging and conversation recovery // Don't record conversation if we only have initial messages; optimizes // the case where user resumes a conversation then quites before doing // anything else - useLogMessages(messages, messages.length === initialMessages?.length) + useLogMessages(messages, messages.length === initialMessages?.length); // REPL Bridge: replicate user/assistant messages to the bridge session // for remote access via claude.ai. No-op in external builds or when not enabled. - const { sendBridgeResult } = useReplBridge( - messages, - setMessages, - abortControllerRef, - commands, - mainLoopModel, - ) - sendBridgeResultRef.current = sendBridgeResult + const { sendBridgeResult } = useReplBridge(messages, setMessages, abortControllerRef, commands, mainLoopModel); + sendBridgeResultRef.current = sendBridgeResult; - useAfterFirstRender() + useAfterFirstRender(); // Track prompt queue usage for analytics. Fire once per transition from // empty to non-empty, not on every length change -- otherwise a render loop @@ -5125,19 +4485,19 @@ export function REPL({ // ELOCKED under concurrent sessions and falls back to unlocked writes. // That write storm is the primary trigger for ~/.claude.json corruption // (GH #3117). - const hasCountedQueueUseRef = useRef(false) + const hasCountedQueueUseRef = useRef(false); useEffect(() => { if (queuedCommands.length < 1) { - hasCountedQueueUseRef.current = false - return + hasCountedQueueUseRef.current = false; + return; } - if (hasCountedQueueUseRef.current) return - hasCountedQueueUseRef.current = true + if (hasCountedQueueUseRef.current) return; + hasCountedQueueUseRef.current = true; saveGlobalConfig(current => ({ ...current, promptQueueUseCount: (current.promptQueueUseCount ?? 0) + 1, - })) - }, [queuedCommands.length]) + })); + }, [queuedCommands.length]); // Process queued commands when query completes and queue has items @@ -5168,7 +4528,7 @@ export function REPL({ addNotification, setMessages, queuedCommands, - }) + }); }, [ queryGuard, @@ -5186,59 +4546,53 @@ export function REPL({ setAppState, onBeforeQuery, ], - ) + ); useQueueProcessor({ executeQueuedInput, hasActiveLocalJsxUI: isShowingLocalJSXCommand, queryGuard, - }) + }); // We'll use the global lastInteractionTime from state.ts // Update last interaction time when input changes. // Must be immediate because useEffect runs after the Ink render cycle flush. useEffect(() => { - activityManager.recordUserActivity() - updateLastInteractionTime(true) - }, [inputValue, submitCount]) + activityManager.recordUserActivity(); + updateLastInteractionTime(true); + }, [inputValue, submitCount]); useEffect(() => { if (submitCount === 1) { - startBackgroundHousekeeping() + startBackgroundHousekeeping(); } - }, [submitCount]) + }, [submitCount]); // Show notification when Claude is done responding and user is idle useEffect(() => { // Don't set up notification if Claude is busy - if (isLoading) return + if (isLoading) return; // Only enable notifications after the first new interaction in this session - if (submitCount === 0) return + if (submitCount === 0) return; // No query has completed yet - if (lastQueryCompletionTime === 0) return + if (lastQueryCompletionTime === 0) return; // Set timeout to check idle state const timer = setTimeout( - ( - lastQueryCompletionTime, - isLoading, - toolJSX, - focusedInputDialogRef, - terminal, - ) => { + (lastQueryCompletionTime, isLoading, toolJSX, focusedInputDialogRef, terminal) => { // Check if user has interacted since the response ended - const lastUserInteraction = getLastInteractionTime() + const lastUserInteraction = getLastInteractionTime(); if (lastUserInteraction > lastQueryCompletionTime) { // User has interacted since Claude finished - they're not idle, don't notify - return + return; } // User hasn't interacted since response ended, check other conditions - const idleTimeSinceResponse = Date.now() - lastQueryCompletionTime + const idleTimeSinceResponse = Date.now() - lastQueryCompletionTime; if ( !isLoading && !toolJSX && @@ -5252,7 +4606,7 @@ export function REPL({ notificationType: 'idle_prompt', }, terminal, - ) + ); } }, getGlobalConfig().messageIdleNotifThresholdMs, @@ -5261,40 +4615,34 @@ export function REPL({ toolJSX, focusedInputDialogRef, terminal, - ) + ); - return () => clearTimeout(timer) - }, [isLoading, toolJSX, submitCount, lastQueryCompletionTime, terminal]) + return () => clearTimeout(timer); + }, [isLoading, toolJSX, submitCount, lastQueryCompletionTime, terminal]); // Idle-return hint: show notification when idle threshold is exceeded. // Timer fires after the configured idle period; notification persists until // dismissed or the user submits. useEffect(() => { - if (lastQueryCompletionTime === 0) return - if (isLoading) return - const willowMode: string = getFeatureValue_CACHED_MAY_BE_STALE( - 'tengu_willow_mode', - 'off', - ) - if (willowMode !== 'hint' && willowMode !== 'hint_v2') return - if (getGlobalConfig().idleReturnDismissed) return - - const tokenThreshold = Number( - process.env.CLAUDE_CODE_IDLE_TOKEN_THRESHOLD ?? 100_000, - ) - if (getTotalInputTokens() < tokenThreshold) return - - const idleThresholdMs = - Number(process.env.CLAUDE_CODE_IDLE_THRESHOLD_MINUTES ?? 75) * 60_000 - const elapsed = Date.now() - lastQueryCompletionTime - const remaining = idleThresholdMs - elapsed + if (lastQueryCompletionTime === 0) return; + if (isLoading) return; + const willowMode: string = getFeatureValue_CACHED_MAY_BE_STALE('tengu_willow_mode', 'off'); + if (willowMode !== 'hint' && willowMode !== 'hint_v2') return; + if (getGlobalConfig().idleReturnDismissed) return; + + const tokenThreshold = Number(process.env.CLAUDE_CODE_IDLE_TOKEN_THRESHOLD ?? 100_000); + if (getTotalInputTokens() < tokenThreshold) return; + + const idleThresholdMs = Number(process.env.CLAUDE_CODE_IDLE_THRESHOLD_MINUTES ?? 75) * 60_000; + const elapsed = Date.now() - lastQueryCompletionTime; + const remaining = idleThresholdMs - elapsed; const timer = setTimeout( (lqct, addNotif, msgsRef, mode, hintRef) => { - if (msgsRef.current.length === 0) return - const totalTokens = getTotalInputTokens() - const formattedTokens = formatTokens(totalTokens) - const idleMinutes = (Date.now() - lqct) / 60_000 + if (msgsRef.current.length === 0) return; + const totalTokens = getTotalInputTokens(); + const formattedTokens = formatTokens(totalTokens); + const idleMinutes = (Date.now() - lqct) / 60_000; addNotif({ key: 'idle-return-hint', jsx: @@ -5306,26 +4654,22 @@ export function REPL({ {formattedTokens} tokens ) : ( - - new task? /clear to save {formattedTokens} tokens - + new task? /clear to save {formattedTokens} tokens ), priority: 'medium', // Persist until submit — the hint fires at T+75min idle, user may // not return for hours. removeNotification in useEffect cleanup // handles dismissal. 0x7FFFFFFF = setTimeout max (~24.8 days). timeoutMs: 0x7fffffff, - }) - hintRef.current = mode + }); + hintRef.current = mode; logEvent('tengu_idle_return_action', { - action: - 'hint_shown' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - variant: - mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + action: 'hint_shown' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + variant: mode as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, idleMinutes: Math.round(idleMinutes), messageCount: msgsRef.current.length, totalInputTokens: totalTokens, - }) + }); }, Math.max(0, remaining), lastQueryCompletionTime, @@ -5333,48 +4677,44 @@ export function REPL({ messagesRef, willowMode, idleHintShownRef, - ) + ); return () => { - clearTimeout(timer) - removeNotification('idle-return-hint') - idleHintShownRef.current = false - } - }, [lastQueryCompletionTime, isLoading, addNotification, removeNotification]) + clearTimeout(timer); + removeNotification('idle-return-hint'); + idleHintShownRef.current = false; + }; + }, [lastQueryCompletionTime, isLoading, addNotification, removeNotification]); // Submits incoming prompts from teammate messages or tasks mode as new turns // Returns true if submission succeeded, false if a query is already running const handleIncomingPrompt = useCallback( (content: string, options?: { isMeta?: boolean }): boolean => { - if (queryGuard.isActive) return false + if (queryGuard.isActive) return false; // Defer to user-queued commands — user input always takes priority // over system messages (teammate messages, task list items, etc.) // Read from the module-level store at call time (not the render-time // snapshot) to avoid a stale closure — this callback's deps don't // include the queue. - if ( - getCommandQueue().some( - cmd => cmd.mode === 'prompt' || cmd.mode === 'bash', - ) - ) { - return false + if (getCommandQueue().some(cmd => cmd.mode === 'prompt' || cmd.mode === 'bash')) { + return false; } - const newAbortController = createAbortController() - setAbortController(newAbortController) + const newAbortController = createAbortController(); + setAbortController(newAbortController); // Create a user message with the formatted content (includes XML wrapper) const userMessage = createUserMessage({ content, isMeta: options?.isMeta ? true : undefined, - }) + }); - void onQuery([userMessage], newAbortController, true, [], mainLoopModel) - return true + void onQuery([userMessage], newAbortController, true, [], mainLoopModel); + return true; }, [onQuery, mainLoopModel, store], - ) + ); // Voice input integration (VOICE_MODE builds only) const voice = feature('VOICE_MODE') @@ -5385,16 +4725,16 @@ export function REPL({ handleKeyEvent: () => {}, resetAnchor: () => {}, interimRange: null, - } + }; useInboxPoller({ enabled: isAgentSwarmsEnabled(), isLoading, focusedInputDialog, onSubmitMessage: handleIncomingPrompt, - }) + }); - useMailboxBridge({ isLoading, onSubmitMessage: handleIncomingPrompt }) + useMailboxBridge({ isLoading, onSubmitMessage: handleIncomingPrompt }); // Scheduled tasks from .claude/scheduled_tasks.json (CronCreate/Delete/List) if (feature('AGENT_TRIGGERS')) { @@ -5404,9 +4744,9 @@ export function REPL({ // subscription needed. The tengu_kairos_cron runtime gate is checked inside // useScheduledTasks's effect (not here) since wrapping a hook call in a dynamic // condition would break rules-of-hooks. - const assistantMode = store.getState().kairosEnabled + const assistantMode = store.getState().kairosEnabled; // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useScheduledTasks!({ isLoading, assistantMode, setMessages }) + useScheduledTasks!({ isLoading, assistantMode, setMessages }); } // Note: Permission polling is now handled by useInboxPoller @@ -5421,7 +4761,7 @@ export function REPL({ taskListId, isLoading, onSubmitTask: handleIncomingPrompt, - }) + }); // Loop mode: auto-tick when enabled (via /job command) // eslint-disable-next-line react-hooks/rules-of-hooks @@ -5434,61 +4774,59 @@ export function REPL({ queuedCommandsLength: queuedCommands.length, hasActiveLocalJsxUI: isShowingLocalJSXCommand, isInPlanMode: toolPermissionContext.mode === 'plan', - onSubmitTick: (prompt: string) => - handleIncomingPrompt(prompt, { isMeta: true }), - onQueueTick: (prompt: string) => - enqueue({ mode: 'prompt', value: prompt, isMeta: true }), - }) + onSubmitTick: (prompt: string) => handleIncomingPrompt(prompt, { isMeta: true }), + onQueueTick: (prompt: string) => enqueue({ mode: 'prompt', value: prompt, isMeta: true }), + }); } // Abort the current operation when a 'now' priority message arrives // (e.g. from a chat UI client via UDS). useEffect(() => { if (queuedCommands.some(cmd => cmd.priority === 'now')) { - abortControllerRef.current?.abort('interrupt') + abortControllerRef.current?.abort('interrupt'); } - }, [queuedCommands]) + }, [queuedCommands]); // Initial load useEffect(() => { - void onInit() + void onInit(); // Cleanup on unmount return () => { - void diagnosticTracker.shutdown() - } + void diagnosticTracker.shutdown(); + }; // TODO: fix this // eslint-disable-next-line react-hooks/exhaustive-deps - }, []) + }, []); // Listen for suspend/resume events - const { internal_eventEmitter } = useStdin() - const [remountKey, setRemountKey] = useState(0) + const { internal_eventEmitter } = useStdin(); + const [remountKey, setRemountKey] = useState(0); useEffect(() => { const handleSuspend = () => { // Print suspension instructions process.stdout.write( `\nClaude Code has been suspended. Run \`fg\` to bring Claude Code back.\nNote: ctrl + z now suspends Claude Code, ctrl + _ undoes input.\n`, - ) - } + ); + }; const handleResume = () => { // Force complete component tree replacement instead of terminal clear // Ink now handles line count reset internally on SIGCONT - setRemountKey(prev => prev + 1) - } + setRemountKey(prev => prev + 1); + }; - internal_eventEmitter?.on('suspend', handleSuspend) - internal_eventEmitter?.on('resume', handleResume) + internal_eventEmitter?.on('suspend', handleSuspend); + internal_eventEmitter?.on('resume', handleResume); return () => { - internal_eventEmitter?.off('suspend', handleSuspend) - internal_eventEmitter?.off('resume', handleResume) - } - }, [internal_eventEmitter]) + internal_eventEmitter?.off('suspend', handleSuspend); + internal_eventEmitter?.off('resume', handleResume); + }; + }, [internal_eventEmitter]); // Derive stop hook spinner suffix from messages state const stopHookSpinnerSuffix = useMemo(() => { - if (!isLoading) return null + if (!isLoading) return null; // Find stop hook progress messages const progressMsgs = messages.filter( @@ -5496,106 +4834,89 @@ export function REPL({ m.type === 'progress' && m.data.type === 'hook_progress' && (m.data.hookEvent === 'Stop' || m.data.hookEvent === 'SubagentStop'), - ) - if (progressMsgs.length === 0) return null + ); + if (progressMsgs.length === 0) return null; // Get the most recent stop hook execution - const currentToolUseID = progressMsgs.at(-1)?.toolUseID - if (!currentToolUseID) return null + const currentToolUseID = progressMsgs.at(-1)?.toolUseID; + if (!currentToolUseID) return null; // Check if there's already a summary message for this execution (hooks completed) const hasSummaryForCurrentExecution = messages.some( - m => - m.type === 'system' && - m.subtype === 'stop_hook_summary' && - m.toolUseID === currentToolUseID, - ) - if (hasSummaryForCurrentExecution) return null - - const currentHooks = progressMsgs.filter( - p => p.toolUseID === currentToolUseID, - ) - const total = currentHooks.length + m => m.type === 'system' && m.subtype === 'stop_hook_summary' && m.toolUseID === currentToolUseID, + ); + if (hasSummaryForCurrentExecution) return null; + + const currentHooks = progressMsgs.filter(p => p.toolUseID === currentToolUseID); + const total = currentHooks.length; // Count completed hooks const completedCount = count(messages, m => { - if (m.type !== 'attachment') return false - const attachment = m.attachment + if (m.type !== 'attachment') return false; + const attachment = m.attachment; return ( 'hookEvent' in attachment && - (attachment.hookEvent === 'Stop' || - attachment.hookEvent === 'SubagentStop') && + (attachment.hookEvent === 'Stop' || attachment.hookEvent === 'SubagentStop') && 'toolUseID' in attachment && attachment.toolUseID === currentToolUseID - ) - }) + ); + }); // Check if any hook has a custom status message - const customMessage = currentHooks.find(p => p.data.statusMessage)?.data - .statusMessage + const customMessage = currentHooks.find(p => p.data.statusMessage)?.data.statusMessage; if (customMessage) { // Use custom message with progress counter if multiple hooks - return total === 1 - ? `${customMessage}…` - : `${customMessage}… ${completedCount}/${total}` + return total === 1 ? `${customMessage}…` : `${customMessage}… ${completedCount}/${total}`; } // Fall back to default behavior - const hookType = - currentHooks[0]?.data.hookEvent === 'SubagentStop' - ? 'subagent stop' - : 'stop' + const hookType = currentHooks[0]?.data.hookEvent === 'SubagentStop' ? 'subagent stop' : 'stop'; if (process.env.USER_TYPE === 'ant') { - const cmd = currentHooks[completedCount]?.data.command - const label = cmd ? ` '${truncateToWidth(cmd, 40)}'` : '' + const cmd = currentHooks[completedCount]?.data.command; + const label = cmd ? ` '${truncateToWidth(cmd, 40)}'` : ''; return total === 1 ? `running ${hookType} hook${label}` - : `running ${hookType} hook${label}\u2026 ${completedCount}/${total}` + : `running ${hookType} hook${label}\u2026 ${completedCount}/${total}`; } - return total === 1 - ? `running ${hookType} hook` - : `running stop hooks… ${completedCount}/${total}` - }, [messages, isLoading]) + return total === 1 ? `running ${hookType} hook` : `running stop hooks… ${completedCount}/${total}`; + }, [messages, isLoading]); // Callback to capture frozen state when entering transcript mode const handleEnterTranscript = useCallback(() => { setFrozenTranscriptState({ messagesLength: messages.length, streamingToolUsesLength: streamingToolUses.length, - }) - }, [messages.length, streamingToolUses.length]) + }); + }, [messages.length, streamingToolUses.length]); // Callback to clear frozen state when exiting transcript mode const handleExitTranscript = useCallback(() => { - setFrozenTranscriptState(null) - }, []) + setFrozenTranscriptState(null); + }, []); // Props for GlobalKeybindingHandlers component (rendered inside KeybindingSetup) - const virtualScrollActive = isFullscreenEnvEnabled() && !disableVirtualScroll + const virtualScrollActive = isFullscreenEnvEnabled() && !disableVirtualScroll; // Transcript search state. Hooks must be unconditional so they live here // (not inside the `if (screen === 'transcript')` branch below); isActive // gates the useInput. Query persists across bar open/close so n/N keep // working after Enter dismisses the bar (less semantics). - const jumpRef = useRef(null) - const [searchOpen, setSearchOpen] = useState(false) - const [searchQuery, setSearchQuery] = useState('') - const [searchCount, setSearchCount] = useState(0) - const [searchCurrent, setSearchCurrent] = useState(0) - const onSearchMatchesChange = useCallback( - (count: number, current: number) => { - setSearchCount(count) - setSearchCurrent(current) - }, - [], - ) + const jumpRef = useRef(null); + const [searchOpen, setSearchOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [searchCount, setSearchCount] = useState(0); + const [searchCurrent, setSearchCurrent] = useState(0); + const onSearchMatchesChange = useCallback((count: number, current: number) => { + setSearchCount(count); + setSearchCurrent(current); + }, []); useInput( (input, key, event) => { - if (key.ctrl || key.meta) return + if (key.ctrl || key.meta) return; // No Esc handling here — less has no navigating mode. Search state // (highlights, n/N) is just state. Esc/q/ctrl+c → transcript:exit // (ungated). Highlights clear on exit via the screen-change effect. @@ -5603,104 +4924,91 @@ export function REPL({ // Capture scrollTop NOW — typing is a preview, 0-matches snaps // back here. Synchronous ref write, fires before the bar's // mount-effect calls setSearchQuery. - jumpRef.current?.setAnchor() - setSearchOpen(true) - event.stopImmediatePropagation() - return + jumpRef.current?.setAnchor(); + setSearchOpen(true); + event.stopImmediatePropagation(); + return; } // Held-key batching: tokenizer coalesces to 'nnn'. Same uniform-batch // pattern as modalPagerAction in ScrollKeybindingHandler.tsx. Each // repeat is a step (n isn't idempotent like g). - const c = input[0] - if ( - (c === 'n' || c === 'N') && - input === c.repeat(input.length) && - searchCount > 0 - ) { - const fn = - c === 'n' ? jumpRef.current?.nextMatch : jumpRef.current?.prevMatch - if (fn) for (let i = 0; i < input.length; i++) fn() - event.stopImmediatePropagation() + const c = input[0]; + if ((c === 'n' || c === 'N') && input === c.repeat(input.length) && searchCount > 0) { + const fn = c === 'n' ? jumpRef.current?.nextMatch : jumpRef.current?.prevMatch; + if (fn) for (let i = 0; i < input.length; i++) fn(); + event.stopImmediatePropagation(); } }, // Search needs virtual scroll (jumpRef drives VirtualMessageList). [ // kills it, so !dumpMode — after [ there's nothing to jump in. { - isActive: - screen === 'transcript' && - virtualScrollActive && - !searchOpen && - !dumpMode, + isActive: screen === 'transcript' && virtualScrollActive && !searchOpen && !dumpMode, }, - ) - const { - setQuery: setHighlight, - scanElement, - setPositions, - } = useSearchHighlight() + ); + const { setQuery: setHighlight, scanElement, setPositions } = useSearchHighlight(); // Resize → abort search. Positions are (msg, query, WIDTH)-keyed — // cached positions are stale after a width change (new layout, new // wrapping). Clearing searchQuery triggers VML's setSearchQuery('') // which clears positionsCache + setPositions(null). Bar closes. // User hits / again → fresh everything. - const transcriptCols = useTerminalSize().columns - const prevColsRef = React.useRef(transcriptCols) + const transcriptCols = useTerminalSize().columns; + const prevColsRef = React.useRef(transcriptCols); React.useEffect(() => { if (prevColsRef.current !== transcriptCols) { - prevColsRef.current = transcriptCols + prevColsRef.current = transcriptCols; if (searchQuery || searchOpen) { - setSearchOpen(false) - setSearchQuery('') - setSearchCount(0) - setSearchCurrent(0) - jumpRef.current?.disarmSearch() - setHighlight('') + setSearchOpen(false); + setSearchQuery(''); + setSearchCount(0); + setSearchCurrent(0); + jumpRef.current?.disarmSearch(); + setHighlight(''); } } - }, [transcriptCols, searchQuery, searchOpen, setHighlight]) + }, [transcriptCols, searchQuery, searchOpen, setHighlight]); // Transcript escape hatches. Bare letters in modal context (no prompt // competing for input) — same class as g/G/j/k in ScrollKeybindingHandler. useInput( (input, key, event) => { - if (key.ctrl || key.meta) return + if (key.ctrl || key.meta) return; if (input === 'q') { // less: q quits the pager. ctrl+o toggles; q is the lineage exit. - handleExitTranscript() - event.stopImmediatePropagation() - return + handleExitTranscript(); + event.stopImmediatePropagation(); + return; } if (input === '[' && !dumpMode) { // Force dump-to-scrollback. Also expand + uncap — no point dumping // a subset. Terminal/tmux cmd-F can now find anything. Guard here // (not in isActive) so v still works post-[ — dump-mode footer at // ~4898 wires editorStatus, confirming v is meant to stay live. - setDumpMode(true) - setShowAllInTranscript(true) - event.stopImmediatePropagation() + setDumpMode(true); + setShowAllInTranscript(true); + event.stopImmediatePropagation(); } else if (input === 'v') { // less-style: v opens the file in $VISUAL/$EDITOR. Render the full // transcript (same path /export uses), write to tmp, hand off. // openFileInExternalEditor handles alt-screen suspend/resume for // terminal editors; GUI editors spawn detached. - event.stopImmediatePropagation() + event.stopImmediatePropagation(); // Drop double-taps: the render is async and a second press before it // completes would run a second parallel render (double memory, two // tempfiles, two editor spawns). editorGenRef only guards // transcript-exit staleness, not same-session concurrency. - if (editorRenderingRef.current) return - editorRenderingRef.current = true + if (editorRenderingRef.current) return; + editorRenderingRef.current = true; // Capture generation + make a staleness-aware setter. Each write // checks gen (transcript exit bumps it → late writes from the // async render go silent). - const gen = editorGenRef.current + const gen = editorGenRef.current; const setStatus = (s: string): void => { - if (gen !== editorGenRef.current) return - clearTimeout(editorTimerRef.current) - setEditorStatus(s) - } - setStatus(`rendering ${deferredMessages.length} messages…`) + if (gen !== editorGenRef.current) return; + clearTimeout(editorTimerRef.current); + setEditorStatus(s); + }; + setStatus(`rendering ${deferredMessages.length} messages…`); void (async () => { try { // Width = terminal minus vim's line-number gutter (4 digits + @@ -5708,62 +5016,52 @@ export function REPL({ // without this Ink defaults to 80. Trailing-space strip: right- // aligned timestamps still leave a flexbox spacer run at EOL. // eslint-disable-next-line custom-rules/prefer-use-terminal-size -- one-shot at keypress time, not a reactive render dep - const w = Math.max(80, (process.stdout.columns ?? 80) - 6) - const raw = await renderMessagesToPlainText( - deferredMessages, - tools, - w, - ) - const text = raw.replace(/[ \t]+$/gm, '') - const path = join(tmpdir(), `cc-transcript-${Date.now()}.txt`) - await writeFile(path, text) - const opened = openFileInExternalEditor(path) - setStatus( - opened - ? `opening ${path}` - : `wrote ${path} · no $VISUAL/$EDITOR set`, - ) + const w = Math.max(80, (process.stdout.columns ?? 80) - 6); + const raw = await renderMessagesToPlainText(deferredMessages, tools, w); + const text = raw.replace(/[ \t]+$/gm, ''); + const path = join(tmpdir(), `cc-transcript-${Date.now()}.txt`); + await writeFile(path, text); + const opened = openFileInExternalEditor(path); + setStatus(opened ? `opening ${path}` : `wrote ${path} · no $VISUAL/$EDITOR set`); } catch (e) { - setStatus( - `render failed: ${e instanceof Error ? e.message : String(e)}`, - ) + setStatus(`render failed: ${e instanceof Error ? e.message : String(e)}`); } - editorRenderingRef.current = false - if (gen !== editorGenRef.current) return - editorTimerRef.current = setTimeout(s => s(''), 4000, setEditorStatus) - })() + editorRenderingRef.current = false; + if (gen !== editorGenRef.current) return; + editorTimerRef.current = setTimeout(s => s(''), 4000, setEditorStatus); + })(); } }, // !searchOpen: typing 'v' or '[' in the search bar is search input, not // a command. No !dumpMode here — v should work after [ (the [ handler // guards itself inline). { isActive: screen === 'transcript' && virtualScrollActive && !searchOpen }, - ) + ); // Fresh `less` per transcript entry. Prevents stale highlights matching // unrelated normal-mode text (overlay is alt-screen-global) and avoids // surprise n/N on re-entry. Same exit resets [ dump mode — each ctrl+o // entry is a fresh instance. - const inTranscript = screen === 'transcript' && virtualScrollActive + const inTranscript = screen === 'transcript' && virtualScrollActive; useEffect(() => { if (!inTranscript) { - setSearchQuery('') - setSearchCount(0) - setSearchCurrent(0) - setSearchOpen(false) - editorGenRef.current++ - clearTimeout(editorTimerRef.current) - setDumpMode(false) - setEditorStatus('') + setSearchQuery(''); + setSearchCount(0); + setSearchCurrent(0); + setSearchOpen(false); + editorGenRef.current++; + clearTimeout(editorTimerRef.current); + setDumpMode(false); + setEditorStatus(''); } - }, [inTranscript]) + }, [inTranscript]); useEffect(() => { - setHighlight(inTranscript ? searchQuery : '') + setHighlight(inTranscript ? searchQuery : ''); // Clear the position-based CURRENT (yellow) overlay too. setHighlight // only clears the scan-based inverse. Without this, the yellow box // persists at its last screen coords after ctrl-c exits transcript. - if (!inTranscript) setPositions(null) - }, [inTranscript, searchQuery, setHighlight, setPositions]) + if (!inTranscript) setPositions(null); + }, [inTranscript, searchQuery, setHighlight, setPositions]); const globalKeybindingProps = { screen, @@ -5781,26 +5079,24 @@ export function REPL({ // would fire on the same Esc that cancels the bar (child registers // first, fires first, bubbles). searchBarOpen: searchOpen, - } + }; // Use frozen lengths to slice arrays, avoiding memory overhead of cloning const transcriptMessages = frozenTranscriptState ? deferredMessages.slice(0, frozenTranscriptState.messagesLength) - : deferredMessages + : deferredMessages; const transcriptStreamingToolUses = frozenTranscriptState ? streamingToolUses.slice(0, frozenTranscriptState.streamingToolUsesLength) - : streamingToolUses + : streamingToolUses; // Handle shift+down for teammate navigation and background task management. // Guard onOpenBackgroundTasks when a local-jsx dialog (e.g. /mcp) is open — // otherwise Shift+Down stacks BackgroundTasksDialog on top and deadlocks input. useBackgroundTaskNavigation({ - onOpenBackgroundTasks: isShowingLocalJSXCommand - ? undefined - : () => setShowBashesDialog(true), - }) + onOpenBackgroundTasks: isShowingLocalJSXCommand ? undefined : () => setShowBashesDialog(true), + }); // Auto-exit viewing mode when teammate completes or errors - useTeammateViewAutoExit() + useTeammateViewAutoExit(); if (screen === 'transcript') { // Virtual scroll replaces the 30-message cap: everything is scrollable @@ -5811,10 +5107,7 @@ export function REPL({ // scrollback, 30-cap + Ctrl+E. Reusing scrollRef is safe — normal-mode // and transcript-mode are mutually exclusive (this early return), so // only one ScrollBox is ever mounted at a time. - const transcriptScrollRef = - isFullscreenEnvEnabled() && !disableVirtualScroll && !dumpMode - ? scrollRef - : undefined + const transcriptScrollRef = isFullscreenEnvEnabled() && !disableVirtualScroll && !dumpMode ? scrollRef : undefined; const transcriptMessagesElement = ( - ) + ); const transcriptToolJSX = toolJSX && ( {toolJSX.jsx} - ) + ); const transcriptReturn = ( ) : null} - + {transcriptScrollRef ? ( // ScrollKeybindingHandler must mount before CancelRequestHandler so // ctrl+c-with-selection copies instead of cancelling the active task. @@ -5913,17 +5203,17 @@ export function REPL({ onClose={q => { // Enter — commit. 0-match guard: junk query shouldn't // persist (badge hidden, n/N dead anyway). - setSearchQuery(searchCount > 0 ? q : '') - setSearchOpen(false) + setSearchQuery(searchCount > 0 ? q : ''); + setSearchOpen(false); // onCancel path: bar unmounts before its useEffect([query]) // can fire with ''. Without this, searchCount stays stale // (n guard at :4956 passes) and VML's matches[] too // (nextMatch walks the old array). Phantom nav, no // highlight. onExit (Enter, q non-empty) still commits. if (!q) { - setSearchCount(0) - setSearchCurrent(0) - jumpRef.current?.setSearchQuery('') + setSearchCount(0); + setSearchCurrent(0); + jumpRef.current?.setSearchQuery(''); } }} onCancel={() => { @@ -5935,10 +5225,10 @@ export function REPL({ // nearest. Both synchronous — one React batch. // setHighlight explicit: REPL's sync-effect dep is // searchQuery (unchanged), wouldn't re-fire. - setSearchOpen(false) - jumpRef.current?.setSearchQuery('') - jumpRef.current?.setSearchQuery(searchQuery) - setHighlight(searchQuery) + setSearchOpen(false); + jumpRef.current?.setSearchQuery(''); + jumpRef.current?.setSearchQuery(searchQuery); + setHighlight(searchQuery); }} setHighlight={setHighlight} /> @@ -5948,9 +5238,7 @@ export function REPL({ virtualScroll={true} status={editorStatus || undefined} searchBadge={ - searchQuery && searchCount > 0 - ? { current: searchCurrent, count: searchCount } - : undefined + searchQuery && searchCount > 0 ? { current: searchCurrent, count: searchCount } : undefined } /> ) @@ -5970,7 +5258,7 @@ export function REPL({ )} - ) + ); // The virtual-scroll branch (FullscreenLayout above) needs // 's constraint — without it, // ScrollBox's flexGrow has no ceiling, viewport = content height, @@ -5980,25 +5268,18 @@ export function REPL({ // stays entered across toggle. The 30-cap dump branch stays // unwrapped — it wants native terminal scrollback. if (transcriptScrollRef) { - return ( - - {transcriptReturn} - - ) + return {transcriptReturn}; } - return transcriptReturn + return transcriptReturn; } // Get viewed agent task (inlined from selectors for explicit data flow). // viewedAgentTask: teammate OR local_agent — drives the boolean checks // below. viewedTeammateTask: teammate-only narrowed, for teammate-specific // field access (inProgressToolUseIDs). - const viewedTask = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined - const viewedTeammateTask = - viewedTask && isInProcessTeammateTask(viewedTask) ? viewedTask : undefined - const viewedAgentTask = - viewedTeammateTask ?? - (viewedTask && isLocalAgentTask(viewedTask) ? viewedTask : undefined) + const viewedTask = viewingAgentTaskId ? tasks[viewingAgentTaskId] : undefined; + const viewedTeammateTask = viewedTask && isInProcessTeammateTask(viewedTask) ? viewedTask : undefined; + const viewedAgentTask = viewedTeammateTask ?? (viewedTask && isLocalAgentTask(viewedTask) ? viewedTask : undefined); // Bypass useDeferredValue when streaming text is showing so Messages renders // the final message in the same frame streaming text clears. Also bypass when @@ -6006,14 +5287,14 @@ export function REPL({ // responsive); after the turn ends, showing messages immediately prevents a // jitter gap where the spinner is gone but the answer hasn't appeared yet. // Only reducedMotion users keep the deferred path during loading. - const usesSyncMessages = showStreamingText || !isLoading + const usesSyncMessages = showStreamingText || !isLoading; // When viewing an agent, never fall through to leader — empty until // bootstrap/stream fills. Closes the see-leader-type-agent footgun. const displayedMessages = viewedAgentTask ? (viewedAgentTask.messages ?? []) : usesSyncMessages ? messages - : deferredMessages + : deferredMessages; // Show the placeholder until the real user message appears in // displayedMessages. userInputOnProcessing stays set for the whole turn // (cleared in resetLoadingState); this length check hides it once @@ -6023,11 +5304,9 @@ export function REPL({ // agent — displayedMessages is a different array there, and onAgentSubmit // doesn't use the placeholder anyway. const placeholderText = - userInputOnProcessing && - !viewedAgentTask && - displayedMessages.length <= userInputBaselineRef.current + userInputOnProcessing && !viewedAgentTask && displayedMessages.length <= userInputBaselineRef.current ? userInputOnProcessing - : undefined + : undefined; const toolPermissionOverlay = focusedInputDialog === 'tool-permission' ? ( @@ -6044,24 +5323,21 @@ export function REPL({ )} verbose={verbose} workerBadge={toolUseConfirmQueue[0]?.workerBadge} - setStickyFooter={ - isFullscreenEnvEnabled() ? setPermissionStickyFooter : undefined - } + setStickyFooter={isFullscreenEnvEnabled() ? setPermissionStickyFooter : undefined} /> - ) : null + ) : null; // Narrow terminals: companion collapses to a one-liner that REPL stacks // on its own row (above input in fullscreen, below in scrollback) instead // of row-beside. Wide terminals keep the row layout with sprite on the right. - const companionNarrow = transcriptCols < MIN_COLS_FOR_FULL_SPRITE + const companionNarrow = transcriptCols < MIN_COLS_FOR_FULL_SPRITE; // Hide the sprite when PromptInput early-returns BackgroundTasksDialog. // The sprite sits as a row sibling of PromptInput, so the dialog's Pane // divider draws at useTerminalSize() width but only gets terminalWidth - // spriteWidth — divider stops short and dialog text wraps early. Don't // check footerSelection: pill FOCUS (arrow-down to tasks pill) must keep // the sprite visible so arrow-right can navigate to it. - const companionVisible = - !toolJSX?.shouldHidePromptInput && !focusedInputDialog && !showBashesDialog + const companionVisible = !toolJSX?.shouldHidePromptInput && !focusedInputDialog && !showBashesDialog; // In fullscreen, ALL local-jsx slash commands float in the modal slot — // FullscreenLayout wraps them in an absolute-positioned bottom-anchored @@ -6070,9 +5346,8 @@ export function REPL({ // render paths below. Commands that used to route through bottom // (immediate: /model, /mcp, /btw, ...) and scrollable (non-immediate: // /config, /theme, /diff, ...) both go here now. - const toolJsxCentered = - isFullscreenEnvEnabled() && toolJSX?.isLocalJSXCommand === true - const centeredModal: React.ReactNode = toolJsxCentered ? toolJSX!.jsx : null + const toolJsxCentered = isFullscreenEnvEnabled() && toolJSX?.isLocalJSXCommand === true; + const centeredModal: React.ReactNode = toolJsxCentered ? toolJSX!.jsx : null; // at the root: everything below is inside its // . Handlers/contexts are zero-height so ScrollBox's @@ -6096,10 +5371,7 @@ export function REPL({ isActive={!toolJSX?.isLocalJSXCommand} /> ) : null} - + {/* ScrollKeybindingHandler must mount before CancelRequestHandler so ctrl+c-with-selection copies instead of cancelling the active task. Its raw useInput handler only stops propagation when a selection @@ -6112,37 +5384,20 @@ export function REPL({ scrollRef={scrollRef} isActive={ isFullscreenEnvEnabled() && - (centeredModal != null || - !focusedInputDialog || - focusedInputDialog === 'tool-permission') - } - onScroll={ - centeredModal || toolPermissionOverlay || viewedAgentTask - ? undefined - : composedOnScroll + (centeredModal != null || !focusedInputDialog || focusedInputDialog === 'tool-permission') } + onScroll={centeredModal || toolPermissionOverlay || viewedAgentTask ? undefined : composedOnScroll} /> - {feature('MESSAGE_ACTIONS') && - isFullscreenEnvEnabled() && - !disableMessageActions ? ( - + {feature('MESSAGE_ACTIONS') && isFullscreenEnvEnabled() && !disableMessageActions ? ( + ) : null} - + - ) : undefined + feature('BUDDY') && companionVisible && !companionNarrow ? : undefined } modal={centeredModal} modalScrollRef={modalScrollRef} @@ -6151,8 +5406,8 @@ export function REPL({ hideSticky={!!viewedTeammateTask} newMessageCount={unseenDivider?.count ?? 0} onPillClick={() => { - setCursor(null) - jumpToNew(scrollRef.current) + setCursor(null); + jumpToNew(scrollRef.current); }} scrollable={ <> @@ -6165,9 +5420,7 @@ export function REPL({ toolJSX={toolJSX} toolUseConfirmQueue={toolUseConfirmQueue} inProgressToolUseIDs={ - viewedTeammateTask - ? (viewedTeammateTask.inProgressToolUseIDs ?? new Set()) - : inProgressToolUseIDs + viewedTeammateTask ? (viewedTeammateTask.inProgressToolUseIDs ?? new Set()) : inProgressToolUseIDs } isMessageSelectorVisible={isMessageSelectorVisible} conversationId={conversationId} @@ -6177,9 +5430,7 @@ export function REPL({ agentDefinitions={agentDefinitions} onOpenRateLimitOptions={handleOpenRateLimitOptions} isLoading={isLoading} - streamingText={ - isLoading && !viewedAgentTask ? visibleStreamingText : null - } + streamingText={isLoading && !viewedAgentTask ? visibleStreamingText : null} isBriefOnly={viewedAgentTask ? false : isBriefOnly} unseenDivider={viewedAgentTask ? undefined : unseenDivider} scrollRef={isFullscreenEnvEnabled() ? scrollRef : undefined} @@ -6195,25 +5446,15 @@ export function REPL({ (the modal IS the /config UI). Outside modals it stays so the user sees their input echoed while Claude processes. */} {!disabled && placeholderText && !centeredModal && ( - + + )} + {toolJSX && !(toolJSX.isLocalJSXCommand && toolJSX.isImmediate) && !toolJsxCentered && ( + + {toolJSX.jsx} + )} - {toolJSX && - !(toolJSX.isLocalJSXCommand && toolJSX.isImmediate) && - !toolJsxCentered && ( - - {toolJSX.jsx} - - )} {process.env.USER_TYPE === 'ant' && } - {feature('WEB_BROWSER_TOOL') - ? WebBrowserPanelModule && ( - - ) - : null} + {feature('WEB_BROWSER_TOOL') ? WebBrowserPanelModule && : null} {showSpinner && ( - {feature('BUDDY') && - companionNarrow && - isFullscreenEnvEnabled() && - companionVisible ? ( + {feature('BUDDY') && companionNarrow && isFullscreenEnvEnabled() && companionVisible ? ( ) : null} @@ -6269,35 +5503,26 @@ export function REPL({ stays in scrollable: the main loop is paused so no jiggle, and their tall content (DiffDetailView renders up to 400 lines with no internal scroll) needs the outer ScrollBox. */} - {toolJSX?.isLocalJSXCommand && - toolJSX.isImmediate && - !toolJsxCentered && ( - - {toolJSX.jsx} - - )} - {!showSpinner && - !toolJSX?.isLocalJSXCommand && - showExpandedTodos && - tasksV2 && - tasksV2.length > 0 && ( - - - - )} + {toolJSX?.isLocalJSXCommand && toolJSX.isImmediate && !toolJsxCentered && ( + + {toolJSX.jsx} + + )} + {!showSpinner && !toolJSX?.isLocalJSXCommand && showExpandedTodos && tasksV2 && tasksV2.length > 0 && ( + + + + )} {focusedInputDialog === 'sandbox-permission' && ( { - const { allow, persistToSettings } = response - const currentRequest = sandboxPermissionRequestQueue[0] - if (!currentRequest) return + onUserResponse={(response: { allow: boolean; persistToSettings: boolean }) => { + const { allow, persistToSettings } = response; + const currentRequest = sandboxPermissionRequestQueue[0]; + if (!currentRequest) return; - const approvedHost = currentRequest.hostPattern.host + const approvedHost = currentRequest.hostPattern.host; if (persistToSettings) { const update = { @@ -6308,49 +5533,39 @@ export function REPL({ ruleContent: `domain:${approvedHost}`, }, ], - behavior: (allow ? 'allow' : 'deny') as - | 'allow' - | 'deny', + behavior: (allow ? 'allow' : 'deny') as 'allow' | 'deny', destination: 'localSettings' as const, - } + }; setAppState(prev => ({ ...prev, - toolPermissionContext: applyPermissionUpdate( - prev.toolPermissionContext, - update, - ), - })) + toolPermissionContext: applyPermissionUpdate(prev.toolPermissionContext, update), + })); - persistPermissionUpdate(update) + persistPermissionUpdate(update); // Immediately update sandbox in-memory config to prevent race conditions // where pending requests slip through before settings change is detected - SandboxManager.refreshConfig() + SandboxManager.refreshConfig(); } // Resolve ALL pending requests for the same host (not just the first one) // This handles the case where multiple parallel requests came in for the same domain setSandboxPermissionRequestQueue(queue => { queue - .filter( - item => item.hostPattern.host === approvedHost, - ) - .forEach(item => item.resolvePromise(allow)) - return queue.filter( - item => item.hostPattern.host !== approvedHost, - ) - }) + .filter(item => item.hostPattern.host === approvedHost) + .forEach(item => item.resolvePromise(allow)); + return queue.filter(item => item.hostPattern.host !== approvedHost); + }); // Clean up bridge subscriptions and cancel remote prompts // for this host since the local user already responded. - const cleanups = - sandboxBridgeCleanupRef.current.get(approvedHost) + const cleanups = sandboxBridgeCleanupRef.current.get(approvedHost); if (cleanups) { for (const fn of cleanups) { - fn() + fn(); } - sandboxBridgeCleanupRef.current.delete(approvedHost) + sandboxBridgeCleanupRef.current.delete(approvedHost); } }} /> @@ -6362,19 +5577,19 @@ export function REPL({ toolInputSummary={promptQueue[0]!.toolInputSummary} request={promptQueue[0]!.request} onRespond={selectedKey => { - const item = promptQueue[0] - if (!item) return + const item = promptQueue[0]; + if (!item) return; item.resolve({ prompt_response: item.request.prompt, selected: selectedKey, - }) - setPromptQueue(([, ...tail]) => tail) + }); + setPromptQueue(([, ...tail]) => tail); }} onAbort={() => { - const item = promptQueue[0] - if (!item) return - item.reject(new Error('Prompt cancelled by user')) - setPromptQueue(([, ...tail]) => tail) + const item = promptQueue[0]; + if (!item) return; + item.reject(new Error('Prompt cancelled by user')); + setPromptQueue(([, ...tail]) => tail); }} /> )} @@ -6402,15 +5617,12 @@ export function REPL({ port: undefined, } as NetworkHostPattern } - onUserResponse={(response: { - allow: boolean - persistToSettings: boolean - }) => { - const { allow, persistToSettings } = response - const currentRequest = workerSandboxPermissions.queue[0] - if (!currentRequest) return + onUserResponse={(response: { allow: boolean; persistToSettings: boolean }) => { + const { allow, persistToSettings } = response; + const currentRequest = workerSandboxPermissions.queue[0]; + if (!currentRequest) return; - const approvedHost = currentRequest.host + const approvedHost = currentRequest.host; // Send response via mailbox to the worker void sendSandboxPermissionResponseViaMailbox( @@ -6419,7 +5631,7 @@ export function REPL({ approvedHost, allow, teamContext?.teamName, - ) + ); if (persistToSettings && allow) { const update = { @@ -6432,18 +5644,15 @@ export function REPL({ ], behavior: 'allow' as const, destination: 'localSettings' as const, - } + }; setAppState(prev => ({ ...prev, - toolPermissionContext: applyPermissionUpdate( - prev.toolPermissionContext, - update, - ), - })) + toolPermissionContext: applyPermissionUpdate(prev.toolPermissionContext, update), + })); - persistPermissionUpdate(update) - SandboxManager.refreshConfig() + persistPermissionUpdate(update); + SandboxManager.refreshConfig(); } // Remove from queue @@ -6453,59 +5662,53 @@ export function REPL({ ...prev.workerSandboxPermissions, queue: prev.workerSandboxPermissions.queue.slice(1), }, - })) + })); }} /> )} {focusedInputDialog === 'elicitation' && ( { - const currentRequest = elicitation.queue[0] - if (!currentRequest) return + const currentRequest = elicitation.queue[0]; + if (!currentRequest) return; // Call respond callback to resolve Promise - currentRequest.respond({ action, content }) + currentRequest.respond({ action, content }); // For URL accept, keep in queue for phase 2 - const isUrlAccept = - currentRequest.params.mode === 'url' && - action === 'accept' + const isUrlAccept = currentRequest.params.mode === 'url' && action === 'accept'; if (!isUrlAccept) { setAppState(prev => ({ ...prev, elicitation: { queue: prev.elicitation.queue.slice(1), }, - })) + })); } }} onWaitingDismiss={action => { - const currentRequest = elicitation.queue[0] + const currentRequest = elicitation.queue[0]; // Remove from queue setAppState(prev => ({ ...prev, elicitation: { queue: prev.elicitation.queue.slice(1), }, - })) - currentRequest?.onWaitingDismiss?.(action) + })); + currentRequest?.onWaitingDismiss?.(action); }} /> )} {focusedInputDialog === 'cost' && ( { - setShowCostDialog(false) - setHaveShownCostDialog(true) + setShowCostDialog(false); + setHaveShownCostDialog(true); saveGlobalConfig(current => ({ ...current, hasAcknowledgedCostThreshold: true, - })) - logEvent('tengu_cost_threshold_acknowledged', {}) + })); + logEvent('tengu_cost_threshold_acknowledged', {}); }} /> )} @@ -6514,50 +5717,46 @@ export function REPL({ idleMinutes={idleReturnPending.idleMinutes} totalInputTokens={getTotalInputTokens()} onDone={async action => { - const pending = idleReturnPending - setIdleReturnPending(null) + const pending = idleReturnPending; + setIdleReturnPending(null); logEvent('tengu_idle_return_action', { - action: - action as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + action: action as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, idleMinutes: Math.round(pending.idleMinutes), messageCount: messagesRef.current.length, totalInputTokens: getTotalInputTokens(), - }) + }); if (action === 'dismiss') { - setInputValue(pending.input) - return + setInputValue(pending.input); + return; } if (action === 'never') { saveGlobalConfig(current => { - if (current.idleReturnDismissed) return current - return { ...current, idleReturnDismissed: true } - }) + if (current.idleReturnDismissed) return current; + return { ...current, idleReturnDismissed: true }; + }); } if (action === 'clear') { - const { clearConversation } = await import( - '../commands/clear/conversation.js' - ) + const { clearConversation } = await import('../commands/clear/conversation.js'); await clearConversation({ setMessages, readFileState: readFileState.current, discoveredSkillNames: discoveredSkillNamesRef.current, - loadedNestedMemoryPaths: - loadedNestedMemoryPathsRef.current, + loadedNestedMemoryPaths: loadedNestedMemoryPathsRef.current, getAppState: () => store.getState(), setAppState, setConversationId, - }) - haikuTitleAttemptedRef.current = false - setHaikuTitle(undefined) - bashTools.current.clear() - bashToolsProcessedIdx.current = 0 + }); + haikuTitleAttemptedRef.current = false; + setHaikuTitle(undefined); + bashTools.current.clear(); + bashToolsProcessedIdx.current = 0; } - skipIdleCheckRef.current = true + skipIdleCheckRef.current = true; void onSubmitRef.current(pending.input, { setCursorOffset: () => {}, clearBuffer: () => {}, resetHistory: () => {}, - }) + }); }} /> )} @@ -6567,39 +5766,33 @@ export function REPL({ installationStatus={ideInstallationStatus} /> )} - {process.env.USER_TYPE === 'ant' && - focusedInputDialog === 'model-switch' && - AntModelSwitchCallout && ( - { - setShowModelSwitchCallout(false) - if (selection === 'switch' && modelAlias) { - setAppState(prev => ({ - ...prev, - mainLoopModel: modelAlias, - mainLoopModelForSession: null, - })) - } - }} - /> - )} + {process.env.USER_TYPE === 'ant' && focusedInputDialog === 'model-switch' && AntModelSwitchCallout && ( + { + setShowModelSwitchCallout(false); + if (selection === 'switch' && modelAlias) { + setAppState(prev => ({ + ...prev, + mainLoopModel: modelAlias, + mainLoopModelForSession: null, + })); + } + }} + /> + )} {process.env.USER_TYPE === 'ant' && focusedInputDialog === 'undercover-callout' && - UndercoverAutoCallout && ( - setShowUndercoverCallout(false)} - /> - )} + UndercoverAutoCallout && setShowUndercoverCallout(false)} />} {focusedInputDialog === 'effort-callout' && ( { - setShowEffortCallout(false) + setShowEffortCallout(false); if (selection !== 'dismiss') { setAppState(prev => ({ ...prev, effortValue: selection, - })) + })); } }} /> @@ -6608,7 +5801,7 @@ export function REPL({ { setAppState(prev => { - if (!prev.showRemoteCallout) return prev + if (!prev.showRemoteCallout) return prev; return { ...prev, showRemoteCallout: false, @@ -6617,8 +5810,8 @@ export function REPL({ replBridgeExplicit: true, replBridgeOutboundOnly: false, }), - } - }) + }; + }); }} /> )} @@ -6635,20 +5828,17 @@ export function REPL({ /> )} - {focusedInputDialog === 'lsp-recommendation' && - lspRecommendation && ( - - )} + {focusedInputDialog === 'lsp-recommendation' && lspRecommendation && ( + + )} {focusedInputDialog === 'desktop-upsell' && ( - setShowDesktopUpsellStartup(false)} - /> + setShowDesktopUpsellStartup(false)} /> )} {feature('ULTRAPLAN') @@ -6671,47 +5861,43 @@ export function REPL({ ultraplanLaunchPending && ( { - const blurb = ultraplanLaunchPending.blurb + const blurb = ultraplanLaunchPending.blurb; setAppState(prev => - prev.ultraplanLaunchPending - ? { ...prev, ultraplanLaunchPending: undefined } - : prev, - ) - if (choice === 'cancel') return + prev.ultraplanLaunchPending ? { ...prev, ultraplanLaunchPending: undefined } : prev, + ); + if (choice === 'cancel') return; // Command's onDone used display:'skip', so add the // echo here — gives immediate feedback before the // ~5s teleportToRemote resolves. setMessages(prev => [ ...prev, - createCommandInputMessage( - formatCommandInputTags('ultraplan', blurb), - ), - ]) + createCommandInputMessage(formatCommandInputTags('ultraplan', blurb)), + ]); const appendStdout = (msg: string) => setMessages(prev => [ ...prev, createCommandInputMessage( `<${LOCAL_COMMAND_STDOUT_TAG}>${escapeXml(msg)}`, ), - ]) + ]); // Defer the second message if a query is mid-turn // so it lands after the assistant reply, not // between the user's prompt and the reply. const appendWhenIdle = (msg: string) => { if (!queryGuard.isActive) { - appendStdout(msg) - return + appendStdout(msg); + return; } const unsub = queryGuard.subscribe(() => { - if (queryGuard.isActive) return - unsub() + if (queryGuard.isActive) return; + unsub(); // Skip if the user stopped ultraplan while we // were waiting — avoids a stale "Monitoring // " message for a session that's gone. - if (!store.getState().ultraplanSessionUrl) return - appendStdout(msg) - }) - } + if (!store.getState().ultraplanSessionUrl) return; + appendStdout(msg); + }); + }; void launchUltraplan({ blurb, getAppState: () => store.getState(), @@ -6721,7 +5907,7 @@ export function REPL({ onSessionReady: appendWhenIdle, }) .then(appendStdout) - .catch(logError) + .catch(logError); }} /> ) @@ -6729,145 +5915,120 @@ export function REPL({ {mrRender()} - {!toolJSX?.shouldHidePromptInput && - !focusedInputDialog && - !isExiting && - !disabled && - !cursor && ( - <> - {autoRunIssueReason && ( - - )} - {postCompactSurvey.state !== 'closed' ? ( - - ) : memorySurvey.state !== 'closed' ? ( - - ) : ( - - )} - {/* Frustration-triggered transcript sharing prompt */} - {frustrationDetection.state !== 'closed' && ( - {}} - handleTranscriptSelect={ - frustrationDetection.handleTranscriptSelect - } - inputValue={inputValue} - setInputValue={setInputValue} - /> - )} - {/* Skill improvement survey - appears when improvements detected (ant-only) */} - {process.env.USER_TYPE === 'ant' && - skillImprovementSurvey.suggestion && ( - - )} - {showIssueFlagBanner && } - { - } - + {autoRunIssueReason && ( + + )} + {postCompactSurvey.state !== 'closed' ? ( + + ) : memorySurvey.state !== 'closed' ? ( + + ) : ( + + )} + {/* Frustration-triggered transcript sharing prompt */} + {frustrationDetection.state !== 'closed' && ( + {}} + handleTranscriptSelect={frustrationDetection.handleTranscriptSelect} + inputValue={inputValue} + setInputValue={setInputValue} /> - - - )} + )} + {showIssueFlagBanner && } + {} + + + + )} {cursor && ( // inputValue is REPL state; typed text survives the round-trip. @@ -6878,17 +6039,12 @@ export function REPL({ preselectedMessage={messageSelectorPreselect} onPreRestore={onCancel} onRestoreCode={async (message: UserMessage) => { - await fileHistoryRewind( - ( - updater: (prev: FileHistoryState) => FileHistoryState, - ) => { - setAppState(prev => ({ - ...prev, - fileHistory: updater(prev.fileHistory), - })) - }, - message.uuid, - ) + await fileHistoryRewind((updater: (prev: FileHistoryState) => FileHistoryState) => { + setAppState(prev => ({ + ...prev, + fileHistory: updater(prev.fileHistory), + })); + }, message.uuid); }} onSummarize={async ( message: UserMessage, @@ -6897,10 +6053,9 @@ export function REPL({ ) => { // Project snipped messages so the compact model // doesn't summarize content that was intentionally removed. - const compactMessages = - getMessagesAfterCompactBoundary(messages) + const compactMessages = getMessagesAfterCompactBoundary(messages); - const messageIndex = compactMessages.indexOf(message) + const messageIndex = compactMessages.indexOf(message); if (messageIndex === -1) { // Selected a snipped or pre-compact message that the // selector still shows (REPL keeps full history for @@ -6912,38 +6067,28 @@ export function REPL({ 'That message is no longer in the active context (snipped or pre-compact). Choose a more recent message.', 'warning', ), - ]) - return + ]); + return; } - const newAbortController = createAbortController() - const context = getToolUseContext( - compactMessages, - [], - newAbortController, - mainLoopModel, - ) + const newAbortController = createAbortController(); + const context = getToolUseContext(compactMessages, [], newAbortController, mainLoopModel); - const appState = context.getAppState() + const appState = context.getAppState(); const defaultSysPrompt = await getSystemPrompt( context.options.tools, context.options.mainLoopModel, - Array.from( - appState.toolPermissionContext.additionalWorkingDirectories.keys(), - ), + Array.from(appState.toolPermissionContext.additionalWorkingDirectories.keys()), context.options.mcpClients, - ) + ); const systemPrompt = buildEffectiveSystemPrompt({ mainThreadAgentDefinition: undefined, toolUseContext: context, customSystemPrompt: context.options.customSystemPrompt, defaultSystemPrompt: defaultSysPrompt, appendSystemPrompt: context.options.appendSystemPrompt, - }) - const [userContext, systemContext] = await Promise.all([ - getUserContext(), - getSystemContext(), - ]) + }); + const [userContext, systemContext] = await Promise.all([getUserContext(), getSystemContext()]); const result = await partialCompactConversation( compactMessages, @@ -6958,19 +6103,19 @@ export function REPL({ }, feedback, direction, - ) + ); - const kept = result.messagesToKeep ?? [] + const kept = result.messagesToKeep ?? []; const ordered = direction === 'up_to' ? [...result.summaryMessages, ...kept] - : [...kept, ...result.summaryMessages] + : [...kept, ...result.summaryMessages]; const postCompact = [ result.boundaryMarker, ...ordered, ...result.attachments, ...result.hookResults, - ] + ]; // Fullscreen 'from' keeps scrollback; 'up_to' must not // (old[0] unchanged + grown array means incremental // useLogMessages path, so boundary never persisted). @@ -6978,58 +6123,47 @@ export function REPL({ // entries can shift the projected messageIndex. if (isFullscreenEnvEnabled() && direction === 'from') { setMessages(old => { - const rawIdx = old.findIndex( - m => m.uuid === message.uuid, - ) - return [ - ...old.slice(0, rawIdx === -1 ? 0 : rawIdx), - ...postCompact, - ] - }) + const rawIdx = old.findIndex(m => m.uuid === message.uuid); + return [...old.slice(0, rawIdx === -1 ? 0 : rawIdx), ...postCompact]; + }); } else { - setMessages(postCompact) + setMessages(postCompact); } // Partial compact bypasses handleMessageFromStream — clear // the context-blocked flag so proactive ticks resume. if (feature('PROACTIVE') || feature('KAIROS')) { - proactiveModule?.setContextBlocked(false) + proactiveModule?.setContextBlocked(false); } - setConversationId(randomUUID()) - runPostCompactCleanup(context.options.querySource) + setConversationId(randomUUID()); + runPostCompactCleanup(context.options.querySource); if (direction === 'from') { - const r = textForResubmit(message) + const r = textForResubmit(message); if (r) { - setInputValue(r.text) - setInputMode(r.mode) + setInputValue(r.text); + setInputMode(r.mode); } } // Show notification with ctrl+o hint - const historyShortcut = getShortcutDisplay( - 'app:toggleTranscript', - 'Global', - 'ctrl+o', - ) + const historyShortcut = getShortcutDisplay('app:toggleTranscript', 'Global', 'ctrl+o'); addNotification({ key: 'summarize-ctrl-o-hint', text: `Conversation summarized (${historyShortcut} for history)`, priority: 'medium', timeoutMs: 8000, - }) + }); }} onRestoreMessage={handleRestoreMessage} onClose={() => { - setIsMessageSelectorVisible(false) - setMessageSelectorPreselect(undefined) + setIsMessageSelectorVisible(false); + setMessageSelectorPreselect(undefined); }} /> )} {process.env.USER_TYPE === 'ant' && } - {feature('BUDDY') && - !(companionNarrow && isFullscreenEnvEnabled()) && - companionVisible ? ( + {feature('BUDDY') && !(companionNarrow && isFullscreenEnvEnabled()) && companionVisible ? ( ) : null} @@ -7037,13 +6171,9 @@ export function REPL({ /> - ) + ); if (isFullscreenEnvEnabled()) { - return ( - - {mainReturn} - - ) + return {mainReturn}; } - return mainReturn + return mainReturn; } diff --git a/src/screens/ResumeConversation.tsx b/src/screens/ResumeConversation.tsx index 019327ff3..39b8208e4 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 '../ink/termio/osc.js' -import { Box, Text } from '../ink.js' -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 '../ink/termio/osc.js'; +import { Box, Text } from '../ink.js'; +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 '../tools/AgentTool/agentColorManager.js' -import type { AgentDefinition } from '../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 '../tools/AgentTool/agentColorManager.js'; +import type { AgentDefinition } from '../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,154 +103,146 @@ 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() - - const crossProjectCheck = checkCrossProjectResume( - log, - showAllProjects, - worktreePaths, - ) + setResuming(true); + const resumeStart = performance.now(); + + const crossProjectCheck = checkCrossProjectResume(log, showAllProjects, worktreePaths); if (crossProjectCheck.isCrossProject) { if (!crossProjectCheck.isSameRepoWorktree) { - const raw = await setClipboard(crossProjectCheck.command) - if (raw) process.stdout.write(raw) - setCrossProjectCommand(crossProjectCheck.command) - return + const raw = await setClipboard(crossProjectCheck.command); + if (raw) process.stdout.write(raw); + setCrossProjectCommand(crossProjectCheck.command); + 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('../tools/AgentTool/loadAgentsDir.js') as typeof import('../tools/AgentTool/loadAgentsDir.js') + require('../tools/AgentTool/loadAgentsDir.js') as typeof import('../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: { @@ -261,101 +250,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) { @@ -381,7 +355,7 @@ export function ResumeConversation({ thinkingConfig={thinkingConfig} onTurnComplete={onTurnComplete} /> - ) + ); } if (loading) { @@ -390,7 +364,7 @@ export function ResumeConversation({ Loading conversations… - ) + ); } if (resuming) { @@ -399,11 +373,11 @@ export function ResumeConversation({ Resuming conversation… - ) + ); } if (filteredLogs.length === 0) { - return + return ; } return ( @@ -412,16 +386,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 { @@ -429,31 +401,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 ( @@ -464,5 +432,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 d034a6c53..2c8c1b8e8 100644 --- a/src/services/AgentSummary/agentSummary.ts +++ b/src/services/AgentSummary/agentSummary.ts @@ -130,7 +130,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/PromptSuggestion/promptSuggestion.ts b/src/services/PromptSuggestion/promptSuggestion.ts index 69412f783..f475ec3d8 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/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/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/growthbook.ts b/src/services/analytics/growthbook.ts index eead6c924..310bbaa62 100644 --- a/src/services/analytics/growthbook.ts +++ b/src/services/analytics/growthbook.ts @@ -500,11 +500,13 @@ const getGrowthBookClient = memoize( const attributes = getUserAttributes() const clientKey = getGrowthBookClientKey() const baseUrl = - process.env.CLAUDE_GB_ADAPTER_URL - || (process.env.USER_TYPE === 'ant' - ? process.env.CLAUDE_CODE_GB_BASE_URL || 'https://api.anthropic.com/' - : 'https://api.anthropic.com/') - const isAdapterMode = !!(process.env.CLAUDE_GB_ADAPTER_URL && process.env.CLAUDE_GB_ADAPTER_KEY) + process.env.CLAUDE_GB_ADAPTER_URL || + (process.env.USER_TYPE === 'ant' + ? process.env.CLAUDE_CODE_GB_BASE_URL || 'https://api.anthropic.com/' + : 'https://api.anthropic.com/') + const isAdapterMode = !!( + process.env.CLAUDE_GB_ADAPTER_URL && process.env.CLAUDE_GB_ADAPTER_KEY + ) if (process.env.USER_TYPE === 'ant') { logForDebugging( `GrowthBook: Creating client with clientKey=${clientKey}, attributes: ${jsonStringify(attributes)}`, @@ -537,7 +539,9 @@ const getGrowthBookClient = memoize( // remoteEval only works with Anthropic internal API, GrowthBook Cloud doesn't support it remoteEval: !isAdapterMode, // cacheKeyAttributes only valid with remoteEval - ...(!isAdapterMode ? { cacheKeyAttributes: ['id', 'organizationUUID'] } : {}), + ...(!isAdapterMode + ? { cacheKeyAttributes: ['id', 'organizationUUID'] } + : {}), // Add auth headers if available ...(authHeaders.error ? {} 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/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 bc6f380f2..2a4656863 100644 --- a/src/services/api/claude.ts +++ b/src/services/api/claude.ts @@ -439,7 +439,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) @@ -1306,7 +1306,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 } @@ -2074,7 +2080,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': @@ -2153,7 +2160,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 } } @@ -2244,7 +2252,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, @@ -2814,10 +2825,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, @@ -2853,7 +2868,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/logging.ts b/src/services/api/logging.ts index 821ce688a..088d8a343 100644 --- a/src/services/api/logging.ts +++ b/src/services/api/logging.ts @@ -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 0e69f1ca8..d57f2e90f 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' }]) diff --git a/src/services/api/openai/__tests__/convertTools.test.ts b/src/services/api/openai/__tests__/convertTools.test.ts index 847c63ce8..bd6b878c0 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,30 @@ 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].function.parameters).toEqual({ type: 'object', properties: {} }) + expect(result[0].function.parameters).toEqual({ + type: 'object', + properties: {}, + }) }) test('strips Anthropic-specific fields', () => { diff --git a/src/services/api/openai/__tests__/modelMapping.test.ts b/src/services/api/openai/__tests__/modelMapping.test.ts index 89bf976ac..e384f419f 100644 --- a/src/services/api/openai/__tests__/modelMapping.test.ts +++ b/src/services/api/openai/__tests__/modelMapping.test.ts @@ -7,6 +7,9 @@ describe('resolveOpenAIModel', () => { ANTHROPIC_DEFAULT_HAIKU_MODEL: process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL, ANTHROPIC_DEFAULT_SONNET_MODEL: process.env.ANTHROPIC_DEFAULT_SONNET_MODEL, ANTHROPIC_DEFAULT_OPUS_MODEL: process.env.ANTHROPIC_DEFAULT_OPUS_MODEL, + OPENAI_DEFAULT_HAIKU_MODEL: process.env.OPENAI_DEFAULT_HAIKU_MODEL, + OPENAI_DEFAULT_SONNET_MODEL: process.env.OPENAI_DEFAULT_SONNET_MODEL, + OPENAI_DEFAULT_OPUS_MODEL: process.env.OPENAI_DEFAULT_OPUS_MODEL, } beforeEach(() => { @@ -14,6 +17,9 @@ describe('resolveOpenAIModel', () => { delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL + delete process.env.OPENAI_DEFAULT_HAIKU_MODEL + delete process.env.OPENAI_DEFAULT_SONNET_MODEL + delete process.env.OPENAI_DEFAULT_OPUS_MODEL }) afterEach(() => { @@ -59,4 +65,39 @@ describe('resolveOpenAIModel', () => { test('strips [1m] suffix', () => { expect(resolveOpenAIModel('claude-sonnet-4-6[1m]')).toBe('gpt-4o') }) + + test('OPENAI_DEFAULT_SONNET_MODEL overrides ANTHROPIC_DEFAULT_SONNET_MODEL', () => { + process.env.OPENAI_DEFAULT_SONNET_MODEL = 'gpt-4.1' + process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'claude-sonnet-4-6' + try { + expect(resolveOpenAIModel('claude-sonnet-4-6')).toBe('gpt-4.1') + } finally { + delete process.env.OPENAI_DEFAULT_SONNET_MODEL + delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL + } + }) + + test('OPENAI_DEFAULT_HAIKU_MODEL overrides ANTHROPIC_DEFAULT_HAIKU_MODEL', () => { + process.env.OPENAI_DEFAULT_HAIKU_MODEL = 'gpt-4.0-mini' + process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = 'claude-haiku-4-5' + try { + expect(resolveOpenAIModel('claude-haiku-4-5-20251001')).toBe( + 'gpt-4.0-mini', + ) + } finally { + delete process.env.OPENAI_DEFAULT_HAIKU_MODEL + delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL + } + }) + + test('OPENAI_DEFAULT_OPUS_MODEL overrides ANTHROPIC_DEFAULT_OPUS_MODEL', () => { + process.env.OPENAI_DEFAULT_OPUS_MODEL = 'o1-pro' + process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = 'claude-opus-4-6' + try { + expect(resolveOpenAIModel('claude-opus-4-6')).toBe('o1-pro') + } finally { + delete process.env.OPENAI_DEFAULT_OPUS_MODEL + delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL + } + }) }) diff --git a/src/services/api/openai/__tests__/streamAdapter.test.ts b/src/services/api/openai/__tests__/streamAdapter.test.ts index bf3b9278d..d91a9c7d4 100644 --- a/src/services/api/openai/__tests__/streamAdapter.test.ts +++ b/src/services/api/openai/__tests__/streamAdapter.test.ts @@ -3,7 +3,9 @@ import { adaptOpenAIStreamToAnthropic } from '../streamAdapter.js' import type { ChatCompletionChunk } from 'openai/resources/chat/completions/completions.mjs' /** 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 @@ -18,7 +20,9 @@ function mockStream(chunks: ChatCompletionChunk[]): AsyncIterable & any = {}): ChatCompletionChunk { +function makeChunk( + overrides: Partial & any = {}, +): ChatCompletionChunk { return { id: 'chatcmpl-test', object: 'chat.completion.chunk', @@ -31,7 +35,10 @@ function makeChunk(overrides: Partial & any = {}): ChatComp async function collectEvents(chunks: ChatCompletionChunk[]) { 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 @@ -41,25 +48,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 }, }), ]) @@ -72,10 +85,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' }], @@ -90,7 +107,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') }) @@ -98,42 +117,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' }, - }], + ], + }), + makeChunk({ + 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"}' }, - }], + ], + }), + makeChunk({ + 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' }], @@ -145,7 +176,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"}') @@ -168,13 +200,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' }], @@ -188,7 +228,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' }], @@ -202,23 +244,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 }], - }), - makeChunk({ - choices: [{ - index: 0, - delta: { - tool_calls: [{ index: 0, id: 'call_1', function: { name: 'grep', arguments: '{"p":"test"}' } }], + 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"}' }, + }, + ], + }, + 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') @@ -229,18 +283,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' }], @@ -254,7 +312,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...') @@ -264,18 +323,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' }], @@ -283,13 +346,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 @@ -303,27 +370,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, - }], - }), - makeChunk({ - choices: [{ - index: 0, - delta: { - tool_calls: [{ index: 0, id: 'call_1', function: { name: 'bash', arguments: '{"c":"ls"}' } }], + choices: [ + { + index: 0, + delta: { reasoning_content: 'I need to run a command.' }, + finish_reason: null, }, - finish_reason: null, - }], + ], + }), + makeChunk({ + choices: [ + { + index: 0, + delta: { + tool_calls: [ + { + index: 0, + id: 'call_1', + function: { name: 'bash', arguments: '{"c":"ls"}' }, + }, + ], + }, + 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') @@ -332,25 +411,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) }) @@ -360,11 +445,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, diff --git a/src/services/api/openai/client.ts b/src/services/api/openai/client.ts index 111e8a330..cdc20ba9e 100644 --- a/src/services/api/openai/client.ts +++ b/src/services/api/openai/client.ts @@ -29,9 +29,15 @@ 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 }), - fetchOptions: getProxyFetchOptions({ forAnthropicAPI: false }) as RequestInit, + ...(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, + }) as RequestInit, ...(options?.fetchOverride && { fetch: options.fetchOverride }), }) diff --git a/src/services/api/openai/convertMessages.ts b/src/services/api/openai/convertMessages.ts index 63fe6c719..4a52b7b0b 100644 --- a/src/services/api/openai/convertMessages.ts +++ b/src/services/api/openai/convertMessages.ts @@ -56,9 +56,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') } function convertInternalUserMessage( @@ -152,7 +150,9 @@ function convertInternalAssistantMessage( } const textParts: string[] = [] - const toolCalls: NonNullable = [] + const toolCalls: NonNullable< + ChatCompletionAssistantMessageParam['tool_calls'] + > = [] for (const block of content) { if (typeof block === 'string') { diff --git a/src/services/api/openai/convertTools.ts b/src/services/api/openai/convertTools.ts index 4e7d4864f..742117162 100644 --- a/src/services/api/openai/convertTools.ts +++ b/src/services/api/openai/convertTools.ts @@ -15,14 +15,18 @@ export function anthropicToolsToOpenAI( return tools .filter(tool => { // Only convert standard tools (skip server tools like computer_use, etc.) - return tool.type === 'custom' || !('type' in tool) || tool.type !== 'server' + return ( + tool.type === 'custom' || !('type' in tool) || tool.type !== 'server' + ) }) .map(tool => { // Handle the various tool shapes from Anthropic SDK const anyTool = tool 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, diff --git a/src/services/api/openai/index.ts b/src/services/api/openai/index.ts index 53734b214..f792cf3c2 100644 --- a/src/services/api/openai/index.ts +++ b/src/services/api/openai/index.ts @@ -1,10 +1,18 @@ 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 { getOpenAIClient } from './client.js' import { anthropicMessagesToOpenAI } from './convertMessages.js' -import { anthropicToolsToOpenAI, anthropicToolChoiceToOpenAI } from './convertTools.js' +import { + anthropicToolsToOpenAI, + anthropicToolChoiceToOpenAI, +} from './convertTools.js' import { adaptOpenAIStreamToAnthropic } from './streamAdapter.js' import { resolveOpenAIModel } from './modelMapping.js' import { normalizeMessagesForAPI } from '../../../utils/messages.js' @@ -59,12 +67,17 @@ export async function* queryModelOpenAI( const standardTools = toolSchemas.filter( (t): t is BetaToolUnion & { type: string } => { const anyT = t as Record - return anyT.type !== 'advisor_20260301' && anyT.type !== 'computer_20250124' + return ( + anyT.type !== 'advisor_20260301' && anyT.type !== 'computer_20250124' + ) }, ) // 4. Convert messages and tools to OpenAI format - const openaiMessages = anthropicMessagesToOpenAI(messagesForAPI, systemPrompt) + const openaiMessages = anthropicMessagesToOpenAI( + messagesForAPI, + systemPrompt, + ) const openaiTools = anthropicToolsToOpenAI(standardTools) const openaiToolChoice = anthropicToolChoiceToOpenAI(options.toolChoice) @@ -75,7 +88,9 @@ export async function* queryModelOpenAI( source: options.querySource, }) - logForDebugging(`[OpenAI] Calling model=${openaiModel}, messages=${openaiMessages.length}, tools=${openaiTools.length}`) + logForDebugging( + `[OpenAI] Calling model=${openaiModel}, messages=${openaiMessages.length}, tools=${openaiTools.length}`, + ) // 6. Call OpenAI API with streaming const stream = await client.chat.completions.create( @@ -121,7 +136,7 @@ export async function* queryModelOpenAI( if ((event as any).message?.usage) { usage = { ...usage, - ...((event as any).message.usage), + ...(event as any).message.usage, } } break @@ -164,11 +179,7 @@ export async function* queryModelOpenAI( const m: AssistantMessage = { message: { ...partialMessage, - content: normalizeContentFromAPI( - [block], - tools, - options.agentId, - ), + content: normalizeContentFromAPI([block], tools, options.agentId), }, requestId: undefined, type: 'assistant', @@ -192,7 +203,10 @@ export async function* queryModelOpenAI( } // Track cost and token usage (matching the Anthropic path in claude.ts) - 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(openaiModel, usage as any) addToTotalSessionCost(costUSD, usage as any, options.model) } diff --git a/src/services/api/openai/modelMapping.ts b/src/services/api/openai/modelMapping.ts index ba546fe48..7cb49c7f9 100644 --- a/src/services/api/openai/modelMapping.ts +++ b/src/services/api/openai/modelMapping.ts @@ -31,9 +31,10 @@ function getModelFamily(model: string): 'haiku' | 'sonnet' | 'opus' | null { * * Priority: * 1. OPENAI_MODEL env var (override all) - * 2. ANTHROPIC_DEFAULT_{FAMILY}_MODEL env var (e.g. ANTHROPIC_DEFAULT_SONNET_MODEL) - * 3. DEFAULT_MODEL_MAP lookup - * 4. Pass through original model name + * 2. OPENAI_DEFAULT_{FAMILY}_MODEL env var (e.g. OPENAI_DEFAULT_SONNET_MODEL) + * 3. ANTHROPIC_DEFAULT_{FAMILY}_MODEL env var (backward compatibility) + * 4. DEFAULT_MODEL_MAP lookup + * 5. Pass through original model name */ export function resolveOpenAIModel(anthropicModel: string): string { // Highest priority: explicit override @@ -44,12 +45,18 @@ export function resolveOpenAIModel(anthropicModel: string): string { // Strip [1m] suffix if present (Claude-specific modifier) const cleanModel = anthropicModel.replace(/\[1m\]$/, '') - // Check ANTHROPIC_DEFAULT_*_MODEL env vars based on model family + // Check family-specific overrides const family = getModelFamily(cleanModel) if (family) { - const envVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL` - const override = process.env[envVar] - if (override) return override + // OpenAI-specific family override (preferred for openai provider) + const openaiEnvVar = `OPENAI_DEFAULT_${family.toUpperCase()}_MODEL` + const openaiOverride = process.env[openaiEnvVar] + if (openaiOverride) return openaiOverride + + // Anthropic env var (backward compatibility) + const anthropicEnvVar = `ANTHROPIC_DEFAULT_${family.toUpperCase()}_MODEL` + const anthropicOverride = process.env[anthropicEnvVar] + if (anthropicOverride) return anthropicOverride } return DEFAULT_MODEL_MAP[cleanModel] ?? cleanModel diff --git a/src/services/api/openai/streamAdapter.ts b/src/services/api/openai/streamAdapter.ts index 0c925fa7d..4f682433a 100644 --- a/src/services/api/openai/streamAdapter.ts +++ b/src/services/api/openai/streamAdapter.ts @@ -33,7 +33,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 @@ -185,7 +188,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/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/compact/__tests__/grouping.test.ts b/src/services/compact/__tests__/grouping.test.ts index c59f75437..9dc270c87 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/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 4be8b45df..e1cfadf56 100644 --- a/src/services/compact/compact.ts +++ b/src/services/compact/compact.ts @@ -265,7 +265,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 } @@ -1330,8 +1332,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 bab885f02..d52f57188 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 93bd0b981..197c1b5c4 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 ad6d5742b..1e250c0b3 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/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"); - -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("different inputs produce different outputs", () => { - const a = shortRequestId("toolu_aaa"); - const b = shortRequestId("toolu_bbb"); - expect(a).not.toBe(b); - }); +} = await import('../channelPermissions') + +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('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); - }); -}); - -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("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 null input", () => { - const result = truncateForPreview(null); - expect(result).toBe("null"); - }); - - test("handles undefined input", () => { - const result = truncateForPreview(undefined); + }) + + 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"}') + }) + + 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('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 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); - }); - - 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("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); - }); - - 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(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('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') + }) +}) + +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) expect(received).toEqual({ - 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("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"); - }); -}); + 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('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') + }) +}) diff --git a/src/services/mcp/__tests__/envExpansion.test.ts b/src/services/mcp/__tests__/envExpansion.test.ts index fe2032f2e..7491c758f 100644 --- a/src/services/mcp/__tests__/envExpansion.test.ts +++ b/src/services/mcp/__tests__/envExpansion.test.ts @@ -1,139 +1,139 @@ -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("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("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("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("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("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("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("handles default value containing colons", () => { + }) + + 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('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('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('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('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('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('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('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 + 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 + 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}}"); - }); - - 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 default value that is empty string", () => { - delete process.env.MISSING; - 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([]); - }); -}); + expect(result.missingVars).toEqual(['${VAR']) + expect(result.expanded).toBe('${${VAR}}') + }) + + 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 default value that is empty string', () => { + delete process.env.MISSING + 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([]) + }) +}) 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" - ); - }); - - 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"); - }); -}); + 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 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"); - }); - - 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 dash separator", () => { - expect(extractMcpToolDisplayName("just-a-name")).toBe("just-a-name"); - }); -}); + 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('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') + }) +}) 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(); - }); - - 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 empty string", () => { - expect(isOfficialMcpUrl("")).toBe(false); - }); -}); - -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); - }); -}); + resetOfficialMcpUrlsForTesting() + }) + + 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 empty string', () => { + expect(isOfficialMcpUrl('')).toBe(false) + }) +}) + +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) + }) +}) diff --git a/src/services/mcp/client.ts b/src/services/mcp/client.ts index d6db09b38..506545e8e 100644 --- a/src/services/mcp/client.ts +++ b/src/services/mcp/client.ts @@ -904,7 +904,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 @@ -917,7 +918,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) @@ -925,7 +928,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 @@ -942,7 +946,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 @@ -959,7 +966,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 @@ -3247,8 +3256,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 288f9e0ce..e1fbad701 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/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/mcpServerApproval.tsx b/src/services/mcpServerApproval.tsx index 4b92d1280..82546a355 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 '../ink.js' -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 '../ink.js'; +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 a1a865cf1..e0e3b1ff0 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/remoteManagedSettings/securityCheck.tsx b/src/services/remoteManagedSettings/securityCheck.tsx index 857103408..ab4328ea8 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 { render } from '../../ink.js' -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 { render } from '../../ink.js'; +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/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/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 cf6370a53..bf8e23ccc 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 2ddc948b5..af65ab151 100644 --- a/src/services/tools/toolOrchestration.ts +++ b/src/services/tools/toolOrchestration.ts @@ -129,10 +129,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, @@ -162,10 +164,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 8c3ce6ca9..7fcd508e7 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/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/skills/mcpSkills.ts b/src/skills/mcpSkills.ts index 122e62fcb..e80f045e9 100644 --- a/src/skills/mcpSkills.ts +++ b/src/skills/mcpSkills.ts @@ -1,7 +1,9 @@ // Auto-generated stub — replace with real implementation -export {}; -import type { Command } from 'src/types/command.js'; -export const fetchMcpSkillsForClient: ((...args: unknown[]) => Promise) & { cache: Map } = Object.assign( +export {} +import type { Command } from 'src/types/command.js' +export const fetchMcpSkillsForClient: (( + ...args: unknown[] +) => Promise) & { cache: Map } = Object.assign( (..._args: unknown[]) => Promise.resolve([] as Command[]), - { cache: new Map() } -); + { cache: new Map() }, +) 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..2a4a9caca 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,22 @@ 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 +86,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 +115,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 +138,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/InProcessTeammateTask/InProcessTeammateTask.tsx b/src/tasks/InProcessTeammateTask/InProcessTeammateTask.tsx index 0fbdc1052..ca2d1b2c4 100644 --- a/src/tasks/InProcessTeammateTask/InProcessTeammateTask.tsx +++ b/src/tasks/InProcessTeammateTask/InProcessTeammateTask.tsx @@ -9,19 +9,14 @@ * 4. Can be idle (waiting for work) or active (processing) */ -import { - isTerminalTaskStatus, - type SetAppState, - type Task, - type TaskStateBase, -} from '../../Task.js' -import type { Message } from '../../types/message.js' -import { logForDebugging } from '../../utils/debug.js' -import { createUserMessage } from '../../utils/messages.js' -import { killInProcessTeammate } from '../../utils/swarm/spawnInProcess.js' -import { updateTaskState } from '../../utils/task/framework.js' -import type { InProcessTeammateTaskState } from './types.js' -import { appendCappedMessage, isInProcessTeammateTask } from './types.js' +import { isTerminalTaskStatus, type SetAppState, type Task, type TaskStateBase } from '../../Task.js'; +import type { Message } from '../../types/message.js'; +import { logForDebugging } from '../../utils/debug.js'; +import { createUserMessage } from '../../utils/messages.js'; +import { killInProcessTeammate } from '../../utils/swarm/spawnInProcess.js'; +import { updateTaskState } from '../../utils/task/framework.js'; +import type { InProcessTeammateTaskState } from './types.js'; +import { appendCappedMessage, isInProcessTeammateTask } from './types.js'; /** * InProcessTeammateTask - Handles in-process teammate execution. @@ -30,48 +25,41 @@ export const InProcessTeammateTask: Task = { name: 'InProcessTeammateTask', type: 'in_process_teammate', async kill(taskId, setAppState) { - killInProcessTeammate(taskId, setAppState) + killInProcessTeammate(taskId, setAppState); }, -} +}; /** * Request shutdown for a teammate. */ -export function requestTeammateShutdown( - taskId: string, - setAppState: SetAppState, -): void { +export function requestTeammateShutdown(taskId: string, setAppState: SetAppState): void { updateTaskState(taskId, setAppState, task => { if (task.status !== 'running' || task.shutdownRequested) { - return task + return task; } return { ...task, shutdownRequested: true, - } - }) + }; + }); } /** * Append a message to a teammate's conversation history. * Used for zoomed view to show the teammate's conversation. */ -export function appendTeammateMessage( - taskId: string, - message: Message, - setAppState: SetAppState, -): void { +export function appendTeammateMessage(taskId: string, message: Message, setAppState: SetAppState): void { updateTaskState(taskId, setAppState, task => { if (task.status !== 'running') { - return task + return task; } return { ...task, messages: appendCappedMessage(task.messages, message), - } - }) + }; + }); } /** @@ -79,30 +67,21 @@ export function appendTeammateMessage( * Used when viewing a teammate's transcript to send typed messages to them. * Also adds the message to task.messages so it appears immediately in the transcript. */ -export function injectUserMessageToTeammate( - taskId: string, - message: string, - setAppState: SetAppState, -): void { +export function injectUserMessageToTeammate(taskId: string, message: string, setAppState: SetAppState): void { updateTaskState(taskId, setAppState, task => { // Allow message injection when teammate is running or idle (waiting for input) // Only reject if teammate is in a terminal state if (isTerminalTaskStatus(task.status)) { - logForDebugging( - `Dropping message for teammate task ${taskId}: task status is "${task.status}"`, - ) - return task + logForDebugging(`Dropping message for teammate task ${taskId}: task status is "${task.status}"`); + return task; } return { ...task, pendingUserMessages: [...task.pendingUserMessages, message], - messages: appendCappedMessage( - task.messages, - createUserMessage({ content: message }), - ), - } - }) + messages: appendCappedMessage(task.messages, createUserMessage({ content: message })), + }; + }); } /** @@ -115,30 +94,28 @@ export function findTeammateTaskByAgentId( agentId: string, tasks: Record, ): InProcessTeammateTaskState | undefined { - let fallback: InProcessTeammateTaskState | undefined + let fallback: InProcessTeammateTaskState | undefined; for (const task of Object.values(tasks)) { if (isInProcessTeammateTask(task) && task.identity.agentId === agentId) { // Prefer running tasks in case old killed tasks still exist in AppState // alongside new running ones with the same agentId if (task.status === 'running') { - return task + return task; } // Keep first match as fallback in case no running task exists if (!fallback) { - fallback = task + fallback = task; } } } - return fallback + return fallback; } /** * Get all in-process teammate tasks from AppState. */ -export function getAllInProcessTeammateTasks( - tasks: Record, -): InProcessTeammateTaskState[] { - return Object.values(tasks).filter(isInProcessTeammateTask) +export function getAllInProcessTeammateTasks(tasks: Record): InProcessTeammateTaskState[] { + return Object.values(tasks).filter(isInProcessTeammateTask); } /** @@ -147,10 +124,8 @@ export function getAllInProcessTeammateTasks( * and useBackgroundTaskNavigation — selectedIPAgentIndex maps into this * array, so all three must agree on sort order. */ -export function getRunningTeammatesSorted( - tasks: Record, -): InProcessTeammateTaskState[] { +export function getRunningTeammatesSorted(tasks: Record): InProcessTeammateTaskState[] { return getAllInProcessTeammateTasks(tasks) .filter(t => t.status === 'running') - .sort((a, b) => a.identity.agentName.localeCompare(b.identity.agentName)) + .sort((a, b) => a.identity.agentName.localeCompare(b.identity.agentName)); } diff --git a/src/tasks/LocalAgentTask/LocalAgentTask.tsx b/src/tasks/LocalAgentTask/LocalAgentTask.tsx index af26854c0..1d80881fc 100644 --- a/src/tasks/LocalAgentTask/LocalAgentTask.tsx +++ b/src/tasks/LocalAgentTask/LocalAgentTask.tsx @@ -1,4 +1,4 @@ -import { getSdkAgentProgressSummariesEnabled } from '../../bootstrap/state.js' +import { getSdkAgentProgressSummariesEnabled } from '../../bootstrap/state.js'; import { OUTPUT_FILE_TAG, STATUS_TAG, @@ -9,69 +9,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 '../../tools/AgentTool/agentToolUtils.js' -import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js' -import { SYNTHETIC_OUTPUT_TOOL_NAME } from '../../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 '../../tools/AgentTool/agentToolUtils.js'; +import type { AgentDefinition } from '../../tools/AgentTool/loadAgentsDir.js'; +import { SYNTHETIC_OUTPUT_TOOL_NAME } from '../../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 { @@ -79,11 +68,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; } /** @@ -91,10 +80,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, @@ -103,39 +89,32 @@ export function updateProgressFromMessage( tools?: Tools, ): void { if (message.type !== 'assistant') { - return + return; } - const usage = message.message.usage + const usage = message.message.usage; // Keep latest input (it's cumulative in the API), sum outputs tracker.latestInputTokens = - usage.input_tokens + - (usage.cache_creation_input_tokens ?? 0) + - (usage.cache_read_input_tokens ?? 0) - tracker.cumulativeOutputTokens += usage.output_tokens + usage.input_tokens + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0); + tracker.cumulativeOutputTokens += usage.output_tokens; for (const content of message.message.content) { 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(); } } @@ -144,67 +123,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'; } /** @@ -214,7 +184,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( @@ -225,7 +195,7 @@ export function queuePendingMessage( updateTaskState(taskId, setAppState, task => ({ ...task, pendingMessages: [...task.pendingMessages, msg], - })) + })); } /** @@ -242,7 +212,7 @@ export function appendMessageToLocalAgent( updateTaskState(taskId, setAppState, task => ({ ...task, messages: [...(task.messages ?? []), message], - })) + })); } export function drainPendingMessages( @@ -250,16 +220,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; } /** @@ -277,72 +247,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' }); } /** @@ -356,22 +324,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', @@ -380,10 +348,10 @@ export function killAsyncAgent(taskId: string, setAppState: SetAppState): void { abortController: undefined, unregisterCleanup: undefined, selectedAgent: undefined, - } - }) + }; + }); if (killed) { - void evictTaskOutput(taskId) + void evictTaskOutput(taskId); } } @@ -391,13 +359,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); } } } @@ -407,19 +372,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, - } - }) + }; + }); } /** @@ -427,45 +389,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 = { @@ -473,7 +425,7 @@ export function updateAgentSummary( toolUseCount: task.progress?.toolUseCount ?? 0, startTime: task.startTime, toolUseId: task.toolUseId, - } + }; return { ...task, @@ -483,14 +435,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, @@ -499,24 +451,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, @@ -527,26 +476,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, @@ -557,9 +502,9 @@ export function failAgentTask( abortController: undefined, unregisterCleanup: undefined, selectedAgent: undefined, - } - }) - void evictTaskOutput(taskId) + }; + }); + void evictTaskOutput(taskId); // Note: Notification is sent by AgentTool via enqueueAgentNotification } @@ -580,23 +525,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), @@ -614,24 +556,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. @@ -647,28 +589,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), @@ -687,27 +626,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, @@ -715,44 +654,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, @@ -760,45 +695,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 f04694bd1..be90dbc60 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 f2f91315a..510fec8ba 100644 --- a/src/tasks/LocalWorkflowTask/LocalWorkflowTask.ts +++ b/src/tasks/LocalWorkflowTask/LocalWorkflowTask.ts @@ -6,6 +6,15 @@ export type LocalWorkflowTaskState = TaskStateBase & { summary?: string description: string } -export const killWorkflowTask: (id: string, setAppState: SetAppState) => void = (() => {}); -export const skipWorkflowAgent: (id: string, agentId: string, setAppState: SetAppState) => void = (() => {}); -export const retryWorkflowAgent: (id: string, agentId: string, setAppState: SetAppState) => void = (() => {}); +export const killWorkflowTask: (id: string, setAppState: SetAppState) => void = + () => {} +export const skipWorkflowAgent: ( + id: string, + agentId: string, + setAppState: SetAppState, +) => void = () => {} +export const retryWorkflowAgent: ( + id: string, + agentId: string, + setAppState: SetAppState, +) => void = () => {} diff --git a/src/tasks/MonitorMcpTask/MonitorMcpTask.ts b/src/tasks/MonitorMcpTask/MonitorMcpTask.ts index 59edd098d..596aacb16 100644 --- a/src/tasks/MonitorMcpTask/MonitorMcpTask.ts +++ b/src/tasks/MonitorMcpTask/MonitorMcpTask.ts @@ -1,10 +1,17 @@ // Auto-generated stub — replace with real implementation -import type { TaskStateBase, SetAppState } from '../../Task.js'; -import type { AppState } from '../../state/AppState.js'; -import type { AgentId } from '../../types/ids.js'; +import type { TaskStateBase, SetAppState } from '../../Task.js' +import type { AppState } from '../../state/AppState.js' +import type { AgentId } from '../../types/ids.js' export type MonitorMcpTaskState = TaskStateBase & { - type: 'monitor_mcp'; -}; -export const killMonitorMcp: (taskId: string, setAppState: SetAppState) => void = (() => {}); -export const killMonitorMcpTasksForAgent: (agentId: AgentId, getAppState: () => AppState, setAppState: SetAppState) => void = (() => {}); + type: 'monitor_mcp' +} +export const killMonitorMcp: ( + taskId: string, + setAppState: SetAppState, +) => void = () => {} +export const killMonitorMcpTasksForAgent: ( + agentId: AgentId, + getAppState: () => AppState, + setAppState: SetAppState, +) => void = () => {} diff --git a/src/tasks/RemoteAgentTask/RemoteAgentTask.tsx b/src/tasks/RemoteAgentTask/RemoteAgentTask.tsx index 8755837e4..9a8365417 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,109 +11,87 @@ import { TASK_TYPE_TAG, TOOL_USE_ID_TAG, ULTRAPLAN_TAG, -} from '../../constants/xml.js' -import type { - SDKAssistantMessage, - SDKMessage, -} from '../../entrypoints/agentSdkTypes.js' -import type { - SetAppState, - Task, - TaskContext, - TaskStateBase, -} from '../../Task.js' -import { createTaskStateBase, generateTaskId } from '../../Task.js' -import { TodoWriteTool } from '../../tools/TodoWriteTool/TodoWriteTool.js' +} from '../../constants/xml.js'; +import type { SDKAssistantMessage, SDKMessage } from '../../entrypoints/agentSdkTypes.js'; +import type { SetAppState, Task, TaskContext, TaskStateBase } from '../../Task.js'; +import { createTaskStateBase, generateTaskId } from '../../Task.js'; +import { TodoWriteTool } from '../../tools/TodoWriteTool/TodoWriteTool.js'; import { type BackgroundRemoteSessionPrecondition, checkBackgroundRemoteSessionEligibility, -} from '../../utils/background/remote/remoteSession.js' -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'; +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 @@ -122,35 +100,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)}`); } } @@ -161,21 +131,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. @@ -183,34 +153,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."; } } @@ -225,29 +193,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' }); } /** @@ -255,15 +216,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; } /** @@ -273,13 +234,13 @@ 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] - if (msg?.type !== 'assistant') continue - const fullText = extractTextContent(msg.message.content, '\n') - const plan = extractTag(fullText, ULTRAPLAN_TAG) - if (plan?.trim()) return plan.trim() + const msg = log[i]; + if (msg?.type !== 'assistant') continue; + const fullText = extractTextContent(msg.message.content, '\n'); + const plan = extractTag(fullText, ULTRAPLAN_TAG); + if (plan?.trim()) return plan.trim(); } - return null + return null; } /** @@ -293,18 +254,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' }); } /** @@ -322,49 +283,42 @@ 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, 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, 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 fullText = extractTextContent(msg.message.content, '\n') - const tagged = extractTag(fullText, REMOTE_REVIEW_TAG) - if (tagged?.trim()) return tagged.trim() + const msg = log[i]; + if (msg?.type !== 'assistant') continue; + const fullText = extractTextContent(msg.message.content, '\n'); + 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) - .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 => extractTextContent(msg.message.content, '\n')) .join('\n') - .trim() + .trim(); - return allText || null + return allText || null; } /** @@ -380,38 +334,31 @@ 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, 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, 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 fullText = extractTextContent(msg.message.content, '\n') - const tagged = extractTag(fullText, REMOTE_REVIEW_TAG) - if (tagged?.trim()) return tagged.trim() + const msg = log[i]; + if (msg?.type !== 'assistant') continue; + const fullText = extractTextContent(msg.message.content, '\n'); + 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) - .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; } /** @@ -420,12 +367,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} @@ -435,20 +378,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} @@ -456,9 +395,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' }); } /** @@ -468,28 +407,25 @@ function extractTodoListFromLog(log: SDKMessage[]): TodoList { const todoListMessage = log.findLast( (msg): msg is SDKAssistantMessage => msg.type === 'assistant' && - msg.message.content.some( - block => block.type === 'tool_use' && block.name === TodoWriteTool.name, - ), - ) + msg.message.content.some(block => block.type === 'tool_use' && block.name === TodoWriteTool.name), + ); if (!todoListMessage) { - return [] + return []; } const input = todoListMessage.message.content.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; } /** @@ -498,19 +434,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, @@ -522,13 +458,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), @@ -545,9 +481,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 @@ -564,19 +500,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, - } + }; } /** @@ -588,27 +524,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 @@ -616,35 +548,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, @@ -656,14 +577,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); } } @@ -671,102 +590,77 @@ 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') { return msg.message.content .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; } } @@ -775,9 +669,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); @@ -787,41 +679,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 - 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; + 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 } @@ -837,15 +724,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 @@ -863,21 +749,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) @@ -886,53 +765,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 @@ -944,63 +814,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' && @@ -1010,15 +863,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 @@ -1027,17 +876,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; + }; } /** @@ -1051,25 +900,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. @@ -1077,26 +926,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/AgentTool/AgentTool.tsx b/src/tools/AgentTool/AgentTool.tsx index 7fbed68a4..729ea4a73 100644 --- a/src/tools/AgentTool/AgentTool.tsx +++ b/src/tools/AgentTool/AgentTool.tsx @@ -1,28 +1,19 @@ -import { feature } from 'bun:bundle' -import * as React from 'react' -import { buildTool, type ToolDef, toolMatchesName } from 'src/Tool.js' -import type { - Message as MessageType, - NormalizedUserMessage, -} from 'src/types/message.js' -import { getQuerySourceForAgent } from 'src/utils/promptCategory.js' -import { z } from 'zod/v4' -import { - clearInvokedSkillsForAgent, - getSdkAgentProgressSummariesEnabled, -} from '../../bootstrap/state.js' -import { - enhanceSystemPromptWithEnvDetails, - getSystemPrompt, -} from '../../constants/prompts.js' -import { isCoordinatorMode } from '../../coordinator/coordinatorMode.js' -import { startAgentSummarization } from '../../services/AgentSummary/agentSummary.js' -import { getFeatureValue_CACHED_MAY_BE_STALE } from '../../services/analytics/growthbook.js' +import { feature } from 'bun:bundle'; +import * as React from 'react'; +import { buildTool, type ToolDef, toolMatchesName } from 'src/Tool.js'; +import type { Message as MessageType, NormalizedUserMessage } from 'src/types/message.js'; +import { getQuerySourceForAgent } from 'src/utils/promptCategory.js'; +import { z } from 'zod/v4'; +import { clearInvokedSkillsForAgent, getSdkAgentProgressSummariesEnabled } from '../../bootstrap/state.js'; +import { enhanceSystemPromptWithEnvDetails, getSystemPrompt } from '../../constants/prompts.js'; +import { isCoordinatorMode } from '../../coordinator/coordinatorMode.js'; +import { startAgentSummarization } from '../../services/AgentSummary/agentSummary.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 { clearDumpState } from '../../services/api/dumpPrompts.js' +} from '../../services/analytics/index.js'; +import { clearDumpState } from '../../services/api/dumpPrompts.js'; import { completeAgentTask as completeAsyncAgent, createActivityDescriptionResolver, @@ -38,57 +29,45 @@ import { unregisterAgentForeground, updateAgentProgress as updateAsyncAgentProgress, updateProgressFromMessage, -} from '../../tasks/LocalAgentTask/LocalAgentTask.js' +} from '../../tasks/LocalAgentTask/LocalAgentTask.js'; import { checkRemoteAgentEligibility, formatPreconditionError, getRemoteTaskSessionUrl, registerRemoteAgentTask, -} from '../../tasks/RemoteAgentTask/RemoteAgentTask.js' -import { assembleToolPool } from '../../tools.js' -import { asAgentId } from '../../types/ids.js' -import { runWithAgentContext } from '../../utils/agentContext.js' -import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' -import { getCwd, runWithCwdOverride } from '../../utils/cwd.js' -import { logForDebugging } from '../../utils/debug.js' -import { isEnvTruthy } from '../../utils/envUtils.js' -import { AbortError, errorMessage, toError } from '../../utils/errors.js' -import type { CacheSafeParams } from '../../utils/forkedAgent.js' -import { lazySchema } from '../../utils/lazySchema.js' -import { - createUserMessage, - extractTextContent, - isSyntheticMessage, - normalizeMessages, -} from '../../utils/messages.js' -import { getAgentModel } from '../../utils/model/agent.js' -import { permissionModeSchema } from '../../utils/permissions/PermissionMode.js' -import type { PermissionResult } from '../../utils/permissions/PermissionResult.js' -import { - filterDeniedAgents, - getDenyRuleForAgent, -} from '../../utils/permissions/permissions.js' -import { enqueueSdkEvent } from '../../utils/sdkEventQueue.js' -import { writeAgentMetadata } from '../../utils/sessionStorage.js' -import { sleep } from '../../utils/sleep.js' -import { buildEffectiveSystemPrompt } from '../../utils/systemPrompt.js' -import { asSystemPrompt } from '../../utils/systemPromptType.js' -import { getTaskOutputPath } from '../../utils/task/diskOutput.js' -import { getParentSessionId, isTeammate } from '../../utils/teammate.js' -import { isInProcessTeammate } from '../../utils/teammateContext.js' -import { teleportToRemote } from '../../utils/teleport.js' -import { getAssistantMessageContentLength } from '../../utils/tokens.js' -import { createAgentId } from '../../utils/uuid.js' -import { - createAgentWorktree, - hasWorktreeChanges, - removeAgentWorktree, -} from '../../utils/worktree.js' -import { BASH_TOOL_NAME } from '../BashTool/toolName.js' -import { BackgroundHint } from '../BashTool/UI.js' -import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js' -import { spawnTeammate } from '../shared/spawnMultiAgent.js' -import { setAgentColor } from './agentColorManager.js' +} from '../../tasks/RemoteAgentTask/RemoteAgentTask.js'; +import { assembleToolPool } from '../../tools.js'; +import { asAgentId } from '../../types/ids.js'; +import { runWithAgentContext } from '../../utils/agentContext.js'; +import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js'; +import { getCwd, runWithCwdOverride } from '../../utils/cwd.js'; +import { logForDebugging } from '../../utils/debug.js'; +import { isEnvTruthy } from '../../utils/envUtils.js'; +import { AbortError, errorMessage, toError } from '../../utils/errors.js'; +import type { CacheSafeParams } from '../../utils/forkedAgent.js'; +import { lazySchema } from '../../utils/lazySchema.js'; +import { createUserMessage, extractTextContent, isSyntheticMessage, normalizeMessages } from '../../utils/messages.js'; +import { getAgentModel } from '../../utils/model/agent.js'; +import { permissionModeSchema } from '../../utils/permissions/PermissionMode.js'; +import type { PermissionResult } from '../../utils/permissions/PermissionResult.js'; +import { filterDeniedAgents, getDenyRuleForAgent } from '../../utils/permissions/permissions.js'; +import { enqueueSdkEvent } from '../../utils/sdkEventQueue.js'; +import { writeAgentMetadata } from '../../utils/sessionStorage.js'; +import { sleep } from '../../utils/sleep.js'; +import { buildEffectiveSystemPrompt } from '../../utils/systemPrompt.js'; +import { asSystemPrompt } from '../../utils/systemPromptType.js'; +import { getTaskOutputPath } from '../../utils/task/diskOutput.js'; +import { getParentSessionId, isTeammate } from '../../utils/teammate.js'; +import { isInProcessTeammate } from '../../utils/teammateContext.js'; +import { teleportToRemote } from '../../utils/teleport.js'; +import { getAssistantMessageContentLength } from '../../utils/tokens.js'; +import { createAgentId } from '../../utils/uuid.js'; +import { createAgentWorktree, hasWorktreeChanges, removeAgentWorktree } from '../../utils/worktree.js'; +import { BASH_TOOL_NAME } from '../BashTool/toolName.js'; +import { BackgroundHint } from '../BashTool/UI.js'; +import { FILE_READ_TOOL_NAME } from '../FileReadTool/prompt.js'; +import { spawnTeammate } from '../shared/spawnMultiAgent.js'; +import { setAgentColor } from './agentColorManager.js'; import { agentToolResultSchema, classifyHandoffIfNeeded, @@ -97,28 +76,20 @@ import { finalizeAgentTool, getLastToolUseName, runAsyncAgentLifecycle, -} from './agentToolUtils.js' -import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js' -import { - AGENT_TOOL_NAME, - LEGACY_AGENT_TOOL_NAME, - ONE_SHOT_BUILTIN_AGENT_TYPES, -} from './constants.js' +} from './agentToolUtils.js'; +import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js'; +import { AGENT_TOOL_NAME, LEGACY_AGENT_TOOL_NAME, ONE_SHOT_BUILTIN_AGENT_TYPES } from './constants.js'; import { buildForkedMessages, buildWorktreeNotice, FORK_AGENT, isForkSubagentEnabled, isInForkChild, -} from './forkSubagent.js' -import type { AgentDefinition } from './loadAgentsDir.js' -import { - filterAgentsByMcpRequirements, - hasRequiredMcpServers, - isBuiltInAgent, -} from './loadAgentsDir.js' -import { getPrompt } from './prompt.js' -import { runAgent } from './runAgent.js' +} from './forkSubagent.js'; +import type { AgentDefinition } from './loadAgentsDir.js'; +import { filterAgentsByMcpRequirements, hasRequiredMcpServers, isBuiltInAgent } from './loadAgentsDir.js'; +import { getPrompt } from './prompt.js'; +import { runAgent } from './runAgent.js'; import { renderGroupedAgentToolUse, renderToolResultMessage, @@ -129,22 +100,22 @@ import { renderToolUseTag, userFacingName, userFacingNameBackgroundColor, -} from './UI.js' +} from './UI.js'; /* eslint-disable @typescript-eslint/no-require-imports */ const proactiveModule = feature('PROACTIVE') || feature('KAIROS') ? (require('../../proactive/index.js') as typeof import('../../proactive/index.js')) - : null + : null; /* eslint-enable @typescript-eslint/no-require-imports */ // Progress display constants (for showing background hint) -const PROGRESS_THRESHOLD_MS = 2000 // Show background hint after 2 seconds +const PROGRESS_THRESHOLD_MS = 2000; // Show background hint after 2 seconds // Check if background tasks are disabled at module load time const isBackgroundTasksDisabled = // eslint-disable-next-line custom-rules/no-process-env-top-level -- Intentional: schema must be defined at module load - isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS) + isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_BACKGROUND_TASKS); // Auto-background agent tasks after this many ms (0 = disabled) // Enabled by env var OR GrowthBook gate (checked lazily since GB may not be ready at module load) @@ -153,9 +124,9 @@ function getAutoBackgroundMs(): number { isEnvTruthy(process.env.CLAUDE_AUTO_BACKGROUND_TASKS) || getFeatureValue_CACHED_MAY_BE_STALE('tengu_auto_background_agents', false) ) { - return 120_000 + return 120_000; } - return 0 + return 0; } // Multi-agent type constants are defined inline inside gated blocks to enable dead code elimination @@ -163,14 +134,9 @@ function getAutoBackgroundMs(): number { // Base input schema without multi-agent parameters const baseInputSchema = lazySchema(() => z.object({ - description: z - .string() - .describe('A short (3-5 word) description of the task'), + description: z.string().describe('A short (3-5 word) description of the task'), prompt: z.string().describe('The task for the agent to perform'), - subagent_type: z - .string() - .optional() - .describe('The type of specialized agent to use for this task'), + subagent_type: z.string().optional().describe('The type of specialized agent to use for this task'), model: z .enum(['sonnet', 'opus', 'haiku']) .optional() @@ -180,11 +146,9 @@ const baseInputSchema = lazySchema(() => run_in_background: z .boolean() .optional() - .describe( - 'Set to true to run this agent in the background. You will be notified when it completes.', - ), + .describe('Set to true to run this agent in the background. You will be notified when it completes.'), }), -) +); // Full schema combining base + multi-agent params + isolation const fullInputSchema = lazySchema(() => { @@ -193,29 +157,17 @@ const fullInputSchema = lazySchema(() => { name: z .string() .optional() - .describe( - 'Name for the spawned agent. Makes it addressable via SendMessage({to: name}) while running.', - ), - team_name: z - .string() - .optional() - .describe( - 'Team name for spawning. Uses current team context if omitted.', - ), + .describe('Name for the spawned agent. Makes it addressable via SendMessage({to: name}) while running.'), + team_name: z.string().optional().describe('Team name for spawning. Uses current team context if omitted.'), mode: permissionModeSchema() .optional() - .describe( - 'Permission mode for spawned teammate (e.g., "plan" to require plan approval).', - ), - }) + .describe('Permission mode for spawned teammate (e.g., "plan" to require plan approval).'), + }); return baseInputSchema() .merge(multiAgentInputSchema) .extend({ - isolation: (process.env.USER_TYPE === 'ant' - ? z.enum(['worktree', 'remote']) - : z.enum(['worktree']) - ) + isolation: (process.env.USER_TYPE === 'ant' ? z.enum(['worktree', 'remote']) : z.enum(['worktree'])) .optional() .describe( process.env.USER_TYPE === 'ant' @@ -228,8 +180,8 @@ const fullInputSchema = lazySchema(() => { .describe( 'Absolute path to run the agent in. Overrides the working directory for all filesystem and shell operations within this agent. Mutually exclusive with isolation: "worktree".', ), - }) -}) + }); +}); // Strip optional fields from the schema when the backing feature is off so // the model never sees them. Done via .omit() rather than conditional spread @@ -238,9 +190,7 @@ const fullInputSchema = lazySchema(() => { // type, but call() destructures via the explicit AgentToolInput type below // which always includes all optional fields. export const inputSchema = lazySchema(() => { - const schema = feature('KAIROS') - ? fullInputSchema() - : fullInputSchema().omit({ cwd: true }) + const schema = feature('KAIROS') ? fullInputSchema() : fullInputSchema().omit({ cwd: true }); // GrowthBook-in-lazySchema is acceptable here (unlike subagent_type, which // was removed in 906da6c723): the divergence window is one-session-per- @@ -249,70 +199,64 @@ export const inputSchema = lazySchema(() => { // by forceAsync) or "schema hides a param that would've worked" (gate // flips off mid-session: everything still runs async via memoized // forceAsync). No Zod rejection, no crash — unlike required→optional. - return isBackgroundTasksDisabled || isForkSubagentEnabled() - ? schema.omit({ run_in_background: true }) - : schema -}) -type InputSchema = ReturnType + return isBackgroundTasksDisabled || isForkSubagentEnabled() ? schema.omit({ run_in_background: true }) : schema; +}); +type InputSchema = ReturnType; // Explicit type widens the schema inference to always include all optional // fields even when .omit() strips them for gating (cwd, run_in_background). // subagent_type is optional; call() defaults it to general-purpose when the // fork gate is off, or routes to the fork path when the gate is on. type AgentToolInput = z.infer> & { - name?: string - team_name?: string - mode?: z.infer> - isolation?: 'worktree' | 'remote' - cwd?: string -} + name?: string; + team_name?: string; + mode?: z.infer>; + isolation?: 'worktree' | 'remote'; + cwd?: string; +}; // Output schema - multi-agent spawned schema added dynamically at runtime when enabled export const outputSchema = lazySchema(() => { const syncOutputSchema = agentToolResultSchema().extend({ status: z.literal('completed'), prompt: z.string(), - }) + }); const asyncOutputSchema = z.object({ status: z.literal('async_launched'), agentId: z.string().describe('The ID of the async agent'), description: z.string().describe('The description of the task'), prompt: z.string().describe('The prompt for the agent'), - outputFile: z - .string() - .describe('Path to the output file for checking agent progress'), + outputFile: z.string().describe('Path to the output file for checking agent progress'), canReadOutputFile: z .boolean() .optional() - .describe( - 'Whether the calling agent has Read/Bash tools to check progress', - ), - }) + .describe('Whether the calling agent has Read/Bash tools to check progress'), + }); - return z.union([syncOutputSchema, asyncOutputSchema]) -}) -type OutputSchema = ReturnType -type Output = z.input + return z.union([syncOutputSchema, asyncOutputSchema]); +}); +type OutputSchema = ReturnType; +type Output = z.input; // Private type for teammate spawn results - excluded from exported schema for dead code elimination // The 'teammate_spawned' status string is only included when ENABLE_AGENT_SWARMS is true type TeammateSpawnedOutput = { - status: 'teammate_spawned' - prompt: string - teammate_id: string - agent_id: string - agent_type?: string - model?: string - name: string - color?: string - tmux_session_name: string - tmux_window_name: string - tmux_pane_id: string - team_name?: string - is_splitpane?: boolean - plan_mode_required?: boolean -} + status: 'teammate_spawned'; + prompt: string; + teammate_id: string; + agent_id: string; + agent_type?: string; + model?: string; + name: string; + color?: string; + tmux_session_name: string; + tmux_window_name: string; + tmux_pane_id: string; + team_name?: string; + is_splitpane?: boolean; + plan_mode_required?: boolean; +}; // Combined output type including both public and internal types // Note: TeammateSpawnedOutput type is fine - TypeScript types are erased at compile time @@ -320,67 +264,58 @@ type TeammateSpawnedOutput = { // like TeammateSpawnedOutput for dead code elimination purposes. Exported // for UI.tsx to do proper discriminated-union narrowing instead of ad-hoc casts. export type RemoteLaunchedOutput = { - status: 'remote_launched' - taskId: string - sessionUrl: string - description: string - prompt: string - outputFile: string -} + status: 'remote_launched'; + taskId: string; + sessionUrl: string; + description: string; + prompt: string; + outputFile: string; +}; -type InternalOutput = Output | TeammateSpawnedOutput | RemoteLaunchedOutput +type InternalOutput = Output | TeammateSpawnedOutput | RemoteLaunchedOutput; -import type { AgentToolProgress, ShellProgress } from '../../types/tools.js' +import type { AgentToolProgress, ShellProgress } from '../../types/tools.js'; // AgentTool forwards both its own progress events and shell progress // events from the sub-agent so the SDK receives tool_progress updates during bash/powershell runs. -export type Progress = AgentToolProgress | ShellProgress +export type Progress = AgentToolProgress | ShellProgress; export const AgentTool = buildTool({ async prompt({ agents, tools, getToolPermissionContext, allowedAgentTypes }) { - const toolPermissionContext = await getToolPermissionContext() + const toolPermissionContext = await getToolPermissionContext(); // Get MCP servers that have tools available - const mcpServersWithTools: string[] = [] + const mcpServersWithTools: string[] = []; for (const tool of tools) { if (tool.name?.startsWith('mcp__')) { - const parts = tool.name.split('__') - const serverName = parts[1] + const parts = tool.name.split('__'); + const serverName = parts[1]; if (serverName && !mcpServersWithTools.includes(serverName)) { - mcpServersWithTools.push(serverName) + mcpServersWithTools.push(serverName); } } } // Filter agents: first by MCP requirements, then by permission rules - const agentsWithMcpRequirementsMet = filterAgentsByMcpRequirements( - agents, - mcpServersWithTools, - ) - const filteredAgents = filterDeniedAgents( - agentsWithMcpRequirementsMet, - toolPermissionContext, - AGENT_TOOL_NAME, - ) + const agentsWithMcpRequirementsMet = filterAgentsByMcpRequirements(agents, mcpServersWithTools); + const filteredAgents = filterDeniedAgents(agentsWithMcpRequirementsMet, toolPermissionContext, AGENT_TOOL_NAME); // Use inline env check instead of coordinatorModule to avoid circular // dependency issues during test module loading. - const isCoordinator = feature('COORDINATOR_MODE') - ? isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) - : false - return await getPrompt(filteredAgents, isCoordinator, allowedAgentTypes) + const isCoordinator = feature('COORDINATOR_MODE') ? isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) : false; + return await getPrompt(filteredAgents, isCoordinator, allowedAgentTypes); }, name: AGENT_TOOL_NAME, searchHint: 'delegate work to a subagent', aliases: [LEGACY_AGENT_TOOL_NAME], maxResultSizeChars: 100_000, async description() { - return 'Launch a new agent' + return 'Launch a new agent'; }, get inputSchema(): InputSchema { - return inputSchema() + return inputSchema(); }, get outputSchema(): OutputSchema { - return outputSchema() + return outputSchema(); }, async call( { @@ -400,30 +335,29 @@ export const AgentTool = buildTool({ assistantMessage, onProgress?, ) { - const startTime = Date.now() - const model = isCoordinatorMode() ? undefined : modelParam + const startTime = Date.now(); + const model = isCoordinatorMode() ? undefined : modelParam; // Get app state for permission mode and agent filtering - const appState = toolUseContext.getAppState() - const permissionMode = appState.toolPermissionContext.mode + const appState = toolUseContext.getAppState(); + const permissionMode = appState.toolPermissionContext.mode; // In-process teammates get a no-op setAppState; setAppStateForTasks // reaches the root store so task registration/progress/kill stay visible. - const rootSetAppState = - toolUseContext.setAppStateForTasks ?? toolUseContext.setAppState + const rootSetAppState = toolUseContext.setAppStateForTasks ?? toolUseContext.setAppState; // Check if user is trying to use agent teams without access if (team_name && !isAgentSwarmsEnabled()) { - throw new Error('Agent Teams is not yet available on your plan.') + throw new Error('Agent Teams is not yet available on your plan.'); } // Teammates (in-process or tmux) passing `name` would trigger spawnTeammate() // below, but TeamFile.members is a flat array with one leadAgentId — nested // teammates land in the roster with no provenance and confuse the lead. - const teamName = resolveTeamName({ team_name }, appState) + const teamName = resolveTeamName({ team_name }, appState); if (isTeammate() && teamName && name) { throw new Error( 'Teammates cannot spawn other teammates — the team roster is flat. To spawn a subagent instead, omit the `name` parameter.', - ) + ); } // In-process teammates cannot spawn background agents (their lifecycle is // tied to the leader's process). Tmux teammates are separate processes and @@ -431,7 +365,7 @@ export const AgentTool = buildTool({ if (isInProcessTeammate() && teamName && run_in_background === true) { throw new Error( 'In-process teammates cannot spawn background agents. Use run_in_background=false for synchronous subagents.', - ) + ); } // Check if this is a multi-agent spawn request @@ -439,12 +373,10 @@ export const AgentTool = buildTool({ if (teamName && name) { // Set agent definition color for grouped UI display before spawning const agentDef = subagent_type - ? toolUseContext.options.agentDefinitions.activeAgents.find( - a => a.agentType === subagent_type, - ) - : undefined + ? toolUseContext.options.agentDefinitions.activeAgents.find(a => a.agentType === subagent_type) + : undefined; if (agentDef?.color) { - setAgentColor(subagent_type!, agentDef.color) + setAgentColor(subagent_type!, agentDef.color); } const result = await spawnTeammate( { @@ -459,7 +391,7 @@ export const AgentTool = buildTool({ invokingRequestId: assistantMessage?.requestId, }, toolUseContext, - ) + ); // Type assertion uses TeammateSpawnedOutput (defined above) instead of any. // This type is excluded from the exported outputSchema for dead code elimination. @@ -469,20 +401,18 @@ export const AgentTool = buildTool({ status: 'teammate_spawned' as const, prompt, ...result.data, - } - return { data: spawnResult } as unknown as { data: Output } + }; + return { data: spawnResult } as unknown as { data: Output }; } // Fork subagent experiment routing: // - subagent_type set: use it (explicit wins) // - subagent_type omitted, gate on: fork path (undefined) // - subagent_type omitted, gate off: default general-purpose - const effectiveType = - subagent_type ?? - (isForkSubagentEnabled() ? undefined : GENERAL_PURPOSE_AGENT.agentType) - const isForkPath = effectiveType === undefined + const effectiveType = subagent_type ?? (isForkSubagentEnabled() ? undefined : GENERAL_PURPOSE_AGENT.agentType); + const isForkPath = effectiveType === undefined; - let selectedAgent: AgentDefinition + let selectedAgent: AgentDefinition; if (isForkPath) { // Recursive fork guard: fork children keep the Agent tool in their // pool for cache-identical tool defs, so reject fork attempts at call @@ -491,69 +421,52 @@ export const AgentTool = buildTool({ // rewrite). Message-scan fallback catches any path where querySource // wasn't threaded. if ( - toolUseContext.options.querySource === - `agent:builtin:${FORK_AGENT.agentType}` || + toolUseContext.options.querySource === `agent:builtin:${FORK_AGENT.agentType}` || isInForkChild(toolUseContext.messages) ) { - throw new Error( - 'Fork is not available inside a forked worker. Complete your task directly using your tools.', - ) + throw new Error('Fork is not available inside a forked worker. Complete your task directly using your tools.'); } - selectedAgent = FORK_AGENT + selectedAgent = FORK_AGENT; } else { // Filter agents to exclude those denied via Agent(AgentName) syntax - const allAgents = toolUseContext.options.agentDefinitions.activeAgents - const { allowedAgentTypes } = toolUseContext.options.agentDefinitions + const allAgents = toolUseContext.options.agentDefinitions.activeAgents; + const { allowedAgentTypes } = toolUseContext.options.agentDefinitions; const agents = filterDeniedAgents( // When allowedAgentTypes is set (from Agent(x,y) tool spec), restrict to those types - allowedAgentTypes - ? allAgents.filter(a => allowedAgentTypes.includes(a.agentType)) - : allAgents, + allowedAgentTypes ? allAgents.filter(a => allowedAgentTypes.includes(a.agentType)) : allAgents, appState.toolPermissionContext, AGENT_TOOL_NAME, - ) + ); - const found = agents.find(agent => agent.agentType === effectiveType) + const found = agents.find(agent => agent.agentType === effectiveType); if (!found) { // Check if the agent exists but is denied by permission rules - const agentExistsButDenied = allAgents.find( - agent => agent.agentType === effectiveType, - ) + const agentExistsButDenied = allAgents.find(agent => agent.agentType === effectiveType); if (agentExistsButDenied) { - const denyRule = getDenyRuleForAgent( - appState.toolPermissionContext, - AGENT_TOOL_NAME, - effectiveType, - ) + const denyRule = getDenyRuleForAgent(appState.toolPermissionContext, AGENT_TOOL_NAME, effectiveType); throw new Error( `Agent type '${effectiveType}' has been denied by permission rule '${AGENT_TOOL_NAME}(${effectiveType})' from ${denyRule?.source ?? 'settings'}.`, - ) + ); } throw new Error( - `Agent type '${effectiveType}' not found. Available agents: ${agents - .map(a => a.agentType) - .join(', ')}`, - ) + `Agent type '${effectiveType}' not found. Available agents: ${agents.map(a => a.agentType).join(', ')}`, + ); } - selectedAgent = found + selectedAgent = found; } // Same lifecycle constraint as the run_in_background guard above, but for // agent definitions that force background via `background: true`. Checked // here because selectedAgent is only now resolved. - if ( - isInProcessTeammate() && - teamName && - selectedAgent.background === true - ) { + if (isInProcessTeammate() && teamName && selectedAgent.background === true) { throw new Error( `In-process teammates cannot spawn background agents. Agent '${selectedAgent.agentType}' has background: true in its definition.`, - ) + ); } // Capture for type narrowing — `let selectedAgent` prevents TS from // narrowing property types across the if-else assignment above. - const requiredMcpServers = selectedAgent.requiredMcpServers + const requiredMcpServers = selectedAgent.requiredMcpServers; // Check if required MCP servers have tools available // A server that's connected but not authenticated won't have any tools @@ -564,74 +477,65 @@ export const AgentTool = buildTool({ const hasPendingRequiredServers = appState.mcp.clients.some( c => c.type === 'pending' && - requiredMcpServers.some(pattern => - c.name.toLowerCase().includes(pattern.toLowerCase()), - ), - ) + requiredMcpServers.some(pattern => c.name.toLowerCase().includes(pattern.toLowerCase())), + ); - let currentAppState = appState + let currentAppState = appState; if (hasPendingRequiredServers) { - const MAX_WAIT_MS = 30_000 - const POLL_INTERVAL_MS = 500 - const deadline = Date.now() + MAX_WAIT_MS + const MAX_WAIT_MS = 30_000; + const POLL_INTERVAL_MS = 500; + const deadline = Date.now() + MAX_WAIT_MS; while (Date.now() < deadline) { - await sleep(POLL_INTERVAL_MS) - currentAppState = toolUseContext.getAppState() + await sleep(POLL_INTERVAL_MS); + currentAppState = toolUseContext.getAppState(); // Early exit: if any required server has already failed, no point // waiting for other pending servers — the check will fail regardless. const hasFailedRequiredServer = currentAppState.mcp.clients.some( c => c.type === 'failed' && - requiredMcpServers.some(pattern => - c.name.toLowerCase().includes(pattern.toLowerCase()), - ), - ) - if (hasFailedRequiredServer) break + requiredMcpServers.some(pattern => c.name.toLowerCase().includes(pattern.toLowerCase())), + ); + if (hasFailedRequiredServer) break; const stillPending = currentAppState.mcp.clients.some( c => c.type === 'pending' && - requiredMcpServers.some(pattern => - c.name.toLowerCase().includes(pattern.toLowerCase()), - ), - ) - if (!stillPending) break + requiredMcpServers.some(pattern => c.name.toLowerCase().includes(pattern.toLowerCase())), + ); + if (!stillPending) break; } } // Get servers that actually have tools (meaning they're connected AND authenticated) - const serversWithTools: string[] = [] + const serversWithTools: string[] = []; for (const tool of currentAppState.mcp.tools) { if (tool.name?.startsWith('mcp__')) { // Extract server name from tool name (format: mcp__serverName__toolName) - const parts = tool.name.split('__') - const serverName = parts[1] + const parts = tool.name.split('__'); + const serverName = parts[1]; if (serverName && !serversWithTools.includes(serverName)) { - serversWithTools.push(serverName) + serversWithTools.push(serverName); } } } if (!hasRequiredMcpServers(selectedAgent, serversWithTools)) { const missing = requiredMcpServers.filter( - pattern => - !serversWithTools.some(server => - server.toLowerCase().includes(pattern.toLowerCase()), - ), - ) + pattern => !serversWithTools.some(server => server.toLowerCase().includes(pattern.toLowerCase())), + ); throw new Error( `Agent '${selectedAgent.agentType}' requires MCP servers matching: ${missing.join(', ')}. ` + `MCP servers with tools: ${serversWithTools.length > 0 ? serversWithTools.join(', ') : 'none'}. ` + `Use /mcp to configure and authenticate the required MCP servers.`, - ) + ); } } // Initialize the color for this agent if it has a predefined one if (selectedAgent.color) { - setAgentColor(selectedAgent.agentType, selectedAgent.color) + setAgentColor(selectedAgent.agentType, selectedAgent.color); } // Resolve agent params for logging (these are already resolved in runAgent) @@ -640,50 +544,42 @@ export const AgentTool = buildTool({ toolUseContext.options.mainLoopModel, isForkPath ? undefined : model, permissionMode, - ) + ); logEvent('tengu_agent_tool_selected', { - agent_type: - selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - model: - resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - source: - selectedAgent.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - color: - selectedAgent.color as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + agent_type: selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + model: resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + source: selectedAgent.source as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + color: selectedAgent.color as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, is_built_in_agent: isBuiltInAgent(selectedAgent), is_resume: false, - is_async: - (run_in_background === true || selectedAgent.background === true) && - !isBackgroundTasksDisabled, + is_async: (run_in_background === true || selectedAgent.background === true) && !isBackgroundTasksDisabled, is_fork: isForkPath, - }) + }); // Resolve effective isolation mode (explicit param overrides agent def) - const effectiveIsolation = isolation ?? selectedAgent.isolation + const effectiveIsolation = isolation ?? selectedAgent.isolation; // Remote isolation: delegate to CCR. Gated ant-only — the guard enables // dead code elimination of the entire block for external builds. if (process.env.USER_TYPE === 'ant' && effectiveIsolation === 'remote') { - const eligibility = await checkRemoteAgentEligibility() + const eligibility = await checkRemoteAgentEligibility(); if (!eligibility.eligible) { - const reasons = eligibility.errors - .map(formatPreconditionError) - .join('\n') - throw new Error(`Cannot launch remote agent:\n${reasons}`) + const reasons = eligibility.errors.map(formatPreconditionError).join('\n'); + throw new Error(`Cannot launch remote agent:\n${reasons}`); } - let bundleFailHint: string | undefined + let bundleFailHint: string | undefined; const session = await teleportToRemote({ initialMessage: prompt, description, signal: toolUseContext.abortController.signal, onBundleFail: msg => { - bundleFailHint = msg + bundleFailHint = msg; }, - }) + }); if (!session) { - throw new Error(bundleFailHint ?? 'Failed to create remote session') + throw new Error(bundleFailHint ?? 'Failed to create remote session'); } const { taskId, sessionId } = registerRemoteAgentTask({ @@ -692,12 +588,11 @@ export const AgentTool = buildTool({ command: prompt, context: toolUseContext, toolUseId: toolUseContext.toolUseId, - }) + }); logEvent('tengu_agent_tool_remote_launched', { - agent_type: - selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + agent_type: selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); const remoteResult: RemoteLaunchedOutput = { status: 'remote_launched', @@ -706,8 +601,8 @@ export const AgentTool = buildTool({ description, prompt, outputFile: getTaskOutputPath(taskId), - } - return { data: remoteResult } as unknown as { data: Output } + }; + return { data: remoteResult } as unknown as { data: Output }; } // System prompt + prompt messages: branch on fork path. // @@ -718,62 +613,55 @@ export const AgentTool = buildTool({ // // Normal path: build the selected agent's own system prompt with env // details, and use a simple user message for the prompt. - let enhancedSystemPrompt: string[] | undefined - let forkParentSystemPrompt: - | ReturnType - | undefined - let promptMessages: MessageType[] + let enhancedSystemPrompt: string[] | undefined; + let forkParentSystemPrompt: ReturnType | undefined; + let promptMessages: MessageType[]; if (isForkPath) { if (toolUseContext.renderedSystemPrompt) { - forkParentSystemPrompt = toolUseContext.renderedSystemPrompt + forkParentSystemPrompt = toolUseContext.renderedSystemPrompt; } else { // Fallback: recompute. May diverge from parent's cached bytes if // GrowthBook state changed between parent turn-start and fork spawn. const mainThreadAgentDefinition = appState.agent - ? appState.agentDefinitions.activeAgents.find( - a => a.agentType === appState.agent, - ) - : undefined + ? appState.agentDefinitions.activeAgents.find(a => a.agentType === appState.agent) + : undefined; const additionalWorkingDirectories = Array.from( appState.toolPermissionContext.additionalWorkingDirectories.keys(), - ) + ); const defaultSystemPrompt = await getSystemPrompt( toolUseContext.options.tools, toolUseContext.options.mainLoopModel, additionalWorkingDirectories, toolUseContext.options.mcpClients, - ) + ); forkParentSystemPrompt = buildEffectiveSystemPrompt({ mainThreadAgentDefinition, toolUseContext, customSystemPrompt: toolUseContext.options.customSystemPrompt, defaultSystemPrompt, appendSystemPrompt: toolUseContext.options.appendSystemPrompt, - }) + }); } - promptMessages = buildForkedMessages(prompt, assistantMessage) + promptMessages = buildForkedMessages(prompt, assistantMessage); } else { try { const additionalWorkingDirectories = Array.from( appState.toolPermissionContext.additionalWorkingDirectories.keys(), - ) + ); // All agents have getSystemPrompt - pass toolUseContext to all - const agentPrompt = selectedAgent.getSystemPrompt({ toolUseContext }) + const agentPrompt = selectedAgent.getSystemPrompt({ toolUseContext }); // Log agent memory loaded event for subagents if (selectedAgent.memory) { logEvent('tengu_agent_memory_loaded', { ...(process.env.USER_TYPE === 'ant' && { - agent_type: - selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + agent_type: selectedAgent.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, }), - scope: - selectedAgent.memory as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - source: - 'subagent' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) + scope: selectedAgent.memory as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + source: 'subagent' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); } // Apply environment details enhancement @@ -781,13 +669,11 @@ export const AgentTool = buildTool({ [agentPrompt], resolvedAgentModel, additionalWorkingDirectories, - ) + ); } catch (error) { - logForDebugging( - `Failed to get system prompt for agent ${selectedAgent.agentType}: ${errorMessage(error)}`, - ) + logForDebugging(`Failed to get system prompt for agent ${selectedAgent.agentType}: ${errorMessage(error)}`); } - promptMessages = [createUserMessage({ content: prompt })] + promptMessages = [createUserMessage({ content: prompt })]; } const metadata = { @@ -796,20 +682,16 @@ export const AgentTool = buildTool({ isBuiltInAgent: isBuiltInAgent(selectedAgent), startTime, agentType: selectedAgent.agentType, - isAsync: - (run_in_background === true || selectedAgent.background === true) && - !isBackgroundTasksDisabled, - } + isAsync: (run_in_background === true || selectedAgent.background === true) && !isBackgroundTasksDisabled, + }; // Use inline env check instead of coordinatorModule to avoid circular // dependency issues during test module loading. - const isCoordinator = feature('COORDINATOR_MODE') - ? isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) - : false + const isCoordinator = feature('COORDINATOR_MODE') ? isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) : false; // Fork subagent experiment: force ALL spawns async for a unified // interaction model (not just fork spawns — all of them). - const forceAsync = isForkSubagentEnabled() + const forceAsync = isForkSubagentEnabled(); // Assistant mode: force all agents async. Synchronous subagents hold the // main loop's turn open until they complete — the daemon's inputQueue @@ -818,9 +700,7 @@ export const AgentTool = buildTool({ // executeForkedSlashCommand's fire-and-forget path; the // re-entry there is handled by the else branch // below (registerAsyncAgentTask + notifyOnCompletion). - const assistantForceAsync = feature('KAIROS') - ? appState.kairosEnabled - : false + const assistantForceAsync = feature('KAIROS') ? appState.kairosEnabled : false; const shouldRunAsync = (run_in_background === true || @@ -829,7 +709,7 @@ export const AgentTool = buildTool({ forceAsync || assistantForceAsync || (proactiveModule?.isProactiveActive() ?? false)) && - !isBackgroundTasksDisabled + !isBackgroundTasksDisabled; // Assemble the worker's tool pool independently of the parent's. // Workers always get their tools from assembleToolPool with their own // permission mode, so they aren't affected by the parent's tool @@ -838,27 +718,24 @@ export const AgentTool = buildTool({ const workerPermissionContext = { ...appState.toolPermissionContext, mode: selectedAgent.permissionMode ?? 'acceptEdits', - } - const workerTools = assembleToolPool( - workerPermissionContext, - appState.mcp.tools, - ) + }; + const workerTools = assembleToolPool(workerPermissionContext, appState.mcp.tools); // Create a stable agent ID early so it can be used for worktree slug - const earlyAgentId = createAgentId() + const earlyAgentId = createAgentId(); // Set up worktree isolation if requested let worktreeInfo: { - worktreePath: string - worktreeBranch?: string - headCommit?: string - gitRoot?: string - hookBased?: boolean - } | null = null + worktreePath: string; + worktreeBranch?: string; + headCommit?: string; + gitRoot?: string; + hookBased?: boolean; + } | null = null; if (effectiveIsolation === 'worktree') { - const slug = `agent-${earlyAgentId.slice(0, 8)}` - worktreeInfo = await createAgentWorktree(slug) + const slug = `agent-${earlyAgentId.slice(0, 8)}`; + worktreeInfo = await createAgentWorktree(slug); } // Fork + worktree: inject a notice telling the child to translate paths @@ -869,7 +746,7 @@ export const AgentTool = buildTool({ createUserMessage({ content: buildWorktreeNotice(getCwd(), worktreeInfo.worktreePath), }), - ) + ); } const runAgentParams: Parameters[0] = { @@ -880,10 +757,7 @@ export const AgentTool = buildTool({ isAsync: shouldRunAsync, querySource: toolUseContext.options.querySource ?? - getQuerySourceForAgent( - selectedAgent.agentType, - isBuiltInAgent(selectedAgent), - ), + getQuerySourceForAgent(selectedAgent.agentType, isBuiltInAgent(selectedAgent)), model: isForkPath ? undefined : model, // Fork path: pass parent's system prompt AND parent's exact tool // array (cache-identical prefix). workerTools is rebuilt under @@ -908,52 +782,48 @@ export const AgentTool = buildTool({ ...(isForkPath && { useExactTools: true }), worktreePath: worktreeInfo?.worktreePath, description, - } + }; // Helper to wrap execution with a cwd override: explicit cwd arg (KAIROS) // takes precedence over worktree isolation path. - const cwdOverridePath = cwd ?? worktreeInfo?.worktreePath - const wrapWithCwd = (fn: () => T): T => - cwdOverridePath ? runWithCwdOverride(cwdOverridePath, fn) : fn() + const cwdOverridePath = cwd ?? worktreeInfo?.worktreePath; + const wrapWithCwd = (fn: () => T): T => (cwdOverridePath ? runWithCwdOverride(cwdOverridePath, fn) : fn()); // Helper to clean up worktree after agent completes const cleanupWorktreeIfNeeded = async (): Promise<{ - worktreePath?: string - worktreeBranch?: string + worktreePath?: string; + worktreeBranch?: string; }> => { - if (!worktreeInfo) return {} - const { worktreePath, worktreeBranch, headCommit, gitRoot, hookBased } = - worktreeInfo + if (!worktreeInfo) return {}; + const { worktreePath, worktreeBranch, headCommit, gitRoot, hookBased } = worktreeInfo; // Null out to make idempotent — guards against double-call if code // between cleanup and end of try throws into catch - worktreeInfo = null + worktreeInfo = null; if (hookBased) { // Hook-based worktrees are always kept since we can't detect VCS changes - logForDebugging(`Hook-based agent worktree kept at: ${worktreePath}`) - return { worktreePath } + logForDebugging(`Hook-based agent worktree kept at: ${worktreePath}`); + return { worktreePath }; } if (headCommit) { - const changed = await hasWorktreeChanges(worktreePath, headCommit) + const changed = await hasWorktreeChanges(worktreePath, headCommit); if (!changed) { - await removeAgentWorktree(worktreePath, worktreeBranch, gitRoot) + await removeAgentWorktree(worktreePath, worktreeBranch, gitRoot); // Clear worktreePath from metadata so resume doesn't try to use // a deleted directory. Fire-and-forget to match runAgent's // writeAgentMetadata handling. void writeAgentMetadata(asAgentId(earlyAgentId), { agentType: selectedAgent.agentType, description, - }).catch(_err => - logForDebugging(`Failed to clear worktree metadata: ${_err}`), - ) - return {} + }).catch(_err => logForDebugging(`Failed to clear worktree metadata: ${_err}`)); + return {}; } } - logForDebugging(`Agent worktree has changes, keeping: ${worktreePath}`) - return { worktreePath, worktreeBranch } - } + logForDebugging(`Agent worktree has changes, keeping: ${worktreePath}`); + return { worktreePath, worktreeBranch }; + }; if (shouldRunAsync) { - const asyncAgentId = earlyAgentId + const asyncAgentId = earlyAgentId; const agentBackgroundTask = registerAsyncAgent({ agentId: asyncAgentId, description, @@ -964,17 +834,17 @@ export const AgentTool = buildTool({ // survive when the user presses ESC to cancel the main thread. // They are killed explicitly via chat:killAgents. toolUseId: toolUseContext.toolUseId, - }) + }); // Register name → agentId for SendMessage routing. Post-registerAsyncAgent // so we don't leave a stale entry if spawn fails. Sync agents skipped — // coordinator is blocked, so SendMessage routing doesn't apply. if (name) { rootSetAppState(prev => { - const next = new Map(prev.agentNameRegistry) - next.set(name, asAgentId(asyncAgentId)) - return { ...prev, agentNameRegistry: next } - }) + const next = new Map(prev.agentNameRegistry); + next.set(name, asAgentId(asyncAgentId)); + return { ...prev, agentNameRegistry: next }; + }); } // Wrap async agent execution in agent context for analytics attribution @@ -989,7 +859,7 @@ export const AgentTool = buildTool({ invokingRequestId: assistantMessage?.requestId, invocationKind: 'spawn' as const, invocationEmitted: false, - } + }; // Workload propagation: handlePromptSubmit wraps the entire turn in // runWithWorkload (AsyncLocalStorage). ALS context is captured at @@ -1016,20 +886,15 @@ export const AgentTool = buildTool({ toolUseContext, rootSetAppState, agentIdForCleanup: asyncAgentId, - enableSummarization: - isCoordinator || - isForkSubagentEnabled() || - getSdkAgentProgressSummariesEnabled(), + enableSummarization: isCoordinator || isForkSubagentEnabled() || getSdkAgentProgressSummariesEnabled(), getWorktreeResult: cleanupWorktreeIfNeeded, }), ), - ) + ); const canReadOutputFile = toolUseContext.options.tools.some( - t => - toolMatchesName(t, FILE_READ_TOOL_NAME) || - toolMatchesName(t, BASH_TOOL_NAME), - ) + t => toolMatchesName(t, FILE_READ_TOOL_NAME) || toolMatchesName(t, BASH_TOOL_NAME), + ); return { data: { isAsync: true as const, @@ -1040,10 +905,10 @@ export const AgentTool = buildTool({ outputFile: getTaskOutputPath(agentBackgroundTask.agentId), canReadOutputFile, }, - } + }; } else { // Create an explicit agentId for sync agents - const syncAgentId = asAgentId(earlyAgentId) + const syncAgentId = asAgentId(earlyAgentId); // Set up agent context for sync execution (for analytics attribution) const syncAgentContext = { @@ -1057,30 +922,24 @@ export const AgentTool = buildTool({ invokingRequestId: assistantMessage?.requestId, invocationKind: 'spawn' as const, invocationEmitted: false, - } + }; // Wrap entire sync agent execution in context for analytics attribution // and optionally in a worktree cwd override for filesystem isolation return runWithAgentContext(syncAgentContext, () => wrapWithCwd(async () => { - const agentMessages: MessageType[] = [] - const agentStartTime = Date.now() - const syncTracker = createProgressTracker() - const syncResolveActivity = createActivityDescriptionResolver( - toolUseContext.options.tools, - ) + const agentMessages: MessageType[] = []; + const agentStartTime = Date.now(); + const syncTracker = createProgressTracker(); + const syncResolveActivity = createActivityDescriptionResolver(toolUseContext.options.tools); // Yield initial progress message to carry metadata (prompt) if (promptMessages.length > 0) { - const normalizedPromptMessages = normalizeMessages(promptMessages) + const normalizedPromptMessages = normalizeMessages(promptMessages); const normalizedFirstMessage = normalizedPromptMessages.find( (m): m is NormalizedUserMessage => m.type === 'user', - ) - if ( - normalizedFirstMessage && - normalizedFirstMessage.type === 'user' && - onProgress - ) { + ); + if (normalizedFirstMessage && normalizedFirstMessage.type === 'user' && onProgress) { onProgress({ toolUseID: `agent_${assistantMessage.message.id}`, data: { @@ -1089,18 +948,18 @@ export const AgentTool = buildTool({ prompt, agentId: syncAgentId, }, - }) + }); } } // Register as foreground task immediately so it can be backgrounded at any time // Skip registration if background tasks are disabled - let foregroundTaskId: string | undefined + let foregroundTaskId: string | undefined; // Create the background race promise once outside the loop — otherwise // each iteration adds a new .then() reaction to the same pending // promise, accumulating callbacks for the lifetime of the agent. - let backgroundPromise: Promise<{ type: 'background' }> | undefined - let cancelAutoBackground: (() => void) | undefined + let backgroundPromise: Promise<{ type: 'background' }> | undefined; + let cancelAutoBackground: (() => void) | undefined; if (!isBackgroundTasksDisabled) { const registration = registerAgentForeground({ agentId: syncAgentId, @@ -1110,23 +969,23 @@ export const AgentTool = buildTool({ setAppState: rootSetAppState, toolUseId: toolUseContext.toolUseId, autoBackgroundMs: getAutoBackgroundMs() || undefined, - }) - foregroundTaskId = registration.taskId + }); + foregroundTaskId = registration.taskId; backgroundPromise = registration.backgroundSignal.then(() => ({ type: 'background' as const, - })) - cancelAutoBackground = registration.cancelAutoBackground + })); + cancelAutoBackground = registration.cancelAutoBackground; } // Track if we've shown the background hint UI - let backgroundHintShown = false + let backgroundHintShown = false; // Track if the agent was backgrounded (cleanup handled by backgrounded finally) - let wasBackgrounded = false + let wasBackgrounded = false; // Per-scope stop function — NOT shared with the backgrounded closure. // idempotent: startAgentSummarization's stop() checks `stopped` flag. - let stopForegroundSummarization: (() => void) | undefined + let stopForegroundSummarization: (() => void) | undefined; // const capture for sound type narrowing inside the callback below - const summaryTaskId = foregroundTaskId + const summaryTaskId = foregroundTaskId; // Get async iterator for the agent const agentIterator = runAgent({ @@ -1138,28 +997,23 @@ export const AgentTool = buildTool({ onCacheSafeParams: summaryTaskId && getSdkAgentProgressSummariesEnabled() ? (params: CacheSafeParams) => { - const { stop } = startAgentSummarization( - summaryTaskId, - syncAgentId, - params, - rootSetAppState, - ) - stopForegroundSummarization = stop + const { stop } = startAgentSummarization(summaryTaskId, syncAgentId, params, rootSetAppState); + stopForegroundSummarization = stop; } : undefined, - })[Symbol.asyncIterator]() + })[Symbol.asyncIterator](); // Track if an error occurred during iteration - let syncAgentError: Error | undefined - let wasAborted = false + let syncAgentError: Error | undefined; + let wasAborted = false; let worktreeResult: { - worktreePath?: string - worktreeBranch?: string - } = {} + worktreePath?: string; + worktreeBranch?: string; + } = {}; try { while (true) { - const elapsed = Date.now() - agentStartTime + const elapsed = Date.now() - agentStartTime; // Show background hint after threshold (but task is already registered) // Skip if background tasks are disabled @@ -1169,18 +1023,18 @@ export const AgentTool = buildTool({ elapsed >= PROGRESS_THRESHOLD_MS && toolUseContext.setToolJSX ) { - backgroundHintShown = true + backgroundHintShown = true; toolUseContext.setToolJSX({ jsx: , shouldHidePromptInput: false, shouldContinueAnimation: true, showSpinner: true, - }) + }); } // Race between next message and background signal // If background tasks are disabled, just await the next message directly - const nextMessagePromise = agentIterator.next() + const nextMessagePromise = agentIterator.next(); const raceResult = backgroundPromise ? await Promise.race([ nextMessagePromise.then(r => ({ @@ -1192,49 +1046,38 @@ export const AgentTool = buildTool({ : { type: 'message' as const, result: await nextMessagePromise, - } + }; // Check if we were backgrounded via backgroundAll() // foregroundTaskId is guaranteed to be defined if raceResult.type is 'background' // because backgroundPromise is only defined when foregroundTaskId is defined if (raceResult.type === 'background' && foregroundTaskId) { - const appState = toolUseContext.getAppState() - const task = appState.tasks[foregroundTaskId] + const appState = toolUseContext.getAppState(); + const task = appState.tasks[foregroundTaskId]; if (isLocalAgentTask(task) && task.isBackgrounded) { // Capture the taskId for use in the async callback - const backgroundedTaskId = foregroundTaskId - wasBackgrounded = true + const backgroundedTaskId = foregroundTaskId; + wasBackgrounded = true; // Stop foreground summarization; the backgrounded closure // below owns its own independent stop function. - stopForegroundSummarization?.() + stopForegroundSummarization?.(); // Workload: inherited via ALS at `void` invocation time, // same as the async-from-start path above. // Continue agent in background and return async result void runWithAgentContext(syncAgentContext, async () => { - let stopBackgroundedSummarization: (() => void) | undefined + let stopBackgroundedSummarization: (() => void) | undefined; try { // Clean up the foreground iterator so its finally block runs // (releases MCP connections, session hooks, prompt cache tracking, etc.) // Timeout prevents blocking if MCP server cleanup hangs. // .catch() prevents unhandled rejection if timeout wins the race. - await Promise.race([ - agentIterator.return(undefined).catch(() => {}), - sleep(1000), - ]) + await Promise.race([agentIterator.return(undefined).catch(() => {}), sleep(1000)]); // Initialize progress tracking from existing messages - const tracker = createProgressTracker() - const resolveActivity2 = - createActivityDescriptionResolver( - toolUseContext.options.tools, - ) + const tracker = createProgressTracker(); + const resolveActivity2 = createActivityDescriptionResolver(toolUseContext.options.tools); for (const existingMsg of agentMessages) { - updateProgressFromMessage( - tracker, - existingMsg, - resolveActivity2, - toolUseContext.options.tools, - ) + updateProgressFromMessage(tracker, existingMsg, resolveActivity2, toolUseContext.options.tools); } for await (const msg of runAgent({ ...runAgentParams, @@ -1251,27 +1094,18 @@ export const AgentTool = buildTool({ asAgentId(backgroundedTaskId), params, rootSetAppState, - ) - stopBackgroundedSummarization = stop + ); + stopBackgroundedSummarization = stop; } : undefined, })) { - agentMessages.push(msg) + agentMessages.push(msg); // Track progress for backgrounded agents - updateProgressFromMessage( - tracker, - msg, - resolveActivity2, - toolUseContext.options.tools, - ) - updateAsyncAgentProgress( - backgroundedTaskId, - getProgressUpdate(tracker), - rootSetAppState, - ) - - const lastToolName = getLastToolUseName(msg) + updateProgressFromMessage(tracker, msg, resolveActivity2, toolUseContext.options.tools); + updateAsyncAgentProgress(backgroundedTaskId, getProgressUpdate(tracker), rootSetAppState); + + const lastToolName = getLastToolUseName(msg); if (lastToolName) { emitTaskProgress( tracker, @@ -1280,46 +1114,37 @@ export const AgentTool = buildTool({ description, startTime, lastToolName, - ) + ); } } - const agentResult = finalizeAgentTool( - agentMessages, - backgroundedTaskId, - metadata, - ) + const agentResult = finalizeAgentTool(agentMessages, backgroundedTaskId, metadata); // Mark task completed FIRST so TaskOutput(block=true) // unblocks immediately. classifyHandoffIfNeeded and // cleanupWorktreeIfNeeded can hang — they must not gate // the status transition (gh-20236). - completeAsyncAgent(agentResult, rootSetAppState) + completeAsyncAgent(agentResult, rootSetAppState); // Extract text from agent result content for the notification - let finalMessage = extractTextContent( - agentResult.content, - '\n', - ) + let finalMessage = extractTextContent(agentResult.content, '\n'); if (feature('TRANSCRIPT_CLASSIFIER')) { - const backgroundedAppState = - toolUseContext.getAppState() + const backgroundedAppState = toolUseContext.getAppState(); const handoffWarning = await classifyHandoffIfNeeded({ agentMessages, tools: toolUseContext.options.tools, - toolPermissionContext: - backgroundedAppState.toolPermissionContext, + toolPermissionContext: backgroundedAppState.toolPermissionContext, abortSignal: task.abortController!.signal, subagentType: selectedAgent.agentType, totalToolUseCount: agentResult.totalToolUseCount, - }) + }); if (handoffWarning) { - finalMessage = `${handoffWarning}\n\n${finalMessage}` + finalMessage = `${handoffWarning}\n\n${finalMessage}`; } } // Clean up worktree before notification so we can include it - const worktreeResult = await cleanupWorktreeIfNeeded() + const worktreeResult = await cleanupWorktreeIfNeeded(); enqueueAgentNotification({ taskId: backgroundedTaskId, @@ -1334,15 +1159,14 @@ export const AgentTool = buildTool({ }, toolUseId: toolUseContext.toolUseId, ...worktreeResult, - }) + }); } catch (error) { if (error instanceof AbortError) { // Transition status BEFORE worktree cleanup so // TaskOutput unblocks even if git hangs (gh-20236). - killAsyncAgent(backgroundedTaskId, rootSetAppState) + killAsyncAgent(backgroundedTaskId, rootSetAppState); logEvent('tengu_agent_tool_terminated', { - agent_type: - metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + agent_type: metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, model: metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, duration_ms: Date.now() - metadata.startTime, @@ -1350,10 +1174,9 @@ export const AgentTool = buildTool({ is_built_in_agent: metadata.isBuiltInAgent, reason: 'user_cancel_background' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - const worktreeResult = await cleanupWorktreeIfNeeded() - const partialResult = - extractPartialResult(agentMessages) + }); + const worktreeResult = await cleanupWorktreeIfNeeded(); + const partialResult = extractPartialResult(agentMessages); enqueueAgentNotification({ taskId: backgroundedTaskId, description, @@ -1362,16 +1185,12 @@ export const AgentTool = buildTool({ toolUseId: toolUseContext.toolUseId, finalMessage: partialResult, ...worktreeResult, - }) - return + }); + return; } - const errMsg = errorMessage(error) - failAsyncAgent( - backgroundedTaskId, - errMsg, - rootSetAppState, - ) - const worktreeResult = await cleanupWorktreeIfNeeded() + const errMsg = errorMessage(error); + failAsyncAgent(backgroundedTaskId, errMsg, rootSetAppState); + const worktreeResult = await cleanupWorktreeIfNeeded(); enqueueAgentNotification({ taskId: backgroundedTaskId, description, @@ -1380,22 +1199,20 @@ export const AgentTool = buildTool({ setAppState: rootSetAppState, toolUseId: toolUseContext.toolUseId, ...worktreeResult, - }) + }); } finally { - stopBackgroundedSummarization?.() - clearInvokedSkillsForAgent(syncAgentId) - clearDumpState(syncAgentId) + stopBackgroundedSummarization?.(); + clearInvokedSkillsForAgent(syncAgentId); + clearDumpState(syncAgentId); // Note: worktree cleanup is done before enqueueAgentNotification // in both try and catch paths so we can include worktree info } - }) + }); // Return async_launched result immediately const canReadOutputFile = toolUseContext.options.tools.some( - t => - toolMatchesName(t, FILE_READ_TOOL_NAME) || - toolMatchesName(t, BASH_TOOL_NAME), - ) + t => toolMatchesName(t, FILE_READ_TOOL_NAME) || toolMatchesName(t, BASH_TOOL_NAME), + ); return { data: { isAsync: true as const, @@ -1406,30 +1223,25 @@ export const AgentTool = buildTool({ outputFile: getTaskOutputPath(backgroundedTaskId), canReadOutputFile, }, - } + }; } } // Process the message from the race result if (raceResult.type !== 'message') { // This shouldn't happen - background case handled above - continue + continue; } - const { result } = raceResult - if (result.done) break - const message = result.value + const { result } = raceResult; + if (result.done) break; + const message = result.value; - agentMessages.push(message) + agentMessages.push(message); // Emit task_progress for the VS Code subagent panel - updateProgressFromMessage( - syncTracker, - message, - syncResolveActivity, - toolUseContext.options.tools, - ) + updateProgressFromMessage(syncTracker, message, syncResolveActivity, toolUseContext.options.tools); if (foregroundTaskId) { - const lastToolName = getLastToolUseName(message) + const lastToolName = getLastToolUseName(message); if (lastToolName) { emitTaskProgress( syncTracker, @@ -1438,16 +1250,12 @@ export const AgentTool = buildTool({ description, agentStartTime, lastToolName, - ) + ); // Keep AppState task.progress in sync when SDK summaries are // enabled, so updateAgentSummary reads correct token/tool counts // instead of zeros. if (getSdkAgentProgressSummariesEnabled()) { - updateAsyncAgentProgress( - foregroundTaskId, - getProgressUpdate(syncTracker), - rootSetAppState, - ) + updateAsyncAgentProgress(foregroundTaskId, getProgressUpdate(syncTracker), rootSetAppState); } } } @@ -1456,38 +1264,34 @@ export const AgentTool = buildTool({ // receives tool_progress events just as it does for the main agent. if ( message.type === 'progress' && - (message.data.type === 'bash_progress' || - message.data.type === 'powershell_progress') && + (message.data.type === 'bash_progress' || message.data.type === 'powershell_progress') && onProgress ) { onProgress({ toolUseID: message.toolUseID, data: message.data, - }) + }); } if (message.type !== 'assistant' && message.type !== 'user') { - continue + continue; } // Increment token count in spinner for assistant messages // Subagent streaming events are filtered out in runAgent.ts, so we // need to count tokens from completed messages here if (message.type === 'assistant') { - const contentLength = getAssistantMessageContentLength(message) + const contentLength = getAssistantMessageContentLength(message); if (contentLength > 0) { - toolUseContext.setResponseLength(len => len + contentLength) + toolUseContext.setResponseLength(len => len + contentLength); } } - const normalizedNew = normalizeMessages([message]) + const normalizedNew = normalizeMessages([message]); for (const m of normalizedNew) { for (const content of m.message.content) { - if ( - content.type !== 'tool_use' && - content.type !== 'tool_result' - ) { - continue + if (content.type !== 'tool_use' && content.type !== 'tool_result') { + continue; } // Forward progress updates @@ -1502,7 +1306,7 @@ export const AgentTool = buildTool({ prompt: '', agentId: syncAgentId, }, - }) + }); } } } @@ -1511,57 +1315,50 @@ export const AgentTool = buildTool({ // Handle errors from the sync agent loop // AbortError should be re-thrown for proper interruption handling if (error instanceof AbortError) { - wasAborted = true + wasAborted = true; logEvent('tengu_agent_tool_terminated', { - agent_type: - metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - model: - metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + agent_type: metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + model: metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, duration_ms: Date.now() - metadata.startTime, is_async: false, is_built_in_agent: metadata.isBuiltInAgent, - reason: - 'user_cancel_sync' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - throw error + reason: 'user_cancel_sync' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + throw error; } // Log the error for debugging logForDebugging(`Sync agent error: ${errorMessage(error)}`, { level: 'error', - }) + }); // Store the error to handle after cleanup - syncAgentError = toError(error) + syncAgentError = toError(error); } finally { // Clear the background hint UI if (toolUseContext.setToolJSX) { - toolUseContext.setToolJSX(null) + toolUseContext.setToolJSX(null); } // Stop foreground summarization. Idempotent — if already stopped at // the backgrounding transition, this is a no-op. The backgrounded // closure owns a separate stop function (stopBackgroundedSummarization). - stopForegroundSummarization?.() + stopForegroundSummarization?.(); // Unregister foreground task if agent completed without being backgrounded if (foregroundTaskId) { - unregisterAgentForeground(foregroundTaskId, rootSetAppState) + unregisterAgentForeground(foregroundTaskId, rootSetAppState); // Notify SDK consumers (e.g. VS Code subagent panel) that this // foreground agent is done. Goes through drainSdkEvents() — does // NOT trigger the print.ts XML task_notification parser or the LLM loop. if (!wasBackgrounded) { - const progress = getProgressUpdate(syncTracker) + const progress = getProgressUpdate(syncTracker); enqueueSdkEvent({ type: 'system', subtype: 'task_notification', task_id: foregroundTaskId, tool_use_id: toolUseContext.toolUseId, - status: syncAgentError - ? 'failed' - : wasAborted - ? 'stopped' - : 'completed', + status: syncAgentError ? 'failed' : wasAborted ? 'stopped' : 'completed', output_file: '', summary: description, usage: { @@ -1569,47 +1366,42 @@ export const AgentTool = buildTool({ tool_uses: progress.toolUseCount, duration_ms: Date.now() - agentStartTime, }, - }) + }); } } // Clean up scoped skills so they don't accumulate in the global map - clearInvokedSkillsForAgent(syncAgentId) + clearInvokedSkillsForAgent(syncAgentId); // Clean up dumpState entry for this agent to prevent unbounded growth // Skip if backgrounded — the backgrounded agent's finally handles cleanup if (!wasBackgrounded) { - clearDumpState(syncAgentId) + clearDumpState(syncAgentId); } // Cancel auto-background timer if agent completed before it fired - cancelAutoBackground?.() + cancelAutoBackground?.(); // Clean up worktree if applicable (in finally to handle abort/error paths) // Skip if backgrounded — the background continuation is still running in it if (!wasBackgrounded) { - worktreeResult = await cleanupWorktreeIfNeeded() + worktreeResult = await cleanupWorktreeIfNeeded(); } } // Re-throw abort errors // TODO: Find a cleaner way to express this - const lastMessage = agentMessages.findLast( - _ => _.type !== 'system' && _.type !== 'progress', - ) + const lastMessage = agentMessages.findLast(_ => _.type !== 'system' && _.type !== 'progress'); if (lastMessage && isSyntheticMessage(lastMessage)) { logEvent('tengu_agent_tool_terminated', { - agent_type: - metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - model: - metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + agent_type: metadata.agentType as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + model: metadata.resolvedAgentModel as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, duration_ms: Date.now() - metadata.startTime, is_async: false, is_built_in_agent: metadata.isBuiltInAgent, - reason: - 'user_cancel_sync' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - }) - throw new AbortError() + reason: 'user_cancel_sync' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }); + throw new AbortError(); } // If an error occurred during iteration, try to return a result with @@ -1617,30 +1409,22 @@ export const AgentTool = buildTool({ // re-throw the error so it's properly handled by the tool framework. if (syncAgentError) { // Check if we have any assistant messages to return - const hasAssistantMessages = agentMessages.some( - msg => msg.type === 'assistant', - ) + const hasAssistantMessages = agentMessages.some(msg => msg.type === 'assistant'); if (!hasAssistantMessages) { // No messages collected, re-throw the error - throw syncAgentError + throw syncAgentError; } // We have some messages, try to finalize and return them // This allows the parent agent to see partial progress even after an error - logForDebugging( - `Sync agent recovering from error with ${agentMessages.length} messages`, - ) + logForDebugging(`Sync agent recovering from error with ${agentMessages.length} messages`); } - const agentResult = finalizeAgentTool( - agentMessages, - syncAgentId, - metadata, - ) + const agentResult = finalizeAgentTool(agentMessages, syncAgentId, metadata); if (feature('TRANSCRIPT_CLASSIFIER')) { - const currentAppState = toolUseContext.getAppState() + const currentAppState = toolUseContext.getAppState(); const handoffWarning = await classifyHandoffIfNeeded({ agentMessages, tools: toolUseContext.options.tools, @@ -1648,12 +1432,9 @@ export const AgentTool = buildTool({ abortSignal: toolUseContext.abortController.signal, subagentType: selectedAgent.agentType, totalToolUseCount: agentResult.totalToolUseCount, - }) + }); if (handoffWarning) { - agentResult.content = [ - { type: 'text' as const, text: handoffWarning }, - ...agentResult.content, - ] + agentResult.content = [{ type: 'text' as const, text: handoffWarning }, ...agentResult.content]; } } @@ -1664,59 +1445,53 @@ export const AgentTool = buildTool({ ...agentResult, ...worktreeResult, }, - } + }; }), - ) + ); } }, isReadOnly() { - return true // delegates permission checks to its underlying tools + return true; // delegates permission checks to its underlying tools }, toAutoClassifierInput(input) { - const i = input as AgentToolInput - const tags = [ - i.subagent_type, - i.mode ? `mode=${i.mode}` : undefined, - ].filter((t): t is string => t !== undefined) - const prefix = tags.length > 0 ? `(${tags.join(', ')}): ` : ': ' - return `${prefix}${i.prompt}` + const i = input as AgentToolInput; + const tags = [i.subagent_type, i.mode ? `mode=${i.mode}` : undefined].filter((t): t is string => t !== undefined); + const prefix = tags.length > 0 ? `(${tags.join(', ')}): ` : ': '; + return `${prefix}${i.prompt}`; }, isConcurrencySafe() { - return true + return true; }, userFacingName, userFacingNameBackgroundColor, getActivityDescription(input) { - return input?.description ?? 'Running task' + return input?.description ?? 'Running task'; }, async checkPermissions(input, context): Promise { - const appState = context.getAppState() + const appState = context.getAppState(); // Only route through auto mode classifier when in auto mode // In all other modes, auto-approve sub-agent generation // Note: process.env.USER_TYPE === 'ant' guard enables dead code elimination for external builds - if ( - process.env.USER_TYPE === 'ant' && - appState.toolPermissionContext.mode === 'auto' - ) { + if (process.env.USER_TYPE === 'ant' && appState.toolPermissionContext.mode === 'auto') { return { behavior: 'passthrough', message: 'Agent tool requires permission to spawn sub-agents.', - } + }; } - return { behavior: 'allow', updatedInput: input } + return { behavior: 'allow', updatedInput: input }; }, mapToolResultToToolResultBlockParam(data, toolUseID) { // Multi-agent spawn result - const internalData = data as InternalOutput + const internalData = data as InternalOutput; if ( typeof internalData === 'object' && internalData !== null && 'status' in internalData && internalData.status === 'teammate_spawned' ) { - const spawnData = internalData as TeammateSpawnedOutput + const spawnData = internalData as TeammateSpawnedOutput; return { tool_use_id: toolUseID, type: 'tool_result', @@ -1730,10 +1505,10 @@ team_name: ${spawnData.team_name} The agent is now running and will receive instructions via mailbox.`, }, ], - } + }; } if ('status' in internalData && internalData.status === 'remote_launched') { - const r = internalData + const r = internalData; return { tool_use_id: toolUseID, type: 'tool_result', @@ -1743,14 +1518,14 @@ The agent is now running and will receive instructions via mailbox.`, text: `Remote agent launched in CCR.\ntaskId: ${r.taskId}\nsession_url: ${r.sessionUrl}\noutput_file: ${r.outputFile}\nThe agent is running remotely. You will be notified automatically when it completes.\nBriefly tell the user what you launched and end your response.`, }, ], - } + }; } if (data.status === 'async_launched') { - const prefix = `Async agent launched successfully.\nagentId: ${data.agentId} (internal ID - do not mention to user. Use SendMessage with to: '${data.agentId}' to continue this agent.)\nThe agent is working in the background. You will be notified automatically when it completes.` + const prefix = `Async agent launched successfully.\nagentId: ${data.agentId} (internal ID - do not mention to user. Use SendMessage with to: '${data.agentId}' to continue this agent.)\nThe agent is working in the background. You will be notified automatically when it completes.`; const instructions = data.canReadOutputFile ? `Do not duplicate this agent's work — avoid working with the same files or topics it is using. Work on non-overlapping tasks, or briefly tell the user what you launched and end your response.\noutput_file: ${data.outputFile}\nIf asked, you can check progress before completion by using ${FILE_READ_TOOL_NAME} or ${BASH_TOOL_NAME} tail on the output file.` - : `Briefly tell the user what you launched and end your response. Do not generate any other text — agent results will arrive in a subsequent message.` - const text = `${prefix}\n${instructions}` + : `Briefly tell the user what you launched and end your response. Do not generate any other text — agent results will arrive in a subsequent message.`; + const text = `${prefix}\n${instructions}`; return { tool_use_id: toolUseID, type: 'tool_result', @@ -1760,13 +1535,13 @@ The agent is now running and will receive instructions via mailbox.`, text, }, ], - } + }; } if (data.status === 'completed') { - const worktreeData = data as Record + const worktreeData = data as Record; const worktreeInfoText = worktreeData.worktreePath ? `\nworktreePath: ${worktreeData.worktreePath}\nworktreeBranch: ${worktreeData.worktreeBranch}` - : '' + : ''; // If the subagent completes with no content, the tool_result is just the // agentId/usage trailer below — a metadata-only block at the prompt tail. // Some models read that as "nothing to act on" and end their turn @@ -1779,22 +1554,18 @@ The agent is now running and will receive instructions via mailbox.`, type: 'text' as const, text: '(Subagent completed but returned no output.)', }, - ] + ]; // One-shot built-ins (Explore, Plan) are never continued via SendMessage // — the agentId hint and block are dead weight (~135 chars × // 34M Explore runs/week ≈ 1-2 Gtok/week). Telemetry doesn't parse this // block (it uses logEvent in finalizeAgentTool), so dropping is safe. // agentType is optional for resume compat — missing means show trailer. - if ( - data.agentType && - ONE_SHOT_BUILTIN_AGENT_TYPES.has(data.agentType) && - !worktreeInfoText - ) { + if (data.agentType && ONE_SHOT_BUILTIN_AGENT_TYPES.has(data.agentType) && !worktreeInfoText) { return { tool_use_id: toolUseID, type: 'tool_result', content: contentOrMarker, - } + }; } return { tool_use_id: toolUseID, @@ -1809,12 +1580,10 @@ tool_uses: ${data.totalToolUseCount} duration_ms: ${data.totalDurationMs}`, }, ], - } + }; } - data satisfies never - throw new Error( - `Unexpected agent tool result status: ${(data as { status: string }).status}`, - ) + data satisfies never; + throw new Error(`Unexpected agent tool result status: ${(data as { status: string }).status}`); }, renderToolResultMessage, renderToolUseMessage, @@ -1823,12 +1592,12 @@ duration_ms: ${data.totalDurationMs}`, renderToolUseRejectedMessage, renderToolUseErrorMessage, renderGroupedToolUse: renderGroupedAgentToolUse, -} satisfies ToolDef) +} satisfies ToolDef); function resolveTeamName( input: { team_name?: string }, appState: { teamContext?: { teamName: string } }, ): string | undefined { - if (!isAgentSwarmsEnabled()) return undefined - return input.team_name || appState.teamContext?.teamName + if (!isAgentSwarmsEnabled()) return undefined; + return input.team_name || appState.teamContext?.teamName; } diff --git a/src/tools/AgentTool/UI.tsx b/src/tools/AgentTool/UI.tsx index aaa312e20..7edbd8f20 100644 --- a/src/tools/AgentTool/UI.tsx +++ b/src/tools/AgentTool/UI.tsx @@ -1,57 +1,36 @@ -import type { - ToolResultBlockParam, - ToolUseBlockParam, -} from '@anthropic-ai/sdk/resources/index.mjs' -import * as React from 'react' -import { ConfigurableShortcutHint } from 'src/components/ConfigurableShortcutHint.js' -import { - CtrlOToExpand, - SubAgentProvider, -} from 'src/components/CtrlOToExpand.js' -import { Byline } from 'src/components/design-system/Byline.js' -import { KeyboardShortcutHint } from 'src/components/design-system/KeyboardShortcutHint.js' -import type { z } from 'zod/v4' -import { AgentProgressLine } from '../../components/AgentProgressLine.js' -import { FallbackToolUseErrorMessage } from '../../components/FallbackToolUseErrorMessage.js' -import { FallbackToolUseRejectedMessage } from '../../components/FallbackToolUseRejectedMessage.js' -import { Markdown } from '../../components/Markdown.js' -import { Message as MessageComponent } from '../../components/Message.js' -import { MessageResponse } from '../../components/MessageResponse.js' -import { ToolUseLoader } from '../../components/ToolUseLoader.js' -import { Box, Text } from '../../ink.js' -import { getDumpPromptsPath } from '../../services/api/dumpPrompts.js' -import { findToolByName, type Tools } from '../../Tool.js' -import type { Message, ProgressMessage } from '../../types/message.js' -import type { AgentToolProgress } from '../../types/tools.js' -import { count } from '../../utils/array.js' -import { - getSearchOrReadFromContent, - getSearchReadSummaryText, -} from '../../utils/collapseReadSearch.js' -import { getDisplayPath } from '../../utils/file.js' -import { formatDuration, formatNumber } from '../../utils/format.js' -import { - buildSubagentLookups, - createAssistantMessage, - EMPTY_LOOKUPS, -} from '../../utils/messages.js' -import type { ModelAlias } from '../../utils/model/aliases.js' -import { - getMainLoopModel, - parseUserSpecifiedModel, - renderModelName, -} from '../../utils/model/model.js' -import type { Theme, ThemeName } from '../../utils/theme.js' -import type { - outputSchema, - Progress, - RemoteLaunchedOutput, -} from './AgentTool.js' -import { inputSchema } from './AgentTool.js' -import { getAgentColor } from './agentColorManager.js' -import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js' - -const MAX_PROGRESS_MESSAGES_TO_SHOW = 3 +import type { ToolResultBlockParam, ToolUseBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; +import * as React from 'react'; +import { ConfigurableShortcutHint } from 'src/components/ConfigurableShortcutHint.js'; +import { CtrlOToExpand, SubAgentProvider } from 'src/components/CtrlOToExpand.js'; +import { Byline } from 'src/components/design-system/Byline.js'; +import { KeyboardShortcutHint } from 'src/components/design-system/KeyboardShortcutHint.js'; +import type { z } from 'zod/v4'; +import { AgentProgressLine } from '../../components/AgentProgressLine.js'; +import { FallbackToolUseErrorMessage } from '../../components/FallbackToolUseErrorMessage.js'; +import { FallbackToolUseRejectedMessage } from '../../components/FallbackToolUseRejectedMessage.js'; +import { Markdown } from '../../components/Markdown.js'; +import { Message as MessageComponent } from '../../components/Message.js'; +import { MessageResponse } from '../../components/MessageResponse.js'; +import { ToolUseLoader } from '../../components/ToolUseLoader.js'; +import { Box, Text } from '../../ink.js'; +import { getDumpPromptsPath } from '../../services/api/dumpPrompts.js'; +import { findToolByName, type Tools } from '../../Tool.js'; +import type { Message, ProgressMessage } from '../../types/message.js'; +import type { AgentToolProgress } from '../../types/tools.js'; +import { count } from '../../utils/array.js'; +import { getSearchOrReadFromContent, getSearchReadSummaryText } from '../../utils/collapseReadSearch.js'; +import { getDisplayPath } from '../../utils/file.js'; +import { formatDuration, formatNumber } from '../../utils/format.js'; +import { buildSubagentLookups, createAssistantMessage, EMPTY_LOOKUPS } from '../../utils/messages.js'; +import type { ModelAlias } from '../../utils/model/aliases.js'; +import { getMainLoopModel, parseUserSpecifiedModel, renderModelName } from '../../utils/model/model.js'; +import type { Theme, ThemeName } from '../../utils/theme.js'; +import type { outputSchema, Progress, RemoteLaunchedOutput } from './AgentTool.js'; +import { inputSchema } from './AgentTool.js'; +import { getAgentColor } from './agentColorManager.js'; +import { GENERAL_PURPOSE_AGENT } from './built-in/generalPurposeAgent.js'; + +const MAX_PROGRESS_MESSAGES_TO_SHOW = 3; /** * Guard: checks if progress data has a `message` field (agent_progress or @@ -60,10 +39,10 @@ const MAX_PROGRESS_MESSAGES_TO_SHOW = 3 */ function hasProgressMessage(data: Progress): data is AgentToolProgress { if (!('message' in data)) { - return false + return false; } - const msg = (data as AgentToolProgress).message - return msg != null && typeof msg === 'object' && 'type' in msg + const msg = (data as AgentToolProgress).message; + return msg != null && typeof msg === 'object' && 'type' in msg; } /** @@ -79,41 +58,39 @@ function getSearchOrReadInfo( toolUseByID: Map, ): { isSearch: boolean; isRead: boolean; isREPL: boolean } | null { if (!hasProgressMessage(progressMessage.data)) { - return null + return null; } - const message = progressMessage.data.message + const message = progressMessage.data.message; // Check tool_use (assistant message) if (message.type === 'assistant') { - return getSearchOrReadFromContent(message.message.content[0], tools) + return getSearchOrReadFromContent(message.message.content[0], tools); } // Check tool_result (user message) - find corresponding tool use from the map if (message.type === 'user') { - const content = message.message.content[0] + const content = message.message.content[0]; if (content?.type === 'tool_result') { - const toolUse = toolUseByID.get(content.tool_use_id) + const toolUse = toolUseByID.get(content.tool_use_id); if (toolUse) { - return getSearchOrReadFromContent(toolUse, tools) + return getSearchOrReadFromContent(toolUse, tools); } } } - return null + return null; } type SummaryMessage = { - type: 'summary' - searchCount: number - readCount: number - replCount: number - uuid: string - isActive: boolean // true if still in progress (last message was tool_use, not tool_result) -} + type: 'summary'; + searchCount: number; + readCount: number; + replCount: number; + uuid: string; + isActive: boolean; // true if still in progress (last message was tool_use, not tool_result) +}; -type ProcessedMessage = - | { type: 'original'; message: ProgressMessage } - | SummaryMessage +type ProcessedMessage = { type: 'original'; message: ProgressMessage } | SummaryMessage; /** * Process progress messages to group consecutive search/read operations into summaries. @@ -126,30 +103,24 @@ function processProgressMessages( isAgentRunning: boolean, ): ProcessedMessage[] { // Only process for ants - if ("external" !== 'ant') { + if ('external' !== 'ant') { return messages .filter( - (m): m is ProgressMessage => - hasProgressMessage(m.data) && m.data.message.type !== 'user', + (m): m is ProgressMessage => hasProgressMessage(m.data) && m.data.message.type !== 'user', ) - .map(m => ({ type: 'original', message: m })) + .map(m => ({ type: 'original', message: m })); } - const result: ProcessedMessage[] = [] + const result: ProcessedMessage[] = []; let currentGroup: { - searchCount: number - readCount: number - replCount: number - startUuid: string - } | null = null + searchCount: number; + readCount: number; + replCount: number; + startUuid: string; + } | null = null; function flushGroup(isActive: boolean): void { - if ( - currentGroup && - (currentGroup.searchCount > 0 || - currentGroup.readCount > 0 || - currentGroup.replCount > 0) - ) { + if (currentGroup && (currentGroup.searchCount > 0 || currentGroup.readCount > 0 || currentGroup.replCount > 0)) { result.push({ type: 'summary', searchCount: currentGroup.searchCount, @@ -157,27 +128,25 @@ function processProgressMessages( replCount: currentGroup.replCount, uuid: `summary-${currentGroup.startUuid}`, isActive, - }) + }); } - currentGroup = null + currentGroup = null; } - const agentMessages = messages.filter( - (m): m is ProgressMessage => hasProgressMessage(m.data), - ) + const agentMessages = messages.filter((m): m is ProgressMessage => hasProgressMessage(m.data)); // Build tool_use lookup incrementally as we iterate - const toolUseByID = new Map() + const toolUseByID = new Map(); for (const msg of agentMessages) { // Track tool_use blocks as we see them if (msg.data.message.type === 'assistant') { for (const c of msg.data.message.message.content) { if (c.type === 'tool_use') { - toolUseByID.set(c.id, c as ToolUseBlockParam) + toolUseByID.set(c.id, c as ToolUseBlockParam); } } } - const info = getSearchOrReadInfo(msg, tools, toolUseByID) + const info = getSearchOrReadInfo(msg, tools, toolUseByID); if (info && (info.isSearch || info.isRead || info.isREPL)) { // This is a search/read/REPL operation - add to current group @@ -187,48 +156,48 @@ function processProgressMessages( readCount: 0, replCount: 0, startUuid: msg.uuid, - } + }; } // Only count tool_result messages (not tool_use) to avoid double counting if (msg.data.message.type === 'user') { if (info.isSearch) { - currentGroup.searchCount++ + currentGroup.searchCount++; } else if (info.isREPL) { - currentGroup.replCount++ + currentGroup.replCount++; } else if (info.isRead) { - currentGroup.readCount++ + currentGroup.readCount++; } } } else { // Non-search/read/REPL message - flush current group (completed) and add this message - flushGroup(false) + flushGroup(false); // Skip user tool_result messages — subagent progress messages lack // toolUseResult, so UserToolSuccessMessage returns null and the // height=1 Box in renderToolUseProgressMessage shows as a blank line. if (msg.data.message.type !== 'user') { - result.push({ type: 'original', message: msg }) + result.push({ type: 'original', message: msg }); } } } // Flush any remaining group - it's active if the agent is still running - flushGroup(isAgentRunning) + flushGroup(isAgentRunning); - return result + return result; } -const ESTIMATED_LINES_PER_TOOL = 9 -const TERMINAL_BUFFER_LINES = 7 +const ESTIMATED_LINES_PER_TOOL = 9; +const TERMINAL_BUFFER_LINES = 7; -type Output = z.input> +type Output = z.input>; export function AgentPromptDisplay({ prompt, dim: _dim = false, }: { - prompt: string - theme?: ThemeName // deprecated, kept for compatibility - Markdown uses useTheme internally - dim?: boolean // deprecated, kept for compatibility - dimColor cannot be applied to Box (Markdown returns Box) + prompt: string; + theme?: ThemeName; // deprecated, kept for compatibility - Markdown uses useTheme internally + dim?: boolean; // deprecated, kept for compatibility - dimColor cannot be applied to Box (Markdown returns Box) }): React.ReactNode { return ( @@ -239,14 +208,14 @@ export function AgentPromptDisplay({ {prompt} - ) + ); } export function AgentResponseDisplay({ content, }: { - content: { type: string; text: string }[] - theme?: ThemeName // deprecated, kept for compatibility - Markdown uses useTheme internally + content: { type: string; text: string }[]; + theme?: ThemeName; // deprecated, kept for compatibility - Markdown uses useTheme internally }): React.ReactNode { return ( @@ -259,44 +228,36 @@ export function AgentResponseDisplay({ ))} - ) + ); } type VerboseAgentTranscriptProps = { - progressMessages: ProgressMessage[] - tools: Tools - verbose: boolean -} + progressMessages: ProgressMessage[]; + tools: Tools; + verbose: boolean; +}; -function VerboseAgentTranscript({ - progressMessages, - tools, - verbose, -}: VerboseAgentTranscriptProps): React.ReactNode { +function VerboseAgentTranscript({ progressMessages, tools, verbose }: VerboseAgentTranscriptProps): React.ReactNode { const { lookups: agentLookups, inProgressToolUseIDs } = buildSubagentLookups( progressMessages - .filter((pm): pm is ProgressMessage => - hasProgressMessage(pm.data), - ) + .filter((pm): pm is ProgressMessage => hasProgressMessage(pm.data)) .map(pm => pm.data), - ) + ); // Filter out user tool_result messages that lack toolUseResult. // Subagent progress messages don't carry the parsed tool output, // so UserToolSuccessMessage returns null and MessageResponse renders // a bare ⎿ with no content. - const filteredMessages = progressMessages.filter( - (pm): pm is ProgressMessage => { - if (!hasProgressMessage(pm.data)) { - return false - } - const msg = pm.data.message - if (msg.type === 'user' && msg.toolUseResult === undefined) { - return false - } - return true - }, - ) + const filteredMessages = progressMessages.filter((pm): pm is ProgressMessage => { + if (!hasProgressMessage(pm.data)) { + return false; + } + const msg = pm.data.message; + if (msg.type === 'user' && msg.toolUseResult === undefined) { + return false; + } + return true; + }); return ( <> @@ -319,7 +280,7 @@ function VerboseAgentTranscript({ ))} - ) + ); } export function renderToolResultMessage( @@ -331,15 +292,15 @@ export function renderToolResultMessage( theme, isTranscriptMode = false, }: { - tools: Tools - verbose: boolean - theme: ThemeName - isTranscriptMode?: boolean + tools: Tools; + verbose: boolean; + theme: ThemeName; + isTranscriptMode?: boolean; }, ): React.ReactNode { // Remote-launched agents (ant-only) use a private output type not in the // public schema. Narrow via the internal discriminant. - const internal = data as Output | RemoteLaunchedOutput + const internal = data as Output | RemoteLaunchedOutput; if (internal.status === 'remote_launched') { return ( @@ -352,10 +313,10 @@ export function renderToolResultMessage( - ) + ); } if (data.status === 'async_launched') { - const { prompt } = data + const { prompt } = data; return ( @@ -386,42 +347,32 @@ export function renderToolResultMessage( )} - ) + ); } if (data.status !== 'completed') { - return null + return null; } - const { - agentId, - totalDurationMs, - totalToolUseCount, - totalTokens, - usage, - content, - prompt, - } = data + const { agentId, totalDurationMs, totalToolUseCount, totalTokens, usage, content, prompt } = data; const result = [ totalToolUseCount === 1 ? '1 tool use' : `${totalToolUseCount} tool uses`, formatNumber(totalTokens) + ' tokens', formatDuration(totalDurationMs), - ] + ]; - const completionMessage = `Done (${result.join(' · ')})` + const completionMessage = `Done (${result.join(' · ')})`; const finalAssistantMessage = createAssistantMessage({ content: completionMessage, usage: { ...usage, inference_geo: null, iterations: null, speed: null }, - }) + }); return ( {process.env.USER_TYPE === 'ant' && ( - - [ANT-ONLY] API calls: {getDisplayPath(getDumpPromptsPath(agentId))} - + [ANT-ONLY] API calls: {getDisplayPath(getDumpPromptsPath(agentId))} )} {isTranscriptMode && prompt && ( @@ -431,11 +382,7 @@ export function renderToolResultMessage( )} {isTranscriptMode ? ( - + ) : null} {isTranscriptMode && content && content.length > 0 && ( @@ -466,52 +413,52 @@ export function renderToolResultMessage( )} - ) + ); } export function renderToolUseMessage({ description, prompt, }: Partial<{ - description: string - prompt: string + description: string; + prompt: string; }>): React.ReactNode { if (!description || !prompt) { - return null + return null; } - return description + return description; } export function renderToolUseTag( input: Partial<{ - description: string - prompt: string - subagent_type: string - model?: ModelAlias + description: string; + prompt: string; + subagent_type: string; + model?: ModelAlias; }>, ): React.ReactNode { - const tags: React.ReactNode[] = [] + const tags: React.ReactNode[] = []; if (input.model) { - const mainModel = getMainLoopModel() - const agentModel = parseUserSpecifiedModel(input.model) + const mainModel = getMainLoopModel(); + const agentModel = parseUserSpecifiedModel(input.model); if (agentModel !== mainModel) { tags.push( {renderModelName(agentModel)} , - ) + ); } } if (tags.length === 0) { - return null + return null; } - return <>{tags} + return <>{tags}; } -const INITIALIZING_TEXT = 'Initializing…' +const INITIALIZING_TEXT = 'Initializing…'; export function renderToolUseProgressMessage( progressMessages: ProgressMessage[], @@ -522,11 +469,11 @@ export function renderToolUseProgressMessage( inProgressToolCallCount, isTranscriptMode = false, }: { - tools: Tools - verbose: boolean - terminalSize?: { columns: number; rows: number } - inProgressToolCallCount?: number - isTranscriptMode?: boolean + tools: Tools; + verbose: boolean; + terminalSize?: { columns: number; rows: number }; + inProgressToolCallCount?: number; + isTranscriptMode?: boolean; }, ): React.ReactNode { if (!progressMessages.length) { @@ -534,57 +481,49 @@ export function renderToolUseProgressMessage( {INITIALIZING_TEXT} - ) + ); } // Checks to see if we should show a super condensed progress message summary. // This prevents flickers when the terminal size is too small to render all the dynamic content - const toolToolRenderLinesEstimate = - (inProgressToolCallCount ?? 1) * ESTIMATED_LINES_PER_TOOL + - TERMINAL_BUFFER_LINES + const toolToolRenderLinesEstimate = (inProgressToolCallCount ?? 1) * ESTIMATED_LINES_PER_TOOL + TERMINAL_BUFFER_LINES; const shouldUseCondensedMode = - !isTranscriptMode && - terminalSize && - terminalSize.rows && - terminalSize.rows < toolToolRenderLinesEstimate + !isTranscriptMode && terminalSize && terminalSize.rows && terminalSize.rows < toolToolRenderLinesEstimate; const getProgressStats = () => { const toolUseCount = count(progressMessages, msg => { if (!hasProgressMessage(msg.data)) { - return false + return false; } - const message = msg.data.message - return message.message.content.some( - content => content.type === 'tool_use', - ) - }) + const message = msg.data.message; + return message.message.content.some(content => content.type === 'tool_use'); + }); const latestAssistant = progressMessages.findLast( (msg): msg is ProgressMessage => hasProgressMessage(msg.data) && msg.data.message.type === 'assistant', - ) + ); - let tokens = null + let tokens = null; if (latestAssistant?.data.message.type === 'assistant') { - const usage = latestAssistant.data.message.message.usage + const usage = latestAssistant.data.message.message.usage; tokens = (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + usage.input_tokens + - usage.output_tokens + usage.output_tokens; } - return { toolUseCount, tokens } - } + return { toolUseCount, tokens }; + }; if (shouldUseCondensedMode) { - const { toolUseCount, tokens } = getProgressStats() + const { toolUseCount, tokens } = getProgressStats(); return ( - In progress… · {toolUseCount} tool{' '} - {toolUseCount === 1 ? 'use' : 'uses'} + In progress… · {toolUseCount} tool {toolUseCount === 1 ? 'use' : 'uses'} {tokens && ` · ${formatNumber(tokens)} tokens`} ·{' '} - ) + ); } // Process messages to group consecutive search/read operations into summaries (ants only) // isAgentRunning=true since this is the progress view while the agent is still running - const processedMessages = processProgressMessages( - progressMessages, - tools, - true, - ) + const processedMessages = processProgressMessages(progressMessages, tools, true); // For display, take the last few processed messages const displayedMessages = isTranscriptMode ? processedMessages - : processedMessages.slice(-MAX_PROGRESS_MESSAGES_TO_SHOW) + : processedMessages.slice(-MAX_PROGRESS_MESSAGES_TO_SHOW); // Count hidden tool uses specifically (not all messages) to match the // final "Done (N tool uses)" count. Each tool use generates multiple @@ -617,26 +552,20 @@ export function renderToolUseProgressMessage( // hidden messages inflates the number shown to the user. const hiddenMessages = isTranscriptMode ? [] - : processedMessages.slice( - 0, - Math.max(0, processedMessages.length - MAX_PROGRESS_MESSAGES_TO_SHOW), - ) + : processedMessages.slice(0, Math.max(0, processedMessages.length - MAX_PROGRESS_MESSAGES_TO_SHOW)); const hiddenToolUseCount = count(hiddenMessages, m => { if (m.type === 'summary') { - return m.searchCount + m.readCount + m.replCount > 0 + return m.searchCount + m.readCount + m.replCount > 0; } - const data = m.message.data + const data = m.message.data; if (!hasProgressMessage(data)) { - return false + return false; } - return data.message.message.content.some( - content => content.type === 'tool_use', - ) - }) + return data.message.message.content.some(content => content.type === 'tool_use'); + }); - const firstData = progressMessages[0]?.data - const prompt = - firstData && hasProgressMessage(firstData) ? firstData.prompt : undefined + const firstData = progressMessages[0]?.data; + const prompt = firstData && hasProgressMessage(firstData) ? firstData.prompt : undefined; // After grouping, displayedMessages can be empty when the only progress so // far is an assistant tool_use for a search/read op (grouped but not yet @@ -647,19 +576,14 @@ export function renderToolUseProgressMessage( {INITIALIZING_TEXT} - ) + ); } - const { - lookups: subagentLookups, - inProgressToolUseIDs: collapsedInProgressIDs, - } = buildSubagentLookups( + const { lookups: subagentLookups, inProgressToolUseIDs: collapsedInProgressIDs } = buildSubagentLookups( progressMessages - .filter((pm): pm is ProgressMessage => - hasProgressMessage(pm.data), - ) + .filter((pm): pm is ProgressMessage => hasProgressMessage(pm.data)) .map(pm => pm.data), - ) + ); return ( @@ -678,12 +602,12 @@ export function renderToolUseProgressMessage( processed.readCount, processed.isActive, processed.replCount, - ) + ); return ( {summaryText} - ) + ); } // Render original message without height=1 wrapper so null // content (tool not found, renderToolUseMessage returns null) @@ -706,18 +630,17 @@ export function renderToolUseProgressMessage( isTranscriptMode={false} isStatic={true} /> - ) + ); })} {hiddenToolUseCount > 0 && ( - +{hiddenToolUseCount} more tool{' '} - {hiddenToolUseCount === 1 ? 'use' : 'uses'} + +{hiddenToolUseCount} more tool {hiddenToolUseCount === 1 ? 'use' : 'uses'} )} - ) + ); } export function renderToolUseRejectedMessage( @@ -728,28 +651,25 @@ export function renderToolUseRejectedMessage( verbose, isTranscriptMode, }: { - columns: number - messages: Message[] - style?: 'condensed' - theme: ThemeName - progressMessagesForMessage: ProgressMessage[] - tools: Tools - verbose: boolean - isTranscriptMode?: boolean + columns: number; + messages: Message[]; + style?: 'condensed'; + theme: ThemeName; + progressMessagesForMessage: ProgressMessage[]; + tools: Tools; + verbose: boolean; + isTranscriptMode?: boolean; }, ): React.ReactNode { // Get agentId from progress messages if available (agent was running before rejection) - const firstData = progressMessagesForMessage[0]?.data - const agentId = - firstData && hasProgressMessage(firstData) ? firstData.agentId : undefined + const firstData = progressMessagesForMessage[0]?.data; + const agentId = firstData && hasProgressMessage(firstData) ? firstData.agentId : undefined; return ( <> {process.env.USER_TYPE === 'ant' && agentId && ( - - [ANT-ONLY] API calls: {getDisplayPath(getDumpPromptsPath(agentId))} - + [ANT-ONLY] API calls: {getDisplayPath(getDumpPromptsPath(agentId))} )} {renderToolUseProgressMessage(progressMessagesForMessage, { @@ -759,7 +679,7 @@ export function renderToolUseRejectedMessage( })} - ) + ); } export function renderToolUseErrorMessage( @@ -770,10 +690,10 @@ export function renderToolUseErrorMessage( verbose, isTranscriptMode, }: { - progressMessagesForMessage: ProgressMessage[] - tools: Tools - verbose: boolean - isTranscriptMode?: boolean + progressMessagesForMessage: ProgressMessage[]; + tools: Tools; + verbose: boolean; + isTranscriptMode?: boolean; }, ): React.ReactNode { return ( @@ -785,159 +705,131 @@ export function renderToolUseErrorMessage( })} - ) + ); } function calculateAgentStats(progressMessages: ProgressMessage[]): { - toolUseCount: number - tokens: number | null + toolUseCount: number; + tokens: number | null; } { const toolUseCount = count(progressMessages, msg => { if (!hasProgressMessage(msg.data)) { - return false + return false; } - const message = msg.data.message - return ( - message.type === 'user' && - message.message.content.some(content => content.type === 'tool_result') - ) - }) + const message = msg.data.message; + return message.type === 'user' && message.message.content.some(content => content.type === 'tool_result'); + }); const latestAssistant = progressMessages.findLast( (msg): msg is ProgressMessage => hasProgressMessage(msg.data) && msg.data.message.type === 'assistant', - ) + ); - let tokens = null + let tokens = null; if (latestAssistant?.data.message.type === 'assistant') { - const usage = latestAssistant.data.message.message.usage + const usage = latestAssistant.data.message.message.usage; tokens = (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + usage.input_tokens + - usage.output_tokens + usage.output_tokens; } - return { toolUseCount, tokens } + return { toolUseCount, tokens }; } export function renderGroupedAgentToolUse( toolUses: Array<{ - param: ToolUseBlockParam - isResolved: boolean - isError: boolean - isInProgress: boolean - progressMessages: ProgressMessage[] + param: ToolUseBlockParam; + isResolved: boolean; + isError: boolean; + isInProgress: boolean; + progressMessages: ProgressMessage[]; result?: { - param: ToolResultBlockParam - output: Output - } + param: ToolResultBlockParam; + output: Output; + }; }>, options: { - shouldAnimate: boolean - tools: Tools + shouldAnimate: boolean; + tools: Tools; }, ): React.ReactNode | null { - const { shouldAnimate, tools } = options + const { shouldAnimate, tools } = options; // Calculate stats for each agent - const agentStats = toolUses.map( - ({ param, isResolved, isError, progressMessages, result }) => { - const stats = calculateAgentStats(progressMessages) - const lastToolInfo = extractLastToolInfo(progressMessages, tools) - const parsedInput = inputSchema().safeParse(param.input) - - // teammate_spawned is not part of the exported Output type (cast through unknown - // for dead code elimination), so check via string comparison on the raw value - const isTeammateSpawn = - (result?.output?.status as string) === 'teammate_spawned' - - // For teammate spawns, show @name with type in parens and description as status - let agentType: string - let description: string | undefined - let color: keyof Theme | undefined - let descriptionColor: keyof Theme | undefined - let taskDescription: string | undefined - if (isTeammateSpawn && parsedInput.success && parsedInput.data.name) { - agentType = `@${parsedInput.data.name}` - const subagentType = parsedInput.data.subagent_type - description = isCustomSubagentType(subagentType) - ? subagentType - : undefined - taskDescription = parsedInput.data.description - // Use the custom agent definition's color on the type, not the name - descriptionColor = isCustomSubagentType(subagentType) - ? (getAgentColor(subagentType) as keyof Theme | undefined) - : undefined - } else { - agentType = parsedInput.success - ? userFacingName(parsedInput.data) - : 'Agent' - description = parsedInput.success - ? parsedInput.data.description - : undefined - color = parsedInput.success - ? userFacingNameBackgroundColor(parsedInput.data) - : undefined - taskDescription = undefined - } - - // Check if this was launched as a background agent OR backgrounded mid-execution - const launchedAsAsync = - parsedInput.success && - 'run_in_background' in parsedInput.data && - parsedInput.data.run_in_background === true - const outputStatus = (result?.output as { status?: string } | undefined) - ?.status - const backgroundedMidExecution = - outputStatus === 'async_launched' || outputStatus === 'remote_launched' - const isAsync = - launchedAsAsync || backgroundedMidExecution || isTeammateSpawn - - const name = parsedInput.success ? parsedInput.data.name : undefined - - return { - id: param.id, - agentType, - description, - toolUseCount: stats.toolUseCount, - tokens: stats.tokens, - isResolved, - isError, - isAsync, - color, - descriptionColor, - lastToolInfo, - taskDescription, - name, - } - }, - ) + const agentStats = toolUses.map(({ param, isResolved, isError, progressMessages, result }) => { + const stats = calculateAgentStats(progressMessages); + const lastToolInfo = extractLastToolInfo(progressMessages, tools); + const parsedInput = inputSchema().safeParse(param.input); + + // teammate_spawned is not part of the exported Output type (cast through unknown + // for dead code elimination), so check via string comparison on the raw value + const isTeammateSpawn = (result?.output?.status as string) === 'teammate_spawned'; + + // For teammate spawns, show @name with type in parens and description as status + let agentType: string; + let description: string | undefined; + let color: keyof Theme | undefined; + let descriptionColor: keyof Theme | undefined; + let taskDescription: string | undefined; + if (isTeammateSpawn && parsedInput.success && parsedInput.data.name) { + agentType = `@${parsedInput.data.name}`; + const subagentType = parsedInput.data.subagent_type; + description = isCustomSubagentType(subagentType) ? subagentType : undefined; + taskDescription = parsedInput.data.description; + // Use the custom agent definition's color on the type, not the name + descriptionColor = isCustomSubagentType(subagentType) + ? (getAgentColor(subagentType) as keyof Theme | undefined) + : undefined; + } else { + agentType = parsedInput.success ? userFacingName(parsedInput.data) : 'Agent'; + description = parsedInput.success ? parsedInput.data.description : undefined; + color = parsedInput.success ? userFacingNameBackgroundColor(parsedInput.data) : undefined; + taskDescription = undefined; + } - const anyUnresolved = toolUses.some(t => !t.isResolved) - const anyError = toolUses.some(t => t.isError) - const allComplete = !anyUnresolved + // Check if this was launched as a background agent OR backgrounded mid-execution + const launchedAsAsync = + parsedInput.success && 'run_in_background' in parsedInput.data && parsedInput.data.run_in_background === true; + const outputStatus = (result?.output as { status?: string } | undefined)?.status; + const backgroundedMidExecution = outputStatus === 'async_launched' || outputStatus === 'remote_launched'; + const isAsync = launchedAsAsync || backgroundedMidExecution || isTeammateSpawn; + + const name = parsedInput.success ? parsedInput.data.name : undefined; + + return { + id: param.id, + agentType, + description, + toolUseCount: stats.toolUseCount, + tokens: stats.tokens, + isResolved, + isError, + isAsync, + color, + descriptionColor, + lastToolInfo, + taskDescription, + name, + }; + }); + + const anyUnresolved = toolUses.some(t => !t.isResolved); + const anyError = toolUses.some(t => t.isError); + const allComplete = !anyUnresolved; // Check if all agents are the same type - const allSameType = - agentStats.length > 0 && - agentStats.every(stat => stat.agentType === agentStats[0]?.agentType) - const commonType = - allSameType && agentStats[0]?.agentType !== 'Agent' - ? agentStats[0]?.agentType - : null + const allSameType = agentStats.length > 0 && agentStats.every(stat => stat.agentType === agentStats[0]?.agentType); + const commonType = allSameType && agentStats[0]?.agentType !== 'Agent' ? agentStats[0]?.agentType : null; // Check if all resolved agents are async (background) - const allAsync = agentStats.every(stat => stat.isAsync) + const allAsync = agentStats.every(stat => stat.isAsync); return ( - + {allComplete ? ( allAsync ? ( @@ -949,14 +841,12 @@ export function renderGroupedAgentToolUse( ) : ( <> - {toolUses.length}{' '} - {commonType ? `${commonType} agents` : 'agents'} finished + {toolUses.length} {commonType ? `${commonType} agents` : 'agents'} finished ) ) : ( <> - Running {toolUses.length}{' '} - {commonType ? `${commonType} agents` : 'agents'}… + Running {toolUses.length} {commonType ? `${commonType} agents` : 'agents'}… )}{' '} @@ -983,154 +873,129 @@ export function renderGroupedAgentToolUse( /> ))} - ) + ); } export function userFacingName( input: | Partial<{ - description: string - prompt: string - subagent_type: string - name: string - team_name: string + description: string; + prompt: string; + subagent_type: string; + name: string; + team_name: string; }> | undefined, ): string { - if ( - input?.subagent_type && - input.subagent_type !== GENERAL_PURPOSE_AGENT.agentType - ) { + if (input?.subagent_type && input.subagent_type !== GENERAL_PURPOSE_AGENT.agentType) { // Display "worker" agents as "Agent" for cleaner UI if (input.subagent_type === 'worker') { - return 'Agent' + return 'Agent'; } - return input.subagent_type + return input.subagent_type; } - return 'Agent' + return 'Agent'; } export function userFacingNameBackgroundColor( - input: - | Partial<{ description: string; prompt: string; subagent_type: string }> - | undefined, + input: Partial<{ description: string; prompt: string; subagent_type: string }> | undefined, ): keyof Theme | undefined { if (!input?.subagent_type) { - return undefined + return undefined; } // Get the color for this agent - return getAgentColor(input.subagent_type) as keyof Theme | undefined + return getAgentColor(input.subagent_type) as keyof Theme | undefined; } -export function extractLastToolInfo( - progressMessages: ProgressMessage[], - tools: Tools, -): string | null { +export function extractLastToolInfo(progressMessages: ProgressMessage[], tools: Tools): string | null { // Build tool_use lookup from all progress messages (needed for reverse iteration) - const toolUseByID = new Map() + const toolUseByID = new Map(); for (const pm of progressMessages) { if (!hasProgressMessage(pm.data)) { - continue + continue; } if (pm.data.message.type === 'assistant') { for (const c of pm.data.message.message.content) { if (c.type === 'tool_use') { - toolUseByID.set(c.id, c as ToolUseBlockParam) + toolUseByID.set(c.id, c as ToolUseBlockParam); } } } } // Count trailing consecutive search/read operations from the end - let searchCount = 0 - let readCount = 0 + let searchCount = 0; + let readCount = 0; for (let i = progressMessages.length - 1; i >= 0; i--) { - const msg = progressMessages[i]! + const msg = progressMessages[i]!; if (!hasProgressMessage(msg.data)) { - continue + continue; } - const info = getSearchOrReadInfo(msg, tools, toolUseByID) + const info = getSearchOrReadInfo(msg, tools, toolUseByID); if (info && (info.isSearch || info.isRead)) { // Only count tool_result messages to avoid double counting if (msg.data.message.type === 'user') { if (info.isSearch) { - searchCount++ + searchCount++; } else if (info.isRead) { - readCount++ + readCount++; } } } else { - break + break; } } if (searchCount + readCount >= 2) { - return getSearchReadSummaryText(searchCount, readCount, true) + return getSearchReadSummaryText(searchCount, readCount, true); } // Find the last tool_result message - const lastToolResult = progressMessages.findLast( - (msg): msg is ProgressMessage => { - if (!hasProgressMessage(msg.data)) { - return false - } - const message = msg.data.message - return ( - message.type === 'user' && - message.message.content.some(c => c.type === 'tool_result') - ) - }, - ) + const lastToolResult = progressMessages.findLast((msg): msg is ProgressMessage => { + if (!hasProgressMessage(msg.data)) { + return false; + } + const message = msg.data.message; + return message.type === 'user' && message.message.content.some(c => c.type === 'tool_result'); + }); if (lastToolResult?.data.message.type === 'user') { - const toolResultBlock = lastToolResult.data.message.message.content.find( - c => c.type === 'tool_result', - ) + const toolResultBlock = lastToolResult.data.message.message.content.find(c => c.type === 'tool_result'); if (toolResultBlock?.type === 'tool_result') { // Look up the corresponding tool_use — already indexed above - const toolUseBlock = toolUseByID.get(toolResultBlock.tool_use_id) + const toolUseBlock = toolUseByID.get(toolResultBlock.tool_use_id); if (toolUseBlock) { - const tool = findToolByName(tools, toolUseBlock.name) + const tool = findToolByName(tools, toolUseBlock.name); if (!tool) { - return toolUseBlock.name // Fallback to raw name + return toolUseBlock.name; // Fallback to raw name } - const input = toolUseBlock.input as Record - const parsedInput = tool.inputSchema.safeParse(input) + const input = toolUseBlock.input as Record; + const parsedInput = tool.inputSchema.safeParse(input); // Get user-facing tool name - const userFacingToolName = tool.userFacingName( - parsedInput.success ? parsedInput.data : undefined, - ) + const userFacingToolName = tool.userFacingName(parsedInput.success ? parsedInput.data : undefined); // Try to get summary from the tool itself if (tool.getToolUseSummary) { - const summary = tool.getToolUseSummary( - parsedInput.success ? parsedInput.data : undefined, - ) + const summary = tool.getToolUseSummary(parsedInput.success ? parsedInput.data : undefined); if (summary) { - return `${userFacingToolName}: ${summary}` + return `${userFacingToolName}: ${summary}`; } } // Default: just show user-facing tool name - return userFacingToolName + return userFacingToolName; } } } - return null + return null; } -function isCustomSubagentType( - subagentType: string | undefined, -): subagentType is string { - return ( - !!subagentType && - subagentType !== GENERAL_PURPOSE_AGENT.agentType && - subagentType !== 'worker' - ) +function isCustomSubagentType(subagentType: string | undefined): subagentType is string { + return !!subagentType && subagentType !== GENERAL_PURPOSE_AGENT.agentType && subagentType !== 'worker'; } diff --git a/src/tools/AgentTool/__tests__/agentDisplay.test.ts b/src/tools/AgentTool/__tests__/agentDisplay.test.ts index 1a9e45c46..c663a39bd 100644 --- a/src/tools/AgentTool/__tests__/agentDisplay.test.ts +++ b/src/tools/AgentTool/__tests__/agentDisplay.test.ts @@ -1,136 +1,131 @@ -import { mock, describe, expect, test } from "bun:test"; +import { mock, describe, expect, test } from 'bun:test' // Mock heavy deps -mock.module("../../utils/model/agent.js", () => ({ +mock.module('../../utils/model/agent.js', () => ({ getDefaultSubagentModel: () => undefined, -})); +})) -mock.module("../../utils/settings/constants.js", () => ({ +mock.module('../../utils/settings/constants.js', () => ({ getSourceDisplayName: (source: string) => source, -})); +})) -const { - resolveAgentOverrides, - compareAgentsByName, - AGENT_SOURCE_GROUPS, -} = await import("../agentDisplay"); +const { resolveAgentOverrides, compareAgentsByName, AGENT_SOURCE_GROUPS } = + await import('../agentDisplay') function makeAgent(agentType: string, source: string): any { - return { agentType, source, name: agentType }; + return { agentType, source, name: agentType } } -describe("resolveAgentOverrides", () => { - test("marks no overrides when all agents active", () => { - const agents = [makeAgent("builder", "userSettings")]; - const result = resolveAgentOverrides(agents, agents); - expect(result).toHaveLength(1); - expect(result[0].overriddenBy).toBeUndefined(); - }); +describe('resolveAgentOverrides', () => { + test('marks no overrides when all agents active', () => { + const agents = [makeAgent('builder', 'userSettings')] + const result = resolveAgentOverrides(agents, agents) + expect(result).toHaveLength(1) + expect(result[0].overriddenBy).toBeUndefined() + }) - test("marks inactive agent as overridden", () => { + test('marks inactive agent as overridden', () => { const allAgents = [ - makeAgent("builder", "projectSettings"), - makeAgent("builder", "userSettings"), - ]; - const activeAgents = [makeAgent("builder", "userSettings")]; - const result = resolveAgentOverrides(allAgents, activeAgents); - const projectAgent = result.find( - (a: any) => a.source === "projectSettings", - ); - expect(projectAgent?.overriddenBy).toBe("userSettings"); - }); - - test("overriddenBy shows the overriding agent source", () => { - const allAgents = [makeAgent("tester", "localSettings")]; - const activeAgents = [makeAgent("tester", "policySettings")]; - const result = resolveAgentOverrides(allAgents, activeAgents); - expect(result[0].overriddenBy).toBe("policySettings"); - }); - - test("deduplicates agents by (agentType, source)", () => { + makeAgent('builder', 'projectSettings'), + makeAgent('builder', 'userSettings'), + ] + const activeAgents = [makeAgent('builder', 'userSettings')] + const result = resolveAgentOverrides(allAgents, activeAgents) + const projectAgent = result.find((a: any) => a.source === 'projectSettings') + expect(projectAgent?.overriddenBy).toBe('userSettings') + }) + + test('overriddenBy shows the overriding agent source', () => { + const allAgents = [makeAgent('tester', 'localSettings')] + const activeAgents = [makeAgent('tester', 'policySettings')] + const result = resolveAgentOverrides(allAgents, activeAgents) + expect(result[0].overriddenBy).toBe('policySettings') + }) + + test('deduplicates agents by (agentType, source)', () => { const agents = [ - makeAgent("builder", "userSettings"), - makeAgent("builder", "userSettings"), // duplicate - ]; - const result = resolveAgentOverrides(agents, agents.slice(0, 1)); - expect(result).toHaveLength(1); - }); - - test("preserves agent definition properties", () => { - const agents = [{ agentType: "a", source: "userSettings", name: "Agent A" }]; - const result = resolveAgentOverrides(agents, agents); - expect(result[0].name).toBe("Agent A"); - expect(result[0].agentType).toBe("a"); - }); - - test("handles empty arrays", () => { - expect(resolveAgentOverrides([], [])).toEqual([]); - }); - - test("handles agent from git worktree (duplicate detection)", () => { + makeAgent('builder', 'userSettings'), + makeAgent('builder', 'userSettings'), // duplicate + ] + const result = resolveAgentOverrides(agents, agents.slice(0, 1)) + expect(result).toHaveLength(1) + }) + + test('preserves agent definition properties', () => { + const agents = [{ agentType: 'a', source: 'userSettings', name: 'Agent A' }] + const result = resolveAgentOverrides(agents, agents) + expect(result[0].name).toBe('Agent A') + expect(result[0].agentType).toBe('a') + }) + + test('handles empty arrays', () => { + expect(resolveAgentOverrides([], [])).toEqual([]) + }) + + test('handles agent from git worktree (duplicate detection)', () => { const agents = [ - makeAgent("builder", "projectSettings"), - makeAgent("builder", "projectSettings"), - makeAgent("builder", "localSettings"), - ]; - const result = resolveAgentOverrides(agents, agents.slice(0, 1)); + makeAgent('builder', 'projectSettings'), + makeAgent('builder', 'projectSettings'), + makeAgent('builder', 'localSettings'), + ] + const result = resolveAgentOverrides(agents, agents.slice(0, 1)) // Deduped: projectSettings appears once, localSettings once - expect(result).toHaveLength(2); - }); -}); - -describe("compareAgentsByName", () => { - test("sorts alphabetically ascending", () => { - const a = makeAgent("alpha", "userSettings"); - const b = makeAgent("beta", "userSettings"); - expect(compareAgentsByName(a, b)).toBeLessThan(0); - }); - - test("returns negative when a.name < b.name", () => { - const a = makeAgent("a", "s"); - const b = makeAgent("b", "s"); - expect(compareAgentsByName(a, b)).toBeLessThan(0); - }); - - test("returns positive when a.name > b.name", () => { - const a = makeAgent("z", "s"); - const b = makeAgent("a", "s"); - expect(compareAgentsByName(a, b)).toBeGreaterThan(0); - }); - - test("returns 0 for same name", () => { - const a = makeAgent("same", "s"); - const b = makeAgent("same", "s"); - expect(compareAgentsByName(a, b)).toBe(0); - }); - - test("is case-insensitive (sensitivity: base)", () => { - const a = makeAgent("Alpha", "s"); - const b = makeAgent("alpha", "s"); - expect(compareAgentsByName(a, b)).toBe(0); - }); -}); - -describe("AGENT_SOURCE_GROUPS", () => { - test("contains expected source groups in order", () => { - expect(AGENT_SOURCE_GROUPS).toHaveLength(7); + expect(result).toHaveLength(2) + }) +}) + +describe('compareAgentsByName', () => { + test('sorts alphabetically ascending', () => { + const a = makeAgent('alpha', 'userSettings') + const b = makeAgent('beta', 'userSettings') + expect(compareAgentsByName(a, b)).toBeLessThan(0) + }) + + test('returns negative when a.name < b.name', () => { + const a = makeAgent('a', 's') + const b = makeAgent('b', 's') + expect(compareAgentsByName(a, b)).toBeLessThan(0) + }) + + test('returns positive when a.name > b.name', () => { + const a = makeAgent('z', 's') + const b = makeAgent('a', 's') + expect(compareAgentsByName(a, b)).toBeGreaterThan(0) + }) + + test('returns 0 for same name', () => { + const a = makeAgent('same', 's') + const b = makeAgent('same', 's') + expect(compareAgentsByName(a, b)).toBe(0) + }) + + test('is case-insensitive (sensitivity: base)', () => { + const a = makeAgent('Alpha', 's') + const b = makeAgent('alpha', 's') + expect(compareAgentsByName(a, b)).toBe(0) + }) +}) + +describe('AGENT_SOURCE_GROUPS', () => { + test('contains expected source groups in order', () => { + expect(AGENT_SOURCE_GROUPS).toHaveLength(7) expect(AGENT_SOURCE_GROUPS[0]).toEqual({ - label: "User agents", - source: "userSettings", - }); + label: 'User agents', + source: 'userSettings', + }) expect(AGENT_SOURCE_GROUPS[6]).toEqual({ - label: "Built-in agents", - source: "built-in", - }); - }); - - test("has unique labels", () => { - const labels = AGENT_SOURCE_GROUPS.map((g) => g.label); - expect(new Set(labels).size).toBe(labels.length); - }); - - test("has unique sources", () => { - const sources = AGENT_SOURCE_GROUPS.map((g) => g.source); - expect(new Set(sources).size).toBe(sources.length); - }); -}); + label: 'Built-in agents', + source: 'built-in', + }) + }) + + test('has unique labels', () => { + const labels = AGENT_SOURCE_GROUPS.map(g => g.label) + expect(new Set(labels).size).toBe(labels.length) + }) + + test('has unique sources', () => { + const sources = AGENT_SOURCE_GROUPS.map(g => g.source) + expect(new Set(sources).size).toBe(sources.length) + }) +}) diff --git a/src/tools/AgentTool/__tests__/agentToolUtils.test.ts b/src/tools/AgentTool/__tests__/agentToolUtils.test.ts index d0335b387..446cac320 100644 --- a/src/tools/AgentTool/__tests__/agentToolUtils.test.ts +++ b/src/tools/AgentTool/__tests__/agentToolUtils.test.ts @@ -1,68 +1,71 @@ -import { mock, describe, expect, test } from "bun:test"; +import { mock, describe, expect, test } from 'bun:test' // ─── Mocks for agentToolUtils.ts dependencies ─── // Only mock modules that are truly unavailable or cause side effects. // Do NOT mock common/shared modules (zod/v4, bootstrap/state, etc.) to avoid // corrupting the module cache for other test files in the same Bun process. -const noop = () => {}; +const noop = () => {} -mock.module("bun:bundle", () => ({ feature: () => false })); +mock.module('bun:bundle', () => ({ feature: () => false })) -mock.module("src/constants/tools.js", () => ({ +mock.module('src/constants/tools.js', () => ({ ALL_AGENT_DISALLOWED_TOOLS: new Set(), ASYNC_AGENT_ALLOWED_TOOLS: new Set(), CUSTOM_AGENT_DISALLOWED_TOOLS: new Set(), IN_PROCESS_TEAMMATE_ALLOWED_TOOLS: new Set(), -})); +})) -mock.module("src/services/AgentSummary/agentSummary.js", () => ({ +mock.module('src/services/AgentSummary/agentSummary.js', () => ({ startAgentSummarization: noop, -})); +})) -mock.module("src/services/analytics/index.js", () => ({ +mock.module('src/services/analytics/index.js', () => ({ logEvent: noop, logEventAsync: async () => {}, stripProtoFields: (v: any) => v, attachAnalyticsSink: noop, _resetForTesting: noop, AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS: undefined, -})); +})) -mock.module("src/services/api/dumpPrompts.js", () => ({ +mock.module('src/services/api/dumpPrompts.js', () => ({ clearDumpState: noop, -})); +})) -mock.module("src/Tool.js", () => ({ +mock.module('src/Tool.js', () => ({ toolMatchesName: () => false, findToolByName: noop, -})); +})) // messages.ts is complex - provide stubs for all named exports -mock.module("src/utils/messages.ts", () => ({ +mock.module('src/utils/messages.ts', () => ({ extractTextContent: (content: any[]) => - content?.filter?.((b: any) => b.type === "text")?.map?.((b: any) => b.text)?.join("") ?? "", + content + ?.filter?.((b: any) => b.type === 'text') + ?.map?.((b: any) => b.text) + ?.join('') ?? '', getLastAssistantMessage: () => null, SYNTHETIC_MESSAGES: new Set(), - INTERRUPT_MESSAGE: "", - INTERRUPT_MESSAGE_FOR_TOOL_USE: "", - CANCEL_MESSAGE: "", - REJECT_MESSAGE: "", - REJECT_MESSAGE_WITH_REASON_PREFIX: "", - SUBAGENT_REJECT_MESSAGE: "", - SUBAGENT_REJECT_MESSAGE_WITH_REASON_PREFIX: "", - PLAN_REJECTION_PREFIX: "", - DENIAL_WORKAROUND_GUIDANCE: "", - NO_RESPONSE_REQUESTED: "", - SYNTHETIC_TOOL_RESULT_PLACEHOLDER: "", - SYNTHETIC_MODEL: "", + INTERRUPT_MESSAGE: '', + INTERRUPT_MESSAGE_FOR_TOOL_USE: '', + CANCEL_MESSAGE: '', + REJECT_MESSAGE: '', + REJECT_MESSAGE_WITH_REASON_PREFIX: '', + SUBAGENT_REJECT_MESSAGE: '', + SUBAGENT_REJECT_MESSAGE_WITH_REASON_PREFIX: '', + PLAN_REJECTION_PREFIX: '', + DENIAL_WORKAROUND_GUIDANCE: '', + NO_RESPONSE_REQUESTED: '', + SYNTHETIC_TOOL_RESULT_PLACEHOLDER: '', + SYNTHETIC_MODEL: '', AUTO_REJECT_MESSAGE: noop, DONT_ASK_REJECT_MESSAGE: noop, withMemoryCorrectionHint: (s: string) => s, - deriveShortMessageId: () => "", + deriveShortMessageId: () => '', isClassifierDenial: () => false, - buildYoloRejectionMessage: () => "", - buildClassifierUnavailableMessage: () => "", + buildYoloRejectionMessage: () => '', + buildClassifierUnavailableMessage: () => '', isEmptyMessageText: () => true, createAssistantMessage: noop, createAssistantAPIErrorMessage: noop, @@ -71,9 +74,9 @@ mock.module("src/utils/messages.ts", () => ({ createUserInterruptionMessage: noop, createSyntheticUserCaveatMessage: noop, formatCommandInputTags: noop, -})); +})) -mock.module("src/tasks/LocalAgentTask/LocalAgentTask.js", () => ({ +mock.module('src/tasks/LocalAgentTask/LocalAgentTask.js', () => ({ completeAgentTask: noop, createActivityDescriptionResolver: () => ({}), createProgressTracker: () => ({}), @@ -85,10 +88,10 @@ mock.module("src/tasks/LocalAgentTask/LocalAgentTask.js", () => ({ killAsyncAgent: noop, updateAgentProgress: noop, updateProgressFromMessage: noop, -})); +})) -mock.module("src/utils/debug.js", () => ({ - getMinDebugLogLevel: () => "warn", +mock.module('src/utils/debug.js', () => ({ + getMinDebugLogLevel: () => 'warn', isDebugMode: () => false, enableDebugLogging: () => false, getDebugFilter: () => null, @@ -98,11 +101,11 @@ mock.module("src/utils/debug.js", () => ({ getHasFormattedOutput: () => false, flushDebugLogs: async () => {}, logForDebugging: noop, - getDebugLogPath: () => "", + getDebugLogPath: () => '', logAntError: noop, -})); +})) -mock.module("src/utils/errors.js", () => ({ +mock.module('src/utils/errors.js', () => ({ ClaudeError: class extends Error {}, MalformedCommandError: class extends Error {}, AbortError: class extends Error {}, @@ -112,142 +115,137 @@ mock.module("src/utils/errors.js", () => ({ TelemetrySafeError_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS: class extends Error {}, isAbortError: () => false, hasExactErrorMessage: () => false, - toError: (e: any) => e instanceof Error ? e : new Error(String(e)), + toError: (e: any) => (e instanceof Error ? e : new Error(String(e))), errorMessage: (e: any) => String(e), getErrnoCode: () => undefined, isENOENT: () => false, getErrnoPath: () => undefined, - shortErrorStack: () => "", + shortErrorStack: () => '', isFsInaccessible: () => false, - classifyAxiosError: () => ({ category: "unknown" }), -})); + classifyAxiosError: () => ({ category: 'unknown' }), +})) -mock.module("src/utils/forkedAgent.js", () => ({})); +mock.module('src/utils/forkedAgent.js', () => ({})) -mock.module("src/utils/permissions/yoloClassifier.js", () => ({ - buildTranscriptForClassifier: () => "", +mock.module('src/utils/permissions/yoloClassifier.js', () => ({ + buildTranscriptForClassifier: () => '', classifyYoloAction: () => null, -})); +})) -mock.module("src/utils/task/sdkProgress.js", () => ({ +mock.module('src/utils/task/sdkProgress.js', () => ({ emitTaskProgress: noop, -})); +})) -mock.module("src/utils/tokens.js", () => ({ +mock.module('src/utils/tokens.js', () => ({ getTokenCountFromUsage: () => 0, -})); +})) -mock.module("src/tools/ExitPlanModeTool/constants.js", () => ({ - EXIT_PLAN_MODE_V2_TOOL_NAME: "exit_plan_mode", -})); +mock.module('src/tools/ExitPlanModeTool/constants.js', () => ({ + EXIT_PLAN_MODE_V2_TOOL_NAME: 'exit_plan_mode', +})) -mock.module("src/tools/AgentTool/constants.js", () => ({ - AGENT_TOOL_NAME: "agent", - LEGACY_AGENT_TOOL_NAME: "task", -})); +mock.module('src/tools/AgentTool/constants.js', () => ({ + AGENT_TOOL_NAME: 'agent', + LEGACY_AGENT_TOOL_NAME: 'task', +})) -mock.module("src/tools/AgentTool/loadAgentsDir.js", () => ({})); +mock.module('src/tools/AgentTool/loadAgentsDir.js', () => ({})) -mock.module("src/state/AppState.js", () => ({})); +mock.module('src/state/AppState.js', () => ({})) -mock.module("src/types/ids.js", () => ({ +mock.module('src/types/ids.js', () => ({ asAgentId: (id: string) => id, -})); +})) // Break circular dep -mock.module("src/tools/AgentTool/AgentTool.tsx", () => ({ +mock.module('src/tools/AgentTool/AgentTool.tsx', () => ({ AgentTool: {}, inputSchema: {}, outputSchema: {}, default: {}, -})); +})) -const { - countToolUses, - getLastToolUseName, -} = await import("../agentToolUtils"); +const { countToolUses, getLastToolUseName } = await import('../agentToolUtils') function makeAssistantMessage(content: any[]): any { - return { type: "assistant", message: { content } }; + return { type: 'assistant', message: { content } } } function makeUserMessage(text: string): any { - return { type: "user", message: { content: text } }; + return { type: 'user', message: { content: text } } } -describe("countToolUses", () => { - test("counts tool_use blocks in messages", () => { +describe('countToolUses', () => { + test('counts tool_use blocks in messages', () => { const messages = [ makeAssistantMessage([ - { type: "tool_use", name: "Read" }, - { type: "text", text: "hello" }, + { type: 'tool_use', name: 'Read' }, + { type: 'text', text: 'hello' }, ]), - ]; - expect(countToolUses(messages)).toBe(1); - }); + ] + expect(countToolUses(messages)).toBe(1) + }) - test("returns 0 for messages without tool_use", () => { - const messages = [ - makeAssistantMessage([{ type: "text", text: "hello" }]), - ]; - expect(countToolUses(messages)).toBe(0); - }); + test('returns 0 for messages without tool_use', () => { + const messages = [makeAssistantMessage([{ type: 'text', text: 'hello' }])] + expect(countToolUses(messages)).toBe(0) + }) - test("returns 0 for empty array", () => { - expect(countToolUses([])).toBe(0); - }); + test('returns 0 for empty array', () => { + expect(countToolUses([])).toBe(0) + }) - test("counts multiple tool_use blocks across messages", () => { + test('counts multiple tool_use blocks across messages', () => { const messages = [ - makeAssistantMessage([{ type: "tool_use", name: "Read" }]), - makeUserMessage("ok"), - makeAssistantMessage([{ type: "tool_use", name: "Write" }]), - ]; - expect(countToolUses(messages)).toBe(2); - }); - - test("counts tool_use in single message with multiple blocks", () => { + makeAssistantMessage([{ type: 'tool_use', name: 'Read' }]), + makeUserMessage('ok'), + makeAssistantMessage([{ type: 'tool_use', name: 'Write' }]), + ] + expect(countToolUses(messages)).toBe(2) + }) + + test('counts tool_use in single message with multiple blocks', () => { const messages = [ makeAssistantMessage([ - { type: "tool_use", name: "Read" }, - { type: "tool_use", name: "Grep" }, - { type: "tool_use", name: "Write" }, + { type: 'tool_use', name: 'Read' }, + { type: 'tool_use', name: 'Grep' }, + { type: 'tool_use', name: 'Write' }, ]), - ]; - expect(countToolUses(messages)).toBe(3); - }); -}); + ] + expect(countToolUses(messages)).toBe(3) + }) +}) -describe("getLastToolUseName", () => { - test("returns last tool name from assistant message", () => { +describe('getLastToolUseName', () => { + test('returns last tool name from assistant message', () => { const msg = makeAssistantMessage([ - { type: "tool_use", name: "Read" }, - { type: "tool_use", name: "Write" }, - ]); - expect(getLastToolUseName(msg)).toBe("Write"); - }); - - test("returns undefined for message without tool_use", () => { - const msg = makeAssistantMessage([{ type: "text", text: "hello" }]); - expect(getLastToolUseName(msg)).toBeUndefined(); - }); - - test("returns the last tool when multiple tool_uses present", () => { + { type: 'tool_use', name: 'Read' }, + { type: 'tool_use', name: 'Write' }, + ]) + expect(getLastToolUseName(msg)).toBe('Write') + }) + + test('returns undefined for message without tool_use', () => { + const msg = makeAssistantMessage([{ type: 'text', text: 'hello' }]) + expect(getLastToolUseName(msg)).toBeUndefined() + }) + + test('returns the last tool when multiple tool_uses present', () => { const msg = makeAssistantMessage([ - { type: "tool_use", name: "Read" }, - { type: "tool_use", name: "Grep" }, - { type: "tool_use", name: "Edit" }, - ]); - expect(getLastToolUseName(msg)).toBe("Edit"); - }); - - test("returns undefined for non-assistant message", () => { - const msg = makeUserMessage("hello"); - expect(getLastToolUseName(msg)).toBeUndefined(); - }); - - test("handles message with null content", () => { - const msg = { type: "assistant", message: { content: null } }; - expect(getLastToolUseName(msg)).toBeUndefined(); - }); -}); + { type: 'tool_use', name: 'Read' }, + { type: 'tool_use', name: 'Grep' }, + { type: 'tool_use', name: 'Edit' }, + ]) + expect(getLastToolUseName(msg)).toBe('Edit') + }) + + test('returns undefined for non-assistant message', () => { + const msg = makeUserMessage('hello') + expect(getLastToolUseName(msg)).toBeUndefined() + }) + + test('handles message with null content', () => { + const msg = { type: 'assistant', message: { content: null } } + expect(getLastToolUseName(msg)).toBeUndefined() + }) +}) diff --git a/src/tools/AgentTool/agentToolUtils.ts b/src/tools/AgentTool/agentToolUtils.ts index 084ac6a85..427607789 100644 --- a/src/tools/AgentTool/agentToolUtils.ts +++ b/src/tools/AgentTool/agentToolUtils.ts @@ -36,7 +36,10 @@ import { updateProgressFromMessage, } from '../../tasks/LocalAgentTask/LocalAgentTask.js' import { asAgentId } from '../../types/ids.js' -import type { Message as MessageType, ContentItem } from '../../types/message.js' +import type { + Message as MessageType, + ContentItem, +} from '../../types/message.js' import { isAgentSwarmsEnabled } from '../../utils/agentSwarmsEnabled.js' import { logForDebugging } from '../../utils/debug.js' import { isInProtectedNamespace } from '../../utils/envUtils.js' @@ -302,14 +305,16 @@ export function finalizeAgentTool( // Extract text content from the agent's response. If the final assistant // message is a pure tool_use block (loop exited mid-turn), fall back to // the most recent assistant message that has text content. - let content = (lastAssistantMessage.message?.content as ContentItem[] ?? []).filter( - _ => _.type === 'text', - ) + let content = ( + (lastAssistantMessage.message?.content as ContentItem[]) ?? [] + ).filter(_ => _.type === 'text') if (content.length === 0) { for (let i = agentMessages.length - 1; i >= 0; i--) { const m = agentMessages[i]! if (m.type !== 'assistant') continue - const textBlocks = (m.message?.content as ContentItem[] ?? []).filter(_ => _.type === 'text') + const textBlocks = ((m.message?.content as ContentItem[]) ?? []).filter( + _ => _.type === 'text', + ) if (textBlocks.length > 0) { content = textBlocks break @@ -317,7 +322,11 @@ export function finalizeAgentTool( } } - const totalTokens = getTokenCountFromUsage(lastAssistantMessage.message?.usage as Parameters[0]) + const totalTokens = getTokenCountFromUsage( + lastAssistantMessage.message?.usage as Parameters< + typeof getTokenCountFromUsage + >[0], + ) const totalToolUseCount = countToolUses(agentMessages) logEvent('tengu_agent_tool_completed', { @@ -363,7 +372,9 @@ export function finalizeAgentTool( */ export function getLastToolUseName(message: MessageType): string | undefined { if (message.type !== 'assistant') return undefined - const block = (message.message?.content as ContentItem[] ?? []).findLast(b => b.type === 'tool_use') + const block = ((message.message?.content as ContentItem[]) ?? []).findLast( + b => b.type === 'tool_use', + ) return block?.type === 'tool_use' ? block.name : undefined } @@ -492,7 +503,10 @@ export function extractPartialResult( for (let i = messages.length - 1; i >= 0; i--) { const m = messages[i]! if (m.type !== 'assistant') continue - const text = extractTextContent(m.message?.content as ContentItem[] ?? [], '\n') + const text = extractTextContent( + (m.message?.content as ContentItem[]) ?? [], + '\n', + ) if (text) { return text } diff --git a/src/tools/AgentTool/built-in/src/tools/BashTool/toolName.ts b/src/tools/AgentTool/built-in/src/tools/BashTool/toolName.ts index 2da8eb7a9..738010863 100644 --- a/src/tools/AgentTool/built-in/src/tools/BashTool/toolName.ts +++ b/src/tools/AgentTool/built-in/src/tools/BashTool/toolName.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type BASH_TOOL_NAME = any; +export type BASH_TOOL_NAME = any diff --git a/src/tools/AgentTool/built-in/src/tools/ExitPlanModeTool/constants.ts b/src/tools/AgentTool/built-in/src/tools/ExitPlanModeTool/constants.ts index 11f9fd01d..8204b84f9 100644 --- a/src/tools/AgentTool/built-in/src/tools/ExitPlanModeTool/constants.ts +++ b/src/tools/AgentTool/built-in/src/tools/ExitPlanModeTool/constants.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type EXIT_PLAN_MODE_TOOL_NAME = any; +export type EXIT_PLAN_MODE_TOOL_NAME = any diff --git a/src/tools/AgentTool/built-in/src/tools/FileEditTool/constants.ts b/src/tools/AgentTool/built-in/src/tools/FileEditTool/constants.ts index b455c0655..f851a8bcc 100644 --- a/src/tools/AgentTool/built-in/src/tools/FileEditTool/constants.ts +++ b/src/tools/AgentTool/built-in/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/tools/AgentTool/built-in/src/tools/FileReadTool/prompt.ts b/src/tools/AgentTool/built-in/src/tools/FileReadTool/prompt.ts index fac6439fc..e8c6709b3 100644 --- a/src/tools/AgentTool/built-in/src/tools/FileReadTool/prompt.ts +++ b/src/tools/AgentTool/built-in/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/tools/AgentTool/built-in/src/tools/FileWriteTool/prompt.ts b/src/tools/AgentTool/built-in/src/tools/FileWriteTool/prompt.ts index e69299d74..45cc15c49 100644 --- a/src/tools/AgentTool/built-in/src/tools/FileWriteTool/prompt.ts +++ b/src/tools/AgentTool/built-in/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/tools/AgentTool/built-in/src/tools/GlobTool/prompt.ts b/src/tools/AgentTool/built-in/src/tools/GlobTool/prompt.ts index 060caf29c..5ff2b16bb 100644 --- a/src/tools/AgentTool/built-in/src/tools/GlobTool/prompt.ts +++ b/src/tools/AgentTool/built-in/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/tools/AgentTool/built-in/src/tools/GrepTool/prompt.ts b/src/tools/AgentTool/built-in/src/tools/GrepTool/prompt.ts index 08b8a8d29..4645d4c52 100644 --- a/src/tools/AgentTool/built-in/src/tools/GrepTool/prompt.ts +++ b/src/tools/AgentTool/built-in/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/tools/AgentTool/built-in/src/tools/NotebookEditTool/constants.ts b/src/tools/AgentTool/built-in/src/tools/NotebookEditTool/constants.ts index 6c6c94bad..3c1d7a0d2 100644 --- a/src/tools/AgentTool/built-in/src/tools/NotebookEditTool/constants.ts +++ b/src/tools/AgentTool/built-in/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/tools/AgentTool/built-in/src/tools/SendMessageTool/constants.ts b/src/tools/AgentTool/built-in/src/tools/SendMessageTool/constants.ts index efd60265b..ae67746ae 100644 --- a/src/tools/AgentTool/built-in/src/tools/SendMessageTool/constants.ts +++ b/src/tools/AgentTool/built-in/src/tools/SendMessageTool/constants.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type SEND_MESSAGE_TOOL_NAME = any; +export type SEND_MESSAGE_TOOL_NAME = any diff --git a/src/tools/AgentTool/built-in/src/tools/WebFetchTool/prompt.ts b/src/tools/AgentTool/built-in/src/tools/WebFetchTool/prompt.ts index 63b342a25..83e9643c5 100644 --- a/src/tools/AgentTool/built-in/src/tools/WebFetchTool/prompt.ts +++ b/src/tools/AgentTool/built-in/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/tools/AgentTool/built-in/src/tools/WebSearchTool/prompt.ts b/src/tools/AgentTool/built-in/src/tools/WebSearchTool/prompt.ts index 38871a0ba..3d3f02b32 100644 --- a/src/tools/AgentTool/built-in/src/tools/WebSearchTool/prompt.ts +++ b/src/tools/AgentTool/built-in/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/tools/AgentTool/built-in/src/utils/auth.ts b/src/tools/AgentTool/built-in/src/utils/auth.ts index 909e31047..b3e5dee07 100644 --- a/src/tools/AgentTool/built-in/src/utils/auth.ts +++ b/src/tools/AgentTool/built-in/src/utils/auth.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type isUsing3PServices = any; +export type isUsing3PServices = any diff --git a/src/tools/AgentTool/built-in/src/utils/embeddedTools.ts b/src/tools/AgentTool/built-in/src/utils/embeddedTools.ts index c0160dbf9..0568d4b57 100644 --- a/src/tools/AgentTool/built-in/src/utils/embeddedTools.ts +++ b/src/tools/AgentTool/built-in/src/utils/embeddedTools.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type hasEmbeddedSearchTools = any; +export type hasEmbeddedSearchTools = any diff --git a/src/tools/AgentTool/built-in/src/utils/settings/settings.ts b/src/tools/AgentTool/built-in/src/utils/settings/settings.ts index 4b9b819d5..b4678951c 100644 --- a/src/tools/AgentTool/built-in/src/utils/settings/settings.ts +++ b/src/tools/AgentTool/built-in/src/utils/settings/settings.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getSettings_DEPRECATED = any; +export type getSettings_DEPRECATED = any diff --git a/src/tools/AgentTool/forkSubagent.ts b/src/tools/AgentTool/forkSubagent.ts index 9a9e43302..30ff83e5a 100644 --- a/src/tools/AgentTool/forkSubagent.ts +++ b/src/tools/AgentTool/forkSubagent.ts @@ -115,14 +115,20 @@ export function buildForkedMessages( uuid: randomUUID(), message: { ...assistantMessage.message, - content: [...(Array.isArray(assistantMessage.message.content) ? assistantMessage.message.content : [])], + content: [ + ...(Array.isArray(assistantMessage.message.content) + ? assistantMessage.message.content + : []), + ], }, } // Collect all tool_use blocks from the assistant message - const toolUseBlocks = (Array.isArray(assistantMessage.message.content) ? assistantMessage.message.content : []).filter( - (block): block is BetaToolUseBlock => block.type === 'tool_use', - ) + const toolUseBlocks = ( + Array.isArray(assistantMessage.message.content) + ? assistantMessage.message.content + : [] + ).filter((block): block is BetaToolUseBlock => block.type === 'tool_use') if (toolUseBlocks.length === 0) { logForDebugging( diff --git a/src/tools/AgentTool/src/Tool.ts b/src/tools/AgentTool/src/Tool.ts index 7e33e7efc..cec74692f 100644 --- a/src/tools/AgentTool/src/Tool.ts +++ b/src/tools/AgentTool/src/Tool.ts @@ -1,4 +1,4 @@ // Auto-generated type stub — replace with real implementation -export type buildTool = any; -export type ToolDef = any; -export type toolMatchesName = any; +export type buildTool = any +export type ToolDef = any +export type toolMatchesName = any diff --git a/src/tools/AgentTool/src/components/ConfigurableShortcutHint.ts b/src/tools/AgentTool/src/components/ConfigurableShortcutHint.ts index d68e6f6e0..e1ede3353 100644 --- a/src/tools/AgentTool/src/components/ConfigurableShortcutHint.ts +++ b/src/tools/AgentTool/src/components/ConfigurableShortcutHint.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type ConfigurableShortcutHint = any; +export type ConfigurableShortcutHint = any diff --git a/src/tools/AgentTool/src/components/CtrlOToExpand.ts b/src/tools/AgentTool/src/components/CtrlOToExpand.ts index b8e3b0a62..05c1118ac 100644 --- a/src/tools/AgentTool/src/components/CtrlOToExpand.ts +++ b/src/tools/AgentTool/src/components/CtrlOToExpand.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type CtrlOToExpand = any; -export type SubAgentProvider = any; +export type CtrlOToExpand = any +export type SubAgentProvider = any diff --git a/src/tools/AgentTool/src/components/design-system/Byline.ts b/src/tools/AgentTool/src/components/design-system/Byline.ts index ed8c71384..5cc7c977b 100644 --- a/src/tools/AgentTool/src/components/design-system/Byline.ts +++ b/src/tools/AgentTool/src/components/design-system/Byline.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type Byline = any; +export type Byline = any diff --git a/src/tools/AgentTool/src/components/design-system/KeyboardShortcutHint.ts b/src/tools/AgentTool/src/components/design-system/KeyboardShortcutHint.ts index ab506bb31..73c2d3482 100644 --- a/src/tools/AgentTool/src/components/design-system/KeyboardShortcutHint.ts +++ b/src/tools/AgentTool/src/components/design-system/KeyboardShortcutHint.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type KeyboardShortcutHint = any; +export type KeyboardShortcutHint = any diff --git a/src/tools/AgentTool/src/types/message.ts b/src/tools/AgentTool/src/types/message.ts index 4b0a33f37..3d11fb316 100644 --- a/src/tools/AgentTool/src/types/message.ts +++ b/src/tools/AgentTool/src/types/message.ts @@ -1,3 +1,3 @@ // Auto-generated type stub — replace with real implementation -export type Message = any; -export type NormalizedUserMessage = any; +export type Message = any +export type NormalizedUserMessage = any diff --git a/src/tools/AgentTool/src/utils/debug.ts b/src/tools/AgentTool/src/utils/debug.ts index c64d5960c..5a0f1d515 100644 --- a/src/tools/AgentTool/src/utils/debug.ts +++ b/src/tools/AgentTool/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/tools/AgentTool/src/utils/promptCategory.ts b/src/tools/AgentTool/src/utils/promptCategory.ts index 207db7233..fb3e57898 100644 --- a/src/tools/AgentTool/src/utils/promptCategory.ts +++ b/src/tools/AgentTool/src/utils/promptCategory.ts @@ -1,2 +1,2 @@ // Auto-generated type stub — replace with real implementation -export type getQuerySourceForAgent = any; +export type getQuerySourceForAgent = any diff --git a/src/tools/AgentTool/src/utils/settings/constants.ts b/src/tools/AgentTool/src/utils/settings/constants.ts index b82138d6a..24eb36c76 100644 --- a/src/tools/AgentTool/src/utils/settings/constants.ts +++ b/src/tools/AgentTool/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/tools/AskUserQuestionTool/AskUserQuestionTool.tsx b/src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx index e71a5c665..93964889d 100644 --- a/src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx +++ b/src/tools/AskUserQuestionTool/AskUserQuestionTool.tsx @@ -1,24 +1,21 @@ -import { feature } from 'bun:bundle' -import * as React from 'react' -import { - getAllowedChannels, - getQuestionPreviewFormat, -} from 'src/bootstrap/state.js' -import { MessageResponse } from 'src/components/MessageResponse.js' -import { BLACK_CIRCLE } from 'src/constants/figures.js' -import { getModeColor } from 'src/utils/permissions/PermissionMode.js' -import { z } from 'zod/v4' -import { Box, Text } from '../../ink.js' -import type { Tool } from '../../Tool.js' -import { buildTool, type ToolDef } from '../../Tool.js' -import { lazySchema } from '../../utils/lazySchema.js' +import { feature } from 'bun:bundle'; +import * as React from 'react'; +import { getAllowedChannels, getQuestionPreviewFormat } from 'src/bootstrap/state.js'; +import { MessageResponse } from 'src/components/MessageResponse.js'; +import { BLACK_CIRCLE } from 'src/constants/figures.js'; +import { getModeColor } from 'src/utils/permissions/PermissionMode.js'; +import { z } from 'zod/v4'; +import { Box, Text } from '../../ink.js'; +import type { Tool } from '../../Tool.js'; +import { buildTool, type ToolDef } from '../../Tool.js'; +import { lazySchema } from '../../utils/lazySchema.js'; import { ASK_USER_QUESTION_TOOL_CHIP_WIDTH, ASK_USER_QUESTION_TOOL_NAME, ASK_USER_QUESTION_TOOL_PROMPT, DESCRIPTION, PREVIEW_FEATURE_PROMPT, -} from './prompt.js' +} from './prompt.js'; const questionOptionSchema = lazySchema(() => z.object({ @@ -39,7 +36,7 @@ const questionOptionSchema = lazySchema(() => 'Optional preview content rendered when this option is focused. Use for mockups, code snippets, or visual comparisons that help users compare options. See the tool description for the expected content format.', ), }), -) +); const questionSchema = lazySchema(() => z.object({ @@ -67,55 +64,44 @@ const questionSchema = lazySchema(() => 'Set to true to allow the user to select multiple options instead of just one. Use when choices are not mutually exclusive.', ), }), -) +); const annotationsSchema = lazySchema(() => { const annotationSchema = z.object({ preview: z .string() .optional() - .describe( - 'The preview content of the selected option, if the question used previews.', - ), - notes: z - .string() - .optional() - .describe('Free-text notes the user added to their selection.'), - }) + .describe('The preview content of the selected option, if the question used previews.'), + notes: z.string().optional().describe('Free-text notes the user added to their selection.'), + }); return z .record(z.string(), annotationSchema) .optional() .describe( 'Optional per-question annotations from the user (e.g., notes on preview selections). Keyed by question text.', - ) -}) + ); +}); const UNIQUENESS_REFINE = { - check: (data: { - questions: { question: string; options: { label: string }[] }[] - }) => { - const questions = data.questions.map(q => q.question) + check: (data: { questions: { question: string; options: { label: string }[] }[] }) => { + const questions = data.questions.map(q => q.question); if (questions.length !== new Set(questions).size) { - return false + return false; } for (const question of data.questions) { - const labels = question.options.map(opt => opt.label) + const labels = question.options.map(opt => opt.label); if (labels.length !== new Set(labels).size) { - return false + return false; } } - return true + return true; }, - message: - 'Question texts must be unique, option labels must be unique within each question', -} as const + message: 'Question texts must be unique, option labels must be unique within each question', +} as const; const commonFields = lazySchema(() => ({ - answers: z - .record(z.string(), z.string()) - .optional() - .describe('User answers collected by the permission component'), + answers: z.record(z.string(), z.string()).optional().describe('User answers collected by the permission component'), annotations: annotationsSchema(), metadata: z .object({ @@ -127,32 +113,24 @@ const commonFields = lazySchema(() => ({ ), }) .optional() - .describe( - 'Optional metadata for tracking and analytics purposes. Not displayed to user.', - ), -})) + .describe('Optional metadata for tracking and analytics purposes. Not displayed to user.'), +})); const inputSchema = lazySchema(() => z .strictObject({ - questions: z - .array(questionSchema()) - .min(1) - .max(4) - .describe('Questions to ask the user (1-4 questions)'), + questions: z.array(questionSchema()).min(1).max(4).describe('Questions to ask the user (1-4 questions)'), ...commonFields(), }) .refine(UNIQUENESS_REFINE.check, { message: UNIQUENESS_REFINE.message, }), -) -type InputSchema = ReturnType +); +type InputSchema = ReturnType; const outputSchema = lazySchema(() => z.object({ - questions: z - .array(questionSchema()) - .describe('The questions that were asked'), + questions: z.array(questionSchema()).describe('The questions that were asked'), answers: z .record(z.string(), z.string()) .describe( @@ -160,23 +138,19 @@ const outputSchema = lazySchema(() => ), annotations: annotationsSchema(), }), -) -type OutputSchema = ReturnType +); +type OutputSchema = ReturnType; // SDK schemas are identical to internal schemas now that `preview` and // `annotations` are public (configurable via `toolConfig.askUserQuestion`). -export const _sdkInputSchema = inputSchema -export const _sdkOutputSchema = outputSchema +export const _sdkInputSchema = inputSchema; +export const _sdkOutputSchema = outputSchema; -export type Question = z.infer> -export type QuestionOption = z.infer> -export type Output = z.infer +export type Question = z.infer>; +export type QuestionOption = z.infer>; +export type Output = z.infer; -function AskUserQuestionResultMessage({ - answers, -}: { - answers: Output['answers'] -}): React.ReactNode { +function AskUserQuestionResultMessage({ answers }: { answers: Output['answers'] }): React.ReactNode { return ( @@ -193,7 +167,7 @@ function AskUserQuestionResultMessage({ - ) + ); } export const AskUserQuestionTool: Tool = buildTool({ @@ -202,25 +176,25 @@ export const AskUserQuestionTool: Tool = buildTool({ maxResultSizeChars: 100_000, shouldDefer: true, async description() { - return DESCRIPTION + return DESCRIPTION; }, async prompt() { - const format = getQuestionPreviewFormat() + const format = getQuestionPreviewFormat(); if (format === undefined) { // SDK consumer that hasn't opted into a preview format — omit preview // guidance (they may not render the field at all). - return ASK_USER_QUESTION_TOOL_PROMPT + return ASK_USER_QUESTION_TOOL_PROMPT; } - return ASK_USER_QUESTION_TOOL_PROMPT + PREVIEW_FEATURE_PROMPT[format] + return ASK_USER_QUESTION_TOOL_PROMPT + PREVIEW_FEATURE_PROMPT[format]; }, get inputSchema(): InputSchema { - return inputSchema() + return inputSchema(); }, get outputSchema(): OutputSchema { - return outputSchema() + return outputSchema(); }, userFacingName() { - return '' + return ''; }, isEnabled() { // When --channels is active the user is likely on Telegram/Discord, not @@ -228,59 +202,56 @@ export const AskUserQuestionTool: Tool = buildTool({ // the keyboard. Channel permission relay already skips // requiresUserInteraction() tools (interactiveHandler.ts) so there's // no alternate approval path. - if ( - (feature('KAIROS') || feature('KAIROS_CHANNELS')) && - getAllowedChannels().length > 0 - ) { - return false + if ((feature('KAIROS') || feature('KAIROS_CHANNELS')) && getAllowedChannels().length > 0) { + return false; } - return true + return true; }, isConcurrencySafe() { - return true + return true; }, isReadOnly() { - return true + return true; }, toAutoClassifierInput(input) { - return input.questions.map(q => q.question).join(' | ') + return input.questions.map(q => q.question).join(' | '); }, requiresUserInteraction() { - return true + return true; }, async validateInput({ questions }) { if (getQuestionPreviewFormat() !== 'html') { - return { result: true } + return { result: true }; } for (const q of questions) { for (const opt of q.options) { - const err = validateHtmlPreview(opt.preview) + const err = validateHtmlPreview(opt.preview); if (err) { return { result: false, message: `Option "${opt.label}" in question "${q.question}": ${err}`, errorCode: 1, - } + }; } } } - return { result: true } + return { result: true }; }, async checkPermissions(input) { return { behavior: 'ask' as const, message: 'Answer questions?', updatedInput: input, - } + }; }, renderToolUseMessage() { - return null + return null; }, renderToolUseProgressMessage() { - return null + return null; }, renderToolResultMessage({ answers }, _toolUseID) { - return + return ; }, renderToolUseRejectedMessage() { return ( @@ -288,55 +259,55 @@ export const AskUserQuestionTool: Tool = buildTool({ {BLACK_CIRCLE}  User declined to answer questions - ) + ); }, renderToolUseErrorMessage() { - return null + return null; }, async call({ questions, answers = {}, annotations }, _context) { return { data: { questions, answers, ...(annotations && { annotations }) }, - } + }; }, mapToolResultToToolResultBlockParam({ answers, annotations }, toolUseID) { const answersText = Object.entries(answers) .map(([questionText, answer]) => { - const annotation = annotations?.[questionText] - const parts = [`"${questionText}"="${answer}"`] + const annotation = annotations?.[questionText]; + const parts = [`"${questionText}"="${answer}"`]; if (annotation?.preview) { - parts.push(`selected preview:\n${annotation.preview}`) + parts.push(`selected preview:\n${annotation.preview}`); } if (annotation?.notes) { - parts.push(`user notes: ${annotation.notes}`) + parts.push(`user notes: ${annotation.notes}`); } - return parts.join(' ') + return parts.join(' '); }) - .join(', ') + .join(', '); return { type: 'tool_result', content: `User has answered your questions: ${answersText}. You can now continue with the user's answers in mind.`, tool_use_id: toolUseID, - } + }; }, -} satisfies ToolDef) +} satisfies ToolDef); // Lightweight HTML fragment check. Not a parser — HTML5 parsers are // error-recovering by spec and accept anything. We're checking model intent // (did it emit HTML?) and catching the specific things we told it not to do. function validateHtmlPreview(preview: string | undefined): string | null { - if (preview === undefined) return null + if (preview === undefined) return null; if (/<\s*(html|body|!doctype)\b/i.test(preview)) { - return 'preview must be an HTML fragment, not a full document (no , , or )' + return 'preview must be an HTML fragment, not a full document (no , , or )'; } // SDK consumers typically set this via innerHTML — disallow executable/style // tags so a preview can't run code or restyle the host page. Inline event // handlers (onclick etc.) are still possible; consumers should sanitize. if (/<\s*(script|style)\b/i.test(preview)) { - return 'preview must not contain