From ae7b92f6736d969e53c75eb4bf1ba7520e8a5cb7 Mon Sep 17 00:00:00 2001 From: claude-code-best Date: Sat, 4 Apr 2026 21:56:58 +0800 Subject: [PATCH] =?UTF-8?q?style(B1-3):=20=E6=A0=BC=E5=BC=8F=E5=8C=96=20co?= =?UTF-8?q?mponents/messages,permissions,mcp,sandbox,shell=20(104=20files)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 纯格式化:移除分号、React Compiler import、import 多行展开。 Co-Authored-By: Claude Opus 4.6 --- src/components/mcp/CapabilitiesSection.tsx | 91 +- src/components/mcp/ElicitationDialog.tsx | 1974 ++++++++++------- src/components/mcp/MCPAgentServerMenu.tsx | 236 +- src/components/mcp/MCPListPanel.tsx | 780 +++---- src/components/mcp/MCPReconnect.tsx | 267 +-- src/components/mcp/MCPRemoteServerMenu.tsx | 955 +++++--- src/components/mcp/MCPSettings.tsx | 608 ++--- src/components/mcp/MCPStdioServerMenu.tsx | 252 ++- src/components/mcp/MCPToolDetailView.tsx | 335 ++- src/components/mcp/MCPToolListView.tsx | 238 +- src/components/mcp/McpParsingWarnings.tsx | 345 ++- src/components/mcp/utils/reconnectHelpers.tsx | 57 +- src/components/messages/AdvisorMessage.tsx | 238 +- .../AssistantRedactedThinkingMessage.tsx | 44 +- .../messages/AssistantTextMessage.tsx | 449 ++-- .../messages/AssistantThinkingMessage.tsx | 143 +- .../messages/AssistantToolUseMessage.tsx | 631 +++--- src/components/messages/AttachmentMessage.tsx | 782 +++---- .../messages/CollapsedReadSearchContent.tsx | 728 +++--- .../messages/CompactBoundaryMessage.tsx | 34 +- .../messages/GroupedToolUseContent.tsx | 85 +- .../messages/HighlightedThinkingText.tsx | 248 +-- .../messages/HookProgressMessage.tsx | 178 +- .../messages/PlanApprovalMessage.tsx | 306 +-- src/components/messages/RateLimitMessage.tsx | 245 +- src/components/messages/ShutdownMessage.tsx | 186 +- .../messages/SystemAPIErrorMessage.tsx | 196 +- src/components/messages/SystemTextMessage.tsx | 1287 ++++------- .../messages/TaskAssignmentMessage.tsx | 98 +- .../messages/UserAgentNotificationMessage.tsx | 104 +- .../messages/UserBashInputMessage.tsx | 83 +- .../messages/UserBashOutputMessage.tsx | 71 +- .../messages/UserChannelMessage.tsx | 166 +- .../messages/UserCommandMessage.tsx | 160 +- src/components/messages/UserImageMessage.tsx | 79 +- .../UserLocalCommandOutputMessage.tsx | 230 +- .../messages/UserMemoryInputMessage.tsx | 110 +- src/components/messages/UserPlanMessage.tsx | 67 +- src/components/messages/UserPromptMessage.tsx | 132 +- .../messages/UserResourceUpdateMessage.tsx | 143 +- .../messages/UserTeammateMessage.tsx | 304 ++- src/components/messages/UserTextMessage.tsx | 467 ++-- .../RejectedPlanMessage.tsx | 53 +- .../RejectedToolUseMessage.tsx | 24 +- .../UserToolCanceledMessage.tsx | 24 +- .../UserToolErrorMessage.tsx | 181 +- .../UserToolRejectMessage.tsx | 149 +- .../UserToolResultMessage.tsx | 202 +- .../UserToolSuccessMessage.tsx | 156 +- .../messages/UserToolResultMessage/utils.tsx | 61 +- src/components/messages/teamMemCollapsed.tsx | 199 +- .../AskUserQuestionPermissionRequest.tsx | 1143 +++++----- .../PreviewBox.tsx | 334 ++- .../PreviewQuestionView.tsx | 560 +++-- .../QuestionNavigationBar.tsx | 320 ++- .../QuestionView.tsx | 828 ++++--- .../SubmitQuestionsView.tsx | 243 +- .../BashPermissionRequest.tsx | 793 ++++--- .../bashToolUseOptions.tsx | 143 +- .../ComputerUseApproval.tsx | 681 +++--- .../EnterPlanModePermissionRequest.tsx | 199 +- .../ExitPlanModePermissionRequest.tsx | 1088 +++++---- .../permissions/FallbackPermissionRequest.tsx | 518 ++--- .../FileEditPermissionRequest.tsx | 240 +- .../FilePermissionDialog.tsx | 317 +-- .../permissionOptions.tsx | 185 +- .../FileWritePermissionRequest.tsx | 231 +- .../FileWriteToolDiff.tsx | 158 +- .../FilesystemPermissionRequest.tsx | 183 +- .../NotebookEditPermissionRequest.tsx | 238 +- .../NotebookEditToolDiff.tsx | 384 ++-- .../PermissionDecisionDebugInfo.tsx | 733 +++--- .../permissions/PermissionDialog.tsx | 121 +- .../permissions/PermissionExplanation.tsx | 350 ++- .../permissions/PermissionPrompt.tsx | 530 ++--- .../permissions/PermissionRequest.tsx | 342 +-- .../permissions/PermissionRequestTitle.tsx | 102 +- .../permissions/PermissionRuleExplanation.tsx | 178 +- .../PowerShellPermissionRequest.tsx | 396 ++-- .../powershellToolUseOptions.tsx | 83 +- .../permissions/SandboxPermissionRequest.tsx | 262 +-- .../SedEditPermissionRequest.tsx | 346 ++- .../SkillPermissionRequest.tsx | 599 ++--- .../WebFetchPermissionRequest.tsx | 381 ++-- src/components/permissions/WorkerBadge.tsx | 61 +- .../permissions/WorkerPendingPermission.tsx | 160 +- .../permissions/rules/AddPermissionRules.tsx | 310 ++- .../rules/AddWorkspaceDirectory.tsx | 597 +++-- .../rules/PermissionRuleDescription.tsx | 105 +- .../permissions/rules/PermissionRuleInput.tsx | 240 +- .../permissions/rules/PermissionRuleList.tsx | 1831 ++++++--------- .../permissions/rules/RecentDenialsTab.tsx | 308 +-- .../rules/RemoveWorkspaceDirectory.tsx | 173 +- .../permissions/rules/WorkspaceTab.tsx | 242 +- .../permissions/shellPermissionHelpers.tsx | 170 +- src/components/sandbox/SandboxConfigTab.tsx | 173 +- .../sandbox/SandboxDependenciesTab.tsx | 237 +- .../sandbox/SandboxDoctorSection.tsx | 81 +- .../sandbox/SandboxOverridesTab.tsx | 307 ++- src/components/sandbox/SandboxSettings.tsx | 496 ++--- .../shell/ExpandShellOutputContext.tsx | 36 +- src/components/shell/OutputLine.tsx | 158 +- src/components/shell/ShellProgressMessage.tsx | 224 +- src/components/shell/ShellTimeDisplay.tsx | 97 +- 104 files changed, 15977 insertions(+), 18419 deletions(-) diff --git a/src/components/mcp/CapabilitiesSection.tsx b/src/components/mcp/CapabilitiesSection.tsx index 3c3044cf4..a5f98466a 100644 --- a/src/components/mcp/CapabilitiesSection.tsx +++ b/src/components/mcp/CapabilitiesSection.tsx @@ -1,60 +1,35 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, Text } from '../../ink.js'; -import { Byline } from '../design-system/Byline.js'; +import React from 'react' +import { Box, Text } from '../../ink.js' +import { Byline } from '../design-system/Byline.js' + type Props = { - serverToolsCount: number; - serverPromptsCount: number; - serverResourcesCount: number; -}; -export function CapabilitiesSection(t0) { - const $ = _c(9); - const { - serverToolsCount, - serverPromptsCount, - serverResourcesCount - } = t0; - let capabilities; - if ($[0] !== serverPromptsCount || $[1] !== serverResourcesCount || $[2] !== serverToolsCount) { - capabilities = []; - if (serverToolsCount > 0) { - capabilities.push("tools"); - } - if (serverResourcesCount > 0) { - capabilities.push("resources"); - } - if (serverPromptsCount > 0) { - capabilities.push("prompts"); - } - $[0] = serverPromptsCount; - $[1] = serverResourcesCount; - $[2] = serverToolsCount; - $[3] = capabilities; - } else { - capabilities = $[3]; - } - let t1; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Capabilities: ; - $[4] = t1; - } else { - t1 = $[4]; - } - let t2; - if ($[5] !== capabilities) { - t2 = capabilities.length > 0 ? {capabilities} : "none"; - $[5] = capabilities; - $[6] = t2; - } else { - t2 = $[6]; - } - let t3; - if ($[7] !== t2) { - t3 = {t1}{t2}; - $[7] = t2; - $[8] = t3; - } else { - t3 = $[8]; - } - return t3; + serverToolsCount: number + serverPromptsCount: number + serverResourcesCount: number +} + +export function CapabilitiesSection({ + serverToolsCount, + serverPromptsCount, + serverResourcesCount, +}: Props): React.ReactNode { + const capabilities = [] + if (serverToolsCount > 0) { + capabilities.push('tools') + } + if (serverResourcesCount > 0) { + capabilities.push('resources') + } + if (serverPromptsCount > 0) { + capabilities.push('prompts') + } + + return ( + + Capabilities: + + {capabilities.length > 0 ? {capabilities} : 'none'} + + + ) } diff --git a/src/components/mcp/ElicitationDialog.tsx b/src/components/mcp/ElicitationDialog.tsx index 0ae90a9bd..dbf8f22f9 100644 --- a/src/components/mcp/ElicitationDialog.tsx +++ b/src/components/mcp/ElicitationDialog.tsx @@ -1,39 +1,62 @@ -import { c as _c } from "react/compiler-runtime"; -import type { ElicitRequestFormParams, ElicitRequestURLParams, ElicitResult, PrimitiveSchemaDefinition } from '@modelcontextprotocol/sdk/types.js'; -import figures from 'figures'; -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useRegisterOverlay } from '../../context/overlayContext.js'; -import { useNotifyAfterTimeout } from '../../hooks/useNotifyAfterTimeout.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import type { + ElicitRequestFormParams, + ElicitRequestURLParams, + ElicitResult, + PrimitiveSchemaDefinition, +} from '@modelcontextprotocol/sdk/types.js' +import figures from 'figures' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useRegisterOverlay } from '../../context/overlayContext.js' +import { useNotifyAfterTimeout } from '../../hooks/useNotifyAfterTimeout.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw text input for elicitation form -import { Box, Text, useInput } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import type { ElicitationRequestEvent } from '../../services/mcp/elicitationHandler.js'; -import { openBrowser } from '../../utils/browser.js'; -import { getEnumLabel, getEnumValues, getMultiSelectLabel, getMultiSelectValues, isDateTimeSchema, isEnumSchema, isMultiSelectEnumSchema, validateElicitationInput, validateElicitationInputAsync } from '../../utils/mcp/elicitationValidation.js'; -import { plural } from '../../utils/stringUtils.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; -import { Byline } from '../design-system/Byline.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; -import TextInput from '../TextInput.js'; +import { Box, Text, useInput } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import type { ElicitationRequestEvent } from '../../services/mcp/elicitationHandler.js' +import { openBrowser } from '../../utils/browser.js' +import { + getEnumLabel, + getEnumValues, + getMultiSelectLabel, + getMultiSelectValues, + isDateTimeSchema, + isEnumSchema, + isMultiSelectEnumSchema, + validateElicitationInput, + validateElicitationInputAsync, +} from '../../utils/mcp/elicitationValidation.js' +import { plural } from '../../utils/stringUtils.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +import { Byline } from '../design-system/Byline.js' +import { Dialog } from '../design-system/Dialog.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' +import TextInput from '../TextInput.js' + type Props = { - event: ElicitationRequestEvent; - onResponse: (action: ElicitResult['action'], content?: ElicitResult['content']) => void; + event: ElicitationRequestEvent + onResponse: ( + action: ElicitResult['action'], + content?: ElicitResult['content'], + ) => void /** Called when the phase 2 waiting state is dismissed (URL elicitations only). */ - onWaitingDismiss?: (action: 'dismiss' | 'retry' | 'cancel') => void; -}; -const isTextField = (s: PrimitiveSchemaDefinition) => ['string', 'number', 'integer'].includes(s.type); -const RESOLVING_SPINNER_CHARS = '\u280B\u2819\u2839\u2838\u283C\u2834\u2826\u2827\u2807\u280F'; -const advanceSpinnerFrame = (f: number) => (f + 1) % RESOLVING_SPINNER_CHARS.length; + onWaitingDismiss?: (action: 'dismiss' | 'retry' | 'cancel') => void +} + +const isTextField = (s: PrimitiveSchemaDefinition) => + ['string', 'number', 'integer'].includes(s.type) + +const RESOLVING_SPINNER_CHARS = + '\u280B\u2819\u2839\u2838\u283C\u2834\u2826\u2827\u2807\u280F' +const advanceSpinnerFrame = (f: number) => + (f + 1) % RESOLVING_SPINNER_CHARS.length /** Timer callback for enumTypeaheadRef — module-scope to avoid closure capture. */ function resetTypeahead(ta: { - buffer: string; - timer: ReturnType | undefined; + buffer: string + timer: ReturnType | undefined }): void { - ta.buffer = ''; - ta.timer = undefined; + ta.buffer = '' + ta.timer = undefined } /** @@ -46,42 +69,24 @@ function resetTypeahead(ta: { * with color="text", which would break the 1-col checkbox * column alignment here (other checkbox states are width-1 glyphs). */ -function ResolvingSpinner() { - const $ = _c(4); - const [frame, setFrame] = useState(0); - let t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = () => { - const timer = setInterval(setFrame, 80, advanceSpinnerFrame); - return () => clearInterval(timer); - }; - t1 = []; - $[0] = t0; - $[1] = t1; - } else { - t0 = $[0]; - t1 = $[1]; - } - useEffect(t0, t1); - const t2 = RESOLVING_SPINNER_CHARS[frame]; - let t3; - if ($[2] !== t2) { - t3 = {t2}; - $[2] = t2; - $[3] = t3; - } else { - t3 = $[3]; - } - return t3; +function ResolvingSpinner(): React.ReactNode { + const [frame, setFrame] = useState(0) + useEffect(() => { + const timer = setInterval(setFrame, 80, advanceSpinnerFrame) + return () => clearInterval(timer) + }, []) + return {RESOLVING_SPINNER_CHARS[frame]} } /** Format an ISO date/datetime for display, keeping the ISO value for submission. */ -function formatDateDisplay(isoValue: string, schema: PrimitiveSchemaDefinition): string { +function formatDateDisplay( + isoValue: string, + schema: PrimitiveSchemaDefinition, +): string { try { - const date = new Date(isoValue); - if (Number.isNaN(date.getTime())) return isoValue; - const format = 'format' in schema ? schema.format : undefined; + const date = new Date(isoValue) + if (Number.isNaN(date.getTime())) return isoValue + const format = 'format' in schema ? schema.format : undefined if (format === 'date-time') { return date.toLocaleDateString('en-US', { weekday: 'short', @@ -90,365 +95,477 @@ function formatDateDisplay(isoValue: string, schema: PrimitiveSchemaDefinition): day: 'numeric', hour: 'numeric', minute: '2-digit', - timeZoneName: 'short' - }); + timeZoneName: 'short', + }) } // date-only: parse as local date to avoid timezone shift - const parts = isoValue.split('-'); + const parts = isoValue.split('-') if (parts.length === 3) { - const local = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2])); + const local = new Date( + Number(parts[0]), + Number(parts[1]) - 1, + Number(parts[2]), + ) return local.toLocaleDateString('en-US', { weekday: 'short', year: 'numeric', month: 'short', - day: 'numeric' - }); + day: 'numeric', + }) } - return isoValue; + return isoValue } catch { - return isoValue; + return isoValue } } -export function ElicitationDialog(t0) { - const $ = _c(7); - const { - event, - onResponse, - onWaitingDismiss - } = t0; - if (event.params.mode === "url") { - let t1; - if ($[0] !== event || $[1] !== onResponse || $[2] !== onWaitingDismiss) { - t1 = ; - $[0] = event; - $[1] = onResponse; - $[2] = onWaitingDismiss; - $[3] = t1; - } else { - t1 = $[3]; - } - return t1; + +export function ElicitationDialog({ + event, + onResponse, + onWaitingDismiss, +}: Props): React.ReactNode { + if (event.params.mode === 'url') { + return ( + + ) } - let t1; - if ($[4] !== event || $[5] !== onResponse) { - t1 = ; - $[4] = event; - $[5] = onResponse; - $[6] = t1; - } else { - t1 = $[6]; - } - return t1; + + return } + function ElicitationFormDialog({ event, - onResponse + onResponse, }: { - event: ElicitationRequestEvent; - onResponse: Props['onResponse']; + event: ElicitationRequestEvent + onResponse: Props['onResponse'] }): React.ReactNode { - const { - serverName, - signal - } = event; - const request = event.params as ElicitRequestFormParams; - const { - message, - requestedSchema - } = request; - const hasFields = Object.keys(requestedSchema.properties).length > 0; - const [focusedButton, setFocusedButton] = useState<'accept' | 'decline' | null>(hasFields ? null : 'accept'); - const [formValues, setFormValues] = useState>(() => { - const initialValues: Record = {}; + const { serverName, signal } = event + const request = event.params as ElicitRequestFormParams + const { message, requestedSchema } = request + const hasFields = Object.keys(requestedSchema.properties).length > 0 + const [focusedButton, setFocusedButton] = useState< + 'accept' | 'decline' | null + >(hasFields ? null : 'accept') + const [formValues, setFormValues] = useState< + Record + >(() => { + const initialValues: Record = + {} if (requestedSchema.properties) { - for (const [propName, propSchema] of Object.entries(requestedSchema.properties)) { + for (const [propName, propSchema] of Object.entries( + requestedSchema.properties, + )) { if (typeof propSchema === 'object' && propSchema !== null) { if (propSchema.default !== undefined) { - initialValues[propName] = propSchema.default; + initialValues[propName] = propSchema.default } } } } - return initialValues; - }); - const [validationErrors, setValidationErrors] = useState>(() => { - const initialErrors: Record = {}; - for (const [propName_0, propSchema_0] of Object.entries(requestedSchema.properties)) { - if (isTextField(propSchema_0) && propSchema_0?.default !== undefined) { - const validation = validateElicitationInput(String(propSchema_0.default), propSchema_0); + return initialValues + }) + + const [validationErrors, setValidationErrors] = useState< + Record + >(() => { + const initialErrors: Record = {} + for (const [propName, propSchema] of Object.entries( + requestedSchema.properties, + )) { + if (isTextField(propSchema) && propSchema?.default !== undefined) { + const validation = validateElicitationInput( + String(propSchema.default), + propSchema, + ) if (!validation.isValid && validation.error) { - initialErrors[propName_0] = validation.error; + initialErrors[propName] = validation.error } } } - return initialErrors; - }); + return initialErrors + }) + useEffect(() => { - if (!signal) return; + if (!signal) return + const handleAbort = () => { - onResponse('cancel'); - }; - if (signal.aborted) { - handleAbort(); - return; + onResponse('cancel') } - signal.addEventListener('abort', handleAbort); + + if (signal.aborted) { + handleAbort() + return + } + + signal.addEventListener('abort', handleAbort) return () => { - signal.removeEventListener('abort', handleAbort); - }; - }, [signal, onResponse]); + signal.removeEventListener('abort', handleAbort) + } + }, [signal, onResponse]) + const schemaFields = useMemo(() => { - const requiredFields = requestedSchema.required ?? []; + const requiredFields = requestedSchema.required ?? [] return Object.entries(requestedSchema.properties).map(([name, schema]) => ({ name, schema, - isRequired: requiredFields.includes(name) - })); - }, [requestedSchema]); - const [currentFieldIndex, setCurrentFieldIndex] = useState(hasFields ? 0 : undefined); + isRequired: requiredFields.includes(name), + })) + }, [requestedSchema]) + + const [currentFieldIndex, setCurrentFieldIndex] = useState< + number | undefined + >(hasFields ? 0 : undefined) const [textInputValue, setTextInputValue] = useState(() => { // Initialize from the first field's value if it's a text field - const firstField = schemaFields[0]; + const firstField = schemaFields[0] if (firstField && isTextField(firstField.schema)) { - const val = formValues[firstField.name]; - if (val === undefined) return ''; - return String(val); + const val = formValues[firstField.name] + if (val === undefined) return '' + return String(val) } - return ''; - }); - const [textInputCursorOffset, setTextInputCursorOffset] = useState(textInputValue.length); - const [resolvingFields, setResolvingFields] = useState>(() => new Set()); + return '' + }) + const [textInputCursorOffset, setTextInputCursorOffset] = useState( + textInputValue.length, + ) + const [resolvingFields, setResolvingFields] = useState>( + () => new Set(), + ) // Accordion state (shared by multi-select and single-select enum) - const [expandedAccordion, setExpandedAccordion] = useState(); - const [accordionOptionIndex, setAccordionOptionIndex] = useState(0); - const dateDebounceRef = useRef | undefined>(undefined); - const resolveAbortRef = useRef>(new Map()); + const [expandedAccordion, setExpandedAccordion] = useState< + string | undefined + >() + const [accordionOptionIndex, setAccordionOptionIndex] = useState(0) + + const dateDebounceRef = useRef | undefined>( + undefined, + ) + const resolveAbortRef = useRef>(new Map()) const enumTypeaheadRef = useRef({ buffer: '', - timer: undefined as ReturnType | undefined - }); + timer: undefined as ReturnType | undefined, + }) // Clear pending debounce/typeahead timers and abort in-flight async // validations on unmount so they don't fire against an unmounted component // (e.g. dialog dismissed mid-debounce or mid-resolve). - useEffect(() => () => { - if (dateDebounceRef.current !== undefined) { - clearTimeout(dateDebounceRef.current); - } - const ta = enumTypeaheadRef.current; - if (ta.timer !== undefined) { - clearTimeout(ta.timer); - } - for (const controller of resolveAbortRef.current.values()) { - controller.abort(); - } - resolveAbortRef.current.clear(); - }, []); - const { - columns, - rows - } = useTerminalSize(); - const currentField = currentFieldIndex !== undefined ? schemaFields[currentFieldIndex] : undefined; - const currentFieldIsText = currentField !== undefined && isTextField(currentField.schema) && !isEnumSchema(currentField.schema); + useEffect( + () => () => { + if (dateDebounceRef.current !== undefined) { + clearTimeout(dateDebounceRef.current) + } + const ta = enumTypeaheadRef.current + if (ta.timer !== undefined) { + clearTimeout(ta.timer) + } + for (const controller of resolveAbortRef.current.values()) { + controller.abort() + } + resolveAbortRef.current.clear() + }, + [], + ) + + const { columns, rows } = useTerminalSize() + + const currentField = + currentFieldIndex !== undefined + ? schemaFields[currentFieldIndex] + : undefined + const currentFieldIsText = + currentField !== undefined && + isTextField(currentField.schema) && + !isEnumSchema(currentField.schema) // Text fields are always in edit mode when focused — no Enter-to-edit step. - const isEditingTextField = currentFieldIsText && !focusedButton; - useRegisterOverlay('elicitation', undefined); - useNotifyAfterTimeout('Claude Code needs your input', 'elicitation_dialog'); + const isEditingTextField = currentFieldIsText && !focusedButton + + useRegisterOverlay('elicitation') + useNotifyAfterTimeout('Claude Code needs your input', 'elicitation_dialog') // Sync textInputValue when the focused field changes - const syncTextInput = useCallback((fieldIndex: number | undefined) => { - if (fieldIndex === undefined) { - setTextInputValue(''); - setTextInputCursorOffset(0); - return; - } - const field = schemaFields[fieldIndex]; - if (field && isTextField(field.schema) && !isEnumSchema(field.schema)) { - const val_0 = formValues[field.name]; - const text = val_0 !== undefined ? String(val_0) : ''; - setTextInputValue(text); - setTextInputCursorOffset(text.length); - } - }, [schemaFields, formValues]); - function validateMultiSelect(fieldName: string, schema_0: PrimitiveSchemaDefinition) { - if (!isMultiSelectEnumSchema(schema_0)) return; - const selected = formValues[fieldName] as string[] | undefined ?? []; - const fieldRequired = schemaFields.find(f => f.name === fieldName)?.isRequired ?? false; - const min = schema_0.minItems; - const max = schema_0.maxItems; + const syncTextInput = useCallback( + (fieldIndex: number | undefined) => { + if (fieldIndex === undefined) { + setTextInputValue('') + setTextInputCursorOffset(0) + return + } + const field = schemaFields[fieldIndex] + if (field && isTextField(field.schema) && !isEnumSchema(field.schema)) { + const val = formValues[field.name] + const text = val !== undefined ? String(val) : '' + setTextInputValue(text) + setTextInputCursorOffset(text.length) + } + }, + [schemaFields, formValues], + ) + + function validateMultiSelect( + fieldName: string, + schema: PrimitiveSchemaDefinition, + ) { + if (!isMultiSelectEnumSchema(schema)) return + const selected = (formValues[fieldName] as string[] | undefined) ?? [] + const fieldRequired = + schemaFields.find(f => f.name === fieldName)?.isRequired ?? false + const min = schema.minItems + const max = schema.maxItems // Skip minItems check when field is optional and unset - if (min !== undefined && selected.length < min && (selected.length > 0 || fieldRequired)) { - updateValidationError(fieldName, `Select at least ${min} ${plural(min, 'item')}`); + if ( + min !== undefined && + selected.length < min && + (selected.length > 0 || fieldRequired) + ) { + updateValidationError( + fieldName, + `Select at least ${min} ${plural(min, 'item')}`, + ) } else if (max !== undefined && selected.length > max) { - updateValidationError(fieldName, `Select at most ${max} ${plural(max, 'item')}`); + updateValidationError( + fieldName, + `Select at most ${max} ${plural(max, 'item')}`, + ) } else { - updateValidationError(fieldName); + updateValidationError(fieldName) } } + function handleNavigation(direction: 'up' | 'down'): void { // Collapse accordion and validate on navigate away if (currentField && isMultiSelectEnumSchema(currentField.schema)) { - validateMultiSelect(currentField.name, currentField.schema); - setExpandedAccordion(undefined); + validateMultiSelect(currentField.name, currentField.schema) + setExpandedAccordion(undefined) } else if (currentField && isEnumSchema(currentField.schema)) { - setExpandedAccordion(undefined); + setExpandedAccordion(undefined) } // Commit current text field before navigating away if (isEditingTextField && currentField) { - commitTextField(currentField.name, currentField.schema, textInputValue); + commitTextField(currentField.name, currentField.schema, textInputValue) // Cancel any pending debounce — we're resolving now on navigate-away if (dateDebounceRef.current !== undefined) { - clearTimeout(dateDebounceRef.current); - dateDebounceRef.current = undefined; + clearTimeout(dateDebounceRef.current) + dateDebounceRef.current = undefined } // For date/datetime fields that failed sync validation, try async NL parsing - if (isDateTimeSchema(currentField.schema) && textInputValue.trim() !== '' && validationErrors[currentField.name]) { - resolveFieldAsync(currentField.name, currentField.schema, textInputValue); + if ( + isDateTimeSchema(currentField.schema) && + textInputValue.trim() !== '' && + validationErrors[currentField.name] + ) { + resolveFieldAsync( + currentField.name, + currentField.schema, + textInputValue, + ) } } // Fields + accept + decline - const itemCount = schemaFields.length + 2; - const index = currentFieldIndex ?? (focusedButton === 'accept' ? schemaFields.length : focusedButton === 'decline' ? schemaFields.length + 1 : undefined); - const nextIndex = index !== undefined ? (index + (direction === 'up' ? itemCount - 1 : 1)) % itemCount : 0; + const itemCount = schemaFields.length + 2 + const index = + currentFieldIndex ?? + (focusedButton === 'accept' + ? schemaFields.length + : focusedButton === 'decline' + ? schemaFields.length + 1 + : undefined) + const nextIndex = + index !== undefined + ? (index + (direction === 'up' ? itemCount - 1 : 1)) % itemCount + : 0 if (nextIndex < schemaFields.length) { - setCurrentFieldIndex(nextIndex); - setFocusedButton(null); - syncTextInput(nextIndex); + setCurrentFieldIndex(nextIndex) + setFocusedButton(null) + syncTextInput(nextIndex) } else { - setCurrentFieldIndex(undefined); - setFocusedButton(nextIndex === schemaFields.length ? 'accept' : 'decline'); - setTextInputValue(''); + setCurrentFieldIndex(undefined) + setFocusedButton(nextIndex === schemaFields.length ? 'accept' : 'decline') + setTextInputValue('') } } - function setField(fieldName_0: string, value: number | string | boolean | string[] | undefined) { + + function setField( + fieldName: string, + value: number | string | boolean | string[] | undefined, + ) { setFormValues(prev => { - const next = { - ...prev - }; + const next = { ...prev } if (value === undefined) { - delete next[fieldName_0]; + delete next[fieldName] } else { - next[fieldName_0] = value; + next[fieldName] = value } - return next; - }); + return next + }) // Clear "required" error when a value is provided - if (value !== undefined && validationErrors[fieldName_0] === 'This field is required') { - updateValidationError(fieldName_0); + if ( + value !== undefined && + validationErrors[fieldName] === 'This field is required' + ) { + updateValidationError(fieldName) } } - function updateValidationError(fieldName_1: string, error?: string) { - setValidationErrors(prev_0 => { - const next_0 = { - ...prev_0 - }; + + function updateValidationError(fieldName: string, error?: string) { + setValidationErrors(prev => { + const next = { ...prev } if (error) { - next_0[fieldName_1] = error; + next[fieldName] = error } else { - delete next_0[fieldName_1]; + delete next[fieldName] } - return next_0; - }); + return next + }) } - function unsetField(fieldName_2: string) { - if (!fieldName_2) return; - setField(fieldName_2, undefined); - updateValidationError(fieldName_2); - setTextInputValue(''); - setTextInputCursorOffset(0); + + function unsetField(fieldName: string) { + if (!fieldName) return + setField(fieldName, undefined) + updateValidationError(fieldName) + setTextInputValue('') + setTextInputCursorOffset(0) } - function commitTextField(fieldName_3: string, schema_1: PrimitiveSchemaDefinition, value_0: string) { - const trimmedValue = value_0.trim(); + + function commitTextField( + fieldName: string, + schema: PrimitiveSchemaDefinition, + value: string, + ) { + const trimmedValue = value.trim() // Empty input for non-plain-string types means unset - if (trimmedValue === '' && (schema_1.type !== 'string' || 'format' in schema_1 && schema_1.format !== undefined)) { - unsetField(fieldName_3); - return; + if ( + trimmedValue === '' && + (schema.type !== 'string' || + ('format' in schema && schema.format !== undefined)) + ) { + unsetField(fieldName) + return } + if (trimmedValue === '') { // Empty plain string — keep or unset depending on whether it was set - if (formValues[fieldName_3] !== undefined) { - setField(fieldName_3, ''); + if (formValues[fieldName] !== undefined) { + setField(fieldName, '') } - return; + return } - const validation_0 = validateElicitationInput(value_0, schema_1); - setField(fieldName_3, validation_0.isValid ? validation_0.value : value_0); - updateValidationError(fieldName_3, validation_0.isValid ? undefined : validation_0.error); + + const validation = validateElicitationInput(value, schema) + setField(fieldName, validation.isValid ? validation.value : value) + updateValidationError( + fieldName, + validation.isValid ? undefined : validation.error, + ) } - function resolveFieldAsync(fieldName_4: string, schema_2: PrimitiveSchemaDefinition, rawValue: string) { - if (!signal) return; + + function resolveFieldAsync( + fieldName: string, + schema: PrimitiveSchemaDefinition, + rawValue: string, + ) { + if (!signal) return // Abort any existing resolution for this field - const existing = resolveAbortRef.current.get(fieldName_4); + const existing = resolveAbortRef.current.get(fieldName) if (existing) { - existing.abort(); + existing.abort() } - const controller_0 = new AbortController(); - resolveAbortRef.current.set(fieldName_4, controller_0); - setResolvingFields(prev_1 => new Set(prev_1).add(fieldName_4)); - void validateElicitationInputAsync(rawValue, schema_2, controller_0.signal).then(result => { - resolveAbortRef.current.delete(fieldName_4); - setResolvingFields(prev_2 => { - const next_1 = new Set(prev_2); - next_1.delete(fieldName_4); - return next_1; - }); - if (controller_0.signal.aborted) return; - if (result.isValid) { - setField(fieldName_4, result.value); - updateValidationError(fieldName_4); - // Update the text input if we're still on this field - const isoText = String(result.value); - setTextInputValue(prev_3 => { - // Only replace if the field is still showing the raw input - if (prev_3 === rawValue) { - setTextInputCursorOffset(isoText.length); - return isoText; - } - return prev_3; - }); - } else { - // Keep raw text, show validation error - updateValidationError(fieldName_4, result.error); - } - }, () => { - resolveAbortRef.current.delete(fieldName_4); - setResolvingFields(prev_4 => { - const next_2 = new Set(prev_4); - next_2.delete(fieldName_4); - return next_2; - }); - }); + + const controller = new AbortController() + resolveAbortRef.current.set(fieldName, controller) + + setResolvingFields(prev => new Set(prev).add(fieldName)) + + void validateElicitationInputAsync( + rawValue, + schema, + controller.signal, + ).then( + result => { + resolveAbortRef.current.delete(fieldName) + setResolvingFields(prev => { + const next = new Set(prev) + next.delete(fieldName) + return next + }) + if (controller.signal.aborted) return + + if (result.isValid) { + setField(fieldName, result.value) + updateValidationError(fieldName) + // Update the text input if we're still on this field + const isoText = String(result.value) + setTextInputValue(prev => { + // Only replace if the field is still showing the raw input + if (prev === rawValue) { + setTextInputCursorOffset(isoText.length) + return isoText + } + return prev + }) + } else { + // Keep raw text, show validation error + updateValidationError(fieldName, result.error) + } + }, + () => { + resolveAbortRef.current.delete(fieldName) + setResolvingFields(prev => { + const next = new Set(prev) + next.delete(fieldName) + return next + }) + }, + ) } + function handleTextInputChange(newValue: string) { - setTextInputValue(newValue); + setTextInputValue(newValue) // Commit immediately on each keystroke (sync validation) if (currentField) { - commitTextField(currentField.name, currentField.schema, newValue); + commitTextField(currentField.name, currentField.schema, newValue) // For date/datetime fields, debounce async NL parsing after 2s of inactivity if (dateDebounceRef.current !== undefined) { - clearTimeout(dateDebounceRef.current); - dateDebounceRef.current = undefined; + clearTimeout(dateDebounceRef.current) + dateDebounceRef.current = undefined } - if (isDateTimeSchema(currentField.schema) && newValue.trim() !== '' && validationErrors[currentField.name]) { - const fieldName_5 = currentField.name; - const schema_3 = currentField.schema; - dateDebounceRef.current = setTimeout((dateDebounceRef_0, resolveFieldAsync_0, fieldName_6, schema_4, newValue_0) => { - dateDebounceRef_0.current = undefined; - resolveFieldAsync_0(fieldName_6, schema_4, newValue_0); - }, 2000, dateDebounceRef, resolveFieldAsync, fieldName_5, schema_3, newValue); + if ( + isDateTimeSchema(currentField.schema) && + newValue.trim() !== '' && + validationErrors[currentField.name] + ) { + const fieldName = currentField.name + const schema = currentField.schema + dateDebounceRef.current = setTimeout( + (dateDebounceRef, resolveFieldAsync, fieldName, schema, newValue) => { + dateDebounceRef.current = undefined + resolveFieldAsync(fieldName, schema, newValue) + }, + 2000, + dateDebounceRef, + resolveFieldAsync, + fieldName, + schema, + newValue, + ) } } } + function handleTextInputSubmit() { - handleNavigation('down'); + handleNavigation('down') } /** @@ -456,290 +573,337 @@ function ElicitationFormDialog({ * call `onMatch` with the index of the first label that prefix-matches. * Shared by boolean y/n, enum accordion, and multi-select accordion. */ - function runTypeahead(char: string, labels: string[], onMatch: (index: number) => void) { - const ta_0 = enumTypeaheadRef.current; - if (ta_0.timer !== undefined) clearTimeout(ta_0.timer); - ta_0.buffer += char.toLowerCase(); - ta_0.timer = setTimeout(resetTypeahead, 2000, ta_0); - const match = labels.findIndex(l => l.startsWith(ta_0.buffer)); - if (match !== -1) onMatch(match); + function runTypeahead( + char: string, + labels: string[], + onMatch: (index: number) => void, + ) { + const ta = enumTypeaheadRef.current + if (ta.timer !== undefined) clearTimeout(ta.timer) + ta.buffer += char.toLowerCase() + ta.timer = setTimeout(resetTypeahead, 2000, ta) + const match = labels.findIndex(l => l.startsWith(ta.buffer)) + if (match !== -1) onMatch(match) } // Esc while a field is focused: cancel the dialog. // Uses Settings context (escape-only, no 'n' key) since Dialog's // Confirmation-context cancel is suppressed when a field is focused. - useKeybinding('confirm:no', () => { - // For text fields, revert uncommitted changes first - if (isEditingTextField && currentField) { - const val_1 = formValues[currentField.name]; - setTextInputValue(val_1 !== undefined ? String(val_1) : ''); - setTextInputCursorOffset(0); - } - onResponse('cancel'); - }, { - context: 'Settings', - isActive: !!currentField && !focusedButton && !expandedAccordion - }); - useInput((_input, key) => { - // Text fields handle their own character input; we only intercept - // navigation keys and backspace-on-empty here. - if (isEditingTextField && !key.upArrow && !key.downArrow && !key.return && !key.backspace) { - return; - } + useKeybinding( + 'confirm:no', + () => { + // For text fields, revert uncommitted changes first + if (isEditingTextField && currentField) { + const val = formValues[currentField.name] + setTextInputValue(val !== undefined ? String(val) : '') + setTextInputCursorOffset(0) + } + onResponse('cancel') + }, + { + context: 'Settings', + isActive: !!currentField && !focusedButton && !expandedAccordion, + }, + ) - // Expanded multi-select accordion - if (expandedAccordion && currentField && isMultiSelectEnumSchema(currentField.schema)) { - const msSchema = currentField.schema; - const msValues = getMultiSelectValues(msSchema); - const selected_0 = formValues[currentField.name] as string[] ?? []; - if (key.leftArrow || key.escape) { - setExpandedAccordion(undefined); - validateMultiSelect(currentField.name, msSchema); - return; + useInput( + (_input, key) => { + // Text fields handle their own character input; we only intercept + // navigation keys and backspace-on-empty here. + if ( + isEditingTextField && + !key.upArrow && + !key.downArrow && + !key.return && + !key.backspace + ) { + return } - if (key.upArrow) { - if (accordionOptionIndex === 0) { - setExpandedAccordion(undefined); - validateMultiSelect(currentField.name, msSchema); - } else { - setAccordionOptionIndex(accordionOptionIndex - 1); + + // Expanded multi-select accordion + if ( + expandedAccordion && + currentField && + isMultiSelectEnumSchema(currentField.schema) + ) { + const msSchema = currentField.schema + const msValues = getMultiSelectValues(msSchema) + const selected = (formValues[currentField.name] as string[]) ?? [] + + if (key.leftArrow || key.escape) { + setExpandedAccordion(undefined) + validateMultiSelect(currentField.name, msSchema) + return } - return; - } - if (key.downArrow) { - if (accordionOptionIndex >= msValues.length - 1) { - setExpandedAccordion(undefined); - handleNavigation('down'); - } else { - setAccordionOptionIndex(accordionOptionIndex + 1); - } - return; - } - if (_input === ' ') { - const optionValue = msValues[accordionOptionIndex]; - if (optionValue !== undefined) { - const newSelected = selected_0.includes(optionValue) ? selected_0.filter(v => v !== optionValue) : [...selected_0, optionValue]; - const newValue_1 = newSelected.length > 0 ? newSelected : undefined; - setField(currentField.name, newValue_1); - const min_0 = msSchema.minItems; - const max_0 = msSchema.maxItems; - if (min_0 !== undefined && newSelected.length < min_0 && (newSelected.length > 0 || currentField.isRequired)) { - updateValidationError(currentField.name, `Select at least ${min_0} ${plural(min_0, 'item')}`); - } else if (max_0 !== undefined && newSelected.length > max_0) { - updateValidationError(currentField.name, `Select at most ${max_0} ${plural(max_0, 'item')}`); + if (key.upArrow) { + if (accordionOptionIndex === 0) { + setExpandedAccordion(undefined) + validateMultiSelect(currentField.name, msSchema) } else { - updateValidationError(currentField.name); + setAccordionOptionIndex(accordionOptionIndex - 1) + } + return + } + if (key.downArrow) { + if (accordionOptionIndex >= msValues.length - 1) { + setExpandedAccordion(undefined) + handleNavigation('down') + } else { + setAccordionOptionIndex(accordionOptionIndex + 1) + } + return + } + if (_input === ' ') { + const optionValue = msValues[accordionOptionIndex] + if (optionValue !== undefined) { + const newSelected = selected.includes(optionValue) + ? selected.filter(v => v !== optionValue) + : [...selected, optionValue] + const newValue = newSelected.length > 0 ? newSelected : undefined + setField(currentField.name, newValue) + const min = msSchema.minItems + const max = msSchema.maxItems + if ( + min !== undefined && + newSelected.length < min && + (newSelected.length > 0 || currentField.isRequired) + ) { + updateValidationError( + currentField.name, + `Select at least ${min} ${plural(min, 'item')}`, + ) + } else if (max !== undefined && newSelected.length > max) { + updateValidationError( + currentField.name, + `Select at most ${max} ${plural(max, 'item')}`, + ) + } else { + updateValidationError(currentField.name) + } + } + return + } + if (key.return) { + // Check (not toggle) the focused item, then collapse and advance + const optionValue = msValues[accordionOptionIndex] + if (optionValue !== undefined && !selected.includes(optionValue)) { + setField(currentField.name, [...selected, optionValue]) + } + setExpandedAccordion(undefined) + handleNavigation('down') + return + } + if (_input) { + const labels = msValues.map(v => + getMultiSelectLabel(msSchema, v).toLowerCase(), + ) + runTypeahead(_input, labels, setAccordionOptionIndex) + return + } + return + } + + // Expanded single-select enum accordion + if ( + expandedAccordion && + currentField && + isEnumSchema(currentField.schema) + ) { + const enumSchema = currentField.schema + const enumValues = getEnumValues(enumSchema) + + if (key.leftArrow || key.escape) { + setExpandedAccordion(undefined) + return + } + if (key.upArrow) { + if (accordionOptionIndex === 0) { + setExpandedAccordion(undefined) + } else { + setAccordionOptionIndex(accordionOptionIndex - 1) + } + return + } + if (key.downArrow) { + if (accordionOptionIndex >= enumValues.length - 1) { + setExpandedAccordion(undefined) + handleNavigation('down') + } else { + setAccordionOptionIndex(accordionOptionIndex + 1) + } + return + } + // Space: select and collapse + if (_input === ' ') { + const optionValue = enumValues[accordionOptionIndex] + if (optionValue !== undefined) { + setField(currentField.name, optionValue) + } + setExpandedAccordion(undefined) + return + } + // Enter: select, collapse, and move to next field + if (key.return) { + const optionValue = enumValues[accordionOptionIndex] + if (optionValue !== undefined) { + setField(currentField.name, optionValue) + } + setExpandedAccordion(undefined) + handleNavigation('down') + return + } + if (_input) { + const labels = enumValues.map(v => + getEnumLabel(enumSchema, v).toLowerCase(), + ) + runTypeahead(_input, labels, setAccordionOptionIndex) + return + } + return + } + + // Accept / Decline buttons + if (key.return && focusedButton === 'accept') { + if (validateRequired() && Object.keys(validationErrors).length === 0) { + onResponse('accept', formValues) + } else { + // Show "required" validation errors on missing fields + const requiredFields = requestedSchema.required || [] + for (const fieldName of requiredFields) { + if (formValues[fieldName] === undefined) { + updateValidationError(fieldName, 'This field is required') + } + } + const firstBadIndex = schemaFields.findIndex( + f => + (requiredFields.includes(f.name) && + formValues[f.name] === undefined) || + validationErrors[f.name] !== undefined, + ) + if (firstBadIndex !== -1) { + setCurrentFieldIndex(firstBadIndex) + setFocusedButton(null) + syncTextInput(firstBadIndex) } } - return; + return } - if (key.return) { - // Check (not toggle) the focused item, then collapse and advance - const optionValue_0 = msValues[accordionOptionIndex]; - if (optionValue_0 !== undefined && !selected_0.includes(optionValue_0)) { - setField(currentField.name, [...selected_0, optionValue_0]); - } - setExpandedAccordion(undefined); - handleNavigation('down'); - return; - } - if (_input) { - const labels_0 = msValues.map(v_0 => getMultiSelectLabel(msSchema, v_0).toLowerCase()); - runTypeahead(_input, labels_0, setAccordionOptionIndex); - return; - } - return; - } - // Expanded single-select enum accordion - if (expandedAccordion && currentField && isEnumSchema(currentField.schema)) { - const enumSchema = currentField.schema; - const enumValues = getEnumValues(enumSchema); - if (key.leftArrow || key.escape) { - setExpandedAccordion(undefined); - return; + if (key.return && focusedButton === 'decline') { + onResponse('decline') + return } - if (key.upArrow) { - if (accordionOptionIndex === 0) { - setExpandedAccordion(undefined); - } else { - setAccordionOptionIndex(accordionOptionIndex - 1); - } - return; - } - if (key.downArrow) { - if (accordionOptionIndex >= enumValues.length - 1) { - setExpandedAccordion(undefined); - handleNavigation('down'); - } else { - setAccordionOptionIndex(accordionOptionIndex + 1); - } - return; - } - // Space: select and collapse - if (_input === ' ') { - const optionValue_1 = enumValues[accordionOptionIndex]; - if (optionValue_1 !== undefined) { - setField(currentField.name, optionValue_1); - } - setExpandedAccordion(undefined); - return; - } - // Enter: select, collapse, and move to next field - if (key.return) { - const optionValue_2 = enumValues[accordionOptionIndex]; - if (optionValue_2 !== undefined) { - setField(currentField.name, optionValue_2); - } - setExpandedAccordion(undefined); - handleNavigation('down'); - return; - } - if (_input) { - const labels_1 = enumValues.map(v_1 => getEnumLabel(enumSchema, v_1).toLowerCase()); - runTypeahead(_input, labels_1, setAccordionOptionIndex); - return; - } - return; - } - // Accept / Decline buttons - if (key.return && focusedButton === 'accept') { - if (validateRequired() && Object.keys(validationErrors).length === 0) { - onResponse('accept', formValues); - } else { - // Show "required" validation errors on missing fields - const requiredFields_0 = requestedSchema.required || []; - for (const fieldName_7 of requiredFields_0) { - if (formValues[fieldName_7] === undefined) { - updateValidationError(fieldName_7, 'This field is required'); + // Up/Down navigation + if (key.upArrow || key.downArrow) { + // Reset enum typeahead when leaving a field + const ta = enumTypeaheadRef.current + ta.buffer = '' + if (ta.timer !== undefined) { + clearTimeout(ta.timer) + ta.timer = undefined + } + handleNavigation(key.upArrow ? 'up' : 'down') + return + } + + // Left/Right to switch between Accept and Decline buttons + if (focusedButton && (key.leftArrow || key.rightArrow)) { + setFocusedButton(focusedButton === 'accept' ? 'decline' : 'accept') + return + } + + if (!currentField) return + const { schema, name } = currentField + const value = formValues[name] + + // Boolean: Space to toggle, Enter to move on + if (schema.type === 'boolean') { + if (_input === ' ') { + setField(name, value === undefined ? true : !value) + return + } + if (key.return) { + handleNavigation('down') + return + } + if (key.backspace && value !== undefined) { + unsetField(name) + return + } + // y/n typeahead + if (_input && !key.return) { + runTypeahead(_input, ['yes', 'no'], i => setField(name, i === 0)) + return + } + return + } + + // Enum or multi-select (collapsed) — accordion style + if (isEnumSchema(schema) || isMultiSelectEnumSchema(schema)) { + if (key.return) { + handleNavigation('down') + return + } + if (key.backspace && value !== undefined) { + unsetField(name) + return + } + // Compute option labels + initial focus index for rightArrow expand. + // Single-select focuses on the current value; multi-select starts at 0. + let labels: string[] + let startIdx = 0 + if (isEnumSchema(schema)) { + const vals = getEnumValues(schema) + labels = vals.map(v => getEnumLabel(schema, v).toLowerCase()) + if (value !== undefined) { + startIdx = Math.max(0, vals.indexOf(value as string)) } + } else { + const vals = getMultiSelectValues(schema) + labels = vals.map(v => getMultiSelectLabel(schema, v).toLowerCase()) } - const firstBadIndex = schemaFields.findIndex(f_0 => requiredFields_0.includes(f_0.name) && formValues[f_0.name] === undefined || validationErrors[f_0.name] !== undefined); - if (firstBadIndex !== -1) { - setCurrentFieldIndex(firstBadIndex); - setFocusedButton(null); - syncTextInput(firstBadIndex); + if (key.rightArrow) { + setExpandedAccordion(name) + setAccordionOptionIndex(startIdx) + return + } + // Typeahead: expand and jump to matching option + if (_input && !key.leftArrow) { + runTypeahead(_input, labels, i => { + setExpandedAccordion(name) + setAccordionOptionIndex(i) + }) + return + } + return + } + + // Backspace: text fields when empty + if (key.backspace) { + if (isEditingTextField && textInputValue === '') { + unsetField(name) + return } } - return; - } - if (key.return && focusedButton === 'decline') { - onResponse('decline'); - return; - } - // Up/Down navigation - if (key.upArrow || key.downArrow) { - // Reset enum typeahead when leaving a field - const ta_1 = enumTypeaheadRef.current; - ta_1.buffer = ''; - if (ta_1.timer !== undefined) { - clearTimeout(ta_1.timer); - ta_1.timer = undefined; - } - handleNavigation(key.upArrow ? 'up' : 'down'); - return; - } + // Text field Enter is handled by TextInput's onSubmit + }, + { isActive: true }, + ) - // Left/Right to switch between Accept and Decline buttons - if (focusedButton && (key.leftArrow || key.rightArrow)) { - setFocusedButton(focusedButton === 'accept' ? 'decline' : 'accept'); - return; - } - if (!currentField) return; - const { - schema: schema_5, - name: name_0 - } = currentField; - const value_1 = formValues[name_0]; - - // Boolean: Space to toggle, Enter to move on - if (schema_5.type === 'boolean') { - if (_input === ' ') { - setField(name_0, value_1 === undefined ? true : !value_1); - return; - } - if (key.return) { - handleNavigation('down'); - return; - } - if (key.backspace && value_1 !== undefined) { - unsetField(name_0); - return; - } - // y/n typeahead - if (_input && !key.return) { - runTypeahead(_input, ['yes', 'no'], i => setField(name_0, i === 0)); - return; - } - return; - } - - // Enum or multi-select (collapsed) — accordion style - if (isEnumSchema(schema_5) || isMultiSelectEnumSchema(schema_5)) { - if (key.return) { - handleNavigation('down'); - return; - } - if (key.backspace && value_1 !== undefined) { - unsetField(name_0); - return; - } - // Compute option labels + initial focus index for rightArrow expand. - // Single-select focuses on the current value; multi-select starts at 0. - let labels_2: string[]; - let startIdx = 0; - if (isEnumSchema(schema_5)) { - const vals = getEnumValues(schema_5); - labels_2 = vals.map(v_2 => getEnumLabel(schema_5, v_2).toLowerCase()); - if (value_1 !== undefined) { - startIdx = Math.max(0, vals.indexOf(value_1 as string)); - } - } else { - const vals_0 = getMultiSelectValues(schema_5); - labels_2 = vals_0.map(v_3 => getMultiSelectLabel(schema_5, v_3).toLowerCase()); - } - if (key.rightArrow) { - setExpandedAccordion(name_0); - setAccordionOptionIndex(startIdx); - return; - } - // Typeahead: expand and jump to matching option - if (_input && !key.leftArrow) { - runTypeahead(_input, labels_2, i_0 => { - setExpandedAccordion(name_0); - setAccordionOptionIndex(i_0); - }); - return; - } - return; - } - - // Backspace: text fields when empty - if (key.backspace) { - if (isEditingTextField && textInputValue === '') { - unsetField(name_0); - return; - } - } - - // Text field Enter is handled by TextInput's onSubmit - }, { - isActive: true - }); function validateRequired(): boolean { - const requiredFields_1 = requestedSchema.required || []; - for (const fieldName_8 of requiredFields_1) { - const value_2 = formValues[fieldName_8]; - if (value_2 === undefined || value_2 === null || value_2 === '') { - return false; + const requiredFields = requestedSchema.required || [] + for (const fieldName of requiredFields) { + const value = formValues[fieldName] + if (value === undefined || value === null || value === '') { + return false } - if (Array.isArray(value_2) && value_2.length === 0) { - return false; + if (Array.isArray(value) && value.length === 0) { + return false } } - return true; + return true } // Scroll windowing: compute visible field range @@ -751,179 +915,268 @@ function ElicitationFormDialog({ // To generalize: track per-field height (3 for collapsed, N+3 for // expanded multi-select) and compute a pixel-budget window instead // of a simple item-count window. - const LINES_PER_FIELD = 3; - const DIALOG_OVERHEAD = 14; - const maxVisibleFields = Math.max(2, Math.floor((rows - DIALOG_OVERHEAD) / LINES_PER_FIELD)); + const LINES_PER_FIELD = 3 + const DIALOG_OVERHEAD = 14 + const maxVisibleFields = Math.max( + 2, + Math.floor((rows - DIALOG_OVERHEAD) / LINES_PER_FIELD), + ) + const scrollWindow = useMemo(() => { - const total = schemaFields.length; + const total = schemaFields.length if (total <= maxVisibleFields) { - return { - start: 0, - end: total - }; + return { start: 0, end: total } } // When buttons are focused (currentFieldIndex undefined), pin to end - const focusIdx = currentFieldIndex ?? total - 1; - let start = Math.max(0, focusIdx - Math.floor(maxVisibleFields / 2)); - const end = Math.min(start + maxVisibleFields, total); + const focusIdx = currentFieldIndex ?? total - 1 + let start = Math.max(0, focusIdx - Math.floor(maxVisibleFields / 2)) + const end = Math.min(start + maxVisibleFields, total) // Adjust start if we hit the bottom - start = Math.max(0, end - maxVisibleFields); - return { - start, - end - }; - }, [schemaFields.length, maxVisibleFields, currentFieldIndex]); - const hasFieldsAbove = scrollWindow.start > 0; - const hasFieldsBelow = scrollWindow.end < schemaFields.length; + start = Math.max(0, end - maxVisibleFields) + return { start, end } + }, [schemaFields.length, maxVisibleFields, currentFieldIndex]) + + const hasFieldsAbove = scrollWindow.start > 0 + const hasFieldsBelow = scrollWindow.end < schemaFields.length + function renderFormFields(): React.ReactNode { - if (!schemaFields.length) return null; - return - {hasFieldsAbove && + if (!schemaFields.length) return null + + return ( + + {hasFieldsAbove && ( + {figures.arrowUp} {scrollWindow.start} more above - } - {schemaFields.slice(scrollWindow.start, scrollWindow.end).map((field_0, visibleIdx) => { - const index_0 = scrollWindow.start + visibleIdx; - const { - name: name_1, - schema: schema_6, - isRequired - } = field_0; - const isActive = index_0 === currentFieldIndex && !focusedButton; - const value_3 = formValues[name_1]; - const hasValue = value_3 !== undefined && (!Array.isArray(value_3) || value_3.length > 0); - const error_0 = validationErrors[name_1]; + + )} + {schemaFields + .slice(scrollWindow.start, scrollWindow.end) + .map((field, visibleIdx) => { + const index = scrollWindow.start + visibleIdx + const { name, schema, isRequired } = field + const isActive = index === currentFieldIndex && !focusedButton + const value = formValues[name] + const hasValue = + value !== undefined && (!Array.isArray(value) || value.length > 0) + const error = validationErrors[name] - // Checkbox: spinner → ⚠ error → ✔ set → * required → space - const isResolving = resolvingFields.has(name_1); - const checkbox = isResolving ? : error_0 ? {figures.warning} : hasValue ? + // Checkbox: spinner → ⚠ error → ✔ set → * required → space + const isResolving = resolvingFields.has(name) + const checkbox = isResolving ? ( + + ) : error ? ( + {figures.warning} + ) : hasValue ? ( + {figures.tick} - : isRequired ? * : ; + + ) : isRequired ? ( + * + ) : ( + + ) - // Selection color matches field status - const selectionColor = error_0 ? 'error' : hasValue ? 'success' : isRequired ? 'error' : 'suggestion'; - const activeColor = isActive ? selectionColor : undefined; - const label = - {schema_6.title || name_1} - ; + // Selection color matches field status + const selectionColor = error + ? 'error' + : hasValue + ? 'success' + : isRequired + ? 'error' + : 'suggestion' - // Render the value portion based on field type - let valueContent: React.ReactNode; - let accordionContent: React.ReactNode = null; - if (isMultiSelectEnumSchema(schema_6)) { - const msValues_0 = getMultiSelectValues(schema_6); - const selected_1 = value_3 as string[] | undefined ?? []; - const isExpanded = expandedAccordion === name_1 && isActive; - if (isExpanded) { - valueContent = {figures.triangleDownSmall}; - accordionContent = - {msValues_0.map((optVal, optIdx) => { - const optLabel = getMultiSelectLabel(schema_6, optVal); - const isChecked = selected_1.includes(optVal); - const isFocused = optIdx === accordionOptionIndex; - return + const activeColor = isActive ? selectionColor : undefined + + const label = ( + + {schema.title || name} + + ) + + // Render the value portion based on field type + let valueContent: React.ReactNode + let accordionContent: React.ReactNode = null + + if (isMultiSelectEnumSchema(schema)) { + const msValues = getMultiSelectValues(schema) + const selected = (value as string[] | undefined) ?? [] + const isExpanded = expandedAccordion === name && isActive + + if (isExpanded) { + valueContent = {figures.triangleDownSmall} + accordionContent = ( + + {msValues.map((optVal, optIdx) => { + const optLabel = getMultiSelectLabel(schema, optVal) + const isChecked = selected.includes(optVal) + const isFocused = optIdx === accordionOptionIndex + return ( + {isFocused ? figures.pointer : ' '} - {isChecked ? figures.checkboxOn : figures.checkboxOff} + {isChecked + ? figures.checkboxOn + : figures.checkboxOff} - + {optLabel} - ; - })} - ; - } else { - // Collapsed: ▸ arrow then comma-joined selected items - const arrow = isActive ? {figures.triangleRightSmall} : null; - if (selected_1.length > 0) { - const displayLabels = selected_1.map(v_4 => getMultiSelectLabel(schema_6, v_4)); - valueContent = + + ) + })} + + ) + } else { + // Collapsed: ▸ arrow then comma-joined selected items + const arrow = isActive ? ( + {figures.triangleRightSmall} + ) : null + if (selected.length > 0) { + const displayLabels = selected.map(v => + getMultiSelectLabel(schema, v), + ) + valueContent = ( + {arrow} {displayLabels.join(', ')} - ; - } else { - valueContent = + + ) + } else { + valueContent = ( + {arrow} not set - ; - } - } - } else if (isEnumSchema(schema_6)) { - const enumValues_0 = getEnumValues(schema_6); - const isExpanded_0 = expandedAccordion === name_1 && isActive; - if (isExpanded_0) { - valueContent = {figures.triangleDownSmall}; - accordionContent = - {enumValues_0.map((optVal_0, optIdx_0) => { - const optLabel_0 = getEnumLabel(schema_6, optVal_0); - const isSelected = value_3 === optVal_0; - const isFocused_0 = optIdx_0 === accordionOptionIndex; - return + + ) + } + } + } else if (isEnumSchema(schema)) { + const enumValues = getEnumValues(schema) + const isExpanded = expandedAccordion === name && isActive + + if (isExpanded) { + valueContent = {figures.triangleDownSmall} + accordionContent = ( + + {enumValues.map((optVal, optIdx) => { + const optLabel = getEnumLabel(schema, optVal) + const isSelected = value === optVal + const isFocused = optIdx === accordionOptionIndex + return ( + - {isFocused_0 ? figures.pointer : ' '} + {isFocused ? figures.pointer : ' '} {isSelected ? figures.radioOn : figures.radioOff} - - {optLabel_0} + + {optLabel} - ; - })} - ; - } else { - // Collapsed: ▸ arrow then current value - const arrow_0 = isActive ? {figures.triangleRightSmall} : null; - if (hasValue) { - valueContent = - {arrow_0} + + ) + })} + + ) + } else { + // Collapsed: ▸ arrow then current value + const arrow = isActive ? ( + {figures.triangleRightSmall} + ) : null + if (hasValue) { + valueContent = ( + + {arrow} - {getEnumLabel(schema_6, value_3 as string)} + {getEnumLabel(schema, value as string)} - ; - } else { - valueContent = - {arrow_0} + + ) + } else { + valueContent = ( + + {arrow} not set - ; - } - } - } else if (schema_6.type === 'boolean') { - if (isActive) { - valueContent = hasValue ? - {value_3 ? figures.checkboxOn : figures.checkboxOff} - : {figures.checkboxOff}; - } else { - valueContent = hasValue ? - {value_3 ? figures.checkboxOn : figures.checkboxOff} - : + + ) + } + } + } else if (schema.type === 'boolean') { + if (isActive) { + valueContent = hasValue ? ( + + {value ? figures.checkboxOn : figures.checkboxOff} + + ) : ( + {figures.checkboxOff} + ) + } else { + valueContent = hasValue ? ( + + {value ? figures.checkboxOn : figures.checkboxOff} + + ) : ( + not set - ; - } - } else if (isTextField(schema_6)) { - if (isActive) { - valueContent = ; - } else { - const displayValue = hasValue && isDateTimeSchema(schema_6) ? formatDateDisplay(String(value_3), schema_6) : String(value_3); - valueContent = hasValue ? {displayValue} : + + ) + } + } else if (isTextField(schema)) { + if (isActive) { + valueContent = ( + + ) + } else { + const displayValue = + hasValue && isDateTimeSchema(schema) + ? formatDateDisplay(String(value), schema) + : String(value) + valueContent = hasValue ? ( + {displayValue} + ) : ( + not set - ; - } - } else { - valueContent = hasValue ? {String(value_3)} : + + ) + } + } else { + valueContent = hasValue ? ( + {String(value)} + ) : ( + not set - ; - } - return + + ) + } + + return ( + {isActive ? figures.pointer : ' '} @@ -936,168 +1189,249 @@ function ElicitationFormDialog({ {accordionContent} - {schema_6.description && - {schema_6.description} - } + {schema.description && ( + + {schema.description} + + )} - {error_0 ? - {error_0} - : } + {error ? ( + + {error} + + ) : ( + + )} - ; - })} - {hasFieldsBelow && + + ) + })} + {hasFieldsBelow && ( + {figures.arrowDown} {schemaFields.length - scrollWindow.end} more below - } - ; + + )} + + ) } - return onResponse('cancel')} isCancelActive={(!currentField || !!focusedButton) && !expandedAccordion} inputGuide={exitState => exitState.pending ? Press {exitState.keyName} again to exit : - + + return ( + onResponse('cancel')} + isCancelActive={(!currentField || !!focusedButton) && !expandedAccordion} + inputGuide={exitState => + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + + - {currentField && } - {currentField && currentField.schema.type === 'boolean' && } - {currentField && isEnumSchema(currentField.schema) && (expandedAccordion ? : )} - {currentField && isMultiSelectEnumSchema(currentField.schema) && (expandedAccordion ? : )} - }> + {currentField && ( + + )} + {currentField && currentField.schema.type === 'boolean' && ( + + )} + {currentField && + isEnumSchema(currentField.schema) && + (expandedAccordion ? ( + + ) : ( + + ))} + {currentField && + isMultiSelectEnumSchema(currentField.schema) && + (expandedAccordion ? ( + + ) : ( + + ))} + + ) + } + > {renderFormFields()} {focusedButton === 'accept' ? figures.pointer : ' '} - + {' Accept '} {focusedButton === 'decline' ? figures.pointer : ' '} - + {' Decline'} - ; + + ) } + function ElicitationURLDialog({ event, onResponse, - onWaitingDismiss + onWaitingDismiss, }: { - event: ElicitationRequestEvent; - onResponse: Props['onResponse']; - onWaitingDismiss: Props['onWaitingDismiss']; + event: ElicitationRequestEvent + onResponse: Props['onResponse'] + onWaitingDismiss: Props['onWaitingDismiss'] }): React.ReactNode { - const { - serverName, - signal, - waitingState - } = event; - const urlParams = event.params as ElicitRequestURLParams; - const { - message, - url - } = urlParams; - const [phase, setPhase] = useState<'prompt' | 'waiting'>('prompt'); - const phaseRef = useRef<'prompt' | 'waiting'>('prompt'); - const [focusedButton, setFocusedButton] = useState<'accept' | 'decline' | 'open' | 'action' | 'cancel'>('accept'); - const showCancel = waitingState?.showCancel ?? false; - useNotifyAfterTimeout('Claude Code needs your input', 'elicitation_url_dialog'); - useRegisterOverlay('elicitation-url', undefined); + const { serverName, signal, waitingState } = event + const urlParams = event.params as ElicitRequestURLParams + const { message, url } = urlParams + const [phase, setPhase] = useState<'prompt' | 'waiting'>('prompt') + const phaseRef = useRef<'prompt' | 'waiting'>('prompt') + const [focusedButton, setFocusedButton] = useState< + 'accept' | 'decline' | 'open' | 'action' | 'cancel' + >('accept') + const showCancel = waitingState?.showCancel ?? false + + useNotifyAfterTimeout( + 'Claude Code needs your input', + 'elicitation_url_dialog', + ) + useRegisterOverlay('elicitation-url') // Keep refs in sync for use in abort handler (avoids re-registering listener) - phaseRef.current = phase; - const onWaitingDismissRef = useRef(onWaitingDismiss); - onWaitingDismissRef.current = onWaitingDismiss; + phaseRef.current = phase + const onWaitingDismissRef = useRef(onWaitingDismiss) + onWaitingDismissRef.current = onWaitingDismiss + useEffect(() => { const handleAbort = () => { if (phaseRef.current === 'waiting') { - onWaitingDismissRef.current?.('cancel'); + onWaitingDismissRef.current?.('cancel') } else { - onResponse('cancel'); + onResponse('cancel') } - }; - if (signal.aborted) { - handleAbort(); - return; } - signal.addEventListener('abort', handleAbort); - return () => signal.removeEventListener('abort', handleAbort); - }, [signal, onResponse]); + if (signal.aborted) { + handleAbort() + return + } + signal.addEventListener('abort', handleAbort) + return () => signal.removeEventListener('abort', handleAbort) + }, [signal, onResponse]) // Parse URL to highlight the domain - let domain = ''; - let urlBeforeDomain = ''; - let urlAfterDomain = ''; + let domain = '' + let urlBeforeDomain = '' + let urlAfterDomain = '' try { - const parsed = new URL(url); - domain = parsed.hostname; - const domainStart = url.indexOf(domain); - urlBeforeDomain = url.slice(0, domainStart); - urlAfterDomain = url.slice(domainStart + domain.length); + const parsed = new URL(url) + domain = parsed.hostname + const domainStart = url.indexOf(domain) + urlBeforeDomain = url.slice(0, domainStart) + urlAfterDomain = url.slice(domainStart + domain.length) } catch { - domain = url; + domain = url } // Auto-dismiss when the server sends a completion notification (sets completed flag) useEffect(() => { if (phase === 'waiting' && event.completed) { - onWaitingDismiss?.(showCancel ? 'retry' : 'dismiss'); + onWaitingDismiss?.(showCancel ? 'retry' : 'dismiss') } - }, [phase, event.completed, onWaitingDismiss, showCancel]); + }, [phase, event.completed, onWaitingDismiss, showCancel]) + const handleAccept = useCallback(() => { - void openBrowser(url); - onResponse('accept'); - setPhase('waiting'); - phaseRef.current = 'waiting'; - setFocusedButton('open'); - }, [onResponse, url]); + void openBrowser(url) + onResponse('accept') + setPhase('waiting') + phaseRef.current = 'waiting' + setFocusedButton('open') + }, [onResponse, url]) // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw input for button navigation useInput((_input, key) => { if (phase === 'prompt') { if (key.leftArrow || key.rightArrow) { - setFocusedButton(prev => prev === 'accept' ? 'decline' : 'accept'); - return; + setFocusedButton(prev => (prev === 'accept' ? 'decline' : 'accept')) + return } if (key.return) { if (focusedButton === 'accept') { - handleAccept(); + handleAccept() } else { - onResponse('decline'); + onResponse('decline') } } } else { // waiting phase — cycle through buttons - type ButtonName = 'accept' | 'decline' | 'open' | 'action' | 'cancel'; - const waitingButtons: readonly ButtonName[] = showCancel ? ['open', 'action', 'cancel'] : ['open', 'action']; + type ButtonName = 'accept' | 'decline' | 'open' | 'action' | 'cancel' + const waitingButtons: readonly ButtonName[] = showCancel + ? ['open', 'action', 'cancel'] + : ['open', 'action'] if (key.leftArrow || key.rightArrow) { - setFocusedButton(prev_0 => { - const idx = waitingButtons.indexOf(prev_0); - const delta = key.rightArrow ? 1 : -1; - return waitingButtons[(idx + delta + waitingButtons.length) % waitingButtons.length]!; - }); - return; + setFocusedButton(prev => { + const idx = waitingButtons.indexOf(prev) + const delta = key.rightArrow ? 1 : -1 + return waitingButtons[ + (idx + delta + waitingButtons.length) % waitingButtons.length + ]! + }) + return } if (key.return) { if (focusedButton === 'open') { - void openBrowser(url); + void openBrowser(url) } else if (focusedButton === 'cancel') { - onWaitingDismiss?.('cancel'); + onWaitingDismiss?.('cancel') } else { - onWaitingDismiss?.(showCancel ? 'retry' : 'dismiss'); + onWaitingDismiss?.(showCancel ? 'retry' : 'dismiss') } } } - }); + }) + if (phase === 'waiting') { - const actionLabel = waitingState?.actionLabel ?? 'Continue without waiting'; - return onWaitingDismiss?.('cancel')} isCancelActive inputGuide={exitState => exitState.pending ? Press {exitState.keyName} again to exit : - + const actionLabel = waitingState?.actionLabel ?? 'Continue without waiting' + return ( + onWaitingDismiss?.('cancel')} + isCancelActive + inputGuide={exitState => + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + + - }> + + ) + } + > @@ -1115,32 +1449,67 @@ function ElicitationURLDialog({ {focusedButton === 'open' ? figures.pointer : ' '} - + {' Reopen URL '} {focusedButton === 'action' ? figures.pointer : ' '} - + {` ${actionLabel}`} - {showCancel && <> + {showCancel && ( + <> {focusedButton === 'cancel' ? figures.pointer : ' '} - + {' Cancel'} - } + + )} - ; + + ) } - return onResponse('cancel')} isCancelActive inputGuide={exitState_0 => exitState_0.pending ? Press {exitState_0.keyName} again to exit : - + + return ( + onResponse('cancel')} + isCancelActive + inputGuide={exitState => + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + + - }> + + ) + } + > @@ -1153,16 +1522,25 @@ function ElicitationURLDialog({ {focusedButton === 'accept' ? figures.pointer : ' '} - + {' Accept '} {focusedButton === 'decline' ? figures.pointer : ' '} - + {' Decline'} - ; + + ) } diff --git a/src/components/mcp/MCPAgentServerMenu.tsx b/src/components/mcp/MCPAgentServerMenu.tsx index e56b99d1f..b02f79dac 100644 --- a/src/components/mcp/MCPAgentServerMenu.tsx +++ b/src/components/mcp/MCPAgentServerMenu.tsx @@ -1,24 +1,29 @@ -import figures from 'figures'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import type { CommandResultDisplay } from '../../commands.js'; -import { Box, color, Link, Text, useTheme } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import { AuthenticationCancelledError, performMCPOAuthFlow } from '../../services/mcp/auth.js'; -import { capitalize } from '../../utils/stringUtils.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; -import { Select } from '../CustomSelect/index.js'; -import { Byline } from '../design-system/Byline.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; -import { Spinner } from '../Spinner.js'; -import type { AgentMcpServerInfo } from './types.js'; +import figures from 'figures' +import React, { useCallback, useEffect, useRef, useState } from 'react' +import type { CommandResultDisplay } from '../../commands.js' +import { Box, color, Link, Text, useTheme } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import { + AuthenticationCancelledError, + performMCPOAuthFlow, +} from '../../services/mcp/auth.js' +import { capitalize } from '../../utils/stringUtils.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +import { Select } from '../CustomSelect/index.js' +import { Byline } from '../design-system/Byline.js' +import { Dialog } from '../design-system/Dialog.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' +import { Spinner } from '../Spinner.js' +import type { AgentMcpServerInfo } from './types.js' + type Props = { - agentServer: AgentMcpServerInfo; - onCancel: () => void; - onComplete?: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; -}; + agentServer: AgentMcpServerInfo + onCancel: () => void + onComplete?: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void +} /** * Menu for agent-specific MCP servers. @@ -28,113 +33,165 @@ type Props = { export function MCPAgentServerMenu({ agentServer, onCancel, - onComplete + onComplete, }: Props): React.ReactNode { - const [theme] = useTheme(); - const [isAuthenticating, setIsAuthenticating] = useState(false); - const [error, setError] = useState(null); - const [authorizationUrl, setAuthorizationUrl] = useState(null); - const authAbortControllerRef = useRef(null); + const [theme] = useTheme() + const [isAuthenticating, setIsAuthenticating] = useState(false) + const [error, setError] = useState(null) + const [authorizationUrl, setAuthorizationUrl] = useState(null) + const authAbortControllerRef = useRef(null) // Abort OAuth flow on unmount so the callback server is closed even if a // parent component's Esc handler navigates away before ours fires. - useEffect(() => () => authAbortControllerRef.current?.abort(), []); + useEffect(() => () => authAbortControllerRef.current?.abort(), []) // Handle ESC to cancel authentication flow const handleEscCancel = useCallback(() => { if (isAuthenticating) { - authAbortControllerRef.current?.abort(); - authAbortControllerRef.current = null; - setIsAuthenticating(false); - setAuthorizationUrl(null); + authAbortControllerRef.current?.abort() + authAbortControllerRef.current = null + setIsAuthenticating(false) + setAuthorizationUrl(null) } - }, [isAuthenticating]); + }, [isAuthenticating]) + useKeybinding('confirm:no', handleEscCancel, { context: 'Confirmation', - isActive: isAuthenticating - }); + isActive: isAuthenticating, + }) + const handleAuthenticate = useCallback(async () => { if (!agentServer.needsAuth || !agentServer.url) { - return; + return } - setIsAuthenticating(true); - setError(null); - const controller = new AbortController(); - authAbortControllerRef.current = controller; + + setIsAuthenticating(true) + setError(null) + + const controller = new AbortController() + authAbortControllerRef.current = controller + try { // Create a temporary config for OAuth const tempConfig = { type: agentServer.transport as 'http' | 'sse', - url: agentServer.url - }; - await performMCPOAuthFlow(agentServer.name, tempConfig, setAuthorizationUrl, controller.signal); - onComplete?.(`Authentication successful for ${agentServer.name}. The server will connect when the agent runs.`); + url: agentServer.url, + } + + await performMCPOAuthFlow( + agentServer.name, + tempConfig, + setAuthorizationUrl, + controller.signal, + ) + + onComplete?.( + `Authentication successful for ${agentServer.name}. The server will connect when the agent runs.`, + ) } catch (err) { // Don't show error if it was a cancellation - if (err instanceof Error && !(err instanceof AuthenticationCancelledError)) { - setError(err.message); + if ( + err instanceof Error && + !(err instanceof AuthenticationCancelledError) + ) { + setError(err.message) } } finally { - setIsAuthenticating(false); - authAbortControllerRef.current = null; + setIsAuthenticating(false) + authAbortControllerRef.current = null } - }, [agentServer, onComplete]); - const capitalizedServerName = capitalize(String(agentServer.name)); + }, [agentServer, onComplete]) + + const capitalizedServerName = capitalize(String(agentServer.name)) + if (isAuthenticating) { - return + return ( + Authenticating with {agentServer.name}… A browser window will open for authentication - {authorizationUrl && + {authorizationUrl && ( + If your browser doesn't open automatically, copy this URL manually: - } + + )} Return here after authenticating in your browser.{' '} - + - ; + + ) } - const menuOptions = []; + + const menuOptions = [] // Only show authenticate option for HTTP/SSE servers if (agentServer.needsAuth) { menuOptions.push({ label: agentServer.isAuthenticated ? 'Re-authenticate' : 'Authenticate', - value: 'auth' - }); + value: 'auth', + }) } + menuOptions.push({ label: 'Back', - value: 'back' - }); - return exitState.pending ? Press {exitState.keyName} again to exit : + value: 'back', + }) + + return ( + + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + - - }> + + + ) + } + > Type: {agentServer.transport} - {agentServer.url && + {agentServer.url && ( + URL: {agentServer.url} - } + + )} - {agentServer.command && + {agentServer.command && ( + Command: {agentServer.command} - } + + )} Used by: @@ -149,34 +206,47 @@ export function MCPAgentServerMenu({ - {agentServer.needsAuth && + {agentServer.needsAuth && ( + Auth: - {agentServer.isAuthenticated ? {color('success', theme)(figures.tick)} authenticated : + {agentServer.isAuthenticated ? ( + {color('success', theme)(figures.tick)} authenticated + ) : ( + {color('warning', theme)(figures.triangleUpOutline)} may need authentication - } - } + + )} + + )} This server connects only when running the agent. - {error && + {error && ( + Error: {error} - } + + )} - { + switch (value) { + case 'auth': + await handleAuthenticate() + break + case 'back': + onCancel() + break + } + }} + onCancel={onCancel} + /> - ; + + ) } diff --git a/src/components/mcp/MCPListPanel.tsx b/src/components/mcp/MCPListPanel.tsx index 075da7e95..af93c5538 100644 --- a/src/components/mcp/MCPListPanel.tsx +++ b/src/components/mcp/MCPListPanel.tsx @@ -1,503 +1,361 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import React, { useCallback, useState } from 'react'; -import type { CommandResultDisplay } from '../../commands.js'; -import { Box, color, Link, Text, useTheme } from '../../ink.js'; -import { useKeybindings } from '../../keybindings/useKeybinding.js'; -import type { ConfigScope } from '../../services/mcp/types.js'; -import { describeMcpConfigFilePath } from '../../services/mcp/utils.js'; -import { isDebugMode } from '../../utils/debug.js'; -import { plural } from '../../utils/stringUtils.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; -import { Byline } from '../design-system/Byline.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; -import { McpParsingWarnings } from './McpParsingWarnings.js'; -import type { AgentMcpServerInfo, ServerInfo } from './types.js'; +import figures from 'figures' +import React, { useCallback, useState } from 'react' +import type { CommandResultDisplay } from '../../commands.js' +import { Box, color, Link, Text, useTheme } from '../../ink.js' +import { useKeybindings } from '../../keybindings/useKeybinding.js' +import type { ConfigScope } from '../../services/mcp/types.js' +import { describeMcpConfigFilePath } from '../../services/mcp/utils.js' +import { isDebugMode } from '../../utils/debug.js' +import { plural } from '../../utils/stringUtils.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +import { Byline } from '../design-system/Byline.js' +import { Dialog } from '../design-system/Dialog.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' +import { McpParsingWarnings } from './McpParsingWarnings.js' +import type { AgentMcpServerInfo, ServerInfo } from './types.js' + type Props = { - servers: ServerInfo[]; - agentServers?: AgentMcpServerInfo[]; - onSelectServer: (server: ServerInfo) => void; - onSelectAgentServer?: (agentServer: AgentMcpServerInfo) => void; - onComplete: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; - defaultTab?: string; -}; -type SelectableItem = { - type: 'server'; - server: ServerInfo; -} | { - type: 'agent-server'; - agentServer: AgentMcpServerInfo; -}; + servers: ServerInfo[] + agentServers?: AgentMcpServerInfo[] + onSelectServer: (server: ServerInfo) => void + onSelectAgentServer?: (agentServer: AgentMcpServerInfo) => void + onComplete: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void + defaultTab?: string +} + +type SelectableItem = + | { type: 'server'; server: ServerInfo } + | { type: 'agent-server'; agentServer: AgentMcpServerInfo } // Define scope order for display (constant, outside component) // 'dynamic' (built-in) is rendered separately at the end -const SCOPE_ORDER: ConfigScope[] = ['project', 'local', 'user', 'enterprise']; +const SCOPE_ORDER: ConfigScope[] = ['project', 'local', 'user', 'enterprise'] // Get scope heading parts (label is bold, path is grey) -function getScopeHeading(scope: ConfigScope): { - label: string; - path?: string; -} { +function getScopeHeading(scope: ConfigScope): { label: string; path?: string } { switch (scope) { case 'project': - return { - label: 'Project MCPs', - path: describeMcpConfigFilePath(scope) - }; + return { label: 'Project MCPs', path: describeMcpConfigFilePath(scope) } case 'user': - return { - label: 'User MCPs', - path: describeMcpConfigFilePath(scope) - }; + return { label: 'User MCPs', path: describeMcpConfigFilePath(scope) } case 'local': - return { - label: 'Local MCPs', - path: describeMcpConfigFilePath(scope) - }; + return { label: 'Local MCPs', path: describeMcpConfigFilePath(scope) } case 'enterprise': - return { - label: 'Enterprise MCPs' - }; + return { label: 'Enterprise MCPs' } case 'dynamic': - return { - label: 'Built-in MCPs', - path: 'always available' - }; + return { label: 'Built-in MCPs', path: 'always available' } default: - return { - label: scope - }; + return { label: scope } } } // Group servers by scope -function groupServersByScope(serverList: ServerInfo[]): Map { - const groups = new Map(); +function groupServersByScope( + serverList: ServerInfo[], +): Map { + const groups = new Map() for (const server of serverList) { - const scope = server.scope; + const scope = server.scope if (!groups.has(scope)) { - groups.set(scope, []); + groups.set(scope, []) } - groups.get(scope)!.push(server); + groups.get(scope)!.push(server) } // Sort servers within each group alphabetically for (const [, groupServers] of groups) { - groupServers.sort((a, b) => a.name.localeCompare(b.name)); + groupServers.sort((a, b) => a.name.localeCompare(b.name)) } - return groups; + return groups } -export function MCPListPanel(t0) { - const $ = _c(78); - const { - servers, - agentServers: t1, - onSelectServer, - onSelectAgentServer, - onComplete - } = t0; - let t2; - if ($[0] !== t1) { - t2 = t1 === undefined ? [] : t1; - $[0] = t1; - $[1] = t2; - } else { - t2 = $[1]; - } - const agentServers = t2; - const [theme] = useTheme(); - const [selectedIndex, setSelectedIndex] = useState(0); - let t3; - if ($[2] !== servers) { - const regularServers = servers.filter(_temp); - t3 = groupServersByScope(regularServers); - $[2] = servers; - $[3] = t3; - } else { - t3 = $[3]; - } - const serversByScope = t3; - let t4; - if ($[4] !== servers) { - t4 = servers.filter(_temp2).sort(_temp3); - $[4] = servers; - $[5] = t4; - } else { - t4 = $[5]; - } - const claudeAiServers = t4; - let t5; - if ($[6] !== serversByScope) { - t5 = (serversByScope.get("dynamic") ?? []).sort(_temp4); - $[6] = serversByScope; - $[7] = t5; - } else { - t5 = $[7]; - } - const dynamicServers = t5; - let t6; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t6 = getScopeHeading("dynamic"); - $[8] = t6; - } else { - t6 = $[8]; - } - const dynamicHeading = t6; - let items; - if ($[9] !== agentServers || $[10] !== claudeAiServers || $[11] !== dynamicServers || $[12] !== serversByScope) { - items = []; + +export function MCPListPanel({ + servers, + agentServers = [], + onSelectServer, + onSelectAgentServer, + onComplete, +}: Props): React.ReactNode { + const [theme] = useTheme() + const [selectedIndex, setSelectedIndex] = useState(0) + + // Non-claudeai servers grouped by scope + const serversByScope = React.useMemo(() => { + const regularServers = servers.filter( + s => s.client.config.type !== 'claudeai-proxy', + ) + return groupServersByScope(regularServers) + }, [servers]) + + const claudeAiServers = React.useMemo( + () => + servers + .filter(s => s.client.config.type === 'claudeai-proxy') + .sort((a, b) => a.name.localeCompare(b.name)), + [servers], + ) + + // Built-in (dynamic) servers - rendered last + const dynamicServers = React.useMemo( + () => + (serversByScope.get('dynamic') ?? []).sort((a, b) => + a.name.localeCompare(b.name), + ), + [serversByScope], + ) + + // Pre-compute dynamic heading for render + const dynamicHeading = getScopeHeading('dynamic') + + // Build flat list of selectable items in display order + const selectableItems = React.useMemo(() => { + const items: SelectableItem[] = [] for (const scope of SCOPE_ORDER) { - const scopeServers = serversByScope.get(scope) ?? []; + const scopeServers = serversByScope.get(scope) ?? [] for (const server of scopeServers) { - items.push({ - type: "server", - server - }); + items.push({ type: 'server', server }) } } - for (const server_0 of claudeAiServers) { - items.push({ - type: "server", - server: server_0 - }); + for (const server of claudeAiServers) { + items.push({ type: 'server', server }) } for (const agentServer of agentServers) { - items.push({ - type: "agent-server", - agentServer - }); + items.push({ type: 'agent-server', agentServer }) } - for (const server_1 of dynamicServers) { - items.push({ - type: "server", - server: server_1 - }); + // Dynamic (built-in) servers come last + for (const server of dynamicServers) { + items.push({ type: 'server', server }) } - $[9] = agentServers; - $[10] = claudeAiServers; - $[11] = dynamicServers; - $[12] = serversByScope; - $[13] = items; - } else { - items = $[13]; + return items + }, [serversByScope, claudeAiServers, agentServers, dynamicServers]) + + const handleCancel = useCallback((): void => { + onComplete('MCP dialog dismissed', { + display: 'system', + }) + }, [onComplete]) + + const handleSelect = useCallback((): void => { + const item = selectableItems[selectedIndex] + if (!item) return + if (item.type === 'server') { + onSelectServer(item.server) + } else if (item.type === 'agent-server' && onSelectAgentServer) { + onSelectAgentServer(item.agentServer) + } + }, [selectableItems, selectedIndex, onSelectServer, onSelectAgentServer]) + + // Use configurable keybindings for navigation and selection + useKeybindings( + { + 'confirm:previous': () => + setSelectedIndex(prev => + prev === 0 ? selectableItems.length - 1 : prev - 1, + ), + 'confirm:next': () => + setSelectedIndex(prev => + prev === selectableItems.length - 1 ? 0 : prev + 1, + ), + 'confirm:yes': handleSelect, + 'confirm:no': handleCancel, + }, + { context: 'Confirmation' }, + ) + + // Build index lookup for each server + const getServerIndex = (server: ServerInfo): number => { + return selectableItems.findIndex( + item => item.type === 'server' && item.server === server, + ) } - const selectableItems = items; - let t7; - if ($[14] !== onComplete) { - t7 = () => { - onComplete("MCP dialog dismissed", { - display: "system" - }); - }; - $[14] = onComplete; - $[15] = t7; - } else { - t7 = $[15]; + + const getAgentServerIndex = (agentServer: AgentMcpServerInfo): number => { + return selectableItems.findIndex( + item => item.type === 'agent-server' && item.agentServer === agentServer, + ) } - const handleCancel = t7; - let t8; - if ($[16] !== onSelectAgentServer || $[17] !== onSelectServer || $[18] !== selectableItems || $[19] !== selectedIndex) { - t8 = () => { - const item = selectableItems[selectedIndex]; - if (!item) { - return; - } - if (item.type === "server") { - onSelectServer(item.server); - } else { - if (item.type === "agent-server" && onSelectAgentServer) { - onSelectAgentServer(item.agentServer); - } - } - }; - $[16] = onSelectAgentServer; - $[17] = onSelectServer; - $[18] = selectableItems; - $[19] = selectedIndex; - $[20] = t8; - } else { - t8 = $[20]; - } - const handleSelect = t8; - let t10; - let t9; - if ($[21] !== selectableItems) { - t9 = () => setSelectedIndex(prev => prev === 0 ? selectableItems.length - 1 : prev - 1); - t10 = () => setSelectedIndex(prev_0 => prev_0 === selectableItems.length - 1 ? 0 : prev_0 + 1); - $[21] = selectableItems; - $[22] = t10; - $[23] = t9; - } else { - t10 = $[22]; - t9 = $[23]; - } - let t11; - if ($[24] !== handleCancel || $[25] !== handleSelect || $[26] !== t10 || $[27] !== t9) { - t11 = { - "confirm:previous": t9, - "confirm:next": t10, - "confirm:yes": handleSelect, - "confirm:no": handleCancel - }; - $[24] = handleCancel; - $[25] = handleSelect; - $[26] = t10; - $[27] = t9; - $[28] = t11; - } else { - t11 = $[28]; - } - let t12; - if ($[29] === Symbol.for("react.memo_cache_sentinel")) { - t12 = { - context: "Confirmation" - }; - $[29] = t12; - } else { - t12 = $[29]; - } - useKeybindings(t11, t12); - let t13; - if ($[30] !== selectableItems) { - t13 = server_2 => selectableItems.findIndex(item_0 => item_0.type === "server" && item_0.server === server_2); - $[30] = selectableItems; - $[31] = t13; - } else { - t13 = $[31]; - } - const getServerIndex = t13; - let t14; - if ($[32] !== selectableItems) { - t14 = agentServer_0 => selectableItems.findIndex(item_1 => item_1.type === "agent-server" && item_1.agentServer === agentServer_0); - $[32] = selectableItems; - $[33] = t14; - } else { - t14 = $[33]; - } - const getAgentServerIndex = t14; - let t15; - if ($[34] === Symbol.for("react.memo_cache_sentinel")) { - t15 = isDebugMode(); - $[34] = t15; - } else { - t15 = $[34]; - } - const debugMode = t15; - let t16; - if ($[35] !== servers) { - t16 = servers.some(_temp5); - $[35] = servers; - $[36] = t16; - } else { - t16 = $[36]; - } - const hasFailedClients = t16; + + const debugMode = isDebugMode() + const hasFailedClients = servers.some(s => s.client.type === 'failed') + if (servers.length === 0 && agentServers.length === 0) { - return null; + return null } - let t17; - if ($[37] !== getServerIndex || $[38] !== selectedIndex || $[39] !== theme) { - t17 = server_3 => { - const index = getServerIndex(server_3); - const isSelected = selectedIndex === index; - let statusIcon; - let statusText; - if (server_3.client.type === "disabled") { - statusIcon = color("inactive", theme)(figures.radioOff); - statusText = "disabled"; + + const renderServerItem = (server: ServerInfo): React.ReactNode => { + const index = getServerIndex(server) + const isSelected = selectedIndex === index + let statusIcon = '' + let statusText = '' + + if (server.client.type === 'disabled') { + statusIcon = color('inactive', theme)(figures.radioOff) + statusText = 'disabled' + } else if (server.client.type === 'connected') { + statusIcon = color('success', theme)(figures.tick) + statusText = 'connected' + } else if (server.client.type === 'pending') { + statusIcon = color('inactive', theme)(figures.radioOff) + const { reconnectAttempt, maxReconnectAttempts } = server.client + if (reconnectAttempt && maxReconnectAttempts) { + statusText = `reconnecting (${reconnectAttempt}/${maxReconnectAttempts})…` } else { - if (server_3.client.type === "connected") { - statusIcon = color("success", theme)(figures.tick); - statusText = "connected"; - } else { - if (server_3.client.type === "pending") { - statusIcon = color("inactive", theme)(figures.radioOff); - const { - reconnectAttempt, - maxReconnectAttempts - } = server_3.client; - if (reconnectAttempt && maxReconnectAttempts) { - statusText = `reconnecting (${reconnectAttempt}/${maxReconnectAttempts})…`; - } else { - statusText = "connecting\u2026"; - } - } else { - if (server_3.client.type === "needs-auth") { - statusIcon = color("warning", theme)(figures.triangleUpOutline); - statusText = "needs authentication"; - } else { - statusIcon = color("error", theme)(figures.cross); - statusText = "failed"; - } - } - } + statusText = 'connecting…' } - return {isSelected ? `${figures.pointer} ` : " "}{server_3.name} · {statusIcon} {statusText}; - }; - $[37] = getServerIndex; - $[38] = selectedIndex; - $[39] = theme; - $[40] = t17; - } else { - t17 = $[40]; + } else if (server.client.type === 'needs-auth') { + statusIcon = color('warning', theme)(figures.triangleUpOutline) + statusText = 'needs authentication' + } else { + statusIcon = color('error', theme)(figures.cross) + statusText = 'failed' + } + + return ( + + + {isSelected ? `${figures.pointer} ` : ' '} + + {server.name} + · {statusIcon} + {statusText} + + ) } - const renderServerItem = t17; - let t18; - if ($[41] !== getAgentServerIndex || $[42] !== selectedIndex || $[43] !== theme) { - t18 = agentServer_1 => { - const index_0 = getAgentServerIndex(agentServer_1); - const isSelected_0 = selectedIndex === index_0; - const statusIcon_0 = agentServer_1.needsAuth ? color("warning", theme)(figures.triangleUpOutline) : color("inactive", theme)(figures.radioOff); - const statusText_0 = agentServer_1.needsAuth ? "may need auth" : "agent-only"; - return {isSelected_0 ? `${figures.pointer} ` : " "}{agentServer_1.name} · {statusIcon_0} {statusText_0}; - }; - $[41] = getAgentServerIndex; - $[42] = selectedIndex; - $[43] = theme; - $[44] = t18; - } else { - t18 = $[44]; + + const renderAgentServerItem = ( + agentServer: AgentMcpServerInfo, + ): React.ReactNode => { + const index = getAgentServerIndex(agentServer) + const isSelected = selectedIndex === index + const statusIcon = agentServer.needsAuth + ? color('warning', theme)(figures.triangleUpOutline) + : color('inactive', theme)(figures.radioOff) + const statusText = agentServer.needsAuth ? 'may need auth' : 'agent-only' + + return ( + + + {isSelected ? `${figures.pointer} ` : ' '} + + + {agentServer.name} + + · {statusIcon} + {statusText} + + ) } - const renderAgentServerItem = t18; - const totalServers = servers.length + agentServers.length; - let t19; - if ($[45] === Symbol.for("react.memo_cache_sentinel")) { - t19 = ; - $[45] = t19; - } else { - t19 = $[45]; - } - let t20; - if ($[46] !== totalServers) { - t20 = plural(totalServers, "server"); - $[46] = totalServers; - $[47] = t20; - } else { - t20 = $[47]; - } - const t21 = `${totalServers} ${t20}`; - let t22; - if ($[48] !== renderServerItem || $[49] !== serversByScope) { - t22 = SCOPE_ORDER.map(scope_0 => { - const scopeServers_0 = serversByScope.get(scope_0); - if (!scopeServers_0 || scopeServers_0.length === 0) { - return null; - } - const heading = getScopeHeading(scope_0); - return {heading.label}{heading.path && ({heading.path})}{scopeServers_0.map(server_4 => renderServerItem(server_4))}; - }); - $[48] = renderServerItem; - $[49] = serversByScope; - $[50] = t22; - } else { - t22 = $[50]; - } - let t23; - if ($[51] !== claudeAiServers || $[52] !== renderServerItem) { - t23 = claudeAiServers.length > 0 && claude.ai{claudeAiServers.map(server_5 => renderServerItem(server_5))}; - $[51] = claudeAiServers; - $[52] = renderServerItem; - $[53] = t23; - } else { - t23 = $[53]; - } - let t24; - if ($[54] !== agentServers || $[55] !== renderAgentServerItem) { - t24 = agentServers.length > 0 && Agent MCPs{[...new Set(agentServers.flatMap(_temp6))].map(agentName => @{agentName}{agentServers.filter(s_3 => s_3.sourceAgents.includes(agentName)).map(agentServer_2 => renderAgentServerItem(agentServer_2))})}; - $[54] = agentServers; - $[55] = renderAgentServerItem; - $[56] = t24; - } else { - t24 = $[56]; - } - let t25; - if ($[57] !== dynamicServers || $[58] !== renderServerItem) { - t25 = dynamicServers.length > 0 && {dynamicHeading.label}{dynamicHeading.path && ({dynamicHeading.path})}{dynamicServers.map(server_6 => renderServerItem(server_6))}; - $[57] = dynamicServers; - $[58] = renderServerItem; - $[59] = t25; - } else { - t25 = $[59]; - } - let t26; - if ($[60] !== hasFailedClients) { - t26 = hasFailedClients && {debugMode ? "\u203B Error logs shown inline with --debug" : "\u203B Run claude --debug to see error logs"}; - $[60] = hasFailedClients; - $[61] = t26; - } else { - t26 = $[61]; - } - let t27; - if ($[62] === Symbol.for("react.memo_cache_sentinel")) { - t27 = https://code.claude.com/docs/en/mcp{" "}for help; - $[62] = t27; - } else { - t27 = $[62]; - } - let t28; - if ($[63] !== t26) { - t28 = {t26}{t27}; - $[63] = t26; - $[64] = t28; - } else { - t28 = $[64]; - } - let t29; - if ($[65] !== t22 || $[66] !== t23 || $[67] !== t24 || $[68] !== t25 || $[69] !== t28) { - t29 = {t22}{t23}{t24}{t25}{t28}; - $[65] = t22; - $[66] = t23; - $[67] = t24; - $[68] = t25; - $[69] = t28; - $[70] = t29; - } else { - t29 = $[70]; - } - let t30; - if ($[71] !== handleCancel || $[72] !== t21 || $[73] !== t29) { - t30 = {t29}; - $[71] = handleCancel; - $[72] = t21; - $[73] = t29; - $[74] = t30; - } else { - t30 = $[74]; - } - let t31; - if ($[75] === Symbol.for("react.memo_cache_sentinel")) { - t31 = ; - $[75] = t31; - } else { - t31 = $[75]; - } - let t32; - if ($[76] !== t30) { - t32 = {t19}{t30}{t31}; - $[76] = t30; - $[77] = t32; - } else { - t32 = $[77]; - } - return t32; -} -function _temp6(s_2) { - return s_2.sourceAgents; -} -function _temp5(s_1) { - return s_1.client.type === "failed"; -} -function _temp4(a_0, b_0) { - return a_0.name.localeCompare(b_0.name); -} -function _temp3(a, b) { - return a.name.localeCompare(b.name); -} -function _temp2(s_0) { - return s_0.client.config.type === "claudeai-proxy"; -} -function _temp(s) { - return s.client.config.type !== "claudeai-proxy"; + + const totalServers = servers.length + agentServers.length + + return ( + + + + + + {/* Regular servers grouped by scope */} + {SCOPE_ORDER.map(scope => { + const scopeServers = serversByScope.get(scope) + if (!scopeServers || scopeServers.length === 0) return null + const heading = getScopeHeading(scope) + return ( + + + {heading.label} + {heading.path && ({heading.path})} + + {scopeServers.map(server => renderServerItem(server))} + + ) + })} + + {/* Claude.ai servers section */} + {claudeAiServers.length > 0 && ( + + + claude.ai + + {claudeAiServers.map(server => renderServerItem(server))} + + )} + + {/* Agent servers section - grouped by source agent */} + {agentServers.length > 0 && ( + + + Agent MCPs + + {/* Group servers by source agent */} + {[...new Set(agentServers.flatMap(s => s.sourceAgents))].map( + agentName => ( + + + @{agentName} + + {agentServers + .filter(s => s.sourceAgents.includes(agentName)) + .map(agentServer => renderAgentServerItem(agentServer))} + + ), + )} + + )} + + {/* Built-in (dynamic) servers section - always last */} + {dynamicServers.length > 0 && ( + + + {dynamicHeading.label} + {dynamicHeading.path && ( + ({dynamicHeading.path}) + )} + + {dynamicServers.map(server => renderServerItem(server))} + + )} + + {/* Footer info */} + + {hasFailedClients && ( + + {debugMode + ? '※ Error logs shown inline with --debug' + : '※ Run claude --debug to see error logs'} + + )} + + + https://code.claude.com/docs/en/mcp + {' '} + for help + + + + + + {/* Custom footer with navigation hint */} + + + + + + + + + + + ) } diff --git a/src/components/mcp/MCPReconnect.tsx b/src/components/mcp/MCPReconnect.tsx index 8ca700239..209c80541 100644 --- a/src/components/mcp/MCPReconnect.tsx +++ b/src/components/mcp/MCPReconnect.tsx @@ -1,166 +1,105 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import React, { useEffect, useState } from 'react'; -import type { CommandResultDisplay } from '../../commands.js'; -import { Box, color, Text, useTheme } from '../../ink.js'; -import { useMcpReconnect } from '../../services/mcp/MCPConnectionManager.js'; -import { useAppStateStore } from '../../state/AppState.js'; -import { Spinner } from '../Spinner.js'; +import figures from 'figures' +import React, { useEffect, useState } from 'react' +import type { CommandResultDisplay } from '../../commands.js' +import { Box, color, Text, useTheme } from '../../ink.js' +import { useMcpReconnect } from '../../services/mcp/MCPConnectionManager.js' +import { useAppStateStore } from '../../state/AppState.js' +import { Spinner } from '../Spinner.js' + type Props = { - serverName: string; - onComplete: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; -}; -export function MCPReconnect(t0) { - const $ = _c(25); - const { - serverName, - onComplete - } = t0; - const [theme] = useTheme(); - const store = useAppStateStore(); - const reconnectMcpServer = useMcpReconnect(); - const [isReconnecting, setIsReconnecting] = useState(true); - const [error, setError] = useState(null); - let t1; - let t2; - if ($[0] !== onComplete || $[1] !== reconnectMcpServer || $[2] !== serverName || $[3] !== store) { - t1 = () => { - const attemptReconnect = async function attemptReconnect() { - ; - try { - const server = store.getState().mcp.clients.find(c => c.name === serverName); - if (!server) { - setError(`MCP server "${serverName}" not found`); - setIsReconnecting(false); - onComplete(`MCP server "${serverName}" not found`); - return; - } - const result = await reconnectMcpServer(serverName); - bb43: switch (result.client.type) { - case "connected": - { - setIsReconnecting(false); - onComplete(`Successfully reconnected to ${serverName}`); - break bb43; - } - case "needs-auth": - { - setError(`${serverName} requires authentication`); - setIsReconnecting(false); - onComplete(`${serverName} requires authentication. Use /mcp to authenticate.`); - break bb43; - } - case "pending": - case "failed": - case "disabled": - { - setError(`Failed to reconnect to ${serverName}`); - setIsReconnecting(false); - onComplete(`Failed to reconnect to ${serverName}`); - } - } - } catch (t3) { - const err = t3; - const errorMessage = err instanceof Error ? err.message : String(err); - setError(errorMessage); - setIsReconnecting(false); - onComplete(`Error: ${errorMessage}`); - } - }; - attemptReconnect(); - }; - t2 = [serverName, reconnectMcpServer, store, onComplete]; - $[0] = onComplete; - $[1] = reconnectMcpServer; - $[2] = serverName; - $[3] = store; - $[4] = t1; - $[5] = t2; - } else { - t1 = $[4]; - t2 = $[5]; - } - useEffect(t1, t2); - if (isReconnecting) { - let t3; - if ($[6] !== serverName) { - t3 = Reconnecting to {serverName}; - $[6] = serverName; - $[7] = t3; - } else { - t3 = $[7]; - } - let t4; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t4 = Establishing connection to MCP server; - $[8] = t4; - } else { - t4 = $[8]; - } - let t5; - if ($[9] !== t3) { - t5 = {t3}{t4}; - $[9] = t3; - $[10] = t5; - } else { - t5 = $[10]; - } - return t5; - } - if (error) { - let t3; - if ($[11] !== theme) { - t3 = color("error", theme)(figures.cross); - $[11] = theme; - $[12] = t3; - } else { - t3 = $[12]; - } - let t4; - if ($[13] !== t3) { - t4 = {t3} ; - $[13] = t3; - $[14] = t4; - } else { - t4 = $[14]; - } - let t5; - if ($[15] !== serverName) { - t5 = Failed to reconnect to {serverName}; - $[15] = serverName; - $[16] = t5; - } else { - t5 = $[16]; - } - let t6; - if ($[17] !== t4 || $[18] !== t5) { - t6 = {t4}{t5}; - $[17] = t4; - $[18] = t5; - $[19] = t6; - } else { - t6 = $[19]; - } - let t7; - if ($[20] !== error) { - t7 = Error: {error}; - $[20] = error; - $[21] = t7; - } else { - t7 = $[21]; - } - let t8; - if ($[22] !== t6 || $[23] !== t7) { - t8 = {t6}{t7}; - $[22] = t6; - $[23] = t7; - $[24] = t8; - } else { - t8 = $[24]; - } - return t8; - } - return null; + serverName: string + onComplete: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void +} + +export function MCPReconnect({ + serverName, + onComplete, +}: Props): React.ReactNode { + const [theme] = useTheme() + const store = useAppStateStore() + const reconnectMcpServer = useMcpReconnect() + const [isReconnecting, setIsReconnecting] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + async function attemptReconnect() { + try { + // Check if server exists. Read via store.getState() instead of a + // reactive selector so this effect does not re-fire when + // reconnectMcpServer updates mcp.clients via onConnectionAttempt. + const server = store + .getState() + .mcp.clients.find(c => c.name === serverName) + if (!server) { + setError(`MCP server "${serverName}" not found`) + setIsReconnecting(false) + onComplete(`MCP server "${serverName}" not found`) + return + } + + // Attempt reconnection + const result = await reconnectMcpServer(serverName) + + switch (result.client.type) { + case 'connected': + setIsReconnecting(false) + onComplete(`Successfully reconnected to ${serverName}`) + break + case 'needs-auth': + setError(`${serverName} requires authentication`) + setIsReconnecting(false) + onComplete( + `${serverName} requires authentication. Use /mcp to authenticate.`, + ) + break + case 'pending': + case 'failed': + case 'disabled': + setError(`Failed to reconnect to ${serverName}`) + setIsReconnecting(false) + onComplete(`Failed to reconnect to ${serverName}`) + break + } + } catch (err) { + // Only catch actual errors (like server not found) + const errorMessage = err instanceof Error ? err.message : String(err) + setError(errorMessage) + setIsReconnecting(false) + onComplete(`Error: ${errorMessage}`) + } + } + + void attemptReconnect() + }, [serverName, reconnectMcpServer, store, onComplete]) + + if (isReconnecting) { + return ( + + + Reconnecting to {serverName} + + + + Establishing connection to MCP server + + + ) + } + + if (error) { + return ( + + + {color('error', theme)(figures.cross)} + Failed to reconnect to {serverName} + + Error: {error} + + ) + } + + return null } diff --git a/src/components/mcp/MCPRemoteServerMenu.tsx b/src/components/mcp/MCPRemoteServerMenu.tsx index e8d8a6a3e..0a8efb76d 100644 --- a/src/components/mcp/MCPRemoteServerMenu.tsx +++ b/src/components/mcp/MCPRemoteServerMenu.tsx @@ -1,74 +1,108 @@ -import figures from 'figures'; -import React, { useEffect, useRef, useState } from 'react'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from 'src/services/analytics/index.js'; -import type { CommandResultDisplay } from '../../commands.js'; -import { getOauthConfig } from '../../constants/oauth.js'; -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { useTerminalSize } from '../../hooks/useTerminalSize.js'; -import { setClipboard } from '../../ink/termio/osc.js'; +import figures from 'figures' +import React, { useEffect, useRef, useState } from 'react' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from 'src/services/analytics/index.js' +import type { CommandResultDisplay } from '../../commands.js' +import { getOauthConfig } from '../../constants/oauth.js' +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import { setClipboard } from '../../ink/termio/osc.js' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- raw j/k/arrow menu navigation -import { Box, color, Link, Text, useInput, useTheme } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import { AuthenticationCancelledError, performMCPOAuthFlow, revokeServerTokens } from '../../services/mcp/auth.js'; -import { clearServerCache } from '../../services/mcp/client.js'; -import { useMcpReconnect, useMcpToggleEnabled } from '../../services/mcp/MCPConnectionManager.js'; -import { describeMcpConfigFilePath, excludeCommandsByServer, excludeResourcesByServer, excludeToolsByServer, filterMcpPromptsByServer } from '../../services/mcp/utils.js'; -import { useAppState, useSetAppState } from '../../state/AppState.js'; -import { getOauthAccountInfo } from '../../utils/auth.js'; -import { openBrowser } from '../../utils/browser.js'; -import { errorMessage } from '../../utils/errors.js'; -import { logMCPDebug } from '../../utils/log.js'; -import { capitalize } from '../../utils/stringUtils.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; -import { Select } from '../CustomSelect/index.js'; -import { Byline } from '../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; -import { Spinner } from '../Spinner.js'; -import TextInput from '../TextInput.js'; -import { CapabilitiesSection } from './CapabilitiesSection.js'; -import type { ClaudeAIServerInfo, HTTPServerInfo, SSEServerInfo } from './types.js'; -import { handleReconnectError, handleReconnectResult } from './utils/reconnectHelpers.js'; +import { Box, color, Link, Text, useInput, useTheme } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import { + AuthenticationCancelledError, + performMCPOAuthFlow, + revokeServerTokens, +} from '../../services/mcp/auth.js' +import { clearServerCache } from '../../services/mcp/client.js' +import { + useMcpReconnect, + useMcpToggleEnabled, +} from '../../services/mcp/MCPConnectionManager.js' +import { + describeMcpConfigFilePath, + excludeCommandsByServer, + excludeResourcesByServer, + excludeToolsByServer, + filterMcpPromptsByServer, +} from '../../services/mcp/utils.js' +import { useAppState, useSetAppState } from '../../state/AppState.js' +import { getOauthAccountInfo } from '../../utils/auth.js' +import { openBrowser } from '../../utils/browser.js' +import { errorMessage } from '../../utils/errors.js' +import { logMCPDebug } from '../../utils/log.js' +import { capitalize } from '../../utils/stringUtils.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +import { Select } from '../CustomSelect/index.js' +import { Byline } from '../design-system/Byline.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' +import { Spinner } from '../Spinner.js' +import TextInput from '../TextInput.js' +import { CapabilitiesSection } from './CapabilitiesSection.js' +import type { + ClaudeAIServerInfo, + HTTPServerInfo, + SSEServerInfo, +} from './types.js' +import { + handleReconnectError, + handleReconnectResult, +} from './utils/reconnectHelpers.js' + type Props = { - server: SSEServerInfo | HTTPServerInfo | ClaudeAIServerInfo; - serverToolsCount: number; - onViewTools: () => void; - onCancel: () => void; - onComplete?: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; - borderless?: boolean; -}; + server: SSEServerInfo | HTTPServerInfo | ClaudeAIServerInfo + serverToolsCount: number + onViewTools: () => void + onCancel: () => void + onComplete?: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void + borderless?: boolean +} + export function MCPRemoteServerMenu({ server, serverToolsCount, onViewTools, onCancel, onComplete, - borderless = false + borderless = false, }: Props): React.ReactNode { - const [theme] = useTheme(); - const exitState = useExitOnCtrlCDWithKeybindings(); - const { - columns: terminalColumns - } = useTerminalSize(); - const [isAuthenticating, setIsAuthenticating] = React.useState(false); - const [error, setError] = React.useState(null); - const mcp = useAppState(s => s.mcp); - const setAppState = useSetAppState(); - const [authorizationUrl, setAuthorizationUrl] = React.useState(null); - const [isReconnecting, setIsReconnecting] = useState(false); - const authAbortControllerRef = useRef(null); - const [isClaudeAIAuthenticating, setIsClaudeAIAuthenticating] = useState(false); - const [claudeAIAuthUrl, setClaudeAIAuthUrl] = useState(null); - const [isClaudeAIClearingAuth, setIsClaudeAIClearingAuth] = useState(false); - const [claudeAIClearAuthUrl, setClaudeAIClearAuthUrl] = useState(null); - const [claudeAIClearAuthBrowserOpened, setClaudeAIClearAuthBrowserOpened] = useState(false); - const [urlCopied, setUrlCopied] = useState(false); - const copyTimeoutRef = useRef | undefined>(undefined); - const unmountedRef = useRef(false); - const [callbackUrlInput, setCallbackUrlInput] = useState(''); - const [callbackUrlCursorOffset, setCallbackUrlCursorOffset] = useState(0); - const [manualCallbackSubmit, setManualCallbackSubmit] = useState<((url: string) => void) | null>(null); + const [theme] = useTheme() + const exitState = useExitOnCtrlCDWithKeybindings() + const { columns: terminalColumns } = useTerminalSize() + const [isAuthenticating, setIsAuthenticating] = React.useState(false) + const [error, setError] = React.useState(null) + const mcp = useAppState(s => s.mcp) + const setAppState = useSetAppState() + const [authorizationUrl, setAuthorizationUrl] = React.useState( + null, + ) + const [isReconnecting, setIsReconnecting] = useState(false) + const authAbortControllerRef = useRef(null) + const [isClaudeAIAuthenticating, setIsClaudeAIAuthenticating] = + useState(false) + const [claudeAIAuthUrl, setClaudeAIAuthUrl] = useState(null) + const [isClaudeAIClearingAuth, setIsClaudeAIClearingAuth] = useState(false) + const [claudeAIClearAuthUrl, setClaudeAIClearAuthUrl] = useState< + string | null + >(null) + const [claudeAIClearAuthBrowserOpened, setClaudeAIClearAuthBrowserOpened] = + useState(false) + const [urlCopied, setUrlCopied] = useState(false) + const copyTimeoutRef = useRef | undefined>( + undefined, + ) + const unmountedRef = useRef(false) + const [callbackUrlInput, setCallbackUrlInput] = useState('') + const [callbackUrlCursorOffset, setCallbackUrlCursorOffset] = useState(0) + const [manualCallbackSubmit, setManualCallbackSubmit] = useState< + ((url: string) => void) | null + >(null) // If the component unmounts mid-auth (e.g. a parent component's Esc handler // navigates away before ours fires), abort the OAuth flow so the callback @@ -76,58 +110,73 @@ export function MCPRemoteServerMenu({ // can outlive the terminal. Also clear the copy-feedback timer and mark // unmounted so the async setClipboard callback doesn't setUrlCopied / // schedule a new timer after unmount. - useEffect(() => () => { - unmountedRef.current = true; - authAbortControllerRef.current?.abort(); - if (copyTimeoutRef.current !== undefined) { - clearTimeout(copyTimeoutRef.current); - } - }, []); + useEffect( + () => () => { + unmountedRef.current = true + authAbortControllerRef.current?.abort() + if (copyTimeoutRef.current !== undefined) { + clearTimeout(copyTimeoutRef.current) + } + }, + [], + ) // A server is effectively authenticated if: // 1. It has OAuth tokens (server.isAuthenticated), OR // 2. It's connected and has tools (meaning it's working via some auth mechanism) - const isEffectivelyAuthenticated = server.isAuthenticated || server.client.type === 'connected' && serverToolsCount > 0; - const reconnectMcpServer = useMcpReconnect(); + const isEffectivelyAuthenticated = + server.isAuthenticated || + (server.client.type === 'connected' && serverToolsCount > 0) + + const reconnectMcpServer = useMcpReconnect() + const handleClaudeAIAuthComplete = React.useCallback(async () => { - setIsClaudeAIAuthenticating(false); - setClaudeAIAuthUrl(null); - setIsReconnecting(true); + setIsClaudeAIAuthenticating(false) + setClaudeAIAuthUrl(null) + setIsReconnecting(true) try { - const result = await reconnectMcpServer(server.name); - const success = result.client.type === 'connected'; - logEvent('tengu_claudeai_mcp_auth_completed', { - success - }); + const result = await reconnectMcpServer(server.name) + const success = result.client.type === 'connected' + logEvent('tengu_claudeai_mcp_auth_completed', { success }) if (success) { - onComplete?.(`Authentication successful. Connected to ${server.name}.`); + onComplete?.(`Authentication successful. Connected to ${server.name}.`) } else if (result.client.type === 'needs-auth') { - onComplete?.('Authentication successful, but server still requires authentication. You may need to manually restart Claude Code.'); + onComplete?.( + 'Authentication successful, but server still requires authentication. You may need to manually restart Claude Code.', + ) } else { - onComplete?.('Authentication successful, but server reconnection failed. You may need to manually restart Claude Code for the changes to take effect.'); + onComplete?.( + 'Authentication successful, but server reconnection failed. You may need to manually restart Claude Code for the changes to take effect.', + ) } } catch (err) { - logEvent('tengu_claudeai_mcp_auth_completed', { - success: false - }); - onComplete?.(handleReconnectError(err, server.name)); + logEvent('tengu_claudeai_mcp_auth_completed', { success: false }) + onComplete?.(handleReconnectError(err, server.name)) } finally { - setIsReconnecting(false); + setIsReconnecting(false) } - }, [reconnectMcpServer, server.name, onComplete]); + }, [reconnectMcpServer, server.name, onComplete]) + const handleClaudeAIClearAuthComplete = React.useCallback(async () => { await clearServerCache(server.name, { ...server.config, - scope: server.scope - }); + scope: server.scope, + }) + setAppState(prev => { - const newClients = prev.mcp.clients.map(c => c.name === server.name ? { - ...c, - type: 'needs-auth' as const - } : c); - const newTools = excludeToolsByServer(prev.mcp.tools, server.name); - const newCommands = excludeCommandsByServer(prev.mcp.commands, server.name); - const newResources = excludeResourcesByServer(prev.mcp.resources, server.name); + const newClients = prev.mcp.clients.map(c => + c.name === server.name ? { ...c, type: 'needs-auth' as const } : c, + ) + const newTools = excludeToolsByServer(prev.mcp.tools, server.name) + const newCommands = excludeCommandsByServer( + prev.mcp.commands, + server.name, + ) + const newResources = excludeResourcesByServer( + prev.mcp.resources, + server.name, + ) + return { ...prev, mcp: { @@ -135,311 +184,445 @@ export function MCPRemoteServerMenu({ clients: newClients, tools: newTools, commands: newCommands, - resources: newResources - } - }; - }); - logEvent('tengu_claudeai_mcp_clear_auth_completed', {}); - onComplete?.(`Disconnected from ${server.name}.`); - setIsClaudeAIClearingAuth(false); - setClaudeAIClearAuthUrl(null); - setClaudeAIClearAuthBrowserOpened(false); - }, [server.name, server.config, server.scope, setAppState, onComplete]); + resources: newResources, + }, + } + }) + + logEvent('tengu_claudeai_mcp_clear_auth_completed', {}) + onComplete?.(`Disconnected from ${server.name}.`) + setIsClaudeAIClearingAuth(false) + setClaudeAIClearAuthUrl(null) + setClaudeAIClearAuthBrowserOpened(false) + }, [server.name, server.config, server.scope, setAppState, onComplete]) // Escape to cancel authentication flow - useKeybinding('confirm:no', () => { - authAbortControllerRef.current?.abort(); - authAbortControllerRef.current = null; - setIsAuthenticating(false); - setAuthorizationUrl(null); - }, { - context: 'Confirmation', - isActive: isAuthenticating - }); + useKeybinding( + 'confirm:no', + () => { + authAbortControllerRef.current?.abort() + authAbortControllerRef.current = null + setIsAuthenticating(false) + setAuthorizationUrl(null) + }, + { + context: 'Confirmation', + isActive: isAuthenticating, + }, + ) // Escape to cancel Claude AI authentication - useKeybinding('confirm:no', () => { - setIsClaudeAIAuthenticating(false); - setClaudeAIAuthUrl(null); - }, { - context: 'Confirmation', - isActive: isClaudeAIAuthenticating - }); + useKeybinding( + 'confirm:no', + () => { + setIsClaudeAIAuthenticating(false) + setClaudeAIAuthUrl(null) + }, + { + context: 'Confirmation', + isActive: isClaudeAIAuthenticating, + }, + ) // Escape to cancel Claude AI clear auth - useKeybinding('confirm:no', () => { - setIsClaudeAIClearingAuth(false); - setClaudeAIClearAuthUrl(null); - setClaudeAIClearAuthBrowserOpened(false); - }, { - context: 'Confirmation', - isActive: isClaudeAIClearingAuth - }); + useKeybinding( + 'confirm:no', + () => { + setIsClaudeAIClearingAuth(false) + setClaudeAIClearAuthUrl(null) + setClaudeAIClearAuthBrowserOpened(false) + }, + { + context: 'Confirmation', + isActive: isClaudeAIClearingAuth, + }, + ) // Return key handling for authentication flows and 'c' to copy URL useInput((input, key) => { if (key.return && isClaudeAIAuthenticating) { - void handleClaudeAIAuthComplete(); + void handleClaudeAIAuthComplete() } if (key.return && isClaudeAIClearingAuth) { if (claudeAIClearAuthBrowserOpened) { - void handleClaudeAIClearAuthComplete(); + void handleClaudeAIClearAuthComplete() } else { // First Enter: open the browser - const connectorsUrl = `${getOauthConfig().CLAUDE_AI_ORIGIN}/settings/connectors`; - setClaudeAIClearAuthUrl(connectorsUrl); - setClaudeAIClearAuthBrowserOpened(true); - void openBrowser(connectorsUrl); + const connectorsUrl = `${getOauthConfig().CLAUDE_AI_ORIGIN}/settings/connectors` + setClaudeAIClearAuthUrl(connectorsUrl) + setClaudeAIClearAuthBrowserOpened(true) + void openBrowser(connectorsUrl) } } if (input === 'c' && !urlCopied) { - const urlToCopy = authorizationUrl || claudeAIAuthUrl || claudeAIClearAuthUrl; + const urlToCopy = + authorizationUrl || claudeAIAuthUrl || claudeAIClearAuthUrl if (urlToCopy) { void setClipboard(urlToCopy).then(raw => { - if (unmountedRef.current) return; - if (raw) process.stdout.write(raw); - setUrlCopied(true); + if (unmountedRef.current) return + if (raw) process.stdout.write(raw) + setUrlCopied(true) if (copyTimeoutRef.current !== undefined) { - clearTimeout(copyTimeoutRef.current); + clearTimeout(copyTimeoutRef.current) } - copyTimeoutRef.current = setTimeout(setUrlCopied, 2000, false); - }); + copyTimeoutRef.current = setTimeout(setUrlCopied, 2000, false) + }) } } - }); - const capitalizedServerName = capitalize(String(server.name)); + }) + + const capitalizedServerName = capitalize(String(server.name)) // Count MCP prompts for this server (skills are shown in /skills, not here) - const serverCommandsCount = filterMcpPromptsByServer(mcp.commands, server.name).length; - const toggleMcpServer = useMcpToggleEnabled(); + const serverCommandsCount = filterMcpPromptsByServer( + mcp.commands, + server.name, + ).length + + const toggleMcpServer = useMcpToggleEnabled() + const handleClaudeAIAuth = React.useCallback(async () => { - const claudeAiBaseUrl = getOauthConfig().CLAUDE_AI_ORIGIN; - const accountInfo = getOauthAccountInfo(); - const orgUuid = accountInfo?.organizationUuid; - let authUrl: string; - if (orgUuid && server.config.type === 'claudeai-proxy' && server.config.id) { + const claudeAiBaseUrl = getOauthConfig().CLAUDE_AI_ORIGIN + const accountInfo = getOauthAccountInfo() + const orgUuid = accountInfo?.organizationUuid + + let authUrl: string + if ( + orgUuid && + server.config.type === 'claudeai-proxy' && + server.config.id + ) { // Use the direct auth URL with org and server IDs // Replace 'mcprs' prefix with 'mcpsrv' if present - const serverId = server.config.id.startsWith('mcprs') ? 'mcpsrv' + server.config.id.slice(5) : server.config.id; - const productSurface = encodeURIComponent(process.env.CLAUDE_CODE_ENTRYPOINT || 'cli'); - authUrl = `${claudeAiBaseUrl}/api/organizations/${orgUuid}/mcp/start-auth/${serverId}?product_surface=${productSurface}`; + const serverId = server.config.id.startsWith('mcprs') + ? 'mcpsrv' + server.config.id.slice(5) + : server.config.id + const productSurface = encodeURIComponent( + process.env.CLAUDE_CODE_ENTRYPOINT || 'cli', + ) + authUrl = `${claudeAiBaseUrl}/api/organizations/${orgUuid}/mcp/start-auth/${serverId}?product_surface=${productSurface}` } else { // Fall back to settings/connectors if we don't have the required IDs - authUrl = `${claudeAiBaseUrl}/settings/connectors`; + authUrl = `${claudeAiBaseUrl}/settings/connectors` } - setClaudeAIAuthUrl(authUrl); - setIsClaudeAIAuthenticating(true); - logEvent('tengu_claudeai_mcp_auth_started', {}); - await openBrowser(authUrl); - }, [server.config]); + + setClaudeAIAuthUrl(authUrl) + setIsClaudeAIAuthenticating(true) + logEvent('tengu_claudeai_mcp_auth_started', {}) + await openBrowser(authUrl) + }, [server.config]) + const handleClaudeAIClearAuth = React.useCallback(() => { - setIsClaudeAIClearingAuth(true); - logEvent('tengu_claudeai_mcp_clear_auth_started', {}); - }, []); + setIsClaudeAIClearingAuth(true) + logEvent('tengu_claudeai_mcp_clear_auth_started', {}) + }, []) + const handleToggleEnabled = React.useCallback(async () => { - const wasEnabled = server.client.type !== 'disabled'; + const wasEnabled = server.client.type !== 'disabled' + try { - await toggleMcpServer(server.name); + await toggleMcpServer(server.name) + if (server.config.type === 'claudeai-proxy') { logEvent('tengu_claudeai_mcp_toggle', { - new_state: (wasEnabled ? 'disabled' : 'enabled') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS - }); + new_state: (wasEnabled + ? 'disabled' + : 'enabled') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + }) } // Return to the server list so user can continue managing other servers - onCancel(); - } catch (err_0) { - const action = wasEnabled ? 'disable' : 'enable'; - onComplete?.(`Failed to ${action} MCP server '${server.name}': ${errorMessage(err_0)}`); + onCancel() + } catch (err) { + const action = wasEnabled ? 'disable' : 'enable' + onComplete?.( + `Failed to ${action} MCP server '${server.name}': ${errorMessage(err)}`, + ) } - }, [server.client.type, server.config.type, server.name, toggleMcpServer, onCancel, onComplete]); + }, [ + server.client.type, + server.config.type, + server.name, + toggleMcpServer, + onCancel, + onComplete, + ]) + const handleAuthenticate = React.useCallback(async () => { - if (server.config.type === 'claudeai-proxy') return; - setIsAuthenticating(true); - setError(null); - const controller = new AbortController(); - authAbortControllerRef.current = controller; + if (server.config.type === 'claudeai-proxy') return + + setIsAuthenticating(true) + setError(null) + + const controller = new AbortController() + authAbortControllerRef.current = controller + try { // Revoke existing tokens if re-authenticating, but preserve step-up // auth state so the next OAuth flow can reuse cached scope/discovery. if (server.isAuthenticated && server.config) { await revokeServerTokens(server.name, server.config, { - preserveStepUpState: true - }); + preserveStepUpState: true, + }) } + if (server.config) { - await performMCPOAuthFlow(server.name, server.config, setAuthorizationUrl, controller.signal, { - onWaitingForCallback: submit => { - setManualCallbackSubmit(() => submit); - } - }); + await performMCPOAuthFlow( + server.name, + server.config, + setAuthorizationUrl, + controller.signal, + { + onWaitingForCallback: submit => { + setManualCallbackSubmit(() => submit) + }, + }, + ) + logEvent('tengu_mcp_auth_config_authenticate', { - wasAuthenticated: server.isAuthenticated - }); - const result_0 = await reconnectMcpServer(server.name); - if (result_0.client.type === 'connected') { - const message = isEffectivelyAuthenticated ? `Authentication successful. Reconnected to ${server.name}.` : `Authentication successful. Connected to ${server.name}.`; - onComplete?.(message); - } else if (result_0.client.type === 'needs-auth') { - onComplete?.('Authentication successful, but server still requires authentication. You may need to manually restart Claude Code.'); + wasAuthenticated: server.isAuthenticated, + }) + + const result = await reconnectMcpServer(server.name) + + if (result.client.type === 'connected') { + const message = isEffectivelyAuthenticated + ? `Authentication successful. Reconnected to ${server.name}.` + : `Authentication successful. Connected to ${server.name}.` + onComplete?.(message) + } else if (result.client.type === 'needs-auth') { + onComplete?.( + 'Authentication successful, but server still requires authentication. You may need to manually restart Claude Code.', + ) } else { // result.client.type === 'failed' - logMCPDebug(server.name, `Reconnection failed after authentication`); - onComplete?.('Authentication successful, but server reconnection failed. You may need to manually restart Claude Code for the changes to take effect.'); + logMCPDebug(server.name, `Reconnection failed after authentication`) + onComplete?.( + 'Authentication successful, but server reconnection failed. You may need to manually restart Claude Code for the changes to take effect.', + ) } } - } catch (err_1) { + } catch (err) { // Don't show error if it was a cancellation - if (err_1 instanceof Error && !(err_1 instanceof AuthenticationCancelledError)) { - setError(err_1.message); + if ( + err instanceof Error && + !(err instanceof AuthenticationCancelledError) + ) { + setError(err.message) } } finally { - setIsAuthenticating(false); - authAbortControllerRef.current = null; - setManualCallbackSubmit(null); - setCallbackUrlInput(''); + setIsAuthenticating(false) + authAbortControllerRef.current = null + setManualCallbackSubmit(null) + setCallbackUrlInput('') } - }, [server.isAuthenticated, server.config, server.name, onComplete, reconnectMcpServer, isEffectivelyAuthenticated]); + }, [ + server.isAuthenticated, + server.config, + server.name, + onComplete, + reconnectMcpServer, + isEffectivelyAuthenticated, + ]) + const handleClearAuth = async () => { - if (server.config.type === 'claudeai-proxy') return; + if (server.config.type === 'claudeai-proxy') return + if (server.config) { // First revoke the authentication tokens and clear all auth state - await revokeServerTokens(server.name, server.config); - logEvent('tengu_mcp_auth_config_clear', {}); + await revokeServerTokens(server.name, server.config) + logEvent('tengu_mcp_auth_config_clear', {}) // Disconnect the client and clear the cache await clearServerCache(server.name, { ...server.config, - scope: server.scope - }); + scope: server.scope, + }) // Update app state to remove the disconnected server's tools, commands, and resources - setAppState(prev_0 => { - const newClients_0 = prev_0.mcp.clients.map(c_0 => - // 'failed' is a misnomer here, but we don't really differentiate between "not connected" and "failed" at the moment - c_0.name === server.name ? { - ...c_0, - type: 'failed' as const - } : c_0); - const newTools_0 = excludeToolsByServer(prev_0.mcp.tools, server.name); - const newCommands_0 = excludeCommandsByServer(prev_0.mcp.commands, server.name); - const newResources_0 = excludeResourcesByServer(prev_0.mcp.resources, server.name); + setAppState(prev => { + const newClients = prev.mcp.clients.map(c => + // 'failed' is a misnomer here, but we don't really differentiate between "not connected" and "failed" at the moment + c.name === server.name ? { ...c, type: 'failed' as const } : c, + ) + const newTools = excludeToolsByServer(prev.mcp.tools, server.name) + const newCommands = excludeCommandsByServer( + prev.mcp.commands, + server.name, + ) + const newResources = excludeResourcesByServer( + prev.mcp.resources, + server.name, + ) + return { - ...prev_0, + ...prev, mcp: { - ...prev_0.mcp, - clients: newClients_0, - tools: newTools_0, - commands: newCommands_0, - resources: newResources_0 - } - }; - }); - onComplete?.(`Authentication cleared for ${server.name}.`); + ...prev.mcp, + clients: newClients, + tools: newTools, + commands: newCommands, + resources: newResources, + }, + } + }) + + onComplete?.(`Authentication cleared for ${server.name}.`) } - }; + } + if (isAuthenticating) { // XAA: silent exchange (cached id_token → no browser), so don't claim // one will open. If IdP login IS needed, authorizationUrl populates and // the URL fallback block below still renders. - const authCopy = server.config.type !== 'claudeai-proxy' && server.config.oauth?.xaa ? ' Authenticating via your identity provider' : ' A browser window will open for authentication'; - return + const authCopy = + server.config.type !== 'claudeai-proxy' && server.config.oauth?.xaa + ? ' Authenticating via your identity provider' + : ' A browser window will open for authentication' + return ( + Authenticating with {server.name}… {authCopy} - {authorizationUrl && + {authorizationUrl && ( + If your browser doesn't open automatically, copy this URL manually{' '} - {urlCopied ? (Copied!) : + {urlCopied ? ( + (Copied!) + ) : ( + - } + + )} - } - {isAuthenticating && authorizationUrl && manualCallbackSubmit && + + )} + {isAuthenticating && authorizationUrl && manualCallbackSubmit && ( + If the redirect page shows a connection error, paste the URL from your browser's address bar: URL {'>'} - { - manualCallbackSubmit(value.trim()); - setCallbackUrlInput(''); - }} cursorOffset={callbackUrlCursorOffset} onChangeCursorOffset={setCallbackUrlCursorOffset} columns={terminalColumns - 8} /> + { + manualCallbackSubmit(value.trim()) + setCallbackUrlInput('') + }} + cursorOffset={callbackUrlCursorOffset} + onChangeCursorOffset={setCallbackUrlCursorOffset} + columns={terminalColumns - 8} + /> - } + + )} Return here after authenticating in your browser. Press Esc to go back. - ; + + ) } + if (isClaudeAIAuthenticating) { - return + return ( + Authenticating with {server.name}… A browser window will open for authentication - {claudeAIAuthUrl && + {claudeAIAuthUrl && ( + If your browser doesn't open automatically, copy this URL manually{' '} - {urlCopied ? (Copied!) : + {urlCopied ? ( + (Copied!) + ) : ( + - } + + )} - } + + )} Press Enter after authenticating in your browser. - + - ; + + ) } + if (isClaudeAIClearingAuth) { - return + return ( + Clear authentication for {server.name} - {claudeAIClearAuthBrowserOpened ? <> + {claudeAIClearAuthBrowserOpened ? ( + <> Find the MCP server in the browser and click "Disconnect". - {claudeAIClearAuthUrl && + {claudeAIClearAuthUrl && ( + If your browser didn't open automatically, copy this URL manually{' '} - {urlCopied ? (Copied!) : + {urlCopied ? ( + (Copied!) + ) : ( + - } + + )} - } + + )} Press Enter when done. - + - : <> + + ) : ( + <> This will open claude.ai in the browser. Find the MCP server in the list and click "Disconnect". @@ -449,14 +632,23 @@ export function MCPRemoteServerMenu({ Press Enter to open the browser. - + - } - ; + + )} + + ) } + if (isReconnecting) { - return + return ( + Connecting to {server.name} @@ -465,75 +657,87 @@ export function MCPRemoteServerMenu({ Establishing connection to MCP server This may take a few moments. - ; + + ) } - const menuOptions = []; + + const menuOptions = [] // If server is disabled, show Enable first as the primary action if (server.client.type === 'disabled') { menuOptions.push({ label: 'Enable', - value: 'toggle-enabled' - }); + value: 'toggle-enabled', + }) } + if (server.client.type === 'connected' && serverToolsCount > 0) { menuOptions.push({ label: 'View tools', - value: 'tools' - }); + value: 'tools', + }) } + if (server.config.type === 'claudeai-proxy') { if (server.client.type === 'connected') { menuOptions.push({ label: 'Clear authentication', - value: 'claudeai-clear-auth' - }); + value: 'claudeai-clear-auth', + }) } else if (server.client.type !== 'disabled') { menuOptions.push({ label: 'Authenticate', - value: 'claudeai-auth' - }); + value: 'claudeai-auth', + }) } } else { if (isEffectivelyAuthenticated) { menuOptions.push({ label: 'Re-authenticate', - value: 'reauth' - }); + value: 'reauth', + }) menuOptions.push({ label: 'Clear authentication', - value: 'clear-auth' - }); + value: 'clear-auth', + }) } + if (!isEffectivelyAuthenticated) { menuOptions.push({ label: 'Authenticate', - value: 'auth' - }); + value: 'auth', + }) } } + if (server.client.type !== 'disabled') { if (server.client.type !== 'needs-auth') { menuOptions.push({ label: 'Reconnect', - value: 'reconnectMcpServer' - }); + value: 'reconnectMcpServer', + }) } menuOptions.push({ label: 'Disable', - value: 'toggle-enabled' - }); + value: 'toggle-enabled', + }) } // If there are no other options, add a back option so Select handles escape if (menuOptions.length === 0) { menuOptions.push({ label: 'Back', - value: 'back' - }); + value: 'back', + }) } - return - + + return ( + + {capitalizedServerName} MCP Server @@ -541,23 +745,39 @@ export function MCPRemoteServerMenu({ Status: - {server.client.type === 'disabled' ? {color('inactive', theme)(figures.radioOff)} disabled : server.client.type === 'connected' ? {color('success', theme)(figures.tick)} connected : server.client.type === 'pending' ? <> + {server.client.type === 'disabled' ? ( + {color('inactive', theme)(figures.radioOff)} disabled + ) : server.client.type === 'connected' ? ( + {color('success', theme)(figures.tick)} connected + ) : server.client.type === 'pending' ? ( + <> {figures.radioOff} connecting… - : server.client.type === 'needs-auth' ? + + ) : server.client.type === 'needs-auth' ? ( + {color('warning', theme)(figures.triangleUpOutline)} needs authentication - : {color('error', theme)(figures.cross)} failed} + + ) : ( + {color('error', theme)(figures.cross)} failed + )} - {server.transport !== 'claudeai-proxy' && + {server.transport !== 'claudeai-proxy' && ( + Auth: - {isEffectivelyAuthenticated ? + {isEffectivelyAuthenticated ? ( + {color('success', theme)(figures.tick)} authenticated - : + + ) : ( + {color('error', theme)(figures.cross)} not authenticated - } - } + + )} + + )} URL: @@ -569,80 +789,107 @@ export function MCPRemoteServerMenu({ {describeMcpConfigFilePath(server.scope)} - {server.client.type === 'connected' && } + {server.client.type === 'connected' && ( + + )} - {server.client.type === 'connected' && serverToolsCount > 0 && + {server.client.type === 'connected' && serverToolsCount > 0 && ( + Tools: {serverToolsCount} tools - } + + )} - {error && + {error && ( + Error: {error} - } + + )} - {menuOptions.length > 0 && - { + switch (value) { + case 'tools': + onViewTools() + break + case 'auth': + case 'reauth': + await handleAuthenticate() + break + case 'clear-auth': + await handleClearAuth() + break + case 'claudeai-auth': + await handleClaudeAIAuth() + break + case 'claudeai-clear-auth': + handleClaudeAIClearAuth() + break + case 'reconnectMcpServer': + setIsReconnecting(true) + try { + const result = await reconnectMcpServer(server.name) + if (server.config.type === 'claudeai-proxy') { + logEvent('tengu_claudeai_mcp_reconnect', { + success: result.client.type === 'connected', + }) + } + const { message } = handleReconnectResult( + result, + server.name, + ) + onComplete?.(message) + } catch (err) { + if (server.config.type === 'claudeai-proxy') { + logEvent('tengu_claudeai_mcp_reconnect', { + success: false, + }) + } + onComplete?.(handleReconnectError(err, server.name)) + } finally { + setIsReconnecting(false) + } + break + case 'toggle-enabled': + await handleToggleEnabled() + break + case 'back': + onCancel() + break } - const { - message: message_0 - } = handleReconnectResult(result_1, server.name); - onComplete?.(message_0); - } catch (err_2) { - if (server.config.type === 'claudeai-proxy') { - logEvent('tengu_claudeai_mcp_reconnect', { - success: false - }); - } - onComplete?.(handleReconnectError(err_2, server.name)); - } finally { - setIsReconnecting(false); - } - break; - case 'toggle-enabled': - await handleToggleEnabled(); - break; - case 'back': - onCancel(); - break; - } - }} onCancel={onCancel} /> - } + }} + onCancel={onCancel} + /> + + )} - {exitState.pending ? <>Press {exitState.keyName} again to exit : + {exitState.pending ? ( + <>Press {exitState.keyName} again to exit + ) : ( + - - } + + + )} - ; + + ) } diff --git a/src/components/mcp/MCPSettings.tsx b/src/components/mcp/MCPSettings.tsx index 4afa20504..b350bf91e 100644 --- a/src/components/mcp/MCPSettings.tsx +++ b/src/components/mcp/MCPSettings.tsx @@ -1,397 +1,247 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useEffect, useMemo } from 'react'; -import type { CommandResultDisplay } from '../../commands.js'; -import { ClaudeAuthProvider } from '../../services/mcp/auth.js'; -import type { McpClaudeAIProxyServerConfig, McpHTTPServerConfig, McpSSEServerConfig, McpStdioServerConfig } from '../../services/mcp/types.js'; -import { extractAgentMcpServers, filterToolsByServer } from '../../services/mcp/utils.js'; -import { useAppState } from '../../state/AppState.js'; -import { getSessionIngressAuthToken } from '../../utils/sessionIngressAuth.js'; -import { MCPAgentServerMenu } from './MCPAgentServerMenu.js'; -import { MCPListPanel } from './MCPListPanel.js'; -import { MCPRemoteServerMenu } from './MCPRemoteServerMenu.js'; -import { MCPStdioServerMenu } from './MCPStdioServerMenu.js'; -import { MCPToolDetailView } from './MCPToolDetailView.js'; -import { MCPToolListView } from './MCPToolListView.js'; -import type { AgentMcpServerInfo, MCPViewState, ServerInfo } from './types.js'; +import React, { useEffect, useMemo } from 'react' +import type { CommandResultDisplay } from '../../commands.js' +import { ClaudeAuthProvider } from '../../services/mcp/auth.js' +import type { + McpClaudeAIProxyServerConfig, + McpHTTPServerConfig, + McpSSEServerConfig, + McpStdioServerConfig, +} from '../../services/mcp/types.js' +import { + extractAgentMcpServers, + filterToolsByServer, +} from '../../services/mcp/utils.js' +import { useAppState } from '../../state/AppState.js' +import { getSessionIngressAuthToken } from '../../utils/sessionIngressAuth.js' +import { MCPAgentServerMenu } from './MCPAgentServerMenu.js' +import { MCPListPanel } from './MCPListPanel.js' +import { MCPRemoteServerMenu } from './MCPRemoteServerMenu.js' +import { MCPStdioServerMenu } from './MCPStdioServerMenu.js' +import { MCPToolDetailView } from './MCPToolDetailView.js' +import { MCPToolListView } from './MCPToolListView.js' +import type { AgentMcpServerInfo, MCPViewState, ServerInfo } from './types.js' + type Props = { - onComplete: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; -}; -export function MCPSettings(t0) { - const $ = _c(66); - const { - onComplete - } = t0; - const mcp = useAppState(_temp); - const agentDefinitions = useAppState(_temp2); - const mcpClients = mcp.clients; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - type: "list" - }; - $[0] = t1; - } else { - t1 = $[0]; - } - const [viewState, setViewState] = React.useState(t1); - let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = []; - $[1] = t2; - } else { - t2 = $[1]; - } - const [servers, setServers] = React.useState(t2); - let t3; - if ($[2] !== agentDefinitions.allAgents) { - t3 = extractAgentMcpServers(agentDefinitions.allAgents); - $[2] = agentDefinitions.allAgents; - $[3] = t3; - } else { - t3 = $[3]; - } - const agentMcpServers = t3; - let t4; - if ($[4] !== mcpClients) { - t4 = mcpClients.filter(_temp3).sort(_temp4); - $[4] = mcpClients; - $[5] = t4; - } else { - t4 = $[5]; - } - const filteredClients = t4; - let t5; - let t6; - if ($[6] !== filteredClients || $[7] !== mcp.tools) { - t5 = () => { - let cancelled = false; - const prepareServers = async function prepareServers() { - const serverInfos = await Promise.all(filteredClients.map(async client_0 => { - const scope = client_0.config.scope; - const isSSE = client_0.config.type === "sse"; - const isHTTP = client_0.config.type === "http"; - const isClaudeAIProxy = client_0.config.type === "claudeai-proxy"; - let isAuthenticated = undefined; + onComplete: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void +} + +export function MCPSettings({ onComplete }: Props): React.ReactNode { + const mcp = useAppState(s => s.mcp) + const agentDefinitions = useAppState(s => s.agentDefinitions) + const mcpClients = mcp.clients + const [viewState, setViewState] = React.useState({ + type: 'list', + }) + const [servers, setServers] = React.useState([]) + + // Extract agent-specific MCP servers from agent definitions + const agentMcpServers = useMemo( + () => extractAgentMcpServers(agentDefinitions.allAgents), + [agentDefinitions.allAgents], + ) + + const filteredClients = React.useMemo( + () => + mcpClients + .filter(client => client.name !== 'ide') + .sort((a, b) => a.name.localeCompare(b.name)), + [mcpClients], + ) + + React.useEffect(() => { + let cancelled = false + async function prepareServers() { + const serverInfos = await Promise.all( + filteredClients.map(async client => { + const scope = client.config.scope + const isSSE = client.config.type === 'sse' + const isHTTP = client.config.type === 'http' + const isClaudeAIProxy = client.config.type === 'claudeai-proxy' + let isAuthenticated: boolean | undefined = undefined + if (isSSE || isHTTP) { - const authProvider = new ClaudeAuthProvider(client_0.name, client_0.config as McpSSEServerConfig | McpHTTPServerConfig); - const tokens = await authProvider.tokens(); - const hasSessionAuth = getSessionIngressAuthToken() !== null && client_0.type === "connected"; - const hasToolsAndConnected = client_0.type === "connected" && filterToolsByServer(mcp.tools, client_0.name).length > 0; - isAuthenticated = Boolean(tokens) || hasSessionAuth || hasToolsAndConnected; + const authProvider = new ClaudeAuthProvider( + client.name, + client.config as McpSSEServerConfig | McpHTTPServerConfig, + ) + const tokens = await authProvider.tokens() + // Server is authenticated if: + // 1. It has OAuth tokens, OR + // 2. It's connected via session auth (has session token and is connected), OR + // 3. It's connected and has tools (meaning it's working, regardless of auth method) + const hasSessionAuth = + getSessionIngressAuthToken() !== null && + client.type === 'connected' + const hasToolsAndConnected = + client.type === 'connected' && + filterToolsByServer(mcp.tools, client.name).length > 0 + isAuthenticated = + Boolean(tokens) || hasSessionAuth || hasToolsAndConnected } + const baseInfo = { - name: client_0.name, - client: client_0, - scope - }; + name: client.name, + client, + scope, + } + if (isClaudeAIProxy) { return { ...baseInfo, - transport: "claudeai-proxy" as const, + transport: 'claudeai-proxy' as const, isAuthenticated: false, - config: client_0.config as McpClaudeAIProxyServerConfig - }; + config: client.config as McpClaudeAIProxyServerConfig, + } + } else if (isSSE) { + return { + ...baseInfo, + transport: 'sse' as const, + isAuthenticated, + config: client.config as McpSSEServerConfig, + } + } else if (isHTTP) { + return { + ...baseInfo, + transport: 'http' as const, + isAuthenticated, + config: client.config as McpHTTPServerConfig, + } } else { - if (isSSE) { - return { - ...baseInfo, - transport: "sse" as const, - isAuthenticated, - config: client_0.config as McpSSEServerConfig - }; - } else { - if (isHTTP) { - return { - ...baseInfo, - transport: "http" as const, - isAuthenticated, - config: client_0.config as McpHTTPServerConfig - }; - } else { - return { - ...baseInfo, - transport: "stdio" as const, - config: client_0.config as McpStdioServerConfig - }; - } + return { + ...baseInfo, + transport: 'stdio' as const, + config: client.config as McpStdioServerConfig, } } - })); - if (cancelled) { - return; - } - setServers(serverInfos); - }; - prepareServers(); - return () => { - cancelled = true; - }; - }; - t6 = [filteredClients, mcp.tools]; - $[6] = filteredClients; - $[7] = mcp.tools; - $[8] = t5; - $[9] = t6; - } else { - t5 = $[8]; - t6 = $[9]; - } - React.useEffect(t5, t6); - let t7; - let t8; - if ($[10] !== agentMcpServers.length || $[11] !== filteredClients.length || $[12] !== onComplete || $[13] !== servers.length) { - t7 = () => { - if (servers.length === 0 && filteredClients.length > 0) { - return; - } - if (servers.length === 0 && agentMcpServers.length === 0) { - onComplete("No MCP servers configured. Please run /doctor if this is unexpected. Otherwise, run `claude mcp --help` or visit https://code.claude.com/docs/en/mcp to learn more."); - } - }; - t8 = [servers.length, filteredClients.length, agentMcpServers.length, onComplete]; - $[10] = agentMcpServers.length; - $[11] = filteredClients.length; - $[12] = onComplete; - $[13] = servers.length; - $[14] = t7; - $[15] = t8; - } else { - t7 = $[14]; - t8 = $[15]; - } - useEffect(t7, t8); + }), + ) + + if (cancelled) return + setServers(serverInfos) + } + + void prepareServers() + return () => { + cancelled = true + } + }, [filteredClients, mcp.tools]) + + useEffect(() => { + if (servers.length === 0 && filteredClients.length > 0) { + // Still loading + return + } + + // Only show "no servers" message if no regular servers AND no agent servers + if (servers.length === 0 && agentMcpServers.length === 0) { + onComplete( + 'No MCP servers configured. Please run /doctor if this is unexpected. Otherwise, run `claude mcp --help` or visit https://code.claude.com/docs/en/mcp to learn more.', + ) + } + }, [ + servers.length, + filteredClients.length, + agentMcpServers.length, + onComplete, + ]) + switch (viewState.type) { - case "list": - { - let t10; - let t9; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t9 = server => setViewState({ - type: "server-menu", - server - }); - t10 = agentServer => setViewState({ - type: "agent-server-menu", - agentServer - }); - $[16] = t10; - $[17] = t9; - } else { - t10 = $[16]; - t9 = $[17]; - } - let t11; - if ($[18] !== agentMcpServers || $[19] !== onComplete || $[20] !== servers || $[21] !== viewState.defaultTab) { - t11 = ; - $[18] = agentMcpServers; - $[19] = onComplete; - $[20] = servers; - $[21] = viewState.defaultTab; - $[22] = t11; - } else { - t11 = $[22]; - } - return t11; + case 'list': + return ( + + setViewState({ type: 'server-menu', server }) + } + onSelectAgentServer={(agentServer: AgentMcpServerInfo) => + setViewState({ type: 'agent-server-menu', agentServer }) + } + onComplete={onComplete} + defaultTab={viewState.defaultTab} + /> + ) + + case 'server-menu': { + const serverTools = filterToolsByServer(mcp.tools, viewState.server.name) + + const defaultTab = + viewState.server.transport === 'claudeai-proxy' + ? 'claude.ai' + : 'Claude Code' + + if (viewState.server.transport === 'stdio') { + return ( + + setViewState({ type: 'server-tools', server: viewState.server }) + } + onCancel={() => setViewState({ type: 'list', defaultTab })} + onComplete={onComplete} + /> + ) + } else { + return ( + + setViewState({ type: 'server-tools', server: viewState.server }) + } + onCancel={() => setViewState({ type: 'list', defaultTab })} + onComplete={onComplete} + /> + ) } - case "server-menu": - { - let t9; - if ($[23] !== mcp.tools || $[24] !== viewState.server.name) { - t9 = filterToolsByServer(mcp.tools, viewState.server.name); - $[23] = mcp.tools; - $[24] = viewState.server.name; - $[25] = t9; - } else { - t9 = $[25]; - } - const serverTools_0 = t9; - const defaultTab = viewState.server.transport === "claudeai-proxy" ? "claude.ai" : "Claude Code"; - if (viewState.server.transport === "stdio") { - let t10; - if ($[26] !== viewState.server) { - t10 = () => setViewState({ - type: "server-tools", - server: viewState.server - }); - $[26] = viewState.server; - $[27] = t10; - } else { - t10 = $[27]; + } + + case 'server-tools': + return ( + + setViewState({ + type: 'server-tool-detail', + server: viewState.server, + toolIndex: index, + }) } - let t11; - if ($[28] !== defaultTab) { - t11 = () => setViewState({ - type: "list", - defaultTab - }); - $[28] = defaultTab; - $[29] = t11; - } else { - t11 = $[29]; + onBack={() => + setViewState({ type: 'server-menu', server: viewState.server }) } - let t12; - if ($[30] !== onComplete || $[31] !== serverTools_0.length || $[32] !== t10 || $[33] !== t11 || $[34] !== viewState.server) { - t12 = ; - $[30] = onComplete; - $[31] = serverTools_0.length; - $[32] = t10; - $[33] = t11; - $[34] = viewState.server; - $[35] = t12; - } else { - t12 = $[35]; - } - return t12; - } else { - let t10; - if ($[36] !== viewState.server) { - t10 = () => setViewState({ - type: "server-tools", - server: viewState.server - }); - $[36] = viewState.server; - $[37] = t10; - } else { - t10 = $[37]; - } - let t11; - if ($[38] !== defaultTab) { - t11 = () => setViewState({ - type: "list", - defaultTab - }); - $[38] = defaultTab; - $[39] = t11; - } else { - t11 = $[39]; - } - let t12; - if ($[40] !== onComplete || $[41] !== serverTools_0.length || $[42] !== t10 || $[43] !== t11 || $[44] !== viewState.server) { - t12 = ; - $[40] = onComplete; - $[41] = serverTools_0.length; - $[42] = t10; - $[43] = t11; - $[44] = viewState.server; - $[45] = t12; - } else { - t12 = $[45]; - } - return t12; - } - } - case "server-tools": - { - let t10; - let t9; - if ($[46] !== viewState.server) { - t9 = (_, index) => setViewState({ - type: "server-tool-detail", - server: viewState.server, - toolIndex: index - }); - t10 = () => setViewState({ - type: "server-menu", - server: viewState.server - }); - $[46] = viewState.server; - $[47] = t10; - $[48] = t9; - } else { - t10 = $[47]; - t9 = $[48]; - } - let t11; - if ($[49] !== t10 || $[50] !== t9 || $[51] !== viewState.server) { - t11 = ; - $[49] = t10; - $[50] = t9; - $[51] = viewState.server; - $[52] = t11; - } else { - t11 = $[52]; - } - return t11; - } - case "server-tool-detail": - { - let t9; - if ($[53] !== mcp.tools || $[54] !== viewState.server.name) { - t9 = filterToolsByServer(mcp.tools, viewState.server.name); - $[53] = mcp.tools; - $[54] = viewState.server.name; - $[55] = t9; - } else { - t9 = $[55]; - } - const serverTools = t9; - const tool = serverTools[viewState.toolIndex]; - if (!tool) { - setViewState({ - type: "server-tools", - server: viewState.server - }); - return null; - } - let t10; - if ($[56] !== viewState.server) { - t10 = () => setViewState({ - type: "server-tools", - server: viewState.server - }); - $[56] = viewState.server; - $[57] = t10; - } else { - t10 = $[57]; - } - let t11; - if ($[58] !== t10 || $[59] !== tool || $[60] !== viewState.server) { - t11 = ; - $[58] = t10; - $[59] = tool; - $[60] = viewState.server; - $[61] = t11; - } else { - t11 = $[61]; - } - return t11; - } - case "agent-server-menu": - { - let t9; - if ($[62] === Symbol.for("react.memo_cache_sentinel")) { - t9 = () => setViewState({ - type: "list", - defaultTab: "Agents" - }); - $[62] = t9; - } else { - t9 = $[62]; - } - let t10; - if ($[63] !== onComplete || $[64] !== viewState.agentServer) { - t10 = ; - $[63] = onComplete; - $[64] = viewState.agentServer; - $[65] = t10; - } else { - t10 = $[65]; - } - return t10; + /> + ) + + case 'server-tool-detail': { + const serverTools = filterToolsByServer(mcp.tools, viewState.server.name) + const tool = serverTools[viewState.toolIndex] + if (!tool) { + setViewState({ type: 'server-tools', server: viewState.server }) + return null } + return ( + + setViewState({ type: 'server-tools', server: viewState.server }) + } + /> + ) + } + + case 'agent-server-menu': + return ( + setViewState({ type: 'list', defaultTab: 'Agents' })} + onComplete={onComplete} + /> + ) } } -function _temp4(a, b) { - return a.name.localeCompare(b.name); -} -function _temp3(client) { - return client.name !== "ide"; -} -function _temp2(s_0) { - return s_0.agentDefinitions; -} -function _temp(s) { - return s.mcp; -} diff --git a/src/components/mcp/MCPStdioServerMenu.tsx b/src/components/mcp/MCPStdioServerMenu.tsx index bd9361b1d..7caba350e 100644 --- a/src/components/mcp/MCPStdioServerMenu.tsx +++ b/src/components/mcp/MCPStdioServerMenu.tsx @@ -1,92 +1,116 @@ -import figures from 'figures'; -import React, { useState } from 'react'; -import type { CommandResultDisplay } from '../../commands.js'; -import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { Box, color, Text, useTheme } from '../../ink.js'; -import { getMcpConfigByName } from '../../services/mcp/config.js'; -import { useMcpReconnect, useMcpToggleEnabled } from '../../services/mcp/MCPConnectionManager.js'; -import { describeMcpConfigFilePath, filterMcpPromptsByServer } from '../../services/mcp/utils.js'; -import { useAppState } from '../../state/AppState.js'; -import { errorMessage } from '../../utils/errors.js'; -import { capitalize } from '../../utils/stringUtils.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; -import { Select } from '../CustomSelect/index.js'; -import { Byline } from '../design-system/Byline.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; -import { Spinner } from '../Spinner.js'; -import { CapabilitiesSection } from './CapabilitiesSection.js'; -import type { StdioServerInfo } from './types.js'; -import { handleReconnectError, handleReconnectResult } from './utils/reconnectHelpers.js'; +import figures from 'figures' +import React, { useState } from 'react' +import type { CommandResultDisplay } from '../../commands.js' +import { useExitOnCtrlCDWithKeybindings } from '../../hooks/useExitOnCtrlCDWithKeybindings.js' +import { Box, color, Text, useTheme } from '../../ink.js' +import { getMcpConfigByName } from '../../services/mcp/config.js' +import { + useMcpReconnect, + useMcpToggleEnabled, +} from '../../services/mcp/MCPConnectionManager.js' +import { + describeMcpConfigFilePath, + filterMcpPromptsByServer, +} from '../../services/mcp/utils.js' +import { useAppState } from '../../state/AppState.js' +import { errorMessage } from '../../utils/errors.js' +import { capitalize } from '../../utils/stringUtils.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +import { Select } from '../CustomSelect/index.js' +import { Byline } from '../design-system/Byline.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' +import { Spinner } from '../Spinner.js' +import { CapabilitiesSection } from './CapabilitiesSection.js' +import type { StdioServerInfo } from './types.js' +import { + handleReconnectError, + handleReconnectResult, +} from './utils/reconnectHelpers.js' + type Props = { - server: StdioServerInfo; - serverToolsCount: number; - onViewTools: () => void; - onCancel: () => void; - onComplete: (result?: string, options?: { - display?: CommandResultDisplay; - }) => void; - borderless?: boolean; -}; + server: StdioServerInfo + serverToolsCount: number + onViewTools: () => void + onCancel: () => void + onComplete: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void + borderless?: boolean +} + export function MCPStdioServerMenu({ server, serverToolsCount, onViewTools, onCancel, onComplete, - borderless = false + borderless = false, }: Props): React.ReactNode { - const [theme] = useTheme(); - const exitState = useExitOnCtrlCDWithKeybindings(); - const mcp = useAppState(s => s.mcp); - const reconnectMcpServer = useMcpReconnect(); - const toggleMcpServer = useMcpToggleEnabled(); - const [isReconnecting, setIsReconnecting] = useState(false); + const [theme] = useTheme() + const exitState = useExitOnCtrlCDWithKeybindings() + const mcp = useAppState(s => s.mcp) + const reconnectMcpServer = useMcpReconnect() + const toggleMcpServer = useMcpToggleEnabled() + const [isReconnecting, setIsReconnecting] = useState(false) + const handleToggleEnabled = React.useCallback(async () => { - const wasEnabled = server.client.type !== 'disabled'; + const wasEnabled = server.client.type !== 'disabled' + try { - await toggleMcpServer(server.name); + await toggleMcpServer(server.name) // Return to the server list so user can continue managing other servers - onCancel(); + onCancel() } catch (err) { - const action = wasEnabled ? 'disable' : 'enable'; - onComplete(`Failed to ${action} MCP server '${server.name}': ${errorMessage(err)}`); + const action = wasEnabled ? 'disable' : 'enable' + onComplete( + `Failed to ${action} MCP server '${server.name}': ${errorMessage(err)}`, + ) } - }, [server.client.type, server.name, toggleMcpServer, onCancel, onComplete]); - const capitalizedServerName = capitalize(String(server.name)); + }, [server.client.type, server.name, toggleMcpServer, onCancel, onComplete]) + + const capitalizedServerName = capitalize(String(server.name)) // Count MCP prompts for this server (skills are shown in /skills, not here) - const serverCommandsCount = filterMcpPromptsByServer(mcp.commands, server.name).length; - const menuOptions = []; + const serverCommandsCount = filterMcpPromptsByServer( + mcp.commands, + server.name, + ).length + + const menuOptions = [] // Only show "View tools" if server is not disabled and has tools if (server.client.type !== 'disabled' && serverToolsCount > 0) { menuOptions.push({ label: 'View tools', - value: 'tools' - }); + value: 'tools', + }) } // Only show reconnect option if the server is not disabled if (server.client.type !== 'disabled') { menuOptions.push({ label: 'Reconnect', - value: 'reconnectMcpServer' - }); + value: 'reconnectMcpServer', + }) } + menuOptions.push({ label: server.client.type !== 'disabled' ? 'Disable' : 'Enable', - value: 'toggle-enabled' - }); + value: 'toggle-enabled', + }) // If there are no other options, add a back option so Select handles escape if (menuOptions.length === 0) { menuOptions.push({ label: 'Back', - value: 'back' - }); + value: 'back', + }) } + if (isReconnecting) { - return + return ( + Reconnecting to {server.name} @@ -95,10 +119,17 @@ export function MCPStdioServerMenu({ Restarting MCP server process This may take a few moments. - ; + + ) } - return - + + return ( + + {capitalizedServerName} MCP Server @@ -106,10 +137,18 @@ export function MCPStdioServerMenu({ Status: - {server.client.type === 'disabled' ? {color('inactive', theme)(figures.radioOff)} disabled : server.client.type === 'connected' ? {color('success', theme)(figures.tick)} connected : server.client.type === 'pending' ? <> + {server.client.type === 'disabled' ? ( + {color('inactive', theme)(figures.radioOff)} disabled + ) : server.client.type === 'connected' ? ( + {color('success', theme)(figures.tick)} connected + ) : server.client.type === 'pending' ? ( + <> {figures.radioOff} connecting… - : {color('error', theme)(figures.cross)} failed} + + ) : ( + {color('error', theme)(figures.cross)} failed + )} @@ -117,60 +156,89 @@ export function MCPStdioServerMenu({ {server.config.command} - {server.config.args && server.config.args.length > 0 && + {server.config.args && server.config.args.length > 0 && ( + Args: {server.config.args.join(' ')} - } + + )} Config location: - {describeMcpConfigFilePath(getMcpConfigByName(server.name)?.scope ?? 'dynamic')} + {describeMcpConfigFilePath( + getMcpConfigByName(server.name)?.scope ?? 'dynamic', + )} - {server.client.type === 'connected' && } + {server.client.type === 'connected' && ( + + )} - {server.client.type === 'connected' && serverToolsCount > 0 && + {server.client.type === 'connected' && serverToolsCount > 0 && ( + Tools: {serverToolsCount} tools - } + + )} - {menuOptions.length > 0 && - { + if (value === 'tools') { + onViewTools() + } else if (value === 'reconnectMcpServer') { + setIsReconnecting(true) + try { + const result = await reconnectMcpServer(server.name) + const { message } = handleReconnectResult( + result, + server.name, + ) + onComplete?.(message) + } catch (err) { + onComplete?.(handleReconnectError(err, server.name)) + } finally { + setIsReconnecting(false) + } + } else if (value === 'toggle-enabled') { + await handleToggleEnabled() + } else if (value === 'back') { + onCancel() + } + }} + onCancel={onCancel} + /> + + )} - {exitState.pending ? <>Press {exitState.keyName} again to exit : + {exitState.pending ? ( + <>Press {exitState.keyName} again to exit + ) : ( + - - } + + + )} - ; + + ) } diff --git a/src/components/mcp/MCPToolDetailView.tsx b/src/components/mcp/MCPToolDetailView.tsx index 6ce619b4b..b1ccb4d73 100644 --- a/src/components/mcp/MCPToolDetailView.tsx +++ b/src/components/mcp/MCPToolDetailView.tsx @@ -1,211 +1,142 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, Text } from '../../ink.js'; -import { extractMcpToolDisplayName, getMcpDisplayName } from '../../services/mcp/mcpStringUtils.js'; -import type { Tool } from '../../Tool.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; -import { Dialog } from '../design-system/Dialog.js'; -import type { ServerInfo } from './types.js'; +import React from 'react' +import { Box, Text } from '../../ink.js' +import { + extractMcpToolDisplayName, + getMcpDisplayName, +} from '../../services/mcp/mcpStringUtils.js' +import type { Tool } from '../../Tool.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +import { Dialog } from '../design-system/Dialog.js' +import type { ServerInfo } from './types.js' + type Props = { - tool: Tool; - server: ServerInfo; - onBack: () => void; -}; -export function MCPToolDetailView(t0) { - const $ = _c(44); - const { - tool, - server, - onBack - } = t0; - const [toolDescription, setToolDescription] = React.useState(""); - let t1; - let toolName; - if ($[0] !== server.name || $[1] !== tool) { - toolName = getMcpDisplayName(tool.name, server.name); - const fullDisplayName = tool.userFacingName ? tool.userFacingName({}) : toolName; - t1 = extractMcpToolDisplayName(fullDisplayName); - $[0] = server.name; - $[1] = tool; - $[2] = t1; - $[3] = toolName; - } else { - t1 = $[2]; - toolName = $[3]; - } - const displayName = t1; - let t2; - if ($[4] !== tool) { - t2 = tool.isReadOnly?.({}) ?? false; - $[4] = tool; - $[5] = t2; - } else { - t2 = $[5]; - } - const isReadOnly = t2; - let t3; - if ($[6] !== tool) { - t3 = tool.isDestructive?.({}) ?? false; - $[6] = tool; - $[7] = t3; - } else { - t3 = $[7]; - } - const isDestructive = t3; - let t4; - if ($[8] !== tool) { - t4 = tool.isOpenWorld?.({}) ?? false; - $[8] = tool; - $[9] = t4; - } else { - t4 = $[9]; - } - const isOpenWorld = t4; - let t5; - let t6; - if ($[10] !== tool) { - t5 = () => { - const loadDescription = async function loadDescription() { - try { - const desc = await tool.description({}, { + tool: Tool + server: ServerInfo + onBack: () => void +} + +export function MCPToolDetailView({ + tool, + server, + onBack, +}: Props): React.ReactNode { + const [toolDescription, setToolDescription] = React.useState('') + + const toolName = getMcpDisplayName(tool.name, server.name) + const fullDisplayName = tool.userFacingName + ? tool.userFacingName({}) + : toolName + const displayName = extractMcpToolDisplayName(fullDisplayName) + + const isReadOnly = tool.isReadOnly?.({}) ?? false + const isDestructive = tool.isDestructive?.({}) ?? false + const isOpenWorld = tool.isOpenWorld?.({}) ?? false + + React.useEffect(() => { + async function loadDescription() { + try { + const desc = await tool.description( + {}, + { isNonInteractiveSession: false, toolPermissionContext: { - mode: "default" as const, + mode: 'default' as const, additionalWorkingDirectories: new Map(), alwaysAllowRules: {}, alwaysDenyRules: {}, alwaysAskRules: {}, - isBypassPermissionsModeAvailable: false + isBypassPermissionsModeAvailable: false, }, - tools: [] - }); - setToolDescription(desc); - } catch { - setToolDescription("Failed to load description"); - } - }; - loadDescription(); - }; - t6 = [tool]; - $[10] = tool; - $[11] = t5; - $[12] = t6; - } else { - t5 = $[11]; - t6 = $[12]; - } - React.useEffect(t5, t6); - let t7; - if ($[13] !== isReadOnly) { - t7 = isReadOnly && [read-only]; - $[13] = isReadOnly; - $[14] = t7; - } else { - t7 = $[14]; - } - let t8; - if ($[15] !== isDestructive) { - t8 = isDestructive && [destructive]; - $[15] = isDestructive; - $[16] = t8; - } else { - t8 = $[16]; - } - let t9; - if ($[17] !== isOpenWorld) { - t9 = isOpenWorld && [open-world]; - $[17] = isOpenWorld; - $[18] = t9; - } else { - t9 = $[18]; - } - let t10; - if ($[19] !== displayName || $[20] !== t7 || $[21] !== t8 || $[22] !== t9) { - t10 = <>{displayName}{t7}{t8}{t9}; - $[19] = displayName; - $[20] = t7; - $[21] = t8; - $[22] = t9; - $[23] = t10; - } else { - t10 = $[23]; - } - const titleContent = t10; - let t11; - if ($[24] === Symbol.for("react.memo_cache_sentinel")) { - t11 = Tool name: ; - $[24] = t11; - } else { - t11 = $[24]; - } - let t12; - if ($[25] !== toolName) { - t12 = {t11}{toolName}; - $[25] = toolName; - $[26] = t12; - } else { - t12 = $[26]; - } - let t13; - if ($[27] === Symbol.for("react.memo_cache_sentinel")) { - t13 = Full name: ; - $[27] = t13; - } else { - t13 = $[27]; - } - let t14; - if ($[28] !== tool.name) { - t14 = {t13}{tool.name}; - $[28] = tool.name; - $[29] = t14; - } else { - t14 = $[29]; - } - let t15; - if ($[30] !== toolDescription) { - t15 = toolDescription && Description:{toolDescription}; - $[30] = toolDescription; - $[31] = t15; - } else { - t15 = $[31]; - } - let t16; - if ($[32] !== tool.inputJSONSchema) { - t16 = tool.inputJSONSchema && tool.inputJSONSchema.properties && Object.keys(tool.inputJSONSchema.properties).length > 0 && Parameters:{Object.entries(tool.inputJSONSchema.properties).map(t17 => { - const [key, value] = t17; - const required = tool.inputJSONSchema?.required as string[] | undefined; - const isRequired = required?.includes(key); - return • {key}{isRequired && (required)}:{" "}{typeof value === "object" && value && "type" in value ? String(value.type) : "unknown"}{typeof value === "object" && value && "description" in value && - {String(value.description)}}; - })}; - $[32] = tool.inputJSONSchema; - $[33] = t16; - } else { - t16 = $[33]; - } - let t17; - if ($[34] !== t12 || $[35] !== t14 || $[36] !== t15 || $[37] !== t16) { - t17 = {t12}{t14}{t15}{t16}; - $[34] = t12; - $[35] = t14; - $[36] = t15; - $[37] = t16; - $[38] = t17; - } else { - t17 = $[38]; - } - let t18; - if ($[39] !== onBack || $[40] !== server.name || $[41] !== t17 || $[42] !== titleContent) { - t18 = {t17}; - $[39] = onBack; - $[40] = server.name; - $[41] = t17; - $[42] = titleContent; - $[43] = t18; - } else { - t18 = $[43]; - } - return t18; -} -function _temp(exitState) { - return exitState.pending ? Press {exitState.keyName} again to exit : ; + tools: [], + }, + ) + setToolDescription(desc) + } catch { + setToolDescription('Failed to load description') + } + } + void loadDescription() + }, [tool]) + + const titleContent = ( + <> + {displayName} + {isReadOnly && [read-only]} + {isDestructive && [destructive]} + {isOpenWorld && [open-world]} + + ) + + return ( + + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + + ) + } + > + + + Tool name: + {toolName} + + + + Full name: + {tool.name} + + + {toolDescription && ( + + Description: + {toolDescription} + + )} + + {tool.inputJSONSchema && + tool.inputJSONSchema.properties && + Object.keys(tool.inputJSONSchema.properties).length > 0 && ( + + Parameters: + + {Object.entries(tool.inputJSONSchema.properties).map( + ([key, value]) => { + const required = tool.inputJSONSchema?.required as + | string[] + | undefined + const isRequired = required?.includes(key) + return ( + + • {key} + {isRequired && (required)}:{' '} + + {typeof value === 'object' && value && 'type' in value + ? String(value.type) + : 'unknown'} + + {typeof value === 'object' && + value && + 'description' in value && ( + - {String(value.description)} + )} + + ) + }, + )} + + + )} + + + ) } diff --git a/src/components/mcp/MCPToolListView.tsx b/src/components/mcp/MCPToolListView.tsx index 1ce14cd30..923791187 100644 --- a/src/components/mcp/MCPToolListView.tsx +++ b/src/components/mcp/MCPToolListView.tsx @@ -1,140 +1,104 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Text } from '../../ink.js'; -import { extractMcpToolDisplayName, getMcpDisplayName } from '../../services/mcp/mcpStringUtils.js'; -import { filterToolsByServer } from '../../services/mcp/utils.js'; -import { useAppState } from '../../state/AppState.js'; -import type { Tool } from '../../Tool.js'; -import { plural } from '../../utils/stringUtils.js'; -import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js'; -import { Select } from '../CustomSelect/index.js'; -import { Byline } from '../design-system/Byline.js'; -import { Dialog } from '../design-system/Dialog.js'; -import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js'; -import type { ServerInfo } from './types.js'; +import React from 'react' +import { Text } from '../../ink.js' +import { + extractMcpToolDisplayName, + getMcpDisplayName, +} from '../../services/mcp/mcpStringUtils.js' +import { filterToolsByServer } from '../../services/mcp/utils.js' +import { useAppState } from '../../state/AppState.js' +import type { Tool } from '../../Tool.js' +import { plural } from '../../utils/stringUtils.js' +import { ConfigurableShortcutHint } from '../ConfigurableShortcutHint.js' +import { Select } from '../CustomSelect/index.js' +import { Byline } from '../design-system/Byline.js' +import { Dialog } from '../design-system/Dialog.js' +import { KeyboardShortcutHint } from '../design-system/KeyboardShortcutHint.js' +import type { ServerInfo } from './types.js' + type Props = { - server: ServerInfo; - onSelectTool: (tool: Tool, index: number) => void; - onBack: () => void; -}; -export function MCPToolListView(t0) { - const $ = _c(21); - const { - server, - onSelectTool, - onBack - } = t0; - const mcpTools = useAppState(_temp); - let t1; - bb0: { - if (server.client.type !== "connected") { - let t2; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t2 = []; - $[0] = t2; - } else { - t2 = $[0]; + server: ServerInfo + onSelectTool: (tool: Tool, index: number) => void + onBack: () => void +} + +export function MCPToolListView({ + server, + onSelectTool, + onBack, +}: Props): React.ReactNode { + const mcpTools = useAppState(s => s.mcp.tools) + + const serverTools = React.useMemo(() => { + if (server.client.type !== 'connected') return [] + return filterToolsByServer(mcpTools, server.name) + }, [server, mcpTools]) + + const toolOptions = serverTools.map((tool, index) => { + const toolName = getMcpDisplayName(tool.name, server.name) + const fullDisplayName = tool.userFacingName + ? tool.userFacingName({}) + : toolName + // Extract just the tool display name without server prefix + const displayName = extractMcpToolDisplayName(fullDisplayName) + + const isReadOnly = tool.isReadOnly?.({}) ?? false + const isDestructive = tool.isDestructive?.({}) ?? false + const isOpenWorld = tool.isOpenWorld?.({}) ?? false + + const annotations = [] + if (isReadOnly) annotations.push('read-only') + if (isDestructive) annotations.push('destructive') + if (isOpenWorld) annotations.push('open-world') + + return { + label: displayName, + value: index.toString(), + description: annotations.length > 0 ? annotations.join(', ') : undefined, + descriptionColor: isDestructive + ? 'error' + : isReadOnly + ? 'success' + : undefined, + } + }) + + return ( + + exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + + + + + + ) } - t1 = t2; - break bb0; - } - let t2; - if ($[1] !== mcpTools || $[2] !== server.name) { - t2 = filterToolsByServer(mcpTools, server.name); - $[1] = mcpTools; - $[2] = server.name; - $[3] = t2; - } else { - t2 = $[3]; - } - t1 = t2; - } - const serverTools = t1; - let t2; - if ($[4] !== server.name || $[5] !== serverTools) { - let t3; - if ($[7] !== server.name) { - t3 = (tool, index) => { - const toolName = getMcpDisplayName(tool.name, server.name); - const fullDisplayName = tool.userFacingName ? tool.userFacingName({}) : toolName; - const displayName = extractMcpToolDisplayName(fullDisplayName); - const isReadOnly = tool.isReadOnly?.({}) ?? false; - const isDestructive = tool.isDestructive?.({}) ?? false; - const isOpenWorld = tool.isOpenWorld?.({}) ?? false; - const annotations = []; - if (isReadOnly) { - annotations.push("read-only"); - } - if (isDestructive) { - annotations.push("destructive"); - } - if (isOpenWorld) { - annotations.push("open-world"); - } - return { - label: displayName, - value: index.toString(), - description: annotations.length > 0 ? annotations.join(", ") : undefined, - descriptionColor: isDestructive ? "error" : isReadOnly ? "success" : undefined - }; - }; - $[7] = server.name; - $[8] = t3; - } else { - t3 = $[8]; - } - t2 = serverTools.map(t3); - $[4] = server.name; - $[5] = serverTools; - $[6] = t2; - } else { - t2 = $[6]; - } - const toolOptions = t2; - const t3 = `Tools for ${server.name}`; - const t4 = serverTools.length; - let t5; - if ($[9] !== serverTools.length) { - t5 = plural(serverTools.length, "tool"); - $[9] = serverTools.length; - $[10] = t5; - } else { - t5 = $[10]; - } - const t6 = `${t4} ${t5}`; - let t7; - if ($[11] !== onBack || $[12] !== onSelectTool || $[13] !== serverTools || $[14] !== toolOptions) { - t7 = serverTools.length === 0 ? No tools available : { + const index = parseInt(value) + const tool = serverTools[index] + if (tool) { + onSelectTool(tool, index) + } + }} + onCancel={onBack} + /> + )} + + ) } diff --git a/src/components/mcp/McpParsingWarnings.tsx b/src/components/mcp/McpParsingWarnings.tsx index e0fd55d18..49e8353b9 100644 --- a/src/components/mcp/McpParsingWarnings.tsx +++ b/src/components/mcp/McpParsingWarnings.tsx @@ -1,212 +1,147 @@ -import { c as _c } from "react/compiler-runtime"; -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(t0) { - const $ = _c(26); - const { - scope, - parsingErrors, - warnings - } = t0; - const hasErrors = parsingErrors.length > 0; - const hasWarnings = warnings.length > 0; +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[] +}): React.ReactNode { + const hasErrors = parsingErrors.length > 0 + const hasWarnings = warnings.length > 0 + if (!hasErrors && !hasWarnings) { - return null; + return null } - let t1; - if ($[0] !== hasErrors || $[1] !== hasWarnings) { - t1 = (hasErrors || hasWarnings) && [{hasErrors ? "Failed to parse" : "Contains warnings"}]{" "}; - $[0] = hasErrors; - $[1] = hasWarnings; - $[2] = t1; - } else { - t1 = $[2]; - } - let t2; - if ($[3] !== scope) { - t2 = getScopeLabel(scope); - $[3] = scope; - $[4] = t2; - } else { - t2 = $[4]; - } - let t3; - if ($[5] !== t2) { - t3 = {t2}; - $[5] = t2; - $[6] = t3; - } else { - t3 = $[6]; - } - let t4; - if ($[7] !== t1 || $[8] !== t3) { - t4 = {t1}{t3}; - $[7] = t1; - $[8] = t3; - $[9] = t4; - } else { - t4 = $[9]; - } - let t5; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t5 = Location: ; - $[10] = t5; - } else { - t5 = $[10]; - } - let t6; - if ($[11] !== scope) { - t6 = describeMcpConfigFilePath(scope); - $[11] = scope; - $[12] = t6; - } else { - t6 = $[12]; - } - let t7; - if ($[13] !== t6) { - t7 = {t5}{t6}; - $[13] = t6; - $[14] = t7; - } else { - t7 = $[14]; - } - let t8; - if ($[15] !== parsingErrors) { - t8 = parsingErrors.map(_temp); - $[15] = parsingErrors; - $[16] = t8; - } else { - t8 = $[16]; - } - let t9; - if ($[17] !== warnings) { - t9 = warnings.map(_temp2); - $[17] = warnings; - $[18] = t9; - } else { - t9 = $[18]; - } - let t10; - if ($[19] !== t8 || $[20] !== t9) { - t10 = {t8}{t9}; - $[19] = t8; - $[20] = t9; - $[21] = t10; - } else { - t10 = $[21]; - } - let t11; - if ($[22] !== t10 || $[23] !== t4 || $[24] !== t7) { - t11 = {t4}{t7}{t10}; - $[22] = t10; - $[23] = t4; - $[24] = t7; - $[25] = t11; - } else { - t11 = $[25]; - } - return t11; + + return ( + + + {(hasErrors || hasWarnings) && ( + + [{hasErrors ? 'Failed to parse' : 'Contains warnings'}]{' '} + + )} + {getScopeLabel(scope)} + + + Location: + {describeMcpConfigFilePath(scope)} + + + {parsingErrors.map((error, i) => { + const serverName = error.mcpErrorMetadata?.serverName + return ( + + + + [Error] + + {' '} + {serverName && `[${serverName}] `} + {error.path && error.path !== '' ? `${error.path}: ` : ''} + {error.message} + + + + ) + })} + {warnings.map((warning, i) => { + const serverName = warning.mcpErrorMetadata?.serverName + + return ( + + + + [Warning] + + {' '} + {serverName && `[${serverName}] `} + {warning.path && warning.path !== '' + ? `${warning.path}: ` + : ''} + {warning.message} + + + + ) + })} + + + ) } -function _temp2(warning, i_0) { - const serverName_0 = warning.mcpErrorMetadata?.serverName; - return [Warning]{" "}{serverName_0 && `[${serverName_0}] `}{warning.path && warning.path !== "" ? `${warning.path}: ` : ""}{warning.message}; -} -function _temp(error, i) { - const serverName = error.mcpErrorMetadata?.serverName; - return [Error]{" "}{serverName && `[${serverName}] `}{error.path && error.path !== "" ? `${error.path}: ` : ""}{error.message}; -} -export function McpParsingWarnings() { - const $ = _c(6); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = { - scope: "user", - config: getMcpConfigsByScope("user") - }; - $[0] = t0; - } else { - t0 = $[0]; - } - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - scope: "project", - config: getMcpConfigsByScope("project") - }; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = { - scope: "local", - config: getMcpConfigsByScope("local") - }; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = [t0, t1, t2, { - scope: "enterprise", - config: getMcpConfigsByScope("enterprise") - }]; - $[3] = t3; - } else { - t3 = $[3]; - } - const scopes = t3 satisfies Array<{ - scope: ConfigScope; - config: { - errors: ValidationError[]; - }; - }>; - const hasParsingErrors = scopes.some(_temp3); - const hasWarnings = scopes.some(_temp4); + +export function McpParsingWarnings(): React.ReactNode { + // Config files don't change during dialog lifetime; read once on mount + // to avoid blocking file IO on every re-render. + const scopes = useMemo( + () => + [ + { scope: 'user', config: getMcpConfigsByScope('user') }, + { scope: 'project', config: getMcpConfigsByScope('project') }, + { scope: 'local', config: getMcpConfigsByScope('local') }, + { scope: 'enterprise', config: getMcpConfigsByScope('enterprise') }, + ] satisfies Array<{ + 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, + ) + if (!hasParsingErrors && !hasWarnings) { - return null; + return null } - let t4; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t4 = MCP Config Diagnostics; - $[4] = t4; - } else { - t4 = $[4]; - } - let t5; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t5 = {t4}For help configuring MCP servers, see:{" "}https://code.claude.com/docs/en/mcp{scopes.map(_temp5)}; - $[5] = t5; - } else { - t5 = $[5]; - } - return t5; + + return ( + + MCP Config Diagnostics + + + For help configuring MCP servers, see:{' '} + + https://code.claude.com/docs/en/mcp + + + + {scopes.map(({ scope, config }) => ( + + ))} + {/* TODO: Add additional diagnostic sections: + * - Duplicate Server Names (check for servers with same name across scopes) + * This section should include: + * - File paths where each server is defined + * - More detailed location info for user/local scopes + * - Approved / disabled status of servers + */} + + ) } -function _temp5(t0) { - const { - scope, - config: config_1 - } = t0; - return ; -} -function _temp4(t0) { - const { - config: config_0 - } = t0; - return filterErrors(config_0.errors, "warning").length > 0; -} -function _temp3(t0) { - const { - config - } = t0; - return filterErrors(config.errors, "fatal").length > 0; -} -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/utils/reconnectHelpers.tsx b/src/components/mcp/utils/reconnectHelpers.tsx index c947b1999..cf7459804 100644 --- a/src/components/mcp/utils/reconnectHelpers.tsx +++ b/src/components/mcp/utils/reconnectHelpers.tsx @@ -1,48 +1,61 @@ -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 } /** * Handles the result of a reconnect attempt and returns an appropriate user message */ -export function handleReconnectResult(result: { - client: MCPServerConnection; - tools: Tool[]; - commands: Command[]; - resources?: ServerResource[]; -}, serverName: string): ReconnectResult { +export function handleReconnectResult( + result: { + client: MCPServerConnection + tools: Tool[] + commands: Command[] + resources?: ServerResource[] + }, + serverName: string, +): ReconnectResult { switch (result.client.type) { case 'connected': return { message: `Reconnected to ${serverName}.`, - success: true - }; + success: true, + } + case 'needs-auth': return { message: `${serverName} requires authentication. Use the 'Authenticate' option.`, - success: false - }; + success: false, + } + case 'failed': return { message: `Failed to reconnect to ${serverName}.`, - success: false - }; + success: false, + } + default: return { message: `Unknown result when reconnecting to ${serverName}.`, - success: false - }; + 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/messages/AdvisorMessage.tsx b/src/components/messages/AdvisorMessage.tsx index a3fb3bd7c..4a77fe7ca 100644 --- a/src/components/messages/AdvisorMessage.tsx +++ b/src/components/messages/AdvisorMessage.tsx @@ -1,157 +1,85 @@ -import { c as _c } from "react/compiler-runtime"; -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; -}; -export function AdvisorMessage(t0) { - const $ = _c(30); - const { - block, - addMargin, - resolvedToolUseIDs, - erroredToolUseIDs, - shouldAnimate, - verbose, - advisorModel - } = t0; - if (block.type === "server_tool_use") { - let t1; - if ($[0] !== block.input) { - t1 = block.input && Object.keys(block.input).length > 0 ? jsonStringify(block.input) : null; - $[0] = block.input; - $[1] = t1; - } else { - t1 = $[1]; - } - const input = t1; - const t2 = addMargin ? 1 : 0; - let t3; - if ($[2] !== block.id || $[3] !== resolvedToolUseIDs) { - t3 = resolvedToolUseIDs.has(block.id); - $[2] = block.id; - $[3] = resolvedToolUseIDs; - $[4] = t3; - } else { - t3 = $[4]; - } - const t4 = !t3; - let t5; - if ($[5] !== block.id || $[6] !== erroredToolUseIDs) { - t5 = erroredToolUseIDs.has(block.id); - $[5] = block.id; - $[6] = erroredToolUseIDs; - $[7] = t5; - } else { - t5 = $[7]; - } - let t6; - if ($[8] !== shouldAnimate || $[9] !== t4 || $[10] !== t5) { - t6 = ; - $[8] = shouldAnimate; - $[9] = t4; - $[10] = t5; - $[11] = t6; - } else { - t6 = $[11]; - } - let t7; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t7 = Advising; - $[12] = t7; - } else { - t7 = $[12]; - } - let t8; - if ($[13] !== advisorModel) { - t8 = advisorModel ? using {renderModelName(advisorModel)} : null; - $[13] = advisorModel; - $[14] = t8; - } else { - t8 = $[14]; - } - let t9; - if ($[15] !== input) { - t9 = input ? · {input} : null; - $[15] = input; - $[16] = t9; - } else { - t9 = $[16]; - } - let t10; - if ($[17] !== t2 || $[18] !== t6 || $[19] !== t8 || $[20] !== t9) { - t10 = {t6}{t7}{t8}{t9}; - $[17] = t2; - $[18] = t6; - $[19] = t8; - $[20] = t9; - $[21] = t10; - } else { - t10 = $[21]; - } - return t10; - } - let body; - bb0: switch (block.content.type) { - case "advisor_tool_result_error": - { - let t1; - if ($[22] !== block.content.error_code) { - t1 = Advisor unavailable ({block.content.error_code}); - $[22] = block.content.error_code; - $[23] = t1; - } else { - t1 = $[23]; - } - body = t1; - break bb0; - } - case "advisor_result": - { - let t1; - if ($[24] !== block.content.text || $[25] !== verbose) { - t1 = verbose ? {block.content.text} : {figures.tick} Advisor has reviewed the conversation and will apply the feedback ; - $[24] = block.content.text; - $[25] = verbose; - $[26] = t1; - } else { - t1 = $[26]; - } - body = t1; - break bb0; - } - case "advisor_redacted_result": - { - let t1; - if ($[27] === Symbol.for("react.memo_cache_sentinel")) { - t1 = {figures.tick} Advisor has reviewed the conversation and will apply the feedback; - $[27] = t1; - } else { - t1 = $[27]; - } - body = t1; - } - } - let t1; - if ($[28] !== body) { - t1 = {body}; - $[28] = body; - $[29] = t1; - } else { - t1 = $[29]; - } - return t1; + block: AdvisorBlock + addMargin: boolean + resolvedToolUseIDs: Set + erroredToolUseIDs: Set + shouldAnimate: boolean + verbose: boolean + advisorModel?: string +} + +export function AdvisorMessage({ + block, + addMargin, + resolvedToolUseIDs, + erroredToolUseIDs, + shouldAnimate, + verbose, + advisorModel, +}: Props): React.ReactNode { + if (block.type === 'server_tool_use') { + const input = + block.input && Object.keys(block.input).length > 0 + ? jsonStringify(block.input) + : null + return ( + + + Advising + {advisorModel ? ( + using {renderModelName(advisorModel)} + ) : null} + {input ? · {input} : null} + + ) + } + + let body: React.ReactNode + switch (block.content.type) { + case 'advisor_tool_result_error': + 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 + + ) + break + case 'advisor_redacted_result': + 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 f7528a5d0..eb0f66d35 100644 --- a/src/components/messages/AssistantRedactedThinkingMessage.tsx +++ b/src/components/messages/AssistantRedactedThinkingMessage.tsx @@ -1,30 +1,18 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, Text } from '../../ink.js'; +import React from 'react' +import { Box, Text } from '../../ink.js' + type Props = { - addMargin: boolean; -}; -export function AssistantRedactedThinkingMessage(t0) { - const $ = _c(3); - const { - addMargin: t1 - } = t0; - const addMargin = t1 === undefined ? false : t1; - const t2 = addMargin ? 1 : 0; - let t3; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t3 = ✻ Thinking…; - $[0] = t3; - } else { - t3 = $[0]; - } - let t4; - if ($[1] !== t2) { - t4 = {t3}; - $[1] = t2; - $[2] = t4; - } else { - t4 = $[2]; - } - return t4; + addMargin: boolean +} + +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 9f70616ce..005d2481e 100644 --- a/src/components/messages/AssistantTextMessage.tsx +++ b/src/components/messages/AssistantTextMessage.tsx @@ -1,269 +1,222 @@ -import { c as _c } from "react/compiler-runtime"; -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, CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE, CUSTOM_OFF_SWITCH_MESSAGE, INVALID_API_KEY_ERROR_MESSAGE, INVALID_API_KEY_ERROR_MESSAGE_EXTERNAL, ORG_DISABLED_ERROR_MESSAGE_ENV_KEY, ORG_DISABLED_ERROR_MESSAGE_ENV_KEY_WITH_OAUTH, 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; +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, + CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE, + CUSTOM_OFF_SWITCH_MESSAGE, + INVALID_API_KEY_ERROR_MESSAGE, + INVALID_API_KEY_ERROR_MESSAGE_EXTERNAL, + ORG_DISABLED_ERROR_MESSAGE_ENV_KEY, + ORG_DISABLED_ERROR_MESSAGE_ENV_KEY_WITH_OAUTH, + 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 + type Props = { - param: TextBlockParam; - addMargin: boolean; - shouldShowDot: boolean; - verbose: boolean; - width?: number | string; - onOpenRateLimitOptions?: () => void; -}; -function InvalidApiKeyMessage() { - const $ = _c(2); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = isMacOsKeychainLocked(); - $[0] = t0; - } else { - t0 = $[0]; - } - const isKeychainLocked = t0; - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = {INVALID_API_KEY_ERROR_MESSAGE}{isKeychainLocked && · Run in another terminal: security unlock-keychain}; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; + param: TextBlockParam + addMargin: boolean + shouldShowDot: boolean + verbose: boolean + width?: number | string + onOpenRateLimitOptions?: () => void } -export function AssistantTextMessage(t0) { - const $ = _c(34); - const { - param: t1, - addMargin, - shouldShowDot, - verbose, - onOpenRateLimitOptions - } = t0; - const { - text - } = t1; - const isSelected = useContext(MessageActionsSelectedContext); + +function InvalidApiKeyMessage(): React.ReactNode { + const isKeychainLocked = isMacOsKeychainLocked() + + return ( + + + {INVALID_API_KEY_ERROR_MESSAGE} + {isKeychainLocked && ( + + · Run in another terminal: security unlock-keychain + + )} + + + ) +} + +export function AssistantTextMessage({ + param: { text }, + addMargin, + shouldShowDot, + verbose, + onOpenRateLimitOptions, +}: Props): React.ReactNode { + 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)) { - let t2; - if ($[0] !== onOpenRateLimitOptions || $[1] !== text) { - t2 = ; - $[0] = onOpenRateLimitOptions; - $[1] = text; - $[2] = t2; - } else { - t2 = $[2]; - } - return t2; + 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; - } - case PROMPT_TOO_LONG_ERROR_MESSAGE: - { - let t2; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t2 = getUpgradeMessage("warning"); - $[3] = t2; - } else { - t2 = $[3]; - } - const upgradeHint = t2; - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = Context limit reached · /compact or /clear to continue{upgradeHint ? ` · ${upgradeHint}` : ""}; - $[4] = t3; - } else { - t3 = $[4]; - } - return t3; - } + return null + + case PROMPT_TOO_LONG_ERROR_MESSAGE: { + const upgradeHint = getUpgradeMessage('warning') + return ( + + + Context limit reached · /compact or /clear to continue + {upgradeHint ? ` · ${upgradeHint}` : ''} + + + ) + } + case CREDIT_BALANCE_TOO_LOW_ERROR_MESSAGE: - { - let t2; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Credit balance too low · Add funds: https://platform.claude.com/settings/billing; - $[5] = t2; - } else { - t2 = $[5]; - } - return t2; - } + return ( + + + Credit balance too low · Add funds: + https://platform.claude.com/settings/billing + + + ) + case INVALID_API_KEY_ERROR_MESSAGE: - { - let t2; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t2 = ; - $[6] = t2; - } else { - t2 = $[6]; - } - return t2; - } + return + case INVALID_API_KEY_ERROR_MESSAGE_EXTERNAL: - { - let t2; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {INVALID_API_KEY_ERROR_MESSAGE_EXTERNAL}; - $[7] = t2; - } else { - t2 = $[7]; - } - return t2; - } + return ( + + {INVALID_API_KEY_ERROR_MESSAGE_EXTERNAL} + + ) + case ORG_DISABLED_ERROR_MESSAGE_ENV_KEY: case ORG_DISABLED_ERROR_MESSAGE_ENV_KEY_WITH_OAUTH: - { - let t2; - if ($[8] !== text) { - t2 = {text}; - $[8] = text; - $[9] = t2; - } else { - t2 = $[9]; - } - return t2; - } + return ( + + {text} + + ) + case TOKEN_REVOKED_ERROR_MESSAGE: - { - let t2; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {TOKEN_REVOKED_ERROR_MESSAGE}; - $[10] = t2; - } else { - t2 = $[10]; - } - return t2; - } + return ( + + {TOKEN_REVOKED_ERROR_MESSAGE} + + ) + case API_TIMEOUT_ERROR_MESSAGE: - { - let t2; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {API_TIMEOUT_ERROR_MESSAGE}{process.env.API_TIMEOUT_MS && <>{" "}(API_TIMEOUT_MS={process.env.API_TIMEOUT_MS}ms, try increasing it)}; - $[11] = t2; - } else { - t2 = $[11]; - } - return t2; - } + return ( + + + {API_TIMEOUT_ERROR_MESSAGE} + {process.env.API_TIMEOUT_MS && ( + <> + {' '} + (API_TIMEOUT_MS={process.env.API_TIMEOUT_MS}ms, try increasing + it) + + )} + + + ) + case CUSTOM_OFF_SWITCH_MESSAGE: - { - let t2; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t2 = We are experiencing high demand for Opus 4.; - $[12] = t2; - } else { - t2 = $[12]; - } - let t3; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t3 = {t2}To continue immediately, use /model to switch to{" "}{renderModelName(getDefaultSonnetModel())} and continue coding.; - $[13] = t3; - } else { - t3 = $[13]; - } - return t3; - } + return ( + + + + We are experiencing high demand for Opus 4. + + + 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: - { - let t2; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t2 = ; - $[14] = t2; - } else { - t2 = $[14]; - } - return t2; - } + return ( + + + + ) + default: - { - if (startsWithApiErrorPrefix(text)) { - const truncated = !verbose && text.length > MAX_API_ERROR_CHARS; - const t2 = text === API_ERROR_MESSAGE_PREFIX ? `${API_ERROR_MESSAGE_PREFIX}: Please wait a moment and try again.` : truncated ? text.slice(0, MAX_API_ERROR_CHARS) + "\u2026" : text; - let t3; - if ($[15] !== t2) { - t3 = {t2}; - $[15] = t2; - $[16] = t3; - } else { - t3 = $[16]; - } - let t4; - if ($[17] !== truncated) { - t4 = truncated && ; - $[17] = truncated; - $[18] = t4; - } else { - t4 = $[18]; - } - let t5; - if ($[19] !== t3 || $[20] !== t4) { - t5 = {t3}{t4}; - $[19] = t3; - $[20] = t4; - $[21] = t5; - } else { - t5 = $[21]; - } - return t5; - } - const t2 = addMargin ? 1 : 0; - const t3 = isSelected ? "messageActionsBackground" : undefined; - let t4; - if ($[22] !== isSelected || $[23] !== shouldShowDot) { - t4 = shouldShowDot && {BLACK_CIRCLE}; - $[22] = isSelected; - $[23] = shouldShowDot; - $[24] = t4; - } else { - t4 = $[24]; - } - let t5; - if ($[25] !== text) { - t5 = {text}; - $[25] = text; - $[26] = t5; - } else { - t5 = $[26]; - } - let t6; - if ($[27] !== t4 || $[28] !== t5) { - t6 = {t4}{t5}; - $[27] = t4; - $[28] = t5; - $[29] = t6; - } else { - t6 = $[29]; - } - let t7; - if ($[30] !== t2 || $[31] !== t3 || $[32] !== t6) { - t7 = {t6}; - $[30] = t2; - $[31] = t3; - $[32] = t6; - $[33] = t7; - } else { - t7 = $[33]; - } - return t7; + if (startsWithApiErrorPrefix(text)) { + const truncated = !verbose && text.length > MAX_API_ERROR_CHARS + return ( + + + + {text === API_ERROR_MESSAGE_PREFIX + ? `${API_ERROR_MESSAGE_PREFIX}: Please wait a moment and try again.` + : truncated + ? text.slice(0, MAX_API_ERROR_CHARS) + '…' + : text} + + {truncated && } + + + ) } + return ( + + + {shouldShowDot && ( + + + {BLACK_CIRCLE} + + + )} + + {text} + + + + ) } } diff --git a/src/components/messages/AssistantThinkingMessage.tsx b/src/components/messages/AssistantThinkingMessage.tsx index 31a691f77..2fc88512d 100644 --- a/src/components/messages/AssistantThinkingMessage.tsx +++ b/src/components/messages/AssistantThinkingMessage.tsx @@ -1,85 +1,66 @@ -import { c as _c } from "react/compiler-runtime"; -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; -}; -export function AssistantThinkingMessage(t0) { - const $ = _c(9); - const { - param: t1, - addMargin: t2, - isTranscriptMode, - verbose, - hideInTranscript: t3 - } = t0; - const { - thinking - } = t1; - const addMargin = t2 === undefined ? false : t2; - const hideInTranscript = t3 === undefined ? false : t3; - if (!thinking) { - return null; - } - if (hideInTranscript) { - return null; - } - const shouldShowFullThinking = isTranscriptMode || verbose; - if (!shouldShowFullThinking) { - const t4 = addMargin ? 1 : 0; - let t5; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t5 = {"\u2234 Thinking"} ; - $[0] = t5; - } else { - t5 = $[0]; - } - let t6; - if ($[1] !== t4) { - t6 = {t5}; - $[1] = t4; - $[2] = t6; - } else { - t6 = $[2]; - } - return t6; - } - const t4 = addMargin ? 1 : 0; - let t5; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t5 = {"\u2234 Thinking"}…; - $[3] = t5; - } else { - t5 = $[3]; - } - let t6; - if ($[4] !== thinking) { - t6 = {thinking}; - $[4] = thinking; - $[5] = t6; - } else { - t6 = $[5]; - } - let t7; - if ($[6] !== t4 || $[7] !== t6) { - t7 = {t5}{t6}; - $[6] = t4; - $[7] = t6; - $[8] = t7; - } else { - t7 = $[8]; - } - return t7; + hideInTranscript?: boolean +} + +export function AssistantThinkingMessage({ + param: { thinking }, + addMargin = false, + isTranscriptMode, + verbose, + hideInTranscript = false, +}: Props): React.ReactNode { + if (!thinking) { + return null + } + + if (hideInTranscript) { + return null + } + + const shouldShowFullThinking = isTranscriptMode || verbose + const label = '∴ Thinking' + + if (!shouldShowFullThinking) { + return ( + + + {label} + + + ) + } + + return ( + + + {label}… + + + {thinking} + + + ) } diff --git a/src/components/messages/AssistantToolUseMessage.tsx b/src/components/messages/AssistantToolUseMessage.tsx index f0c8f028f..65a92aad6 100644 --- a/src/components/messages/AssistantToolUseMessage.tsx +++ b/src/components/messages/AssistantToolUseMessage.tsx @@ -1,367 +1,326 @@ -import { c as _c } from "react/compiler-runtime"; -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; -}; -export function AssistantToolUseMessage(t0) { - const $ = _c(81); - const { - param, - addMargin, - tools, - commands, - verbose, - inProgressToolUseIDs, - progressMessagesForMessage, - shouldAnimate, - shouldShowDot, - inProgressToolCallCount, - lookups, - isTranscriptMode - } = t0; - const terminalSize = useTerminalSize(); - const [theme] = useTheme(); - const bg = useSelectedMessageBg(); - const pendingWorkerRequest = useAppStateMaybeOutsideOfProvider(_temp); - const isClassifierCheckingRaw = useIsClassifierChecking(param.id); - const permissionMode = useAppStateMaybeOutsideOfProvider(_temp2); - const hasStrippedRules = useAppStateMaybeOutsideOfProvider(_temp3); - const isAutoClassifier = permissionMode === "auto" || permissionMode === "plan" && hasStrippedRules; - const isClassifierChecking = false && isClassifierCheckingRaw && permissionMode !== "auto"; - let t1; - if ($[0] !== param.input || $[1] !== param.name || $[2] !== tools) { - bb0: { - if (!tools) { - t1 = null; - break bb0; - } - const tool = findToolByName(tools, param.name); - if (!tool) { - t1 = null; - break bb0; - } - const input = tool.inputSchema.safeParse(param.input); - const data = input.success ? input.data : undefined; - t1 = { - tool, - input, - userFacingToolName: tool.userFacingName(data), - userFacingToolNameBackgroundColor: tool.userFacingNameBackgroundColor?.(data), - isTransparentWrapper: tool.isTransparentWrapper?.() ?? false - }; + 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, + addMargin, + tools, + commands, + verbose, + inProgressToolUseIDs, + progressMessagesForMessage, + shouldAnimate, + shouldShowDot, + inProgressToolCallCount, + 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, + ) + // 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' + + // Memoize on param identity (stable — from the persisted message object). + // Zod safeParse allocates per call, and some tools' userFacingName() + // (BashTool → shouldUseSandbox → shell-quote parse) are expensive. Without + // 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 + return { + tool, + input, + userFacingToolName: tool.userFacingName(data), + userFacingToolNameBackgroundColor: + tool.userFacingNameBackgroundColor?.(data), + isTransparentWrapper: tool.isTransparentWrapper?.() ?? false, } - $[0] = param.input; - $[1] = param.name; - $[2] = tools; - $[3] = t1; - } else { - t1 = $[3]; - } - const parsed = t1; + }, [tools, param]) + if (!parsed) { - logError(new Error(tools ? `Tool ${param.name} not found` : `Tools array is undefined for tool ${param.name}`)); - return null; + // 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 } + const { - tool: tool_0, - input: input_0, + tool, + input, userFacingToolName, userFacingToolNameBackgroundColor, - isTransparentWrapper - } = parsed; - let t2; - if ($[4] !== lookups.resolvedToolUseIDs || $[5] !== param.id) { - t2 = lookups.resolvedToolUseIDs.has(param.id); - $[4] = lookups.resolvedToolUseIDs; - $[5] = param.id; - $[6] = t2; - } else { - t2 = $[6]; - } - const isResolved = t2; - let t3; - if ($[7] !== inProgressToolUseIDs || $[8] !== isResolved || $[9] !== param.id) { - t3 = !inProgressToolUseIDs.has(param.id) && !isResolved; - $[7] = inProgressToolUseIDs; - $[8] = isResolved; - $[9] = param.id; - $[10] = t3; - } else { - t3 = $[10]; - } - const isQueued = t3; - const isWaitingForPermission = pendingWorkerRequest?.toolUseId === param.id; + isTransparentWrapper, + } = parsed + + 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; - } - let t4; - if ($[11] !== inProgressToolCallCount || $[12] !== isTranscriptMode || $[13] !== lookups || $[14] !== param.id || $[15] !== progressMessagesForMessage || $[16] !== terminalSize || $[17] !== tool_0 || $[18] !== tools || $[19] !== verbose) { - t4 = renderToolUseProgressMessage(tool_0, tools, lookups, param.id, progressMessagesForMessage, { - verbose, - inProgressToolCallCount, - isTranscriptMode - }, terminalSize); - $[11] = inProgressToolCallCount; - $[12] = isTranscriptMode; - $[13] = lookups; - $[14] = param.id; - $[15] = progressMessagesForMessage; - $[16] = terminalSize; - $[17] = tool_0; - $[18] = tools; - $[19] = verbose; - $[20] = t4; - } else { - t4 = $[20]; - } - let t5; - if ($[21] !== bg || $[22] !== t4) { - t5 = {t4}; - $[21] = bg; - $[22] = t4; - $[23] = t5; - } else { - t5 = $[23]; - } - return t5; + if (isQueued || isResolved) return null + return ( + + {renderToolUseProgressMessage( + tool, + tools, + lookups, + param.id, + progressMessagesForMessage, + { verbose, inProgressToolCallCount, isTranscriptMode }, + terminalSize, + )} + + ) } - if (userFacingToolName === "") { - return null; + + if (userFacingToolName === '') { + return null } - let t4; - if ($[24] !== commands || $[25] !== input_0.data || $[26] !== input_0.success || $[27] !== theme || $[28] !== tool_0 || $[29] !== verbose) { - t4 = input_0.success ? renderToolUseMessage(tool_0, input_0.data, { - theme, - verbose, - commands - }) : null; - $[24] = commands; - $[25] = input_0.data; - $[26] = input_0.success; - $[27] = theme; - $[28] = tool_0; - $[29] = verbose; - $[30] = t4; - } else { - t4 = $[30]; - } - const renderedToolUseMessage = t4; + + const renderedToolUseMessage = input.success + ? renderToolUseMessage(tool, input.data, { theme, verbose, commands }) + : null if (renderedToolUseMessage === null) { - return null; + return null } - const t5 = addMargin ? 1 : 0; - const t6 = stringWidth(userFacingToolName) + (shouldShowDot ? 2 : 0); - let t7; - if ($[31] !== isQueued || $[32] !== isResolved || $[33] !== lookups.erroredToolUseIDs || $[34] !== param.id || $[35] !== shouldAnimate || $[36] !== shouldShowDot) { - t7 = shouldShowDot && (isQueued ? {BLACK_CIRCLE} : ); - $[31] = isQueued; - $[32] = isResolved; - $[33] = lookups.erroredToolUseIDs; - $[34] = param.id; - $[35] = shouldAnimate; - $[36] = shouldShowDot; - $[37] = t7; - } else { - t7 = $[37]; - } - const t8 = userFacingToolNameBackgroundColor ? "inverseText" : undefined; - let t9; - if ($[38] !== t8 || $[39] !== userFacingToolName || $[40] !== userFacingToolNameBackgroundColor) { - t9 = {userFacingToolName}; - $[38] = t8; - $[39] = userFacingToolName; - $[40] = userFacingToolNameBackgroundColor; - $[41] = t9; - } else { - t9 = $[41]; - } - let t10; - if ($[42] !== renderedToolUseMessage) { - t10 = renderedToolUseMessage !== "" && ({renderedToolUseMessage}); - $[42] = renderedToolUseMessage; - $[43] = t10; - } else { - t10 = $[43]; - } - let t11; - if ($[44] !== input_0.data || $[45] !== input_0.success || $[46] !== tool_0) { - t11 = input_0.success && tool_0.renderToolUseTag && tool_0.renderToolUseTag(input_0.data); - $[44] = input_0.data; - $[45] = input_0.success; - $[46] = tool_0; - $[47] = t11; - } else { - t11 = $[47]; - } - let t12; - if ($[48] !== t10 || $[49] !== t11 || $[50] !== t6 || $[51] !== t7 || $[52] !== t9) { - t12 = {t7}{t9}{t10}{t11}; - $[48] = t10; - $[49] = t11; - $[50] = t6; - $[51] = t7; - $[52] = t9; - $[53] = t12; - } else { - t12 = $[53]; - } - let t13; - if ($[54] !== inProgressToolCallCount || $[55] !== isAutoClassifier || $[56] !== isClassifierChecking || $[57] !== isQueued || $[58] !== isResolved || $[59] !== isTranscriptMode || $[60] !== isWaitingForPermission || $[61] !== lookups || $[62] !== param.id || $[63] !== progressMessagesForMessage || $[64] !== terminalSize || $[65] !== tool_0 || $[66] !== tools || $[67] !== verbose) { - t13 = !isResolved && !isQueued && (isClassifierChecking ? {isAutoClassifier ? "Auto classifier checking\u2026" : "Bash classifier checking\u2026"} : isWaitingForPermission ? Waiting for permission… : renderToolUseProgressMessage(tool_0, tools, lookups, param.id, progressMessagesForMessage, { - verbose, - inProgressToolCallCount, - isTranscriptMode - }, terminalSize)); - $[54] = inProgressToolCallCount; - $[55] = isAutoClassifier; - $[56] = isClassifierChecking; - $[57] = isQueued; - $[58] = isResolved; - $[59] = isTranscriptMode; - $[60] = isWaitingForPermission; - $[61] = lookups; - $[62] = param.id; - $[63] = progressMessagesForMessage; - $[64] = terminalSize; - $[65] = tool_0; - $[66] = tools; - $[67] = verbose; - $[68] = t13; - } else { - t13 = $[68]; - } - let t14; - if ($[69] !== isQueued || $[70] !== isResolved || $[71] !== tool_0) { - t14 = !isResolved && isQueued && renderToolUseQueuedMessage(tool_0); - $[69] = isQueued; - $[70] = isResolved; - $[71] = tool_0; - $[72] = t14; - } else { - t14 = $[72]; - } - let t15; - if ($[73] !== t12 || $[74] !== t13 || $[75] !== t14) { - t15 = {t12}{t13}{t14}; - $[73] = t12; - $[74] = t13; - $[75] = t14; - $[76] = t15; - } else { - t15 = $[76]; - } - let t16; - if ($[77] !== bg || $[78] !== t15 || $[79] !== t5) { - t16 = {t15}; - $[77] = bg; - $[78] = t15; - $[79] = t5; - $[80] = t16; - } else { - t16 = $[80]; - } - return t16; + + return ( + + + + {shouldShowDot && + (isQueued ? ( + + {BLACK_CIRCLE} + + ) : ( + // WARNING: The code here and in ToolUseLoader is particularly + // sensitive to what *should* just be trivial refactorings. See + // the comment in ToolUseLoader for more details. + + ))} + + + {userFacingToolName} + + + {renderedToolUseMessage !== '' && ( + + ({renderedToolUseMessage}) + + )} + {/* Render tool-specific tags (timeout, model, resume ID, etc.) */} + {input.success && + tool.renderToolUseTag && + tool.renderToolUseTag(input.data)} + + {!isResolved && + !isQueued && + (isClassifierChecking ? ( + + + {isAutoClassifier + ? 'Auto classifier checking\u2026' + : 'Bash classifier checking\u2026'} + + + ) : isWaitingForPermission ? ( + + Waiting for permission… + + ) : ( + renderToolUseProgressMessage( + tool, + tools, + lookups, + param.id, + progressMessagesForMessage, + { + verbose, + inProgressToolCallCount, + isTranscriptMode, + }, + terminalSize, + ) + ))} + {!isResolved && isQueued && renderToolUseQueuedMessage(tool)} + + + ) } -function _temp3(state_1) { - return !!state_1.toolPermissionContext.strippedDangerousRules; -} -function _temp2(state_0) { - return state_0.toolPermissionContext.mode; -} -function _temp(state) { - return state.pendingWorkerRequest; -} -function renderToolUseMessage(tool: Tool, input: unknown, { - theme, - verbose, - commands -}: { - theme: ThemeName; - verbose: boolean; - commands: Command[]; -}): React.ReactNode { + +function renderToolUseMessage( + tool: Tool, + input: unknown, + { + 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 '' } } -function renderToolUseProgressMessage(tool: Tool, tools: Tools, lookups: ReturnType, toolUseID: string, progressMessagesForMessage: ProgressMessage[], { - verbose, - inProgressToolCallCount, - isTranscriptMode -}: { - verbose: boolean; - inProgressToolCallCount?: number; - isTranscriptMode?: boolean; -}, terminalSize: { - columns: number; - rows: number; -}): React.ReactNode { - const toolProgressMessages = progressMessagesForMessage.filter((msg): msg is ProgressMessage => (msg.data as { type?: string }).type !== 'hook_progress'); + +function renderToolUseProgressMessage( + tool: Tool, + tools: Tools, + lookups: ReturnType, + toolUseID: string, + progressMessagesForMessage: ProgressMessage[], + { + verbose, + inProgressToolCallCount, + isTranscriptMode, + }: { + 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', + ) try { - const toolMessages = tool.renderToolUseProgressMessage?.(toolProgressMessages, { - tools, - verbose, - terminalSize, - inProgressToolCallCount: inProgressToolCallCount ?? 1, - isTranscriptMode - }) ?? null; - return <> + const toolMessages = + tool.renderToolUseProgressMessage?.(toolProgressMessages, { + tools, + verbose, + terminalSize, + inProgressToolCallCount: inProgressToolCallCount ?? 1, + isTranscriptMode, + }) ?? null + return ( + <> - + {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 3b23cd8ee..51f9ea67d 100644 --- a/src/components/messages/AttachmentMessage.tsx +++ b/src/components/messages/AttachmentMessage.tsx @@ -1,106 +1,134 @@ -import { c as _c } from "react/compiler-runtime"; // 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 { type AppState, useAppState } from '../../state/AppState.js'; -import type { TaskState } from '../../tasks/types.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 + isTranscriptMode, }: Props): React.ReactNode { - const bg = useSelectedMessageBg(); + 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; + const isDemoEnv = feature('EXPERIMENTAL_SKILL_SEARCH') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useMemo(() => isEnvTruthy(process.env.IS_DEMO), []) + : 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_0, idx) => { - // Try to parse as JSON for task_assignment messages - let parsedMsg: { - type?: string; - taskId?: string; - subject?: string; - assignedBy?: string; - } | null = null; - try { - parsedMsg = jsonParse(msg_0.text); - } catch { - // Not JSON, treat as plain text - } - if (parsedMsg?.type === 'task_assignment') { - return + 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 + try { + parsedMsg = jsonParse(msg.text) + } catch { + // Not JSON, treat as plain text + } + + if (parsedMsg?.type === 'task_assignment') { + return ( + {BLACK_CIRCLE} Task assigned: #{parsedMsg.taskId} - {parsedMsg.subject} - (from {parsedMsg.assignedBy || msg_0.from}) - ; - } + (from {parsedMsg.assignedBy || msg.from}) + + ) + } - // Note: idle_notification messages already filtered out above + // Note: idle_notification messages already filtered out above - // Try to render as plan approval message (request or response) - const planApprovalElement = tryRenderPlanApprovalMessage(msg_0.text, msg_0.from); - if (planApprovalElement) { - return {planApprovalElement}; - } + // Try to render as plan approval message (request or response) + const planApprovalElement = tryRenderPlanApprovalMessage( + msg.text, + msg.from, + ) + if (planApprovalElement) { + return ( + {planApprovalElement} + ) + } - // Plain text message - sender header with chevron, truncated content - const inkColor = toInkColor(msg_0.color); - const formattedContent = formatTeammateMessageContent(msg_0.text) ?? msg_0.text; - return ; - })} - ; + // Plain text message - sender header with chevron, truncated content + 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' @@ -108,83 +136,117 @@ 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 hint = (process.env.USER_TYPE) === 'ant' && !isDemoEnv && firstId ? ` · /skill-feedback ${firstId} 1=wrong 2=noisy 3=good [comment]` : ''; - return + 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} {hint && {hint}} - ; + + ) } } // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- teammate_mailbox/skill_discovery handled before switch switch (attachment.type) { case 'directory': - return + return ( + Listed directory {attachment.displayPath + sep} - ; + + ) case 'file': case 'already_read_file': if (attachment.content.type === 'notebook') { - return + return ( + Read {attachment.displayPath} ( {attachment.content.file.cells.length} cells) - ; + + ) } if (attachment.content.type === 'file_unchanged') { - return + return ( + Read {attachment.displayPath} (unchanged) - ; + + ) } - return + return ( + Read {attachment.displayPath} ( - {attachment.content.type === 'text' ? `${attachment.content.file.numLines}${attachment.truncated ? '+' : ''} lines` : formatFileSize(attachment.content.file.originalSize)} + {attachment.content.type === 'text' + ? `${attachment.content.file.numLines}${attachment.truncated ? '+' : ''} lines` + : formatFileSize(attachment.content.file.originalSize)} ) - ; + + ) case 'compact_file_reference': - return + return ( + Referenced file {attachment.displayPath} - ; + + ) case 'pdf_reference': - return + return ( + Referenced PDF {attachment.displayPath} ( {attachment.pageCount} pages) - ; + + ) case 'selected_lines_in_ide': - return + return ( + ⧉ Selected{' '} {attachment.lineEnd - attachment.lineStart + 1}{' '} lines from {attachment.displayPath} in{' '} {attachment.ideName} - ; + + ) case 'nested_memory': - return + 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 + return ( + Recalled {attachment.memories.length}{' '} {attachment.memories.length === 1 ? 'memory' : 'memories'} - {!isTranscriptMode && <> + {!isTranscriptMode && ( + <> {' '} - } + + )} - {(verbose || isTranscriptMode) && attachment.memories.map(m => + {(verbose || isTranscriptMode) && + attachment.memories.map(m => ( + @@ -192,156 +254,201 @@ export function AttachmentMessage({ - {isTranscriptMode && + {isTranscriptMode && ( + {m.content} - } - )} - ; - case 'dynamic_skill': - { - const skillCount = attachment.skillNames.length; - return + + )} + + ))} + + ) + case 'dynamic_skill': { + const skillCount = attachment.skillNames.length + return ( + Loaded{' '} {skillCount} {plural(skillCount, 'skill')} {' '} from {attachment.displayPath} - ; + + ) + } + case 'skill_listing': { + if (attachment.isInitial) { + return null } - case 'skill_listing': - { - if (attachment.isInitial) { - return null; - } - return + return ( + {attachment.skillCount}{' '} {plural(attachment.skillCount, 'skill')} available - ; + + ) + } + case 'agent_listing_delta': { + if (attachment.isInitial || attachment.addedTypes.length === 0) { + return null } - case 'agent_listing_delta': - { - if (attachment.isInitial || attachment.addedTypes.length === 0) { - return null; - } - const count = attachment.addedTypes.length; - return + 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; - return - - {hasImages && attachment.imagePasteIds?.map(id => )} - ; - } + + ) + } + case 'queued_command': { + 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 => ( + + ))} + + ) + } case 'plan_file_reference': - return + return ( + Plan file referenced ({getDisplayPath(attachment.planFilePath)}) - ; - case 'invoked_skills': - { - if (attachment.skills.length === 0) { - return null; - } - const skillNames = attachment.skills.map(s_0 => s_0.name).join(', '); - return Skills restored ({skillNames}); + + ) + case 'invoked_skills': { + if (attachment.skills.length === 0) { + return null } + const skillNames = attachment.skills.map(s => s.name).join(', ') + return Skills restored ({skillNames}) + } case 'diagnostics': - return ; + return case 'mcp_resource': - return + return ( + 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; - case 'async_hook_response': - { - // SessionStart hook completions are only shown in verbose mode - if (attachment.hookEvent === 'SessionStart' && !verbose) { - return null; - } - // Generally hide async hook completion messages unless in verbose mode - if (!verbose && !isTranscriptMode) { - return null; - } - return - Async hook {attachment.hookEvent} completed - ; + return null + case 'async_hook_response': { + // SessionStart hook completions are only shown in verbose mode + if (attachment.hookEvent === 'SessionStart' && !verbose) { + return null } - case 'hook_blocking_error': - { - // Stop hooks are rendered as a summary in SystemStopHookSummaryMessage - 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(); - return <> + // Generally hide async hook completion messages unless in verbose mode + if (!verbose && !isTranscriptMode) { + 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 + } + // Show stderr to the user so they can understand why the hook blocked + const stderr = attachment.blockingError.blockingError.trim() + return ( + <> {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; - } - // Full hook output is logged to debug log via hookEvents.ts - return {attachment.hookName} hook error; + + ) + } + case 'hook_non_blocking_error': { + // Stop hooks are rendered as a summary in SystemStopHookSummaryMessage + 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 + } 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 + return ( + {attachment.hookName} hook stopped continuation: {attachment.message} - ; + + ) case 'hook_system_message': - return + return ( + {attachment.hookName} says: {attachment.content} - ; - case 'hook_permission_decision': - { - const action = attachment.decision === 'allow' ? 'Allowed' : 'Denied'; - return + + ) + case 'hook_permission_decision': { + const action = attachment.decision === 'allow' ? 'Allowed' : 'Denied' + return ( + {action} by {attachment.hookEvent} hook - ; - } + + ) + } case 'task_status': - return ; + return case 'teammate_shutdown_batch': - return + return ( + {BLACK_CIRCLE} {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 @@ -352,185 +459,110 @@ 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' | 'bagel_console'; - return null; + attachment.type satisfies + | NullRenderingAttachmentType + | 'skill_discovery' + | 'teammate_mailbox' + return null } } -type TaskStatusAttachment = Extract; -function TaskStatusMessage(t0) { - const $ = _c(4); - const { - attachment - } = t0; - if (false && attachment.status === "killed") { - return null; + +type TaskStatusAttachment = Extract + +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 } - if (isAgentSwarmsEnabled() && attachment.taskType === "in_process_teammate") { - let t1; - if ($[0] !== attachment) { - t1 = ; - $[0] = attachment; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; + + // 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 } - let t1; - if ($[2] !== attachment) { - t1 = ; - $[2] = attachment; - $[3] = t1; - } else { - t1 = $[3]; - } - return t1; + + return } -function GenericTaskStatus(t0) { - const $ = _c(9); - const { - attachment - } = t0; - const bg = useSelectedMessageBg(); - const statusText = attachment.status === "completed" ? "completed in background" : attachment.status === "killed" ? "stopped" : attachment.status === "running" ? "still running in background" : attachment.status; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = {BLACK_CIRCLE} ; - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] !== attachment.description) { - t2 = {attachment.description}; - $[1] = attachment.description; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== statusText || $[4] !== t2) { - t3 = Task "{t2}" {statusText}; - $[3] = statusText; - $[4] = t2; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] !== bg || $[7] !== t3) { - t4 = {t1}{t3}; - $[6] = bg; - $[7] = t3; - $[8] = t4; - } else { - t4 = $[8]; - } - return t4; + +function GenericTaskStatus({ + attachment, +}: { + attachment: TaskStatusAttachment +}): React.ReactNode { + const bg = useSelectedMessageBg() + const statusText = + attachment.status === 'completed' + ? 'completed in background' + : attachment.status === 'killed' + ? 'stopped' + : attachment.status === 'running' + ? 'still running in background' + : attachment.status + return ( + + {BLACK_CIRCLE} + + Task "{attachment.description}" {statusText} + + + ) } -function TeammateTaskStatus(t0: { attachment: TaskStatusAttachment }) { - const $ = _c(16); - const { - attachment - } = t0; - const bg = useSelectedMessageBg(); - let t1: (s: AppState) => TaskState; - if ($[0] !== attachment.taskId) { - t1 = s => s.tasks[attachment.taskId]; - $[0] = attachment.taskId; - $[1] = t1; - } else { - t1 = $[1] as (s: AppState) => TaskState; + +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]) + if (task?.type !== 'in_process_teammate') { + // Fall through to generic rendering (task not yet in store, or wrong type) + return } - const task = useAppState(t1); - if (task?.type !== "in_process_teammate") { - let t2; - if ($[2] !== attachment) { - t2 = ; - $[2] = attachment; - $[3] = t2; - } else { - t2 = $[3]; - } - return t2; - } - let t2; - if ($[4] !== task.identity.color) { - t2 = toInkColor(task.identity.color); - $[4] = task.identity.color; - $[5] = t2; - } else { - t2 = $[5]; - } - const agentColor = t2; - const statusText = attachment.status === "completed" ? "shut down gracefully" : attachment.status; - let t3; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t3 = {BLACK_CIRCLE} ; - $[6] = t3; - } else { - t3 = $[6]; - } - let t4; - if ($[7] !== agentColor || $[8] !== task.identity.agentName) { - t4 = @{task.identity.agentName}; - $[7] = agentColor; - $[8] = task.identity.agentName; - $[9] = t4; - } else { - t4 = $[9]; - } - let t5; - if ($[10] !== statusText || $[11] !== t4) { - t5 = Teammate{" "}{t4}{" "}{statusText}; - $[10] = statusText; - $[11] = t4; - $[12] = t5; - } else { - t5 = $[12]; - } - let t6; - if ($[13] !== bg || $[14] !== t5) { - t6 = {t3}{t5}; - $[13] = bg; - $[14] = t5; - $[15] = t6; - } else { - t6 = $[15]; - } - return t6; + const agentColor = toInkColor(task.identity.color) + const statusText = + attachment.status === 'completed' + ? 'shut down gracefully' + : attachment.status + return ( + + {BLACK_CIRCLE} + + Teammate{' '} + + @{task.identity.agentName} + {' '} + {statusText} + + + ) } // We allow setting dimColor to false here to help work around the dim-bold bug. // https://github.com/chalk/chalk/issues/290 -function Line(t0) { - const $ = _c(7); - const { - dimColor: t1, - children, - color - } = t0; - const dimColor = t1 === undefined ? true : t1; - const bg = useSelectedMessageBg(); - let t2; - if ($[0] !== children || $[1] !== color || $[2] !== dimColor) { - t2 = {children}; - $[0] = children; - $[1] = color; - $[2] = dimColor; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== bg || $[5] !== t2) { - t3 = {t2}; - $[4] = bg; - $[5] = t2; - $[6] = t3; - } else { - t3 = $[6]; - } - return t3; +function Line({ + dimColor = true, + children, + color, +}: { + dimColor?: boolean + children: React.ReactNode + color?: keyof Theme +}): React.ReactNode { + const bg = useSelectedMessageBg() + return ( + + + + {children} + + + + ) } diff --git a/src/components/messages/CollapsedReadSearchContent.tsx b/src/components/messages/CollapsedReadSearchContent.tsx index e21659c59..d8df34f69 100644 --- a/src/components/messages/CollapsedReadSearchContent.tsx +++ b/src/components/messages/CollapsedReadSearchContent.tsx @@ -1,144 +1,122 @@ -import { c as _c } from "react/compiler-runtime"; -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; +const teamMemCollapsed = feature('TEAMMEM') + ? (require('./teamMemCollapsed.js') as typeof import('./teamMemCollapsed.js')) + : 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(t0) { - const $ = _c(24); - const { - content, - tools, - lookups, - inProgressToolUseIDs, - shouldAnimate, - theme - } = t0; - const bg = useSelectedMessageBg(); - let t1; - let t2; - if ($[0] !== bg || $[1] !== content.id || $[2] !== content.input || $[3] !== content.name || $[4] !== inProgressToolUseIDs || $[5] !== lookups || $[6] !== shouldAnimate || $[7] !== theme || $[8] !== tools) { - t2 = Symbol.for("react.early_return_sentinel"); - bb0: { - const tool = findToolByName(tools, content.name) ?? findToolByName(getReplPrimitiveTools(), content.name); - if (!tool) { - t2 = null; - break bb0; - } - let t3; - if ($[11] !== content.id || $[12] !== lookups.resolvedToolUseIDs) { - t3 = lookups.resolvedToolUseIDs.has(content.id); - $[11] = content.id; - $[12] = lookups.resolvedToolUseIDs; - $[13] = t3; - } else { - t3 = $[13]; - } - const isResolved = t3; - let t4; - if ($[14] !== content.id || $[15] !== lookups.erroredToolUseIDs) { - t4 = lookups.erroredToolUseIDs.has(content.id); - $[14] = content.id; - $[15] = lookups.erroredToolUseIDs; - $[16] = t4; - } else { - t4 = $[16]; - } - const isError = t4; - let t5; - if ($[17] !== content.id || $[18] !== inProgressToolUseIDs) { - t5 = inProgressToolUseIDs.has(content.id); - $[17] = content.id; - $[18] = inProgressToolUseIDs; - $[19] = t5; - } else { - t5 = $[19]; - } - const isInProgress = t5; - 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 t6 = shouldAnimate && isInProgress; - const t7 = !isResolved; - let t8; - if ($[20] !== isError || $[21] !== t6 || $[22] !== t7) { - t8 = ; - $[20] = isError; - $[21] = t6; - $[22] = t7; - $[23] = t8; - } else { - t8 = $[23]; - } - t1 = {t8}{userFacingName}{toolUseMessage && ({toolUseMessage})}{input && tool.renderToolUseTag?.(input)}{isResolved && !isError && toolResult !== undefined && {tool.renderToolResultMessage?.(toolResult, [], { +function VerboseToolUse({ + content, + tools, + lookups, + inProgressToolUseIDs, + shouldAnimate, + theme, +}: { + 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() + // 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 + + return ( + + + + + {userFacingName} + {toolUseMessage && ({toolUseMessage})} + + {input && tool.renderToolUseTag?.(input)} + + {isResolved && !isError && toolResult !== undefined && ( + + {tool.renderToolResultMessage?.(toolResult, [], { verbose: true, tools, - theme - })}}; - } - $[0] = bg; - $[1] = content.id; - $[2] = content.input; - $[3] = content.name; - $[4] = inProgressToolUseIDs; - $[5] = lookups; - $[6] = shouldAnimate; - $[7] = theme; - $[8] = tools; - $[9] = t1; - $[10] = t2; - } else { - t1 = $[9]; - t2 = $[10]; - } - if (t2 !== Symbol.for("react.early_return_sentinel")) { - return t2; - } - return t1; + theme, + })} + + )} + + ) } + export function CollapsedReadSearchContent({ message, inProgressToolUseIDs, @@ -146,9 +124,9 @@ export function CollapsedReadSearchContent({ verbose, tools, lookups, - isActiveGroup + isActiveGroup, }: Props): React.ReactNode { - const bg = useSelectedMessageBg(); + const bg = useSelectedMessageBg() const { searchCount: rawSearchCount, readCount: rawReadCount, @@ -157,94 +135,141 @@ export function CollapsedReadSearchContent({ memorySearchCount, 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; + 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 // 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 hasNonMemoryOps = searchCount > 0 || readCount > 0 || listCount > 0 || replCount > 0 || mcpCallCount > 0 || bashCount > 0 || gitOpBashCount > 0; - const readPaths = message.readFilePaths; - const searchArgs = message.searchArgs; - let incomingHint = message.latestDisplayHint; + const gitOpBashCount = message.gitOpBashCount ?? 0 + const bashCount = isFullscreenEnvEnabled() + ? Math.max(0, maxBashCountRef.current - gitOpBashCount) + : 0 + + const hasNonMemoryOps = + searchCount > 0 || + readCount > 0 || + listCount > 0 || + replCount > 0 || + mcpCallCount > 0 || + bashCount > 0 || + gitOpBashCount > 0 + + 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 // tool's name+input. Virtual messages don't arrive until REPL completes, // so this is the only source of a live hint during execution. if (isActiveGroup) { - for (const id_0 of toolUseIds) { - if (!inProgressToolUseIDs.has(id_0)) continue; - const latest = lookups.progressMessagesByToolUseID.get(id_0)?.at(-1)?.data as { type?: string; phase?: string; toolInput?: unknown; toolName?: string } | undefined; + for (const id of toolUseIds) { + 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; - }; - incomingHint = input.file_path ?? (input.pattern ? `"${input.pattern}"` : undefined) ?? input.command ?? latest.toolName; + command?: string + pattern?: string + file_path?: string + } + incomingHint = + 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_0 => { - const content = msg_0.message.content[0]; - if (!content || typeof content === 'string' || content?.type !== 'tool_use') return null; - return ; - })} - {message.hookInfos && message.hookInfos.length > 0 && <> + + return ( + + {toolUses.map(msg => { + 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'} ( {formatSecondsShort(message.hookTotalMs ?? 0)}) - {message.hookInfos.map((info, idx) => + {message.hookInfos.map((info, idx) => ( + {' ⎿ '} {info.command} ({formatSecondsShort(info.durationMs ?? 0)}) - )} - } - {message.relevantMemories?.map(m => + + ))} + + )} + {message.relevantMemories?.map(m => ( + {' ⎿ '}Recalled {basename(m.path)} @@ -253,8 +278,10 @@ export function CollapsedReadSearchContent({ {m.content} - )} - ; + + ))} + + ) } // Non-verbose mode: Show counts with blinking grey dot while active, green dot when finalized @@ -263,70 +290,79 @@ 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; - for (const id_1 of toolUseIds) { - if (!inProgressToolUseIDs.has(id_1)) continue; - const data = lookups.progressMessagesByToolUseID.get(id_1)?.at(-1)?.data as { type?: string; elapsedTimeSeconds?: number; totalLines?: number } | undefined; - if (data?.type !== 'bash_progress' && data?.type !== 'powershell_progress') { - continue; + 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 (elapsed === undefined || (data.elapsedTimeSeconds ?? 0) > elapsed) { - elapsed = data.elapsedTimeSeconds ?? 0; - lines = data.totalLines ?? 0; + if (elapsed === undefined || data.elapsedTimeSeconds > elapsed) { + 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(, ); - 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' - }; + '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_0 => c_0.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) { @@ -336,148 +372,248 @@ export function CollapsedReadSearchContent({ merged: 'merged', commented: 'commented on', closed: 'closed', - ready: 'marked ready' - }; + ready: 'marked ready', + } for (const pr of message.prs) { - pushPart(`pr-${pr.action}-${pr.number}`, verbs[pr.action], pr.url ? : PR #{pr.number}); + pushPart( + `pr-${pr.action}-${pr.number}`, + verbs[pr.action], + pr.url ? ( + + ) : ( + PR #{pr.number} + ), + ) } } + if (searchCount > 0) { - const isFirst_0 = nonMemParts.length === 0; - const searchVerb = isActiveGroup ? isFirst_0 ? 'Searching for' : 'searching for' : isFirst_0 ? 'Searched for' : 'searched for'; - if (!isFirst_0) { - nonMemParts.push(, ); + const isFirst = nonMemParts.length === 0 + const searchVerb = isActiveGroup + ? isFirst + ? 'Searching for' + : 'searching for' + : isFirst + ? 'Searched for' + : 'searched for' + if (!isFirst) { + nonMemParts.push(, ) } - nonMemParts.push( + nonMemParts.push( + {searchVerb} {searchCount}{' '} {searchCount === 1 ? 'pattern' : 'patterns'} - ); + , + ) } + if (readCount > 0) { - const isFirst_1 = nonMemParts.length === 0; - const readVerb = isActiveGroup ? isFirst_1 ? 'Reading' : 'reading' : isFirst_1 ? 'Read' : 'read'; - if (!isFirst_1) { - nonMemParts.push(, ); + 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'} - ); + , + ) } + if (listCount > 0) { - const isFirst_2 = nonMemParts.length === 0; - const listVerb = isActiveGroup ? isFirst_2 ? 'Listing' : 'listing' : isFirst_2 ? 'Listed' : 'listed'; - if (!isFirst_2) { - nonMemParts.push(, ); + 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'} - ); + , + ) } + 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( + nonMemParts.push( + {replVerb} {replCount}{' '} {replCount === 1 ? 'time' : 'times'} - ); + , + ) } + if (mcpCallCount > 0) { - const serverLabel = message.mcpServerNames?.map(n => n.replace(/^claude\.ai /, '')).join(', ') || 'MCP'; - const isFirst_3 = nonMemParts.length === 0; - const verb_0 = isActiveGroup ? isFirst_3 ? 'Querying' : 'querying' : isFirst_3 ? 'Queried' : 'queried'; - if (!isFirst_3) { - nonMemParts.push(, ); + 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( - {verb_0} {serverLabel} - {mcpCallCount > 1 && <> + nonMemParts.push( + + {verb} {serverLabel} + {mcpCallCount > 1 && ( + <> {' '} {mcpCallCount} times - } - ); + + )} + , + ) } + if (isFullscreenEnvEnabled() && bashCount > 0) { - const isFirst_4 = nonMemParts.length === 0; - const verb_1 = isActiveGroup ? isFirst_4 ? 'Running' : 'running' : isFirst_4 ? 'Ran' : 'ran'; - if (!isFirst_4) { - nonMemParts.push(, ); + const isFirst = nonMemParts.length === 0 + const verb = isActiveGroup + ? isFirst + ? 'Running' + : 'running' + : isFirst + ? 'Ran' + : 'ran' + if (!isFirst) { + nonMemParts.push(, ) } - nonMemParts.push( - {verb_1} {bashCount} bash{' '} + nonMemParts.push( + + {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_5 = !hasPrecedingNonMem && memParts.length === 0; - const verb_2 = isActiveGroup ? isFirst_5 ? 'Recalling' : 'recalling' : isFirst_5 ? 'Recalled' : 'recalled'; - if (!isFirst_5) { - memParts.push(, ); + const isFirst = !hasPrecedingNonMem && memParts.length === 0 + const verb = isActiveGroup + ? isFirst + ? 'Recalling' + : 'recalling' + : isFirst + ? 'Recalled' + : 'recalled' + if (!isFirst) { + memParts.push(, ) } - memParts.push( - {verb_2} {memoryReadCount}{' '} + memParts.push( + + {verb} {memoryReadCount}{' '} {memoryReadCount === 1 ? 'memory' : 'memories'} - ); + , + ) } + if (memorySearchCount > 0) { - const isFirst_6 = !hasPrecedingNonMem && memParts.length === 0; - const verb_3 = isActiveGroup ? isFirst_6 ? 'Searching' : 'searching' : isFirst_6 ? 'Searched' : 'searched'; - if (!isFirst_6) { - memParts.push(, ); + const isFirst = !hasPrecedingNonMem && memParts.length === 0 + const verb = isActiveGroup + ? isFirst + ? 'Searching' + : 'searching' + : isFirst + ? 'Searched' + : 'searched' + if (!isFirst) { + memParts.push(, ) } - memParts.push({`${verb_3} memories`}); + memParts.push({`${verb} memories`}) } + if (memoryWriteCount > 0) { - const isFirst_7 = !hasPrecedingNonMem && memParts.length === 0; - const verb_4 = isActiveGroup ? isFirst_7 ? 'Writing' : 'writing' : isFirst_7 ? 'Wrote' : 'wrote'; - if (!isFirst_7) { - memParts.push(, ); + const isFirst = !hasPrecedingNonMem && memParts.length === 0 + const verb = isActiveGroup + ? isFirst + ? 'Writing' + : 'writing' + : isFirst + ? 'Wrote' + : 'wrote' + if (!isFirst) { + memParts.push(, ) } - memParts.push( - {verb_4} {memoryWriteCount}{' '} + memParts.push( + + {verb} {memoryWriteCount}{' '} {memoryWriteCount === 1 ? 'memory' : 'memories'} - ); + , + ) } - return + + return ( + - {isActiveGroup ? : } + {isActiveGroup ? ( + + ) : ( + + )} {nonMemParts} {memParts} - {feature('TEAMMEM') ? teamMemCollapsed!.TeamMemCountParts({ - message, - isActiveGroup, - hasPrecedingParts: hasPrecedingNonMem || memParts.length > 0 - }) : null} + {feature('TEAMMEM') + ? teamMemCollapsed!.TeamMemCountParts({ + message, + isActiveGroup, + hasPrecedingParts: hasPrecedingNonMem || memParts.length > 0, + }) + : null} {isActiveGroup && } - {isActiveGroup && displayedHint !== undefined && - // Row layout: 5-wide gutter for ⎿, then a flex column for the text. - // Ink's wrap stays inside the right column so continuation lines - // indent under ⎿. MAX_HINT_CHARS in commandAsHint caps total at ~5 lines. - + {isActiveGroup && displayedHint !== undefined && ( + // Row layout: 5-wide gutter for ⎿, then a flex column for the text. + // Ink's wrap stays inside the right column so continuation lines + // indent under ⎿. MAX_HINT_CHARS in commandAsHint caps total at ~5 lines. + {' ⎿ '} - {displayedHint.split('\n').map((line, i, arr) => + {displayedHint.split('\n').map((line, i, arr) => ( + {line} {i === arr.length - 1 && shellProgressSuffix} - )} + + ))} - } - {message.hookTotalMs !== undefined && message.hookTotalMs > 0 && + + )} + {message.hookTotalMs !== undefined && message.hookTotalMs > 0 && ( + {' ⎿ '}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 f8c373f15..7c4e87af1 100644 --- a/src/components/messages/CompactBoundaryMessage.tsx +++ b/src/components/messages/CompactBoundaryMessage.tsx @@ -1,17 +1,19 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box, Text } from '../../ink.js'; -import { useShortcutDisplay } from '../../keybindings/useShortcutDisplay.js'; -export function CompactBoundaryMessage() { - const $ = _c(2); - const historyShortcut = useShortcutDisplay("app:toggleTranscript", "Global", "ctrl+o"); - let t0; - if ($[0] !== historyShortcut) { - t0 = ✻ Conversation compacted ({historyShortcut} for history); - $[0] = historyShortcut; - $[1] = t0; - } else { - t0 = $[1]; - } - return t0; +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', + ) + + return ( + + + ✻ Conversation compacted ({historyShortcut} for history) + + + ) } diff --git a/src/components/messages/GroupedToolUseContent.tsx b/src/components/messages/GroupedToolUseContent.tsx index 218fbba42..2376e377c 100644 --- a/src/components/messages/GroupedToolUseContent.tsx +++ b/src/components/messages/GroupedToolUseContent.tsx @@ -1,62 +1,71 @@ -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, tools, lookups, inProgressToolUseIDs, - shouldAnimate + 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(); + const resultsByToolUseId = new Map< + string, + { param: ToolResultBlockParam; output: unknown } + >() for (const resultMsg of message.results) { - const contentArr = resultMsg.message.content; - if (!Array.isArray(contentArr)) continue; - for (const content of contentArr) { - if (typeof content === 'string') continue; + for (const content of resultMsg.message.content) { if (content.type === 'tool_result') { - resultsByToolUseId.set((content as ToolResultBlockParam).tool_use_id, { - param: content as ToolResultBlockParam, - output: resultMsg.toolUseResult - }); + resultsByToolUseId.set(content.tool_use_id, { + param: content, + output: resultMsg.toolUseResult, + }) } } } + const toolUsesData = message.messages.map(msg => { - const contentArr = msg.message.content; - const rawContent = Array.isArray(contentArr) ? contentArr[0] : undefined; - const content = rawContent as ToolUseBlockParam; - const result = resultsByToolUseId.get(content.id); + const content = msg.message.content[0] + const result = resultsByToolUseId.get(content.id) return { - param: content, + 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) ?? []), - result - }; - }); - const anyInProgress = toolUsesData.some(d => d.isInProgress); + progressMessages: filterToolProgressMessages( + lookups.progressMessagesByToolUseID.get(content.id) ?? [], + ), + result, + } + }) + + const anyInProgress = toolUsesData.some(d => d.isInProgress) + return tool.renderGroupedToolUse(toolUsesData, { shouldAnimate: shouldAnimate && anyInProgress, - tools - }); + tools, + }) } diff --git a/src/components/messages/HighlightedThinkingText.tsx b/src/components/messages/HighlightedThinkingText.tsx index 3109f2048..1b4fd0c3c 100644 --- a/src/components/messages/HighlightedThinkingText.tsx +++ b/src/components/messages/HighlightedThinkingText.tsx @@ -1,161 +1,91 @@ -import { c as _c } from "react/compiler-runtime"; -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; -}; -export function HighlightedThinkingText(t0) { - const $ = _c(31); - const { - text, - useBriefLayout, - timestamp - } = t0; - const isQueued = useQueuedMessage()?.isQueued ?? false; - const isSelected = useContext(MessageActionsSelectedContext); - const pointerColor = isSelected ? "suggestion" : "subtle"; - if (useBriefLayout) { - let t1; - if ($[0] !== timestamp) { - t1 = timestamp ? formatBriefTimestamp(timestamp) : ""; - $[0] = timestamp; - $[1] = t1; - } else { - t1 = $[1]; - } - const ts = t1; - const t2 = isQueued ? "subtle" : "briefLabelYou"; - let t3; - if ($[2] !== t2) { - t3 = You; - $[2] = t2; - $[3] = t3; - } else { - t3 = $[3]; - } - let t4; - if ($[4] !== ts) { - t4 = ts ? {ts} : null; - $[4] = ts; - $[5] = t4; - } else { - t4 = $[5]; - } - let t5; - if ($[6] !== t3 || $[7] !== t4) { - t5 = {t3}{t4}; - $[6] = t3; - $[7] = t4; - $[8] = t5; - } else { - t5 = $[8]; - } - const t6 = isQueued ? "subtle" : "text"; - let t7; - if ($[9] !== t6 || $[10] !== text) { - t7 = {text}; - $[9] = t6; - $[10] = text; - $[11] = t7; - } else { - t7 = $[11]; - } - let t8; - if ($[12] !== t5 || $[13] !== t7) { - t8 = {t5}{t7}; - $[12] = t5; - $[13] = t7; - $[14] = t8; - } else { - t8 = $[14]; - } - return t8; - } - let parts; - let t1; - if ($[15] !== pointerColor || $[16] !== text) { - t1 = Symbol.for("react.early_return_sentinel"); - bb0: { - const triggers = isUltrathinkEnabled() ? findThinkingTriggerPositions(text) : []; - if (triggers.length === 0) { - let t2; - if ($[19] !== pointerColor) { - t2 = {figures.pointer} ; - $[19] = pointerColor; - $[20] = t2; - } else { - t2 = $[20]; - } - let t3; - if ($[21] !== text) { - t3 = {text}; - $[21] = text; - $[22] = t3; - } else { - t3 = $[22]; - } - let t4; - if ($[23] !== t2 || $[24] !== t3) { - t4 = {t2}{t3}; - $[23] = t2; - $[24] = t3; - $[25] = t4; - } else { - t4 = $[25]; - } - t1 = t4; - break bb0; - } - parts = []; - 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; - } - if (cursor < text.length) { - parts.push({text.slice(cursor)}); - } - } - $[15] = pointerColor; - $[16] = text; - $[17] = parts; - $[18] = t1; - } else { - parts = $[17]; - t1 = $[18]; - } - if (t1 !== Symbol.for("react.early_return_sentinel")) { - return t1; - } - let t2; - if ($[26] !== pointerColor) { - t2 = {figures.pointer} ; - $[26] = pointerColor; - $[27] = t2; - } else { - t2 = $[27]; - } - let t3; - if ($[28] !== parts || $[29] !== t2) { - t3 = {t2}{parts}; - $[28] = parts; - $[29] = t2; - $[30] = t3; - } else { - t3 = $[30]; - } - return t3; + text: string + useBriefLayout?: boolean + timestamp?: string +} + +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' + if (useBriefLayout) { + const ts = timestamp ? formatBriefTimestamp(timestamp) : '' + return ( + + + You + {ts ? {ts} : null} + + {text} + + ) + } + + const triggers = isUltrathinkEnabled() + ? findThinkingTriggerPositions(text) + : [] + + if (triggers.length === 0) { + return ( + + {figures.pointer} + {text} + + ) + } + + // Static rainbow (no shimmer — transcript messages don't animate) + 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 + } + if (cursor < text.length) { + parts.push( + + {text.slice(cursor)} + , + ) + } + + return ( + + {figures.pointer} + {parts} + + ) } diff --git a/src/components/messages/HookProgressMessage.tsx b/src/components/messages/HookProgressMessage.tsx index eabd2e0e2..61bfddf96 100644 --- a/src/components/messages/HookProgressMessage.tsx +++ b/src/components/messages/HookProgressMessage.tsx @@ -1,115 +1,67 @@ -import { c as _c } from "react/compiler-runtime"; -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; -}; -export function HookProgressMessage(t0) { - const $ = _c(22); - const { - hookEvent, - lookups, - toolUseID, - isTranscriptMode - } = t0; - let t1; - if ($[0] !== hookEvent || $[1] !== lookups.inProgressHookCounts || $[2] !== toolUseID) { - t1 = lookups.inProgressHookCounts.get(toolUseID)?.get(hookEvent) ?? 0; - $[0] = hookEvent; - $[1] = lookups.inProgressHookCounts; - $[2] = toolUseID; - $[3] = t1; - } else { - t1 = $[3]; - } - const inProgressHookCount = t1; - const resolvedHookCount = lookups.resolvedHookCounts.get(toolUseID)?.get(hookEvent) ?? 0; - if (inProgressHookCount === 0) { - return null; - } - if (hookEvent === "PreToolUse" || hookEvent === "PostToolUse") { - if (isTranscriptMode) { - let t2; - if ($[4] !== inProgressHookCount) { - t2 = {inProgressHookCount} ; - $[4] = inProgressHookCount; - $[5] = t2; - } else { - t2 = $[5]; - } - let t3; - if ($[6] !== hookEvent) { - t3 = {hookEvent}; - $[6] = hookEvent; - $[7] = t3; - } else { - t3 = $[7]; - } - const t4 = inProgressHookCount === 1 ? " hook" : " hooks"; - let t5; - if ($[8] !== t4) { - t5 = {t4} ran; - $[8] = t4; - $[9] = t5; - } else { - t5 = $[9]; - } - let t6; - if ($[10] !== t2 || $[11] !== t3 || $[12] !== t5) { - t6 = {t2}{t3}{t5}; - $[10] = t2; - $[11] = t3; - $[12] = t5; - $[13] = t6; - } else { - t6 = $[13]; - } - return t6; - } - return null; - } - if (resolvedHookCount === inProgressHookCount) { - return null; - } - let t2; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Running ; - $[14] = t2; - } else { - t2 = $[14]; - } - let t3; - if ($[15] !== hookEvent) { - t3 = {hookEvent}; - $[15] = hookEvent; - $[16] = t3; - } else { - t3 = $[16]; - } - const t4 = inProgressHookCount === 1 ? " hook\u2026" : " hooks\u2026"; - let t5; - if ($[17] !== t4) { - t5 = {t4}; - $[17] = t4; - $[18] = t5; - } else { - t5 = $[18]; - } - let t6; - if ($[19] !== t3 || $[20] !== t5) { - t6 = {t2}{t3}{t5}; - $[19] = t3; - $[20] = t5; - $[21] = t6; - } else { - t6 = $[21]; - } - return t6; + 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 + if (inProgressHookCount === 0) { + return null + } + + if (hookEvent === 'PreToolUse' || hookEvent === 'PostToolUse') { + // In transcript mode, show a static summary since messages never re-render + // (so a transient "Running..." would get stuck). + if (isTranscriptMode) { + return ( + + + {inProgressHookCount} + + {hookEvent} + + + {inProgressHookCount === 1 ? ' hook' : ' hooks'} ran + + + + ) + } + // Outside transcript mode, hide — completion info is shown via + // async_hook_response attachments instead. + return null + } + + if (resolvedHookCount === inProgressHookCount) { + return null + } + + return ( + + + Running + + {hookEvent} + + {inProgressHookCount === 1 ? ' hook…' : ' hooks…'} + + + ) } diff --git a/src/components/messages/PlanApprovalMessage.tsx b/src/components/messages/PlanApprovalMessage.tsx index 33a3947cf..a7fbced71 100644 --- a/src/components/messages/PlanApprovalMessage.tsx +++ b/src/components/messages/PlanApprovalMessage.tsx @@ -1,149 +1,158 @@ -import { c as _c } from "react/compiler-runtime"; -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, isPlanApprovalRequest, isPlanApprovalResponse, type PlanApprovalRequestMessage, type PlanApprovalResponseMessage } from '../../utils/teammateMailbox.js'; -import { getShutdownMessageSummary } from './ShutdownMessage.js'; -import { getTaskAssignmentSummary } from './TaskAssignmentMessage.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, + isPlanApprovalRequest, + isPlanApprovalResponse, + type PlanApprovalRequestMessage, + type PlanApprovalResponseMessage, +} 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(t0) { - const $ = _c(10); - const { - request - } = t0; - let t1; - if ($[0] !== request.from) { - t1 = Plan Approval Request from {request.from}; - $[0] = request.from; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== request.planContent) { - t2 = {request.planContent}; - $[2] = request.planContent; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== request.planFilePath) { - t3 = Plan file: {request.planFilePath}; - $[4] = request.planFilePath; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] !== t1 || $[7] !== t2 || $[8] !== t3) { - t4 = {t1}{t2}{t3}; - $[6] = t1; - $[7] = t2; - $[8] = t3; - $[9] = t4; - } else { - t4 = $[9]; - } - return t4; +export function PlanApprovalRequestDisplay({ + request, +}: PlanApprovalRequestProps): React.ReactNode { + return ( + + + + + Plan Approval Request from {request.from} + + + + {request.planContent} + + 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(t0) { - const $ = _c(13); - const { - response, - senderName - } = t0; +export function PlanApprovalResponseDisplay({ + response, + senderName, +}: PlanApprovalResponseProps): React.ReactNode { if (response.approved) { - let t1; - if ($[0] !== senderName) { - t1 = ✓ Plan Approved by {senderName}; - $[0] = senderName; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = You can now proceed with implementation. Your plan mode restrictions have been lifted.; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== t1) { - t3 = {t1}{t2}; - $[3] = t1; - $[4] = t3; - } else { - t3 = $[4]; - } - return t3; + return ( + + + + + ✓ Plan Approved by {senderName} + + + + + You can now proceed with implementation. Your plan mode + restrictions have been lifted. + + + + + ) } - let t1; - if ($[5] !== senderName) { - t1 = ✗ Plan Rejected by {senderName}; - $[5] = senderName; - $[6] = t1; - } else { - t1 = $[6]; - } - let t2; - if ($[7] !== response.feedback) { - t2 = response.feedback && Feedback: {response.feedback}; - $[7] = response.feedback; - $[8] = t2; - } else { - t2 = $[8]; - } - let t3; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t3 = Please revise your plan based on the feedback and call ExitPlanMode again.; - $[9] = t3; - } else { - t3 = $[9]; - } - let t4; - if ($[10] !== t1 || $[11] !== t2) { - t4 = {t1}{t2}{t3}; - $[10] = t1; - $[11] = t2; - $[12] = t4; - } else { - t4 = $[12]; - } - return t4; + + return ( + + + + + ✗ Plan Rejected by {senderName} + + + {response.feedback && ( + + Feedback: {response.feedback} + + )} + + + 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 } /** @@ -152,34 +161,36 @@ export function tryRenderPlanApprovalMessage(content: string, senderName: string * 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(' · ') } /** @@ -188,34 +199,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 e8b439e2b..c9a42815b 100644 --- a/src/components/messages/RateLimitMessage.tsx +++ b/src/components/messages/RateLimitMessage.tsx @@ -1,160 +1,131 @@ -import { c as _c } from "react/compiler-runtime"; -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, isMax20x, isExtraUsageCommandEnabled, shouldAutoOpenRateLimitOptionsMenu, isTeamOrEnterprise, - hasBillingAccess + 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(t0) { - const $ = _c(16); - const { - text, - onOpenRateLimitOptions - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = getSubscriptionType(); - $[0] = t1; - } else { - t1 = $[0]; - } - const subscriptionType = t1; - let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = getRateLimitTier(); - $[1] = t2; - } else { - t2 = $[1]; - } - const rateLimitTier = t2; - const isTeamOrEnterprise = subscriptionType === "team" || subscriptionType === "enterprise"; - const isMax20x = rateLimitTier === "default_claude_max_20x"; - let t3; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t3 = shouldProcessMockLimits() || isClaudeAISubscriber(); - $[2] = t3; - } else { - t3 = $[2]; - } - const shouldShowUpsell = t3; - const canSeeRateLimitOptionsUpsell = shouldShowUpsell && !isMax20x; - const [hasOpenedInteractiveMenu, setHasOpenedInteractiveMenu] = useState(false); - const claudeAiLimits = useClaudeAiLimits(); - const isCurrentlyRateLimited = claudeAiLimits.status === "rejected" && claudeAiLimits.resetsAt !== undefined && !claudeAiLimits.isUsingOverage; - const shouldAutoOpenRateLimitOptionsMenu = canSeeRateLimitOptionsUpsell && !hasOpenedInteractiveMenu && isCurrentlyRateLimited && onOpenRateLimitOptions; - let t4; - let t5; - if ($[3] !== onOpenRateLimitOptions || $[4] !== shouldAutoOpenRateLimitOptionsMenu) { - t4 = () => { - if (shouldAutoOpenRateLimitOptionsMenu) { - setHasOpenedInteractiveMenu(true); - onOpenRateLimitOptions(); - } - }; - t5 = [shouldAutoOpenRateLimitOptionsMenu, onOpenRateLimitOptions]; - $[3] = onOpenRateLimitOptions; - $[4] = shouldAutoOpenRateLimitOptionsMenu; - $[5] = t4; - $[6] = t5; - } else { - t4 = $[5]; - t5 = $[6]; - } - useEffect(t4, t5); - let t6; - bb0: { - let t7; - if ($[7] !== shouldAutoOpenRateLimitOptionsMenu) { - t7 = getUpsellMessage({ - shouldShowUpsell, - isMax20x, - isExtraUsageCommandEnabled: extraUsage.isEnabled(), - shouldAutoOpenRateLimitOptionsMenu: !!shouldAutoOpenRateLimitOptionsMenu, - isTeamOrEnterprise, - hasBillingAccess: hasClaudeAiBillingAccess() - }); - $[7] = shouldAutoOpenRateLimitOptionsMenu; - $[8] = t7; - } else { - t7 = $[8]; - } - const message = t7; - if (!message) { - t6 = null; - break bb0; - } - let t8; - if ($[9] !== message) { - t8 = {message}; - $[9] = message; - $[10] = t8; - } else { - t8 = $[10]; - } - t6 = t8; - } - const upsell = t6; - let t7; - if ($[11] !== text) { - t7 = {text}; - $[11] = text; - $[12] = t7; - } else { - t7 = $[12]; - } - const t8 = hasOpenedInteractiveMenu ? null : upsell; - let t9; - if ($[13] !== t7 || $[14] !== t8) { - t9 = {t7}{t8}; - $[13] = t7; - $[14] = t8; - $[15] = t9; - } else { - t9 = $[15]; - } - return t9; + 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 canSeeRateLimitOptionsUpsell = shouldShowUpsell && !isMax20x + + 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 isCurrentlyRateLimited = + claudeAiLimits.status === 'rejected' && + claudeAiLimits.resetsAt !== undefined && + !claudeAiLimits.isUsingOverage + + const shouldAutoOpenRateLimitOptionsMenu = + canSeeRateLimitOptionsUpsell && + !hasOpenedInteractiveMenu && + isCurrentlyRateLimited && + onOpenRateLimitOptions + + useEffect(() => { + if (shouldAutoOpenRateLimitOptionsMenu) { + setHasOpenedInteractiveMenu(true) + onOpenRateLimitOptions() + } + }, [shouldAutoOpenRateLimitOptionsMenu, onOpenRateLimitOptions]) + + const upsell = useMemo(() => { + const message = getUpsellMessage({ + shouldShowUpsell, + isMax20x, + isExtraUsageCommandEnabled: extraUsage.isEnabled(), + shouldAutoOpenRateLimitOptionsMenu: !!shouldAutoOpenRateLimitOptionsMenu, + isTeamOrEnterprise, + hasBillingAccess: hasClaudeAiBillingAccess(), + }) + if (!message) return null + return {message} + }, [ + shouldShowUpsell, + isMax20x, + isTeamOrEnterprise, + shouldAutoOpenRateLimitOptionsMenu, + ]) + + return ( + + + {text} + {hasOpenedInteractiveMenu ? null : upsell} + + + ) } diff --git a/src/components/messages/ShutdownMessage.tsx b/src/components/messages/ShutdownMessage.tsx index 257f29a7c..82e0d59e1 100644 --- a/src/components/messages/ShutdownMessage.tsx +++ b/src/components/messages/ShutdownMessage.tsx @@ -1,112 +1,113 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box, Text } from '../../ink.js'; -import { isShutdownApproved, isShutdownRejected, isShutdownRequest, type ShutdownRejectedMessage, type ShutdownRequestMessage } from '../../utils/teammateMailbox.js'; +import * as React from 'react' +import { Box, Text } from '../../ink.js' +import { + isShutdownApproved, + isShutdownRejected, + isShutdownRequest, + type ShutdownRejectedMessage, + type ShutdownRequestMessage, +} from '../../utils/teammateMailbox.js' + type ShutdownRequestProps = { - request: ShutdownRequestMessage; -}; + request: ShutdownRequestMessage +} /** * Renders a shutdown request with a warning-colored border. */ -export function ShutdownRequestDisplay(t0) { - const $ = _c(7); - const { - request - } = t0; - let t1; - if ($[0] !== request.from) { - t1 = Shutdown request from {request.from}; - $[0] = request.from; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== request.reason) { - t2 = request.reason && Reason: {request.reason}; - $[2] = request.reason; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== t1 || $[5] !== t2) { - t3 = {t1}{t2}; - $[4] = t1; - $[5] = t2; - $[6] = t3; - } else { - t3 = $[6]; - } - return t3; +export function ShutdownRequestDisplay({ + request, +}: ShutdownRequestProps): React.ReactNode { + return ( + + + + + Shutdown request from {request.from} + + + {request.reason && ( + + Reason: {request.reason} + + )} + + + ) } + type ShutdownRejectedProps = { - response: ShutdownRejectedMessage; -}; + response: ShutdownRejectedMessage +} /** * Renders a shutdown rejected message with a subtle (grey) border. */ -export function ShutdownRejectedDisplay(t0) { - const $ = _c(8); - const { - response - } = t0; - let t1; - if ($[0] !== response.from) { - t1 = Shutdown rejected by {response.from}; - $[0] = response.from; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== response.reason) { - t2 = Reason: {response.reason}; - $[2] = response.reason; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = Teammate is continuing to work. You may request shutdown again later.; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== t1 || $[6] !== t2) { - t4 = {t1}{t2}{t3}; - $[5] = t1; - $[6] = t2; - $[7] = t4; - } else { - t4 = $[7]; - } - return t4; +export function ShutdownRejectedDisplay({ + response, +}: ShutdownRejectedProps): React.ReactNode { + return ( + + + + Shutdown rejected by {response.from} + + + Reason: {response.reason} + + + + 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 } /** @@ -115,17 +116,20 @@ export function tryRenderShutdownMessage(content: string): React.ReactNode | nul * 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/SystemAPIErrorMessage.tsx b/src/components/messages/SystemAPIErrorMessage.tsx index 6bf5e60d6..c87dec717 100644 --- a/src/components/messages/SystemAPIErrorMessage.tsx +++ b/src/components/messages/SystemAPIErrorMessage.tsx @@ -1,140 +1,64 @@ -import { c as _c } from "react/compiler-runtime"; -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; +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 + type Props = { - message: SystemAPIErrorMessage; - verbose: boolean; -}; -export function SystemAPIErrorMessage(t0) { - const $ = _c(33); - const { - message: t1, - verbose - } = t0; - const { - retryAttempt, - error, - retryInMs, - maxRetries - } = t1; - const hidden = true && retryAttempt < 4; - const [countdownMs, setCountdownMs] = useState(0); - const done = countdownMs >= retryInMs; - let t2; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t2 = () => setCountdownMs(_temp); - $[0] = t2; - } else { - t2 = $[0]; - } - useInterval(t2, hidden || done ? null : 1000); + message: SystemAPIErrorMessage + verbose: boolean +} + +export function SystemAPIErrorMessage({ + message: { retryAttempt, error, retryInMs, maxRetries }, + verbose, +}: 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 [countdownMs, setCountdownMs] = useState(0) + const done = countdownMs >= retryInMs + useInterval( + () => setCountdownMs(ms => ms + 1000), + hidden || done ? null : 1000, + ) + if (hidden) { - return null; + return null } - let t3; - if ($[1] !== countdownMs || $[2] !== retryInMs) { - t3 = Math.round((retryInMs - countdownMs) / 1000); - $[1] = countdownMs; - $[2] = retryInMs; - $[3] = t3; - } else { - t3 = $[3]; - } - const retryInSecondsLive = Math.max(0, t3); - let T0; - let T1; - let T2; - let t4; - let t5; - let t6; - let truncated; - if ($[4] !== error || $[5] !== verbose) { - const formatted = formatAPIError(error); - truncated = !verbose && formatted.length > MAX_API_ERROR_CHARS; - T2 = MessageResponse; - T1 = Box; - t6 = "column"; - T0 = Text; - t4 = "error"; - t5 = truncated ? formatted.slice(0, MAX_API_ERROR_CHARS) + "\u2026" : formatted; - $[4] = error; - $[5] = verbose; - $[6] = T0; - $[7] = T1; - $[8] = T2; - $[9] = t4; - $[10] = t5; - $[11] = t6; - $[12] = truncated; - } else { - T0 = $[6]; - T1 = $[7]; - T2 = $[8]; - t4 = $[9]; - t5 = $[10]; - t6 = $[11]; - truncated = $[12]; - } - let t7; - if ($[13] !== T0 || $[14] !== t4 || $[15] !== t5) { - t7 = {t5}; - $[13] = T0; - $[14] = t4; - $[15] = t5; - $[16] = t7; - } else { - t7 = $[16]; - } - let t8; - if ($[17] !== truncated) { - t8 = truncated && ; - $[17] = truncated; - $[18] = t8; - } else { - t8 = $[18]; - } - const t9 = retryInSecondsLive === 1 ? "second" : "seconds"; - let t10; - if ($[19] !== maxRetries || $[20] !== retryAttempt || $[21] !== retryInSecondsLive || $[22] !== t9) { - t10 = Retrying in {retryInSecondsLive}{" "}{t9}… (attempt{" "}{retryAttempt}/{maxRetries}){process.env.API_TIMEOUT_MS ? ` · API_TIMEOUT_MS=${process.env.API_TIMEOUT_MS}ms, try increasing it` : ""}; - $[19] = maxRetries; - $[20] = retryAttempt; - $[21] = retryInSecondsLive; - $[22] = t9; - $[23] = t10; - } else { - t10 = $[23]; - } - let t11; - if ($[24] !== T1 || $[25] !== t10 || $[26] !== t6 || $[27] !== t7 || $[28] !== t8) { - t11 = {t7}{t8}{t10}; - $[24] = T1; - $[25] = t10; - $[26] = t6; - $[27] = t7; - $[28] = t8; - $[29] = t11; - } else { - t11 = $[29]; - } - let t12; - if ($[30] !== T2 || $[31] !== t11) { - t12 = {t11}; - $[30] = T2; - $[31] = t11; - $[32] = t12; - } else { - t12 = $[32]; - } - return t12; -} -function _temp(ms) { - return ms + 1000; + + const retryInSecondsLive = Math.max( + 0, + Math.round((retryInMs - countdownMs) / 1000), + ) + + const formatted = formatAPIError(error) + const truncated = !verbose && formatted.length > MAX_API_ERROR_CHARS + + return ( + + + + {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` + : ''} + + + + ) } diff --git a/src/components/messages/SystemTextMessage.tsx b/src/components/messages/SystemTextMessage.tsx index 169654fca..7d05f054a 100644 --- a/src/components/messages/SystemTextMessage.tsx +++ b/src/components/messages/SystemTextMessage.tsx @@ -1,826 +1,509 @@ -import { c as _c } from "react/compiler-runtime"; // 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 type { SystemMessage, SystemStopHookSummaryMessage, SystemBridgeStatusMessage, 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'; +import { TURN_COMPLETION_VERBS } from '../../constants/turnCompletionVerbs.js' +import { useTerminalSize } from '../../hooks/useTerminalSize.js' +import type { + SystemMessage, + SystemStopHookSummaryMessage, + SystemBridgeStatusMessage, + 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' + type Props = { - message: SystemMessage; - addMargin: boolean; - verbose: boolean; - isTranscriptMode?: boolean; -}; -export function SystemTextMessage(t0) { - const $ = _c(51); - const { - message, - addMargin, - verbose, - isTranscriptMode - } = t0; - const bg = useSelectedMessageBg(); - if (message.subtype === "turn_duration") { - let t1; - if ($[0] !== addMargin || $[1] !== message) { - t1 = ; - $[0] = addMargin; - $[1] = message; - $[2] = t1; - } else { - t1 = $[2]; - } - return t1; - } - if (message.subtype === "memory_saved") { - let t1; - if ($[3] !== addMargin || $[4] !== message) { - t1 = ; - $[3] = addMargin; - $[4] = message; - $[5] = t1; - } else { - t1 = $[5]; - } - return t1; - } - if (message.subtype === "away_summary") { - const t1 = addMargin ? 1 : 0; - let t2; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {REFERENCE_MARK}; - $[6] = t2; - } else { - t2 = $[6]; - } - let t3; - if ($[7] !== message.content) { - t3 = {message.content}; - $[7] = message.content; - $[8] = t3; - } else { - t3 = $[8]; - } - let t4; - if ($[9] !== bg || $[10] !== t1 || $[11] !== t3) { - t4 = {t2}{t3}; - $[9] = bg; - $[10] = t1; - $[11] = t3; - $[12] = t4; - } else { - t4 = $[12]; - } - return t4; - } - if (message.subtype === "agents_killed") { - const t1 = addMargin ? 1 : 0; - let t2; - let t3; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {BLACK_CIRCLE}; - t3 = All background agents stopped; - $[13] = t2; - $[14] = t3; - } else { - t2 = $[13]; - t3 = $[14]; - } - let t4; - if ($[15] !== bg || $[16] !== t1) { - t4 = {t2}{t3}; - $[15] = bg; - $[16] = t1; - $[17] = t4; - } else { - t4 = $[17]; - } - return t4; - } - if (message.subtype === "thinking") { - return null; - } - if (message.subtype === "bridge_status") { - let t1; - if ($[18] !== addMargin || $[19] !== message) { - t1 = ; - $[18] = addMargin; - $[19] = message; - $[20] = t1; - } else { - t1 = $[20]; - } - return t1; - } - if (message.subtype === "scheduled_task_fire") { - const t1 = addMargin ? 1 : 0; - let t2; - if ($[21] !== message.content) { - t2 = {TEARDROP_ASTERISK} {message.content}; - $[21] = message.content; - $[22] = t2; - } else { - t2 = $[22]; - } - let t3; - if ($[23] !== bg || $[24] !== t1 || $[25] !== t2) { - t3 = {t2}; - $[23] = bg; - $[24] = t1; - $[25] = t2; - $[26] = t3; - } else { - t3 = $[26]; - } - return t3; - } - if (message.subtype === "permission_retry") { - const t1 = addMargin ? 1 : 0; - let t2; - let t3; - if ($[27] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {TEARDROP_ASTERISK} ; - t3 = Allowed ; - $[27] = t2; - $[28] = t3; - } else { - t2 = $[27]; - t3 = $[28]; - } - let t4; - if ($[29] !== message.commands) { - t4 = message.commands.join(", "); - $[29] = message.commands; - $[30] = t4; - } else { - t4 = $[30]; - } - let t5; - if ($[31] !== t4) { - t5 = {t4}; - $[31] = t4; - $[32] = t5; - } else { - t5 = $[32]; - } - let t6; - if ($[33] !== bg || $[34] !== t1 || $[35] !== t5) { - t6 = {t2}{t3}{t5}; - $[33] = bg; - $[34] = t1; - $[35] = t5; - $[36] = t6; - } else { - t6 = $[36]; - } - return t6; - } - const isStopHookSummary = message.subtype === "stop_hook_summary"; - if (!isStopHookSummary && !verbose && message.level === "info") { - return null; - } - if (message.subtype === "api_error") { - let t1; - if ($[37] !== message || $[38] !== verbose) { - t1 = ; - $[37] = message; - $[38] = verbose; - $[39] = t1; - } else { - t1 = $[39]; - } - return t1; - } - if (message.subtype === "stop_hook_summary") { - let t1; - if ($[40] !== addMargin || $[41] !== isTranscriptMode || $[42] !== message || $[43] !== verbose) { - t1 = ; - $[40] = addMargin; - $[41] = isTranscriptMode; - $[42] = message; - $[43] = verbose; - $[44] = t1; - } else { - t1 = $[44]; - } - return t1; - } - const content = message.content; - if (typeof content !== "string") { - return null; - } - const t1 = message.level !== "info"; - const t2 = message.level === "warning" ? "warning" : undefined; - const t3 = message.level === "info"; - let t4; - if ($[45] !== addMargin || $[46] !== content || $[47] !== t1 || $[48] !== t2 || $[49] !== t3) { - t4 = ; - $[45] = addMargin; - $[46] = content; - $[47] = t1; - $[48] = t2; - $[49] = t3; - $[50] = t4; - } else { - t4 = $[50]; - } - return t4; + message: SystemMessage + addMargin: boolean + verbose: boolean + isTranscriptMode?: boolean } -function StopHookSummaryMessage(t0) { - const $ = _c(47); - const { - message, - addMargin, - verbose, - isTranscriptMode - } = t0; - 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 + } + + if (message.subtype === 'memory_saved') { + 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 null + } + + + if (message.subtype === 'bridge_status') { + return + } + + if (message.subtype === 'scheduled_task_fire') { + return ( + + + {TEARDROP_ASTERISK} {message.content} + + + ) + } + + if (message.subtype === 'permission_retry') { + return ( + + {TEARDROP_ASTERISK} + Allowed + {message.commands.join(', ')} + + ) + } + + // Stop hook summaries should always be visible + const isStopHookSummary = message.subtype === 'stop_hook_summary' + + if (!isStopHookSummary && !verbose && message.level === 'info') { + return null + } + + if (message.subtype === 'api_error') { + return + } + + if (message.subtype === 'stop_hook_summary') { + return ( + + ) + } + + 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 ( + + + + ) +} + +function StopHookSummaryMessage({ + message, + addMargin, + verbose, + isTranscriptMode, +}: { + message: SystemStopHookSummaryMessage + addMargin: boolean + verbose: boolean + isTranscriptMode?: boolean +}): React.ReactNode { + const bg = useSelectedMessageBg() const { hookCount, hookInfos, hookErrors, preventedContinuation, - stopReason - } = message; - const { - columns - } = useTerminalSize(); - let t1; - if ($[0] !== hookInfos || $[1] !== message.totalDurationMs) { - t1 = message.totalDurationMs ?? hookInfos.reduce(_temp, 0); - $[0] = hookInfos; - $[1] = message.totalDurationMs; - $[2] = t1; - } else { - t1 = $[2]; - } - const totalDurationMs = t1; + 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' + + // 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 (true || totalDurationMs < HOOK_TIMING_DISPLAY_THRESHOLD_MS) { - return null; + if (!isAnt || totalDurationMs < HOOK_TIMING_DISPLAY_THRESHOLD_MS) { + return null } } - let t2; - if ($[3] !== totalDurationMs) { - t2 = false && totalDurationMs > 0 ? ` (${formatSecondsShort(totalDurationMs)})` : ""; - $[3] = totalDurationMs; - $[4] = t2; - } else { - t2 = $[4]; - } - const totalStr = t2; + + const totalStr = + isAnt && totalDurationMs > 0 + ? ` (${formatSecondsShort(totalDurationMs)})` + : '' + // Non-stop hooks (e.g. PreToolUse) render as a child line without bullet if (message.hookLabel) { - const t3 = hookCount === 1 ? "hook" : "hooks"; - let t4; - if ($[5] !== hookCount || $[6] !== message.hookLabel || $[7] !== t3 || $[8] !== totalStr) { - t4 = {" \u23BF "}Ran {hookCount} {message.hookLabel}{" "}{t3}{totalStr}; - $[5] = hookCount; - $[6] = message.hookLabel; - $[7] = t3; - $[8] = totalStr; - $[9] = t4; - } else { - t4 = $[9]; - } - let t5; - if ($[10] !== hookInfos || $[11] !== isTranscriptMode) { - t5 = isTranscriptMode && hookInfos.map(_temp2); - $[10] = hookInfos; - $[11] = isTranscriptMode; - $[12] = t5; - } else { - t5 = $[12]; - } - let t6; - if ($[13] !== t4 || $[14] !== t5) { - t6 = {t4}{t5}; - $[13] = t4; - $[14] = t5; - $[15] = t6; - } else { - t6 = $[15]; - } - return t6; + return ( + + + {' ⎿ '}Ran {hookCount} {message.hookLabel}{' '} + {hookCount === 1 ? 'hook' : 'hooks'} + {totalStr} + + {isTranscriptMode && + hookInfos.map((info, idx) => { + const durationStr = + isAnt && info.durationMs !== undefined + ? ` (${formatSecondsShort(info.durationMs)})` + : '' + return ( + + {' ⎿ '} + {info.command === 'prompt' + ? `prompt: ${info.promptText || ''}` + : info.command} + {durationStr} + + ) + })} + + ) } - const t3 = addMargin ? 1 : 0; - let t4; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t4 = {BLACK_CIRCLE}; - $[16] = t4; - } else { - t4 = $[16]; - } - const t5 = columns - 10; - let t6; - if ($[17] !== hookCount) { - t6 = {hookCount}; - $[17] = hookCount; - $[18] = t6; - } else { - t6 = $[18]; - } - const t7 = message.hookLabel ?? "stop"; - const t8 = hookCount === 1 ? "hook" : "hooks"; - let t9; - if ($[19] !== hookInfos || $[20] !== verbose) { - t9 = !verbose && hookInfos.length > 0 && <>{" "}; - $[19] = hookInfos; - $[20] = verbose; - $[21] = t9; - } else { - t9 = $[21]; - } - let t10; - if ($[22] !== t6 || $[23] !== t7 || $[24] !== t8 || $[25] !== t9 || $[26] !== totalStr) { - t10 = Ran {t6} {t7}{" "}{t8}{totalStr}{t9}; - $[22] = t6; - $[23] = t7; - $[24] = t8; - $[25] = t9; - $[26] = totalStr; - $[27] = t10; - } else { - t10 = $[27]; - } - let t11; - if ($[28] !== hookInfos || $[29] !== verbose) { - t11 = verbose && hookInfos.length > 0 && hookInfos.map(_temp3); - $[28] = hookInfos; - $[29] = verbose; - $[30] = t11; - } else { - t11 = $[30]; - } - let t12; - if ($[31] !== preventedContinuation || $[32] !== stopReason) { - t12 = preventedContinuation && stopReason && ⎿  {stopReason}; - $[31] = preventedContinuation; - $[32] = stopReason; - $[33] = t12; - } else { - t12 = $[33]; - } - let t13; - if ($[34] !== hookErrors || $[35] !== message.hookLabel) { - t13 = hookErrors.length > 0 && hookErrors.map((err, idx_1) => ⎿  {message.hookLabel ?? "Stop"} hook error: {err}); - $[34] = hookErrors; - $[35] = message.hookLabel; - $[36] = t13; - } else { - t13 = $[36]; - } - let t14; - if ($[37] !== t10 || $[38] !== t11 || $[39] !== t12 || $[40] !== t13 || $[41] !== t5) { - t14 = {t10}{t11}{t12}{t13}; - $[37] = t10; - $[38] = t11; - $[39] = t12; - $[40] = t13; - $[41] = t5; - $[42] = t14; - } else { - t14 = $[42]; - } - let t15; - if ($[43] !== bg || $[44] !== t14 || $[45] !== t3) { - t15 = {t4}{t14}; - $[43] = bg; - $[44] = t14; - $[45] = t3; - $[46] = t15; - } else { - t15 = $[46]; - } - return t15; + + return ( + + + {BLACK_CIRCLE} + + + + Ran {hookCount} {message.hookLabel ?? 'stop'}{' '} + {hookCount === 1 ? 'hook' : 'hooks'} + {totalStr} + {!verbose && hookInfos.length > 0 && ( + <> + {' '} + + + )} + + {verbose && + hookInfos.length > 0 && + hookInfos.map((info, idx) => { + const durationStr = + isAnt && info.durationMs !== undefined + ? ` (${formatSecondsShort(info.durationMs)})` + : '' + return ( + + ⎿   + {info.command === 'prompt' + ? `prompt: ${info.promptText || ''}` + : info.command} + {durationStr} + + ) + })} + {preventedContinuation && stopReason && ( + + ⎿   + {stopReason} + + )} + {hookErrors.length > 0 && + hookErrors.map((err, idx) => ( + + ⎿   + {message.hookLabel ?? 'Stop'} hook error: {err} + + ))} + + + ) } -function _temp3(info_0, idx_0) { - const durationStr_0 = false && info_0.durationMs !== undefined ? ` (${formatSecondsShort(info_0.durationMs)})` : ""; - return ⎿  {info_0.command === "prompt" ? `prompt: ${info_0.promptText || ""}` : info_0.command}{durationStr_0}; + +function SystemTextMessageInner({ + content, + addMargin, + dot, + color, + dimColor, +}: { + content: string + addMargin: boolean + dot: boolean + color?: TextProps['color'] + dimColor?: boolean +}): React.ReactNode { + const { columns } = useTerminalSize() + const bg = useSelectedMessageBg() + + return ( + + {dot && ( + + + {BLACK_CIRCLE} + + + )} + + + {content.trim()} + + + + ) } -function _temp2(info, idx) { - const durationStr = false && info.durationMs !== undefined ? ` (${formatSecondsShort(info.durationMs)})` : ""; - return {" \u23BF "}{info.command === "prompt" ? `prompt: ${info.promptText || ""}` : info.command}{durationStr}; -} -function _temp(sum, h) { - return sum + (h.durationMs ?? 0); -} -function SystemTextMessageInner(t0) { - const $ = _c(18); - const { - content, - addMargin, - dot, - color, - dimColor - } = t0; - const { - columns - } = useTerminalSize(); - const bg = useSelectedMessageBg(); - const t1 = addMargin ? 1 : 0; - let t2; - if ($[0] !== color || $[1] !== dimColor || $[2] !== dot) { - t2 = dot && {BLACK_CIRCLE}; - $[0] = color; - $[1] = dimColor; - $[2] = dot; - $[3] = t2; - } else { - t2 = $[3]; - } - const t3 = columns - 10; - let t4; - if ($[4] !== content) { - t4 = content.trim(); - $[4] = content; - $[5] = t4; - } else { - t4 = $[5]; - } - let t5; - if ($[6] !== color || $[7] !== dimColor || $[8] !== t4) { - t5 = {t4}; - $[6] = color; - $[7] = dimColor; - $[8] = t4; - $[9] = t5; - } else { - t5 = $[9]; - } - let t6; - if ($[10] !== t3 || $[11] !== t5) { - t6 = {t5}; - $[10] = t3; - $[11] = t5; - $[12] = t6; - } else { - t6 = $[12]; - } - let t7; - if ($[13] !== bg || $[14] !== t1 || $[15] !== t2 || $[16] !== t6) { - t7 = {t2}{t6}; - $[13] = bg; - $[14] = t1; - $[15] = t2; - $[16] = t6; - $[17] = t7; - } else { - t7 = $[17]; - } - return t7; -} -function TurnDurationMessage(t0) { - const $ = _c(17); - const { - message, - addMargin - } = t0; - const bg = useSelectedMessageBg(); - const [verb] = useState(_temp4); - const store = useAppStateStore(); - let t1; - if ($[0] !== store) { - t1 = () => { - const tasks = store.getState().tasks; - const running = (Object.values(tasks ?? {}) as TaskState[]).filter(isBackgroundTask); - return running.length > 0 ? getPillLabel(running) : null; - }; - $[0] = store; - $[1] = t1; - } else { - t1 = $[1]; - } - const [backgroundTaskSummary] = useState(t1); - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = getGlobalConfig().showTurnDuration ?? true; - $[2] = t2; - } else { - t2 = $[2]; - } - const showTurnDuration = t2; - let t3; - if ($[3] !== message.durationMs) { - t3 = formatDuration(message.durationMs); - $[3] = message.durationMs; - $[4] = t3; - } else { - t3 = $[4]; - } - const duration = t3; - const hasBudget = message.budgetLimit !== undefined; - let t4; - bb0: { - if (!hasBudget) { - t4 = ""; - break bb0; - } - const tokens = message.budgetTokens; - const limit = message.budgetLimit; - let t5; - if ($[5] !== limit || $[6] !== tokens) { - t5 = tokens >= limit ? `${formatNumber(tokens)} used (${formatNumber(limit)} min ${figures.tick})` : `${formatNumber(tokens)} / ${formatNumber(limit)} (${Math.round(tokens / limit * 100)}%)`; - $[5] = limit; - $[6] = tokens; - $[7] = t5; - } else { - t5 = $[7]; - } - const usage = t5; - const nudges = message.budgetNudges > 0 ? ` \u00B7 ${message.budgetNudges} ${message.budgetNudges === 1 ? "nudge" : "nudges"}` : ""; - t4 = `${showTurnDuration ? " \xB7 " : ""}${usage}${nudges}`; - } - const budgetSuffix = t4; + +function TurnDurationMessage({ + message, + addMargin, +}: { + message: SystemTurnDurationMessage + addMargin: boolean +}): React.ReactNode { + 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 showTurnDuration = getGlobalConfig().showTurnDuration ?? true + + const duration = formatDuration(message.durationMs) + const hasBudget = message.budgetLimit !== undefined + const budgetSuffix = (() => { + 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)}%)` + const nudges = + message.budgetNudges! > 0 + ? ` \u00B7 ${message.budgetNudges} ${message.budgetNudges === 1 ? 'nudge' : 'nudges'}` + : '' + return `${showTurnDuration ? ' \u00B7 ' : ''}${usage}${nudges}` + })() + if (!showTurnDuration && !hasBudget) { - return null; + return null } - const t5 = addMargin ? 1 : 0; - let t6; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t6 = {TEARDROP_ASTERISK}; - $[8] = t6; - } else { - t6 = $[8]; - } - const t7 = showTurnDuration && `${verb} for ${duration}`; - const t8 = backgroundTaskSummary && ` \u00B7 ${backgroundTaskSummary} still running`; - let t9; - if ($[9] !== budgetSuffix || $[10] !== t7 || $[11] !== t8) { - t9 = {t7}{budgetSuffix}{t8}; - $[9] = budgetSuffix; - $[10] = t7; - $[11] = t8; - $[12] = t9; - } else { - t9 = $[12]; - } - let t10; - if ($[13] !== bg || $[14] !== t5 || $[15] !== t9) { - t10 = {t6}{t9}; - $[13] = bg; - $[14] = t5; - $[15] = t9; - $[16] = t10; - } else { - t10 = $[16]; - } - return t10; + + return ( + + + {TEARDROP_ASTERISK} + + + {showTurnDuration && `${verb} for ${duration}`} + {budgetSuffix} + {backgroundTaskSummary && + ` \u00B7 ${backgroundTaskSummary} still running`} + + + ) } -function _temp4() { - return sample(TURN_COMPLETION_VERBS) ?? "Worked"; + +function MemorySavedMessage({ + message, + addMargin, +}: { + 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 parts = [ + privateCount > 0 + ? `${privateCount} ${privateCount === 1 ? 'memory' : 'memories'}` + : null, + team?.segment, + ].filter(Boolean) + return ( + + + + {BLACK_CIRCLE} + + + {message.verb ?? 'Saved'} {parts.join(' \u00B7 ')} + + + {writtenPaths.map(p => ( + + ))} + + ) } -function MemorySavedMessage(t0) { - const $ = _c(16); - const { - message, - addMargin - } = t0; - const bg = useSelectedMessageBg(); - const { - writtenPaths - } = message; - let t1; - if ($[0] !== message) { - t1 = feature("TEAMMEM") ? teamMemSaved.teamMemSavedPart(message) : null; - $[0] = message; - $[1] = t1; - } else { - t1 = $[1]; - } - const team = t1; - const privateCount = writtenPaths.length - (team?.count ?? 0); - const t2 = privateCount > 0 ? `${privateCount} ${privateCount === 1 ? "memory" : "memories"}` : null; - const t3 = team?.segment; - let t4; - if ($[2] !== t2 || $[3] !== t3) { - t4 = [t2, t3].filter(Boolean); - $[2] = t2; - $[3] = t3; - $[4] = t4; - } else { - t4 = $[4]; - } - const parts = t4; - const t5 = addMargin ? 1 : 0; - let t6; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t6 = {BLACK_CIRCLE}; - $[5] = t6; - } else { - t6 = $[5]; - } - const t7 = message.verb ?? "Saved"; - const t8 = parts.join(" \xB7 "); - let t9; - if ($[6] !== t7 || $[7] !== t8) { - t9 = {t6}{t7} {t8}; - $[6] = t7; - $[7] = t8; - $[8] = t9; - } else { - t9 = $[8]; - } - let t10; - if ($[9] !== writtenPaths) { - t10 = writtenPaths.map(_temp5); - $[9] = writtenPaths; - $[10] = t10; - } else { - t10 = $[10]; - } - let t11; - if ($[11] !== bg || $[12] !== t10 || $[13] !== t5 || $[14] !== t9) { - t11 = {t9}{t10}; - $[11] = bg; - $[12] = t10; - $[13] = t5; - $[14] = t9; - $[15] = t11; - } else { - t11 = $[15]; - } - return t11; + +function MemoryFileRow({ path }: { path: string }): React.ReactNode { + const [hover, setHover] = useState(false) + return ( + + void openPath(path)} + onMouseEnter={() => setHover(true)} + onMouseLeave={() => setHover(false)} + > + + {basename(path)} + + + + ) } -function _temp5(p) { - return ; + +function ThinkingMessage({ + message, + addMargin, +}: { + message: SystemThinkingMessage + addMargin: boolean +}): React.ReactNode { + const bg = useSelectedMessageBg() + return ( + + + {TEARDROP_ASTERISK} + + {message.content} + + ) } -function MemoryFileRow(t0) { - const $ = _c(16); - const { - path - } = t0; - const [hover, setHover] = useState(false); - let t1; - if ($[0] !== path) { - t1 = () => void openPath(path); - $[0] = path; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - let t3; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = () => setHover(true); - t3 = () => setHover(false); - $[2] = t2; - $[3] = t3; - } else { - t2 = $[2]; - t3 = $[3]; - } - const t4 = !hover; - let t5; - if ($[4] !== path) { - t5 = basename(path); - $[4] = path; - $[5] = t5; - } else { - t5 = $[5]; - } - let t6; - if ($[6] !== path || $[7] !== t5) { - t6 = {t5}; - $[6] = path; - $[7] = t5; - $[8] = t6; - } else { - t6 = $[8]; - } - let t7; - if ($[9] !== hover || $[10] !== t4 || $[11] !== t6) { - t7 = {t6}; - $[9] = hover; - $[10] = t4; - $[11] = t6; - $[12] = t7; - } else { - t7 = $[12]; - } - let t8; - if ($[13] !== t1 || $[14] !== t7) { - t8 = {t7}; - $[13] = t1; - $[14] = t7; - $[15] = t8; - } else { - t8 = $[15]; - } - return t8; -} -function ThinkingMessage(t0) { - const $ = _c(7); - const { - message, - addMargin - } = t0; - const bg = useSelectedMessageBg(); - const t1 = addMargin ? 1 : 0; - let t2; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {TEARDROP_ASTERISK}; - $[0] = t2; - } else { - t2 = $[0]; - } - let t3; - if ($[1] !== message.content) { - t3 = {message.content}; - $[1] = message.content; - $[2] = t3; - } else { - t3 = $[2]; - } - let t4; - if ($[3] !== bg || $[4] !== t1 || $[5] !== t3) { - t4 = {t2}{t3}; - $[3] = bg; - $[4] = t1; - $[5] = t3; - $[6] = t4; - } else { - t4 = $[6]; - } - return t4; -} -function BridgeStatusMessage(t0) { - const $ = _c(13); - const { - message, - addMargin - } = t0; - const bg = useSelectedMessageBg(); - const t1 = addMargin ? 1 : 0; - let t2; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t2 = ; - $[0] = t2; - } else { - t2 = $[0]; - } - let t3; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t3 = /remote-control is active. Code in CLI or at; - $[1] = t3; - } else { - t3 = $[1]; - } - let t4; - if ($[2] !== message.url) { - t4 = {message.url}; - $[2] = message.url; - $[3] = t4; - } else { - t4 = $[3]; - } - let t5; - if ($[4] !== message.upgradeNudge) { - t5 = message.upgradeNudge && ⎿ {message.upgradeNudge}; - $[4] = message.upgradeNudge; - $[5] = t5; - } else { - t5 = $[5]; - } - let t6; - if ($[6] !== t4 || $[7] !== t5) { - t6 = {t3}{t4}{t5}; - $[6] = t4; - $[7] = t5; - $[8] = t6; - } else { - t6 = $[8]; - } - let t7; - if ($[9] !== bg || $[10] !== t1 || $[11] !== t6) { - t7 = {t2}{t6}; - $[9] = bg; - $[10] = t1; - $[11] = t6; - $[12] = t7; - } else { - t7 = $[12]; - } - return t7; + +function BridgeStatusMessage({ + message, + addMargin, +}: { + message: SystemBridgeStatusMessage + addMargin: boolean +}): React.ReactNode { + const bg = useSelectedMessageBg() + return ( + + + + + /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 8ae4dfe17..1f7797873 100644 --- a/src/components/messages/TaskAssignmentMessage.tsx +++ b/src/components/messages/TaskAssignmentMessage.tsx @@ -1,75 +1,65 @@ -import { c as _c } from "react/compiler-runtime"; -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). */ -export function TaskAssignmentDisplay(t0) { - const $ = _c(11); - const { - assignment - } = t0; - let t1; - if ($[0] !== assignment.assignedBy || $[1] !== assignment.taskId) { - t1 = Task #{assignment.taskId} assigned by {assignment.assignedBy}; - $[0] = assignment.assignedBy; - $[1] = assignment.taskId; - $[2] = t1; - } else { - t1 = $[2]; - } - let t2; - if ($[3] !== assignment.subject) { - t2 = {assignment.subject}; - $[3] = assignment.subject; - $[4] = t2; - } else { - t2 = $[4]; - } - let t3; - if ($[5] !== assignment.description) { - t3 = assignment.description && {assignment.description}; - $[5] = assignment.description; - $[6] = t3; - } else { - t3 = $[6]; - } - let t4; - if ($[7] !== t1 || $[8] !== t2 || $[9] !== t3) { - t4 = {t1}{t2}{t3}; - $[7] = t1; - $[8] = t2; - $[9] = t3; - $[10] = t4; - } else { - t4 = $[10]; - } - return t4; +export function TaskAssignmentDisplay({ assignment }: Props): React.ReactNode { + return ( + + + + + Task #{assignment.taskId} assigned by {assignment.assignedBy} + + + + {assignment.subject} + + {assignment.description && ( + + {assignment.description} + + )} + + + ) } /** * 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 22425e704..7e19c34d7 100644 --- a/src/components/messages/UserAgentNotificationMessage.tsx +++ b/src/components/messages/UserAgentNotificationMessage.tsx @@ -1,82 +1,42 @@ -import { c as _c } from "react/compiler-runtime"; -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(t0) { - const $ = _c(12); - const { - addMargin, - param: t1 - } = t0; - const { - text - } = t1; - let t2; - if ($[0] !== text) { - t2 = extractTag(text, "summary"); - $[0] = text; - $[1] = t2; - } else { - t2 = $[1]; - } - const summary = t2; - if (!summary) { - return null; - } - let t3; - if ($[2] !== text) { - const status = extractTag(text, "status"); - t3 = getStatusColor(status); - $[2] = text; - $[3] = t3; - } else { - t3 = $[3]; - } - const color = t3; - const t4 = addMargin ? 1 : 0; - let t5; - if ($[4] !== color) { - t5 = {BLACK_CIRCLE}; - $[4] = color; - $[5] = t5; - } else { - t5 = $[5]; - } - let t6; - if ($[6] !== summary || $[7] !== t5) { - t6 = {t5} {summary}; - $[6] = summary; - $[7] = t5; - $[8] = t6; - } else { - t6 = $[8]; - } - let t7; - if ($[9] !== t4 || $[10] !== t6) { - t7 = {t6}; - $[9] = t4; - $[10] = t6; - $[11] = t7; - } else { - t7 = $[11]; - } - return t7; + +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) + + return ( + + + {BLACK_CIRCLE} {summary} + + + ) } diff --git a/src/components/messages/UserBashInputMessage.tsx b/src/components/messages/UserBashInputMessage.tsx index 1eb384dc4..c78fafea1 100644 --- a/src/components/messages/UserBashInputMessage.tsx +++ b/src/components/messages/UserBashInputMessage.tsx @@ -1,57 +1,30 @@ -import { c as _c } from "react/compiler-runtime"; -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; -}; -export function UserBashInputMessage(t0) { - const $ = _c(8); - const { - param: t1, - addMargin - } = t0; - const { - text - } = t1; - let t2; - if ($[0] !== text) { - t2 = extractTag(text, "bash-input"); - $[0] = text; - $[1] = t2; - } else { - t2 = $[1]; - } - const input = t2; - if (!input) { - return null; - } - const t3 = addMargin ? 1 : 0; - let t4; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t4 = ! ; - $[2] = t4; - } else { - t4 = $[2]; - } - let t5; - if ($[3] !== input) { - t5 = {input}; - $[3] = input; - $[4] = t5; - } else { - t5 = $[4]; - } - let t6; - if ($[5] !== t3 || $[6] !== t5) { - t6 = {t4}{t5}; - $[5] = t3; - $[6] = t5; - $[7] = t6; - } else { - t6 = $[7]; - } - return t6; + addMargin: boolean + param: TextBlockParam +} + +export function UserBashInputMessage({ + param: { text }, + addMargin, +}: Props): React.ReactNode { + const input = extractTag(text, 'bash-input') + if (!input) { + return null + } + return ( + + ! + {input} + + ) } diff --git a/src/components/messages/UserBashOutputMessage.tsx b/src/components/messages/UserBashOutputMessage.tsx index 30c4088d8..99ec8ea7e 100644 --- a/src/components/messages/UserBashOutputMessage.tsx +++ b/src/components/messages/UserBashOutputMessage.tsx @@ -1,53 +1,20 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import BashToolResultMessage from '../../tools/BashTool/BashToolResultMessage.js'; -import { extractTag } from '../../utils/messages.js'; -export function UserBashOutputMessage(t0) { - const $ = _c(10); - const { - content, - verbose - } = t0; - let t1; - if ($[0] !== content) { - const rawStdout = extractTag(content, "bash-stdout") ?? ""; - t1 = extractTag(rawStdout, "persisted-output") ?? rawStdout; - $[0] = content; - $[1] = t1; - } else { - t1 = $[1]; - } - const stdout = t1; - let t2; - if ($[2] !== content) { - t2 = extractTag(content, "bash-stderr") ?? ""; - $[2] = content; - $[3] = t2; - } else { - t2 = $[3]; - } - const stderr = t2; - let t3; - if ($[4] !== stderr || $[5] !== stdout) { - t3 = { - stdout, - stderr - }; - $[4] = stderr; - $[5] = stdout; - $[6] = t3; - } else { - t3 = $[6]; - } - const t4 = !!verbose; - let t5; - if ($[7] !== t3 || $[8] !== t4) { - t5 = ; - $[7] = t3; - $[8] = t4; - $[9] = t5; - } else { - t5 = $[9]; - } - return t5; +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') ?? '' + // 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 ( + + ) } diff --git a/src/components/messages/UserChannelMessage.tsx b/src/components/messages/UserChannelMessage.tsx index af6e6c3ce..8e7101b1a 100644 --- a/src/components/messages/UserChannelMessage.tsx +++ b/src/components/messages/UserChannelMessage.tsx @@ -1,136 +1,52 @@ -import { c as _c } from "react/compiler-runtime"; -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; -export function UserChannelMessage(t0) { - const $ = _c(29); - const { - addMargin, - param: t1 - } = t0; - const { - text - } = t1; - let T0; - let T1; - let T2; - let t2; - let t3; - let t4; - let t5; - let t6; - let t7; - let truncated; - let user; - if ($[0] !== addMargin || $[1] !== text) { - t7 = Symbol.for("react.early_return_sentinel"); - bb0: { - const m = CHANNEL_RE.exec(text); - if (!m) { - t7 = null; - break bb0; - } - const [, source, attrs, content] = m; - user = USER_ATTR_RE.exec(attrs ?? "")?.[1]; - const body = (content ?? "").trim().replace(/\s+/g, " "); - truncated = truncateToWidth(body, TRUNCATE_AT); - T2 = Box; - t6 = addMargin ? 1 : 0; - T1 = Text; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t4 = {CHANNEL_ARROW}; - $[13] = t4; - } else { - t4 = $[13]; - } - t5 = " "; - T0 = Text; - t2 = true; - t3 = displayServerName(source ?? ""); - } - $[0] = addMargin; - $[1] = text; - $[2] = T0; - $[3] = T1; - $[4] = T2; - $[5] = t2; - $[6] = t3; - $[7] = t4; - $[8] = t5; - $[9] = t6; - $[10] = t7; - $[11] = truncated; - $[12] = user; - } else { - T0 = $[2]; - T1 = $[3]; - T2 = $[4]; - t2 = $[5]; - t3 = $[6]; - t4 = $[7]; - t5 = $[8]; - t6 = $[9]; - t7 = $[10]; - truncated = $[11]; - user = $[12]; - } - if (t7 !== Symbol.for("react.early_return_sentinel")) { - return t7; - } - const t8 = user ? ` \u00b7 ${user}` : ""; - let t9; - if ($[14] !== T0 || $[15] !== t2 || $[16] !== t3 || $[17] !== t8) { - t9 = {t3}{t8}:; - $[14] = T0; - $[15] = t2; - $[16] = t3; - $[17] = t8; - $[18] = t9; - } else { - t9 = $[18]; - } - let t10; - if ($[19] !== T1 || $[20] !== t4 || $[21] !== t5 || $[22] !== t9 || $[23] !== truncated) { - t10 = {t4}{t5}{t9}{" "}{truncated}; - $[19] = T1; - $[20] = t4; - $[21] = t5; - $[22] = t9; - $[23] = truncated; - $[24] = t10; - } else { - t10 = $[24]; - } - let t11; - if ($[25] !== T2 || $[26] !== t10 || $[27] !== t6) { - t11 = {t10}; - $[25] = T2; - $[26] = t10; - $[27] = t6; - $[28] = t11; - } else { - t11 = $[28]; - } - return t11; + +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) + return ( + + + {CHANNEL_ARROW}{' '} + + {displayServerName(source ?? '')} + {user ? ` \u00b7 ${user}` : ''}: + {' '} + {truncated} + + + ) } diff --git a/src/components/messages/UserCommandMessage.tsx b/src/components/messages/UserCommandMessage.tsx index a77c95c4b..31f6b2871 100644 --- a/src/components/messages/UserCommandMessage.tsx +++ b/src/components/messages/UserCommandMessage.tsx @@ -1,107 +1,57 @@ -import { c as _c } from "react/compiler-runtime"; -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; -}; -export function UserCommandMessage(t0) { - const $ = _c(19); - const { - addMargin, - param: t1 - } = t0; - const { - text - } = t1; - let t2; - if ($[0] !== text) { - t2 = extractTag(text, COMMAND_MESSAGE_TAG); - $[0] = text; - $[1] = t2; - } else { - t2 = $[1]; - } - const commandMessage = t2; - let t3; - if ($[2] !== text) { - t3 = extractTag(text, "command-args"); - $[2] = text; - $[3] = t3; - } else { - t3 = $[3]; - } - const args = t3; - const isSkillFormat = extractTag(text, "skill-format") === "true"; - if (!commandMessage) { - return null; - } - if (isSkillFormat) { - const t4 = addMargin ? 1 : 0; - let t5; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t5 = {figures.pointer} ; - $[4] = t5; - } else { - t5 = $[4]; - } - let t6; - if ($[5] !== commandMessage) { - t6 = {t5}Skill({commandMessage}); - $[5] = commandMessage; - $[6] = t6; - } else { - t6 = $[6]; - } - let t7; - if ($[7] !== t4 || $[8] !== t6) { - t7 = {t6}; - $[7] = t4; - $[8] = t6; - $[9] = t7; - } else { - t7 = $[9]; - } - return t7; - } - let t4; - if ($[10] !== args || $[11] !== commandMessage) { - t4 = [commandMessage, args].filter(Boolean); - $[10] = args; - $[11] = commandMessage; - $[12] = t4; - } else { - t4 = $[12]; - } - const content = `/${t4.join(" ")}`; - const t5 = addMargin ? 1 : 0; - let t6; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t6 = {figures.pointer} ; - $[13] = t6; - } else { - t6 = $[13]; - } - let t7; - if ($[14] !== content) { - t7 = {t6}{content}; - $[14] = content; - $[15] = t7; - } else { - t7 = $[15]; - } - let t8; - if ($[16] !== t5 || $[17] !== t7) { - t8 = {t7}; - $[16] = t5; - $[17] = t7; - $[18] = t8; - } else { - t8 = $[18]; - } - return t8; + 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' + + if (!commandMessage) { + return null + } + + // Skills use "Skill(name)" format + if (isSkillFormat) { + return ( + + + {figures.pointer} + Skill({commandMessage}) + + + ) + } + + // Slash command format: show as "❯ /command args" + const content = `/${[commandMessage, args].filter(Boolean).join(' ')}` + return ( + + + {figures.pointer} + {content} + + + ) } diff --git a/src/components/messages/UserImageMessage.tsx b/src/components/messages/UserImageMessage.tsx index cd5150a34..3f542dfb6 100644 --- a/src/components/messages/UserImageMessage.tsx +++ b/src/components/messages/UserImageMessage.tsx @@ -1,15 +1,15 @@ -import { c as _c } from "react/compiler-runtime"; -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,42 +17,27 @@ 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(t0) { - const $ = _c(7); - const { - imageId, - addMargin - } = t0; - const label = imageId ? `[Image #${imageId}]` : "[Image]"; - let t1; - if ($[0] !== imageId || $[1] !== label) { - const imagePath = imageId ? getStoredImagePath(imageId) : null; - t1 = imagePath && supportsHyperlinks() ? {label} : {label}; - $[0] = imageId; - $[1] = label; - $[2] = t1; - } else { - t1 = $[2]; - } - const content = t1; +export function UserImageMessage({ + imageId, + addMargin, +}: Props): React.ReactNode { + const label = imageId ? `[Image #${imageId}]` : '[Image]' + const imagePath = imageId ? getStoredImagePath(imageId) : null + + const content = + imagePath && supportsHyperlinks() ? ( + + {label} + + ) : ( + {label} + ) + + // When this image starts a new user turn (no text before it), + // show with margin instead of the connected line style if (addMargin) { - let t2; - if ($[3] !== content) { - t2 = {content}; - $[3] = content; - $[4] = t2; - } else { - t2 = $[4]; - } - return t2; + return {content} } - let t2; - if ($[5] !== content) { - t2 = {content}; - $[5] = content; - $[6] = t2; - } else { - t2 = $[6]; - } - return t2; + + return {content} } diff --git a/src/components/messages/UserLocalCommandOutputMessage.tsx b/src/components/messages/UserLocalCommandOutputMessage.tsx index 3de6acce3..b1c95616a 100644 --- a/src/components/messages/UserLocalCommandOutputMessage.tsx +++ b/src/components/messages/UserLocalCommandOutputMessage.tsx @@ -1,166 +1,80 @@ -import { c as _c } from "react/compiler-runtime"; -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; -}; -export function UserLocalCommandOutputMessage(t0) { - const $ = _c(4); - const { - content - } = t0; - let lines; - let t1; - if ($[0] !== content) { - t1 = Symbol.for("react.early_return_sentinel"); - bb0: { - const stdout = extractTag(content, "local-command-stdout"); - const stderr = extractTag(content, "local-command-stderr"); - if (!stdout && !stderr) { - let t2; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {NO_CONTENT_MESSAGE}; - $[3] = t2; - } else { - t2 = $[3]; - } - t1 = t2; - break bb0; - } - lines = []; - if (stdout?.trim()) { - lines.push({stdout.trim()}); - } - if (stderr?.trim()) { - lines.push({stderr.trim()}); - } - } - $[0] = content; - $[1] = lines; - $[2] = t1; - } else { - lines = $[1]; - t1 = $[2]; - } - if (t1 !== Symbol.for("react.early_return_sentinel")) { - return t1; - } - return lines; + content: string } -function IndentedContent(t0) { - const $ = _c(5); - const { - children - } = t0; - if (children.startsWith(`${DIAMOND_OPEN} `) || children.startsWith(`${DIAMOND_FILLED} `)) { - let t1; - if ($[0] !== children) { - t1 = {children}; - $[0] = children; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; + +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} + + ) } - let t1; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t1 = {" \u23BF "}; - $[2] = t1; - } else { - t1 = $[2]; + + const lines: React.ReactNode[] = [] + if (stdout?.trim()) { + lines.push({stdout.trim()}) } - let t2; - if ($[3] !== children) { - t2 = {t1}{children}; - $[3] = children; - $[4] = t2; - } else { - t2 = $[4]; + if (stderr?.trim()) { + lines.push({stderr.trim()}) } - return t2; + return lines } -function CloudLaunchContent(t0) { - const $ = _c(19); - const { - children - } = t0; - const diamond = children[0]; - let label; - let rest; - let t1; - if ($[0] !== children) { - const nl = children.indexOf("\n"); - const header = nl === -1 ? children.slice(2) : children.slice(2, nl); - rest = nl === -1 ? "" : children.slice(nl + 1).trim(); - const sep = header.indexOf(" \xB7 "); - label = sep === -1 ? header : header.slice(0, sep); - t1 = sep === -1 ? "" : header.slice(sep); - $[0] = children; - $[1] = label; - $[2] = rest; - $[3] = t1; - } else { - label = $[1]; - rest = $[2]; - t1 = $[3]; + +function IndentedContent({ children }: { children: string }): React.ReactNode { + if ( + children.startsWith(`${DIAMOND_OPEN} `) || + children.startsWith(`${DIAMOND_FILLED} `) + ) { + return {children} } - const suffix = t1; - let t2; - if ($[4] !== diamond) { - t2 = {diamond} ; - $[4] = diamond; - $[5] = t2; - } else { - t2 = $[5]; - } - let t3; - if ($[6] !== label) { - t3 = {label}; - $[6] = label; - $[7] = t3; - } else { - t3 = $[7]; - } - let t4; - if ($[8] !== suffix) { - t4 = suffix && {suffix}; - $[8] = suffix; - $[9] = t4; - } else { - t4 = $[9]; - } - let t5; - if ($[10] !== t2 || $[11] !== t3 || $[12] !== t4) { - t5 = {t2}{t3}{t4}; - $[10] = t2; - $[11] = t3; - $[12] = t4; - $[13] = t5; - } else { - t5 = $[13]; - } - let t6; - if ($[14] !== rest) { - t6 = rest && {" \u23BF "}{rest}; - $[14] = rest; - $[15] = t6; - } else { - t6 = $[15]; - } - let t7; - if ($[16] !== t5 || $[17] !== t6) { - t7 = {t5}{t6}; - $[16] = t5; - $[17] = t6; - $[18] = t7; - } else { - t7 = $[18]; - } - return t7; + return ( + + {' ⎿ '} + + {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) + return ( + + + {diamond} + {label} + {suffix && {suffix}} + + {rest && ( + + {' ⎿ '} + {rest} + + )} + + ) } diff --git a/src/components/messages/UserMemoryInputMessage.tsx b/src/components/messages/UserMemoryInputMessage.tsx index 513281bd9..25a8d7a1c 100644 --- a/src/components/messages/UserMemoryInputMessage.tsx +++ b/src/components/messages/UserMemoryInputMessage.tsx @@ -1,74 +1,44 @@ -import { c as _c } from "react/compiler-runtime"; -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; -}; -export function UserMemoryInputMessage(t0) { - const $ = _c(10); - const { - text, - addMargin - } = t0; - let t1; - if ($[0] !== text) { - t1 = extractTag(text, "user-memory-input"); - $[0] = text; - $[1] = t1; - } else { - t1 = $[1]; - } - const input = t1; - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = getSavingMessage(); - $[2] = t2; - } else { - t2 = $[2]; - } - const savingText = t2; - if (!input) { - return null; - } - const t3 = addMargin ? 1 : 0; - let t4; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t4 = #; - $[3] = t4; - } else { - t4 = $[3]; - } - let t5; - if ($[4] !== input) { - t5 = {t4}{" "}{input}{" "}; - $[4] = input; - $[5] = t5; - } else { - t5 = $[5]; - } - let t6; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t6 = {savingText}; - $[6] = t6; - } else { - t6 = $[6]; - } - let t7; - if ($[7] !== t3 || $[8] !== t5) { - t7 = {t5}{t6}; - $[7] = t3; - $[8] = t5; - $[9] = t7; - } else { - t7 = $[9]; - } - return t7; + addMargin: boolean + text: string +} + +export function UserMemoryInputMessage({ + text, + addMargin, +}: Props): React.ReactNode { + const input = extractTag(text, 'user-memory-input') + const savingText = useMemo(() => getSavingMessage(), []) + + if (!input) { + return null + } + + return ( + + + + # + + + {' '} + {input}{' '} + + + + {savingText} + + + ) } diff --git a/src/components/messages/UserPlanMessage.tsx b/src/components/messages/UserPlanMessage.tsx index 1b738e087..5ef8fa89a 100644 --- a/src/components/messages/UserPlanMessage.tsx +++ b/src/components/messages/UserPlanMessage.tsx @@ -1,41 +1,30 @@ -import { c as _c } from "react/compiler-runtime"; -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; -}; -export function UserPlanMessage(t0) { - const $ = _c(6); - const { - addMargin, - planContent - } = t0; - const t1 = addMargin ? 1 : 0; - let t2; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Plan to implement; - $[0] = t2; - } else { - t2 = $[0]; - } - let t3; - if ($[1] !== planContent) { - t3 = {planContent}; - $[1] = planContent; - $[2] = t3; - } else { - t3 = $[2]; - } - let t4; - if ($[3] !== t1 || $[4] !== t3) { - t4 = {t2}{t3}; - $[3] = t1; - $[4] = t3; - $[5] = t4; - } else { - t4 = $[5]; - } - return t4; + addMargin: boolean + planContent: string +} + +export function UserPlanMessage({ + addMargin, + planContent, +}: Props): React.ReactNode { + return ( + + + + Plan to implement + + + {planContent} + + ) } diff --git a/src/components/messages/UserPromptMessage.tsx b/src/components/messages/UserPromptMessage.tsx index 617181437..090cac272 100644 --- a/src/components/messages/UserPromptMessage.tsx +++ b/src/components/messages/UserPromptMessage.tsx @@ -1,21 +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 @@ -25,16 +26,15 @@ 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 - }, + param: { text }, isTranscriptMode, - timestamp + timestamp, }: Props): React.ReactNode { // REPL.tsx passes isBriefOnly={viewedTeammateTask ? false : isBriefOnly} // but that prop isn't threaded this deep — replicate the override by @@ -48,32 +48,72 @@ export function UserPromptMessage({ // bypasses React.memo). Runtime-gated like isBriefEnabled() but inlined // to avoid pulling BriefTool.ts → prompt.ts tool-name strings into // external builds. - const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s => s.isBriefOnly) : false; - const viewingAgentTaskId = feature('KAIROS') || feature('KAIROS_BRIEF') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s_0 => s_0.viewingAgentTaskId) : null; + const isBriefOnly = + feature('KAIROS') || feature('KAIROS_BRIEF') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAppState(s => s.isBriefOnly) + : false + const viewingAgentTaskId = + feature('KAIROS') || feature('KAIROS_BRIEF') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAppState(s => s.viewingAgentTaskId) + : 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; - const useBriefLayout = feature('KAIROS') || feature('KAIROS_BRIEF') ? (getKairosActive() || getUserMsgOptIn() && (briefEnvEnabled || getFeatureValue_CACHED_MAY_BE_STALE('tengu_kairos_brief', false))) && isBriefOnly && !isTranscriptMode && !viewingAgentTaskId : false; + 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 + const useBriefLayout = + feature('KAIROS') || feature('KAIROS_BRIEF') + ? (getKairosActive() || + (getUserMsgOptIn() && + (briefEnvEnabled || + getFeatureValue_CACHED_MAY_BE_STALE( + 'tengu_kairos_brief', + false, + )))) && + isBriefOnly && + !isTranscriptMode && + !viewingAgentTaskId + : 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]); - const isSelected = useContext(MessageActionsSelectedContext); + 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) + 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 - - ; + + return ( + + + + ) } diff --git a/src/components/messages/UserResourceUpdateMessage.tsx b/src/components/messages/UserResourceUpdateMessage.tsx index e7e4df2d3..ce1f4f5d5 100644 --- a/src/components/messages/UserResourceUpdateMessage.tsx +++ b/src/components/messages/UserResourceUpdateMessage.tsx @@ -1,120 +1,91 @@ -import { c as _c } from "react/compiler-runtime"; -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; + const resourceRegex = + /]*>(?:[\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] - }); + reason: match[3], + }) } // Match - const pollingRegex = /]*>(?:[\s\S]*?([^<]+)<\/reason>)?/g; + const pollingRegex = + /]*>(?:[\s\S]*?([^<]+)<\/reason>)?/g while ((match = pollingRegex.exec(text)) !== null) { updates.push({ kind: 'polling', server: match[2] ?? '', target: match[3] ?? '', - reason: match[4] - }); + 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(t0) { - const $ = _c(12); - const { - addMargin, - param: t1 - } = t0; - const { - text - } = t1; - let T0; - let t2; - let t3; - let t4; - let t5; - if ($[0] !== addMargin || $[1] !== text) { - t5 = Symbol.for("react.early_return_sentinel"); - bb0: { - const updates = parseUpdates(text); - if (updates.length === 0) { - t5 = null; - break bb0; - } - T0 = Box; - t2 = "column"; - t3 = addMargin ? 1 : 0; - t4 = updates.map(_temp); - } - $[0] = addMargin; - $[1] = text; - $[2] = T0; - $[3] = t2; - $[4] = t3; - $[5] = t4; - $[6] = t5; - } else { - T0 = $[2]; - t2 = $[3]; - t3 = $[4]; - t4 = $[5]; - t5 = $[6]; - } - if (t5 !== Symbol.for("react.early_return_sentinel")) { - return t5; - } - let t6; - if ($[7] !== T0 || $[8] !== t2 || $[9] !== t3 || $[10] !== t4) { - t6 = {t4}; - $[7] = T0; - $[8] = t2; - $[9] = t3; - $[10] = t4; - $[11] = t6; - } else { - t6 = $[11]; - } - return t6; -} -function _temp(update, i) { - return {REFRESH_ARROW}{" "}{update.server}:{" "}{update.kind === "resource" ? formatUri(update.target) : update.target}{update.reason && · {update.reason}}; + +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} + + {update.reason && · {update.reason}} + + + ))} + + ) } diff --git a/src/components/messages/UserTeammateMessage.tsx b/src/components/messages/UserTeammateMessage.tsx index 8ee66f8d3..4c174ff1c 100644 --- a/src/components/messages/UserTeammateMessage.tsx +++ b/src/components/messages/UserTeammateMessage.tsx @@ -1,28 +1,33 @@ -import { c as _c } from "react/compiler-runtime"; -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; -}; -const TEAMMATE_MSG_REGEX = new RegExp(`<${TEAMMATE_MESSAGE_TAG}\\s+teammate_id="([^"]+)"(?:\\s+color="([^"]+)")?(?:\\s+summary="([^"]+)")?>\\n?([\\s\\S]*?)\\n?<\\/${TEAMMATE_MESSAGE_TAG}>`, 'g'); + 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: @@ -30,176 +35,169 @@ const TEAMMATE_MSG_REGEX = new RegExp(`<${TEAMMATE_MESSAGE_TAG}\\s+teammate_id=" * 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]) { messages.push({ teammateId: match[1], - color: match[2], - // may be undefined - summary: match[3], - // may be undefined - content: match[4].trim() - }); + 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 + 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_0, index) => { - const inkColor = toInkColor(msg_0.color); - const displayName = getDisplayName(msg_0.teammateId); - // Try to render as plan approval message (request or response) - const planApprovalElement = tryRenderPlanApprovalMessage(msg_0.content, displayName); - if (planApprovalElement) { - return {planApprovalElement}; - } + return ( + + {messages.map((msg, index) => { + const inkColor = toInkColor(msg.color) + const displayName = getDisplayName(msg.teammateId) - // Try to render as shutdown message (request or rejected) - const shutdownElement = tryRenderShutdownMessage(msg_0.content); - if (shutdownElement) { - return {shutdownElement}; - } + // Try to render as plan approval message (request or response) + const planApprovalElement = tryRenderPlanApprovalMessage( + msg.content, + displayName, + ) + if (planApprovalElement) { + return ( + {planApprovalElement} + ) + } - // Try to render as task assignment message - const taskAssignmentElement = tryRenderTaskAssignmentMessage(msg_0.content); - if (taskAssignmentElement) { - return {taskAssignmentElement}; - } + // Try to render as shutdown message (request or rejected) + const shutdownElement = tryRenderShutdownMessage(msg.content) + if (shutdownElement) { + return {shutdownElement} + } - // Try to parse as structured JSON message - let parsedIdleNotification: { - type?: string; - } | null = null; - try { - parsedIdleNotification = jsonParse(msg_0.content); - } catch { - // Not JSON - } + // Try to render as task assignment message + const taskAssignmentElement = tryRenderTaskAssignmentMessage( + msg.content, + ) + if (taskAssignmentElement) { + return ( + {taskAssignmentElement} + ) + } - // Hide idle notifications - they are processed silently - if (parsedIdleNotification?.type === 'idle_notification') { - return null; - } + // Try to parse as structured JSON message + let parsedIdleNotification: { type?: string } | null = null + try { + parsedIdleNotification = jsonParse(msg.content) + } catch { + // Not JSON + } - // 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; - }; - return - {`@${displayName}${figures.pointer}`} + // Hide idle notifications - they are processed silently + if (parsedIdleNotification?.type === 'idle_notification') { + 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 + } + return ( + + {`@${displayName}${figures.pointer}`} {' '} Completed task #{taskCompleted.taskId} - {taskCompleted.taskSubject && ({taskCompleted.taskSubject})} + {taskCompleted.taskSubject && ( + ({taskCompleted.taskSubject}) + )} - ; - } + + ) + } - // Default: plain text message (truncated) - return ; - })} - ; + // Default: plain text message (truncated) + return ( + + ) + })} + + ) } + type TeammateMessageContentProps = { - displayName: string; - inkColor: TextProps['color']; - content: string; - summary?: string; - isTranscriptMode?: boolean; -}; -export function TeammateMessageContent(t0) { - const $ = _c(14); - const { - displayName, - inkColor, - content, - summary, - isTranscriptMode - } = t0; - const t1 = `@${displayName}${figures.pointer}`; - let t2; - if ($[0] !== inkColor || $[1] !== t1) { - t2 = {t1}; - $[0] = inkColor; - $[1] = t1; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== summary) { - t3 = summary && {summary}; - $[3] = summary; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== t2 || $[6] !== t3) { - t4 = {t2}{t3}; - $[5] = t2; - $[6] = t3; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] !== content || $[9] !== isTranscriptMode) { - t5 = isTranscriptMode && {content}; - $[8] = content; - $[9] = isTranscriptMode; - $[10] = t5; - } else { - t5 = $[10]; - } - let t6; - if ($[11] !== t4 || $[12] !== t5) { - t6 = {t4}{t5}; - $[11] = t4; - $[12] = t5; - $[13] = t6; - } else { - t6 = $[13]; - } - return t6; + displayName: string + inkColor: TextProps['color'] + content: string + summary?: string + isTranscriptMode?: boolean +} + +export function TeammateMessageContent({ + displayName, + inkColor, + content, + summary, + isTranscriptMode, +}: TeammateMessageContentProps): React.ReactNode { + return ( + + + {`@${displayName}${figures.pointer}`} + {summary && {summary}} + + {isTranscriptMode && ( + + + {content} + + + )} + + ) } diff --git a/src/components/messages/UserTextMessage.tsx b/src/components/messages/UserTextMessage.tsx index 464460e9c..73ef3929d 100644 --- a/src/components/messages/UserTextMessage.tsx +++ b/src/components/messages/UserTextMessage.tsx @@ -1,274 +1,197 @@ -import { c as _c } from "react/compiler-runtime"; -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'; +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' + type Props = { - addMargin: boolean; - param: TextBlockParam; - verbose: boolean; - planContent?: string; - isTranscriptMode?: boolean; - timestamp?: string; -}; -export function UserTextMessage(t0) { - const $ = _c(49); - const { - addMargin, - param, - verbose, - planContent, - isTranscriptMode, - timestamp - } = t0; - if (param.text.trim() === NO_CONTENT_MESSAGE) { - return null; - } - if (planContent) { - let t1; - if ($[0] !== addMargin || $[1] !== planContent) { - t1 = ; - $[0] = addMargin; - $[1] = planContent; - $[2] = t1; - } else { - t1 = $[2]; - } - return t1; - } - if (extractTag(param.text, TICK_TAG)) { - return null; - } - if (param.text.includes(`<${LOCAL_COMMAND_CAVEAT_TAG}>`)) { - return null; - } - if (param.text.startsWith("; - $[3] = param.text; - $[4] = verbose; - $[5] = t1; - } else { - t1 = $[5]; - } - return t1; - } - if (param.text.startsWith("; - $[6] = param.text; - $[7] = t1; - } else { - t1 = $[7]; - } - return t1; - } - if (param.text === INTERRUPT_MESSAGE || param.text === INTERRUPT_MESSAGE_FOR_TOOL_USE) { - let t1; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[8] = t1; - } else { - t1 = $[8]; - } - return t1; - } - if (feature("KAIROS_GITHUB_WEBHOOKS")) { - if (param.text.startsWith("")) { - let t1; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t1 = require("./UserGitHubWebhookMessage.js"); - $[9] = t1; - } else { - t1 = $[9]; - } - const { - UserGitHubWebhookMessage - } = t1 as typeof import('./UserGitHubWebhookMessage.js'); - let t2; - if ($[10] !== addMargin || $[11] !== param) { - t2 = ; - $[10] = addMargin; - $[11] = param; - $[12] = t2; - } else { - t2 = $[12]; - } - return t2; - } - } - if (param.text.includes("")) { - let t1; - if ($[13] !== addMargin || $[14] !== param) { - t1 = ; - $[13] = addMargin; - $[14] = param; - $[15] = t1; - } else { - t1 = $[15]; - } - return t1; - } - if (param.text.includes(`<${COMMAND_MESSAGE_TAG}>`)) { - let t1; - if ($[16] !== addMargin || $[17] !== param) { - t1 = ; - $[16] = addMargin; - $[17] = param; - $[18] = t1; - } else { - t1 = $[18]; - } - return t1; - } - if (param.text.includes("")) { - let t1; - if ($[19] !== addMargin || $[20] !== param.text) { - t1 = ; - $[19] = addMargin; - $[20] = param.text; - $[21] = t1; - } else { - t1 = $[21]; - } - return t1; - } - if (isAgentSwarmsEnabled() && param.text.includes(`<${TEAMMATE_MESSAGE_TAG}`)) { - let t1; - if ($[22] !== addMargin || $[23] !== isTranscriptMode || $[24] !== param) { - t1 = ; - $[22] = addMargin; - $[23] = isTranscriptMode; - $[24] = param; - $[25] = t1; - } else { - t1 = $[25]; - } - return t1; - } - if (param.text.includes(`<${TASK_NOTIFICATION_TAG}`)) { - let t1; - if ($[26] !== addMargin || $[27] !== param) { - t1 = ; - $[26] = addMargin; - $[27] = param; - $[28] = t1; - } else { - t1 = $[28]; - } - return t1; - } - if (param.text.includes("; - $[29] = addMargin; - $[30] = param; - $[31] = t1; - } else { - t1 = $[31]; - } - return t1; - } - if (feature("FORK_SUBAGENT")) { - if (param.text.includes("")) { - let t1; - if ($[32] === Symbol.for("react.memo_cache_sentinel")) { - t1 = require("./UserForkBoilerplateMessage.js"); - $[32] = t1; - } else { - t1 = $[32]; - } - const { - UserForkBoilerplateMessage - } = t1 as typeof import('./UserForkBoilerplateMessage.js'); - let t2; - if ($[33] !== addMargin || $[34] !== param) { - t2 = ; - $[33] = addMargin; - $[34] = param; - $[35] = t2; - } else { - t2 = $[35]; - } - return t2; - } - } - if (feature("UDS_INBOX")) { - if (param.text.includes("; - $[37] = addMargin; - $[38] = param; - $[39] = t2; - } else { - t2 = $[39]; - } - return t2; - } - } - if (feature("KAIROS") || feature("KAIROS_CHANNELS")) { - if (param.text.includes("; - $[41] = addMargin; - $[42] = param; - $[43] = t2; - } else { - t2 = $[43]; - } - return t2; - } - } - let t1; - if ($[44] !== addMargin || $[45] !== isTranscriptMode || $[46] !== param || $[47] !== timestamp) { - t1 = ; - $[44] = addMargin; - $[45] = isTranscriptMode; - $[46] = param; - $[47] = timestamp; - $[48] = t1; - } else { - t1 = $[48]; - } - return t1; + addMargin: boolean + param: TextBlockParam + verbose: boolean + planContent?: string + isTranscriptMode?: boolean + timestamp?: string +} + +export function UserTextMessage({ + addMargin, + param, + verbose, + planContent, + isTranscriptMode, + timestamp, +}: Props): React.ReactNode { + if (param.text.trim() === NO_CONTENT_MESSAGE) { + return null + } + + // Plan to implement message (cleared context flow) + if (planContent) { + return + } + + if (extractTag(param.text, TICK_TAG)) { + return null + } + + // Hide synthetic caveat messages (should be filtered by isMeta, this is defensive) + if (param.text.includes(`<${LOCAL_COMMAND_CAVEAT_TAG}>`)) { + return null + } + + // Show bash output + if ( + param.text.startsWith(' + } + + // Show command output + if ( + param.text.startsWith(' + } + + // Handle interruption messages specially + if ( + param.text === INTERRUPT_MESSAGE || + param.text === INTERRUPT_MESSAGE_FOR_TOOL_USE + ) { + return ( + + + + ) + } + + // GitHub webhook events (check_run, review comments, pushes) delivered via + // bound-session routing after /subscribe-pr. The tag constant is stripped + // from external builds — inline the literal so the import doesn't fail. + // The require() below DCEs when both flags are off. startsWith (not + // includes) and before the includes-checks below: defense-in-depth if + // the sanitizer were ever weakened. + if (feature('KAIROS_GITHUB_WEBHOOKS')) { + if (param.text.startsWith('')) { + /* eslint-disable @typescript-eslint/no-require-imports */ + const { UserGitHubWebhookMessage } = + require('./UserGitHubWebhookMessage.js') as typeof import('./UserGitHubWebhookMessage.js') + /* eslint-enable @typescript-eslint/no-require-imports */ + return + } + } + + // Bash inputs! + if (param.text.includes('')) { + return + } + + // Slash commands/ + if (param.text.includes(`<${COMMAND_MESSAGE_TAG}>`)) { + return + } + + if (param.text.includes('')) { + return + } + + // Teammate messages - only check when swarms enabled + if ( + isAgentSwarmsEnabled() && + param.text.includes(`<${TEAMMATE_MESSAGE_TAG}`) + ) { + return ( + + ) + } + + // Task notifications (agent completions, bash completions, etc.) + if (param.text.includes(`<${TASK_NOTIFICATION_TAG}`)) { + return + } + + // MCP resource and polling update notifications + if ( + param.text.includes(' + } + + // Fork child's first message: collapse the rules/format boilerplate, show + // only the directive. FORK_BOILERPLATE_TAG is inlined so the import doesn't + // ship in external builds where feature('FORK_SUBAGENT') is false. + if (feature('FORK_SUBAGENT')) { + if (param.text.includes('')) { + /* eslint-disable @typescript-eslint/no-require-imports */ + const { UserForkBoilerplateMessage } = + require('./UserForkBoilerplateMessage.js') as typeof import('./UserForkBoilerplateMessage.js') + /* eslint-enable @typescript-eslint/no-require-imports */ + return + } + } + + // Cross-session UDS message (from another Claude session's SendMessage). + // CROSS_SESSION_MESSAGE_TAG is inlined so the import doesn't ship in + // external builds where feature('UDS_INBOX') is false. + if (feature('UDS_INBOX')) { + if (param.text.includes(' + } + } + + // Inbound channel message (MCP server push). + if (feature('KAIROS') || feature('KAIROS_CHANNELS')) { + if (param.text.includes('User rejected Claude's plan:; - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] !== plan) { - t2 = {t1}{plan}; - $[1] = plan; - $[2] = t2; - } else { - t2 = $[2]; - } - return t2; + plan: string +} + +export function RejectedPlanMessage({ plan }: Props): React.ReactNode { + return ( + + + User rejected Claude's plan: + + {plan} + + + + ) } diff --git a/src/components/messages/UserToolResultMessage/RejectedToolUseMessage.tsx b/src/components/messages/UserToolResultMessage/RejectedToolUseMessage.tsx index 2fd528995..b387b0fea 100644 --- a/src/components/messages/UserToolResultMessage/RejectedToolUseMessage.tsx +++ b/src/components/messages/UserToolResultMessage/RejectedToolUseMessage.tsx @@ -1,15 +1,11 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Text } from '../../../ink.js'; -import { MessageResponse } from '../../MessageResponse.js'; -export function RejectedToolUseMessage() { - const $ = _c(1); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = Tool use rejected; - $[0] = t0; - } else { - t0 = $[0]; - } - return t0; +import * as React from 'react' +import { Text } from '../../../ink.js' +import { MessageResponse } from '../../MessageResponse.js' + +export function RejectedToolUseMessage(): React.ReactNode { + return ( + + Tool use rejected + + ) } diff --git a/src/components/messages/UserToolResultMessage/UserToolCanceledMessage.tsx b/src/components/messages/UserToolResultMessage/UserToolCanceledMessage.tsx index c5668443c..cb8f242eb 100644 --- a/src/components/messages/UserToolResultMessage/UserToolCanceledMessage.tsx +++ b/src/components/messages/UserToolResultMessage/UserToolCanceledMessage.tsx @@ -1,15 +1,11 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { InterruptedByUser } from 'src/components/InterruptedByUser.js'; -import { MessageResponse } from 'src/components/MessageResponse.js'; -export function UserToolCanceledMessage() { - const $ = _c(1); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = ; - $[0] = t0; - } else { - t0 = $[0]; - } - return t0; +import * as React from 'react' +import { InterruptedByUser } from 'src/components/InterruptedByUser.js' +import { MessageResponse } from 'src/components/MessageResponse.js' + +export function UserToolCanceledMessage(): React.ReactNode { + return ( + + + + ) } diff --git a/src/components/messages/UserToolResultMessage/UserToolErrorMessage.tsx b/src/components/messages/UserToolResultMessage/UserToolErrorMessage.tsx index 74b32dcf5..33249d591 100644 --- a/src/components/messages/UserToolResultMessage/UserToolErrorMessage.tsx +++ b/src/components/messages/UserToolResultMessage/UserToolErrorMessage.tsx @@ -1,102 +1,95 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; -import * as React from 'react'; -import { BULLET_OPERATOR } from '../../../constants/figures.js'; -import { Text } from '../../../ink.js'; -import { filterToolProgressMessages, type Tool, type Tools } from '../../../Tool.js'; -import type { ProgressMessage } from '../../../types/message.js'; -import { INTERRUPT_MESSAGE_FOR_TOOL_USE, isClassifierDenial, PLAN_REJECTION_PREFIX, REJECT_MESSAGE_WITH_REASON_PREFIX } from '../../../utils/messages.js'; -import { FallbackToolUseErrorMessage } from '../../FallbackToolUseErrorMessage.js'; -import { InterruptedByUser } from '../../InterruptedByUser.js'; -import { MessageResponse } from '../../MessageResponse.js'; -import { RejectedPlanMessage } from './RejectedPlanMessage.js'; -import { RejectedToolUseMessage } from './RejectedToolUseMessage.js'; +import { feature } from 'bun:bundle' +import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' +import * as React from 'react' +import { BULLET_OPERATOR } from '../../../constants/figures.js' +import { Text } from '../../../ink.js' +import { + filterToolProgressMessages, + type Tool, + type Tools, +} from '../../../Tool.js' +import type { ProgressMessage } from '../../../types/message.js' +import { + INTERRUPT_MESSAGE_FOR_TOOL_USE, + isClassifierDenial, + PLAN_REJECTION_PREFIX, + REJECT_MESSAGE_WITH_REASON_PREFIX, +} from '../../../utils/messages.js' +import { FallbackToolUseErrorMessage } from '../../FallbackToolUseErrorMessage.js' +import { InterruptedByUser } from '../../InterruptedByUser.js' +import { MessageResponse } from '../../MessageResponse.js' +import { RejectedPlanMessage } from './RejectedPlanMessage.js' +import { RejectedToolUseMessage } from './RejectedToolUseMessage.js' + type Props = { - progressMessagesForMessage: ProgressMessage[]; - tool?: Tool; // undefined when resuming an old conversation that uses an old tool - tools: Tools; - param: ToolResultBlockParam; - verbose: boolean; - isTranscriptMode?: boolean; -}; -export function UserToolErrorMessage(t0) { - const $ = _c(14); - const { - progressMessagesForMessage, - tool, - tools, - param, - verbose, - isTranscriptMode - } = t0; - if (typeof param.content === "string" && param.content.includes(INTERRUPT_MESSAGE_FOR_TOOL_USE)) { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; + progressMessagesForMessage: ProgressMessage[] + tool?: Tool // undefined when resuming an old conversation that uses an old tool + tools: Tools + param: ToolResultBlockParam + verbose: boolean + isTranscriptMode?: boolean +} + +export function UserToolErrorMessage({ + progressMessagesForMessage, + tool, + tools, + param, + verbose, + isTranscriptMode, +}: Props): React.ReactNode { + if ( + typeof param.content === 'string' && + param.content.includes(INTERRUPT_MESSAGE_FOR_TOOL_USE) + ) { + return ( + + + + ) } - if (typeof param.content === "string" && param.content.startsWith(PLAN_REJECTION_PREFIX)) { - let t1; - if ($[1] !== param.content) { - t1 = param.content.substring(PLAN_REJECTION_PREFIX.length); - $[1] = param.content; - $[2] = t1; - } else { - t1 = $[2]; - } - const planContent = t1; - let t2; - if ($[3] !== planContent) { - t2 = ; - $[3] = planContent; - $[4] = t2; - } else { - t2 = $[4]; - } - return t2; + + if ( + typeof param.content === 'string' && + param.content.startsWith(PLAN_REJECTION_PREFIX) + ) { + // Extract the plan content from the error message + const planContent = param.content.substring(PLAN_REJECTION_PREFIX.length) + return } - if (typeof param.content === "string" && param.content.startsWith(REJECT_MESSAGE_WITH_REASON_PREFIX)) { - let t1; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[5] = t1; - } else { - t1 = $[5]; - } - return t1; + + if ( + typeof param.content === 'string' && + param.content.startsWith(REJECT_MESSAGE_WITH_REASON_PREFIX) + ) { + return } - if (feature("TRANSCRIPT_CLASSIFIER") && typeof param.content === "string" && isClassifierDenial(param.content)) { - let t1; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Denied by auto mode classifier {BULLET_OPERATOR} /feedback if incorrect; - $[6] = t1; - } else { - t1 = $[6]; - } - return t1; + + if ( + feature('TRANSCRIPT_CLASSIFIER') && + typeof param.content === 'string' && + isClassifierDenial(param.content) + ) { + return ( + + + Denied by auto mode classifier {BULLET_OPERATOR} /feedback if + incorrect + + + ) } - let t1; - if ($[7] !== isTranscriptMode || $[8] !== param.content || $[9] !== progressMessagesForMessage || $[10] !== tool || $[11] !== tools || $[12] !== verbose) { - t1 = tool?.renderToolUseErrorMessage?.(param.content, { - progressMessagesForMessage: filterToolProgressMessages(progressMessagesForMessage), + + return ( + tool?.renderToolUseErrorMessage?.(param.content, { + progressMessagesForMessage: filterToolProgressMessages( + progressMessagesForMessage, + ), tools, verbose, - isTranscriptMode - }) ?? ; - $[7] = isTranscriptMode; - $[8] = param.content; - $[9] = progressMessagesForMessage; - $[10] = tool; - $[11] = tools; - $[12] = verbose; - $[13] = t1; - } else { - t1 = $[13]; - } - return t1; + isTranscriptMode, + }) ?? ( + + ) + ) } diff --git a/src/components/messages/UserToolResultMessage/UserToolRejectMessage.tsx b/src/components/messages/UserToolResultMessage/UserToolRejectMessage.tsx index 5ed3571bd..c1a37ef4e 100644 --- a/src/components/messages/UserToolResultMessage/UserToolRejectMessage.tsx +++ b/src/components/messages/UserToolResultMessage/UserToolRejectMessage.tsx @@ -1,94 +1,59 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useTerminalSize } from '../../../hooks/useTerminalSize.js'; -import { useTheme } from '../../../ink.js'; -import { filterToolProgressMessages, type Tool, type Tools } from '../../../Tool.js'; -import type { ProgressMessage } from '../../../types/message.js'; -import type { buildMessageLookups } from '../../../utils/messages.js'; -import { FallbackToolUseRejectedMessage } from '../../FallbackToolUseRejectedMessage.js'; +import * as React from 'react' +import { useTerminalSize } from '../../../hooks/useTerminalSize.js' +import { useTheme } from '../../../ink.js' +import { + filterToolProgressMessages, + type Tool, + type Tools, +} from '../../../Tool.js' +import type { ProgressMessage } from '../../../types/message.js' +import type { buildMessageLookups } from '../../../utils/messages.js' +import { FallbackToolUseRejectedMessage } from '../../FallbackToolUseRejectedMessage.js' + type Props = { - input: { - [key: string]: unknown; - }; - progressMessagesForMessage: ProgressMessage[]; - style?: 'condensed'; - tool?: Tool; - tools: Tools; - lookups: ReturnType; - verbose: boolean; - isTranscriptMode?: boolean; -}; -export function UserToolRejectMessage(t0) { - const $ = _c(13); - const { - input, - progressMessagesForMessage, - style, - tool, - tools, - verbose, - isTranscriptMode - } = t0; - const { - columns - } = useTerminalSize(); - const [theme] = useTheme(); - if (!tool || !tool.renderToolUseRejectedMessage) { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; - } - const t1 = tool.inputSchema; - let t2; - let t3; - if ($[1] !== columns || $[2] !== input || $[3] !== isTranscriptMode || $[4] !== progressMessagesForMessage || $[5] !== style || $[6] !== theme || $[7] !== tool || $[8] !== tools || $[9] !== verbose) { - t3 = Symbol.for("react.early_return_sentinel"); - bb0: { - const parsedInput = t1.safeParse(input); - if (!parsedInput.success) { - let t4; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t4 = ; - $[12] = t4; - } else { - t4 = $[12]; - } - t3 = t4; - break bb0; - } - t2 = tool.renderToolUseRejectedMessage(parsedInput.data, { - columns, - messages: [], - tools, - verbose, - progressMessagesForMessage: filterToolProgressMessages(progressMessagesForMessage), - style, - theme, - isTranscriptMode - }) ?? ; - } - $[1] = columns; - $[2] = input; - $[3] = isTranscriptMode; - $[4] = progressMessagesForMessage; - $[5] = style; - $[6] = theme; - $[7] = tool; - $[8] = tools; - $[9] = verbose; - $[10] = t2; - $[11] = t3; - } else { - t2 = $[10]; - t3 = $[11]; - } - if (t3 !== Symbol.for("react.early_return_sentinel")) { - return t3; - } - return t2; + input: { [key: string]: unknown } + progressMessagesForMessage: ProgressMessage[] + style?: 'condensed' + tool?: Tool + tools: Tools + lookups: ReturnType + verbose: boolean + isTranscriptMode?: boolean +} + +export function UserToolRejectMessage({ + input, + progressMessagesForMessage, + style, + tool, + tools, + verbose, + isTranscriptMode, +}: Props): React.ReactNode { + const { columns } = useTerminalSize() + const [theme] = useTheme() + + if (!tool || !tool.renderToolUseRejectedMessage) { + return + } + + const parsedInput = tool.inputSchema.safeParse(input) + if (!parsedInput.success) { + return + } + + return ( + tool.renderToolUseRejectedMessage(parsedInput.data, { + columns, + messages: [], + tools, + verbose, + progressMessagesForMessage: filterToolProgressMessages( + progressMessagesForMessage, + ), + style, + theme, + isTranscriptMode, + }) ?? + ) } diff --git a/src/components/messages/UserToolResultMessage/UserToolResultMessage.tsx b/src/components/messages/UserToolResultMessage/UserToolResultMessage.tsx index 29f7ee31f..abd7a8fce 100644 --- a/src/components/messages/UserToolResultMessage/UserToolResultMessage.tsx +++ b/src/components/messages/UserToolResultMessage/UserToolResultMessage.tsx @@ -1,105 +1,101 @@ -import { c as _c } from "react/compiler-runtime"; -import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs'; -import * as React from 'react'; -import type { Tools } from '../../../Tool.js'; -import type { NormalizedUserMessage, ProgressMessage } from '../../../types/message.js'; -import { type buildMessageLookups, CANCEL_MESSAGE, INTERRUPT_MESSAGE_FOR_TOOL_USE, REJECT_MESSAGE } from '../../../utils/messages.js'; -import { UserToolCanceledMessage } from './UserToolCanceledMessage.js'; -import { UserToolErrorMessage } from './UserToolErrorMessage.js'; -import { UserToolRejectMessage } from './UserToolRejectMessage.js'; -import { UserToolSuccessMessage } from './UserToolSuccessMessage.js'; -import { useGetToolFromMessages } from './utils.js'; +import type { ToolResultBlockParam } from '@anthropic-ai/sdk/resources/index.mjs' +import * as React from 'react' +import type { Tools } from '../../../Tool.js' +import type { + NormalizedUserMessage, + ProgressMessage, +} from '../../../types/message.js' +import { + type buildMessageLookups, + CANCEL_MESSAGE, + INTERRUPT_MESSAGE_FOR_TOOL_USE, + REJECT_MESSAGE, +} from '../../../utils/messages.js' +import { UserToolCanceledMessage } from './UserToolCanceledMessage.js' +import { UserToolErrorMessage } from './UserToolErrorMessage.js' +import { UserToolRejectMessage } from './UserToolRejectMessage.js' +import { UserToolSuccessMessage } from './UserToolSuccessMessage.js' +import { useGetToolFromMessages } from './utils.js' + type Props = { - param: ToolResultBlockParam; - message: NormalizedUserMessage; - lookups: ReturnType; - progressMessagesForMessage: ProgressMessage[]; - style?: 'condensed'; - tools: Tools; - verbose: boolean; - width: number | string; - isTranscriptMode?: boolean; -}; -export function UserToolResultMessage(t0) { - const $ = _c(28); - const { - param, - message, - lookups, - progressMessagesForMessage, - style, - tools, - verbose, - width, - isTranscriptMode - } = t0; - const toolUse = useGetToolFromMessages(param.tool_use_id, tools, lookups); - if (!toolUse) { - return null; - } - if (typeof param.content === "string" && param.content.startsWith(CANCEL_MESSAGE)) { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; - } - if (typeof param.content === "string" && param.content.startsWith(REJECT_MESSAGE) || param.content === INTERRUPT_MESSAGE_FOR_TOOL_USE) { - const t1 = toolUse.toolUse.input as { - [key: string]: unknown; - }; - let t2; - if ($[1] !== isTranscriptMode || $[2] !== lookups || $[3] !== progressMessagesForMessage || $[4] !== style || $[5] !== t1 || $[6] !== toolUse.tool || $[7] !== tools || $[8] !== verbose) { - t2 = ; - $[1] = isTranscriptMode; - $[2] = lookups; - $[3] = progressMessagesForMessage; - $[4] = style; - $[5] = t1; - $[6] = toolUse.tool; - $[7] = tools; - $[8] = verbose; - $[9] = t2; - } else { - t2 = $[9]; - } - return t2; - } - if (param.is_error) { - let t1; - if ($[10] !== isTranscriptMode || $[11] !== param || $[12] !== progressMessagesForMessage || $[13] !== toolUse.tool || $[14] !== tools || $[15] !== verbose) { - t1 = ; - $[10] = isTranscriptMode; - $[11] = param; - $[12] = progressMessagesForMessage; - $[13] = toolUse.tool; - $[14] = tools; - $[15] = verbose; - $[16] = t1; - } else { - t1 = $[16]; - } - return t1; - } - let t1; - if ($[17] !== isTranscriptMode || $[18] !== lookups || $[19] !== message || $[20] !== progressMessagesForMessage || $[21] !== style || $[22] !== toolUse.tool || $[23] !== toolUse.toolUse.id || $[24] !== tools || $[25] !== verbose || $[26] !== width) { - t1 = ; - $[17] = isTranscriptMode; - $[18] = lookups; - $[19] = message; - $[20] = progressMessagesForMessage; - $[21] = style; - $[22] = toolUse.tool; - $[23] = toolUse.toolUse.id; - $[24] = tools; - $[25] = verbose; - $[26] = width; - $[27] = t1; - } else { - t1 = $[27]; - } - return t1; + param: ToolResultBlockParam + message: NormalizedUserMessage + lookups: ReturnType + progressMessagesForMessage: ProgressMessage[] + style?: 'condensed' + tools: Tools + verbose: boolean + width: number | string + isTranscriptMode?: boolean +} + +export function UserToolResultMessage({ + param, + message, + lookups, + progressMessagesForMessage, + style, + tools, + verbose, + width, + isTranscriptMode, +}: Props): React.ReactNode { + const toolUse = useGetToolFromMessages(param.tool_use_id, tools, lookups) + if (!toolUse) { + return null + } + + if ( + typeof param.content === 'string' && + param.content.startsWith(CANCEL_MESSAGE) + ) { + return + } + + if ( + (typeof param.content === 'string' && + param.content.startsWith(REJECT_MESSAGE)) || + param.content === INTERRUPT_MESSAGE_FOR_TOOL_USE + ) { + return ( + + ) + } + + if (param.is_error) { + return ( + + ) + } + + return ( + + ) } diff --git a/src/components/messages/UserToolResultMessage/UserToolSuccessMessage.tsx b/src/components/messages/UserToolResultMessage/UserToolSuccessMessage.tsx index 4ad021c60..931e0df3e 100644 --- a/src/components/messages/UserToolResultMessage/UserToolSuccessMessage.tsx +++ b/src/components/messages/UserToolResultMessage/UserToolSuccessMessage.tsx @@ -1,27 +1,40 @@ -import { feature } from 'bun:bundle'; -import figures from 'figures'; -import * as React from 'react'; -import { SentryErrorBoundary } from 'src/components/SentryErrorBoundary.js'; -import { Box, Text, useTheme } from '../../../ink.js'; -import { useAppState } from '../../../state/AppState.js'; -import { filterToolProgressMessages, type Tool, type Tools } from '../../../Tool.js'; -import type { NormalizedUserMessage, ProgressMessage } from '../../../types/message.js'; -import { deleteClassifierApproval, getClassifierApproval, getYoloClassifierApproval } from '../../../utils/classifierApprovals.js'; -import type { buildMessageLookups } from '../../../utils/messages.js'; -import { MessageResponse } from '../../MessageResponse.js'; -import { HookProgressMessage } from '../HookProgressMessage.js'; +import { feature } from 'bun:bundle' +import figures from 'figures' +import * as React from 'react' +import { SentryErrorBoundary } from 'src/components/SentryErrorBoundary.js' +import { Box, Text, useTheme } from '../../../ink.js' +import { useAppState } from '../../../state/AppState.js' +import { + filterToolProgressMessages, + type Tool, + type Tools, +} from '../../../Tool.js' +import type { + NormalizedUserMessage, + ProgressMessage, +} from '../../../types/message.js' +import { + deleteClassifierApproval, + getClassifierApproval, + getYoloClassifierApproval, +} from '../../../utils/classifierApprovals.js' +import type { buildMessageLookups } from '../../../utils/messages.js' +import { MessageResponse } from '../../MessageResponse.js' +import { HookProgressMessage } from '../HookProgressMessage.js' + type Props = { - message: NormalizedUserMessage; - lookups: ReturnType; - toolUseID: string; - progressMessagesForMessage: ProgressMessage[]; - style?: 'condensed'; - tool?: Tool; - tools: Tools; - verbose: boolean; - width: number | string; - isTranscriptMode?: boolean; -}; + message: NormalizedUserMessage + lookups: ReturnType + toolUseID: string + progressMessagesForMessage: ProgressMessage[] + style?: 'condensed' + tool?: Tool + tools: Tools + verbose: boolean + width: number | string + isTranscriptMode?: boolean +} + export function UserToolSuccessMessage({ message, lookups, @@ -32,72 +45,105 @@ export function UserToolSuccessMessage({ tools, verbose, width, - isTranscriptMode + isTranscriptMode, }: Props): React.ReactNode { - const [theme] = useTheme(); + const [theme] = useTheme() // Hook stays inside feature() ternary so external builds don't pay a // per-scrollback-message store subscription — same pattern as // UserPromptMessage.tsx. - const isBriefOnly = feature('KAIROS') || feature('KAIROS_BRIEF') ? - // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant - useAppState(s => s.isBriefOnly) : false; + const isBriefOnly = + feature('KAIROS') || feature('KAIROS_BRIEF') + ? // biome-ignore lint/correctness/useHookAtTopLevel: feature() is a compile-time constant + useAppState(s => s.isBriefOnly) + : false // Capture classifier approval once on mount, then delete from Map to prevent linear growth. // useState lazy initializer ensures the value persists across re-renders. - const [classifierRule] = React.useState(() => getClassifierApproval(toolUseID)); - const [yoloReason] = React.useState(() => getYoloClassifierApproval(toolUseID)); + const [classifierRule] = React.useState(() => + getClassifierApproval(toolUseID), + ) + const [yoloReason] = React.useState(() => + getYoloClassifierApproval(toolUseID), + ) React.useEffect(() => { - deleteClassifierApproval(toolUseID); - }, [toolUseID]); + deleteClassifierApproval(toolUseID) + }, [toolUseID]) + if (!message.toolUseResult || !tool) { - return null; + return null } // Resumed transcripts deserialize toolUseResult via raw JSON.parse with no // validation (parseJSONL). A partial/corrupt/old-format result crashes // renderToolResultMessage on first field access (anthropics/claude-code#39817). // Validate against outputSchema before rendering — mirrors CollapsedReadSearchContent. - const parsedOutput = tool.outputSchema?.safeParse(message.toolUseResult); + const parsedOutput = tool.outputSchema?.safeParse(message.toolUseResult) if (parsedOutput && !parsedOutput.success) { - return null; + return null } - const toolResult = parsedOutput?.data ?? message.toolUseResult; - const renderedMessage = tool.renderToolResultMessage?.(toolResult as never, filterToolProgressMessages(progressMessagesForMessage), { - style, - theme, - tools, - verbose, - isTranscriptMode, - isBriefOnly, - input: lookups.toolUseByToolUseID.get(toolUseID)?.input - }) ?? null; + const toolResult = parsedOutput?.data ?? message.toolUseResult + + const renderedMessage = + tool.renderToolResultMessage?.( + toolResult as never, + filterToolProgressMessages(progressMessagesForMessage), + { + style, + theme, + tools, + verbose, + isTranscriptMode, + isBriefOnly, + input: lookups.toolUseByToolUseID.get(toolUseID)?.input, + }, + ) ?? null // Don't render anything if the tool result message is null if (renderedMessage === null) { - return null; + return null } // Tools that return '' from userFacingName opt out of tool chrome and // render like plain assistant text. Skip the tool-result width constraint // so MarkdownTable's SAFETY_MARGIN=4 (tuned for the assistant-text 2-col // dot gutter) holds — otherwise tables wrap their box-drawing chars. - const rendersAsAssistantText = tool.userFacingName(undefined) === ''; - return - + const rendersAsAssistantText = tool.userFacingName(undefined) === '' + + return ( + + {renderedMessage} - {feature('BASH_CLASSIFIER') ? classifierRule && + {feature('BASH_CLASSIFIER') + ? classifierRule && ( + {figures.tick} {' Auto-approved \u00b7 matched '} {`"${classifierRule}"`} - : null} - {feature('TRANSCRIPT_CLASSIFIER') ? yoloReason && + + ) + : null} + {feature('TRANSCRIPT_CLASSIFIER') + ? yoloReason && ( + Allowed by auto mode classifier - : null} + + ) + : null} - + - ; + + ) } diff --git a/src/components/messages/UserToolResultMessage/utils.tsx b/src/components/messages/UserToolResultMessage/utils.tsx index d86dccf16..4eeeb8004 100644 --- a/src/components/messages/UserToolResultMessage/utils.tsx +++ b/src/components/messages/UserToolResultMessage/utils.tsx @@ -1,43 +1,22 @@ -import { c as _c } from "react/compiler-runtime"; -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, tools, lookups) { - const $ = _c(7); - let t0; - if ($[0] !== lookups.toolUseByToolUseID || $[1] !== toolUseID || $[2] !== tools) { - bb0: { - const toolUse = lookups.toolUseByToolUseID.get(toolUseID); - if (!toolUse) { - t0 = null; - break bb0; - } - const tool = findToolByName(tools, toolUse.name); - if (!tool) { - t0 = null; - break bb0; - } - let t1; - if ($[4] !== tool || $[5] !== toolUse) { - t1 = { - tool, - toolUse - }; - $[4] = tool; - $[5] = toolUse; - $[6] = t1; - } else { - t1 = $[6]; - } - t0 = t1; +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, + tools: Tools, + lookups: ReturnType, +): { tool: Tool; toolUse: ToolUseBlockParam } | null { + return useMemo(() => { + const toolUse = lookups.toolUseByToolUseID.get(toolUseID) + if (!toolUse) { + return null } - $[0] = lookups.toolUseByToolUseID; - $[1] = toolUseID; - $[2] = tools; - $[3] = t0; - } else { - t0 = $[3]; - } - return t0; + const tool = findToolByName(tools, toolUse.name) + if (!tool) { + return null + } + return { tool, toolUse } + }, [toolUseID, lookups, tools]) } diff --git a/src/components/messages/teamMemCollapsed.tsx b/src/components/messages/teamMemCollapsed.tsx index bcb0362c7..63fcdaf0e 100644 --- a/src/components/messages/teamMemCollapsed.tsx +++ b/src/components/messages/teamMemCollapsed.tsx @@ -1,7 +1,6 @@ -import { c as _c } from "react/compiler-runtime"; -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 @@ -9,7 +8,11 @@ import type { CollapsedReadSearchGroup } from '../../types/message.js'; * is only loaded when feature('TEAMMEM') is true. */ export function checkHasTeamMemOps(message: CollapsedReadSearchGroup): boolean { - return (message.teamMemorySearchCount ?? 0) > 0 || (message.teamMemoryReadCount ?? 0) > 0 || (message.teamMemoryWriteCount ?? 0) > 0; + return ( + (message.teamMemorySearchCount ?? 0) > 0 || + (message.teamMemoryReadCount ?? 0) > 0 || + (message.teamMemoryWriteCount ?? 0) > 0 + ) } /** @@ -17,123 +20,79 @@ export function checkHasTeamMemOps(message: CollapsedReadSearchGroup): boolean { * This module is only loaded when feature('TEAMMEM') is true, * so DCE removes it entirely from external builds. */ -export function TeamMemCountParts(t0) { - const $ = _c(23); - const { - message, - isActiveGroup, - hasPrecedingParts - } = t0; - const tmReadCount = message.teamMemoryReadCount ?? 0; - const tmSearchCount = message.teamMemorySearchCount ?? 0; - const tmWriteCount = message.teamMemoryWriteCount ?? 0; +export function TeamMemCountParts({ + message, + isActiveGroup, + hasPrecedingParts, +}: { + message: CollapsedReadSearchGroup + isActiveGroup: boolean | undefined + hasPrecedingParts: boolean +}): React.ReactNode { + 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 } - let t1; - if ($[0] !== hasPrecedingParts || $[1] !== isActiveGroup || $[2] !== tmReadCount || $[3] !== tmSearchCount || $[4] !== tmWriteCount) { - const nodes = []; - let count = hasPrecedingParts ? 1 : 0; - if (tmReadCount > 0) { - const verb = isActiveGroup ? count === 0 ? "Recalling" : "recalling" : count === 0 ? "Recalled" : "recalled"; - if (count > 0) { - let t2; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t2 = , ; - $[6] = t2; - } else { - t2 = $[6]; - } - nodes.push(t2); - } - let t2; - if ($[7] !== tmReadCount) { - t2 = {tmReadCount}; - $[7] = tmReadCount; - $[8] = t2; - } else { - t2 = $[8]; - } - const t3 = tmReadCount === 1 ? "memory" : "memories"; - let t4; - if ($[9] !== t2 || $[10] !== t3 || $[11] !== verb) { - t4 = {verb} {t2} team{" "}{t3}; - $[9] = t2; - $[10] = t3; - $[11] = verb; - $[12] = t4; - } else { - t4 = $[12]; - } - nodes.push(t4); - count++; + + const nodes: React.ReactNode[] = [] + let count = hasPrecedingParts ? 1 : 0 + + if (tmReadCount > 0) { + const verb = isActiveGroup + ? count === 0 + ? 'Recalling' + : 'recalling' + : count === 0 + ? 'Recalled' + : 'recalled' + if (count > 0) { + nodes.push(, ) } - if (tmSearchCount > 0) { - const verb_0 = isActiveGroup ? count === 0 ? "Searching" : "searching" : count === 0 ? "Searched" : "searched"; - if (count > 0) { - let t2; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t2 = , ; - $[13] = t2; - } else { - t2 = $[13]; - } - nodes.push(t2); - } - const t2 = `${verb_0} team memories`; - let t3; - if ($[14] !== t2) { - t3 = {t2}; - $[14] = t2; - $[15] = t3; - } else { - t3 = $[15]; - } - nodes.push(t3); - count++; - } - if (tmWriteCount > 0) { - const verb_1 = isActiveGroup ? count === 0 ? "Writing" : "writing" : count === 0 ? "Wrote" : "wrote"; - if (count > 0) { - let t2; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t2 = , ; - $[16] = t2; - } else { - t2 = $[16]; - } - nodes.push(t2); - } - let t2; - if ($[17] !== tmWriteCount) { - t2 = {tmWriteCount}; - $[17] = tmWriteCount; - $[18] = t2; - } else { - t2 = $[18]; - } - const t3 = tmWriteCount === 1 ? "memory" : "memories"; - let t4; - if ($[19] !== t2 || $[20] !== t3 || $[21] !== verb_1) { - t4 = {verb_1} {t2} team{" "}{t3}; - $[19] = t2; - $[20] = t3; - $[21] = verb_1; - $[22] = t4; - } else { - t4 = $[22]; - } - nodes.push(t4); - } - t1 = <>{nodes}; - $[0] = hasPrecedingParts; - $[1] = isActiveGroup; - $[2] = tmReadCount; - $[3] = tmSearchCount; - $[4] = tmWriteCount; - $[5] = t1; - } else { - t1 = $[5]; + nodes.push( + + {verb} {tmReadCount} team{' '} + {tmReadCount === 1 ? 'memory' : 'memories'} + , + ) + count++ } - return t1; + + if (tmSearchCount > 0) { + const verb = isActiveGroup + ? count === 0 + ? 'Searching' + : 'searching' + : count === 0 + ? 'Searched' + : 'searched' + if (count > 0) { + nodes.push(, ) + } + nodes.push({`${verb} team memories`}) + count++ + } + + if (tmWriteCount > 0) { + const verb = isActiveGroup + ? count === 0 + ? 'Writing' + : 'writing' + : count === 0 + ? 'Wrote' + : 'wrote' + if (count > 0) { + nodes.push(, ) + } + nodes.push( + + {verb} {tmWriteCount} team{' '} + {tmWriteCount === 1 ? 'memory' : 'memories'} + , + ) + } + + return <>{nodes} } diff --git a/src/components/permissions/AskUserQuestionPermissionRequest/AskUserQuestionPermissionRequest.tsx b/src/components/permissions/AskUserQuestionPermissionRequest/AskUserQuestionPermissionRequest.tsx index 81411534c..3768dbd73 100644 --- a/src/components/permissions/AskUserQuestionPermissionRequest/AskUserQuestionPermissionRequest.tsx +++ b/src/components/permissions/AskUserQuestionPermissionRequest/AskUserQuestionPermissionRequest.tsx @@ -1,243 +1,233 @@ -import { c as _c } from "react/compiler-runtime"; -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; +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 // Lines used by chrome around the content area (nav bar, title, footer, help text, etc.) -const CONTENT_CHROME_OVERHEAD = 15; -export function AskUserQuestionPermissionRequest(props) { - const $ = _c(4); - const settings = useSettings(); +const CONTENT_CHROME_OVERHEAD = 15 + +export function AskUserQuestionPermissionRequest( + props: PermissionRequestProps, +): React.ReactNode { + const settings = useSettings() if (settings.syntaxHighlightingDisabled) { - let t0; - if ($[0] !== props) { - t0 = ; - $[0] = props; - $[1] = t0; - } else { - t0 = $[1]; - } - return t0; + return } - let t0; - if ($[2] !== props) { - t0 = }>; - $[2] = props; - $[3] = t0; - } else { - t0 = $[3]; - } - return t0; + return ( + + } + > + + + ) } -function AskUserQuestionWithHighlight(props) { - const $ = _c(4); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = getCliHighlightPromise(); - $[0] = t0; - } else { - t0 = $[0]; - } - const highlight = use(t0); - let t1; - if ($[1] !== highlight || $[2] !== props) { - t1 = ; - $[1] = highlight; - $[2] = props; - $[3] = t1; - } else { - t1 = $[3]; - } - return t1; + +function AskUserQuestionWithHighlight( + props: PermissionRequestProps, +): React.ReactNode { + const highlight = use(getCliHighlightPromise()) + return ( + + ) } -function AskUserQuestionPermissionRequestBody(t0) { - const $ = _c(115); - const { - toolUseConfirm, - onDone, - onReject, - highlight - } = t0; - let t1; - if ($[0] !== toolUseConfirm.input) { - t1 = AskUserQuestionTool.inputSchema.safeParse(toolUseConfirm.input); - $[0] = toolUseConfirm.input; - $[1] = t1; - } else { - t1 = $[1]; - } - const result = t1; - let t2; - if ($[2] !== result.data || $[3] !== result.success) { - t2 = result.success ? result.data.questions || [] : []; - $[2] = result.data; - $[3] = result.success; - $[4] = t2; - } else { - t2 = $[4]; - } - const questions = t2; - const { - rows: terminalRows - } = useTerminalSize(); - const [theme] = useTheme(); - let maxHeight = 0; - let maxWidth = 0; - const maxAllowedHeight = Math.max(MIN_CONTENT_HEIGHT, terminalRows - CONTENT_CHROME_OVERHEAD); - if ($[5] !== highlight || $[6] !== maxAllowedHeight || $[7] !== maxHeight || $[8] !== maxWidth || $[9] !== questions || $[10] !== theme) { + +function AskUserQuestionPermissionRequestBody({ + toolUseConfirm, + onDone, + onReject, + highlight, +}: PermissionRequestProps & { + 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 + // React Compiler bail out on this component, so nothing is auto-memoized — + // `questions` changes identity every render, and the `globalContentHeight` + // 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() + + // 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 + + // Footer (divider + "Chat about this" + optional plan) + help text ≈ 7 lines + 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, + ) + + // 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 + for (const q of questions) { - const hasPreview = q.options.some(_temp); + const hasPreview = q.options.some(opt => opt.preview) + if (hasPreview) { - const maxPreviewContentLines = Math.max(1, maxAllowedHeight - 11); - let maxPreviewBoxHeight = 0; - for (const opt_0 of q.options) { - if (opt_0.preview) { - const rendered = applyMarkdown(opt_0.preview, theme, highlight); - const previewLines = rendered.split("\n"); - const isTruncated = previewLines.length > maxPreviewContentLines; - const displayedLines = isTruncated ? maxPreviewContentLines : previewLines.length; - maxPreviewBoxHeight = Math.max(maxPreviewBoxHeight, displayedLines + (isTruncated ? 1 : 0) + 2); + // 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, + ) + + // 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 + 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 + // Preview box: displayed content + truncation indicator + 2 borders + 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)) } } } - const rightPanelHeight = maxPreviewBoxHeight + 2; - const leftPanelHeight = q.options.length + 2; - const sideByHeight = Math.max(leftPanelHeight, rightPanelHeight); - maxHeight = Math.max(maxHeight, sideByHeight + 7); + // Right panel: preview box + notes (2 lines with margin) + 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) } else { - maxHeight = Math.max(maxHeight, q.options.length + 3 + 7); + // For regular questions: options + "Other" + footer/help + maxHeight = Math.max( + maxHeight, + q.options.length + 3 + FOOTER_HELP_LINES, + ) } } - $[5] = highlight; - $[6] = maxAllowedHeight; - $[7] = maxHeight; - $[8] = maxWidth; - $[9] = questions; - $[10] = theme; - $[11] = maxHeight; - } else { - maxHeight = $[11] as number; + + return { + 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 + + const [pastedContentsByQuestion, setPastedContentsByQuestion] = useState< + Record> + >({}) + const nextPasteIdRef = useRef(0) + + function onImagePaste( + questionText: string, + base64Image: string, + mediaType?: string, + filename?: string, + dimensions?: ImageDimensions, + _sourcePath?: string, + ) { + const pasteId = nextPasteIdRef.current++ + const newContent: PastedContent = { + id: pasteId, + type: 'image', + content: base64Image, + mediaType: mediaType || 'image/png', + filename: filename || 'Pasted image', + dimensions, + } + cacheImagePath(newContent) + void storeImage(newContent) + setPastedContentsByQuestion(prev => ({ + ...prev, + [questionText]: { ...(prev[questionText] ?? {}), [pasteId]: newContent }, + })) } - const t3 = Math.min(Math.max(maxHeight, MIN_CONTENT_HEIGHT), maxAllowedHeight); - const t4 = Math.max(maxWidth, MIN_CONTENT_WIDTH); - let t5; - if ($[12] !== t3 || $[13] !== t4) { - t5 = { - globalContentHeight: t3, - globalContentWidth: t4 - }; - $[12] = t3; - $[13] = t4; - $[14] = t5; - } else { - t5 = $[14]; - } - const { - globalContentHeight, - globalContentWidth - } = t5; - const metadataSource = result.success ? result.data.metadata?.source : undefined; - let t6; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t6 = {}; - $[15] = t6; - } else { - t6 = $[15]; - } - const [pastedContentsByQuestion, setPastedContentsByQuestion] = useState(t6); - const nextPasteIdRef = useRef(0); - let t7; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t7 = function onImagePaste(questionText, base64Image, mediaType, filename, dimensions, _sourcePath) { - nextPasteIdRef.current = nextPasteIdRef.current + 1; - const pasteId = nextPasteIdRef.current; - const newContent = { - id: pasteId, - type: "image" as const, - content: base64Image, - mediaType: mediaType || "image/png", - filename: filename || "Pasted image", - dimensions - }; - cacheImagePath(newContent); - storeImage(newContent); - setPastedContentsByQuestion(prev => ({ - ...prev, - [questionText]: { - ...(prev[questionText] ?? {}), - [pasteId]: newContent - } - })); - }; - $[16] = t7; - } else { - t7 = $[16]; - } - const onImagePaste = t7; - let t8; - if ($[17] === Symbol.for("react.memo_cache_sentinel")) { - t8 = (questionText_0, id) => { - setPastedContentsByQuestion(prev_0 => { - const questionContents = { - ...(prev_0[questionText_0] ?? {}) - }; - delete questionContents[id]; - return { - ...prev_0, - [questionText_0]: questionContents - }; - }); - }; - $[17] = t8; - } else { - t8 = $[17]; - } - const onRemoveImage = t8; - let t9; - if ($[18] !== pastedContentsByQuestion) { - t9 = Object.values(pastedContentsByQuestion).flatMap(_temp2).filter(_temp3); - $[18] = pastedContentsByQuestion; - $[19] = t9; - } else { - t9 = $[19]; - } - const allImageAttachments = t9; - const toolPermissionContextMode = useAppState(_temp4); - const isInPlanMode = toolPermissionContextMode === "plan"; - let t10; - if ($[20] !== isInPlanMode) { - t10 = isInPlanMode ? getPlanFilePath() : undefined; - $[20] = isInPlanMode; - $[21] = t10; - } else { - t10 = $[21]; - } - const planFilePath = t10; - const state = useMultipleChoiceState(); + + const onRemoveImage = useCallback((questionText: string, id: number) => { + setPastedContentsByQuestion(prev => { + 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') + + const toolPermissionContextMode = useAppState( + s => s.toolPermissionContext.mode, + ) + const isInPlanMode = toolPermissionContextMode === 'plan' + const planFilePath = isInPlanMode ? getPlanFilePath() : undefined + + const state = useMultipleChoiceState() const { currentQuestionIndex, answers, @@ -247,398 +237,369 @@ function AskUserQuestionPermissionRequestBody(t0) { prevQuestion, updateQuestionState, setAnswer, - setTextInputMode - } = state; - const currentQuestion = currentQuestionIndex < (questions?.length || 0) ? questions?.[currentQuestionIndex] : null; - const isInSubmitView = currentQuestionIndex === (questions?.length || 0); - let t11; - if ($[22] !== answers || $[23] !== questions) { - t11 = questions?.every(q_0 => q_0?.question && !!answers[q_0.question]) ?? false; - $[22] = answers; - $[23] = questions; - $[24] = t11; - } else { - t11 = $[24]; - } - const allQuestionsAnswered = t11; - const hideSubmitTab = questions.length === 1 && !questions[0]?.multiSelect; - let t12; - if ($[25] !== isInPlanMode || $[26] !== metadataSource || $[27] !== onDone || $[28] !== onReject || $[29] !== questions.length || $[30] !== toolUseConfirm) { - t12 = () => { - if (metadataSource) { - logEvent("tengu_ask_user_question_rejected", { - source: metadataSource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - questionCount: questions.length, - isInPlanMode, - interviewPhaseEnabled: isInPlanMode && isPlanModeInterviewPhaseEnabled() - }); - } - onDone(); - onReject(); - toolUseConfirm.onReject(); - }; - $[25] = isInPlanMode; - $[26] = metadataSource; - $[27] = onDone; - $[28] = onReject; - $[29] = questions.length; - $[30] = toolUseConfirm; - $[31] = t12; - } else { - t12 = $[31]; - } - const handleCancel = t12; - let t13; - if ($[32] !== allImageAttachments || $[33] !== answers || $[34] !== isInPlanMode || $[35] !== metadataSource || $[36] !== onDone || $[37] !== questions || $[38] !== toolUseConfirm) { - t13 = async () => { - const questionsWithAnswers = questions.map(q_1 => { - const answer = answers[q_1.question]; + setTextInputMode, + } = state + + 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 + + // 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 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, + questionCount: questions.length, + isInPlanMode, + interviewPhaseEnabled: + isInPlanMode && isPlanModeInterviewPhaseEnabled(), + }) + } + 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] if (answer) { - return `- "${q_1.question}"\n Answer: ${answer}`; + return `- "${q.question}"\n Answer: ${answer}` } - return `- "${q_1.question}"\n (No answer provided)`; - }).join("\n"); - const feedback = `The user wants to clarify these questions. + return `- "${q.question}"\n (No answer provided)` + }) + .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}`; - if (metadataSource) { - logEvent("tengu_ask_user_question_respond_to_claude", { - source: metadataSource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - questionCount: questions.length, - isInPlanMode, - interviewPhaseEnabled: isInPlanMode && isPlanModeInterviewPhaseEnabled() - }); - } - const imageBlocks = await convertImagesToBlocks(allImageAttachments); - onDone(); - toolUseConfirm.onReject(feedback, imageBlocks && imageBlocks.length > 0 ? imageBlocks : undefined); - }; - $[32] = allImageAttachments; - $[33] = answers; - $[34] = isInPlanMode; - $[35] = metadataSource; - $[36] = onDone; - $[37] = questions; - $[38] = toolUseConfirm; - $[39] = t13; - } else { - t13 = $[39]; - } - const handleRespondToClaude = t13; - let t14; - if ($[40] !== allImageAttachments || $[41] !== answers || $[42] !== isInPlanMode || $[43] !== metadataSource || $[44] !== onDone || $[45] !== questions || $[46] !== toolUseConfirm) { - t14 = async () => { - const questionsWithAnswers_0 = questions.map(q_2 => { - const answer_0 = answers[q_2.question]; - if (answer_0) { - return `- "${q_2.question}"\n Answer: ${answer_0}`; + 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, + questionCount: questions.length, + isInPlanMode, + interviewPhaseEnabled: + isInPlanMode && isPlanModeInterviewPhaseEnabled(), + }) + } + + const imageBlocks = await convertImagesToBlocks(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] + if (answer) { + return `- "${q.question}"\n Answer: ${answer}` } - return `- "${q_2.question}"\n (No answer provided)`; - }).join("\n"); - const feedback_0 = `The user has indicated they have provided enough answers for the plan interview. + return `- "${q.question}"\n (No answer provided)` + }) + .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_0}`; +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, + questionCount: questions.length, + isInPlanMode, + interviewPhaseEnabled: + isInPlanMode && isPlanModeInterviewPhaseEnabled(), + }) + } + + const imageBlocks = await convertImagesToBlocks(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_finish_plan_interview", { - source: metadataSource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - questionCount: questions.length, - isInPlanMode, - interviewPhaseEnabled: isInPlanMode && isPlanModeInterviewPhaseEnabled() - }); - } - const imageBlocks_0 = await convertImagesToBlocks(allImageAttachments); - onDone(); - toolUseConfirm.onReject(feedback_0, imageBlocks_0 && imageBlocks_0.length > 0 ? imageBlocks_0 : undefined); - }; - $[40] = allImageAttachments; - $[41] = answers; - $[42] = isInPlanMode; - $[43] = metadataSource; - $[44] = onDone; - $[45] = questions; - $[46] = toolUseConfirm; - $[47] = t14; - } else { - t14 = $[47]; - } - const handleFinishPlanInterview = t14; - let t15; - if ($[48] !== allImageAttachments || $[49] !== isInPlanMode || $[50] !== metadataSource || $[51] !== onDone || $[52] !== questionStates || $[53] !== questions || $[54] !== toolUseConfirm) { - t15 = async answersToSubmit => { - if (metadataSource) { - logEvent("tengu_ask_user_question_accepted", { - source: metadataSource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent('tengu_ask_user_question_accepted', { + source: + metadataSource as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, questionCount: questions.length, answerCount: Object.keys(answersToSubmit).length, isInPlanMode, - interviewPhaseEnabled: isInPlanMode && isPlanModeInterviewPhaseEnabled() - }); - } - const annotations = {}; - for (const q_3 of questions) { - const answer_1 = answersToSubmit[q_3.question]; - const notes = questionStates[q_3.question]?.textInputValue; - const selectedOption = answer_1 ? q_3.options.find(opt_1 => opt_1.label === answer_1) : undefined; - const preview = selectedOption?.preview; - if (preview || notes?.trim()) { - annotations[q_3.question] = { - ...(preview && { - preview - }), - ...(notes?.trim() && { - notes: notes.trim() - }) - }; - } - } - const updatedInput = { - ...toolUseConfirm.input, - answers: answersToSubmit, - ...(Object.keys(annotations).length > 0 && { - annotations + interviewPhaseEnabled: + isInPlanMode && isPlanModeInterviewPhaseEnabled(), }) - }; - const contentBlocks = await convertImagesToBlocks(allImageAttachments); - onDone(); - toolUseConfirm.onAllow(updatedInput, [], undefined, contentBlocks && contentBlocks.length > 0 ? contentBlocks : undefined); - }; - $[48] = allImageAttachments; - $[49] = isInPlanMode; - $[50] = metadataSource; - $[51] = onDone; - $[52] = questionStates; - $[53] = questions; - $[54] = toolUseConfirm; - $[55] = t15; - } else { - t15 = $[55]; - } - const submitAnswers = t15; - let t16; - if ($[56] !== answers || $[57] !== pastedContentsByQuestion || $[58] !== questions.length || $[59] !== setAnswer || $[60] !== submitAnswers) { - t16 = (questionText_1, label, textInput, t17) => { - const shouldAdvance = t17 === undefined ? true : t17; - let answer_2; - const isMultiSelect = Array.isArray(label); - if (isMultiSelect) { - answer_2 = label.join(", "); - } else { - if (textInput) { - const questionImages = Object.values(pastedContentsByQuestion[questionText_1] ?? {}).filter(_temp5); - answer_2 = questionImages.length > 0 ? `${textInput} (Image attached)` : textInput; - } else { - if (label === "__other__") { - const questionImages_0 = Object.values(pastedContentsByQuestion[questionText_1] ?? {}).filter(_temp6); - answer_2 = questionImages_0.length > 0 ? "(Image attached)" : label; - } else { - answer_2 = label; + } + // Build annotations from questionStates (e.g., selected preview, user notes) + const annotations: Record = + {} + for (const q of questions) { + 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 + if (preview || notes?.trim()) { + annotations[q.question] = { + ...(preview && { preview }), + ...(notes?.trim() && { notes: notes.trim() }), } } } - const isSingleQuestion = questions.length === 1; + + const updatedInput = { + ...toolUseConfirm.input, + answers: answersToSubmit, + ...(Object.keys(annotations).length > 0 && { annotations }), + } + + const contentBlocks = await convertImagesToBlocks(allImageAttachments) + + onDone() + toolUseConfirm.onAllow( + updatedInput, + [], + undefined, + contentBlocks && contentBlocks.length > 0 ? contentBlocks : undefined, + ) + }, + [ + 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) + if (isMultiSelect) { + 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 + } 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 + } else { + answer = label + } + } + + // For single-select with only one question, auto-submit instead of showing review screen + const isSingleQuestion = questions.length === 1 if (!isMultiSelect && isSingleQuestion && shouldAdvance) { const updatedAnswers = { ...answers, - [questionText_1]: answer_2 - }; - submitAnswers(updatedAnswers).catch(logError); - return; + [questionText]: answer, + } + void submitAnswers(updatedAnswers).catch(logError) + return } - setAnswer(questionText_1, answer_2, shouldAdvance); - }; - $[56] = answers; - $[57] = pastedContentsByQuestion; - $[58] = questions.length; - $[59] = setAnswer; - $[60] = submitAnswers; - $[61] = t16; - } else { - t16 = $[61]; + + setAnswer(questionText, answer, shouldAdvance) + }, + [ + setAnswer, + questions.length, + answers, + submitAnswers, + pastedContentsByQuestion, + ], + ) + + function handleFinalResponse(value: 'submit' | 'cancel'): void { + if (value === 'cancel') { + handleCancel() + return + } + + if (value === 'submit') { + void submitAnswers(answers).catch(logError) + } } - const handleQuestionAnswer = t16; - let t17; - if ($[62] !== answers || $[63] !== handleCancel || $[64] !== submitAnswers) { - t17 = function handleFinalResponse(value) { - if (value === "cancel") { - handleCancel(); - return; - } - if (value === "submit") { - submitAnswers(answers).catch(logError); - } - }; - $[62] = answers; - $[63] = handleCancel; - $[64] = submitAnswers; - $[65] = t17; - } else { - t17 = $[65]; - } - const handleFinalResponse = t17; - const maxIndex = hideSubmitTab ? (questions?.length || 1) - 1 : questions?.length || 0; - let t18; - if ($[66] !== currentQuestionIndex || $[67] !== prevQuestion) { - t18 = () => { - if (currentQuestionIndex > 0) { - prevQuestion(); - } - }; - $[66] = currentQuestionIndex; - $[67] = prevQuestion; - $[68] = t18; - } else { - t18 = $[68]; - } - const handleTabPrev = t18; - let t19; - if ($[69] !== currentQuestionIndex || $[70] !== maxIndex || $[71] !== nextQuestion) { - t19 = () => { - if (currentQuestionIndex < maxIndex) { - nextQuestion(); - } - }; - $[69] = currentQuestionIndex; - $[70] = maxIndex; - $[71] = nextQuestion; - $[72] = t19; - } else { - t19 = $[72]; - } - const handleTabNext = t19; - let t20; - if ($[73] !== handleTabNext || $[74] !== handleTabPrev) { - t20 = { - "tabs:previous": handleTabPrev, - "tabs:next": handleTabNext - }; - $[73] = handleTabNext; - $[74] = handleTabPrev; - $[75] = t20; - } else { - t20 = $[75]; - } - const t21 = !(isInTextInput && !isInSubmitView); - let t22; - if ($[76] !== t21) { - t22 = { - context: "Tabs", - isActive: t21 - }; - $[76] = t21; - $[77] = t22; - } else { - t22 = $[77]; - } - useKeybindings(t20, t22); + + // When submit tab is hidden, don't allow navigating past the last question + const maxIndex = hideSubmitTab + ? (questions?.length || 1) - 1 + : questions?.length || 0 + + // Bounded navigation callbacks for question tabs + const handleTabPrev = useCallback(() => { + if (currentQuestionIndex > 0) { + prevQuestion() + } + }, [currentQuestionIndex, prevQuestion]) + + const handleTabNext = useCallback(() => { + if (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 + // to tabs:next/tabs:previous and may stopImmediatePropagation before useInput fires. + // Child components (e.g., PreviewQuestionView) also register their own tabs:next/tabs:previous + // keybindings to ensure reliable handling regardless of listener ordering. + useKeybindings( + { + 'tabs:previous': handleTabPrev, + 'tabs:next': handleTabNext, + }, + { context: 'Tabs', isActive: !(isInTextInput && !isInSubmitView) }, + ) + if (currentQuestion) { - let t23; - if ($[78] !== currentQuestion.question) { - t23 = (base64, mediaType_0, filename_0, dims, path) => onImagePaste(currentQuestion.question, base64, mediaType_0, filename_0, dims, path); - $[78] = currentQuestion.question; - $[79] = t23; - } else { - t23 = $[79]; - } - let t24; - if ($[80] !== currentQuestion.question || $[81] !== pastedContentsByQuestion) { - t24 = pastedContentsByQuestion[currentQuestion.question] ?? {}; - $[80] = currentQuestion.question; - $[81] = pastedContentsByQuestion; - $[82] = t24; - } else { - t24 = $[82]; - } - let t25; - if ($[83] !== currentQuestion.question) { - t25 = id_0 => onRemoveImage(currentQuestion.question, id_0); - $[83] = currentQuestion.question; - $[84] = t25; - } else { - t25 = $[84]; - } - let t26; - if ($[85] !== answers || $[86] !== currentQuestion || $[87] !== currentQuestionIndex || $[88] !== globalContentHeight || $[89] !== globalContentWidth || $[90] !== handleCancel || $[91] !== handleFinishPlanInterview || $[92] !== handleQuestionAnswer || $[93] !== handleRespondToClaude || $[94] !== handleTabNext || $[95] !== handleTabPrev || $[96] !== hideSubmitTab || $[97] !== nextQuestion || $[98] !== planFilePath || $[99] !== questionStates || $[100] !== questions || $[101] !== setTextInputMode || $[102] !== t23 || $[103] !== t24 || $[104] !== t25 || $[105] !== updateQuestionState) { - t26 = <>; - $[85] = answers; - $[86] = currentQuestion; - $[87] = currentQuestionIndex; - $[88] = globalContentHeight; - $[89] = globalContentWidth; - $[90] = handleCancel; - $[91] = handleFinishPlanInterview; - $[92] = handleQuestionAnswer; - $[93] = handleRespondToClaude; - $[94] = handleTabNext; - $[95] = handleTabPrev; - $[96] = hideSubmitTab; - $[97] = nextQuestion; - $[98] = planFilePath; - $[99] = questionStates; - $[100] = questions; - $[101] = setTextInputMode; - $[102] = t23; - $[103] = t24; - $[104] = t25; - $[105] = updateQuestionState; - $[106] = t26; - } else { - t26 = $[106]; - } - return t26; + return ( + <> + + onImagePaste( + currentQuestion.question, + base64, + mediaType, + filename, + dims, + path, + ) + } + pastedContents={ + pastedContentsByQuestion[currentQuestion.question] ?? {} + } + onRemoveImage={id => onRemoveImage(currentQuestion.question, id)} + /> + + ) } + if (isInSubmitView) { - let t23; - if ($[107] !== allQuestionsAnswered || $[108] !== answers || $[109] !== currentQuestionIndex || $[110] !== globalContentHeight || $[111] !== handleFinalResponse || $[112] !== questions || $[113] !== toolUseConfirm.permissionResult) { - t23 = <>; - $[107] = allQuestionsAnswered; - $[108] = answers; - $[109] = currentQuestionIndex; - $[110] = globalContentHeight; - $[111] = handleFinalResponse; - $[112] = questions; - $[113] = toolUseConfirm.permissionResult; - $[114] = t23; - } else { - t23 = $[114]; - } - return t23; + return ( + <> + + + ) } - return null; + + // This should never be reached + return null } -function _temp6(c_1) { - return c_1.type === "image"; -} -function _temp5(c_0) { - return c_0.type === "image"; -} -function _temp4(s) { - return s.toolPermissionContext.mode; -} -function _temp3(c) { - return c.type === "image"; -} -function _temp2(contents) { - return Object.values(contents); -} -function _temp(opt) { - return opt.preview; -} -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'], - data: img.content + +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'], + 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 c48f4e4d9..7b4fd6149 100644 --- a/src/components/permissions/AskUserQuestionPermissionRequest/PreviewBox.tsx +++ b/src/components/permissions/AskUserQuestionPermissionRequest/PreviewBox.tsx @@ -1,25 +1,29 @@ -import { c as _c } from "react/compiler-runtime"; -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: '┌', topRight: '┐', @@ -28,201 +32,127 @@ const BOX_CHARS = { horizontal: '─', vertical: '│', teeLeft: '├', - teeRight: '┤' -}; + teeRight: '┤', +} /** * A bordered monospace box for displaying preview content. * Truncates content that exceeds maxLines with an indicator. * The parent component should pass maxLines based on its available height budget. */ -export function PreviewBox(props) { - const $ = _c(4); - const settings = useSettings(); +export function PreviewBox(props: PreviewBoxProps): React.ReactNode { + const settings = useSettings() if (settings.syntaxHighlightingDisabled) { - let t0; - if ($[0] !== props) { - t0 = ; - $[0] = props; - $[1] = t0; - } else { - t0 = $[1]; - } - return t0; + return } - let t0; - if ($[2] !== props) { - t0 = }>; - $[2] = props; - $[3] = t0; - } else { - t0 = $[3]; - } - return t0; + return ( + }> + + + ) } -function PreviewBoxWithHighlight(props) { - const $ = _c(4); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = getCliHighlightPromise(); - $[0] = t0; - } else { - t0 = $[0]; - } - const highlight = use(t0); - let t1; - if ($[1] !== highlight || $[2] !== props) { - t1 = ; - $[1] = highlight; - $[2] = props; - $[3] = t1; - } else { - t1 = $[3]; - } - return t1; + +function PreviewBoxWithHighlight(props: PreviewBoxProps): React.ReactNode { + const highlight = use(getCliHighlightPromise()) + return } -function PreviewBoxBody(t0) { - const $ = _c(34); - const { - content, - maxLines, - minHeight, - minWidth: t1, - maxWidth, - highlight - } = t0; - const minWidth = t1 === undefined ? 40 : t1; - const { - columns: terminalWidth - } = useTerminalSize(); - const [theme] = useTheme(); - const effectiveMaxWidth = maxWidth ?? terminalWidth - 4; - const effectiveMaxLines = maxLines ?? 20; - let t2; - if ($[0] !== content || $[1] !== highlight || $[2] !== theme) { - t2 = applyMarkdown(content, theme, highlight); - $[0] = content; - $[1] = highlight; - $[2] = theme; - $[3] = t2; - } else { - t2 = $[3]; - } - const rendered = t2; - let T0; - let bottomBorder; - let t3; - let t4; - let t5; - let truncationBar; - if ($[4] !== effectiveMaxLines || $[5] !== effectiveMaxWidth || $[6] !== minHeight || $[7] !== minWidth || $[8] !== rendered) { - const contentLines = rendered.split("\n"); - const isTruncated = contentLines.length > effectiveMaxLines; - const truncatedLines = isTruncated ? contentLines.slice(0, effectiveMaxLines) : contentLines; - 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 contentWidth = Math.max(minWidth, ...lines.map(_temp)); - const boxWidth = Math.min(contentWidth + 4, effectiveMaxWidth); - const innerWidth = boxWidth - 4; - let t6; - if ($[15] !== boxWidth) { - t6 = BOX_CHARS.horizontal.repeat(boxWidth - 2); - $[15] = boxWidth; - $[16] = t6; - } else { - t6 = $[16]; - } - const topBorder = `${BOX_CHARS.topLeft}${t6}${BOX_CHARS.topRight}`; - let t7; - if ($[17] !== boxWidth) { - t7 = BOX_CHARS.horizontal.repeat(boxWidth - 2); - $[17] = boxWidth; - $[18] = t7; - } else { - t7 = $[18]; - } - bottomBorder = `${BOX_CHARS.bottomLeft}${t7}${BOX_CHARS.bottomRight}`; - 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}`; - })() : null; - T0 = Box; - t3 = "column"; - if ($[19] !== topBorder) { - t4 = {topBorder}; - $[19] = topBorder; - $[20] = t4; - } else { - t4 = $[20]; - } - let t8; - if ($[21] !== innerWidth) { - t8 = (line_0, index) => { - const lineWidth = stringWidth(line_0); - const displayLine = lineWidth > innerWidth ? sliceAnsi(line_0, 0, innerWidth) : line_0; - const padding = " ".repeat(Math.max(0, innerWidth - stringWidth(displayLine))); - return {BOX_CHARS.vertical} {displayLine}{padding} {BOX_CHARS.vertical}; - }; - $[21] = innerWidth; - $[22] = t8; - } else { - t8 = $[22]; - } - t5 = lines.map(t8); - $[4] = effectiveMaxLines; - $[5] = effectiveMaxWidth; - $[6] = minHeight; - $[7] = minWidth; - $[8] = rendered; - $[9] = T0; - $[10] = bottomBorder; - $[11] = t3; - $[12] = t4; - $[13] = t5; - $[14] = truncationBar; - } else { - T0 = $[9]; - bottomBorder = $[10]; - t3 = $[11]; - t4 = $[12]; - t5 = $[13]; - truncationBar = $[14]; - } - let t6; - if ($[23] !== truncationBar) { - t6 = truncationBar && {truncationBar}; - $[23] = truncationBar; - $[24] = t6; - } else { - t6 = $[24]; - } - let t7; - if ($[25] !== bottomBorder) { - t7 = {bottomBorder}; - $[25] = bottomBorder; - $[26] = t7; - } else { - t7 = $[26]; - } - let t8; - if ($[27] !== T0 || $[28] !== t3 || $[29] !== t4 || $[30] !== t5 || $[31] !== t6 || $[32] !== t7) { - t8 = {t4}{t5}{t6}{t7}; - $[27] = T0; - $[28] = t3; - $[29] = t4; - $[30] = t5; - $[31] = t6; - $[32] = t7; - $[33] = t8; - } else { - t8 = $[33]; - } - return t8; -} -function _temp(line) { - return stringWidth(line); + +function PreviewBoxBody({ + content, + maxLines, + minHeight, + minWidth = 40, + maxWidth, + highlight, +}: PreviewBoxProps & { highlight: CliHighlight | null }): React.ReactNode { + const { columns: terminalWidth } = useTerminalSize() + const [theme] = useTheme() + const effectiveMaxWidth = maxWidth ?? terminalWidth - 4 + + // Use provided maxLines, or a reasonable default + 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 + + // Truncate to effectiveMaxLines + 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 + + // Calculate content width (max visual line width, handling unicode/emoji/CJK) + 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 + + // Render top border + 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}` + + // 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}` + })() + : null + + return ( + + {topBorder} + + {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)), + ) + + return ( + + {BOX_CHARS.vertical} + {displayLine} + + {padding} {BOX_CHARS.vertical} + + + ) + })} + + {truncationBar && {truncationBar}} + + {bottomBorder} + + ) } diff --git a/src/components/permissions/AskUserQuestionPermissionRequest/PreviewQuestionView.tsx b/src/components/permissions/AskUserQuestionPermissionRequest/PreviewQuestionView.tsx index f00c9b4a2..78289da5f 100644 --- a/src/components/permissions/AskUserQuestionPermissionRequest/PreviewQuestionView.tsx +++ b/src/components/permissions/AskUserQuestionPermissionRequest/PreviewQuestionView.tsx @@ -1,38 +1,51 @@ -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. @@ -54,188 +67,235 @@ export function PreviewQuestionView({ onTabPrev, onTabNext, onRespondToClaude, - onFinishPlanInterview + 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 editor = getExternalEditor(); - const editorName = editor ? toIDEDisplayName(editor) : null; - const questionText = question.question; - const questionState = questionStates[questionText]; + 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 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 handleSelectOption = useCallback((index: number) => { - const option = allOptions[index]; - if (!option) return; - setFocusedIndex(index); - onUpdateQuestionState(questionText, { - selectedValue: option.label - }, false); - onAnswer(questionText, option.label); - }, [allOptions, questionText, onUpdateQuestionState, onAnswer]); - const handleNavigate = useCallback((direction: 'up' | 'down' | number) => { - if (isInNotesInput) return; - let newIndex: number; - if (typeof direction === 'number') { - newIndex = direction; - } else if (direction === 'up') { - newIndex = focusedIndex > 0 ? focusedIndex - 1 : focusedIndex; - } else { - newIndex = focusedIndex < allOptions.length - 1 ? focusedIndex + 1 : focusedIndex; - } - if (newIndex >= 0 && newIndex < allOptions.length) { - setFocusedIndex(newIndex); - } - }, [focusedIndex, allOptions.length, isInNotesInput]); + + 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 + + setFocusedIndex(index) + onUpdateQuestionState( + questionText, + { selectedValue: option.label }, + false, + ) + + onAnswer(questionText, option.label) + }, + [allOptions, questionText, onUpdateQuestionState, onAnswer], + ) + + const handleNavigate = useCallback( + (direction: 'up' | 'down' | number) => { + if (isInNotesInput) return + + let newIndex: number + if (typeof direction === 'number') { + newIndex = direction + } else if (direction === 'up') { + newIndex = focusedIndex > 0 ? focusedIndex - 1 : focusedIndex + } else { + newIndex = + focusedIndex < allOptions.length - 1 ? focusedIndex + 1 : focusedIndex + } + + if (newIndex >= 0 && newIndex < allOptions.length) { + 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); - if (result.content !== null && result.content !== currentValue) { - onUpdateQuestionState(questionText, { - textInputValue: result.content - }, false); - } - }, { - context: 'Chat', - isActive: isInNotesInput && !!editor - }); + useKeybinding( + 'chat:externalEditor', + async () => { + const currentValue = questionState?.textInputValue || '' + const result = await editPromptInEditor(currentValue) + if (result.content !== null && result.content !== currentValue) { + 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 // handlers register first on the event emitter and fire before parent handlers. // Without this, the parent's useKeybindings may not fire reliably depending on // listener ordering in the event emitter. - useKeybindings({ - 'tabs:previous': () => onTabPrev?.(), - 'tabs:next': () => onTabNext?.() - }, { - context: 'Tabs', - isActive: !isInNotesInput && !isFooterFocused - }); + useKeybindings( + { + 'tabs:previous': () => onTabPrev?.(), + '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. - const handleKeyDown = useCallback((e: KeyboardEvent) => { - if (isFooterFocused) { - if (e.key === 'up' || e.ctrl && e.key === 'p') { - e.preventDefault(); - if (footerIndex === 0) { - handleUpFromFooter(); - } else { - setFooterIndex(0); + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (isFooterFocused) { + if (e.key === 'up' || (e.ctrl && e.key === 'p')) { + e.preventDefault() + if (footerIndex === 0) { + handleUpFromFooter() + } else { + setFooterIndex(0) + } + return } - return; - } - if (e.key === 'down' || e.ctrl && e.key === 'n') { - e.preventDefault(); - if (isInPlanMode && footerIndex === 0) { - setFooterIndex(1); - } - return; - } - if (e.key === 'return') { - e.preventDefault(); - if (footerIndex === 0) { - onRespondToClaude(); - } else { - onFinishPlanInterview(); - } - return; - } - if (e.key === 'escape') { - e.preventDefault(); - onCancel(); - } - return; - } - if (isInNotesInput) { - // In notes input mode, handle escape to exit back to option navigation - if (e.key === 'escape') { - e.preventDefault(); - handleNotesExit(); - } - return; - } - // Handle option navigation (vertical) - if (e.key === 'up' || e.ctrl && e.key === 'p') { - e.preventDefault(); - if (focusedIndex > 0) { - handleNavigate('up'); + if (e.key === 'down' || (e.ctrl && e.key === 'n')) { + e.preventDefault() + if (isInPlanMode && footerIndex === 0) { + setFooterIndex(1) + } + return + } + + if (e.key === 'return') { + e.preventDefault() + if (footerIndex === 0) { + onRespondToClaude() + } else { + onFinishPlanInterview() + } + return + } + + if (e.key === 'escape') { + e.preventDefault() + onCancel() + } + return } - } else if (e.key === 'down' || e.ctrl && e.key === 'n') { - e.preventDefault(); - if (focusedIndex === allOptions.length - 1) { - // At bottom of options, go to footer - handleDownFromPreview(); - } else { - handleNavigate('down'); + + if (isInNotesInput) { + // In notes input mode, handle escape to exit back to option navigation + if (e.key === 'escape') { + e.preventDefault() + handleNotesExit() + } + return } - } else if (e.key === 'return') { - 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); - } else if (e.key === 'escape') { - e.preventDefault(); - onCancel(); - } else if (e.key.length === 1 && e.key >= '1' && e.key <= '9') { - e.preventDefault(); - const idx_0 = parseInt(e.key, 10) - 1; - if (idx_0 < allOptions.length) { - handleNavigate(idx_0); + + // Handle option navigation (vertical) + if (e.key === 'up' || (e.ctrl && e.key === 'p')) { + e.preventDefault() + if (focusedIndex > 0) { + handleNavigate('up') + } + } else if (e.key === 'down' || (e.ctrl && e.key === 'n')) { + e.preventDefault() + if (focusedIndex === allOptions.length - 1) { + // At bottom of options, go to footer + handleDownFromPreview() + } else { + handleNavigate('down') + } + } else if (e.key === 'return') { + 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) + } else if (e.key === 'escape') { + e.preventDefault() + onCancel() + } else if (e.key.length === 1 && e.key >= '1' && e.key <= '9') { + e.preventDefault() + const idx = parseInt(e.key, 10) - 1 + if (idx < allOptions.length) { + handleNavigate(idx) + } } - } - }, [isFooterFocused, footerIndex, isInPlanMode, isInNotesInput, focusedIndex, allOptions.length, handleUpFromFooter, handleDownFromPreview, handleNavigate, handleSelectOption, handleNotesExit, onRespondToClaude, onFinishPlanInterview, onCancel, onTextInputFocus]); - const previewContent = focusedOption?.preview || null; + }, + [ + isFooterFocused, + footerIndex, + isInPlanMode, + isInNotesInput, + focusedIndex, + allOptions.length, + handleUpFromFooter, + handleDownFromPreview, + handleNavigate, + handleSelectOption, + handleNotesExit, + onRespondToClaude, + onFinishPlanInterview, + onCancel, + onTextInputFocus, + ], + ) + + 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 @@ -245,19 +305,34 @@ 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 + return minContentHeight + ? Math.max(1, minContentHeight - PREVIEW_OVERHEAD) + : undefined + }, [minContentHeight]) + + return ( + - + @@ -265,33 +340,71 @@ export function PreviewQuestionView({ {/* Left panel: vertical option list */} - {allOptions.map((option_0, index_0) => { - const isFocused = focusedIndex === index_0; - const isSelected = selectedValue === option_0.label; - return - {isFocused ? {figures.pointer} : } - {index_0 + 1}. - + {allOptions.map((option, index) => { + const isFocused = focusedIndex === index + const isSelected = selectedValue === option.label + + return ( + + {isFocused ? ( + {figures.pointer} + ) : ( + + )} + {index + 1}. + {' '} - {option_0.label} + {option.label} {isSelected && {figures.tick}} - ; - })} + + ) + })} {/* Right panel: preview + notes */} - + Notes: - {isInNotesInput ? { - onUpdateQuestionState(questionText, { - textInputValue: value - }, false); - }} onSubmit={handleNotesExit} onExit={handleNotesExit} focus={true} showCursor={true} columns={60} cursorOffset={cursorOffset} onChangeCursorOffset={setCursorOffset} /> : + {isInNotesInput ? ( + { + onUpdateQuestionState( + questionText, + { textInputValue: value }, + false, + ) + }} + onSubmit={handleNotesExit} + onExit={handleNotesExit} + focus={true} + showCursor={true} + columns={60} + cursorOffset={cursorOffset} + onChangeCursorOffset={setCursorOffset} + /> + ) : ( + {notesValue || 'press n to add notes'} - } + + )} @@ -300,28 +413,53 @@ export function PreviewQuestionView({ - {isFooterFocused && footerIndex === 0 ? {figures.pointer} : } - + {isFooterFocused && footerIndex === 0 ? ( + {figures.pointer} + ) : ( + + )} + Chat about this - {isInPlanMode && - {isFooterFocused && footerIndex === 1 ? {figures.pointer} : } - + {isInPlanMode && ( + + {isFooterFocused && footerIndex === 1 ? ( + {figures.pointer} + ) : ( + + )} + Skip interview and plan immediately - } + + )} 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}}{' '} + {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 d6094492d..3440e9daf 100644 --- a/src/components/permissions/AskUserQuestionPermissionRequest/QuestionNavigationBar.tsx +++ b/src/components/permissions/AskUserQuestionPermissionRequest/QuestionNavigationBar.tsx @@ -1,177 +1,151 @@ -import { c as _c } from "react/compiler-runtime"; -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; -}; -export function QuestionNavigationBar(t0) { - const $ = _c(39); - const { - questions, - currentQuestionIndex, - answers, - hideSubmitTab: t1 - } = t0; - const hideSubmitTab = t1 === undefined ? false : t1; - const { - columns - } = useTerminalSize(); - let t2; - if ($[0] !== columns || $[1] !== currentQuestionIndex || $[2] !== hideSubmitTab || $[3] !== questions) { - bb0: { - const submitText = hideSubmitTab ? "" : ` ${figures.tick} Submit `; - const fixedWidth = stringWidth("\u2190 ") + stringWidth(" \u2192") + stringWidth(submitText); - const availableForTabs = columns - fixedWidth; - if (availableForTabs <= 0) { - let t3; - if ($[5] !== currentQuestionIndex || $[6] !== questions) { - let t4; - if ($[8] !== currentQuestionIndex) { - t4 = (q, index) => { - const header = q?.header || `Q${index + 1}`; - return index === currentQuestionIndex ? header.slice(0, 3) : ""; - }; - $[8] = currentQuestionIndex; - $[9] = t4; - } else { - t4 = $[9]; - } - t3 = questions.map(t4); - $[5] = currentQuestionIndex; - $[6] = questions; - $[7] = t3; - } else { - t3 = $[7]; - } - t2 = t3; - break bb0; - } - const tabHeaders = questions.map(_temp); - const idealWidths = tabHeaders.map(_temp2); - const totalIdealWidth = idealWidths.reduce(_temp3, 0); - if (totalIdealWidth <= availableForTabs) { - t2 = tabHeaders; - break bb0; - } - const currentHeader = tabHeaders[currentQuestionIndex] || ""; - const currentIdealWidth = 4 + stringWidth(currentHeader); - const currentTabWidth = Math.min(currentIdealWidth, availableForTabs / 2); - const remainingWidth = availableForTabs - currentTabWidth; - const otherTabCount = questions.length - 1; - const widthPerOtherTab = Math.max(6, Math.floor(remainingWidth / Math.max(otherTabCount, 1))); - let t3; - if ($[10] !== currentQuestionIndex || $[11] !== currentTabWidth || $[12] !== widthPerOtherTab) { - t3 = (header_1, index_1) => { - if (index_1 === currentQuestionIndex) { - const maxTextWidth = currentTabWidth - 2 - 2; - return truncateToWidth(header_1, maxTextWidth); - } else { - const maxTextWidth_0 = widthPerOtherTab - 2 - 2; - return truncateToWidth(header_1, maxTextWidth_0); - } - }; - $[10] = currentQuestionIndex; - $[11] = currentTabWidth; - $[12] = widthPerOtherTab; - $[13] = t3; + questions: Question[] + currentQuestionIndex: number + answers: Record + hideSubmitTab?: boolean +} + +export function QuestionNavigationBar({ + questions, + currentQuestionIndex, + answers, + hideSubmitTab = false, +}: Props): React.ReactNode { + 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 fixedWidth = + stringWidth(leftArrow) + stringWidth(rightArrow) + stringWidth(submitText) + + // Available width for all question tabs + 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) : '' + }) + } + + // 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), + ) + + // Calculate total ideal width + const totalIdealWidth = idealWidths.reduce((sum, w) => sum + w, 0) + + // If everything fits, use full headers + if (totalIdealWidth <= availableForTabs) { + return tabHeaders + } + + // Need to truncate - prioritize current tab + 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…" + + // Calculate space for current tab (try to show full text) + 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)), + ) + + return tabHeaders.map((header, index) => { + if (index === currentQuestionIndex) { + // Current tab - show as much as possible + const maxTextWidth = currentTabWidth - checkboxWidth - paddingPerTab + return truncateToWidth(header, maxTextWidth) } else { - t3 = $[13]; + // Other tabs - truncate to fit + const maxTextWidth = widthPerOtherTab - checkboxWidth - paddingPerTab + return truncateToWidth(header, maxTextWidth) } - t2 = tabHeaders.map(t3); - } - $[0] = columns; - $[1] = currentQuestionIndex; - $[2] = hideSubmitTab; - $[3] = questions; - $[4] = t2; - } else { - t2 = $[4]; - } - const tabDisplayTexts = t2; - const hideArrows = questions.length === 1 && hideSubmitTab; - let t3; - if ($[14] !== currentQuestionIndex || $[15] !== hideArrows) { - t3 = !hideArrows && ←{" "}; - $[14] = currentQuestionIndex; - $[15] = hideArrows; - $[16] = t3; - } else { - t3 = $[16]; - } - let t4; - if ($[17] !== answers || $[18] !== currentQuestionIndex || $[19] !== questions || $[20] !== tabDisplayTexts) { - let t5; - if ($[22] !== answers || $[23] !== currentQuestionIndex || $[24] !== tabDisplayTexts) { - t5 = (q_1, index_2) => { - const isSelected = index_2 === currentQuestionIndex; - const isAnswered = q_1?.question && !!answers[q_1.question]; - const checkbox = isAnswered ? figures.checkboxOn : figures.checkboxOff; - const displayText = tabDisplayTexts[index_2] || q_1?.header || `Q${index_2 + 1}`; - return {isSelected ? {" "}{checkbox} {displayText}{" "} : {" "}{checkbox} {displayText}{" "}}; - }; - $[22] = answers; - $[23] = currentQuestionIndex; - $[24] = tabDisplayTexts; - $[25] = t5; - } else { - t5 = $[25]; - } - t4 = questions.map(t5); - $[17] = answers; - $[18] = currentQuestionIndex; - $[19] = questions; - $[20] = tabDisplayTexts; - $[21] = t4; - } else { - t4 = $[21]; - } - let t5; - if ($[26] !== currentQuestionIndex || $[27] !== hideSubmitTab || $[28] !== questions.length) { - t5 = !hideSubmitTab && {currentQuestionIndex === questions.length ? {" "}{figures.tick} Submit{" "} : {figures.tick} Submit }; - $[26] = currentQuestionIndex; - $[27] = hideSubmitTab; - $[28] = questions.length; - $[29] = t5; - } else { - t5 = $[29]; - } - let t6; - if ($[30] !== currentQuestionIndex || $[31] !== hideArrows || $[32] !== questions.length) { - t6 = !hideArrows && {" "}→; - $[30] = currentQuestionIndex; - $[31] = hideArrows; - $[32] = questions.length; - $[33] = t6; - } else { - t6 = $[33]; - } - let t7; - if ($[34] !== t3 || $[35] !== t4 || $[36] !== t5 || $[37] !== t6) { - t7 = {t3}{t4}{t5}{t6}; - $[34] = t3; - $[35] = t4; - $[36] = t5; - $[37] = t6; - $[38] = t7; - } else { - t7 = $[38]; - } - return t7; -} -function _temp3(sum, w) { - return sum + w; -} -function _temp2(header_0) { - return 4 + stringWidth(header_0); -} -function _temp(q_0, index_0) { - return q_0?.header || `Q${index_0 + 1}`; + }) + }, [questions, currentQuestionIndex, columns, hideSubmitTab]) + + const hideArrows = questions.length === 1 && hideSubmitTab + + return ( + + {!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}` + + return ( + + {isSelected ? ( + + {' '} + {checkbox} {displayText}{' '} + + ) : ( + + {' '} + {checkbox} {displayText}{' '} + + )} + + ) + })} + {!hideSubmitTab && ( + + {currentQuestionIndex === questions.length ? ( + + {' '} + {figures.tick} Submit{' '} + + ) : ( + {figures.tick} Submit + )} + + )} + {!hideArrows && ( + + {' '} + → + + )} + + ) } diff --git a/src/components/permissions/AskUserQuestionPermissionRequest/QuestionView.tsx b/src/components/permissions/AskUserQuestionPermissionRequest/QuestionView.tsx index 87be921ac..ef45238ab 100644 --- a/src/components/permissions/AskUserQuestionPermissionRequest/QuestionView.tsx +++ b/src/components/permissions/AskUserQuestionPermissionRequest/QuestionView.tsx @@ -1,464 +1,398 @@ -import { c as _c } from "react/compiler-runtime"; -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; - onImagePaste?: (base64Image: string, mediaType?: string, filename?: string, dimensions?: ImageDimensions, sourcePath?: string) => void; - onRemoveImage?: (id: number) => void; -}; -export function QuestionView(t0) { - const $ = _c(114); - const { - question, - questions, - currentQuestionIndex, - answers, - questionStates, - hideSubmitTab: t1, - planFilePath, - minContentHeight, - minContentWidth, - onUpdateQuestionState, - onAnswer, - onTextInputFocus, - onCancel, - onSubmit, - onTabPrev, - onTabNext, - onRespondToClaude, - onFinishPlanInterview, - onImagePaste, - pastedContents, - onRemoveImage - } = t0; - const hideSubmitTab = t1 === undefined ? false : t1; - const isInPlanMode = useAppState(_temp) === "plan"; - const [isFooterFocused, setIsFooterFocused] = useState(false); - const [footerIndex, setFooterIndex] = useState(0); - const [isOtherFocused, setIsOtherFocused] = useState(false); - let t2; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - const editor = getExternalEditor(); - t2 = editor ? toIDEDisplayName(editor) : null; - $[0] = t2; - } else { - t2 = $[0]; - } - const editorName = t2; - let t3; - if ($[1] !== onTextInputFocus) { - t3 = value => { - const isOther = value === "__other__"; - setIsOtherFocused(isOther); - onTextInputFocus(isOther); - }; - $[1] = onTextInputFocus; - $[2] = t3; - } else { - t3 = $[2]; - } - const handleFocus = t3; - let t4; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t4 = () => { - setIsFooterFocused(true); - }; - $[3] = t4; - } else { - t4 = $[3]; - } - const handleDownFromLastItem = t4; - let t5; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t5 = () => { - setIsFooterFocused(false); - }; - $[4] = t5; - } else { - t5 = $[4]; - } - const handleUpFromFooter = t5; - let t6; - if ($[5] !== footerIndex || $[6] !== isFooterFocused || $[7] !== isInPlanMode || $[8] !== onCancel || $[9] !== onFinishPlanInterview || $[10] !== onRespondToClaude) { - t6 = e => { - if (!isFooterFocused) { - return; - } - if (e.key === "up" || e.ctrl && e.key === "p") { - e.preventDefault(); + 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 +} + +export function QuestionView({ + question, + questions, + currentQuestionIndex, + answers, + questionStates, + hideSubmitTab = false, + planFilePath, + minContentHeight, + minContentWidth, + onUpdateQuestionState, + onAnswer, + onTextInputFocus, + onCancel, + onSubmit, + onTabPrev, + onTabNext, + onRespondToClaude, + onFinishPlanInterview, + onImagePaste, + 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 editor = getExternalEditor() + const editorName = editor ? toIDEDisplayName(editor) : null + + const handleFocus = useCallback( + (value: string) => { + const isOther = value === '__other__' + setIsOtherFocused(isOther) + onTextInputFocus(isOther) + }, + [onTextInputFocus], + ) + + const handleDownFromLastItem = useCallback(() => { + setIsFooterFocused(true) + }, []) + + const handleUpFromFooter = useCallback(() => { + setIsFooterFocused(false) + }, []) + + // Handle keyboard input when footer is focused + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!isFooterFocused) return + + if (e.key === 'up' || (e.ctrl && e.key === 'p')) { + 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(); + + if (e.key === 'down' || (e.ctrl && e.key === 'n')) { + e.preventDefault() if (isInPlanMode && footerIndex === 0) { - setFooterIndex(1); + setFooterIndex(1) } - return; + return } - if (e.key === "return") { - e.preventDefault(); + + if (e.key === 'return') { + e.preventDefault() if (footerIndex === 0) { - onRespondToClaude(); + onRespondToClaude() } else { - onFinishPlanInterview(); + onFinishPlanInterview() } - return; + return } - if (e.key === "escape") { - e.preventDefault(); - onCancel(); + + if (e.key === 'escape') { + e.preventDefault() + onCancel() } - }; - $[5] = footerIndex; - $[6] = isFooterFocused; - $[7] = isInPlanMode; - $[8] = onCancel; - $[9] = onFinishPlanInterview; - $[10] = onRespondToClaude; - $[11] = t6; - } else { - t6 = $[11]; + }, + [ + isFooterFocused, + footerIndex, + isInPlanMode, + handleUpFromFooter, + onRespondToClaude, + onFinishPlanInterview, + onCancel, + ], + ) + + 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 handleOpenEditor = useCallback( + async (currentValue: string, setValue: (value: string) => void) => { + 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) + // Also update the question state for persistence + onUpdateQuestionState( + questionText, + { textInputValue: result.content }, + question.multiSelect ?? false, + ) + } + }, + [questionText, onUpdateQuestionState, question.multiSelect], + ) + + const otherOption: OptionWithDescription = { + type: 'input' as const, + value: '__other__', + label: 'Other', + placeholder: question.multiSelect ? 'Type something' : 'Type something.', + initialValue: questionState?.textInputValue ?? '', + onChange: (value: string) => { + onUpdateQuestionState( + questionText, + { textInputValue: value }, + question.multiSelect ?? false, + ) + }, } - const handleKeyDown = t6; - let handleOpenEditor; - let questionText; - let t7; - if ($[12] !== onUpdateQuestionState || $[13] !== question || $[14] !== questionStates) { - const textOptions = question.options.map(_temp2); - questionText = question.question; - const questionState = questionStates[questionText]; - let t8; - if ($[18] !== onUpdateQuestionState || $[19] !== question.multiSelect || $[20] !== questionText) { - t8 = async (currentValue, setValue) => { - const result = await editPromptInEditor(currentValue); - if (result.content !== null && result.content !== currentValue) { - setValue(result.content); - onUpdateQuestionState(questionText, { - textInputValue: result.content - }, question.multiSelect ?? false); - } - }; - $[18] = onUpdateQuestionState; - $[19] = question.multiSelect; - $[20] = questionText; - $[21] = t8; - } else { - t8 = $[21]; - } - handleOpenEditor = t8; - const t9 = question.multiSelect ? "Type something" : "Type something."; - const t10 = questionState?.textInputValue ?? ""; - let t11; - if ($[22] !== onUpdateQuestionState || $[23] !== question.multiSelect || $[24] !== questionText) { - t11 = value_0 => { - onUpdateQuestionState(questionText, { - textInputValue: value_0 - }, question.multiSelect ?? false); - }; - $[22] = onUpdateQuestionState; - $[23] = question.multiSelect; - $[24] = questionText; - $[25] = t11; - } else { - t11 = $[25]; - } - let t12; - if ($[26] !== t10 || $[27] !== t11 || $[28] !== t9) { - t12 = { - type: "input" as const, - value: "__other__", - label: "Other", - placeholder: t9, - initialValue: t10, - onChange: t11 - }; - $[26] = t10; - $[27] = t11; - $[28] = t9; - $[29] = t12; - } else { - t12 = $[29]; - } - const otherOption = t12; - t7 = [...textOptions, otherOption]; - $[12] = onUpdateQuestionState; - $[13] = question; - $[14] = questionStates; - $[15] = handleOpenEditor; - $[16] = questionText; - $[17] = t7; - } else { - handleOpenEditor = $[15]; - questionText = $[16]; - t7 = $[17]; - } - const options = t7; - const hasAnyPreview = !question.multiSelect && question.options.some(_temp3); + + 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) + + // Delegate to PreviewQuestionView for carousel-style preview mode if (hasAnyPreview) { - let t8; - if ($[30] !== answers || $[31] !== currentQuestionIndex || $[32] !== hideSubmitTab || $[33] !== minContentHeight || $[34] !== minContentWidth || $[35] !== onAnswer || $[36] !== onCancel || $[37] !== onFinishPlanInterview || $[38] !== onRespondToClaude || $[39] !== onTabNext || $[40] !== onTabPrev || $[41] !== onTextInputFocus || $[42] !== onUpdateQuestionState || $[43] !== question || $[44] !== questionStates || $[45] !== questions) { - t8 = ; - $[30] = answers; - $[31] = currentQuestionIndex; - $[32] = hideSubmitTab; - $[33] = minContentHeight; - $[34] = minContentWidth; - $[35] = onAnswer; - $[36] = onCancel; - $[37] = onFinishPlanInterview; - $[38] = onRespondToClaude; - $[39] = onTabNext; - $[40] = onTabPrev; - $[41] = onTextInputFocus; - $[42] = onUpdateQuestionState; - $[43] = question; - $[44] = questionStates; - $[45] = questions; - $[46] = t8; - } else { - t8 = $[46]; - } - return t8; + return ( + + ) } - let t8; - if ($[47] !== isInPlanMode || $[48] !== planFilePath) { - t8 = isInPlanMode && planFilePath && Planning: ; - $[47] = isInPlanMode; - $[48] = planFilePath; - $[49] = t8; - } else { - t8 = $[49]; - } - let t9; - if ($[50] === Symbol.for("react.memo_cache_sentinel")) { - t9 = ; - $[50] = t9; - } else { - t9 = $[50]; - } - let t10; - if ($[51] !== answers || $[52] !== currentQuestionIndex || $[53] !== hideSubmitTab || $[54] !== questions) { - t10 = ; - $[51] = answers; - $[52] = currentQuestionIndex; - $[53] = hideSubmitTab; - $[54] = questions; - $[55] = t10; - } else { - t10 = $[55]; - } - let t11; - if ($[56] !== question.question) { - t11 = ; - $[56] = question.question; - $[57] = t11; - } else { - t11 = $[57]; - } - let t12; - if ($[58] !== currentQuestionIndex || $[59] !== handleFocus || $[60] !== handleOpenEditor || $[61] !== isFooterFocused || $[62] !== onAnswer || $[63] !== onCancel || $[64] !== onImagePaste || $[65] !== onRemoveImage || $[66] !== onSubmit || $[67] !== onUpdateQuestionState || $[68] !== options || $[69] !== pastedContents || $[70] !== question.multiSelect || $[71] !== question.question || $[72] !== questionStates || $[73] !== questionText || $[74] !== questions.length) { - t12 = {question.multiSelect ? { - onUpdateQuestionState(questionText, { - selectedValue: values - }, true); - const textInput = values.includes("__other__") ? questionStates[questionText]?.textInputValue : undefined; - const finalValues = values.filter(_temp4).concat(textInput ? [textInput] : []); - onAnswer(questionText, finalValues, undefined, false); - }} onFocus={handleFocus} onCancel={onCancel} submitButtonText={currentQuestionIndex === questions.length - 1 ? "Submit" : "Next"} onSubmit={onSubmit} onDownFromLastItem={handleDownFromLastItem} isDisabled={isFooterFocused} onOpenEditor={handleOpenEditor} onImagePaste={onImagePaste} pastedContents={pastedContents} onRemoveImage={onRemoveImage} /> : { + onUpdateQuestionState( + questionText, + { selectedValue: value }, + false, + ) + const textInput = + value === '__other__' + ? questionStates[questionText]?.textInputValue + : undefined + onAnswer(questionText, value, textInput) + }} + onFocus={handleFocus} + onCancel={onCancel} + onDownFromLastItem={handleDownFromLastItem} + isDisabled={isFooterFocused} + layout="compact-vertical" + onOpenEditor={handleOpenEditor} + onImagePaste={onImagePaste} + pastedContents={pastedContents} + onRemoveImage={onRemoveImage} + /> + )} + + {/* Footer section - always visible, separate from Select */} + + + + {isFooterFocused && footerIndex === 0 ? ( + {figures.pointer} + ) : ( + + )} + + {options.length + 1}. Chat about this + + + {isInPlanMode && ( + + {isFooterFocused && footerIndex === 1 ? ( + {figures.pointer} + ) : ( + + )} + + {options.length + 2}. Skip interview and plan immediately + + + )} + + + + Enter to select ·{' '} + {questions.length === 1 ? ( + <> + {figures.arrowUp}/{figures.arrowDown} to navigate + + ) : ( + 'Tab/Arrow keys to navigate' + )} + {isOtherFocused && editorName && ( + <> · ctrl+g to edit in {editorName} + )}{' '} + · Esc to cancel + + + + + + ) } diff --git a/src/components/permissions/AskUserQuestionPermissionRequest/SubmitQuestionsView.tsx b/src/components/permissions/AskUserQuestionPermissionRequest/SubmitQuestionsView.tsx index 4ecc55635..b17a26c2a 100644 --- a/src/components/permissions/AskUserQuestionPermissionRequest/SubmitQuestionsView.tsx +++ b/src/components/permissions/AskUserQuestionPermissionRequest/SubmitQuestionsView.tsx @@ -1,143 +1,104 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import React from 'react'; -import { Box, Text } from '../../../ink.js'; -import type { Question } from '../../../tools/AskUserQuestionTool/AskUserQuestionTool.js'; -import type { PermissionDecision } from '../../../utils/permissions/PermissionResult.js'; -import { Select } from '../../CustomSelect/index.js'; -import { Divider } from '../../design-system/Divider.js'; -import { PermissionRequestTitle } from '../PermissionRequestTitle.js'; -import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js'; -import { QuestionNavigationBar } from './QuestionNavigationBar.js'; +import figures from 'figures' +import React from 'react' +import { Box, Text } from '../../../ink.js' +import type { Question } from '../../../tools/AskUserQuestionTool/AskUserQuestionTool.js' +import type { PermissionDecision } from '../../../utils/permissions/PermissionResult.js' +import { Select } from '../../CustomSelect/index.js' +import { Divider } from '../../design-system/Divider.js' +import { PermissionRequestTitle } from '../PermissionRequestTitle.js' +import { PermissionRuleExplanation } from '../PermissionRuleExplanation.js' +import { QuestionNavigationBar } from './QuestionNavigationBar.js' + type Props = { - questions: Question[]; - currentQuestionIndex: number; - answers: Record; - allQuestionsAnswered: boolean; - permissionResult: PermissionDecision; - minContentHeight?: number; - onFinalResponse: (value: 'submit' | 'cancel') => void; -}; -export function SubmitQuestionsView(t0) { - const $ = _c(27); - const { - questions, - currentQuestionIndex, - answers, - allQuestionsAnswered, - permissionResult, - minContentHeight, - onFinalResponse - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] !== answers || $[2] !== currentQuestionIndex || $[3] !== questions) { - t2 = ; - $[1] = answers; - $[2] = currentQuestionIndex; - $[3] = questions; - $[4] = t2; - } else { - t2 = $[4]; - } - let t3; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t3 = ; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] !== allQuestionsAnswered) { - t4 = !allQuestionsAnswered && {figures.warning} You have not answered all questions; - $[6] = allQuestionsAnswered; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] !== answers || $[9] !== questions) { - t5 = Object.keys(answers).length > 0 && {questions.filter(q => q?.question && answers[q.question]).map(q_0 => { - const answer = answers[q_0?.question]; - return {figures.bullet} {q_0?.question || "Question"}{figures.arrowRight} {answer}; - })}; - $[8] = answers; - $[9] = questions; - $[10] = t5; - } else { - t5 = $[10]; - } - let t6; - if ($[11] !== permissionResult) { - t6 = ; - $[11] = permissionResult; - $[12] = t6; - } else { - t6 = $[12]; - } - let t7; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t7 = Ready to submit your answers?; - $[13] = t7; - } else { - t7 = $[13]; - } - let t8; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t8 = { - type: "text" as const, - label: "Submit answers", - value: "submit" - }; - $[14] = t8; - } else { - t8 = $[14]; - } - let t9; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t9 = [t8, { - type: "text" as const, - label: "Cancel", - value: "cancel" - }]; - $[15] = t9; - } else { - t9 = $[15]; - } - let t10; - if ($[16] !== onFinalResponse) { - t10 = onFinalResponse(value as 'submit' | 'cancel')} + onCancel={() => onFinalResponse('cancel')} + /> + + + + + ) } diff --git a/src/components/permissions/BashPermissionRequest/BashPermissionRequest.tsx b/src/components/permissions/BashPermissionRequest/BashPermissionRequest.tsx index bce88e24e..1eb1ffffe 100644 --- a/src/components/permissions/BashPermissionRequest/BashPermissionRequest.tsx +++ b/src/components/permissions/BashPermissionRequest/BashPermissionRequest.tsx @@ -1,37 +1,51 @@ -import { c as _c } from "react/compiler-runtime"; -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'; -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'; -const CHECKING_TEXT = 'Attempting to auto-approve\u2026'; +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' +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' + +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 @@ -39,97 +53,77 @@ const CHECKING_TEXT = 'Attempting to auto-approve\u2026'; // all children) for the ~1-3 seconds the classifier typically takes. Inner also // 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() { - const $ = _c(6); - const [ref, glimmerIndex] = useShimmerAnimation("requesting", CHECKING_TEXT, false); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = [...CHECKING_TEXT]; - $[0] = t0; - } else { - t0 = $[0]; - } - let t1; - if ($[1] !== glimmerIndex) { - t1 = {t0.map((char, i) => )}; - $[1] = glimmerIndex; - $[2] = t1; - } else { - t1 = $[2]; - } - let t2; - if ($[3] !== ref || $[4] !== t1) { - t2 = {t1}; - $[3] = ref; - $[4] = t1; - $[5] = t2; - } else { - t2 = $[5]; - } - return t2; +function ClassifierCheckingSubtitle(): React.ReactNode { + const [ref, glimmerIndex] = useShimmerAnimation( + 'requesting', + CHECKING_TEXT, + false, + ) + return ( + + + {[...CHECKING_TEXT].map((char, i) => ( + + ))} + + + ) } -export function BashPermissionRequest(props) { - const $ = _c(21); + +export function BashPermissionRequest( + props: PermissionRequestProps, +): React.ReactNode { const { toolUseConfirm, toolUseContext, onDone, onReject, verbose, - workerBadge - } = props; - let command; - let description; - let t0; - if ($[0] !== toolUseConfirm.input) { - ({ - command, - description - } = BashTool.inputSchema.parse(toolUseConfirm.input)); - t0 = parseSedEditCommand(command); - $[0] = toolUseConfirm.input; - $[1] = command; - $[2] = description; - $[3] = t0; - } else { - command = $[1]; - description = $[2]; - t0 = $[3]; - } - const sedInfo = t0; + workerBadge, + } = props + + 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) + if (sedInfo) { - let t1; - if ($[4] !== onDone || $[5] !== onReject || $[6] !== sedInfo || $[7] !== toolUseConfirm || $[8] !== toolUseContext || $[9] !== verbose || $[10] !== workerBadge) { - t1 = ; - $[4] = onDone; - $[5] = onReject; - $[6] = sedInfo; - $[7] = toolUseConfirm; - $[8] = toolUseContext; - $[9] = verbose; - $[10] = workerBadge; - $[11] = t1; - } else { - t1 = $[11]; - } - return t1; + return ( + + ) } - let t1; - if ($[12] !== command || $[13] !== description || $[14] !== onDone || $[15] !== onReject || $[16] !== toolUseConfirm || $[17] !== toolUseContext || $[18] !== verbose || $[19] !== workerBadge) { - t1 = ; - $[12] = command; - $[13] = description; - $[14] = onDone; - $[15] = onReject; - $[16] = toolUseConfirm; - $[17] = toolUseContext; - $[18] = verbose; - $[19] = workerBadge; - $[20] = t1; - } else { - t1 = $[20]; - } - return t1; + + // Regular bash command - render with hooks + return ( + + ) } // Inner component that uses hooks - only called for non-MCP CLI commands @@ -141,19 +135,19 @@ function BashPermissionRequestInner({ verbose: _verbose, workerBadge, command, - description + 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 - }); + messages: toolUseContext.messages, + }) const { yesInputMode, noInputMode, @@ -166,31 +160,39 @@ function BashPermissionRequestInner({ focusedOption, handleInputModeToggle, handleReject, - handleFocus + handleFocus, } = useShellPermissionFeedback({ toolUseConfirm, onDone, onReject, - explainerVisible: explainerState.visible - }); - const [showPermissionDebug, setShowPermissionDebug] = useState(false); - const [classifierDescription, setClassifierDescription] = useState(description || ''); + explainerVisible: explainerState.visible, + }) + 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; - const abortController = new AbortController(); - generateGenericDescription(command, description, abortController.signal).then(generic => { - if (generic && !abortController.signal.aborted) { - setClassifierDescription(generic); - setInitialClassifierDescriptionEmpty(false); - } - }).catch(() => {}); // Keep original on error - return () => abortController.abort(); - }, [command, description]); + if (!isClassifierPermissionsEnabled()) return + + const abortController = new AbortController() + generateGenericDescription(command, description, abortController.signal) + .then(generic => { + if (generic && !abortController.signal.aborted) { + setClassifierDescription(generic) + setInitialClassifierDescriptionEmpty(false) + } + }) + .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 @@ -206,7 +208,8 @@ 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 @@ -216,49 +219,63 @@ 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 - })).then(prefixes => { - if (cancelled || hasUserEditedPrefix.current) return; - if (prefixes.length > 0) { - setEditablePrefix(`${prefixes[0]}:*`); - } - }).catch(() => {}); // Keep sync prefix on tree-sitter failure + if (isCompound) return + let cancelled = false + getCompoundCommandPrefixesStatic(command, subcmd => + BashTool.isReadOnly({ command: subcmd }), + ) + .then(prefixes => { + if (cancelled || hasUserEditedPrefix.current) return + if (prefixes.length > 0) { + setEditablePrefix(`${prefixes[0]}:*`) + } + }) + .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) // and only ever transitions true→false, so capturing the mount-time value is // 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); + const [classifierWasChecking] = useState( + 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 @@ -266,216 +283,330 @@ function BashPermissionRequestInner({ // extraction). React Compiler can't auto-memoize imported functions (can't // 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: destructiveWarning_0, - sandboxingEnabled: sandboxingEnabled_0, - isSandboxed: isSandboxed_0 - } = useMemo(() => { - const destructiveWarning = getFeatureValue_CACHED_MAY_BE_STALE('tengu_destructive_command_warning', false) ? getDestructiveCommandWarning(command) : null; - const sandboxingEnabled = SandboxManager.isSandboxingEnabled(); - const isSandboxed = sandboxingEnabled && shouldUseSandbox(toolUseConfirm.input); - return { - destructiveWarning, - sandboxingEnabled, - isSandboxed - }; - }, [command, toolUseConfirm.input]); - const unaryEvent = useMemo(() => ({ - completion_type: 'tool_use_single', - language_name: 'none' - }), []); - usePermissionRequestLogging(toolUseConfirm, unaryEvent); - const existingAllowDescriptions = useMemo(() => getBashPromptAllowDescriptions(toolPermissionContext), [toolPermissionContext]); - const options = useMemo(() => bashToolUseOptions({ - suggestions: toolUseConfirm.permissionResult.behavior === 'ask' ? toolUseConfirm.permissionResult.suggestions : undefined, - decisionReason: toolUseConfirm.permissionResult.decisionReason, - onRejectFeedbackChange: setRejectFeedback, - onAcceptFeedbackChange: setAcceptFeedback, - onClassifierDescriptionChange: setClassifierDescription, - classifierDescription, - initialClassifierDescriptionEmpty, - existingAllowDescriptions, - yesInputMode, - noInputMode, - editablePrefix, - onEditablePrefixChange - }), [toolUseConfirm, classifierDescription, initialClassifierDescriptionEmpty, existingAllowDescriptions, yesInputMode, noInputMode, editablePrefix, onEditablePrefixChange]); + const { destructiveWarning, sandboxingEnabled, isSandboxed } = useMemo(() => { + const destructiveWarning = getFeatureValue_CACHED_MAY_BE_STALE( + 'tengu_destructive_command_warning', + false, + ) + ? getDestructiveCommandWarning(command) + : null + + const sandboxingEnabled = SandboxManager.isSandboxingEnabled() + const isSandboxed = + sandboxingEnabled && shouldUseSandbox(toolUseConfirm.input) + + return { destructiveWarning, sandboxingEnabled, isSandboxed } + }, [command, toolUseConfirm.input]) + + const unaryEvent = useMemo( + () => ({ completion_type: 'tool_use_single', language_name: 'none' }), + [], + ) + + usePermissionRequestLogging(toolUseConfirm, unaryEvent) + + const existingAllowDescriptions = useMemo( + () => getBashPromptAllowDescriptions(toolPermissionContext), + [toolPermissionContext], + ) + + const options = useMemo( + () => + bashToolUseOptions({ + suggestions: + toolUseConfirm.permissionResult.behavior === 'ask' + ? toolUseConfirm.permissionResult.suggestions + : undefined, + decisionReason: toolUseConfirm.permissionResult.decisionReason, + onRejectFeedbackChange: setRejectFeedback, + onAcceptFeedbackChange: setAcceptFeedback, + onClassifierDescriptionChange: setClassifierDescription, + classifierDescription, + initialClassifierDescriptionEmpty, + existingAllowDescriptions, + yesInputMode, + noInputMode, + editablePrefix, + onEditablePrefixChange, + }), + [ + toolUseConfirm, + classifierDescription, + initialClassifierDescriptionEmpty, + existingAllowDescriptions, + 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' - }); + 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 - }); - function onSelect(value_0: string) { + isActive: feature('BASH_CLASSIFIER') + ? !!toolUseConfirm.classifierAutoApproved + : false, + }) + + function onSelect(value: string) { // Map options to numeric values for analytics (strings not allowed in logEvent) let optionIndex: Record = { yes: 1, 'yes-apply-suggestions': 2, 'yes-prefix-edited': 2, - no: 3 - }; + no: 3, + } if (feature('BASH_CLASSIFIER')) { optionIndex = { yes: 1, 'yes-apply-suggestions': 2, 'yes-prefix-edited': 2, 'yes-classifier-reviewed': 3, - no: 4 - }; + no: 4, + } } logEvent('tengu_permission_request_option_selected', { - option_index: optionIndex[value_0], - explainer_visible: explainerState.visible - }); - const toolNameForAnalytics = sanitizeToolNameForAnalytics(toolUseConfirm.tool.name) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS; - if (value_0 === 'yes-prefix-edited') { - const trimmedPrefix = (editablePrefix ?? '').trim(); - logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept'); + option_index: optionIndex[value], + explainer_visible: explainerState.visible, + }) + + const toolNameForAnalytics = sanitizeToolNameForAnalytics( + toolUseConfirm.tool.name, + ) 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') if (!trimmedPrefix) { - toolUseConfirm.onAllow(toolUseConfirm.input, []); + toolUseConfirm.onAllow(toolUseConfirm.input, []) } else { - const prefixUpdates: PermissionUpdate[] = [{ - type: 'addRules', - rules: [{ - toolName: BashTool.name, - ruleContent: trimmedPrefix - }], - behavior: 'allow', - destination: 'localSettings' - }]; - toolUseConfirm.onAllow(toolUseConfirm.input, prefixUpdates); + const prefixUpdates: PermissionUpdate[] = [ + { + type: 'addRules', + rules: [ + { + toolName: BashTool.name, + ruleContent: trimmedPrefix, + }, + ], + behavior: 'allow', + destination: 'localSettings', + }, + ] + toolUseConfirm.onAllow(toolUseConfirm.input, prefixUpdates) } - onDone(); - return; + onDone() + return } - if (feature('BASH_CLASSIFIER') && value_0 === 'yes-classifier-reviewed') { - const trimmedDescription = classifierDescription.trim(); - logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept'); + + if (feature('BASH_CLASSIFIER') && value === 'yes-classifier-reviewed') { + const trimmedDescription = classifierDescription.trim() + logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept') if (!trimmedDescription) { - toolUseConfirm.onAllow(toolUseConfirm.input, []); + toolUseConfirm.onAllow(toolUseConfirm.input, []) } else { - const permissionUpdates: PermissionUpdate[] = [{ - type: 'addRules', - rules: [{ - toolName: BashTool.name, - ruleContent: createPromptRuleContent(trimmedDescription) - }], - behavior: 'allow', - destination: 'session' - }]; - toolUseConfirm.onAllow(toolUseConfirm.input, permissionUpdates); + const permissionUpdates: PermissionUpdate[] = [ + { + type: 'addRules', + rules: [ + { + toolName: BashTool.name, + ruleContent: createPromptRuleContent(trimmedDescription), + }, + ], + behavior: 'allow', + destination: 'session', + }, + ] + toolUseConfirm.onAllow(toolUseConfirm.input, permissionUpdates) } - onDone(); - return; + onDone() + return } - switch (value_0) { - case 'yes': - { - const trimmedFeedback_0 = acceptFeedback.trim(); - logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept'); - // Log accept submission with feedback context - logEvent('tengu_accept_submitted', { - toolName: toolNameForAnalytics, - isMcp: toolUseConfirm.tool.isMcp ?? false, - has_instructions: !!trimmedFeedback_0, - instructions_length: trimmedFeedback_0.length, - entered_feedback_mode: yesFeedbackModeEntered - }); - toolUseConfirm.onAllow(toolUseConfirm.input, [], trimmedFeedback_0 || undefined); - onDone(); - break; - } - case 'yes-apply-suggestions': - { - logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept'); - // Extract suggestions if present (works for both 'ask' and 'passthrough' behaviors) - const permissionUpdates_0 = 'suggestions' in toolUseConfirm.permissionResult ? toolUseConfirm.permissionResult.suggestions || [] : []; - toolUseConfirm.onAllow(toolUseConfirm.input, permissionUpdates_0); - onDone(); - break; - } - case 'no': - { - const trimmedFeedback = rejectFeedback.trim(); - // Log reject submission with feedback context - logEvent('tengu_reject_submitted', { - toolName: toolNameForAnalytics, - isMcp: toolUseConfirm.tool.isMcp ?? false, - has_instructions: !!trimmedFeedback, - instructions_length: trimmedFeedback.length, - entered_feedback_mode: noFeedbackModeEntered - }); + switch (value) { + case 'yes': { + const trimmedFeedback = acceptFeedback.trim() + logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept') + // Log accept submission with feedback context + logEvent('tengu_accept_submitted', { + toolName: toolNameForAnalytics, + isMcp: toolUseConfirm.tool.isMcp ?? false, + has_instructions: !!trimmedFeedback, + instructions_length: trimmedFeedback.length, + entered_feedback_mode: yesFeedbackModeEntered, + }) + toolUseConfirm.onAllow( + toolUseConfirm.input, + [], + trimmedFeedback || undefined, + ) + onDone() + break + } + case 'yes-apply-suggestions': { + 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 + } + case 'no': { + const trimmedFeedback = rejectFeedback.trim() - // Process rejection (with or without feedback) - handleReject(trimmedFeedback || undefined); - break; - } + // Log reject submission with feedback context + logEvent('tengu_reject_submitted', { + toolName: toolNameForAnalytics, + isMcp: toolUseConfirm.tool.isMcp ?? false, + has_instructions: !!trimmedFeedback, + instructions_length: trimmedFeedback.length, + entered_feedback_mode: noFeedbackModeEntered, + }) + + // Process rejection (with or without feedback) + handleReject(trimmedFeedback || undefined) + break + } } } - const classifierSubtitle = feature('BASH_CLASSIFIER') ? toolUseConfirm.classifierAutoApproved ? + + const classifierSubtitle = feature('BASH_CLASSIFIER') ? ( + toolUseConfirm.classifierAutoApproved ? ( + {figures.tick} Auto-approved - {toolUseConfirm.classifierMatchedRule && + {toolUseConfirm.classifierMatchedRule && ( + {' \u00b7 matched "'} {toolUseConfirm.classifierMatchedRule} {'"'} - } - : toolUseConfirm.classifierCheckInProgress ? : classifierWasChecking ? Requires manual approval : undefined : undefined; - return + + )} + + ) : toolUseConfirm.classifierCheckInProgress ? ( + + ) : classifierWasChecking ? ( + Requires manual approval + ) : undefined + ) : undefined + + return ( + - {BashTool.renderToolUseMessage({ - command, - description - }, { - theme, - verbose: true - } // always show the full command - )} + {BashTool.renderToolUseMessage( + { command, description }, + { theme, verbose: true }, // always show the full command + )} - {!explainerState.visible && {toolUseConfirm.description}} - + {!explainerState.visible && ( + {toolUseConfirm.description} + )} + - {showPermissionDebug ? <> - - {toolUseContext.options.debug && + {showPermissionDebug ? ( + <> + + {toolUseContext.options.debug && ( + Ctrl-D to hide debug info - } - : <> + + )} + + ) : ( + <> - - {destructiveWarning_0 && - - {destructiveWarning_0} + + {destructiveWarning && ( + + + {destructiveWarning} - } - + + )} + Do you want to proceed? - ({ ...o, disabled: true })) + : options + : options + } + isDisabled={ + feature('BASH_CLASSIFIER') + ? toolUseConfirm.classifierAutoApproved + : false + } + inlineDescriptions + onChange={onSelect} + onCancel={() => handleReject()} + onFocus={handleFocus} + onInputModeToggle={handleInputModeToggle} + /> Esc to cancel - {(focusedOption === 'yes' && !yesInputMode || focusedOption === 'no' && !noInputMode) && ' · Tab to amend'} - {explainerState.enabled && ` · ctrl+e to ${explainerState.visible ? 'hide' : 'explain'}`} + {((focusedOption === 'yes' && !yesInputMode) || + (focusedOption === 'no' && !noInputMode)) && + ' · Tab to amend'} + {explainerState.enabled && + ` · ctrl+e to ${explainerState.visible ? 'hide' : 'explain'}`} - {toolUseContext.options.debug && Ctrl+d to show debug info} + {toolUseContext.options.debug && ( + Ctrl+d to show debug info + )} - } - ; + + )} + + ) } diff --git a/src/components/permissions/BashPermissionRequest/bashToolUseOptions.tsx b/src/components/permissions/BashPermissionRequest/bashToolUseOptions.tsx index f1f7c4a89..18d35d062 100644 --- a/src/components/permissions/BashPermissionRequest/bashToolUseOptions.tsx +++ b/src/components/permissions/BashPermissionRequest/bashToolUseOptions.tsx @@ -1,33 +1,43 @@ -import { BASH_TOOL_NAME } from '../../../tools/BashTool/toolName.js'; -import { extractOutputRedirections } from '../../../utils/bash/commands.js'; -import { isClassifierPermissionsEnabled } from '../../../utils/permissions/bashClassifier.js'; -import type { PermissionDecisionReason } from '../../../utils/permissions/PermissionResult.js'; -import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js'; -import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js'; -import type { OptionWithDescription } from '../../CustomSelect/select.js'; -import { generateShellSuggestionsLabel } from '../shellPermissionHelpers.js'; -export type BashToolUseOption = 'yes' | 'yes-apply-suggestions' | 'yes-prefix-edited' | 'yes-classifier-reviewed' | 'no'; +import { BASH_TOOL_NAME } from '../../../tools/BashTool/toolName.js' +import { extractOutputRedirections } from '../../../utils/bash/commands.js' +import { isClassifierPermissionsEnabled } from '../../../utils/permissions/bashClassifier.js' +import type { PermissionDecisionReason } from '../../../utils/permissions/PermissionResult.js' +import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js' +import { shouldShowAlwaysAllowOptions } from '../../../utils/permissions/permissionsLoader.js' +import type { OptionWithDescription } from '../../CustomSelect/select.js' +import { generateShellSuggestionsLabel } from '../shellPermissionHelpers.js' + +export type BashToolUseOption = + | 'yes' + | 'yes-apply-suggestions' + | 'yes-prefix-edited' + | 'yes-classifier-reviewed' + | 'no' /** * Check if a description already exists in the allow list. * Compares lowercase and trailing-whitespace-trimmed versions. */ -function descriptionAlreadyExists(description: string, existingDescriptions: string[]): boolean { - const normalized = description.toLowerCase().trimEnd(); - return existingDescriptions.some(existing => existing.toLowerCase().trimEnd() === normalized); +function descriptionAlreadyExists( + description: string, + existingDescriptions: string[], +): boolean { + const normalized = description.toLowerCase().trimEnd() + return existingDescriptions.some( + existing => existing.toLowerCase().trimEnd() === normalized, + ) } /** * Strip output redirections so filenames don't show as commands in the label. */ function stripBashRedirections(command: string): string { - const { - commandWithoutRedirections, - redirections - } = extractOutputRedirections(command); + const { commandWithoutRedirections, redirections } = + extractOutputRedirections(command) // Only use stripped version if there were actual redirections - return redirections.length > 0 ? commandWithoutRedirections : command; + return redirections.length > 0 ? commandWithoutRedirections : command } + export function bashToolUseOptions({ suggestions = [], decisionReason, @@ -40,25 +50,26 @@ export function bashToolUseOptions({ yesInputMode = false, noInputMode = false, editablePrefix, - onEditablePrefixChange + onEditablePrefixChange, }: { - suggestions?: PermissionUpdate[]; - decisionReason?: PermissionDecisionReason; - onRejectFeedbackChange: (value: string) => void; - onAcceptFeedbackChange: (value: string) => void; - onClassifierDescriptionChange?: (value: string) => void; - classifierDescription?: string; + suggestions?: PermissionUpdate[] + decisionReason?: PermissionDecisionReason + onRejectFeedbackChange: (value: string) => void + onAcceptFeedbackChange: (value: string) => void + onClassifierDescriptionChange?: (value: string) => void + classifierDescription?: string /** Whether the initial classifier description was empty. When true, hides the option. */ - initialClassifierDescriptionEmpty?: boolean; - existingAllowDescriptions?: string[]; - yesInputMode?: boolean; - noInputMode?: boolean; + initialClassifierDescriptionEmpty?: boolean + existingAllowDescriptions?: string[] + yesInputMode?: boolean + noInputMode?: boolean /** Editable prefix rule content (e.g., "npm run:*"). When set, replaces Haiku-based suggestions. */ - editablePrefix?: string; + editablePrefix?: string /** Callback when the user edits the prefix value. */ - onEditablePrefixChange?: (value: string) => void; + onEditablePrefixChange?: (value: string) => void }): OptionWithDescription[] { - const options: OptionWithDescription[] = []; + const options: OptionWithDescription[] = [] + if (yesInputMode) { options.push({ type: 'input', @@ -66,13 +77,13 @@ export function bashToolUseOptions({ value: 'yes', placeholder: 'and tell Claude what to do next', onChange: onAcceptFeedbackChange, - allowEmptySubmitToCancel: true - }); + allowEmptySubmitToCancel: true, + }) } else { options.push({ label: 'Yes', - value: 'yes' - }); + value: 'yes', + }) } // Only show "always allow" options when not restricted by allowManagedPermissionRulesOnly @@ -81,8 +92,18 @@ export function bashToolUseOptions({ // Haiku-generated suggestion label — but only when the suggestions // don't contain non-Bash items (addDirectories, Read rules) that // the editable prefix can't represent. - const hasNonBashSuggestions = suggestions.some(s => s.type === 'addDirectories' || s.type === 'addRules' && s.rules?.some(r => r.toolName !== BASH_TOOL_NAME)); - if (editablePrefix !== undefined && onEditablePrefixChange && !hasNonBashSuggestions && suggestions.length > 0) { + const hasNonBashSuggestions = suggestions.some( + s => + s.type === 'addDirectories' || + (s.type === 'addRules' && + s.rules?.some(r => r.toolName !== BASH_TOOL_NAME)), + ) + if ( + editablePrefix !== undefined && + onEditablePrefixChange && + !hasNonBashSuggestions && + suggestions.length > 0 + ) { options.push({ type: 'input', label: 'Yes, and don\u2019t ask again for', @@ -93,15 +114,20 @@ export function bashToolUseOptions({ allowEmptySubmitToCancel: true, showLabelWithValue: true, labelValueSeparator: ': ', - resetCursorOnUpdate: true - }); + resetCursorOnUpdate: true, + }) } else if (suggestions.length > 0) { - const label = generateShellSuggestionsLabel(suggestions, BASH_TOOL_NAME, stripBashRedirections); + const label = generateShellSuggestionsLabel( + suggestions, + BASH_TOOL_NAME, + stripBashRedirections, + ) + if (label) { options.push({ label, - value: 'yes-apply-suggestions' - }); + value: 'yes-apply-suggestions', + }) } } @@ -111,8 +137,21 @@ export function bashToolUseOptions({ // (prompt-based rules don't help when the server-side classifier triggers first). // Skip when the editable prefix option is already shown — they serve the // same role and having two identical-looking "don't ask again" inputs is confusing. - const editablePrefixShown = options.some(o => o.value === 'yes-prefix-edited'); - if ((process.env.USER_TYPE) === 'ant' && !editablePrefixShown && isClassifierPermissionsEnabled() && onClassifierDescriptionChange && !initialClassifierDescriptionEmpty && !descriptionAlreadyExists(classifierDescription ?? '', existingAllowDescriptions) && decisionReason?.type !== 'classifier') { + const editablePrefixShown = options.some( + o => o.value === 'yes-prefix-edited', + ) + if ( + process.env.USER_TYPE === 'ant' && + !editablePrefixShown && + isClassifierPermissionsEnabled() && + onClassifierDescriptionChange && + !initialClassifierDescriptionEmpty && + !descriptionAlreadyExists( + classifierDescription ?? '', + existingAllowDescriptions, + ) && + decisionReason?.type !== 'classifier' + ) { options.push({ type: 'input', label: 'Yes, and don\u2019t ask again for', @@ -123,10 +162,11 @@ export function bashToolUseOptions({ allowEmptySubmitToCancel: true, showLabelWithValue: true, labelValueSeparator: ': ', - resetCursorOnUpdate: true - }); + resetCursorOnUpdate: true, + }) } } + if (noInputMode) { options.push({ type: 'input', @@ -134,13 +174,14 @@ export function bashToolUseOptions({ value: 'no', placeholder: 'and tell Claude what to do differently', onChange: onRejectFeedbackChange, - allowEmptySubmitToCancel: true - }); + allowEmptySubmitToCancel: true, + }) } else { options.push({ label: 'No', - value: 'no' - }); + value: 'no', + }) } - return options; + + return options } diff --git a/src/components/permissions/ComputerUseApproval/ComputerUseApproval.tsx b/src/components/permissions/ComputerUseApproval/ComputerUseApproval.tsx index bf4bdcbd5..c591082e4 100644 --- a/src/components/permissions/ComputerUseApproval/ComputerUseApproval.tsx +++ b/src/components/permissions/ComputerUseApproval/ComputerUseApproval.tsx @@ -1,25 +1,29 @@ -import { c as _c } from "react/compiler-runtime"; -import { getSentinelCategory } from '@ant/computer-use-mcp/sentinelApps'; -import type { CuPermissionRequest, CuPermissionResponse } from '@ant/computer-use-mcp/types'; -import { DEFAULT_GRANT_FLAGS } from '@ant/computer-use-mcp/types'; -import figures from 'figures'; -import * as React from 'react'; -import { useMemo, useState } from 'react'; -import { Box, Text } from '../../../ink.js'; -import { execFileNoThrow } from '../../../utils/execFileNoThrow.js'; -import { plural } from '../../../utils/stringUtils.js'; -import type { OptionWithDescription } from '../../CustomSelect/select.js'; -import { Select } from '../../CustomSelect/select.js'; -import { Dialog } from '../../design-system/Dialog.js'; +import { getSentinelCategory } from '@ant/computer-use-mcp/sentinelApps' +import type { + CuPermissionRequest, + CuPermissionResponse, +} from '@ant/computer-use-mcp/types' +import { DEFAULT_GRANT_FLAGS } from '@ant/computer-use-mcp/types' +import figures from 'figures' +import * as React from 'react' +import { useMemo, useState } from 'react' +import { Box, Text } from '../../../ink.js' +import { execFileNoThrow } from '../../../utils/execFileNoThrow.js' +import { plural } from '../../../utils/stringUtils.js' +import type { OptionWithDescription } from '../../CustomSelect/select.js' +import { Select } from '../../CustomSelect/select.js' +import { Dialog } from '../../design-system/Dialog.js' + type ComputerUseApprovalProps = { - request: CuPermissionRequest; - onDone: (response: CuPermissionResponse) => void; -}; + request: CuPermissionRequest + onDone: (response: CuPermissionResponse) => void +} + const DENY_ALL_RESPONSE: CuPermissionResponse = { granted: [], denied: [], - flags: DEFAULT_GRANT_FLAGS -}; + flags: DEFAULT_GRANT_FLAGS, +} /** * Two-panel dispatcher. When `request.tccState` is present, macOS permissions @@ -27,414 +31,271 @@ const DENY_ALL_RESPONSE: CuPermissionResponse = { * irrelevant — show a TCC panel that opens System Settings. Otherwise show the * app allowlist + grant-flags panel. */ -export function ComputerUseApproval(t0) { - const $ = _c(3); - const { - request, - onDone - } = t0; - let t1; - if ($[0] !== onDone || $[1] !== request) { - t1 = request.tccState ? onDone(DENY_ALL_RESPONSE)} /> : ; - $[0] = onDone; - $[1] = request; - $[2] = t1; - } else { - t1 = $[2]; - } - return t1; +export function ComputerUseApproval({ + request, + onDone, +}: ComputerUseApprovalProps): React.ReactNode { + return request.tccState ? ( + onDone(DENY_ALL_RESPONSE)} + /> + ) : ( + + ) } // ── TCC panel ───────────────────────────────────────────────────────────── -type TccOption = 'open_accessibility' | 'open_screen_recording' | 'retry'; -function ComputerUseTccPanel(t0) { - const $ = _c(26); - const { - tccState, - onDone - } = t0; - let opts; - if ($[0] !== tccState.accessibility || $[1] !== tccState.screenRecording) { - opts = []; +type TccOption = 'open_accessibility' | 'open_screen_recording' | 'retry' + +function ComputerUseTccPanel({ + tccState, + onDone, +}: { + tccState: NonNullable + onDone: () => void +}): React.ReactNode { + const options = useMemo[]>(() => { + const opts: OptionWithDescription[] = [] if (!tccState.accessibility) { - let t1; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - label: "Open System Settings \u2192 Accessibility", - value: "open_accessibility" - }; - $[3] = t1; - } else { - t1 = $[3]; - } - opts.push(t1); + opts.push({ + label: 'Open System Settings → Accessibility', + value: 'open_accessibility', + }) } if (!tccState.screenRecording) { - let t1; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - label: "Open System Settings \u2192 Screen Recording", - value: "open_screen_recording" - }; - $[4] = t1; - } else { - t1 = $[4]; - } - opts.push(t1); + opts.push({ + label: 'Open System Settings → Screen Recording', + value: 'open_screen_recording', + }) } - let t1; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - label: "Try again", - value: "retry" - }; - $[5] = t1; - } else { - t1 = $[5]; + opts.push({ label: 'Try again', value: 'retry' }) + return opts + }, [tccState.accessibility, tccState.screenRecording]) + + function onChange(value: TccOption): void { + switch (value) { + case 'open_accessibility': + void execFileNoThrow( + 'open', + [ + 'x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility', + ], + { useCwd: false }, + ) + return + case 'open_screen_recording': + void execFileNoThrow( + 'open', + [ + 'x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture', + ], + { useCwd: false }, + ) + return + case 'retry': + // Resolve with deny-all — the model re-calls request_access, which + // re-checks TCC and renders the app list if now granted. + onDone() + return } - opts.push(t1); - $[0] = tccState.accessibility; - $[1] = tccState.screenRecording; - $[2] = opts; - } else { - opts = $[2]; } - const options = opts; - let t1; - if ($[6] !== onDone) { - t1 = function onChange(value) { - switch (value) { - case "open_accessibility": - { - execFileNoThrow("open", ["x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility"], { - useCwd: false - }); - return; - } - case "open_screen_recording": - { - execFileNoThrow("open", ["x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture"], { - useCwd: false - }); - return; - } - case "retry": - { - onDone(); - return; - } - } - }; - $[6] = onDone; - $[7] = t1; - } else { - t1 = $[7]; - } - const onChange = t1; - const t2 = tccState.accessibility ? `${figures.tick} granted` : `${figures.cross} not granted`; - let t3; - if ($[8] !== t2) { - t3 = Accessibility:{" "}{t2}; - $[8] = t2; - $[9] = t3; - } else { - t3 = $[9]; - } - const t4 = tccState.screenRecording ? `${figures.tick} granted` : `${figures.cross} not granted`; - let t5; - if ($[10] !== t4) { - t5 = Screen Recording:{" "}{t4}; - $[10] = t4; - $[11] = t5; - } else { - t5 = $[11]; - } - let t6; - if ($[12] !== t3 || $[13] !== t5) { - t6 = {t3}{t5}; - $[12] = t3; - $[13] = t5; - $[14] = t6; - } else { - t6 = $[14]; - } - let t7; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t7 = Grant the missing permissions in System Settings, then select "Try again". macOS may require you to restart Claude Code after granting Screen Recording.; - $[15] = t7; - } else { - t7 = $[15]; - } - let t8; - if ($[16] !== onChange || $[17] !== onDone || $[18] !== options) { - t8 = + + + ) } // ── App allowlist panel ─────────────────────────────────────────────────── -type AppListOption = 'allow_all' | 'deny'; -const SENTINEL_WARNING: Record>, string> = { +type AppListOption = 'allow_all' | 'deny' + +const SENTINEL_WARNING: Record< + NonNullable>, + string +> = { shell: 'equivalent to shell access', filesystem: 'can read/write any file', - system_settings: 'can change system settings' -}; -function ComputerUseAppListPanel(t0) { - const $ = _c(48); - const { - request, - onDone - } = t0; - let t1; - if ($[0] !== request.apps) { - t1 = () => new Set(request.apps.flatMap(_temp)); - $[0] = request.apps; - $[1] = t1; - } else { - t1 = $[1]; - } - const [checked] = useState(t1); - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = ["clipboardRead", "clipboardWrite", "systemKeyCombos"]; - $[2] = t2; - } else { - t2 = $[2]; - } - const ALL_FLAG_KEYS = t2; - let t3; - if ($[3] !== request.requestedFlags) { - t3 = ALL_FLAG_KEYS.filter(k => request.requestedFlags[k]); - $[3] = request.requestedFlags; - $[4] = t3; - } else { - t3 = $[4]; - } - const requestedFlagKeys = t3; - const t4 = checked.size; - let t5; - if ($[5] !== checked.size) { - t5 = plural(checked.size, "app"); - $[5] = checked.size; - $[6] = t5; - } else { - t5 = $[6]; - } - const t6 = `Allow for this session (${t4} ${t5})`; - let t7; - if ($[7] !== t6) { - t7 = { - label: t6, - value: "allow_all" - }; - $[7] = t6; - $[8] = t7; - } else { - t7 = $[8]; - } - let t8; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t8 = { - label: Deny, and tell Claude what to do differently (esc), - value: "deny" - }; - $[9] = t8; - } else { - t8 = $[9]; - } - let t9; - if ($[10] !== t7) { - t9 = [t7, t8]; - $[10] = t7; - $[11] = t9; - } else { - t9 = $[11]; - } - const options = t9; - let t10; - if ($[12] !== checked || $[13] !== onDone || $[14] !== request.apps || $[15] !== requestedFlagKeys) { - t10 = function respond(allow) { - if (!allow) { - onDone(DENY_ALL_RESPONSE); - return; - } - const now = Date.now(); - const granted = request.apps.flatMap(a_0 => a_0.resolved && checked.has(a_0.resolved.bundleId) ? [{ - bundleId: a_0.resolved.bundleId, - displayName: a_0.resolved.displayName, - grantedAt: now - }] : []); - const denied = request.apps.filter(a_1 => !a_1.resolved || !checked.has(a_1.resolved.bundleId)).map(_temp2); - const flags = { - ...DEFAULT_GRANT_FLAGS, - ...Object.fromEntries(requestedFlagKeys.map(_temp3)) - }; - onDone({ - granted, - denied, - flags - }); - }; - $[12] = checked; - $[13] = onDone; - $[14] = request.apps; - $[15] = requestedFlagKeys; - $[16] = t10; - } else { - t10 = $[16]; - } - const respond = t10; - let t11; - if ($[17] !== respond) { - t11 = () => respond(false); - $[17] = respond; - $[18] = t11; - } else { - t11 = $[18]; - } - let t12; - if ($[19] !== request.reason) { - t12 = request.reason ? {request.reason} : null; - $[19] = request.reason; - $[20] = t12; - } else { - t12 = $[20]; - } - let t13; - if ($[21] !== checked || $[22] !== request.apps) { - let t14; - if ($[24] !== checked) { - t14 = a_3 => { - const resolved = a_3.resolved; - if (!resolved) { - return {" "}{figures.circle} {a_3.requestedName}{" "}(not installed); - } - if (a_3.alreadyGranted) { - return {" "}{figures.tick} {resolved.displayName}{" "}(already granted); - } - const sentinel = getSentinelCategory(resolved.bundleId); - const isChecked = checked.has(resolved.bundleId); - return {" "}{isChecked ? figures.circleFilled : figures.circle}{" "}{resolved.displayName}{sentinel ? {" "}{figures.warning} {SENTINEL_WARNING[sentinel]} : null}; - }; - $[24] = checked; - $[25] = t14; - } else { - t14 = $[25]; + system_settings: 'can change system settings', +} + +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] : [], + ), + ), + ) + + 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[]>( + () => [ + { + label: `Allow for this session (${checked.size} ${plural(checked.size, 'app')})`, + value: 'allow_all', + }, + { + label: ( + + Deny, and tell Claude what to do differently (esc) + + ), + value: 'deny', + }, + ], + [checked.size], + ) + + function respond(allow: boolean): void { + if (!allow) { + onDone(DENY_ALL_RESPONSE) + return } - t13 = request.apps.map(t14); - $[21] = checked; - $[22] = request.apps; - $[23] = t13; - } else { - t13 = $[23]; + const now = Date.now() + const granted = request.apps.flatMap(a => + a.resolved && checked.has(a.resolved.bundleId) + ? [ + { + bundleId: a.resolved.bundleId, + displayName: a.resolved.displayName, + grantedAt: now, + }, + ] + : [], + ) + 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), + })) + // 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 }) } - let t14; - if ($[26] !== t13) { - t14 = {t13}; - $[26] = t13; - $[27] = t14; - } else { - t14 = $[27]; - } - let t15; - if ($[28] !== requestedFlagKeys) { - t15 = requestedFlagKeys.length > 0 ? Also requested:{requestedFlagKeys.map(_temp4)} : null; - $[28] = requestedFlagKeys; - $[29] = t15; - } else { - t15 = $[29]; - } - let t16; - if ($[30] !== request.willHide) { - t16 = request.willHide && request.willHide.length > 0 ? {request.willHide.length} other{" "}{plural(request.willHide.length, "app")} will be hidden while Claude works. : null; - $[30] = request.willHide; - $[31] = t16; - } else { - t16 = $[31]; - } - let t17; - let t18; - if ($[32] !== respond) { - t17 = v => respond(v === "allow_all"); - t18 = () => respond(false); - $[32] = respond; - $[33] = t17; - $[34] = t18; - } else { - t17 = $[33]; - t18 = $[34]; - } - let t19; - if ($[35] !== options || $[36] !== t17 || $[37] !== t18) { - t19 = respond(v === 'allow_all')} + onCancel={() => respond(false)} + /> + + + ) } diff --git a/src/components/permissions/EnterPlanModePermissionRequest/EnterPlanModePermissionRequest.tsx b/src/components/permissions/EnterPlanModePermissionRequest/EnterPlanModePermissionRequest.tsx index debc8888e..4251891e0 100644 --- a/src/components/permissions/EnterPlanModePermissionRequest/EnterPlanModePermissionRequest.tsx +++ b/src/components/permissions/EnterPlanModePermissionRequest/EnterPlanModePermissionRequest.tsx @@ -1,121 +1,82 @@ -import { c as _c } from "react/compiler-runtime"; -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'; -export function EnterPlanModePermissionRequest(t0) { - const $ = _c(18); - const { - toolUseConfirm, - onDone, - onReject, - workerBadge - } = t0; - const toolPermissionContextMode = useAppState(_temp); - let t1; - if ($[0] !== onDone || $[1] !== onReject || $[2] !== toolPermissionContextMode || $[3] !== toolUseConfirm) { - t1 = function handleResponse(value) { - 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" - }]); - } else { - onDone(); - onReject(); - toolUseConfirm.onReject(); - } - }; - $[0] = onDone; - $[1] = onReject; - $[2] = toolPermissionContextMode; - $[3] = toolUseConfirm; - $[4] = t1; - } else { - t1 = $[4]; +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' + +export function EnterPlanModePermissionRequest({ + toolUseConfirm, + onDone, + onReject, + workerBadge, +}: PermissionRequestProps): React.ReactNode { + 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' }, + ]) + } else { + onDone() + onReject() + toolUseConfirm.onReject() + } } - const handleResponse = t1; - let t2; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Claude wants to enter plan mode to explore and design an implementation approach.; - $[5] = t2; - } else { - t2 = $[5]; - } - let t3; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t3 = In plan mode, Claude will: · Explore the codebase thoroughly · Identify existing patterns · Design an implementation strategy · Present a plan for your approval; - $[6] = t3; - } else { - t3 = $[6]; - } - let t4; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t4 = No code changes will be made until you approve the plan.; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t5 = { - label: "Yes, enter plan mode", - value: "yes" as const - }; - $[8] = t5; - } else { - t5 = $[8]; - } - let t6; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t6 = [t5, { - label: "No, start implementing now", - value: "no" as const - }]; - $[9] = t6; - } else { - t6 = $[9]; - } - let t7; - if ($[10] !== handleResponse) { - t7 = () => handleResponse("no"); - $[10] = handleResponse; - $[11] = t7; - } else { - t7 = $[11]; - } - let t8; - if ($[12] !== handleResponse || $[13] !== t7) { - t8 = {t2}{t3}{t4} handleResponse('no')} + /> + + + + ) } diff --git a/src/components/permissions/ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.tsx b/src/components/permissions/ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.tsx index 4741d0bde..fddadaa7e 100644 --- a/src/components/permissions/ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.tsx +++ b/src/components/permissions/ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.tsx @@ -1,78 +1,152 @@ -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'; -import { getSdkBetas, getSessionId, isSessionPersistenceDisabled, 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'; -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'; -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'; -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'; +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' +import { + getSdkBetas, + getSessionId, + isSessionPersistenceDisabled, + 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' +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' +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' +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' /* 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; -import type { Base64ImageSource, ImageBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'; +const autoModeStateModule = feature('TRANSCRIPT_CLASSIFIER') + ? (require('../../../utils/permissions/autoModeState.js') as typeof import('../../../utils/permissions/autoModeState.js')) + : null + +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'; -type ResponseValue = 'yes-bypass-permissions' | 'yes-accept-edits' | 'yes-accept-edits-keep-context' | 'yes-default-keep-context' | 'yes-resume-auto-mode' | 'yes-auto-clear-context' | 'ultraplan' | 'no'; +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' + | 'yes-accept-edits' + | 'yes-accept-edits-keep-context' + | 'yes-default-keep-context' + | 'yes-resume-auto-mode' + | 'yes-auto-clear-context' + | 'ultraplan' + | '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[] { - const updates: PermissionUpdate[] = [{ - type: 'setMode', - mode: toExternalPermissionMode(mode), - destination: 'session' - }]; +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 => ({ toolName: p.tool, - ruleContent: createPromptRuleContent(p.prompt) + ruleContent: createPromptRuleContent(p.prompt), })), behavior: 'allow', - destination: 'session' - }); + destination: 'session', + }) } - return updates; + + return updates } /** @@ -80,200 +154,242 @@ export function buildPermissionUpdates(mode: PermissionMode, allowedPrompts?: Al * if they haven't already named it via /rename or --name. Fire-and-forget. * Mirrors /rename: kebab-case name, updates the prompt-border badge. */ -export function autoNameSessionFromPlan(plan: string, setAppState: (updater: (prev: AppState) => AppState) => void, isClearContext: boolean): void { - if (isSessionPersistenceDisabled() || getSettings_DEPRECATED()?.cleanupPeriodDays === 0) { - return; +export function autoNameSessionFromPlan( + plan: string, + setAppState: (updater: (prev: AppState) => AppState) => void, + isClearContext: boolean, +): void { + 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 - // end with testing steps — head-slice so Haiku sees the summary. - [createUserMessage({ - content: plan.slice(0, 1000) - })], new AbortController().signal).then(async name => { - // 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'); - setAppState(prev => { - if (prev.standaloneAgentContext?.name === name) return prev; - return { - ...prev, - standaloneAgentContext: { - ...prev.standaloneAgentContext, - name + // generateSessionName tail-slices to the last 1000 chars (correct for + // conversations, where recency matters). Plans front-load the goal and + // end with testing steps — head-slice so Haiku sees the summary. + [createUserMessage({ content: plan.slice(0, 1000) })], + new AbortController().signal, + ) + .then(async name => { + // 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') + setAppState(prev => { + if (prev.standaloneAgentContext?.name === name) return prev + return { + ...prev, + standaloneAgentContext: { ...prev.standaloneAgentContext, name }, } - }; - }); - }).catch(logError); + }) + }) + .catch(logError) } + export function ExitPlanModePermissionRequest({ toolUseConfirm, onDone, onReject, workerBadge, - setStickyFooter + 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>({}); - 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 [planFeedback, setPlanFeedback] = useState('') + const [pastedContents, setPastedContents] = useState< + Record + >({}) + const nextPasteIdRef = useRef(0) + + 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 options = useMemo(() => buildPlanApprovalOptions({ - showClearContext, - showUltraplan, - usedPercent: showClearContext ? getContextUsedPercent(usage as any, mode) : null, - isAutoModeAvailable, - isBypassPermissionsModeAvailable, - onFeedbackChange: setPlanFeedback - }), [showClearContext, showUltraplan, usage, mode, isAutoModeAvailable, isBypassPermissionsModeAvailable]); - function onImagePaste(base64Image: string, mediaType?: string, filename?: string, dimensions?: ImageDimensions, _sourcePath?: string) { - const pasteId = nextPasteIdRef.current++; + 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, + isAutoModeAvailable, + isBypassPermissionsModeAvailable, + onFeedbackChange: setPlanFeedback, + }), + [ + showClearContext, + showUltraplan, + usage, + mode, + isAutoModeAvailable, + isBypassPermissionsModeAvailable, + ], + ) + + function onImagePaste( + base64Image: string, + mediaType?: string, + filename?: string, + dimensions?: ImageDimensions, + _sourcePath?: string, + ) { + const pasteId = nextPasteIdRef.current++ const newContent: PastedContent = { id: pasteId, type: 'image', content: base64Image, mediaType: mediaType || 'image/png', filename: filename || 'Pasted image', - dimensions - }; - cacheImagePath(newContent); - void storeImage(newContent); - setPastedContents(prev => ({ - ...prev, - [pasteId]: newContent - })); + dimensions, + } + 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 imageAttachments = Object.values(pastedContents).filter(c => c.type === 'image'); - const hasImages = imageAttachments.length > 0; + const next = { ...prev } + delete next[id] + return next + }) + }, []) + + 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); + const [planStructureVariant] = useState( + () => + (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' - }); + 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' - }); + 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 @@ -281,145 +397,179 @@ 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.'); + planStructureVariant, + }) + onDone() + onReject() + toolUseConfirm.onReject( + 'Plan being refined via Ultraplan — please wait for the result.', + ) void launchUltraplan({ blurb: '', seedPlan: currentPlan, getAppState: store.getState, setAppState: store.setState, - signal: new AbortController().signal - }).then(msg => enqueuePendingNotification({ - value: msg, - mode: 'task-notification' - })).catch(logError); - return; + signal: new AbortController().signal, + }) + .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(); + const goingToAuto = + (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 - } - })); + 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 isKeepContextOption = value === 'yes-accept-edits-keep-context' || value === 'yes-default-keep-context' || isResumeAutoOption; + 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 + 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 - }); + hasFeedback: !!acceptFeedback, + }) // Set initial message - REPL will handle context clear and fresh query // Add verification instruction if the feature is enabled // Dead code elimination: CLAUDE_CODE_VERIFY_PLAN='false' in external builds, so === 'true' check allows Bun to eliminate the string - 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.` : ''; + 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 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 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}` + : '' + setAppState(prev => ({ ...prev, initialMessage: { message: { ...createUserMessage({ - content: `Implement the following plan:\n\n${currentPlan}${verificationInstruction}${transcriptHint}${teamHint}${feedbackSuffix}` + content: `Implement the following plan:\n\n${currentPlan}${verificationInstruction}${transcriptHint}${teamHint}${feedbackSuffix}`, }), - planContent: currentPlan + planContent: currentPlan, }, clearContext: true, mode, - allowedPrompts - } - })); - setHasExitedPlanMode(true); - onDone(); - onReject(); + allowedPrompts, + }, + })) + + 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); + hasFeedback: !!acceptFeedback, + }) + setHasExitedPlanMode(true) + setNeedsPlanModeExitAttachment(true) + autoModeStateModule?.setAutoModeActive(true) setAppState(prev => ({ ...prev, toolPermissionContext: stripDangerousPermissionsForAutoMode({ ...prev.toolPermissionContext, mode: 'auto', - prePlanMode: undefined - }) - })); - onDone(); - toolUseConfirm.onAllow(updatedInput, [], acceptFeedback); - return; + prePlanMode: undefined, + }), + })) + onDone() + toolUseConfirm.onAllow(updatedInput, [], acceptFeedback) + return } // Handle keep-context options (goes through normal onAllow flow) @@ -428,86 +578,109 @@ 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; + hasFeedback: !!acceptFeedback, + }) + 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]; + 'yes-accept-edits': 'acceptEdits', + } + 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; + hasFeedback: !!acceptFeedback, + }) + 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 - }); + 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 => { - const block: ImageBlockParam = { - type: 'image', - source: { - type: 'base64', - media_type: (img.mediaType || 'image/png') as Base64ImageSource['media_type'], - data: img.content + imageBlocks = await Promise.all( + imageAttachments.map(async img => { + const block: ImageBlockParam = { + type: 'image', + source: { + type: 'base64', + 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(); - toolUseConfirm.onReject(trimmedFeedback || (hasImages ? '(See attached image)' : undefined), imageBlocks && imageBlocks.length > 0 ? imageBlocks : undefined); + + 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 @@ -515,44 +688,77 @@ 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; + planStructureVariant, + }) + onDone() + onReject() + toolUseConfirm.onReject() + } + const useStickyFooter = !isEmpty && !!setStickyFooter useLayoutEffect(() => { - if (!useStickyFooter) return; - setStickyFooter( + if (!useStickyFooter) return + setStickyFooter( + Would you like to proceed? - void handleResponseRef.current(v)} + onCancel={() => handleCancelRef.current?.()} + onImagePaste={onImagePaste} + pastedContents={pastedContents} + onRemoveImage={onRemoveImage} + /> - {editorName && + {editorName && ( + ctrl-g to edit in {editorName} - {isV2 && planFilePath && · {getDisplayPath(planFilePath)}} - {showSaveMessage && <> + {isV2 && planFilePath && ( + · {getDisplayPath(planFilePath)} + )} + {showSaveMessage && ( + <> {' · '} {figures.tick}Plan saved! - } - } - ); - 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) { @@ -560,114 +766,169 @@ 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 - }); + 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 - } - })); + 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(); + planStructureVariant, + }) + onDone() + onReject() + toolUseConfirm.onReject() } } - return + + return ( + Claude wants to exit plan mode - { + logEvent('tengu_plan_exit', { + planLengthChars: 0, + outcome: + 'no' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + interviewPhaseEnabled: isPlanModeInterviewPhaseEnabled(), + planStructureVariant, + }) + onDone() + onReject() + toolUseConfirm.onReject() + }} + /> - ; + + ) } - return - + + return ( + + Here is Claude's plan: - + {currentPlan} - - {isClassifierPermissionsEnabled() && allowedPrompts && allowedPrompts.length > 0 && + + {isClassifierPermissionsEnabled() && + allowedPrompts && + allowedPrompts.length > 0 && ( + Requested permissions: - {allowedPrompts.map((p, i) => + {allowedPrompts.map((p, i) => ( + {' '}· {p.tool}({PROMPT_PREFIX} {p.prompt}) - )} - } - {!useStickyFooter && <> + + ))} + + )} + {!useStickyFooter && ( + <> Claude has written up a plan and is ready to execute. Would you like to proceed? - handleCancelRef.current?.()} + onImagePaste={onImagePaste} + pastedContents={pastedContents} + onRemoveImage={onRemoveImage} + /> - } + + )} - {!useStickyFooter && editorName && + {!useStickyFooter && editorName && ( + ctrl-g to edit in {editorName} - {isV2 && planFilePath && · {getDisplayPath(planFilePath)}} + {isV2 && planFilePath && ( + · {getDisplayPath(planFilePath)} + )} - {showSaveMessage && + {showSaveMessage && ( + {' · '} {figures.tick}Plan saved! - } - } - ; + + )} + + )} + + ) } /** @internal Exported for testing. */ @@ -677,33 +938,34 @@ export function buildPlanApprovalOptions({ usedPercent, isAutoModeAvailable, isBypassPermissionsModeAvailable, - onFeedbackChange + onFeedbackChange, }: { - showClearContext: boolean; - showUltraplan: boolean; - usedPercent: number | null; - isAutoModeAvailable: boolean | undefined; - isBypassPermissionsModeAvailable: boolean | undefined; - onFeedbackChange: (v: string) => void; + showClearContext: boolean + showUltraplan: boolean + usedPercent: number | null + isAutoModeAvailable: boolean | undefined + isBypassPermissionsModeAvailable: boolean | undefined + onFeedbackChange: (v: string) => void }): OptionWithDescription[] { - const options: OptionWithDescription[] = []; - const usedLabel = usedPercent !== null ? ` (${usedPercent}% used)` : ''; + const options: OptionWithDescription[] = [] + const usedLabel = usedPercent !== null ? ` (${usedPercent}% used)` : '' + if (showClearContext) { if (feature('TRANSCRIPT_CLASSIFIER') && isAutoModeAvailable) { options.push({ label: `Yes, clear context${usedLabel} and use auto mode`, - value: 'yes-auto-clear-context' - }); + value: 'yes-auto-clear-context', + }) } else if (isBypassPermissionsModeAvailable) { options.push({ label: `Yes, clear context${usedLabel} and bypass permissions`, - value: 'yes-bypass-permissions' - }); + value: 'yes-bypass-permissions', + }) } else { options.push({ label: `Yes, clear context${usedLabel} and auto-accept edits`, - value: 'yes-accept-edits' - }); + value: 'yes-accept-edits', + }) } } @@ -711,57 +973,71 @@ export function buildPlanApprovalOptions({ if (feature('TRANSCRIPT_CLASSIFIER') && isAutoModeAvailable) { options.push({ label: 'Yes, and use auto mode', - value: 'yes-resume-auto-mode' - }); + value: 'yes-resume-auto-mode', + }) } else if (isBypassPermissionsModeAvailable) { options.push({ label: 'Yes, and bypass permissions', - value: 'yes-accept-edits-keep-context' - }); + value: 'yes-accept-edits-keep-context', + }) } else { options.push({ label: 'Yes, auto-accept edits', - value: 'yes-accept-edits-keep-context' - }); + value: 'yes-accept-edits-keep-context', + }) } + options.push({ label: 'Yes, manually approve edits', - value: 'yes-default-keep-context' - }); + value: 'yes-default-keep-context', + }) + if (showUltraplan) { options.push({ label: 'No, refine with Ultraplan on Claude Code on the web', - value: 'ultraplan' - }); + value: 'ultraplan', + }) } + options.push({ type: 'input', label: 'No, keep planning', value: 'no', placeholder: 'Tell Claude what to change', description: 'shift+tab to approve with this feedback', - onChange: onFeedbackChange - }); - return options; + onChange: onFeedbackChange, + }) + + return options } -function getContextUsedPercent(usage: { - input_tokens: number; - cache_creation_input_tokens?: number | null; - cache_read_input_tokens?: number | null; -} | undefined, permissionMode: PermissionMode): number | null { - if (!usage) return null; + +function getContextUsedPercent( + usage: + | { + input_tokens: number + cache_creation_input_tokens?: number | null + cache_read_input_tokens?: number | null + } + | undefined, + permissionMode: PermissionMode, +): number | null { + if (!usage) return null const runtimeModel = getRuntimeMainLoopModel({ permissionMode, mainLoopModel: getMainLoopModel(), - exceeds200kTokens: false - }); - const contextWindowSize = getContextWindowForModel(runtimeModel, getSdkBetas()); - const { - used - } = calculateContextPercentages({ - input_tokens: usage.input_tokens, - cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0, - cache_read_input_tokens: usage.cache_read_input_tokens ?? 0 - }, contextWindowSize); - return used; + exceeds200kTokens: false, + }) + const contextWindowSize = getContextWindowForModel( + runtimeModel, + getSdkBetas(), + ) + const { used } = calculateContextPercentages( + { + input_tokens: usage.input_tokens, + cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0, + cache_read_input_tokens: usage.cache_read_input_tokens ?? 0, + }, + contextWindowSize, + ) + return used } diff --git a/src/components/permissions/FallbackPermissionRequest.tsx b/src/components/permissions/FallbackPermissionRequest.tsx index 38266d23e..9b7fee994 100644 --- a/src/components/permissions/FallbackPermissionRequest.tsx +++ b/src/components/permissions/FallbackPermissionRequest.tsx @@ -1,332 +1,196 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { useCallback, useMemo } from 'react'; -import { getOriginalCwd } from '../../bootstrap/state.js'; -import { Box, Text, useTheme } from '../../ink.js'; -import { sanitizeToolNameForAnalytics } from '../../services/analytics/metadata.js'; -import { env } from '../../utils/env.js'; -import { shouldShowAlwaysAllowOptions } from '../../utils/permissions/permissionsLoader.js'; -import { truncateToLines } from '../../utils/stringUtils.js'; -import { logUnaryEvent } from '../../utils/unaryLogging.js'; -import { type UnaryEvent, usePermissionRequestLogging } from './hooks.js'; -import { PermissionDialog } from './PermissionDialog.js'; -import { PermissionPrompt, type PermissionPromptOption, type ToolAnalyticsContext } from './PermissionPrompt.js'; -import type { PermissionRequestProps } from './PermissionRequest.js'; -import { PermissionRuleExplanation } from './PermissionRuleExplanation.js'; -type FallbackOptionValue = 'yes' | 'yes-dont-ask-again' | 'no'; -export function FallbackPermissionRequest(t0) { - const $ = _c(58); - const { - toolUseConfirm, - onDone, - onReject, - workerBadge - } = t0; - const [theme] = useTheme(); - let originalUserFacingName; - let t1; - if ($[0] !== toolUseConfirm.input || $[1] !== toolUseConfirm.tool) { - originalUserFacingName = toolUseConfirm.tool.userFacingName(toolUseConfirm.input as never); - t1 = originalUserFacingName.endsWith(" (MCP)") ? originalUserFacingName.slice(0, -6) : originalUserFacingName; - $[0] = toolUseConfirm.input; - $[1] = toolUseConfirm.tool; - $[2] = originalUserFacingName; - $[3] = t1; - } else { - originalUserFacingName = $[2]; - t1 = $[3]; - } - const userFacingName = t1; - let t2; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t2 = { - completion_type: "tool_use_single", - language_name: "none" - }; - $[4] = t2; - } else { - t2 = $[4]; - } - const unaryEvent = t2; - usePermissionRequestLogging(toolUseConfirm, unaryEvent); - let t3; - if ($[5] !== onDone || $[6] !== onReject || $[7] !== toolUseConfirm) { - t3 = (value, feedback) => { - bb8: switch (value) { - case "yes": - { - logUnaryEvent({ - completion_type: "tool_use_single", - event: "accept", - metadata: { - language_name: "none", - message_id: toolUseConfirm.assistantMessage.message.id, - platform: env.platform - } - }); - toolUseConfirm.onAllow(toolUseConfirm.input, [], feedback); - onDone(); - break bb8; - } - case "yes-dont-ask-again": - { - logUnaryEvent({ - completion_type: "tool_use_single", - event: "accept", - metadata: { - language_name: "none", - message_id: toolUseConfirm.assistantMessage.message.id, - platform: env.platform - } - }); - toolUseConfirm.onAllow(toolUseConfirm.input, [{ - type: "addRules", - rules: [{ - toolName: toolUseConfirm.tool.name - }], - behavior: "allow", - destination: "localSettings" - }]); - onDone(); - break bb8; - } - case "no": - { - logUnaryEvent({ - completion_type: "tool_use_single", - event: "reject", - metadata: { - language_name: "none", - message_id: toolUseConfirm.assistantMessage.message.id, - platform: env.platform - } - }); - toolUseConfirm.onReject(feedback); - onReject(); - onDone(); - } - } - }; - $[5] = onDone; - $[6] = onReject; - $[7] = toolUseConfirm; - $[8] = t3; - } else { - t3 = $[8]; - } - const handleSelect = t3; - let t4; - if ($[9] !== onDone || $[10] !== onReject || $[11] !== toolUseConfirm) { - t4 = () => { - logUnaryEvent({ - completion_type: "tool_use_single", - event: "reject", - metadata: { - language_name: "none", - message_id: toolUseConfirm.assistantMessage.message.id, - platform: env.platform +import React, { useCallback, useMemo } from 'react' +import { getOriginalCwd } from '../../bootstrap/state.js' +import { Box, Text, useTheme } from '../../ink.js' +import { sanitizeToolNameForAnalytics } from '../../services/analytics/metadata.js' +import { env } from '../../utils/env.js' +import { shouldShowAlwaysAllowOptions } from '../../utils/permissions/permissionsLoader.js' +import { truncateToLines } from '../../utils/stringUtils.js' +import { logUnaryEvent } from '../../utils/unaryLogging.js' +import { type UnaryEvent, usePermissionRequestLogging } from './hooks.js' +import { PermissionDialog } from './PermissionDialog.js' +import { + PermissionPrompt, + type PermissionPromptOption, + type ToolAnalyticsContext, +} from './PermissionPrompt.js' +import type { PermissionRequestProps } from './PermissionRequest.js' +import { PermissionRuleExplanation } from './PermissionRuleExplanation.js' + +type FallbackOptionValue = 'yes' | 'yes-dont-ask-again' | 'no' + +export function FallbackPermissionRequest({ + toolUseConfirm, + onDone, + onReject, + verbose: _verbose, + workerBadge, +}: PermissionRequestProps): React.ReactNode { + const [theme] = useTheme() + // TODO: Avoid these special cases + const originalUserFacingName = toolUseConfirm.tool.userFacingName( + toolUseConfirm.input as never, + ) + const userFacingName = originalUserFacingName.endsWith(' (MCP)') + ? originalUserFacingName.slice(0, -6) + : originalUserFacingName + + const unaryEvent = useMemo( + () => ({ + completion_type: 'tool_use_single', + language_name: 'none', + }), + [], + ) + + usePermissionRequestLogging(toolUseConfirm, unaryEvent) + + const handleSelect = useCallback( + (value: FallbackOptionValue, feedback?: string) => { + switch (value) { + case 'yes': + void logUnaryEvent({ + completion_type: 'tool_use_single', + event: 'accept', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + toolUseConfirm.onAllow(toolUseConfirm.input, [], feedback) + onDone() + break + case 'yes-dont-ask-again': { + void logUnaryEvent({ + completion_type: 'tool_use_single', + event: 'accept', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + + toolUseConfirm.onAllow(toolUseConfirm.input, [ + { + type: 'addRules', + rules: [ + { + toolName: toolUseConfirm.tool.name, + }, + ], + behavior: 'allow', + destination: 'localSettings', + }, + ]) + onDone() + break } - }); - toolUseConfirm.onReject(); - onReject(); - onDone(); - }; - $[9] = onDone; - $[10] = onReject; - $[11] = toolUseConfirm; - $[12] = t4; - } else { - t4 = $[12]; - } - const handleCancel = t4; - let t5; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t5 = getOriginalCwd(); - $[13] = t5; - } else { - t5 = $[13]; - } - const originalCwd = t5; - let t6; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t6 = shouldShowAlwaysAllowOptions(); - $[14] = t6; - } else { - t6 = $[14]; - } - const showAlwaysAllowOptions = t6; - let t7; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t7 = { - label: "Yes", - value: "yes", - feedbackConfig: { - type: "accept" + case 'no': + void logUnaryEvent({ + completion_type: 'tool_use_single', + event: 'reject', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + toolUseConfirm.onReject(feedback) + onReject() + onDone() + break } - }; - $[15] = t7; - } else { - t7 = $[15]; - } - let result; - if ($[16] !== userFacingName) { - result = [t7]; + }, + [toolUseConfirm, onDone, onReject], + ) + + const handleCancel = useCallback(() => { + void logUnaryEvent({ + completion_type: 'tool_use_single', + event: 'reject', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + toolUseConfirm.onReject() + onReject() + onDone() + }, [toolUseConfirm, onDone, onReject]) + + const originalCwd = getOriginalCwd() + const showAlwaysAllowOptions = shouldShowAlwaysAllowOptions() + const options = useMemo((): PermissionPromptOption[] => { + const result: PermissionPromptOption[] = [ + { + label: 'Yes', + value: 'yes', + feedbackConfig: { type: 'accept' }, + }, + ] + if (showAlwaysAllowOptions) { - const t8 = {userFacingName}; - let t9; - if ($[18] === Symbol.for("react.memo_cache_sentinel")) { - t9 = {originalCwd}; - $[18] = t9; - } else { - t9 = $[18]; - } - let t10; - if ($[19] !== t8) { - t10 = { - label: Yes, and don't ask again for {t8}{" "}commands in {t9}, - value: "yes-dont-ask-again" - }; - $[19] = t8; - $[20] = t10; - } else { - t10 = $[20]; - } - result.push(t10); + result.push({ + label: ( + + Yes, and don't ask again for {userFacingName}{' '} + commands in {originalCwd} + + ), + value: 'yes-dont-ask-again', + }) } - let t8; - if ($[21] === Symbol.for("react.memo_cache_sentinel")) { - t8 = { - label: "No", - value: "no", - feedbackConfig: { - type: "reject" - } - }; - $[21] = t8; - } else { - t8 = $[21]; - } - result.push(t8); - $[16] = userFacingName; - $[17] = result; - } else { - result = $[17]; - } - const options = result; - let t8; - if ($[22] !== toolUseConfirm.tool.name) { - t8 = sanitizeToolNameForAnalytics(toolUseConfirm.tool.name); - $[22] = toolUseConfirm.tool.name; - $[23] = t8; - } else { - t8 = $[23]; - } - const t9 = toolUseConfirm.tool.isMcp ?? false; - let t10; - if ($[24] !== t8 || $[25] !== t9) { - t10 = { - toolName: t8, - isMcp: t9 - }; - $[24] = t8; - $[25] = t9; - $[26] = t10; - } else { - t10 = $[26]; - } - const toolAnalyticsContext = t10; - let t11; - if ($[27] !== theme || $[28] !== toolUseConfirm.input || $[29] !== toolUseConfirm.tool) { - t11 = toolUseConfirm.tool.renderToolUseMessage(toolUseConfirm.input as never, { - theme, - verbose: true - }); - $[27] = theme; - $[28] = toolUseConfirm.input; - $[29] = toolUseConfirm.tool; - $[30] = t11; - } else { - t11 = $[30]; - } - let t12; - if ($[31] !== originalUserFacingName) { - t12 = originalUserFacingName.endsWith(" (MCP)") ? (MCP) : ""; - $[31] = originalUserFacingName; - $[32] = t12; - } else { - t12 = $[32]; - } - let t13; - if ($[33] !== t11 || $[34] !== t12 || $[35] !== userFacingName) { - t13 = {userFacingName}({t11}){t12}; - $[33] = t11; - $[34] = t12; - $[35] = userFacingName; - $[36] = t13; - } else { - t13 = $[36]; - } - let t14; - if ($[37] !== toolUseConfirm.description) { - t14 = truncateToLines(toolUseConfirm.description, 3); - $[37] = toolUseConfirm.description; - $[38] = t14; - } else { - t14 = $[38]; - } - let t15; - if ($[39] !== t14) { - t15 = {t14}; - $[39] = t14; - $[40] = t15; - } else { - t15 = $[40]; - } - let t16; - if ($[41] !== t13 || $[42] !== t15) { - t16 = {t13}{t15}; - $[41] = t13; - $[42] = t15; - $[43] = t16; - } else { - t16 = $[43]; - } - let t17; - if ($[44] !== toolUseConfirm.permissionResult) { - t17 = ; - $[44] = toolUseConfirm.permissionResult; - $[45] = t17; - } else { - t17 = $[45]; - } - let t18; - if ($[46] !== handleCancel || $[47] !== handleSelect || $[48] !== options || $[49] !== toolAnalyticsContext) { - t18 = ; - $[46] = handleCancel; - $[47] = handleSelect; - $[48] = options; - $[49] = toolAnalyticsContext; - $[50] = t18; - } else { - t18 = $[50]; - } - let t19; - if ($[51] !== t17 || $[52] !== t18) { - t19 = {t17}{t18}; - $[51] = t17; - $[52] = t18; - $[53] = t19; - } else { - t19 = $[53]; - } - let t20; - if ($[54] !== t16 || $[55] !== t19 || $[56] !== workerBadge) { - t20 = {t16}{t19}; - $[54] = t16; - $[55] = t19; - $[56] = workerBadge; - $[57] = t20; - } else { - t20 = $[57]; - } - return t20; + + result.push({ + label: 'No', + value: 'no', + feedbackConfig: { type: 'reject' }, + }) + + return result + }, [userFacingName, originalCwd, showAlwaysAllowOptions]) + + const toolAnalyticsContext = useMemo( + (): ToolAnalyticsContext => ({ + toolName: sanitizeToolNameForAnalytics(toolUseConfirm.tool.name), + isMcp: toolUseConfirm.tool.isMcp ?? false, + }), + [toolUseConfirm.tool.name, toolUseConfirm.tool.isMcp], + ) + + return ( + + + + {userFacingName}( + {toolUseConfirm.tool.renderToolUseMessage( + toolUseConfirm.input as never, + { theme, verbose: true }, + )} + ) + {originalUserFacingName.endsWith(' (MCP)') ? ( + (MCP) + ) : ( + '' + )} + + {truncateToLines(toolUseConfirm.description, 3)} + + + + + + + + ) } diff --git a/src/components/permissions/FileEditPermissionRequest/FileEditPermissionRequest.tsx b/src/components/permissions/FileEditPermissionRequest/FileEditPermissionRequest.tsx index 44f620ce5..d3bae2c17 100644 --- a/src/components/permissions/FileEditPermissionRequest/FileEditPermissionRequest.tsx +++ b/src/components/permissions/FileEditPermissionRequest/FileEditPermissionRequest.tsx @@ -1,181 +1,79 @@ -import { c as _c } from "react/compiler-runtime"; -import { basename, relative } from 'path'; -import React from 'react'; -import { FileEditToolDiff } from 'src/components/FileEditToolDiff.js'; -import { getCwd } from 'src/utils/cwd.js'; -import type { z } from 'zod/v4'; -import { Text } from '../../../ink.js'; -import { FileEditTool } from '../../../tools/FileEditTool/FileEditTool.js'; -import { FilePermissionDialog } from '../FilePermissionDialog/FilePermissionDialog.js'; -import { createSingleEditDiffConfig, type FileEdit, type IDEDiffSupport } from '../FilePermissionDialog/ideDiffConfig.js'; -import type { PermissionRequestProps } from '../PermissionRequest.js'; -type FileEditInput = z.infer; +import { basename, relative } from 'path' +import React from 'react' +import { FileEditToolDiff } from 'src/components/FileEditToolDiff.js' +import { getCwd } from 'src/utils/cwd.js' +import type { z } from 'zod/v4' +import { Text } from '../../../ink.js' +import { FileEditTool } from '../../../tools/FileEditTool/FileEditTool.js' +import { FilePermissionDialog } from '../FilePermissionDialog/FilePermissionDialog.js' +import { + createSingleEditDiffConfig, + type FileEdit, + type IDEDiffSupport, +} from '../FilePermissionDialog/ideDiffConfig.js' +import type { PermissionRequestProps } from '../PermissionRequest.js' + +type FileEditInput = z.infer + const ideDiffSupport: IDEDiffSupport = { - getConfig: (input: FileEditInput) => createSingleEditDiffConfig(input.file_path, input.old_string, input.new_string, input.replace_all), + getConfig: (input: FileEditInput) => + createSingleEditDiffConfig( + input.file_path, + input.old_string, + input.new_string, + input.replace_all, + ), applyChanges: (input: FileEditInput, modifiedEdits: FileEdit[]) => { - const firstEdit = modifiedEdits[0]; + const firstEdit = modifiedEdits[0] if (firstEdit) { return { ...input, old_string: firstEdit.old_string, new_string: firstEdit.new_string, - replace_all: firstEdit.replace_all - }; + replace_all: firstEdit.replace_all, + } } - return input; - } -}; -export function FileEditPermissionRequest(props) { - const $ = _c(51); - const parseInput = _temp; - let T0; - let T1; - let T2; - let file_path; - let new_string; - let old_string; - let replace_all; - let t0; - let t1; - let t10; - let t2; - let t3; - let t4; - let t5; - let t6; - let t7; - let t8; - let t9; - if ($[0] !== props.onDone || $[1] !== props.onReject || $[2] !== props.toolUseConfirm || $[3] !== props.toolUseContext || $[4] !== props.workerBadge) { - const parsed = parseInput(props.toolUseConfirm.input); - ({ - file_path, - old_string, - new_string, - replace_all - } = parsed); - T2 = FilePermissionDialog; - t4 = props.toolUseConfirm; - t5 = props.toolUseContext; - t6 = props.onDone; - t7 = props.onReject; - t8 = props.workerBadge; - t9 = "Edit file"; - t10 = relative(getCwd(), file_path); - T1 = Text; - t2 = "Do you want to make this edit to"; - t3 = " "; - T0 = Text; - t0 = true; - t1 = basename(file_path); - $[0] = props.onDone; - $[1] = props.onReject; - $[2] = props.toolUseConfirm; - $[3] = props.toolUseContext; - $[4] = props.workerBadge; - $[5] = T0; - $[6] = T1; - $[7] = T2; - $[8] = file_path; - $[9] = new_string; - $[10] = old_string; - $[11] = replace_all; - $[12] = t0; - $[13] = t1; - $[14] = t10; - $[15] = t2; - $[16] = t3; - $[17] = t4; - $[18] = t5; - $[19] = t6; - $[20] = t7; - $[21] = t8; - $[22] = t9; - } else { - T0 = $[5]; - T1 = $[6]; - T2 = $[7]; - file_path = $[8]; - new_string = $[9]; - old_string = $[10]; - replace_all = $[11]; - t0 = $[12]; - t1 = $[13]; - t10 = $[14]; - t2 = $[15]; - t3 = $[16]; - t4 = $[17]; - t5 = $[18]; - t6 = $[19]; - t7 = $[20]; - t8 = $[21]; - t9 = $[22]; - } - let t11; - if ($[23] !== T0 || $[24] !== t0 || $[25] !== t1) { - t11 = {t1}; - $[23] = T0; - $[24] = t0; - $[25] = t1; - $[26] = t11; - } else { - t11 = $[26]; - } - let t12; - if ($[27] !== T1 || $[28] !== t11 || $[29] !== t2 || $[30] !== t3) { - t12 = {t2}{t3}{t11}?; - $[27] = T1; - $[28] = t11; - $[29] = t2; - $[30] = t3; - $[31] = t12; - } else { - t12 = $[31]; - } - const t13 = replace_all || false; - let t14; - if ($[32] !== new_string || $[33] !== old_string || $[34] !== t13) { - t14 = [{ - old_string, - new_string, - replace_all: t13 - }]; - $[32] = new_string; - $[33] = old_string; - $[34] = t13; - $[35] = t14; - } else { - t14 = $[35]; - } - let t15; - if ($[36] !== file_path || $[37] !== t14) { - t15 = ; - $[36] = file_path; - $[37] = t14; - $[38] = t15; - } else { - t15 = $[38]; - } - let t16; - if ($[39] !== T2 || $[40] !== file_path || $[41] !== t10 || $[42] !== t12 || $[43] !== t15 || $[44] !== t4 || $[45] !== t5 || $[46] !== t6 || $[47] !== t7 || $[48] !== t8 || $[49] !== t9) { - t16 = ; - $[39] = T2; - $[40] = file_path; - $[41] = t10; - $[42] = t12; - $[43] = t15; - $[44] = t4; - $[45] = t5; - $[46] = t6; - $[47] = t7; - $[48] = t8; - $[49] = t9; - $[50] = t16; - } else { - t16 = $[50]; - } - return t16; + return input + }, } -function _temp(input) { - return FileEditTool.inputSchema.parse(input); + +export function FileEditPermissionRequest( + props: PermissionRequestProps, +): React.ReactNode { + const parseInput = (input: unknown): FileEditInput => { + return FileEditTool.inputSchema.parse(input) + } + + const parsed = parseInput(props.toolUseConfirm.input) + const { file_path, old_string, new_string, replace_all } = parsed + + return ( + + Do you want to make this edit to{' '} + {basename(file_path)}? + + } + content={ + + } + path={file_path} + completionType="str_replace_single" + parseInput={parseInput} + ideDiffSupport={ideDiffSupport} + /> + ) } diff --git a/src/components/permissions/FilePermissionDialog/FilePermissionDialog.tsx b/src/components/permissions/FilePermissionDialog/FilePermissionDialog.tsx index 5814be3e9..b645949dc 100644 --- a/src/components/permissions/FilePermissionDialog/FilePermissionDialog.tsx +++ b/src/components/permissions/FilePermissionDialog/FilePermissionDialog.tsx @@ -1,50 +1,61 @@ -import { relative } from 'path'; -import React, { useMemo } from 'react'; -import { useDiffInIDE } from '../../../hooks/useDiffInIDE.js'; -import { Box, Text } from '../../../ink.js'; -import type { ToolUseContext } from '../../../Tool.js'; -import { getLanguageName } from '../../../utils/cliHighlight.js'; -import { getCwd } from '../../../utils/cwd.js'; -import { getFsImplementation, safeResolvePath } from '../../../utils/fsOperations.js'; -import { expandPath } from '../../../utils/path.js'; -import type { CompletionType } from '../../../utils/unaryLogging.js'; -import { Select } from '../../CustomSelect/index.js'; -import { ShowInIDEPrompt } from '../../ShowInIDEPrompt.js'; -import { usePermissionRequestLogging } from '../hooks.js'; -import { PermissionDialog } from '../PermissionDialog.js'; -import type { ToolUseConfirm } from '../PermissionRequest.js'; -import type { WorkerBadgeProps } from '../WorkerBadge.js'; -import type { IDEDiffSupport } from './ideDiffConfig.js'; -import type { FileOperationType, PermissionOption } from './permissionOptions.js'; -import { type ToolInput, useFilePermissionDialog } from './useFilePermissionDialog.js'; +import { relative } from 'path' +import React, { useMemo } from 'react' +import { useDiffInIDE } from '../../../hooks/useDiffInIDE.js' +import { Box, Text } from '../../../ink.js' +import type { ToolUseContext } from '../../../Tool.js' +import { getLanguageName } from '../../../utils/cliHighlight.js' +import { getCwd } from '../../../utils/cwd.js' +import { + getFsImplementation, + safeResolvePath, +} from '../../../utils/fsOperations.js' +import { expandPath } from '../../../utils/path.js' +import type { CompletionType } from '../../../utils/unaryLogging.js' +import { Select } from '../../CustomSelect/index.js' +import { ShowInIDEPrompt } from '../../ShowInIDEPrompt.js' +import { usePermissionRequestLogging } from '../hooks.js' +import { PermissionDialog } from '../PermissionDialog.js' +import type { ToolUseConfirm } from '../PermissionRequest.js' +import type { WorkerBadgeProps } from '../WorkerBadge.js' +import type { IDEDiffSupport } from './ideDiffConfig.js' +import type { + FileOperationType, + PermissionOption, +} from './permissionOptions.js' +import { + type ToolInput, + useFilePermissionDialog, +} from './useFilePermissionDialog.js' + export type FilePermissionDialogProps = { // Required props from PermissionRequestProps - toolUseConfirm: ToolUseConfirm; - toolUseContext: ToolUseContext; - onDone: () => void; - onReject: () => void; + toolUseConfirm: ToolUseConfirm + toolUseContext: ToolUseContext + onDone: () => void + onReject: () => void // Dialog customization - title: string; - subtitle?: React.ReactNode; - question?: string | React.ReactNode; - content?: React.ReactNode; // Can be general content or diff component + title: string + subtitle?: React.ReactNode + question?: string | React.ReactNode + content?: React.ReactNode // Can be general content or diff component // Logging - completionType?: CompletionType; - languageName?: string; // override — derived from path when omitted + completionType?: CompletionType + languageName?: string // override — derived from path when omitted // File/directory operations - path: string | null; - parseInput: (input: unknown) => T; - operationType?: FileOperationType; + path: string | null + parseInput: (input: unknown) => T + operationType?: FileOperationType // IDE diff support - ideDiffSupport?: IDEDiffSupport; + ideDiffSupport?: IDEDiffSupport // Worker badge for teammate permission requests - workerBadge: WorkerBadgeProps | undefined; -}; + workerBadge: WorkerBadgeProps | undefined +} + export function FilePermissionDialog({ toolUseConfirm, toolUseContext, @@ -60,33 +71,38 @@ export function FilePermissionDialog({ operationType = 'write', ideDiffSupport, workerBadge, - languageName: languageNameOverride + languageName: languageNameOverride, }: FilePermissionDialogProps): React.ReactNode { // Derive from path unless caller provided an explicit override (NotebookEdit // passes 'python'/'markdown' from cell_type). getLanguageName is async; // downstream UnaryEvent.language_name and logPermissionEvent already accept // Promise. useMemo keeps the promise stable across renders. - const languageName = useMemo(() => languageNameOverride ?? (path ? getLanguageName(path) : 'none'), [languageNameOverride, path]); - const unaryEvent = useMemo(() => ({ - completion_type: completionType, - language_name: languageName - }), [completionType, languageName]); - usePermissionRequestLogging(toolUseConfirm, unaryEvent); + const languageName = useMemo( + () => languageNameOverride ?? (path ? getLanguageName(path) : 'none'), + [languageNameOverride, path], + ) + const unaryEvent = useMemo( + () => ({ + completion_type: completionType, + language_name: languageName, + }), + [completionType, languageName], + ) + usePermissionRequestLogging(toolUseConfirm, unaryEvent) + const symlinkTarget = useMemo(() => { if (!path || operationType === 'read') { - return null; + return null } - const expandedPath = expandPath(path); - const fs = getFsImplementation(); - const { - resolvedPath, - isSymlink - } = safeResolvePath(fs, expandedPath); + const expandedPath = expandPath(path) + const fs = getFsImplementation() + const { resolvedPath, isSymlink } = safeResolvePath(fs, expandedPath) if (isSymlink) { - return resolvedPath; + return resolvedPath } - return null; - }, [path, operationType]); + return null + }, [path, operationType]) + const fileDialogResult = useFilePermissionDialog({ filePath: path || '', completionType, @@ -95,8 +111,8 @@ export function FilePermissionDialog({ onDone, onReject, parseInput, - operationType - }); + operationType, + }) // Use file dialog results for options const { @@ -107,97 +123,150 @@ export function FilePermissionDialog({ handleInputModeToggle, focusedOption, yesInputMode, - noInputMode - } = fileDialogResult; + noInputMode, + } = fileDialogResult // Parse input using the provided parser - const parsedInput = parseInput(toolUseConfirm.input); + const parsedInput = parseInput(toolUseConfirm.input) // Set up IDE diff support if enabled. Memoized: getConfig may do disk I/O // (FileWrite's getConfig calls readFileSync for the old-content diff). // Keyed on the raw input — parseInput is a pure Zod parse whose result // depends only on toolUseConfirm.input. - const ideDiffConfig = useMemo(() => ideDiffSupport ? ideDiffSupport.getConfig(parseInput(toolUseConfirm.input)) : null, [ideDiffSupport, toolUseConfirm.input]); + const ideDiffConfig = useMemo( + () => + ideDiffSupport + ? ideDiffSupport.getConfig(parseInput(toolUseConfirm.input)) + : null, + [ideDiffSupport, toolUseConfirm.input], + ) // Create diff params based on whether IDE diff is available - const diffParams = ideDiffConfig ? { - onChange: (option: PermissionOption, input: { - file_path: string; - edits: Array<{ - old_string: string; - new_string: string; - replace_all?: boolean; - }>; - }) => { - const transformedInput = ideDiffSupport!.applyChanges(parsedInput, input.edits); - fileDialogResult.onChange(option, transformedInput); - }, - toolUseContext, - filePath: ideDiffConfig.filePath, - edits: (ideDiffConfig.edits || []).map(e => ({ - old_string: e.old_string, - new_string: e.new_string, - replace_all: e.replace_all || false - })), - editMode: ideDiffConfig.editMode || 'single' - } : { - onChange: () => {}, - toolUseContext, - filePath: '', - edits: [], - editMode: 'single' as const - }; - const { - closeTabInIDE, - showingDiffInIDE, - ideName - } = useDiffInIDE(diffParams); - const onChange = (option_0: PermissionOption, feedback?: string) => { - closeTabInIDE?.(); - fileDialogResult.onChange(option_0, parsedInput, feedback?.trim()); - }; - if (showingDiffInIDE && ideDiffConfig && path) { - return onChange(option_1, feedback_0)} options={options} filePath={path} input={parsedInput} ideName={ideName} symlinkTarget={symlinkTarget} rejectFeedback={rejectFeedback} acceptFeedback={acceptFeedback} setFocusedOption={setFocusedOption} onInputModeToggle={handleInputModeToggle} focusedOption={focusedOption} yesInputMode={yesInputMode} noInputMode={noInputMode} />; + const diffParams = ideDiffConfig + ? { + onChange: ( + option: PermissionOption, + input: { + file_path: string + edits: Array<{ + old_string: string + new_string: string + replace_all?: boolean + }> + }, + ) => { + const transformedInput = ideDiffSupport!.applyChanges( + parsedInput, + input.edits, + ) + fileDialogResult.onChange(option, transformedInput) + }, + toolUseContext, + filePath: ideDiffConfig.filePath, + edits: (ideDiffConfig.edits || []).map(e => ({ + old_string: e.old_string, + new_string: e.new_string, + replace_all: e.replace_all || false, + })), + editMode: ideDiffConfig.editMode || 'single', + } + : { + onChange: () => {}, + toolUseContext, + filePath: '', + edits: [], + editMode: 'single' as const, + } + + const { closeTabInIDE, showingDiffInIDE, ideName } = useDiffInIDE(diffParams) + + const onChange = (option: PermissionOption, feedback?: string) => { + closeTabInIDE?.() + fileDialogResult.onChange(option, parsedInput, feedback?.trim()) } - const isSymlinkOutsideCwd = symlinkTarget != null && relative(getCwd(), symlinkTarget).startsWith('..'); - const symlinkWarning = symlinkTarget ? + + if (showingDiffInIDE && ideDiffConfig && path) { + return ( + + onChange(option, feedback) + } + options={options} + filePath={path} + input={parsedInput} + ideName={ideName} + symlinkTarget={symlinkTarget} + rejectFeedback={rejectFeedback} + acceptFeedback={acceptFeedback} + setFocusedOption={setFocusedOption} + onInputModeToggle={handleInputModeToggle} + focusedOption={focusedOption} + yesInputMode={yesInputMode} + noInputMode={noInputMode} + /> + ) + } + + const isSymlinkOutsideCwd = + symlinkTarget != null && relative(getCwd(), symlinkTarget).startsWith('..') + + const symlinkWarning = symlinkTarget ? ( + - {isSymlinkOutsideCwd ? `This will modify ${symlinkTarget} (outside working directory) via a symlink` : `Symlink target: ${symlinkTarget}`} + {isSymlinkOutsideCwd + ? `This will modify ${symlinkTarget} (outside working directory) via a symlink` + : `Symlink target: ${symlinkTarget}`} - : null; - return <> - + + ) : null + + return ( + <> + {symlinkWarning} {content} {typeof question === 'string' ? {question} : question} - { + const selected = options.find(opt => opt.value === value) + if (selected) { + // For reject option + if (selected.option.type === 'reject') { + const trimmedFeedback = rejectFeedback.trim() + onChange(selected.option, trimmedFeedback || undefined) + return + } + // For accept-once option, pass accept feedback if present + if (selected.option.type === 'accept-once') { + const trimmedFeedback = acceptFeedback.trim() + onChange(selected.option, trimmedFeedback || undefined) + return + } + onChange(selected.option) + } + }} + onCancel={() => onChange({ type: 'reject' })} + onFocus={value => setFocusedOption(value)} + onInputModeToggle={handleInputModeToggle} + /> Esc to cancel - {(focusedOption === 'yes' && !yesInputMode || focusedOption === 'no' && !noInputMode) && ' · Tab to amend'} + {((focusedOption === 'yes' && !yesInputMode) || + (focusedOption === 'no' && !noInputMode)) && + ' · Tab to amend'} - ; + + ) } diff --git a/src/components/permissions/FilePermissionDialog/permissionOptions.tsx b/src/components/permissions/FilePermissionDialog/permissionOptions.tsx index e40d8df00..3a3507234 100644 --- a/src/components/permissions/FilePermissionDialog/permissionOptions.tsx +++ b/src/components/permissions/FilePermissionDialog/permissionOptions.tsx @@ -1,29 +1,37 @@ -import { homedir } from 'os'; -import { basename, join, sep } from 'path'; -import React, { type ReactNode } from 'react'; -import { getOriginalCwd } from '../../../bootstrap/state.js'; -import { Text } from '../../../ink.js'; -import { getShortcutDisplay } from '../../../keybindings/shortcutFormat.js'; -import type { ToolPermissionContext } from '../../../Tool.js'; -import { expandPath, getDirectoryForPath } from '../../../utils/path.js'; -import { normalizeCaseForComparison, pathInAllowedWorkingPath } from '../../../utils/permissions/filesystem.js'; -import type { OptionWithDescription } from '../../CustomSelect/select.js'; +import { homedir } from 'os' +import { basename, join, sep } from 'path' +import React, { type ReactNode } from 'react' +import { getOriginalCwd } from '../../../bootstrap/state.js' +import { Text } from '../../../ink.js' +import { getShortcutDisplay } from '../../../keybindings/shortcutFormat.js' +import type { ToolPermissionContext } from '../../../Tool.js' +import { expandPath, getDirectoryForPath } from '../../../utils/path.js' +import { + normalizeCaseForComparison, + pathInAllowedWorkingPath, +} from '../../../utils/permissions/filesystem.js' +import type { OptionWithDescription } from '../../CustomSelect/select.js' /** * Check if a path is within the project's .claude/ folder. * This is used to determine whether to show the special ".claude folder" permission option. */ export function isInClaudeFolder(filePath: string): boolean { - const absolutePath = expandPath(filePath); - const claudeFolderPath = expandPath(`${getOriginalCwd()}/.claude`); + const absolutePath = expandPath(filePath) + const claudeFolderPath = expandPath(`${getOriginalCwd()}/.claude`) // Check if the path is within the project's .claude folder - const normalizedAbsolutePath = normalizeCaseForComparison(absolutePath); - const normalizedClaudeFolderPath = normalizeCaseForComparison(claudeFolderPath); + const normalizedAbsolutePath = normalizeCaseForComparison(absolutePath) + const normalizedClaudeFolderPath = + normalizeCaseForComparison(claudeFolderPath) // Path must start with the .claude folder path (and be inside it, not just the folder itself) - return normalizedAbsolutePath.startsWith(normalizedClaudeFolderPath + sep.toLowerCase()) || - // Also match case where sep is / on posix systems - normalizedAbsolutePath.startsWith(normalizedClaudeFolderPath + '/'); + return ( + normalizedAbsolutePath.startsWith( + normalizedClaudeFolderPath + sep.toLowerCase(), + ) || + // Also match case where sep is / on posix systems + normalizedAbsolutePath.startsWith(normalizedClaudeFolderPath + '/') + ) } /** @@ -32,24 +40,33 @@ export function isInClaudeFolder(filePath: string): boolean { * for files in the user's home directory. */ export function isInGlobalClaudeFolder(filePath: string): boolean { - const absolutePath = expandPath(filePath); - const globalClaudeFolderPath = join(homedir(), '.claude'); - const normalizedAbsolutePath = normalizeCaseForComparison(absolutePath); - const normalizedGlobalClaudeFolderPath = normalizeCaseForComparison(globalClaudeFolderPath); - return normalizedAbsolutePath.startsWith(normalizedGlobalClaudeFolderPath + sep.toLowerCase()) || normalizedAbsolutePath.startsWith(normalizedGlobalClaudeFolderPath + '/'); + const absolutePath = expandPath(filePath) + const globalClaudeFolderPath = join(homedir(), '.claude') + + const normalizedAbsolutePath = normalizeCaseForComparison(absolutePath) + const normalizedGlobalClaudeFolderPath = normalizeCaseForComparison( + globalClaudeFolderPath, + ) + + return ( + normalizedAbsolutePath.startsWith( + normalizedGlobalClaudeFolderPath + sep.toLowerCase(), + ) || + normalizedAbsolutePath.startsWith(normalizedGlobalClaudeFolderPath + '/') + ) } -export type PermissionOption = { - type: 'accept-once'; -} | { - type: 'accept-session'; - scope?: 'claude-folder' | 'global-claude-folder'; -} | { - type: 'reject'; -}; + +export type PermissionOption = + | { type: 'accept-once' } + | { type: 'accept-session'; scope?: 'claude-folder' | 'global-claude-folder' } + | { type: 'reject' } + export type PermissionOptionWithLabel = OptionWithDescription & { - option: PermissionOption; -}; -export type FileOperationType = 'read' | 'write' | 'create'; + option: PermissionOption +} + +export type FileOperationType = 'read' | 'write' | 'create' + export function getFilePermissionOptions({ filePath, toolPermissionContext, @@ -57,18 +74,22 @@ export function getFilePermissionOptions({ onRejectFeedbackChange, onAcceptFeedbackChange, yesInputMode = false, - noInputMode = false + noInputMode = false, }: { - filePath: string; - toolPermissionContext: ToolPermissionContext; - operationType?: FileOperationType; - onRejectFeedbackChange?: (value: string) => void; - onAcceptFeedbackChange?: (value: string) => void; - yesInputMode?: boolean; - noInputMode?: boolean; + filePath: string + toolPermissionContext: ToolPermissionContext + operationType?: FileOperationType + onRejectFeedbackChange?: (value: string) => void + onAcceptFeedbackChange?: (value: string) => void + yesInputMode?: boolean + noInputMode?: boolean }): PermissionOptionWithLabel[] { - const options: PermissionOptionWithLabel[] = []; - const modeCycleShortcut = getShortcutDisplay('chat:cycleMode', 'Chat', 'shift+tab'); + const options: PermissionOptionWithLabel[] = [] + const modeCycleShortcut = getShortcutDisplay( + 'chat:cycleMode', + 'Chat', + 'shift+tab', + ) // When in input mode, show input field if (yesInputMode && onAcceptFeedbackChange) { @@ -79,24 +100,24 @@ export function getFilePermissionOptions({ placeholder: 'and tell Claude what to do next', onChange: onAcceptFeedbackChange, allowEmptySubmitToCancel: true, - option: { - type: 'accept-once' - } - }); + option: { type: 'accept-once' }, + }) } else { options.push({ label: 'Yes', value: 'yes', - option: { - type: 'accept-once' - } - }); + option: { type: 'accept-once' }, + }) } - const inAllowedPath = pathInAllowedWorkingPath(filePath, toolPermissionContext); + + const inAllowedPath = pathInAllowedWorkingPath( + filePath, + toolPermissionContext, + ) // Check if this is a .claude/ folder path (project or global) - const inClaudeFolder = isInClaudeFolder(filePath); - const inGlobalClaudeFolder = isInGlobalClaudeFolder(filePath); + const inClaudeFolder = isInClaudeFolder(filePath) + const inGlobalClaudeFolder = isInGlobalClaudeFolder(filePath) // Option 2: For .claude/ folder, show special option instead of generic session option // Note: Session-level options are always shown since they only affect in-memory state, @@ -108,45 +129,52 @@ export function getFilePermissionOptions({ value: 'yes-claude-folder', option: { type: 'accept-session', - scope: inGlobalClaudeFolder ? 'global-claude-folder' : 'claude-folder' - } - }); + scope: inGlobalClaudeFolder ? 'global-claude-folder' : 'claude-folder', + }, + }) } else { // Option 2: Allow all changes/reads during session - let sessionLabel: ReactNode; + let sessionLabel: ReactNode + if (inAllowedPath) { // Inside working directory if (operationType === 'read') { - sessionLabel = 'Yes, during this session'; + sessionLabel = 'Yes, during this session' } else { - sessionLabel = + sessionLabel = ( + Yes, allow all edits during this session{' '} ({modeCycleShortcut}) - ; + + ) } } else { // Outside working directory - include directory name - const dirPath = getDirectoryForPath(filePath); - const dirName = basename(dirPath) || 'this directory'; + const dirPath = getDirectoryForPath(filePath) + const dirName = basename(dirPath) || 'this directory' + if (operationType === 'read') { - sessionLabel = + sessionLabel = ( + Yes, allow reading from {dirName}/ during this session - ; + + ) } else { - sessionLabel = + sessionLabel = ( + Yes, allow all edits in {dirName}/ during this session ({modeCycleShortcut}) - ; + + ) } } + options.push({ label: sessionLabel, value: 'yes-session', - option: { - type: 'accept-session' - } - }); + option: { type: 'accept-session' }, + }) } // When in input mode, show input field for reject @@ -158,19 +186,16 @@ export function getFilePermissionOptions({ placeholder: 'and tell Claude what to do differently', onChange: onRejectFeedbackChange, allowEmptySubmitToCancel: true, - option: { - type: 'reject' - } - }); + option: { type: 'reject' }, + }) } else { // Not in input mode - simple option options.push({ label: 'No', value: 'no', - option: { - type: 'reject' - } - }); + option: { type: 'reject' }, + }) } - return options; + + return options } diff --git a/src/components/permissions/FileWritePermissionRequest/FileWritePermissionRequest.tsx b/src/components/permissions/FileWritePermissionRequest/FileWritePermissionRequest.tsx index eb7c6f8eb..ce352858d 100644 --- a/src/components/permissions/FileWritePermissionRequest/FileWritePermissionRequest.tsx +++ b/src/components/permissions/FileWritePermissionRequest/FileWritePermissionRequest.tsx @@ -1,160 +1,101 @@ -import { c as _c } from "react/compiler-runtime"; -import { basename, relative } from 'path'; -import React, { useMemo } from 'react'; -import type { z } from 'zod/v4'; -import { Text } from '../../../ink.js'; -import { FileWriteTool } from '../../../tools/FileWriteTool/FileWriteTool.js'; -import { getCwd } from '../../../utils/cwd.js'; -import { isENOENT } from '../../../utils/errors.js'; -import { readFileSync } from '../../../utils/fileRead.js'; -import { FilePermissionDialog } from '../FilePermissionDialog/FilePermissionDialog.js'; -import { createSingleEditDiffConfig, type FileEdit, type IDEDiffSupport } from '../FilePermissionDialog/ideDiffConfig.js'; -import type { PermissionRequestProps } from '../PermissionRequest.js'; -import { FileWriteToolDiff } from './FileWriteToolDiff.js'; -type FileWriteToolInput = z.infer; +import { basename, relative } from 'path' +import React, { useMemo } from 'react' +import type { z } from 'zod/v4' +import { Text } from '../../../ink.js' +import { FileWriteTool } from '../../../tools/FileWriteTool/FileWriteTool.js' +import { getCwd } from '../../../utils/cwd.js' +import { isENOENT } from '../../../utils/errors.js' +import { readFileSync } from '../../../utils/fileRead.js' +import { FilePermissionDialog } from '../FilePermissionDialog/FilePermissionDialog.js' +import { + createSingleEditDiffConfig, + type FileEdit, + type IDEDiffSupport, +} from '../FilePermissionDialog/ideDiffConfig.js' +import type { PermissionRequestProps } from '../PermissionRequest.js' +import { FileWriteToolDiff } from './FileWriteToolDiff.js' + +type FileWriteToolInput = z.infer + const ideDiffSupport: IDEDiffSupport = { getConfig: (input: FileWriteToolInput) => { - let oldContent: string; + let oldContent: string try { - oldContent = readFileSync(input.file_path); + oldContent = readFileSync(input.file_path) } catch (e) { - if (!isENOENT(e)) throw e; - oldContent = ''; + if (!isENOENT(e)) throw e + oldContent = '' } - return createSingleEditDiffConfig(input.file_path, oldContent, input.content, false // For file writes, we replace the entire content - ); + + return createSingleEditDiffConfig( + input.file_path, + oldContent, + input.content, + false, // For file writes, we replace the entire content + ) }, applyChanges: (input: FileWriteToolInput, modifiedEdits: FileEdit[]) => { - const firstEdit = modifiedEdits[0]; + const firstEdit = modifiedEdits[0] if (firstEdit) { return { ...input, - content: firstEdit.new_string - }; + content: firstEdit.new_string, + } } - return input; + return input + }, +} + +export function FileWritePermissionRequest( + props: PermissionRequestProps, +): React.ReactNode { + const parseInput = (input: unknown): FileWriteToolInput => { + return FileWriteTool.inputSchema.parse(input) } -}; -export function FileWritePermissionRequest(props) { - const $ = _c(30); - const parseInput = _temp; - let t0; - if ($[0] !== props.toolUseConfirm.input) { - t0 = parseInput(props.toolUseConfirm.input); - $[0] = props.toolUseConfirm.input; - $[1] = t0; - } else { - t0 = $[1]; - } - const parsed = t0; - const { - file_path, - content - } = parsed; - let t1; - if ($[2] !== file_path) { - ; + + const parsed = parseInput(props.toolUseConfirm.input) + const { file_path, content } = parsed + + // Single read drives both UI text ("Create" vs "Overwrite") and the diff + // shown by FileWriteToolDiff — avoids a redundant existsSync stat that would + // block first-mount commit on slow/networked filesystems. + const { fileExists, oldContent } = useMemo(() => { try { - t1 = { - fileExists: true, - oldContent: readFileSync(file_path) - }; - } catch (t2) { - const e = t2; - if (!isENOENT(e)) { - throw e; - } - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = { - fileExists: false, - oldContent: "" - }; - $[4] = t3; - } else { - t3 = $[4]; - } - t1 = t3; + return { fileExists: true, oldContent: readFileSync(file_path) } + } catch (e) { + if (!isENOENT(e)) throw e + return { fileExists: false, oldContent: '' } } - $[2] = file_path; - $[3] = t1; - } else { - t1 = $[3]; - } - const { - fileExists, - oldContent - } = t1; - const actionText = fileExists ? "overwrite" : "create"; - const t2 = props.toolUseConfirm; - const t3 = props.toolUseContext; - const t4 = props.onDone; - const t5 = props.onReject; - const t6 = props.workerBadge; - const t7 = fileExists ? "Overwrite file" : "Create file"; - let t8; - if ($[5] !== file_path) { - t8 = relative(getCwd(), file_path); - $[5] = file_path; - $[6] = t8; - } else { - t8 = $[6]; - } - let t9; - if ($[7] !== file_path) { - t9 = basename(file_path); - $[7] = file_path; - $[8] = t9; - } else { - t9 = $[8]; - } - let t10; - if ($[9] !== t9) { - t10 = {t9}; - $[9] = t9; - $[10] = t10; - } else { - t10 = $[10]; - } - let t11; - if ($[11] !== actionText || $[12] !== t10) { - t11 = Do you want to {actionText} {t10}?; - $[11] = actionText; - $[12] = t10; - $[13] = t11; - } else { - t11 = $[13]; - } - let t12; - if ($[14] !== content || $[15] !== fileExists || $[16] !== file_path || $[17] !== oldContent) { - t12 = ; - $[14] = content; - $[15] = fileExists; - $[16] = file_path; - $[17] = oldContent; - $[18] = t12; - } else { - t12 = $[18]; - } - let t13; - if ($[19] !== file_path || $[20] !== props.onDone || $[21] !== props.onReject || $[22] !== props.toolUseConfirm || $[23] !== props.toolUseContext || $[24] !== props.workerBadge || $[25] !== t11 || $[26] !== t12 || $[27] !== t7 || $[28] !== t8) { - t13 = ; - $[19] = file_path; - $[20] = props.onDone; - $[21] = props.onReject; - $[22] = props.toolUseConfirm; - $[23] = props.toolUseContext; - $[24] = props.workerBadge; - $[25] = t11; - $[26] = t12; - $[27] = t7; - $[28] = t8; - $[29] = t13; - } else { - t13 = $[29]; - } - return t13; -} -function _temp(input) { - return FileWriteTool.inputSchema.parse(input); + }, [file_path]) + + const actionText = fileExists ? 'overwrite' : 'create' + + return ( + + Do you want to {actionText} {basename(file_path)}? + + } + content={ + + } + path={file_path} + completionType="write_file_single" + parseInput={parseInput} + ideDiffSupport={ideDiffSupport} + /> + ) } diff --git a/src/components/permissions/FileWritePermissionRequest/FileWriteToolDiff.tsx b/src/components/permissions/FileWritePermissionRequest/FileWriteToolDiff.tsx index c9fa7e83f..36147ef03 100644 --- a/src/components/permissions/FileWritePermissionRequest/FileWriteToolDiff.tsx +++ b/src/components/permissions/FileWritePermissionRequest/FileWriteToolDiff.tsx @@ -1,88 +1,82 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useMemo } from 'react'; -import { useTerminalSize } from '../../../hooks/useTerminalSize.js'; -import { Box, NoSelect, Text } from '../../../ink.js'; -import { intersperse } from '../../../utils/array.js'; -import { getPatchForDisplay } from '../../../utils/diff.js'; -import { HighlightedCode } from '../../HighlightedCode.js'; -import { StructuredDiff } from '../../StructuredDiff.js'; +import * as React from 'react' +import { useMemo } from 'react' +import { useTerminalSize } from '../../../hooks/useTerminalSize.js' +import { Box, NoSelect, Text } from '../../../ink.js' +import { intersperse } from '../../../utils/array.js' +import { getPatchForDisplay } from '../../../utils/diff.js' +import { HighlightedCode } from '../../HighlightedCode.js' +import { StructuredDiff } from '../../StructuredDiff.js' + type Props = { - file_path: string; - content: string; - fileExists: boolean; - oldContent: string; -}; -export function FileWriteToolDiff(t0) { - const $ = _c(15); - const { - file_path, - content, - fileExists, - oldContent - } = t0; - const { - columns - } = useTerminalSize(); - let t1; - bb0: { + file_path: string + content: string + fileExists: boolean + oldContent: string +} + +export function FileWriteToolDiff({ + file_path, + content, + fileExists, + oldContent, +}: Props): React.ReactNode { + const { columns } = useTerminalSize() + const hunks = useMemo(() => { if (!fileExists) { - t1 = null; - break bb0; + return null } - let t2; - if ($[0] !== content || $[1] !== file_path || $[2] !== oldContent) { - t2 = getPatchForDisplay({ - filePath: file_path, - fileContents: oldContent, - edits: [{ + return getPatchForDisplay({ + filePath: file_path, + fileContents: oldContent, + edits: [ + { old_string: oldContent, new_string: content, - replace_all: false - }] - }); - $[0] = content; - $[1] = file_path; - $[2] = oldContent; - $[3] = t2; - } else { - t2 = $[3]; - } - t1 = t2; - } - const hunks = t1; - let t2; - if ($[4] !== content) { - t2 = content.split("\n")[0] ?? null; - $[4] = content; - $[5] = t2; - } else { - t2 = $[5]; - } - const firstLine = t2; - let t3; - if ($[6] !== columns || $[7] !== content || $[8] !== file_path || $[9] !== firstLine || $[10] !== hunks || $[11] !== oldContent) { - t3 = hunks ? intersperse(hunks.map(_ => ), _temp) : ; - $[6] = columns; - $[7] = content; - $[8] = file_path; - $[9] = firstLine; - $[10] = hunks; - $[11] = oldContent; - $[12] = t3; - } else { - t3 = $[12]; - } - let t4; - if ($[13] !== t3) { - t4 = {t3}; - $[13] = t3; - $[14] = t4; - } else { - t4 = $[14]; - } - return t4; -} -function _temp(i) { - return ...; + replace_all: false, + }, + ], + }) + }, [fileExists, file_path, oldContent, content]) + + const firstLine = content.split('\n')[0] ?? null + const paddingX = 1 + + return ( + + + {hunks ? ( + intersperse( + hunks.map(_ => ( + + )), + i => ( + + ... + + ), + ) + ) : ( + + )} + + + ) } diff --git a/src/components/permissions/FilesystemPermissionRequest/FilesystemPermissionRequest.tsx b/src/components/permissions/FilesystemPermissionRequest/FilesystemPermissionRequest.tsx index 8f5982b5a..ebfdc8817 100644 --- a/src/components/permissions/FilesystemPermissionRequest/FilesystemPermissionRequest.tsx +++ b/src/components/permissions/FilesystemPermissionRequest/FilesystemPermissionRequest.tsx @@ -1,114 +1,89 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, Text, useTheme } from '../../../ink.js'; -import { FallbackPermissionRequest } from '../FallbackPermissionRequest.js'; -import { FilePermissionDialog } from '../FilePermissionDialog/FilePermissionDialog.js'; -import type { ToolInput } from '../FilePermissionDialog/useFilePermissionDialog.js'; -import type { PermissionRequestProps, ToolUseConfirm } from '../PermissionRequest.js'; +import React from 'react' +import { Box, Text, useTheme } from '../../../ink.js' +import { FallbackPermissionRequest } from '../FallbackPermissionRequest.js' +import { FilePermissionDialog } from '../FilePermissionDialog/FilePermissionDialog.js' +import type { ToolInput } from '../FilePermissionDialog/useFilePermissionDialog.js' +import type { + PermissionRequestProps, + ToolUseConfirm, +} from '../PermissionRequest.js' + function pathFromToolUse(toolUseConfirm: ToolUseConfirm): string | null { - const tool = toolUseConfirm.tool; + const tool = toolUseConfirm.tool if ('getPath' in tool && typeof tool.getPath === 'function') { try { - return tool.getPath(toolUseConfirm.input); + return tool.getPath(toolUseConfirm.input) } catch { - return null; + return null } } - return null; + return null } -export function FilesystemPermissionRequest(t0) { - const $ = _c(30); - const { - toolUseConfirm, - onDone, - onReject, - verbose, - toolUseContext, - workerBadge - } = t0; - const [theme] = useTheme(); - let t1; - if ($[0] !== toolUseConfirm) { - t1 = pathFromToolUse(toolUseConfirm); - $[0] = toolUseConfirm; - $[1] = t1; - } else { - t1 = $[1]; - } - const path = t1; - let t2; - if ($[2] !== toolUseConfirm.input || $[3] !== toolUseConfirm.tool) { - t2 = toolUseConfirm.tool.userFacingName(toolUseConfirm.input as never); - $[2] = toolUseConfirm.input; - $[3] = toolUseConfirm.tool; - $[4] = t2; - } else { - t2 = $[4]; - } - const userFacingName = t2; - const isReadOnly = toolUseConfirm.tool.isReadOnly(toolUseConfirm.input); - const userFacingReadOrEdit = isReadOnly ? "Read" : "Edit"; - const title = `${userFacingReadOrEdit} file`; - const parseInput = _temp; + +export function FilesystemPermissionRequest({ + toolUseConfirm, + onDone, + onReject, + verbose, + toolUseContext, + workerBadge, +}: PermissionRequestProps): React.ReactNode { + const [theme] = useTheme() + const path = pathFromToolUse(toolUseConfirm) + const userFacingName = toolUseConfirm.tool.userFacingName( + toolUseConfirm.input as never, + ) + + const isReadOnly = toolUseConfirm.tool.isReadOnly(toolUseConfirm.input) + const userFacingReadOrEdit = isReadOnly ? 'Read' : 'Edit' + + // Use simple singular form - the actual operation details are shown in content + const title = `${userFacingReadOrEdit} file` + + // Simple pass-through parser since we don't need to transform the input + const parseInput = (input: unknown): ToolInput => input as ToolInput + + // Fall back to generic permission request if no path is found if (!path) { - let t3; - if ($[5] !== onDone || $[6] !== onReject || $[7] !== toolUseConfirm || $[8] !== toolUseContext || $[9] !== verbose || $[10] !== workerBadge) { - t3 = ; - $[5] = onDone; - $[6] = onReject; - $[7] = toolUseConfirm; - $[8] = toolUseContext; - $[9] = verbose; - $[10] = workerBadge; - $[11] = t3; - } else { - t3 = $[11]; - } - return t3; + return ( + + ) } - let t3; - if ($[12] !== theme || $[13] !== toolUseConfirm.input || $[14] !== toolUseConfirm.tool || $[15] !== verbose) { - t3 = toolUseConfirm.tool.renderToolUseMessage(toolUseConfirm.input as never, { - theme, - verbose - }); - $[12] = theme; - $[13] = toolUseConfirm.input; - $[14] = toolUseConfirm.tool; - $[15] = verbose; - $[16] = t3; - } else { - t3 = $[16]; - } - let t4; - if ($[17] !== t3 || $[18] !== userFacingName) { - t4 = {userFacingName}({t3}); - $[17] = t3; - $[18] = userFacingName; - $[19] = t4; - } else { - t4 = $[19]; - } - const content = t4; - const t5 = isReadOnly ? "read" : "write"; - let t6; - if ($[20] !== content || $[21] !== onDone || $[22] !== onReject || $[23] !== path || $[24] !== t5 || $[25] !== title || $[26] !== toolUseConfirm || $[27] !== toolUseContext || $[28] !== workerBadge) { - t6 = ; - $[20] = content; - $[21] = onDone; - $[22] = onReject; - $[23] = path; - $[24] = t5; - $[25] = title; - $[26] = toolUseConfirm; - $[27] = toolUseContext; - $[28] = workerBadge; - $[29] = t6; - } else { - t6 = $[29]; - } - return t6; -} -function _temp(input) { - return input as ToolInput; + + // Render tool use message content + const content = ( + + + {userFacingName}( + {toolUseConfirm.tool.renderToolUseMessage( + toolUseConfirm.input as never, + { theme, verbose }, + )} + ) + + + ) + + return ( + + ) } diff --git a/src/components/permissions/NotebookEditPermissionRequest/NotebookEditPermissionRequest.tsx b/src/components/permissions/NotebookEditPermissionRequest/NotebookEditPermissionRequest.tsx index 6b2134cd4..6c03b94d3 100644 --- a/src/components/permissions/NotebookEditPermissionRequest/NotebookEditPermissionRequest.tsx +++ b/src/components/permissions/NotebookEditPermissionRequest/NotebookEditPermissionRequest.tsx @@ -1,165 +1,77 @@ -import { c as _c } from "react/compiler-runtime"; -import { basename } from 'path'; -import React from 'react'; -import type { z } from 'zod/v4'; -import { Text } from '../../../ink.js'; -import { NotebookEditTool } from '../../../tools/NotebookEditTool/NotebookEditTool.js'; -import { logError } from '../../../utils/log.js'; -import { FilePermissionDialog } from '../FilePermissionDialog/FilePermissionDialog.js'; -import type { PermissionRequestProps } from '../PermissionRequest.js'; -import { NotebookEditToolDiff } from './NotebookEditToolDiff.js'; -type NotebookEditInput = z.infer; -export function NotebookEditPermissionRequest(props) { - const $ = _c(52); - const parseInput = _temp; - let T0; - let T1; - let T2; - let language; - let notebook_path; - let parsed; - let t0; - let t1; - let t10; - let t2; - let t3; - let t4; - let t5; - let t6; - let t7; - let t8; - let t9; - if ($[0] !== props.onDone || $[1] !== props.onReject || $[2] !== props.toolUseConfirm || $[3] !== props.toolUseContext || $[4] !== props.workerBadge) { - parsed = parseInput(props.toolUseConfirm.input); - const { - notebook_path: t11, - edit_mode, - cell_type - } = parsed; - notebook_path = t11; - language = cell_type === "markdown" ? "markdown" : "python"; - const editTypeText = edit_mode === "insert" ? "insert this cell into" : edit_mode === "delete" ? "delete this cell from" : "make this edit to"; - T2 = FilePermissionDialog; - t5 = props.toolUseConfirm; - t6 = props.toolUseContext; - t7 = props.onDone; - t8 = props.onReject; - t9 = props.workerBadge; - t10 = "Edit notebook"; - T1 = Text; - t2 = "Do you want to "; - t3 = editTypeText; - t4 = " "; - T0 = Text; - t0 = true; - t1 = basename(notebook_path); - $[0] = props.onDone; - $[1] = props.onReject; - $[2] = props.toolUseConfirm; - $[3] = props.toolUseContext; - $[4] = props.workerBadge; - $[5] = T0; - $[6] = T1; - $[7] = T2; - $[8] = language; - $[9] = notebook_path; - $[10] = parsed; - $[11] = t0; - $[12] = t1; - $[13] = t10; - $[14] = t2; - $[15] = t3; - $[16] = t4; - $[17] = t5; - $[18] = t6; - $[19] = t7; - $[20] = t8; - $[21] = t9; - } else { - T0 = $[5]; - T1 = $[6]; - T2 = $[7]; - language = $[8]; - notebook_path = $[9]; - parsed = $[10]; - t0 = $[11]; - t1 = $[12]; - t10 = $[13]; - t2 = $[14]; - t3 = $[15]; - t4 = $[16]; - t5 = $[17]; - t6 = $[18]; - t7 = $[19]; - t8 = $[20]; - t9 = $[21]; +import { basename } from 'path' +import React from 'react' +import type { z } from 'zod/v4' +import { Text } from '../../../ink.js' +import { NotebookEditTool } from '../../../tools/NotebookEditTool/NotebookEditTool.js' +import { logError } from '../../../utils/log.js' +import { FilePermissionDialog } from '../FilePermissionDialog/FilePermissionDialog.js' +import type { PermissionRequestProps } from '../PermissionRequest.js' +import { NotebookEditToolDiff } from './NotebookEditToolDiff.js' + +type NotebookEditInput = z.infer + +export function NotebookEditPermissionRequest( + props: PermissionRequestProps, +): React.ReactNode { + const parseInput = (input: unknown): NotebookEditInput => { + const result = NotebookEditTool.inputSchema.safeParse(input) + if (!result.success) { + logError( + new Error( + `Failed to parse notebook edit input: ${result.error.message}`, + ), + ) + // Return a default value to avoid crashing + return { + notebook_path: '', + new_source: '', + cell_id: '', + } as NotebookEditInput + } + return result.data } - let t11; - if ($[22] !== T0 || $[23] !== t0 || $[24] !== t1) { - t11 = {t1}; - $[22] = T0; - $[23] = t0; - $[24] = t1; - $[25] = t11; - } else { - t11 = $[25]; - } - let t12; - if ($[26] !== T1 || $[27] !== t11 || $[28] !== t2 || $[29] !== t3 || $[30] !== t4) { - t12 = {t2}{t3}{t4}{t11}?; - $[26] = T1; - $[27] = t11; - $[28] = t2; - $[29] = t3; - $[30] = t4; - $[31] = t12; - } else { - t12 = $[31]; - } - const t13 = props.verbose ? 120 : 80; - let t14; - if ($[32] !== parsed.cell_id || $[33] !== parsed.cell_type || $[34] !== parsed.edit_mode || $[35] !== parsed.new_source || $[36] !== parsed.notebook_path || $[37] !== props.verbose || $[38] !== t13) { - t14 = ; - $[32] = parsed.cell_id; - $[33] = parsed.cell_type; - $[34] = parsed.edit_mode; - $[35] = parsed.new_source; - $[36] = parsed.notebook_path; - $[37] = props.verbose; - $[38] = t13; - $[39] = t14; - } else { - t14 = $[39]; - } - let t15; - if ($[40] !== T2 || $[41] !== language || $[42] !== notebook_path || $[43] !== t10 || $[44] !== t12 || $[45] !== t14 || $[46] !== t5 || $[47] !== t6 || $[48] !== t7 || $[49] !== t8 || $[50] !== t9) { - t15 = ; - $[40] = T2; - $[41] = language; - $[42] = notebook_path; - $[43] = t10; - $[44] = t12; - $[45] = t14; - $[46] = t5; - $[47] = t6; - $[48] = t7; - $[49] = t8; - $[50] = t9; - $[51] = t15; - } else { - t15 = $[51]; - } - return t15; -} -function _temp(input) { - const result = NotebookEditTool.inputSchema.safeParse(input); - if (!result.success) { - logError(new Error(`Failed to parse notebook edit input: ${result.error.message}`)); - return { - notebook_path: "", - new_source: "", - cell_id: "" - } as NotebookEditInput; - } - return result.data; + + const parsed = parseInput(props.toolUseConfirm.input) + const { notebook_path, edit_mode, cell_type } = parsed + + const language = cell_type === 'markdown' ? 'markdown' : 'python' + + const editTypeText = + edit_mode === 'insert' + ? 'insert this cell into' + : edit_mode === 'delete' + ? 'delete this cell from' + : 'make this edit to' + + return ( + + Do you want to {editTypeText}{' '} + {basename(notebook_path)}? + + } + content={ + + } + path={notebook_path} + completionType="tool_use_single" + languageName={language} + parseInput={parseInput} + /> + ) } diff --git a/src/components/permissions/NotebookEditPermissionRequest/NotebookEditToolDiff.tsx b/src/components/permissions/NotebookEditPermissionRequest/NotebookEditToolDiff.tsx index 13e073aed..9b5373142 100644 --- a/src/components/permissions/NotebookEditPermissionRequest/NotebookEditToolDiff.tsx +++ b/src/components/permissions/NotebookEditPermissionRequest/NotebookEditToolDiff.tsx @@ -1,234 +1,172 @@ -import { c as _c } from "react/compiler-runtime"; -import { relative } from 'path'; -import * as React from 'react'; -import { Suspense, use, useMemo } from 'react'; -import { Box, NoSelect, Text } from '../../../ink.js'; -import type { NotebookCellType, NotebookContent } from '../../../types/notebook.js'; -import { intersperse } from '../../../utils/array.js'; -import { getCwd } from '../../../utils/cwd.js'; -import { getPatchForDisplay } from '../../../utils/diff.js'; -import { getFsImplementation } from '../../../utils/fsOperations.js'; -import { safeParseJSON } from '../../../utils/json.js'; -import { parseCellId } from '../../../utils/notebook.js'; -import { HighlightedCode } from '../../HighlightedCode.js'; -import { StructuredDiff } from '../../StructuredDiff.js'; +import { relative } from 'path' +import * as React from 'react' +import { Suspense, use, useMemo } from 'react' +import { Box, NoSelect, Text } from '../../../ink.js' +import type { + NotebookCellType, + NotebookContent, +} from '../../../types/notebook.js' +import { intersperse } from '../../../utils/array.js' +import { getCwd } from '../../../utils/cwd.js' +import { getPatchForDisplay } from '../../../utils/diff.js' +import { getFsImplementation } from '../../../utils/fsOperations.js' +import { safeParseJSON } from '../../../utils/json.js' +import { parseCellId } from '../../../utils/notebook.js' +import { HighlightedCode } from '../../HighlightedCode.js' +import { StructuredDiff } from '../../StructuredDiff.js' + type Props = { - notebook_path: string; - cell_id: string | undefined; - new_source: string; - cell_type?: NotebookCellType; - edit_mode?: string; - verbose: boolean; - width: number; -}; + notebook_path: string + cell_id: string | undefined + new_source: string + cell_type?: NotebookCellType + edit_mode?: string + verbose: boolean + width: number +} + type InnerProps = { - notebook_path: string; - cell_id: string | undefined; - new_source: string; - cell_type?: NotebookCellType; - edit_mode?: string; - verbose: boolean; - width: number; - promise: Promise; -}; -export function NotebookEditToolDiff(props: Props) { - const $ = _c(5); - let t0; - if ($[0] !== props.notebook_path) { - t0 = getFsImplementation().readFile(props.notebook_path, { - encoding: "utf-8" - }).then(_temp).catch(_temp2); - $[0] = props.notebook_path; - $[1] = t0; - } else { - t0 = $[1]; - } - const notebookDataPromise = t0; - let t1; - if ($[2] !== notebookDataPromise || $[3] !== props) { - t1 = ; - $[2] = notebookDataPromise; - $[3] = props; - $[4] = t1; - } else { - t1 = $[4]; - } - return t1; + notebook_path: string + cell_id: string | undefined + new_source: string + cell_type?: NotebookCellType + edit_mode?: string + verbose: boolean + width: number + promise: Promise } -function _temp2() { - return null; + +export function NotebookEditToolDiff(props: Props): React.ReactNode { + // Create a promise that never rejects so we can handle errors inline. + // Memoized on notebook_path so we don't re-read on every render. + const notebookDataPromise = useMemo( + () => + getFsImplementation() + .readFile(props.notebook_path, { encoding: 'utf-8' }) + .then(content => safeParseJSON(content) as NotebookContent | null) + .catch(() => null), + [props.notebook_path], + ) + + return ( + + + + ) } -function _temp(content) { - return safeParseJSON(content) as NotebookContent | null; -} -function NotebookEditToolDiffInner(t0: InnerProps) { - const $ = _c(34); - const { - notebook_path, - cell_id, - new_source, - cell_type, - edit_mode: t1, - verbose, - width, - promise - } = t0; - const edit_mode = t1 === undefined ? "replace" : t1; - const notebookData = use(promise); - let t2; - if ($[0] !== cell_id || $[1] !== notebookData) { - bb0: { - if (!notebookData || !cell_id) { - t2 = ""; - break bb0; - } - const cellIndex = parseCellId(cell_id); - if (cellIndex !== undefined) { - if (notebookData.cells[cellIndex]) { - const source = notebookData.cells[cellIndex].source; - let t3; - if ($[3] !== source) { - t3 = Array.isArray(source) ? source.join("") : source; - $[3] = source; - $[4] = t3; - } else { - t3 = $[4]; - } - t2 = t3; - break bb0; - } - t2 = ""; - break bb0; - } - let t3; - if ($[5] !== cell_id) { - t3 = cell => cell.id === cell_id; - $[5] = cell_id; - $[6] = t3; - } else { - t3 = $[6]; - } - const cell_0 = notebookData.cells.find(t3); - if (!cell_0) { - t2 = ""; - break bb0; - } - t2 = Array.isArray(cell_0.source) ? cell_0.source.join("") : cell_0.source; + +function NotebookEditToolDiffInner({ + notebook_path, + cell_id, + new_source, + cell_type, + edit_mode = 'replace', + verbose, + width, + promise, +}: InnerProps): React.ReactNode { + const notebookData = use(promise) + + const oldSource = useMemo(() => { + if (!notebookData || !cell_id) { + return '' } - $[0] = cell_id; - $[1] = notebookData; - $[2] = t2; - } else { - t2 = $[2]; - } - const oldSource = t2; - let t3; - bb1: { - if (!notebookData || edit_mode === "insert" || edit_mode === "delete") { - t3 = null; - break bb1; + const cellIndex = parseCellId(cell_id) + if (cellIndex !== undefined) { + if (notebookData.cells[cellIndex]) { + const source = notebookData.cells[cellIndex].source + return Array.isArray(source) ? source.join('') : source + } + return '' } - let t4; - if ($[7] !== new_source || $[8] !== notebook_path || $[9] !== oldSource) { - t4 = getPatchForDisplay({ - filePath: notebook_path, - fileContents: oldSource, - edits: [{ + const cell = notebookData.cells.find(cell => cell.id === cell_id) + if (!cell) { + return '' + } + return Array.isArray(cell.source) ? cell.source.join('') : cell.source + }, [notebookData, cell_id]) + + const hunks = useMemo(() => { + if (!notebookData || edit_mode === 'insert' || edit_mode === 'delete') { + return null + } + // Create a "fake" file content with just the cell source + // This allows us to use the regular diff mechanism + return getPatchForDisplay({ + filePath: notebook_path, + fileContents: oldSource, + edits: [ + { old_string: oldSource, new_string: new_source, - replace_all: false - }], - ignoreWhitespace: false - }); - $[7] = new_source; - $[8] = notebook_path; - $[9] = oldSource; - $[10] = t4; - } else { - t4 = $[10]; - } - t3 = t4; - } - const hunks = t3; - let editTypeDescription; - bb2: switch (edit_mode) { - case "insert": - { - editTypeDescription = "Insert new cell"; - break bb2; - } - case "delete": - { - editTypeDescription = "Delete cell"; - break bb2; - } + replace_all: false, + }, + ], + ignoreWhitespace: false, + }) + }, [notebookData, notebook_path, oldSource, new_source, edit_mode]) + + let editTypeDescription: string + switch (edit_mode) { + case 'insert': + editTypeDescription = 'Insert new cell' + break + case 'delete': + editTypeDescription = 'Delete cell' + break default: - { - editTypeDescription = "Replace cell contents"; - } + editTypeDescription = 'Replace cell contents' } - let t4; - if ($[11] !== notebook_path || $[12] !== verbose) { - t4 = verbose ? notebook_path : relative(getCwd(), notebook_path); - $[11] = notebook_path; - $[12] = verbose; - $[13] = t4; - } else { - t4 = $[13]; - } - let t5; - if ($[14] !== t4) { - t5 = {t4}; - $[14] = t4; - $[15] = t5; - } else { - t5 = $[15]; - } - const t6 = cell_type ? ` (${cell_type})` : ""; - let t7; - if ($[16] !== cell_id || $[17] !== editTypeDescription || $[18] !== t6) { - t7 = {editTypeDescription} for cell {cell_id}{t6}; - $[16] = cell_id; - $[17] = editTypeDescription; - $[18] = t6; - $[19] = t7; - } else { - t7 = $[19]; - } - let t8; - if ($[20] !== t5 || $[21] !== t7) { - t8 = {t5}{t7}; - $[20] = t5; - $[21] = t7; - $[22] = t8; - } else { - t8 = $[22]; - } - let t9; - if ($[23] !== cell_type || $[24] !== edit_mode || $[25] !== hunks || $[26] !== new_source || $[27] !== notebook_path || $[28] !== oldSource || $[29] !== width) { - t9 = edit_mode === "delete" ? : edit_mode === "insert" ? : hunks ? intersperse(hunks.map(_ => ), _temp3) : ; - $[23] = cell_type; - $[24] = edit_mode; - $[25] = hunks; - $[26] = new_source; - $[27] = notebook_path; - $[28] = oldSource; - $[29] = width; - $[30] = t9; - } else { - t9 = $[30]; - } - let t10; - if ($[31] !== t8 || $[32] !== t9) { - t10 = {t8}{t9}; - $[31] = t8; - $[32] = t9; - $[33] = t10; - } else { - t10 = $[33]; - } - return t10; -} -function _temp3(i) { - return ...; + + return ( + + + + + {verbose ? notebook_path : relative(getCwd(), notebook_path)} + + + {editTypeDescription} for cell {cell_id} + {cell_type ? ` (${cell_type})` : ''} + + + {edit_mode === 'delete' ? ( + + + + ) : edit_mode === 'insert' ? ( + + + + ) : hunks ? ( + intersperse( + hunks.map(_ => ( + + )), + i => ( + + ... + + ), + ) + ) : ( + + )} + + + ) } diff --git a/src/components/permissions/PermissionDecisionDebugInfo.tsx b/src/components/permissions/PermissionDecisionDebugInfo.tsx index d877faa33..afd855343 100644 --- a/src/components/permissions/PermissionDecisionDebugInfo.tsx +++ b/src/components/permissions/PermissionDecisionDebugInfo.tsx @@ -1,459 +1,350 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import chalk from 'chalk'; -import figures from 'figures'; -import React, { useMemo } from 'react'; -import { Ansi, Box, color, Text, useTheme } from '../../ink.js'; -import { useAppState } from '../../state/AppState.js'; -import type { PermissionMode } from '../../utils/permissions/PermissionMode.js'; -import { permissionModeTitle } from '../../utils/permissions/PermissionMode.js'; -import type { PermissionDecision, PermissionDecisionReason } from '../../utils/permissions/PermissionResult.js'; -import { extractRules } from '../../utils/permissions/PermissionUpdate.js'; -import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js'; -import { permissionRuleValueToString } from '../../utils/permissions/permissionRuleParser.js'; -import { detectUnreachableRules } from '../../utils/permissions/shadowedRuleDetection.js'; -import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'; -import { getSettingSourceDisplayNameLowercase } from '../../utils/settings/constants.js'; +import { feature } from 'bun:bundle' +import chalk from 'chalk' +import figures from 'figures' +import React, { useMemo } from 'react' +import { Ansi, Box, color, Text, useTheme } from '../../ink.js' +import { useAppState } from '../../state/AppState.js' +import type { PermissionMode } from '../../utils/permissions/PermissionMode.js' +import { permissionModeTitle } from '../../utils/permissions/PermissionMode.js' +import type { + PermissionDecision, + PermissionDecisionReason, +} from '../../utils/permissions/PermissionResult.js' +import { extractRules } from '../../utils/permissions/PermissionUpdate.js' +import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js' +import { permissionRuleValueToString } from '../../utils/permissions/permissionRuleParser.js' +import { detectUnreachableRules } from '../../utils/permissions/shadowedRuleDetection.js' +import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js' +import { getSettingSourceDisplayNameLowercase } from '../../utils/settings/constants.js' + type PermissionDecisionInfoItemProps = { - title?: string; - decisionReason: PermissionDecisionReason; -}; -function decisionReasonDisplayString(decisionReason: PermissionDecisionReason & { - type: Exclude; -}): string { - if ((feature('BASH_CLASSIFIER') || feature('TRANSCRIPT_CLASSIFIER')) && decisionReason.type === 'classifier') { - return `${chalk.bold(decisionReason.classifier)} classifier: ${decisionReason.reason}`; + title?: string + decisionReason: PermissionDecisionReason +} + +function decisionReasonDisplayString( + decisionReason: PermissionDecisionReason & { + type: Exclude + }, +): string { + if ( + (feature('BASH_CLASSIFIER') || feature('TRANSCRIPT_CLASSIFIER')) && + decisionReason.type === 'classifier' + ) { + return `${chalk.bold(decisionReason.classifier)} classifier: ${decisionReason.reason}` } switch (decisionReason.type) { case 'rule': - return `${chalk.bold(permissionRuleValueToString(decisionReason.rule.ruleValue))} rule from ${getSettingSourceDisplayNameLowercase(decisionReason.rule.source)}`; + return `${chalk.bold(permissionRuleValueToString(decisionReason.rule.ruleValue))} rule from ${getSettingSourceDisplayNameLowercase(decisionReason.rule.source)}` case 'mode': - return `${permissionModeTitle(decisionReason.mode)} mode`; + return `${permissionModeTitle(decisionReason.mode)} mode` case 'sandboxOverride': - return 'Requires permission to bypass sandbox'; + return 'Requires permission to bypass sandbox' case 'workingDir': - return decisionReason.reason; + return decisionReason.reason case 'safetyCheck': case 'other': - return decisionReason.reason; + return decisionReason.reason case 'permissionPromptTool': - return `${chalk.bold(decisionReason.permissionPromptToolName)} permission prompt tool`; + return `${chalk.bold(decisionReason.permissionPromptToolName)} permission prompt tool` case 'hook': - return decisionReason.reason ? `${chalk.bold(decisionReason.hookName)} hook: ${decisionReason.reason}` : `${chalk.bold(decisionReason.hookName)} hook`; + return decisionReason.reason + ? `${chalk.bold(decisionReason.hookName)} hook: ${decisionReason.reason}` + : `${chalk.bold(decisionReason.hookName)} hook` case 'asyncAgent': - return decisionReason.reason; + return decisionReason.reason default: - return ''; + return '' } } -function PermissionDecisionInfoItem(t0) { - const $ = _c(10); - const { - title, - decisionReason - } = t0; - const [theme] = useTheme(); - let t1; - if ($[0] !== decisionReason || $[1] !== theme) { - t1 = function formatDecisionReason() { - switch (decisionReason.type) { - case "subcommandResults": - { - return {Array.from(decisionReason.reasons.entries()).map(t2 => { - const [subcommand, result] = t2 as [string, { behavior: string; decisionReason?: { type: string }; suggestions?: unknown }]; - const icon = result.behavior === "allow" ? color("success", theme)(figures.tick) : color("error", theme)(figures.cross); - return {icon} {subcommand}{result.decisionReason !== undefined && result.decisionReason.type !== "subcommandResults" && {" "}⎿{" "}{decisionReasonDisplayString(result.decisionReason as any)}}{result.behavior === "ask" && }; - })}; - } - default: - { - return {decisionReasonDisplayString(decisionReason)}; - } - } - }; - $[0] = decisionReason; - $[1] = theme; - $[2] = t1; - } else { - t1 = $[2]; - } - const formatDecisionReason = t1; - let t2; - if ($[3] !== title) { - t2 = title && {title}; - $[3] = title; - $[4] = t2; - } else { - t2 = $[4]; - } - let t3; - if ($[5] !== formatDecisionReason) { - t3 = formatDecisionReason(); - $[5] = formatDecisionReason; - $[6] = t3; - } else { - t3 = $[6]; - } - let t4; - if ($[7] !== t2 || $[8] !== t3) { - t4 = {t2}{t3}; - $[7] = t2; - $[8] = t3; - $[9] = t4; - } else { - t4 = $[9]; - } - return t4; -} -function SuggestedRules(t0) { - const $ = _c(18); - const { - suggestions - } = t0; - let T0; - let T1; - let t1; - let t2; - let t3; - let t4; - let t5; - if ($[0] !== suggestions) { - t5 = Symbol.for("react.early_return_sentinel"); - bb0: { - const rules = extractRules(suggestions); - if (rules.length === 0) { - t5 = null; - break bb0; - } - T1 = Text; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {" "}⎿{" "}; - $[8] = t2; - } else { - t2 = $[8]; - } - t3 = "Suggested rules:"; - t4 = " "; - T0 = Ansi; - t1 = rules.map(_temp).join(", "); + +function PermissionDecisionInfoItem({ + title, + decisionReason, +}: PermissionDecisionInfoItemProps): React.ReactNode { + const [theme] = useTheme() + + function formatDecisionReason(): React.ReactNode { + switch (decisionReason.type) { + case 'subcommandResults': + return ( + + {Array.from(decisionReason.reasons.entries()).map( + ([subcommand, result]) => { + const icon = + result.behavior === 'allow' + ? color('success', theme)(figures.tick) + : color('error', theme)(figures.cross) + return ( + + + {icon} {subcommand} + + {result.decisionReason !== undefined && + result.decisionReason.type !== 'subcommandResults' && ( + + + {' '}⎿{' '} + + + {decisionReasonDisplayString(result.decisionReason)} + + + )} + {result.behavior === 'ask' && ( + + )} + + ) + }, + )} + + ) + default: + return ( + + {decisionReasonDisplayString(decisionReason)} + + ) } - $[0] = suggestions; - $[1] = T0; - $[2] = T1; - $[3] = t1; - $[4] = t2; - $[5] = t3; - $[6] = t4; - $[7] = t5; - } else { - T0 = $[1]; - T1 = $[2]; - t1 = $[3]; - t2 = $[4]; - t3 = $[5]; - t4 = $[6]; - t5 = $[7]; } - if (t5 !== Symbol.for("react.early_return_sentinel")) { - return t5; - } - let t6; - if ($[9] !== T0 || $[10] !== t1) { - t6 = {t1}; - $[9] = T0; - $[10] = t1; - $[11] = t6; - } else { - t6 = $[11]; - } - let t7; - if ($[12] !== T1 || $[13] !== t2 || $[14] !== t3 || $[15] !== t4 || $[16] !== t6) { - t7 = {t2}{t3}{t4}{t6}; - $[12] = T1; - $[13] = t2; - $[14] = t3; - $[15] = t4; - $[16] = t6; - $[17] = t7; - } else { - t7 = $[17]; - } - return t7; + + return ( + + {title && {title}} + {formatDecisionReason()} + + ) } -function _temp(rule) { - return chalk.bold(permissionRuleValueToString(rule)); + +function SuggestedRules({ + suggestions, +}: { + suggestions: PermissionUpdate[] | undefined +}): React.ReactNode { + const rules = extractRules(suggestions) + if (rules.length === 0) return null + return ( + + + {' '}⎿{' '} + + Suggested rules:{' '} + + {rules + .map(rule => chalk.bold(permissionRuleValueToString(rule))) + .join(', ')} + + + ) } + type Props = { - permissionResult: PermissionDecision; - toolName?: string; // Filter unreachable rules to this tool -}; + permissionResult: PermissionDecision + toolName?: string // Filter unreachable rules to this tool +} // Helper function to extract directories from permission updates function extractDirectories(updates: PermissionUpdate[] | undefined): string[] { - if (!updates) return []; + if (!updates) return [] + return updates.flatMap(update => { switch (update.type) { case 'addDirectories': - return update.directories; + return update.directories default: - return []; + return [] } - }); + }) } // Helper function to extract mode from permission updates -function extractMode(updates: PermissionUpdate[] | undefined): PermissionMode | undefined { - if (!updates) return undefined; - const update = updates.findLast(u => u.type === 'setMode'); - return update?.type === 'setMode' ? update.mode : undefined; +function extractMode( + updates: PermissionUpdate[] | undefined, +): PermissionMode | undefined { + if (!updates) return undefined + const update = updates.findLast(u => u.type === 'setMode') + return update?.type === 'setMode' ? update.mode : undefined } -function SuggestionDisplay(t0) { - const $ = _c(22); - const { - suggestions, - width - } = t0; + +function SuggestionDisplay({ + suggestions, + width, +}: { + suggestions: PermissionUpdate[] | undefined + width: number +}): React.ReactNode { if (!suggestions || suggestions.length === 0) { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Suggestions ; - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] !== width) { - t2 = {t1}; - $[1] = width; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = None; - $[3] = t3; - } else { - t3 = $[3]; - } - let t4; - if ($[4] !== t2) { - t4 = {t2}{t3}; - $[4] = t2; - $[5] = t4; - } else { - t4 = $[5]; - } - return t4; + return ( + + + Suggestions + + None + + ) } - let t1; - let t2; - if ($[6] !== suggestions || $[7] !== width) { - t2 = Symbol.for("react.early_return_sentinel"); - bb0: { - const rules = extractRules(suggestions); - const directories = extractDirectories(suggestions); - const mode = extractMode(suggestions); - if (rules.length === 0 && directories.length === 0 && !mode) { - let t3; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t3 = Suggestion ; - $[10] = t3; - } else { - t3 = $[10]; - } - let t4; - if ($[11] !== width) { - t4 = {t3}; - $[11] = width; - $[12] = t4; - } else { - t4 = $[12]; - } - let t5; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t5 = None; - $[13] = t5; - } else { - t5 = $[13]; - } - let t6; - if ($[14] !== t4) { - t6 = {t4}{t5}; - $[14] = t4; - $[15] = t6; - } else { - t6 = $[15]; - } - t2 = t6; - break bb0; - } - let t3; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t3 = Suggestions ; - $[16] = t3; - } else { - t3 = $[16]; - } - let t4; - if ($[17] !== width) { - t4 = {t3}; - $[17] = width; - $[18] = t4; - } else { - t4 = $[18]; - } - let t5; - if ($[19] === Symbol.for("react.memo_cache_sentinel")) { - t5 = ; - $[19] = t5; - } else { - t5 = $[19]; - } - let t6; - if ($[20] !== t4) { - t6 = {t4}{t5}; - $[20] = t4; - $[21] = t6; - } else { - t6 = $[21]; - } - t1 = {t6}{rules.length > 0 && Rules {rules.map(_temp2)}}{directories.length > 0 && Directories {directories.map(_temp3)}}{mode && Mode {permissionModeTitle(mode)}}; - } - $[6] = suggestions; - $[7] = width; - $[8] = t1; - $[9] = t2; - } else { - t1 = $[8]; - t2 = $[9]; + + const rules = extractRules(suggestions) + const directories = extractDirectories(suggestions) + const mode = extractMode(suggestions) + + // If nothing to display, show None + if (rules.length === 0 && directories.length === 0 && !mode) { + return ( + + + Suggestion + + None + + ) } - if (t2 !== Symbol.for("react.early_return_sentinel")) { - return t2; - } - return t1; + + return ( + + + + Suggestions + + + + + {/* Display rules */} + {rules.length > 0 && ( + + + Rules + + + {rules.map((rule, index) => ( + + {figures.bullet} {permissionRuleValueToString(rule)} + + ))} + + + )} + + {/* Display directories */} + {directories.length > 0 && ( + + + Directories + + + {directories.map((dir, index) => ( + + {figures.bullet} {dir} + + ))} + + + )} + + {/* Display mode change */} + {mode && ( + + + Mode + + {permissionModeTitle(mode)} + + )} + + ) } -function _temp3(dir, index_0) { - return {figures.bullet} {dir}; -} -function _temp2(rule, index) { - return {figures.bullet} {permissionRuleValueToString(rule)}; -} -export function PermissionDecisionDebugInfo(t0) { - const $ = _c(25); - const { - permissionResult, - toolName - } = t0; - const toolPermissionContext = useAppState(_temp4); - const decisionReason = permissionResult.decisionReason; - const suggestions = "suggestions" in permissionResult ? permissionResult.suggestions : undefined; - let t1; - if ($[0] !== suggestions || $[1] !== toolName || $[2] !== toolPermissionContext) { - bb0: { - const sandboxAutoAllowEnabled = SandboxManager.isSandboxingEnabled() && SandboxManager.isAutoAllowBashIfSandboxedEnabled(); - const all = detectUnreachableRules(toolPermissionContext, { - sandboxAutoAllowEnabled - }); - const suggestedRules = extractRules(suggestions); - if (suggestedRules.length > 0) { - t1 = all.filter(u => suggestedRules.some(suggested => suggested.toolName === u.rule.ruleValue.toolName && suggested.ruleContent === u.rule.ruleValue.ruleContent)); - break bb0; - } - if (toolName) { - let t2; - if ($[4] !== toolName) { - t2 = u_0 => u_0.rule.ruleValue.toolName === toolName; - $[4] = toolName; - $[5] = t2; - } else { - t2 = $[5]; - } - t1 = all.filter(t2); - break bb0; - } - t1 = all; + +export function PermissionDecisionDebugInfo({ + permissionResult, + toolName, +}: Props): React.ReactNode { + const toolPermissionContext = useAppState(s => s.toolPermissionContext) + const decisionReason = permissionResult.decisionReason + const suggestions = + 'suggestions' in permissionResult ? permissionResult.suggestions : undefined + + const unreachableRules = useMemo(() => { + const sandboxAutoAllowEnabled = + SandboxManager.isSandboxingEnabled() && + SandboxManager.isAutoAllowBashIfSandboxedEnabled() + const all = detectUnreachableRules(toolPermissionContext, { + sandboxAutoAllowEnabled, + }) + + // Get the suggested rules from the permission result + const suggestedRules = extractRules(suggestions) + + // Filter to rules that match any of the suggested rules + // A rule matches if it has the same toolName and ruleContent + if (suggestedRules.length > 0) { + return all.filter(u => + suggestedRules.some( + suggested => + suggested.toolName === u.rule.ruleValue.toolName && + suggested.ruleContent === u.rule.ruleValue.ruleContent, + ), + ) } - $[0] = suggestions; - $[1] = toolName; - $[2] = toolPermissionContext; - $[3] = t1; - } else { - t1 = $[3]; - } - const unreachableRules = t1; - let t2; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Behavior ; - $[6] = t2; - } else { - t2 = $[6]; - } - let t3; - if ($[7] !== permissionResult.behavior) { - t3 = {t2}{permissionResult.behavior}; - $[7] = permissionResult.behavior; - $[8] = t3; - } else { - t3 = $[8]; - } - let t4; - if ($[9] !== permissionResult.behavior || $[10] !== permissionResult.message) { - t4 = permissionResult.behavior !== "allow" && Message {permissionResult.message}; - $[9] = permissionResult.behavior; - $[10] = permissionResult.message; - $[11] = t4; - } else { - t4 = $[11]; - } - let t5; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t5 = Reason ; - $[12] = t5; - } else { - t5 = $[12]; - } - let t6; - if ($[13] !== decisionReason) { - t6 = {t5}{decisionReason === undefined ? undefined : }; - $[13] = decisionReason; - $[14] = t6; - } else { - t6 = $[14]; - } - let t7; - if ($[15] !== suggestions) { - t7 = ; - $[15] = suggestions; - $[16] = t7; - } else { - t7 = $[16]; - } - let t8; - if ($[17] !== unreachableRules) { - t8 = unreachableRules.length > 0 && {figures.warning} Unreachable Rules ({unreachableRules.length}){unreachableRules.map(_temp5)}; - $[17] = unreachableRules; - $[18] = t8; - } else { - t8 = $[18]; - } - let t9; - if ($[19] !== t3 || $[20] !== t4 || $[21] !== t6 || $[22] !== t7 || $[23] !== t8) { - t9 = {t3}{t4}{t6}{t7}{t8}; - $[19] = t3; - $[20] = t4; - $[21] = t6; - $[22] = t7; - $[23] = t8; - $[24] = t9; - } else { - t9 = $[24]; - } - return t9; -} -function _temp5(u_1, i) { - return {permissionRuleValueToString(u_1.rule.ruleValue)}{" "}{u_1.reason}{" "}Fix: {u_1.fix}; -} -function _temp4(s) { - return s.toolPermissionContext; + + // Fallback: filter by tool name if specified + if (toolName) { + return all.filter(u => u.rule.ruleValue.toolName === toolName) + } + + return all + }, [toolPermissionContext, toolName, suggestions]) + + const WIDTH = 10 + + return ( + + + + Behavior + + {permissionResult.behavior} + + {permissionResult.behavior !== 'allow' && ( + + + Message + + {permissionResult.message} + + )} + + + Reason + + {decisionReason === undefined ? ( + undefined + ) : ( + + )} + + + {unreachableRules.length > 0 && ( + + + {figures.warning} Unreachable Rules ({unreachableRules.length}) + + {unreachableRules.map((u, i) => ( + + + {permissionRuleValueToString(u.rule.ruleValue)} + + + {' '} + {u.reason} + + + {' '}Fix: {u.fix} + + + ))} + + )} + + ) } diff --git a/src/components/permissions/PermissionDialog.tsx b/src/components/permissions/PermissionDialog.tsx index 330f1d36d..210bbb16e 100644 --- a/src/components/permissions/PermissionDialog.tsx +++ b/src/components/permissions/PermissionDialog.tsx @@ -1,71 +1,54 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box } from '../../ink.js'; -import type { Theme } from '../../utils/theme.js'; -import { PermissionRequestTitle } from './PermissionRequestTitle.js'; -import type { WorkerBadgeProps } from './WorkerBadge.js'; +import * as React from 'react' +import { Box } from '../../ink.js' +import type { Theme } from '../../utils/theme.js' +import { PermissionRequestTitle } from './PermissionRequestTitle.js' +import type { WorkerBadgeProps } from './WorkerBadge.js' + type Props = { - title: string; - subtitle?: React.ReactNode; - color?: keyof Theme; - titleColor?: keyof Theme; - innerPaddingX?: number; - workerBadge?: WorkerBadgeProps; - titleRight?: React.ReactNode; - children: React.ReactNode; -}; -export function PermissionDialog(t0) { - const $ = _c(15); - const { - title, - subtitle, - color: t1, - titleColor, - innerPaddingX: t2, - workerBadge, - titleRight, - children - } = t0; - const color = t1 === undefined ? "permission" : t1; - const innerPaddingX = t2 === undefined ? 1 : t2; - let t3; - if ($[0] !== subtitle || $[1] !== title || $[2] !== titleColor || $[3] !== workerBadge) { - t3 = ; - $[0] = subtitle; - $[1] = title; - $[2] = titleColor; - $[3] = workerBadge; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== t3 || $[6] !== titleRight) { - t4 = {t3}{titleRight}; - $[5] = t3; - $[6] = titleRight; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] !== children || $[9] !== innerPaddingX) { - t5 = {children}; - $[8] = children; - $[9] = innerPaddingX; - $[10] = t5; - } else { - t5 = $[10]; - } - let t6; - if ($[11] !== color || $[12] !== t4 || $[13] !== t5) { - t6 = {t4}{t5}; - $[11] = color; - $[12] = t4; - $[13] = t5; - $[14] = t6; - } else { - t6 = $[14]; - } - return t6; + title: string + subtitle?: React.ReactNode + color?: keyof Theme + titleColor?: keyof Theme + innerPaddingX?: number + workerBadge?: WorkerBadgeProps + titleRight?: React.ReactNode + children: React.ReactNode +} + +export function PermissionDialog({ + title, + subtitle, + color = 'permission', + titleColor, + innerPaddingX = 1, + workerBadge, + titleRight, + children, +}: Props): React.ReactNode { + return ( + + + + + {titleRight} + + + + {children} + + + ) } diff --git a/src/components/permissions/PermissionExplanation.tsx b/src/components/permissions/PermissionExplanation.tsx index 0cb5e2390..2fe08a858 100644 --- a/src/components/permissions/PermissionExplanation.tsx +++ b/src/components/permissions/PermissionExplanation.tsx @@ -1,87 +1,93 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { Suspense, use, useState } from 'react'; -import { Box, Text } from '../../ink.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import { logEvent } from '../../services/analytics/index.js'; -import type { Message } from '../../types/message.js'; -import { generatePermissionExplanation, isPermissionExplainerEnabled, type PermissionExplanation as PermissionExplanationType, type RiskLevel } from '../../utils/permissions/permissionExplainer.js'; -import { ShimmerChar } from '../Spinner/ShimmerChar.js'; -import { useShimmerAnimation } from '../Spinner/useShimmerAnimation.js'; -const LOADING_MESSAGE = 'Loading explanation…'; -function ShimmerLoadingText() { - const $ = _c(7); - const [ref, glimmerIndex] = useShimmerAnimation("responding", LOADING_MESSAGE, false); - let t0; - if ($[0] !== glimmerIndex) { - t0 = LOADING_MESSAGE.split("").map((char, index) => ); - $[0] = glimmerIndex; - $[1] = t0; - } else { - t0 = $[1]; - } - let t1; - if ($[2] !== t0) { - t1 = {t0}; - $[2] = t0; - $[3] = t1; - } else { - t1 = $[3]; - } - let t2; - if ($[4] !== ref || $[5] !== t1) { - t2 = {t1}; - $[4] = ref; - $[5] = t1; - $[6] = t2; - } else { - t2 = $[6]; - } - return t2; +import React, { Suspense, use, useState } from 'react' +import { Box, Text } from '../../ink.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import { logEvent } from '../../services/analytics/index.js' +import type { Message } from '../../types/message.js' +import { + generatePermissionExplanation, + isPermissionExplainerEnabled, + type PermissionExplanation as PermissionExplanationType, + type RiskLevel, +} from '../../utils/permissions/permissionExplainer.js' +import { ShimmerChar } from '../Spinner/ShimmerChar.js' +import { useShimmerAnimation } from '../Spinner/useShimmerAnimation.js' + +const LOADING_MESSAGE = 'Loading explanation…' + +function ShimmerLoadingText(): React.ReactNode { + const [ref, glimmerIndex] = useShimmerAnimation( + 'responding', + LOADING_MESSAGE, + false, + ) + + return ( + + + {LOADING_MESSAGE.split('').map((char, index) => ( + + ))} + + + ) } + function getRiskColor(riskLevel: RiskLevel): 'success' | 'warning' | 'error' { switch (riskLevel) { case 'LOW': - return 'success'; + return 'success' case 'MEDIUM': - return 'warning'; + return 'warning' case 'HIGH': - return 'error'; + return 'error' } } + function getRiskLabel(riskLevel: RiskLevel): string { switch (riskLevel) { case 'LOW': - return 'Low risk'; + return 'Low risk' case 'MEDIUM': - return 'Med risk'; + return 'Med risk' case 'HIGH': - return 'High risk'; + return 'High risk' } } + type PermissionExplanationProps = { - toolName: string; - toolInput: unknown; - toolDescription?: string; - messages?: Message[]; -}; + toolName: string + toolInput: unknown + toolDescription?: string + messages?: Message[] +} + type ExplainerState = { - visible: boolean; - enabled: boolean; - promise: Promise | null; -}; + visible: boolean + enabled: boolean + promise: Promise | null +} /** * Creates an explanation promise that never rejects. * Errors are caught and returned as null. */ -function createExplanationPromise(props: PermissionExplanationProps): Promise { +function createExplanationPromise( + props: PermissionExplanationProps, +): Promise { return generatePermissionExplanation({ toolName: props.toolName, toolInput: props.toolInput, toolDescription: props.toolDescription, messages: props.messages, - signal: new AbortController().signal // Won't abort - request is fast enough - }).catch(() => null); + signal: new AbortController().signal, // Won't abort - request is fast enough + }).catch(() => null) } /** @@ -89,183 +95,93 @@ function createExplanationPromise(props: PermissionExplanationProps): Promise { +export function usePermissionExplainerUI( + props: PermissionExplanationProps, +): ExplainerState { + const enabled = isPermissionExplainerEnabled() + const [visible, setVisible] = useState(false) + const [promise, setPromise] = + useState | null>(null) + + // Use keybinding for ctrl+e toggle (configurable via keybindings.json) + useKeybinding( + 'confirm:toggleExplanation', + () => { if (!visible) { - logEvent("tengu_permission_explainer_shortcut_used", {}); + logEvent('tengu_permission_explainer_shortcut_used', {}) + // Only create the promise on first toggle (lazy loading) if (!promise) { - setPromise(createExplanationPromise(props)); + setPromise(createExplanationPromise(props)) } } - setVisible(_temp); - }; - $[1] = promise; - $[2] = props; - $[3] = visible; - $[4] = t1; - } else { - t1 = $[4]; - } - let t2; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t2 = { - context: "Confirmation", - isActive: enabled - }; - $[5] = t2; - } else { - t2 = $[5]; - } - useKeybinding("confirm:toggleExplanation", t1, t2); - let t3; - if ($[6] !== promise || $[7] !== visible) { - t3 = { - visible, - enabled, - promise - }; - $[6] = promise; - $[7] = visible; - $[8] = t3; - } else { - t3 = $[8]; - } - return t3; + setVisible(v => !v) + }, + { context: 'Confirmation', isActive: enabled }, + ) + + return { visible, enabled, promise } } /** * Inner component that uses React 19's use() to read the promise. * Suspends while loading, returns null on error. */ -function _temp(v) { - return !v; -} -function ExplanationResult(t0) { - const $ = _c(21); - const { - promise - } = t0; - const explanation = use(promise) as PermissionExplanationType | null; +function ExplanationResult({ + promise, +}: { + promise: Promise +}): React.ReactNode { + const explanation = use(promise) + if (!explanation) { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Explanation unavailable; - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; + return ( + + Explanation unavailable + + ) } - let t1; - if ($[1] !== explanation.explanation) { - t1 = {explanation.explanation}; - $[1] = explanation.explanation; - $[2] = t1; - } else { - t1 = $[2]; - } - let t2; - if ($[3] !== explanation.reasoning) { - t2 = {explanation.reasoning}; - $[3] = explanation.reasoning; - $[4] = t2; - } else { - t2 = $[4]; - } - let t3; - if ($[5] !== explanation.riskLevel) { - t3 = getRiskColor(explanation.riskLevel); - $[5] = explanation.riskLevel; - $[6] = t3; - } else { - t3 = $[6]; - } - let t4; - if ($[7] !== explanation.riskLevel) { - t4 = getRiskLabel(explanation.riskLevel); - $[7] = explanation.riskLevel; - $[8] = t4; - } else { - t4 = $[8]; - } - let t5; - if ($[9] !== t3 || $[10] !== t4) { - t5 = {t4}:; - $[9] = t3; - $[10] = t4; - $[11] = t5; - } else { - t5 = $[11]; - } - let t6; - if ($[12] !== explanation.risk) { - t6 = {explanation.risk}; - $[12] = explanation.risk; - $[13] = t6; - } else { - t6 = $[13]; - } - let t7; - if ($[14] !== t5 || $[15] !== t6) { - t7 = {t5}{t6}; - $[14] = t5; - $[15] = t6; - $[16] = t7; - } else { - t7 = $[16]; - } - let t8; - if ($[17] !== t1 || $[18] !== t2 || $[19] !== t7) { - t8 = {t1}{t2}{t7}; - $[17] = t1; - $[18] = t2; - $[19] = t7; - $[20] = t8; - } else { - t8 = $[20]; - } - return t8; + + return ( + + {explanation.explanation} + + {explanation.reasoning} + + + + + {getRiskLabel(explanation.riskLevel)}: + + {explanation.risk} + + + + ) } /** * Content component - shows loading (via Suspense) or explanation when visible */ -export function PermissionExplainerContent(t0) { - const $ = _c(3); - const { - visible, - promise - } = t0; +export function PermissionExplainerContent({ + visible, + promise, +}: { + visible: boolean + promise: Promise | null +}): React.ReactNode { if (!visible || !promise) { - return null; + return null } - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = ; - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] !== promise) { - t2 = ; - $[1] = promise; - $[2] = t2; - } else { - t2 = $[2]; - } - return t2; + + return ( + + + + } + > + + + ) } diff --git a/src/components/permissions/PermissionPrompt.tsx b/src/components/permissions/PermissionPrompt.tsx index 3ec9fbb08..ae9ba0730 100644 --- a/src/components/permissions/PermissionPrompt.tsx +++ b/src/components/permissions/PermissionPrompt.tsx @@ -1,36 +1,43 @@ -import { c as _c } from "react/compiler-runtime"; -import React, { type ReactNode, useCallback, useMemo, useState } from 'react'; -import { Box, Text } from '../../ink.js'; -import type { KeybindingAction } from '../../keybindings/types.js'; -import { useKeybindings } from '../../keybindings/useKeybinding.js'; -import { type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, logEvent } from '../../services/analytics/index.js'; -import { useSetAppState } from '../../state/AppState.js'; -import { type OptionWithDescription, Select } from '../CustomSelect/select.js'; -export type FeedbackType = 'accept' | 'reject'; +import React, { type ReactNode, useCallback, useMemo, useState } from 'react' +import { Box, Text } from '../../ink.js' +import type { KeybindingAction } from '../../keybindings/types.js' +import { useKeybindings } from '../../keybindings/useKeybinding.js' +import { + type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + logEvent, +} from '../../services/analytics/index.js' +import { useSetAppState } from '../../state/AppState.js' +import { type OptionWithDescription, Select } from '../CustomSelect/select.js' + +export type FeedbackType = 'accept' | 'reject' + export type PermissionPromptOption = { - value: T; - label: ReactNode; + value: T + label: ReactNode feedbackConfig?: { - type: FeedbackType; - placeholder?: string; - }; - keybinding?: KeybindingAction; -}; + type: FeedbackType + placeholder?: string + } + keybinding?: KeybindingAction +} + export type ToolAnalyticsContext = { - toolName: string; - isMcp: boolean; -}; + toolName: string + isMcp: boolean +} + export type PermissionPromptProps = { - options: PermissionPromptOption[]; - onSelect: (value: T, feedback?: string) => void; - onCancel?: () => void; - question?: string | ReactNode; - toolAnalyticsContext?: ToolAnalyticsContext; -}; + options: PermissionPromptOption[] + onSelect: (value: T, feedback?: string) => void + onCancel?: () => void + question?: string | ReactNode + toolAnalyticsContext?: ToolAnalyticsContext +} + const DEFAULT_PLACEHOLDERS: Record = { accept: 'tell Claude what to do next', - reject: 'tell Claude what to do differently' -}; + reject: 'tell Claude what to do differently', +} /** * Shared component for permission prompts with optional feedback input. @@ -42,294 +49,219 @@ const DEFAULT_PLACEHOLDERS: Record = { * - Analytics events for feedback interactions * - Transforming options to Select-compatible format */ -export function PermissionPrompt(t0) { - const $ = _c(54); - const { - options, - onSelect, - onCancel, - question: t1, - toolAnalyticsContext - } = t0; - const question = t1 === undefined ? "Do you want to proceed?" : t1; - const setAppState = useSetAppState(); - const [acceptFeedback, setAcceptFeedback] = useState(""); - const [rejectFeedback, setRejectFeedback] = useState(""); - const [acceptInputMode, setAcceptInputMode] = useState(false); - const [rejectInputMode, setRejectInputMode] = useState(false); - const [focusedValue, setFocusedValue] = useState(null); - const [acceptFeedbackModeEntered, setAcceptFeedbackModeEntered] = useState(false); - const [rejectFeedbackModeEntered, setRejectFeedbackModeEntered] = useState(false); - let t2; - if ($[0] !== focusedValue || $[1] !== options) { - let t3; - if ($[3] !== focusedValue) { - t3 = opt => opt.value === focusedValue; - $[3] = focusedValue; - $[4] = t3; - } else { - t3 = $[4]; - } - t2 = options.find(t3); - $[0] = focusedValue; - $[1] = options; - $[2] = t2; - } else { - t2 = $[2]; - } - const focusedOption = t2; - const focusedFeedbackType = focusedOption?.feedbackConfig?.type; - const showTabHint = focusedFeedbackType === "accept" && !acceptInputMode || focusedFeedbackType === "reject" && !rejectInputMode; - let t3; - if ($[5] !== acceptInputMode || $[6] !== options || $[7] !== rejectInputMode) { - let t4; - if ($[9] !== acceptInputMode || $[10] !== rejectInputMode) { - t4 = opt_0 => { - const { - value, - label, - feedbackConfig - } = opt_0; - if (!feedbackConfig) { - return { - label, - value - }; - } - const { - type, - placeholder - } = feedbackConfig; - const isInputMode = type === "accept" ? acceptInputMode : rejectInputMode; - const onChange = type === "accept" ? setAcceptFeedback : setRejectFeedback; - const defaultPlaceholder = DEFAULT_PLACEHOLDERS[type]; - if (isInputMode) { - return { - type: "input" as const, - label, - value, - placeholder: placeholder ?? defaultPlaceholder, - onChange, - allowEmptySubmitToCancel: true - }; - } +export function PermissionPrompt({ + options, + onSelect, + onCancel, + question = 'Do you want to proceed?', + toolAnalyticsContext, +}: PermissionPromptProps): React.ReactNode { + const setAppState = useSetAppState() + const [acceptFeedback, setAcceptFeedback] = useState('') + const [rejectFeedback, setRejectFeedback] = useState('') + const [acceptInputMode, setAcceptInputMode] = useState(false) + const [rejectInputMode, setRejectInputMode] = useState(false) + const [focusedValue, setFocusedValue] = useState(null) + // Track whether user ever entered feedback mode (persists after collapse) + const [acceptFeedbackModeEntered, setAcceptFeedbackModeEntered] = + useState(false) + const [rejectFeedbackModeEntered, setRejectFeedbackModeEntered] = + useState(false) + + // Find which option is focused and whether it has feedback config + const focusedOption = options.find(opt => opt.value === focusedValue) + const focusedFeedbackType = focusedOption?.feedbackConfig?.type + + // Show Tab hint when focused on a feedback-enabled option that's not already in input mode + const showTabHint = + (focusedFeedbackType === 'accept' && !acceptInputMode) || + (focusedFeedbackType === 'reject' && !rejectInputMode) + + // Transform options to Select-compatible format + const selectOptions = useMemo((): OptionWithDescription[] => { + return options.map(opt => { + const { value, label, feedbackConfig } = opt + + // No feedback config = simple option + if (!feedbackConfig) { return { label, - value - }; - }; - $[9] = acceptInputMode; - $[10] = rejectInputMode; - $[11] = t4; - } else { - t4 = $[11]; - } - t3 = options.map(t4); - $[5] = acceptInputMode; - $[6] = options; - $[7] = rejectInputMode; - $[8] = t3; - } else { - t3 = $[8]; - } - const selectOptions = t3; - let t4; - if ($[12] !== acceptInputMode || $[13] !== options || $[14] !== rejectInputMode || $[15] !== toolAnalyticsContext?.isMcp || $[16] !== toolAnalyticsContext?.toolName) { - t4 = value_0 => { - const option = options.find(opt_1 => opt_1.value === value_0); - if (!option?.feedbackConfig) { - return; + value, + } } - const { - type: type_0 - } = option.feedbackConfig; + + const { type, placeholder } = feedbackConfig + const isInputMode = type === 'accept' ? acceptInputMode : rejectInputMode + const onChange = type === 'accept' ? setAcceptFeedback : setRejectFeedback + const defaultPlaceholder = DEFAULT_PLACEHOLDERS[type] + + // When in input mode, show input field + if (isInputMode) { + return { + type: 'input' as const, + label, + value, + placeholder: placeholder ?? defaultPlaceholder, + onChange, + allowEmptySubmitToCancel: true, + } + } + + // Not in input mode - show simple option + return { + label, + value, + } + }) + }, [options, acceptInputMode, rejectInputMode]) + + // Handle Tab key to toggle input mode + const handleInputModeToggle = useCallback( + (value: T) => { + const option = options.find(opt => opt.value === value) + if (!option?.feedbackConfig) return + + const { type } = option.feedbackConfig const analyticsProps = { - toolName: toolAnalyticsContext?.toolName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, - isMcp: toolAnalyticsContext?.isMcp ?? false - }; - if (type_0 === "accept") { + toolName: + toolAnalyticsContext?.toolName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + isMcp: toolAnalyticsContext?.isMcp ?? false, + } + + if (type === 'accept') { if (acceptInputMode) { - setAcceptInputMode(false); - logEvent("tengu_accept_feedback_mode_collapsed", analyticsProps); + setAcceptInputMode(false) + logEvent('tengu_accept_feedback_mode_collapsed', analyticsProps) } else { - setAcceptInputMode(true); - setAcceptFeedbackModeEntered(true); - logEvent("tengu_accept_feedback_mode_entered", analyticsProps); + setAcceptInputMode(true) + setAcceptFeedbackModeEntered(true) + logEvent('tengu_accept_feedback_mode_entered', analyticsProps) } - } else { - if (type_0 === "reject") { - if (rejectInputMode) { - setRejectInputMode(false); - logEvent("tengu_reject_feedback_mode_collapsed", analyticsProps); - } else { - setRejectInputMode(true); - setRejectFeedbackModeEntered(true); - logEvent("tengu_reject_feedback_mode_entered", analyticsProps); - } + } else if (type === 'reject') { + if (rejectInputMode) { + setRejectInputMode(false) + logEvent('tengu_reject_feedback_mode_collapsed', analyticsProps) + } else { + setRejectInputMode(true) + setRejectFeedbackModeEntered(true) + logEvent('tengu_reject_feedback_mode_entered', analyticsProps) } } - }; - $[12] = acceptInputMode; - $[13] = options; - $[14] = rejectInputMode; - $[15] = toolAnalyticsContext?.isMcp; - $[16] = toolAnalyticsContext?.toolName; - $[17] = t4; - } else { - t4 = $[17]; - } - const handleInputModeToggle = t4; - let t5; - if ($[18] !== acceptFeedback || $[19] !== acceptFeedbackModeEntered || $[20] !== onSelect || $[21] !== options || $[22] !== rejectFeedback || $[23] !== rejectFeedbackModeEntered || $[24] !== toolAnalyticsContext?.isMcp || $[25] !== toolAnalyticsContext?.toolName) { - t5 = value_1 => { - const option_0 = options.find(opt_2 => opt_2.value === value_1); - if (!option_0) { - return; - } - let feedback; - if (option_0.feedbackConfig) { - const rawFeedback = option_0.feedbackConfig.type === "accept" ? acceptFeedback : rejectFeedback; - const trimmedFeedback = rawFeedback.trim(); + }, + [options, acceptInputMode, rejectInputMode, toolAnalyticsContext], + ) + + // Handle selection + const handleSelect = useCallback( + (value: T) => { + const option = options.find(opt => opt.value === value) + if (!option) return + + // Get feedback if applicable + let feedback: string | undefined + if (option.feedbackConfig) { + const rawFeedback = + option.feedbackConfig.type === 'accept' + ? acceptFeedback + : rejectFeedback + const trimmedFeedback = rawFeedback.trim() + if (trimmedFeedback) { - feedback = trimmedFeedback; + feedback = trimmedFeedback } - const analyticsProps_0 = { - toolName: toolAnalyticsContext?.toolName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, + + // Log accept/reject submission with feedback context + const analyticsProps = { + toolName: + toolAnalyticsContext?.toolName as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS, isMcp: toolAnalyticsContext?.isMcp ?? false, has_instructions: !!trimmedFeedback, instructions_length: trimmedFeedback?.length ?? 0, - entered_feedback_mode: option_0.feedbackConfig.type === "accept" ? acceptFeedbackModeEntered : rejectFeedbackModeEntered - }; - if (option_0.feedbackConfig.type === "accept") { - logEvent("tengu_accept_submitted", analyticsProps_0); - } else { - if (option_0.feedbackConfig.type === "reject") { - logEvent("tengu_reject_submitted", analyticsProps_0); - } + entered_feedback_mode: + option.feedbackConfig.type === 'accept' + ? acceptFeedbackModeEntered + : rejectFeedbackModeEntered, + } + + if (option.feedbackConfig.type === 'accept') { + logEvent('tengu_accept_submitted', analyticsProps) + } else if (option.feedbackConfig.type === 'reject') { + logEvent('tengu_reject_submitted', analyticsProps) } } - onSelect(value_1, feedback); - }; - $[18] = acceptFeedback; - $[19] = acceptFeedbackModeEntered; - $[20] = onSelect; - $[21] = options; - $[22] = rejectFeedback; - $[23] = rejectFeedbackModeEntered; - $[24] = toolAnalyticsContext?.isMcp; - $[25] = toolAnalyticsContext?.toolName; - $[26] = t5; - } else { - t5 = $[26]; - } - const handleSelect = t5; - let handlers; - if ($[27] !== handleSelect || $[28] !== options) { - handlers = {}; - for (const opt_3 of options) { - if (opt_3.keybinding) { - handlers[opt_3.keybinding] = () => handleSelect(opt_3.value); + + onSelect(value, feedback) + }, + [ + options, + acceptFeedback, + rejectFeedback, + onSelect, + toolAnalyticsContext, + acceptFeedbackModeEntered, + rejectFeedbackModeEntered, + ], + ) + + // Register keybinding handlers for options that have a keybinding set + const keybindingHandlers = useMemo(() => { + const handlers: Record void> = {} + for (const opt of options) { + if (opt.keybinding) { + handlers[opt.keybinding] = () => handleSelect(opt.value) } } - $[27] = handleSelect; - $[28] = options; - $[29] = handlers; - } else { - handlers = $[29]; - } - const keybindingHandlers = handlers; - let t6; - if ($[30] === Symbol.for("react.memo_cache_sentinel")) { - t6 = { - context: "Confirmation" - }; - $[30] = t6; - } else { - t6 = $[30]; - } - useKeybindings(keybindingHandlers, t6); - let t7; - if ($[31] !== onCancel || $[32] !== setAppState) { - t7 = () => { - logEvent("tengu_permission_request_escape", {}); - setAppState(_temp); - onCancel?.(); - }; - $[31] = onCancel; - $[32] = setAppState; - $[33] = t7; - } else { - t7 = $[33]; - } - const handleCancel = t7; - let t8; - if ($[34] !== question) { - t8 = typeof question === "string" ? {question} : question; - $[34] = question; - $[35] = t8; - } else { - t8 = $[35]; - } - let t9; - if ($[36] !== acceptFeedback || $[37] !== acceptInputMode || $[38] !== options || $[39] !== rejectFeedback || $[40] !== rejectInputMode) { - t9 = value_2 => { - const newOption = options.find(opt_4 => opt_4.value === value_2); - if (newOption?.feedbackConfig?.type !== "accept" && acceptInputMode && !acceptFeedback.trim()) { - setAcceptInputMode(false); - } - if (newOption?.feedbackConfig?.type !== "reject" && rejectInputMode && !rejectFeedback.trim()) { - setRejectInputMode(false); - } - setFocusedValue(value_2); - }; - $[36] = acceptFeedback; - $[37] = acceptInputMode; - $[38] = options; - $[39] = rejectFeedback; - $[40] = rejectInputMode; - $[41] = t9; - } else { - t9 = $[41]; - } - let t10; - if ($[42] !== handleCancel || $[43] !== handleInputModeToggle || $[44] !== handleSelect || $[45] !== selectOptions || $[46] !== t9) { - t10 = { + // Reset input mode when navigating away, but only if no text typed + const newOption = options.find(opt => opt.value === value) + if ( + newOption?.feedbackConfig?.type !== 'accept' && + acceptInputMode && + !acceptFeedback.trim() + ) { + setAcceptInputMode(false) + } + if ( + newOption?.feedbackConfig?.type !== 'reject' && + rejectInputMode && + !rejectFeedback.trim() + ) { + setRejectInputMode(false) + } + setFocusedValue(value) + }} + onInputModeToggle={handleInputModeToggle} + /> + + Esc to cancel{showTabHint && ' · Tab to amend'} + + + ) } diff --git a/src/components/permissions/PermissionRequest.tsx b/src/components/permissions/PermissionRequest.tsx index 9ab17c578..53dba4032 100644 --- a/src/components/permissions/PermissionRequest.tsx +++ b/src/components/permissions/PermissionRequest.tsx @@ -1,92 +1,125 @@ -import { c as _c } from "react/compiler-runtime"; -import { feature } from 'bun:bundle'; -import * as React from 'react'; -import { EnterPlanModeTool } from 'src/tools/EnterPlanModeTool/EnterPlanModeTool.js'; -import { ExitPlanModeV2Tool } from 'src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.js'; -import { useNotifyAfterTimeout } from '../../hooks/useNotifyAfterTimeout.js'; -import { useKeybinding } from '../../keybindings/useKeybinding.js'; -import type { AnyObject, Tool, ToolUseContext } from '../../Tool.js'; -import { AskUserQuestionTool } from '../../tools/AskUserQuestionTool/AskUserQuestionTool.js'; -import { BashTool } from '../../tools/BashTool/BashTool.js'; -import { FileEditTool } from '../../tools/FileEditTool/FileEditTool.js'; -import { FileReadTool } from '../../tools/FileReadTool/FileReadTool.js'; -import { FileWriteTool } from '../../tools/FileWriteTool/FileWriteTool.js'; -import { GlobTool } from '../../tools/GlobTool/GlobTool.js'; -import { GrepTool } from '../../tools/GrepTool/GrepTool.js'; -import { NotebookEditTool } from '../../tools/NotebookEditTool/NotebookEditTool.js'; -import { PowerShellTool } from '../../tools/PowerShellTool/PowerShellTool.js'; -import { SkillTool } from '../../tools/SkillTool/SkillTool.js'; -import { WebFetchTool } from '../../tools/WebFetchTool/WebFetchTool.js'; -import type { AssistantMessage } from '../../types/message.js'; -import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js'; -import { AskUserQuestionPermissionRequest } from './AskUserQuestionPermissionRequest/AskUserQuestionPermissionRequest.js'; -import { BashPermissionRequest } from './BashPermissionRequest/BashPermissionRequest.js'; -import { EnterPlanModePermissionRequest } from './EnterPlanModePermissionRequest/EnterPlanModePermissionRequest.js'; -import { ExitPlanModePermissionRequest } from './ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.js'; -import { FallbackPermissionRequest } from './FallbackPermissionRequest.js'; -import { FileEditPermissionRequest } from './FileEditPermissionRequest/FileEditPermissionRequest.js'; -import { FilesystemPermissionRequest } from './FilesystemPermissionRequest/FilesystemPermissionRequest.js'; -import { FileWritePermissionRequest } from './FileWritePermissionRequest/FileWritePermissionRequest.js'; -import { NotebookEditPermissionRequest } from './NotebookEditPermissionRequest/NotebookEditPermissionRequest.js'; -import { PowerShellPermissionRequest } from './PowerShellPermissionRequest/PowerShellPermissionRequest.js'; -import { SkillPermissionRequest } from './SkillPermissionRequest/SkillPermissionRequest.js'; -import { WebFetchPermissionRequest } from './WebFetchPermissionRequest/WebFetchPermissionRequest.js'; +import { feature } from 'bun:bundle' +import * as React from 'react' +import { EnterPlanModeTool } from 'src/tools/EnterPlanModeTool/EnterPlanModeTool.js' +import { ExitPlanModeV2Tool } from 'src/tools/ExitPlanModeTool/ExitPlanModeV2Tool.js' +import { useNotifyAfterTimeout } from '../../hooks/useNotifyAfterTimeout.js' +import { useKeybinding } from '../../keybindings/useKeybinding.js' +import type { AnyObject, Tool, ToolUseContext } from '../../Tool.js' +import { AskUserQuestionTool } from '../../tools/AskUserQuestionTool/AskUserQuestionTool.js' +import { BashTool } from '../../tools/BashTool/BashTool.js' +import { FileEditTool } from '../../tools/FileEditTool/FileEditTool.js' +import { FileReadTool } from '../../tools/FileReadTool/FileReadTool.js' +import { FileWriteTool } from '../../tools/FileWriteTool/FileWriteTool.js' +import { GlobTool } from '../../tools/GlobTool/GlobTool.js' +import { GrepTool } from '../../tools/GrepTool/GrepTool.js' +import { NotebookEditTool } from '../../tools/NotebookEditTool/NotebookEditTool.js' +import { PowerShellTool } from '../../tools/PowerShellTool/PowerShellTool.js' +import { SkillTool } from '../../tools/SkillTool/SkillTool.js' +import { WebFetchTool } from '../../tools/WebFetchTool/WebFetchTool.js' +import type { AssistantMessage } from '../../types/message.js' +import type { PermissionDecision } from '../../utils/permissions/PermissionResult.js' +import { AskUserQuestionPermissionRequest } from './AskUserQuestionPermissionRequest/AskUserQuestionPermissionRequest.js' +import { BashPermissionRequest } from './BashPermissionRequest/BashPermissionRequest.js' +import { EnterPlanModePermissionRequest } from './EnterPlanModePermissionRequest/EnterPlanModePermissionRequest.js' +import { ExitPlanModePermissionRequest } from './ExitPlanModePermissionRequest/ExitPlanModePermissionRequest.js' +import { FallbackPermissionRequest } from './FallbackPermissionRequest.js' +import { FileEditPermissionRequest } from './FileEditPermissionRequest/FileEditPermissionRequest.js' +import { FilesystemPermissionRequest } from './FilesystemPermissionRequest/FilesystemPermissionRequest.js' +import { FileWritePermissionRequest } from './FileWritePermissionRequest/FileWritePermissionRequest.js' +import { NotebookEditPermissionRequest } from './NotebookEditPermissionRequest/NotebookEditPermissionRequest.js' +import { PowerShellPermissionRequest } from './PowerShellPermissionRequest/PowerShellPermissionRequest.js' +import { SkillPermissionRequest } from './SkillPermissionRequest/SkillPermissionRequest.js' +import { WebFetchPermissionRequest } from './WebFetchPermissionRequest/WebFetchPermissionRequest.js' /* eslint-disable @typescript-eslint/no-require-imports */ -const ReviewArtifactTool = feature('REVIEW_ARTIFACT') ? (require('../../tools/ReviewArtifactTool/ReviewArtifactTool.js') as typeof import('../../tools/ReviewArtifactTool/ReviewArtifactTool.js')).ReviewArtifactTool : null; -const ReviewArtifactPermissionRequest = feature('REVIEW_ARTIFACT') ? (require('./ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.js') as typeof import('./ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.js')).ReviewArtifactPermissionRequest : null; -const WorkflowTool = feature('WORKFLOW_SCRIPTS') ? (require('../../tools/WorkflowTool/WorkflowTool.js') as typeof import('../../tools/WorkflowTool/WorkflowTool.js')).WorkflowTool : null; -const WorkflowPermissionRequest = feature('WORKFLOW_SCRIPTS') ? (require('../../tools/WorkflowTool/WorkflowPermissionRequest.js') as typeof import('../../tools/WorkflowTool/WorkflowPermissionRequest.js')).WorkflowPermissionRequest : null; -const MonitorTool = feature('MONITOR_TOOL') ? (require('../../tools/MonitorTool/MonitorTool.js') as typeof import('../../tools/MonitorTool/MonitorTool.js')).MonitorTool : null; -const MonitorPermissionRequest = feature('MONITOR_TOOL') ? (require('./MonitorPermissionRequest/MonitorPermissionRequest.js') as typeof import('./MonitorPermissionRequest/MonitorPermissionRequest.js')).MonitorPermissionRequest : null; -import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs'; +const ReviewArtifactTool = feature('REVIEW_ARTIFACT') + ? ( + require('../../tools/ReviewArtifactTool/ReviewArtifactTool.js') as typeof import('../../tools/ReviewArtifactTool/ReviewArtifactTool.js') + ).ReviewArtifactTool + : null + +const ReviewArtifactPermissionRequest = feature('REVIEW_ARTIFACT') + ? ( + require('./ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.js') as typeof import('./ReviewArtifactPermissionRequest/ReviewArtifactPermissionRequest.js') + ).ReviewArtifactPermissionRequest + : null + +const WorkflowTool = feature('WORKFLOW_SCRIPTS') + ? ( + require('../../tools/WorkflowTool/WorkflowTool.js') as typeof import('../../tools/WorkflowTool/WorkflowTool.js') + ).WorkflowTool + : null + +const WorkflowPermissionRequest = feature('WORKFLOW_SCRIPTS') + ? ( + require('../../tools/WorkflowTool/WorkflowPermissionRequest.js') as typeof import('../../tools/WorkflowTool/WorkflowPermissionRequest.js') + ).WorkflowPermissionRequest + : null + +const MonitorTool = feature('MONITOR_TOOL') + ? ( + require('../../tools/MonitorTool/MonitorTool.js') as typeof import('../../tools/MonitorTool/MonitorTool.js') + ).MonitorTool + : null + +const MonitorPermissionRequest = feature('MONITOR_TOOL') + ? ( + require('./MonitorPermissionRequest/MonitorPermissionRequest.js') as typeof import('./MonitorPermissionRequest/MonitorPermissionRequest.js') + ).MonitorPermissionRequest + : null + +import type { ContentBlockParam } from '@anthropic-ai/sdk/resources/messages.mjs' /* eslint-enable @typescript-eslint/no-require-imports */ -import type { z } from 'zod/v4'; -import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js'; -import type { WorkerBadgeProps } from './WorkerBadge.js'; -function permissionComponentForTool(tool: Tool): React.ComponentType { +import type { z } from 'zod/v4' +import type { PermissionUpdate } from '../../utils/permissions/PermissionUpdateSchema.js' +import type { WorkerBadgeProps } from './WorkerBadge.js' + +function permissionComponentForTool( + tool: Tool, +): React.ComponentType { switch (tool) { case FileEditTool: - return FileEditPermissionRequest; + return FileEditPermissionRequest case FileWriteTool: - return FileWritePermissionRequest; + return FileWritePermissionRequest case BashTool: - return BashPermissionRequest; + return BashPermissionRequest case PowerShellTool: - return PowerShellPermissionRequest; + return PowerShellPermissionRequest case ReviewArtifactTool: - return ReviewArtifactPermissionRequest ?? FallbackPermissionRequest; + return ReviewArtifactPermissionRequest ?? FallbackPermissionRequest case WebFetchTool: - return WebFetchPermissionRequest; + return WebFetchPermissionRequest case NotebookEditTool: - return NotebookEditPermissionRequest; + return NotebookEditPermissionRequest case ExitPlanModeV2Tool: - return ExitPlanModePermissionRequest; + return ExitPlanModePermissionRequest case EnterPlanModeTool: - return EnterPlanModePermissionRequest; + return EnterPlanModePermissionRequest case SkillTool: - return SkillPermissionRequest; + return SkillPermissionRequest case AskUserQuestionTool: - return AskUserQuestionPermissionRequest; + return AskUserQuestionPermissionRequest case WorkflowTool: - return WorkflowPermissionRequest ?? FallbackPermissionRequest; + return WorkflowPermissionRequest ?? FallbackPermissionRequest case MonitorTool: - return MonitorPermissionRequest ?? FallbackPermissionRequest; + return MonitorPermissionRequest ?? FallbackPermissionRequest case GlobTool: case GrepTool: case FileReadTool: - return FilesystemPermissionRequest; + return FilesystemPermissionRequest default: - return FallbackPermissionRequest; + return FallbackPermissionRequest } } + export type PermissionRequestProps = { - toolUseConfirm: ToolUseConfirm; - toolUseContext: ToolUseContext; - onDone(): void; - onReject(): void; - verbose: boolean; - workerBadge: WorkerBadgeProps | undefined; + toolUseConfirm: ToolUseConfirm + toolUseContext: ToolUseContext + onDone(): void + onReject(): void + verbose: boolean + workerBadge: WorkerBadgeProps | undefined /** * Register JSX to render in a sticky footer below the scrollable area. * Fullscreen mode only (non-fullscreen has no sticky area — terminal @@ -98,119 +131,102 @@ 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; - onAllow(updatedInput: z.infer, permissionUpdates: PermissionUpdate[], feedback?: string, contentBlocks?: ContentBlockParam[]): void; - onReject(feedback?: string, contentBlocks?: ContentBlockParam[]): void; - recheckPermission(): Promise; -}; + 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 +} + 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 -export function PermissionRequest(t0) { - const $ = _c(18); - const { - toolUseConfirm, - toolUseContext, - onDone, - onReject, - verbose, - workerBadge, - setStickyFooter - } = t0; - let t1; - if ($[0] !== onDone || $[1] !== onReject || $[2] !== toolUseConfirm) { - t1 = () => { - onDone(); - onReject(); - toolUseConfirm.onReject(); - }; - $[0] = onDone; - $[1] = onReject; - $[2] = toolUseConfirm; - $[3] = t1; - } else { - t1 = $[3]; - } - let t2; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t2 = { - context: "Confirmation" - }; - $[4] = t2; - } else { - t2 = $[4]; - } - useKeybinding("app:interrupt", t1, t2); - let t3; - if ($[5] !== toolUseConfirm) { - t3 = getNotificationMessage(toolUseConfirm); - $[5] = toolUseConfirm; - $[6] = t3; - } else { - t3 = $[6]; - } - const notificationMessage = t3; - useNotifyAfterTimeout(notificationMessage, "permission_prompt"); - let t4; - if ($[7] !== toolUseConfirm.tool) { - t4 = permissionComponentForTool(toolUseConfirm.tool); - $[7] = toolUseConfirm.tool; - $[8] = t4; - } else { - t4 = $[8]; - } - const PermissionComponent = t4; - let t5; - if ($[9] !== PermissionComponent || $[10] !== onDone || $[11] !== onReject || $[12] !== setStickyFooter || $[13] !== toolUseConfirm || $[14] !== toolUseContext || $[15] !== verbose || $[16] !== workerBadge) { - t5 = ; - $[9] = PermissionComponent; - $[10] = onDone; - $[11] = onReject; - $[12] = setStickyFooter; - $[13] = toolUseConfirm; - $[14] = toolUseContext; - $[15] = verbose; - $[16] = workerBadge; - $[17] = t5; - } else { - t5 = $[17]; - } - return t5; +export function PermissionRequest({ + toolUseConfirm, + toolUseContext, + onDone, + onReject, + verbose, + workerBadge, + setStickyFooter, +}: PermissionRequestProps): React.ReactNode { + // Handle Ctrl+C (app:interrupt) to reject + useKeybinding( + 'app:interrupt', + () => { + onDone() + onReject() + toolUseConfirm.onReject() + }, + { context: 'Confirmation' }, + ) + + const notificationMessage = getNotificationMessage(toolUseConfirm) + useNotifyAfterTimeout(notificationMessage, 'permission_prompt') + + const PermissionComponent = permissionComponentForTool(toolUseConfirm.tool) + + return ( + + ) } diff --git a/src/components/permissions/PermissionRequestTitle.tsx b/src/components/permissions/PermissionRequestTitle.tsx index a324d3e97..953cca22b 100644 --- a/src/components/permissions/PermissionRequestTitle.tsx +++ b/src/components/permissions/PermissionRequestTitle.tsx @@ -1,65 +1,41 @@ -import { c as _c } from "react/compiler-runtime"; -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; -}; -export function PermissionRequestTitle(t0) { - const $ = _c(13); - const { - title, - subtitle, - color: t1, - workerBadge - } = t0; - const color = t1 === undefined ? "permission" : t1; - let t2; - if ($[0] !== color || $[1] !== title) { - t2 = {title}; - $[0] = color; - $[1] = title; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== workerBadge) { - t3 = workerBadge && {"\xB7 "}@{workerBadge.name}; - $[3] = workerBadge; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== t2 || $[6] !== t3) { - t4 = {t2}{t3}; - $[5] = t2; - $[6] = t3; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] !== subtitle) { - t5 = subtitle != null && (typeof subtitle === "string" ? {subtitle} : subtitle); - $[8] = subtitle; - $[9] = t5; - } else { - t5 = $[9]; - } - let t6; - if ($[10] !== t4 || $[11] !== t5) { - t6 = {t4}{t5}; - $[10] = t4; - $[11] = t5; - $[12] = t6; - } else { - t6 = $[12]; - } - return t6; + title: string + subtitle?: React.ReactNode + color?: keyof Theme + workerBadge?: WorkerBadgeProps +} + +export function PermissionRequestTitle({ + title, + subtitle, + color = 'permission', + workerBadge, +}: Props): React.ReactNode { + return ( + + + + {title} + + {workerBadge && ( + + {'· '}@{workerBadge.name} + + )} + + {subtitle != null && + (typeof subtitle === 'string' ? ( + + {subtitle} + + ) : ( + subtitle + ))} + + ) } diff --git a/src/components/permissions/PermissionRuleExplanation.tsx b/src/components/permissions/PermissionRuleExplanation.tsx index 32e6a944b..406f7e3b8 100644 --- a/src/components/permissions/PermissionRuleExplanation.tsx +++ b/src/components/permissions/PermissionRuleExplanation.tsx @@ -1,120 +1,118 @@ -import { c as _c } from "react/compiler-runtime"; -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; -}; -function stringsForDecisionReason(reason: PermissionDecisionReason | undefined, toolType: 'tool' | 'command' | 'edit' | 'read'): DecisionReasonStrings | null { + 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' - }; + themeColor: 'error', + } } return { reasonString: `Classifier ${chalk.bold(reason.classifier)} requires confirmation for this ${toolType}.\n${reason.reason}`, - configString: undefined - }; + configString: undefined, + } } switch (reason.type) { case 'rule': return { - reasonString: `Permission rule ${chalk.bold(permissionRuleValueToString(reason.rule.ruleValue))} requires confirmation for this ${toolType}.`, - 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}]`)}` : ''; - return { - reasonString: `Hook ${chalk.bold(reason.hookName)} requires confirmation for this ${toolType}${hookReasonString}${sourceLabel}`, - configString: '/hooks to update' - }; + reasonString: `Permission rule ${chalk.bold( + permissionRuleValueToString(reason.rule.ruleValue), + )} requires confirmation for this ${toolType}.`, + 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}]`)}` + : '' + 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 - }; + configString: undefined, + } case 'workingDir': return { reasonString: reason.reason, - configString: '/permissions to update rules' - }; + configString: '/permissions to update rules', + } default: - return null; + return null } } -export function PermissionRuleExplanation(t0) { - const $ = _c(11); - const { - permissionResult, - toolType - } = t0; - const permissionMode = useAppState(_temp); - const t1 = permissionResult?.decisionReason; - let t2; - if ($[0] !== t1 || $[1] !== toolType) { - t2 = stringsForDecisionReason(t1, toolType); - $[0] = t1; - $[1] = toolType; - $[2] = t2; - } else { - t2 = $[2]; - } - const strings = t2; + +export function PermissionRuleExplanation({ + permissionResult, + toolType, +}: PermissionRuleExplanationProps): React.ReactNode { + 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); - let t3; - if ($[3] !== strings.reasonString || $[4] !== themeColor) { - t3 = themeColor ? {strings.reasonString} : {strings.reasonString}; - $[3] = strings.reasonString; - $[4] = themeColor; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] !== strings.configString) { - t4 = strings.configString && {strings.configString}; - $[6] = strings.configString; - $[7] = t4; - } else { - t4 = $[7]; - } - let t5; - if ($[8] !== t3 || $[9] !== t4) { - t5 = {t3}{t4}; - $[8] = t3; - $[9] = t4; - $[10] = t5; - } else { - t5 = $[10]; - } - return t5; -} -function _temp(s) { - return s.toolPermissionContext.mode; + + const themeColor = + strings.themeColor ?? + (permissionResult?.decisionReason?.type === 'hook' && + permissionMode === 'auto' + ? 'warning' + : undefined) + + return ( + + {themeColor ? ( + {strings.reasonString} + ) : ( + + {strings.reasonString} + + )} + {strings.configString && {strings.configString}} + + ) } diff --git a/src/components/permissions/PowerShellPermissionRequest/PowerShellPermissionRequest.tsx b/src/components/permissions/PowerShellPermissionRequest/PowerShellPermissionRequest.tsx index 89604c4e9..5dcd0e488 100644 --- a/src/components/permissions/PowerShellPermissionRequest/PowerShellPermissionRequest.tsx +++ b/src/components/permissions/PowerShellPermissionRequest/PowerShellPermissionRequest.tsx @@ -1,43 +1,48 @@ -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'; -export function PowerShellPermissionRequest(props: PermissionRequestProps): React.ReactNode { - const { - toolUseConfirm, - toolUseContext, - onDone, - onReject, - workerBadge - } = props; - const { - command, - description - } = PowerShellTool.inputSchema.parse(toolUseConfirm.input); - const [theme] = useTheme(); +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' + +export function PowerShellPermissionRequest( + props: PermissionRequestProps, +): React.ReactNode { + const { toolUseConfirm, toolUseContext, onDone, onReject, workerBadge } = + props + + const { command, description } = PowerShellTool.inputSchema.parse( + toolUseConfirm.input, + ) + + const [theme] = useTheme() const explainerState = usePermissionExplainerUI({ toolName: toolUseConfirm.tool.name, toolInput: toolUseConfirm.input, toolDescription: toolUseConfirm.description, - messages: toolUseContext.messages - }); + messages: toolUseContext.messages, + }) const { yesInputMode, noInputMode, @@ -50,15 +55,21 @@ export function PowerShellPermissionRequest(props: PermissionRequestProps): Reac focusedOption, handleInputModeToggle, handleReject, - handleFocus + handleFocus, } = useShellPermissionFeedback({ toolUseConfirm, onDone, onReject, - explainerVisible: explainerState.visible - }); - const destructiveWarning = getFeatureValue_CACHED_MAY_BE_STALE('tengu_destructive_command_warning', false) ? getDestructiveCommandWarning(command) : null; - const [showPermissionDebug, setShowPermissionDebug] = useState(false); + explainerVisible: explainerState.visible, + }) + const destructiveWarning = getFeatureValue_CACHED_MAY_BE_STALE( + 'tengu_destructive_command_warning', + false, + ) + ? getDestructiveCommandWarning(command) + : null + + 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 @@ -69,166 +80,233 @@ export function PowerShellPermissionRequest(props: PermissionRequestProps): Reac // corpus shows 14 multiline rules, zero match twice). For compound commands, // computes a prefix per subcommand, excluding subcommands that are already // auto-allowed (read-only). - const [editablePrefix, setEditablePrefix] = useState(command.includes('\n') ? undefined : command); - const hasUserEditedPrefix = useRef(false); + const [editablePrefix, setEditablePrefix] = useState( + command.includes('\n') ? undefined : command, + ) + 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)).then(prefixes => { - if (cancelled || hasUserEditedPrefix.current) return; - if (prefixes.length > 0) { - setEditablePrefix(`${prefixes[0]}:*`); - } - }).catch(() => {}); + getCompoundCommandPrefixesStatic(command, element => + isAllowlistedCommand(element, element.text), + ) + .then(prefixes => { + if (cancelled || hasUserEditedPrefix.current) return + if (prefixes.length > 0) { + setEditablePrefix(`${prefixes[0]}:*`) + } + }) + .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); - }, []); - const unaryEvent = useMemo(() => ({ - completion_type: 'tool_use_single', - language_name: 'none' - }), []); - usePermissionRequestLogging(toolUseConfirm, unaryEvent); - const options = useMemo(() => powershellToolUseOptions({ - suggestions: toolUseConfirm.permissionResult.behavior === 'ask' ? toolUseConfirm.permissionResult.suggestions : undefined, - onRejectFeedbackChange: setRejectFeedback, - onAcceptFeedbackChange: setAcceptFeedback, - yesInputMode, - noInputMode, - editablePrefix, - onEditablePrefixChange - }), [toolUseConfirm, yesInputMode, noInputMode, editablePrefix, onEditablePrefixChange]); + hasUserEditedPrefix.current = true + setEditablePrefix(value) + }, []) + + const unaryEvent = useMemo( + () => ({ completion_type: 'tool_use_single', language_name: 'none' }), + [], + ) + + usePermissionRequestLogging(toolUseConfirm, unaryEvent) + + const options = useMemo( + () => + powershellToolUseOptions({ + suggestions: + toolUseConfirm.permissionResult.behavior === 'ask' + ? toolUseConfirm.permissionResult.suggestions + : undefined, + onRejectFeedbackChange: setRejectFeedback, + onAcceptFeedbackChange: setAcceptFeedback, + 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' - }); + context: 'Confirmation', + }) + function onSelect(value: string) { // Map options to numeric values for analytics (strings not allowed in logEvent) const optionIndex: Record = { yes: 1, 'yes-apply-suggestions': 2, 'yes-prefix-edited': 2, - no: 3 - }; + 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; + explainer_visible: explainerState.visible, + }) + + const toolNameForAnalytics = sanitizeToolNameForAnalytics( + toolUseConfirm.tool.name, + ) 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[] = [{ - type: 'addRules', - rules: [{ - toolName: PowerShellTool.name, - ruleContent: trimmedPrefix - }], - behavior: 'allow', - destination: 'localSettings' - }]; - toolUseConfirm.onAllow(toolUseConfirm.input, prefixUpdates); + const prefixUpdates: PermissionUpdate[] = [ + { + type: 'addRules', + rules: [ + { + toolName: PowerShellTool.name, + ruleContent: trimmedPrefix, + }, + ], + behavior: 'allow', + destination: 'localSettings', + }, + ] + toolUseConfirm.onAllow(toolUseConfirm.input, prefixUpdates) } - onDone(); - return; + onDone() + return } + switch (value) { - case 'yes': - { - const trimmedFeedback = acceptFeedback.trim(); - logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept'); - // Log accept submission with feedback context - logEvent('tengu_accept_submitted', { - toolName: toolNameForAnalytics, - isMcp: toolUseConfirm.tool.isMcp ?? false, - has_instructions: !!trimmedFeedback, - instructions_length: trimmedFeedback.length, - entered_feedback_mode: yesFeedbackModeEntered - }); - toolUseConfirm.onAllow(toolUseConfirm.input, [], trimmedFeedback || undefined); - onDone(); - break; - } - case 'yes-apply-suggestions': - { - 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; - } - case 'no': - { - const trimmedFeedback = rejectFeedback.trim(); + case 'yes': { + const trimmedFeedback = acceptFeedback.trim() + logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept') + // Log accept submission with feedback context + logEvent('tengu_accept_submitted', { + toolName: toolNameForAnalytics, + isMcp: toolUseConfirm.tool.isMcp ?? false, + has_instructions: !!trimmedFeedback, + instructions_length: trimmedFeedback.length, + entered_feedback_mode: yesFeedbackModeEntered, + }) + toolUseConfirm.onAllow( + toolUseConfirm.input, + [], + trimmedFeedback || undefined, + ) + onDone() + break + } + case 'yes-apply-suggestions': { + 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 + } + case 'no': { + const trimmedFeedback = rejectFeedback.trim() - // Log reject submission with feedback context - logEvent('tengu_reject_submitted', { - toolName: toolNameForAnalytics, - isMcp: toolUseConfirm.tool.isMcp ?? false, - has_instructions: !!trimmedFeedback, - instructions_length: trimmedFeedback.length, - entered_feedback_mode: noFeedbackModeEntered - }); + // Log reject submission with feedback context + logEvent('tengu_reject_submitted', { + toolName: toolNameForAnalytics, + isMcp: toolUseConfirm.tool.isMcp ?? false, + has_instructions: !!trimmedFeedback, + instructions_length: trimmedFeedback.length, + entered_feedback_mode: noFeedbackModeEntered, + }) - // Process rejection (with or without feedback) - handleReject(trimmedFeedback || undefined); - break; - } + // Process rejection (with or without feedback) + handleReject(trimmedFeedback || undefined) + break + } } } - return + + return ( + - {PowerShellTool.renderToolUseMessage({ - command, - description - }, { - theme, - verbose: true - } // always show the full command - )} + {PowerShellTool.renderToolUseMessage( + { command, description }, + { theme, verbose: true }, // always show the full command + )} - {!explainerState.visible && {toolUseConfirm.description}} - + {!explainerState.visible && ( + {toolUseConfirm.description} + )} + - {showPermissionDebug ? <> - - {toolUseContext.options.debug && + {showPermissionDebug ? ( + <> + + {toolUseContext.options.debug && ( + Ctrl-D to hide debug info - } - : <> + + )} + + ) : ( + <> - - {destructiveWarning && + + {destructiveWarning && ( + {destructiveWarning} - } + + )} Do you want to proceed? - handleReject()} + onFocus={handleFocus} + onInputModeToggle={handleInputModeToggle} + /> Esc to cancel - {(focusedOption === 'yes' && !yesInputMode || focusedOption === 'no' && !noInputMode) && ' · Tab to amend'} - {explainerState.enabled && ` · ctrl+e to ${explainerState.visible ? 'hide' : 'explain'}`} + {((focusedOption === 'yes' && !yesInputMode) || + (focusedOption === 'no' && !noInputMode)) && + ' · Tab to amend'} + {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 d1daa5124..2ad089efe 100644 --- a/src/components/permissions/PowerShellPermissionRequest/powershellToolUseOptions.tsx +++ b/src/components/permissions/PowerShellPermissionRequest/powershellToolUseOptions.tsx @@ -1,9 +1,15 @@ -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'; +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 function powershellToolUseOptions({ suggestions = [], onRejectFeedbackChange, @@ -11,17 +17,18 @@ export function powershellToolUseOptions({ yesInputMode = false, noInputMode = false, editablePrefix, - onEditablePrefixChange + 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({ type: 'input', @@ -29,13 +36,13 @@ export function powershellToolUseOptions({ value: 'yes', placeholder: 'and tell Claude what to do next', onChange: onAcceptFeedbackChange, - allowEmptySubmitToCancel: true - }); + allowEmptySubmitToCancel: true, + }) } else { options.push({ label: 'Yes', - value: 'yes' - }); + value: 'yes', + }) } // Note: No sandbox toggle for PowerShell - sandbox is not supported on Windows @@ -47,8 +54,17 @@ export function powershellToolUseOptions({ // directory permissions or Read-tool rules, so fall back to the label when // those are present. if (shouldShowAlwaysAllowOptions() && suggestions.length > 0) { - 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) { + 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 + ) { options.push({ type: 'input', label: 'Yes, and don\u2019t ask again for', @@ -59,18 +75,22 @@ export function powershellToolUseOptions({ allowEmptySubmitToCancel: true, showLabelWithValue: true, labelValueSeparator: ': ', - resetCursorOnUpdate: true - }); + 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' - }); + value: 'yes-apply-suggestions', + }) } } } + if (noInputMode) { options.push({ type: 'input', @@ -78,13 +98,14 @@ export function powershellToolUseOptions({ value: 'no', placeholder: 'and tell Claude what to do differently', onChange: onRejectFeedbackChange, - allowEmptySubmitToCancel: true - }); + allowEmptySubmitToCancel: true, + }) } else { options.push({ label: 'No', - value: 'no' - }); + value: 'no', + }) } - return options; + + return options } diff --git a/src/components/permissions/SandboxPermissionRequest.tsx b/src/components/permissions/SandboxPermissionRequest.tsx index affbc35fb..9dc4d6629 100644 --- a/src/components/permissions/SandboxPermissionRequest.tsx +++ b/src/components/permissions/SandboxPermissionRequest.tsx @@ -1,162 +1,106 @@ -import { c as _c } from "react/compiler-runtime"; -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'; +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' + export type SandboxPermissionRequestProps = { - hostPattern: NetworkHostPattern; + hostPattern: NetworkHostPattern onUserResponse: (response: { - allow: boolean; - persistToSettings: boolean; - }) => void; -}; -export function SandboxPermissionRequest(t0) { - const $ = _c(22); - const { - hostPattern: t1, - onUserResponse - } = t0; - const { - host - } = t1; - let t2; - if ($[0] !== onUserResponse) { - t2 = function onSelect(value) { - bb4: switch (value) { - case "yes": - { - onUserResponse({ - allow: true, - persistToSettings: false - }); - break bb4; - } - case "yes-dont-ask-again": - { - onUserResponse({ - allow: true, - persistToSettings: true - }); - break bb4; - } - case "no": - { - onUserResponse({ - allow: false, - persistToSettings: false - }); - } - } - }; - $[0] = onUserResponse; - $[1] = t2; - } else { - t2 = $[1]; - } - const onSelect = t2; - let t3; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t3 = shouldAllowManagedSandboxDomainsOnly(); - $[2] = t3; - } else { - t3 = $[2]; - } - const managedDomainsOnly = t3; - let t4; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t4 = { - label: "Yes", - value: "yes" - }; - $[3] = t4; - } else { - t4 = $[3]; - } - let t5; - if ($[4] !== host) { - t5 = !managedDomainsOnly ? [{ - label: Yes, and don't ask again for {host}, - value: "yes-dont-ask-again" - }] : []; - $[4] = host; - $[5] = t5; - } else { - t5 = $[5]; - } - let t6; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t6 = { - label: No, and tell Claude what to do differently (esc), - value: "no" - }; - $[6] = t6; - } else { - t6 = $[6]; - } - let t7; - if ($[7] !== t5) { - t7 = [t4, ...t5, t6]; - $[7] = t5; - $[8] = t7; - } else { - t7 = $[8]; - } - const options = t7; - let t8; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t8 = Host:; - $[9] = t8; - } else { - t8 = $[9]; - } - let t9; - if ($[10] !== host) { - t9 = {t8} {host}; - $[10] = host; - $[11] = t9; - } else { - t9 = $[11]; - } - let t10; - if ($[12] === Symbol.for("react.memo_cache_sentinel")) { - t10 = Do you want to allow this connection?; - $[12] = t10; - } else { - t10 = $[12]; - } - let t11; - if ($[13] !== onUserResponse) { - t11 = () => { - onUserResponse({ - allow: false, - persistToSettings: false - }); - }; - $[13] = onUserResponse; - $[14] = t11; - } else { - t11 = $[14]; - } - let t12; - if ($[15] !== onSelect || $[16] !== options || $[17] !== t11) { - t12 = { + 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, + }) + } + onUserResponse({ allow: false, persistToSettings: false }) + }} + /> + + + + ) } diff --git a/src/components/permissions/SedEditPermissionRequest/SedEditPermissionRequest.tsx b/src/components/permissions/SedEditPermissionRequest/SedEditPermissionRequest.tsx index 999a63f53..209cd08f4 100644 --- a/src/components/permissions/SedEditPermissionRequest/SedEditPermissionRequest.tsx +++ b/src/components/permissions/SedEditPermissionRequest/SedEditPermissionRequest.tsx @@ -1,229 +1,139 @@ -import { c as _c } from "react/compiler-runtime"; -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; -}; -type FileReadResult = { - oldContent: string; - fileExists: boolean; -}; -export function SedEditPermissionRequest(t0) { - const $ = _c(9); - let props; - let sedInfo; - if ($[0] !== t0) { - ({ - sedInfo, - ...props - } = t0); - $[0] = t0; - $[1] = props; - $[2] = sedInfo; - } else { - props = $[1]; - sedInfo = $[2]; - } - const { - filePath - } = sedInfo; - let t1; - if ($[3] !== filePath) { - t1 = (async () => { - const encoding = detectEncodingForResolvedPath(filePath); - const raw = await getFsImplementation().readFile(filePath, { - encoding - }); - return { - oldContent: raw.replaceAll("\r\n", "\n"), - fileExists: true - }; - })().catch(_temp); - $[3] = filePath; - $[4] = t1; - } else { - t1 = $[4]; - } - const contentPromise = t1; - let t2; - if ($[5] !== contentPromise || $[6] !== props || $[7] !== sedInfo) { - t2 = ; - $[5] = contentPromise; - $[6] = props; - $[7] = sedInfo; - $[8] = t2; - } else { - t2 = $[8]; - } - return t2; + sedInfo: SedEditInfo } -function _temp(e) { - if (!isENOENT(e)) { - throw e; - } - return { - oldContent: "", - fileExists: false - }; + +type FileReadResult = { oldContent: string; fileExists: boolean } + +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. + // Memoized on filePath so we don't re-read on every render. + const contentPromise = useMemo( + () => + (async (): Promise => { + // 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 }) + return { + oldContent: raw.replaceAll('\r\n', '\n'), + fileExists: true, + } + })().catch((e: unknown): FileReadResult => { + if (!isENOENT(e)) throw e + return { oldContent: '', fileExists: false } + }), + [filePath], + ) + + return ( + + + + ) } -function SedEditPermissionRequestInner(t0) { - const $ = _c(35); - let contentPromise; - let props; - let sedInfo; - if ($[0] !== t0) { - ({ - sedInfo, - contentPromise, - ...props - } = t0); - $[0] = t0; - $[1] = contentPromise; - $[2] = props; - $[3] = sedInfo; - } else { - contentPromise = $[1]; - props = $[2]; - sedInfo = $[3]; - } - const { - filePath - } = sedInfo; - const { - oldContent, - fileExists - } = use(contentPromise) as any; - let t1; - if ($[4] !== oldContent || $[5] !== sedInfo) { - t1 = applySedSubstitution(oldContent, sedInfo); - $[4] = oldContent; - $[5] = sedInfo; - $[6] = t1; - } else { - t1 = $[6]; - } - const newContent = t1; - let t2; - bb0: { + +function SedEditPermissionRequestInner({ + sedInfo, + contentPromise, + ...props +}: SedEditPermissionRequestProps & { + contentPromise: Promise +}): React.ReactNode { + 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]) + + // Create the edit representation for the diff + const edits = useMemo(() => { if (oldContent === newContent) { - let t3; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t3 = []; - $[7] = t3; - } else { - t3 = $[7]; - } - t2 = t3; - break bb0; + return [] } - let t3; - if ($[8] !== newContent || $[9] !== oldContent) { - t3 = [{ + return [ + { old_string: oldContent, new_string: newContent, - replace_all: false - }]; - $[8] = newContent; - $[9] = oldContent; - $[10] = t3; - } else { - t3 = $[10]; - } - t2 = t3; - } - const edits = t2; - let t3; - bb1: { + replace_all: false, + }, + ] + }, [oldContent, newContent]) + + // Determine appropriate message when no changes + const noChangesMessage = useMemo(() => { if (!fileExists) { - t3 = "File does not exist"; - break bb1; + return 'File does not exist' + } + 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) + return { + ...parsed, + _simulatedSedEdit: { + filePath, + newContent, + }, } - t3 = "Pattern did not match any content"; } - const noChangesMessage = t3; - let t4; - if ($[11] !== filePath || $[12] !== newContent) { - t4 = input => { - const parsed = BashTool.inputSchema.parse(input); - return { - ...parsed, - _simulatedSedEdit: { - filePath, - newContent - } - }; - }; - $[11] = filePath; - $[12] = newContent; - $[13] = t4; - } else { - t4 = $[13]; - } - const parseInput = t4; - const t5 = props.toolUseConfirm; - const t6 = props.toolUseContext; - const t7 = props.onDone; - const t8 = props.onReject; - let t9; - if ($[14] !== filePath) { - t9 = relative(getCwd(), filePath); - $[14] = filePath; - $[15] = t9; - } else { - t9 = $[15]; - } - let t10; - if ($[16] !== filePath) { - t10 = basename(filePath); - $[16] = filePath; - $[17] = t10; - } else { - t10 = $[17]; - } - let t11; - if ($[18] !== t10) { - t11 = Do you want to make this edit to{" "}{t10}?; - $[18] = t10; - $[19] = t11; - } else { - t11 = $[19]; - } - let t12; - if ($[20] !== edits || $[21] !== filePath || $[22] !== noChangesMessage) { - t12 = edits.length > 0 ? : {noChangesMessage}; - $[20] = edits; - $[21] = filePath; - $[22] = noChangesMessage; - $[23] = t12; - } else { - t12 = $[23]; - } - let t13; - if ($[24] !== filePath || $[25] !== parseInput || $[26] !== props.onDone || $[27] !== props.onReject || $[28] !== props.toolUseConfirm || $[29] !== props.toolUseContext || $[30] !== props.workerBadge || $[31] !== t11 || $[32] !== t12 || $[33] !== t9) { - t13 = ; - $[24] = filePath; - $[25] = parseInput; - $[26] = props.onDone; - $[27] = props.onReject; - $[28] = props.toolUseConfirm; - $[29] = props.toolUseContext; - $[30] = props.workerBadge; - $[31] = t11; - $[32] = t12; - $[33] = t9; - $[34] = t13; - } else { - t13 = $[34]; - } - return t13; + + return ( + + Do you want to make this edit to{' '} + {basename(filePath)}? + + } + content={ + edits.length > 0 ? ( + + ) : ( + {noChangesMessage} + ) + } + path={filePath} + completionType="str_replace_single" + parseInput={parseInput} + workerBadge={props.workerBadge} + /> + ) } diff --git a/src/components/permissions/SkillPermissionRequest/SkillPermissionRequest.tsx b/src/components/permissions/SkillPermissionRequest/SkillPermissionRequest.tsx index ef66df877..799c88705 100644 --- a/src/components/permissions/SkillPermissionRequest/SkillPermissionRequest.tsx +++ b/src/components/permissions/SkillPermissionRequest/SkillPermissionRequest.tsx @@ -1,368 +1,253 @@ -import { c as _c } from "react/compiler-runtime"; -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) { - const $ = _c(51); +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, - workerBadge - } = props; - const parseInput = _temp; - let t0; - if ($[0] !== toolUseConfirm.input) { - t0 = parseInput(toolUseConfirm.input); - $[0] = toolUseConfirm.input; - $[1] = t0; - } else { - t0 = $[1]; + verbose: _verbose, + workerBadge, + } = props + const parseInput = (input: unknown): string => { + const result = SkillTool.inputSchema.safeParse(input) + if (!result.success) { + logError( + new Error(`Failed to parse skill tool input: ${result.error.message}`), + ) + return '' + } + return result.data.skill } - const skill = t0; - const commandObj = toolUseConfirm.permissionResult.behavior === "ask" && toolUseConfirm.permissionResult.metadata && "command" in toolUseConfirm.permissionResult.metadata ? toolUseConfirm.permissionResult.metadata.command : undefined; - let t1; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - completion_type: "tool_use_single", - language_name: "none" - }; - $[2] = t1; - } else { - t1 = $[2]; - } - const unaryEvent = t1; - usePermissionRequestLogging(toolUseConfirm, unaryEvent); - let t2; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t2 = getOriginalCwd(); - $[3] = t2; - } else { - t2 = $[3]; - } - const originalCwd = t2; - let t3; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t3 = shouldShowAlwaysAllowOptions(); - $[4] = t3; - } else { - t3 = $[4]; - } - const showAlwaysAllowOptions = t3; - let t4; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t4 = [{ - label: "Yes", - value: "yes", - feedbackConfig: { - type: "accept" - } - }]; - $[5] = t4; - } else { - t4 = $[5]; - } - const baseOptions = t4; - let alwaysAllowOptions; - if ($[6] !== skill) { - alwaysAllowOptions = []; + + const skill = parseInput(toolUseConfirm.input) + + // Check if this is a command using metadata from checkPermissions + const commandObj = + toolUseConfirm.permissionResult.behavior === 'ask' && + toolUseConfirm.permissionResult.metadata && + 'command' in toolUseConfirm.permissionResult.metadata + ? toolUseConfirm.permissionResult.metadata.command + : undefined + + const unaryEvent = useMemo( + () => ({ + completion_type: 'tool_use_single', + language_name: 'none', + }), + [], + ) + + usePermissionRequestLogging(toolUseConfirm, unaryEvent) + + const originalCwd = getOriginalCwd() + const showAlwaysAllowOptions = shouldShowAlwaysAllowOptions() + const options = useMemo((): PermissionPromptOption[] => { + const baseOptions: PermissionPromptOption[] = [ + { + label: 'Yes', + value: 'yes', + feedbackConfig: { type: 'accept' }, + }, + ] + + // Only add "always allow" options when not restricted by allowManagedPermissionRulesOnly + const alwaysAllowOptions: PermissionPromptOption[] = [] if (showAlwaysAllowOptions) { - const t5 = {skill}; - let t6; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t6 = {originalCwd}; - $[8] = t6; - } else { - t6 = $[8]; - } - let t7; - if ($[9] !== t5) { - t7 = { - label: Yes, and don't ask again for {t5} in{" "}{t6}, - value: "yes-exact" - }; - $[9] = t5; - $[10] = t7; - } else { - t7 = $[10]; - } - alwaysAllowOptions.push(t7); - const spaceIndex = skill.indexOf(" "); + // Add exact match option + alwaysAllowOptions.push({ + label: ( + + 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(' ') if (spaceIndex > 0) { - const commandPrefix = skill.substring(0, spaceIndex); - const t8 = commandPrefix + ":*"; - let t9; - if ($[11] !== t8) { - t9 = {t8}; - $[11] = t8; - $[12] = t9; - } else { - t9 = $[12]; - } - let t10; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t10 = {originalCwd}; - $[13] = t10; - } else { - t10 = $[13]; - } - let t11; - if ($[14] !== t9) { - t11 = { - label: Yes, and don't ask again for{" "}{t9} commands in{" "}{t10}, - value: "yes-prefix" - }; - $[14] = t9; - $[15] = t11; - } else { - t11 = $[15]; - } - alwaysAllowOptions.push(t11); + const commandPrefix = skill.substring(0, spaceIndex) + alwaysAllowOptions.push({ + label: ( + + Yes, and don't ask again for{' '} + {commandPrefix + ':*'} commands in{' '} + {originalCwd} + + ), + value: 'yes-prefix', + }) } } - $[6] = skill; - $[7] = alwaysAllowOptions; - } else { - alwaysAllowOptions = $[7]; - } - let t5; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t5 = { - label: "No", - value: "no", - feedbackConfig: { - type: "reject" - } - }; - $[16] = t5; - } else { - t5 = $[16]; - } - const noOption = t5; - let t6; - if ($[17] !== alwaysAllowOptions) { - t6 = [...baseOptions, ...alwaysAllowOptions, noOption]; - $[17] = alwaysAllowOptions; - $[18] = t6; - } else { - t6 = $[18]; - } - const options = t6; - let t7; - if ($[19] !== toolUseConfirm.tool.name) { - t7 = sanitizeToolNameForAnalytics(toolUseConfirm.tool.name); - $[19] = toolUseConfirm.tool.name; - $[20] = t7; - } else { - t7 = $[20]; - } - const t8 = toolUseConfirm.tool.isMcp ?? false; - let t9; - if ($[21] !== t7 || $[22] !== t8) { - t9 = { - toolName: t7, - isMcp: t8 - }; - $[21] = t7; - $[22] = t8; - $[23] = t9; - } else { - t9 = $[23]; - } - const toolAnalyticsContext = t9; - let t10; - if ($[24] !== onDone || $[25] !== onReject || $[26] !== skill || $[27] !== toolUseConfirm) { - t10 = (value, feedback) => { - bb33: switch (value) { - case "yes": - { - logUnaryEvent({ - completion_type: "tool_use_single", - event: "accept", - metadata: { - language_name: "none", - message_id: toolUseConfirm.assistantMessage.message.id, - platform: env.platform - } - }); - toolUseConfirm.onAllow(toolUseConfirm.input, [], feedback); - onDone(); - break bb33; - } - case "yes-exact": - { - logUnaryEvent({ - completion_type: "tool_use_single", - event: "accept", - metadata: { - language_name: "none", - message_id: toolUseConfirm.assistantMessage.message.id, - platform: env.platform - } - }); - toolUseConfirm.onAllow(toolUseConfirm.input, [{ - type: "addRules", - rules: [{ - toolName: SKILL_TOOL_NAME, - ruleContent: skill - }], - behavior: "allow", - destination: "localSettings" - }]); - onDone(); - break bb33; - } - case "yes-prefix": - { - logUnaryEvent({ - completion_type: "tool_use_single", - event: "accept", - metadata: { - language_name: "none", - message_id: toolUseConfirm.assistantMessage.message.id, - platform: env.platform - } - }); - const spaceIndex_0 = skill.indexOf(" "); - const commandPrefix_0 = spaceIndex_0 > 0 ? skill.substring(0, spaceIndex_0) : skill; - toolUseConfirm.onAllow(toolUseConfirm.input, [{ - type: "addRules", - rules: [{ - toolName: SKILL_TOOL_NAME, - ruleContent: `${commandPrefix_0}:*` - }], - behavior: "allow", - destination: "localSettings" - }]); - onDone(); - break bb33; - } - case "no": - { - logUnaryEvent({ - completion_type: "tool_use_single", - event: "reject", - metadata: { - language_name: "none", - message_id: toolUseConfirm.assistantMessage.message.id, - platform: env.platform - } - }); - toolUseConfirm.onReject(feedback); - onReject(); - onDone(); - } - } - }; - $[24] = onDone; - $[25] = onReject; - $[26] = skill; - $[27] = toolUseConfirm; - $[28] = t10; - } else { - t10 = $[28]; - } - const handleSelect = t10; - let t11; - if ($[29] !== onDone || $[30] !== onReject || $[31] !== toolUseConfirm) { - t11 = () => { - logUnaryEvent({ - completion_type: "tool_use_single", - event: "reject", - metadata: { - language_name: "none", - message_id: toolUseConfirm.assistantMessage.message.id, - platform: env.platform + + const noOption: PermissionPromptOption = { + label: 'No', + value: 'no', + feedbackConfig: { type: 'reject' }, + } + + return [...baseOptions, ...alwaysAllowOptions, noOption] + }, [skill, originalCwd, showAlwaysAllowOptions]) + + const toolAnalyticsContext = useMemo( + (): ToolAnalyticsContext => ({ + toolName: sanitizeToolNameForAnalytics(toolUseConfirm.tool.name), + isMcp: toolUseConfirm.tool.isMcp ?? false, + }), + [toolUseConfirm.tool.name, toolUseConfirm.tool.isMcp], + ) + + const handleSelect = useCallback( + (value: SkillOptionValue, feedback?: string) => { + switch (value) { + case 'yes': + void logUnaryEvent({ + completion_type: 'tool_use_single', + event: 'accept', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + toolUseConfirm.onAllow(toolUseConfirm.input, [], feedback) + onDone() + break + case 'yes-exact': { + void logUnaryEvent({ + completion_type: 'tool_use_single', + event: 'accept', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + + toolUseConfirm.onAllow(toolUseConfirm.input, [ + { + type: 'addRules', + rules: [ + { + toolName: SKILL_TOOL_NAME, + ruleContent: skill, + }, + ], + behavior: 'allow', + destination: 'localSettings', + }, + ]) + onDone() + break } - }); - toolUseConfirm.onReject(); - onReject(); - onDone(); - }; - $[29] = onDone; - $[30] = onReject; - $[31] = toolUseConfirm; - $[32] = t11; - } else { - t11 = $[32]; - } - const handleCancel = t11; - const t12 = `Use skill "${skill}"?`; - let t13; - if ($[33] === Symbol.for("react.memo_cache_sentinel")) { - t13 = Claude may use instructions, code, or files from this Skill.; - $[33] = t13; - } else { - t13 = $[33]; - } - const t14 = commandObj?.description; - let t15; - if ($[34] !== t14) { - t15 = {t14}; - $[34] = t14; - $[35] = t15; - } else { - t15 = $[35]; - } - let t16; - if ($[36] !== toolUseConfirm.permissionResult) { - t16 = ; - $[36] = toolUseConfirm.permissionResult; - $[37] = t16; - } else { - t16 = $[37]; - } - let t17; - if ($[38] !== handleCancel || $[39] !== handleSelect || $[40] !== options || $[41] !== toolAnalyticsContext) { - t17 = ; - $[38] = handleCancel; - $[39] = handleSelect; - $[40] = options; - $[41] = toolAnalyticsContext; - $[42] = t17; - } else { - t17 = $[42]; - } - let t18; - if ($[43] !== t16 || $[44] !== t17) { - t18 = {t16}{t17}; - $[43] = t16; - $[44] = t17; - $[45] = t18; - } else { - t18 = $[45]; - } - let t19; - if ($[46] !== t12 || $[47] !== t15 || $[48] !== t18 || $[49] !== workerBadge) { - t19 = {t13}{t15}{t18}; - $[46] = t12; - $[47] = t15; - $[48] = t18; - $[49] = workerBadge; - $[50] = t19; - } else { - t19 = $[50]; - } - return t19; -} -function _temp(input) { - const result = SkillTool.inputSchema.safeParse(input); - if (!result.success) { - logError(new Error(`Failed to parse skill tool input: ${result.error.message}`)); - return ""; - } - return result.data.skill; + case 'yes-prefix': { + void logUnaryEvent({ + completion_type: 'tool_use_single', + event: 'accept', + metadata: { + language_name: 'none', + 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 + + toolUseConfirm.onAllow(toolUseConfirm.input, [ + { + type: 'addRules', + rules: [ + { + toolName: SKILL_TOOL_NAME, + ruleContent: `${commandPrefix}:*`, + }, + ], + behavior: 'allow', + destination: 'localSettings', + }, + ]) + onDone() + break + } + case 'no': + void logUnaryEvent({ + completion_type: 'tool_use_single', + event: 'reject', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + toolUseConfirm.onReject(feedback) + onReject() + onDone() + break + } + }, + [toolUseConfirm, onDone, onReject, skill], + ) + + const handleCancel = useCallback(() => { + void logUnaryEvent({ + completion_type: 'tool_use_single', + event: 'reject', + metadata: { + language_name: 'none', + message_id: toolUseConfirm.assistantMessage.message.id, + platform: env.platform, + }, + }) + toolUseConfirm.onReject() + onReject() + onDone() + }, [toolUseConfirm, onDone, onReject]) + + return ( + + Claude may use instructions, code, or files from this Skill. + + {commandObj?.description} + + + + + + + + ) } diff --git a/src/components/permissions/WebFetchPermissionRequest/WebFetchPermissionRequest.tsx b/src/components/permissions/WebFetchPermissionRequest/WebFetchPermissionRequest.tsx index 2a93fd5c9..da2498885 100644 --- a/src/components/permissions/WebFetchPermissionRequest/WebFetchPermissionRequest.tsx +++ b/src/components/permissions/WebFetchPermissionRequest/WebFetchPermissionRequest.tsx @@ -1,257 +1,148 @@ -import { c as _c } from "react/compiler-runtime"; -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 { +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()}` } } -export function WebFetchPermissionRequest(t0) { - const $ = _c(41); - const { - toolUseConfirm, - onDone, - onReject, - verbose, - workerBadge - } = t0; - const [theme] = useTheme(); - const { - url - } = toolUseConfirm.input as { - url: string; - }; - let t1; - if ($[0] !== url) { - t1 = new URL(url); - $[0] = url; - $[1] = t1; - } else { - t1 = $[1]; - } - const hostname = t1.hostname; - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = { - completion_type: "tool_use_single", - language_name: "none" - }; - $[2] = t2; - } else { - t2 = $[2]; - } - const unaryEvent = t2; - usePermissionRequestLogging(toolUseConfirm, unaryEvent); - let t3; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t3 = shouldShowAlwaysAllowOptions(); - $[3] = t3; - } else { - t3 = $[3]; - } - const showAlwaysAllowOptions = t3; - let t4; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t4 = { - label: "Yes", - value: "yes" - }; - $[4] = t4; - } else { - t4 = $[4]; - } - let result; - if ($[5] !== hostname) { - result = [t4]; + +export function WebFetchPermissionRequest({ + toolUseConfirm, + onDone, + onReject, + verbose, + workerBadge, +}: PermissionRequestProps): React.ReactNode { + const [theme] = useTheme() + // url is already validated by the input schema + const { url } = toolUseConfirm.input as { url: string } + + // Extract hostname from URL + const hostname = new URL(url).hostname + + const unaryEvent = useMemo( + () => ({ completion_type: 'tool_use_single', language_name: 'none' }), + [], + ) + + usePermissionRequestLogging(toolUseConfirm, unaryEvent) + + // Generate permission options specific to domains + const showAlwaysAllowOptions = shouldShowAlwaysAllowOptions() + const options = useMemo((): OptionWithDescription[] => { + const result: OptionWithDescription[] = [ + { + label: 'Yes', + value: 'yes', + }, + ] + if (showAlwaysAllowOptions) { - const t5 = {hostname}; - let t6; - if ($[7] !== t5) { - t6 = { - label: Yes, and don't ask again for {t5}, - value: "yes-dont-ask-again-domain" - }; - $[7] = t5; - $[8] = t6; - } else { - t6 = $[8]; - } - result.push(t6); + result.push({ + label: ( + + Yes, and don't ask again for {hostname} + + ), + value: 'yes-dont-ask-again-domain', + }) } - let t5; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t5 = { - label: No, and tell Claude what to do differently (esc), - value: "no" - }; - $[9] = t5; - } else { - t5 = $[9]; - } - result.push(t5); - $[5] = hostname; - $[6] = result; - } else { - result = $[6]; - } - const options = result; - let t5; - if ($[10] !== onDone || $[11] !== onReject || $[12] !== toolUseConfirm) { - t5 = function onChange(newValue) { - bb8: switch (newValue) { - case "yes": + + result.push({ + label: ( + + No, and tell Claude what to do differently (esc) + + ), + value: 'no', + }) + + return result + }, [hostname, showAlwaysAllowOptions]) + + function onChange(newValue: string) { + switch (newValue) { + case 'yes': + 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) + const ruleValue = { + toolName: toolUseConfirm.tool.name, + ruleContent, + } + + // Pass permission update directly to onAllow + toolUseConfirm.onAllow(toolUseConfirm.input, [ { - logUnaryPermissionEvent("tool_use_single", toolUseConfirm, "accept"); - toolUseConfirm.onAllow(toolUseConfirm.input, []); - onDone(); - break bb8; - } - case "yes-dont-ask-again-domain": - { - logUnaryPermissionEvent("tool_use_single", toolUseConfirm, "accept"); - const ruleContent = inputToPermissionRuleContent(toolUseConfirm.input); - const ruleValue = { - toolName: toolUseConfirm.tool.name, - ruleContent - }; - toolUseConfirm.onAllow(toolUseConfirm.input, [{ - type: "addRules", - rules: [ruleValue], - behavior: "allow", - destination: "localSettings" - }]); - onDone(); - break bb8; - } - case "no": - { - logUnaryPermissionEvent("tool_use_single", toolUseConfirm, "reject"); - toolUseConfirm.onReject(); - onReject(); - onDone(); - } + type: 'addRules', + rules: [ruleValue], + behavior: 'allow', + destination: 'localSettings', + }, + ]) + onDone() + break } - }; - $[10] = onDone; - $[11] = onReject; - $[12] = toolUseConfirm; - $[13] = t5; - } else { - t5 = $[13]; + case 'no': + logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'reject') + toolUseConfirm.onReject() + onReject() + onDone() + break + } } - const onChange = t5; - let t6; - if ($[14] !== theme || $[15] !== toolUseConfirm.input || $[16] !== verbose) { - t6 = WebFetchTool.renderToolUseMessage(toolUseConfirm.input as { - url: string; - prompt: string; - }, { - theme, - verbose - }); - $[14] = theme; - $[15] = toolUseConfirm.input; - $[16] = verbose; - $[17] = t6; - } else { - t6 = $[17]; - } - let t7; - if ($[18] !== t6) { - t7 = {t6}; - $[18] = t6; - $[19] = t7; - } else { - t7 = $[19]; - } - let t8; - if ($[20] !== toolUseConfirm.description) { - t8 = {toolUseConfirm.description}; - $[20] = toolUseConfirm.description; - $[21] = t8; - } else { - t8 = $[21]; - } - let t9; - if ($[22] !== t7 || $[23] !== t8) { - t9 = {t7}{t8}; - $[22] = t7; - $[23] = t8; - $[24] = t9; - } else { - t9 = $[24]; - } - let t10; - if ($[25] !== toolUseConfirm.permissionResult) { - t10 = ; - $[25] = toolUseConfirm.permissionResult; - $[26] = t10; - } else { - t10 = $[26]; - } - let t11; - if ($[27] === Symbol.for("react.memo_cache_sentinel")) { - t11 = Do you want to allow Claude to fetch this content?; - $[27] = t11; - } else { - t11 = $[27]; - } - let t12; - if ($[28] !== onChange) { - t12 = () => onChange("no"); - $[28] = onChange; - $[29] = t12; - } else { - t12 = $[29]; - } - let t13; - if ($[30] !== onChange || $[31] !== options || $[32] !== t12) { - t13 = onChange('no')} + /> + + + ) } diff --git a/src/components/permissions/WorkerBadge.tsx b/src/components/permissions/WorkerBadge.tsx index bc6bb357f..61d5873ab 100644 --- a/src/components/permissions/WorkerBadge.tsx +++ b/src/components/permissions/WorkerBadge.tsx @@ -1,48 +1,27 @@ -import { c as _c } from "react/compiler-runtime"; -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(t0) { - const $ = _c(7); - const { - name, - color - } = t0; - let t1; - if ($[0] !== color) { - t1 = toInkColor(color); - $[0] = color; - $[1] = t1; - } else { - t1 = $[1]; - } - const inkColor = t1; - let t2; - if ($[2] !== name) { - t2 = @{name}; - $[2] = name; - $[3] = t2; - } else { - t2 = $[3]; - } - let t3; - if ($[4] !== inkColor || $[5] !== t2) { - t3 = {BLACK_CIRCLE} {t2}; - $[4] = inkColor; - $[5] = t2; - $[6] = t3; - } else { - t3 = $[6]; - } - return t3; +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 7caad36c2..06aab0334 100644 --- a/src/components/permissions/WorkerPendingPermission.tsx +++ b/src/components/permissions/WorkerPendingPermission.tsx @@ -1,104 +1,70 @@ -import { c as _c } from "react/compiler-runtime"; -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(t0) { - const $ = _c(15); - const { - toolName, - description - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = getTeamName(); - $[0] = t1; - } else { - t1 = $[0]; - } - const teamName = t1; - let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = getAgentName(); - $[1] = t2; - } else { - t2 = $[1]; - } - const agentName = t2; - let t3; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t3 = getTeammateColor(); - $[2] = t3; - } else { - t3 = $[2]; - } - const agentColor = t3; - let t4; - let t5; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t4 = {" "}Waiting for team lead approval; - t5 = agentName && agentColor && ; - $[3] = t4; - $[4] = t5; - } else { - t4 = $[3]; - t5 = $[4]; - } - let t6; - if ($[5] === Symbol.for("react.memo_cache_sentinel")) { - t6 = Tool: ; - $[5] = t6; - } else { - t6 = $[5]; - } - let t7; - if ($[6] !== toolName) { - t7 = {t6}{toolName}; - $[6] = toolName; - $[7] = t7; - } else { - t7 = $[7]; - } - let t8; - if ($[8] === Symbol.for("react.memo_cache_sentinel")) { - t8 = Action: ; - $[8] = t8; - } else { - t8 = $[8]; - } - let t9; - if ($[9] !== description) { - t9 = {t8}{description}; - $[9] = description; - $[10] = t9; - } else { - t9 = $[10]; - } - let t10; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t10 = teamName && Permission request sent to team {"\""}{teamName}{"\""} leader; - $[11] = t10; - } else { - t10 = $[11]; - } - let t11; - if ($[12] !== t7 || $[13] !== t9) { - t11 = {t4}{t5}{t7}{t9}{t10}; - $[12] = t7; - $[13] = t9; - $[14] = t11; - } else { - t11 = $[14]; - } - return t11; +export function WorkerPendingPermission({ + toolName, + description, +}: Props): React.ReactNode { + const teamName = getTeamName() + const agentName = getAgentName() + const agentColor = getTeammateColor() + + return ( + + + + + {' '} + Waiting for team lead approval + + + + {agentName && agentColor && ( + + + + )} + + + Tool: + {toolName} + + + + Action: + {description} + + + {teamName && ( + + + Permission request sent to team {'"'} + {teamName} + {'"'} leader + + + )} + + ) } diff --git a/src/components/permissions/rules/AddPermissionRules.tsx b/src/components/permissions/rules/AddPermissionRules.tsx index 5de1bf288..6e48e1dcb 100644 --- a/src/components/permissions/rules/AddPermissionRules.tsx +++ b/src/components/permissions/rules/AddPermissionRules.tsx @@ -1,179 +1,165 @@ -import { c as _c } from "react/compiler-runtime"; -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'; -export function optionForPermissionSaveDestination(saveDestination: EditableSettingSource): OptionWithDescription { +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' + +export function optionForPermissionSaveDestination( + saveDestination: EditableSettingSource, +): OptionWithDescription { switch (saveDestination) { case 'localSettings': return { label: 'Project settings (local)', description: `Saved in ${getRelativeSettingsFilePathForSource('localSettings')}`, - value: saveDestination - }; + value: saveDestination, + } case 'projectSettings': return { label: 'Project settings', description: `Checked in at ${getRelativeSettingsFilePathForSource('projectSettings')}`, - value: saveDestination - }; + 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; -}; -export function AddPermissionRules(t0) { - const $ = _c(26); - const { - onAddRules, - onCancel, - ruleValues, - ruleBehavior, - initialContext, - setToolPermissionContext - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = SOURCES.map(optionForPermissionSaveDestination); - $[0] = t1; - } else { - t1 = $[0]; - } - const allOptions = t1; - let t2; - if ($[1] !== initialContext || $[2] !== onAddRules || $[3] !== onCancel || $[4] !== ruleBehavior || $[5] !== ruleValues || $[6] !== setToolPermissionContext) { - t2 = selectedValue => { - if (selectedValue === "cancel") { - onCancel(); - return; - } else { - if ((SOURCES as readonly string[]).includes(selectedValue)) { - const destination = selectedValue as EditableSettingSource; - const updatedContext = applyPermissionUpdate(initialContext, { - type: "addRules", - rules: ruleValues, - behavior: ruleBehavior, - destination - }); - persistPermissionUpdate({ - type: "addRules", - rules: ruleValues, - behavior: ruleBehavior, - destination - }); - setToolPermissionContext(updatedContext); - const rules = ruleValues.map(ruleValue => ({ - ruleValue, - ruleBehavior, - source: destination - })); - const sandboxAutoAllowEnabled = SandboxManager.isSandboxingEnabled() && SandboxManager.isAutoAllowBashIfSandboxedEnabled(); - const allUnreachable = detectUnreachableRules(updatedContext, { - sandboxAutoAllowEnabled - }); - const newUnreachable = allUnreachable.filter(u => ruleValues.some(rv => rv.toolName === u.rule.ruleValue.toolName && rv.ruleContent === u.rule.ruleValue.ruleContent)); - onAddRules(rules, newUnreachable.length > 0 ? newUnreachable : undefined); - } + value: saveDestination, } - }; - $[1] = initialContext; - $[2] = onAddRules; - $[3] = onCancel; - $[4] = ruleBehavior; - $[5] = ruleValues; - $[6] = setToolPermissionContext; - $[7] = t2; - } else { - t2 = $[7]; } - const onSelect = t2; - let t3; - if ($[8] !== ruleValues.length) { - t3 = plural(ruleValues.length, "rule"); - $[8] = ruleValues.length; - $[9] = t3; - } else { - t3 = $[9]; - } - const title = `Add ${ruleBehavior} permission ${t3}`; - let t4; - if ($[10] !== ruleValues) { - t4 = ruleValues.map(_temp); - $[10] = ruleValues; - $[11] = t4; - } else { - t4 = $[11]; - } - let t5; - if ($[12] !== t4) { - t5 = {t4}; - $[12] = t4; - $[13] = t5; - } else { - t5 = $[13]; - } - const t6 = ruleValues.length === 1 ? "Where should this rule be saved?" : "Where should these rules be saved?"; - let t7; - if ($[14] !== t6) { - t7 = {t6}; - $[14] = t6; - $[15] = t7; - } else { - t7 = $[15]; - } - let t8; - if ($[16] !== onSelect) { - t8 = + + + ) } diff --git a/src/components/permissions/rules/AddWorkspaceDirectory.tsx b/src/components/permissions/rules/AddWorkspaceDirectory.tsx index 3ff73d080..07d0a00ef 100644 --- a/src/components/permissions/rules/AddWorkspaceDirectory.tsx +++ b/src/components/permissions/rules/AddWorkspaceDirectory.tsx @@ -1,339 +1,292 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import * as React from 'react'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import { useDebounceCallback } from 'usehooks-ts'; -import { addDirHelpMessage, validateDirectoryForWorkspace } from '../../../commands/add-dir/validation.js'; -import TextInput from '../../../components/TextInput.js'; -import type { KeyboardEvent } from '../../../ink/events/keyboard-event.js'; -import { Box, Text } from '../../../ink.js'; -import { useKeybinding } from '../../../keybindings/useKeybinding.js'; -import type { ToolPermissionContext } from '../../../Tool.js'; -import { getDirectoryCompletions } from '../../../utils/suggestions/directoryCompletion.js'; -import { ConfigurableShortcutHint } from '../../ConfigurableShortcutHint.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 { PromptInputFooterSuggestions, type SuggestionItem } from '../../PromptInput/PromptInputFooterSuggestions.js'; +import figures from 'figures' +import * as React from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useDebounceCallback } from 'usehooks-ts' +import { + addDirHelpMessage, + validateDirectoryForWorkspace, +} from '../../../commands/add-dir/validation.js' +import TextInput from '../../../components/TextInput.js' +import type { KeyboardEvent } from '../../../ink/events/keyboard-event.js' +import { Box, Text } from '../../../ink.js' +import { useKeybinding } from '../../../keybindings/useKeybinding.js' +import type { ToolPermissionContext } from '../../../Tool.js' +import { getDirectoryCompletions } from '../../../utils/suggestions/directoryCompletion.js' +import { ConfigurableShortcutHint } from '../../ConfigurableShortcutHint.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 { + PromptInputFooterSuggestions, + type SuggestionItem, +} from '../../PromptInput/PromptInputFooterSuggestions.js' + type Props = { - onAddDirectory: (path: string, remember?: boolean) => void; - onCancel: () => void; - permissionContext: ToolPermissionContext; - directoryPath?: string; // When directoryPath is provided, show selection options instead of input -}; -type RememberDirectoryOption = 'yes-session' | 'yes-remember' | 'no'; + onAddDirectory: (path: string, remember?: boolean) => void + onCancel: () => void + permissionContext: ToolPermissionContext + directoryPath?: string // When directoryPath is provided, show selection options instead of input +} + +type RememberDirectoryOption = 'yes-session' | 'yes-remember' | 'no' + const REMEMBER_DIRECTORY_OPTIONS: Array<{ - value: RememberDirectoryOption; - label: string; -}> = [{ - value: 'yes-session', - label: 'Yes, for this session' -}, { - value: 'yes-remember', - label: 'Yes, and remember this directory' -}, { - value: 'no', - label: 'No' -}]; -function PermissionDescription() { - const $ = _c(1); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t0 = Claude Code will be able to read files in this directory and make edits when auto-accept edits is on.; - $[0] = t0; - } else { - t0 = $[0]; - } - return t0; + value: RememberDirectoryOption + label: string +}> = [ + { + value: 'yes-session', + label: 'Yes, for this session', + }, + { + value: 'yes-remember', + label: 'Yes, and remember this directory', + }, + { + value: 'no', + label: 'No', + }, +] + +function PermissionDescription(): React.ReactNode { + return ( + + Claude Code will be able to read files in this directory and make edits + when auto-accept edits is on. + + ) } -function DirectoryDisplay(t0) { - const $ = _c(5); - const { - path - } = t0; - let t1; - if ($[0] !== path) { - t1 = {path}; - $[0] = path; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = ; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== t1) { - t3 = {t1}{t2}; - $[3] = t1; - $[4] = t3; - } else { - t3 = $[4]; - } - return t3; + +function DirectoryDisplay({ path }: { path: string }): React.ReactNode { + return ( + + {path} + + + ) } -function DirectoryInput(t0) { - const $ = _c(14); - const { - value, - onChange, - onSubmit, - error, - suggestions, - selectedSuggestion - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Enter the path to the directory:; - $[0] = t1; - } else { - t1 = $[0]; - } - let t2; - if ($[1] !== onChange || $[2] !== onSubmit || $[3] !== value) { - t2 = ; - $[1] = onChange; - $[2] = onSubmit; - $[3] = value; - $[4] = t2; - } else { - t2 = $[4]; - } - let t3; - if ($[5] !== selectedSuggestion || $[6] !== suggestions) { - t3 = suggestions.length > 0 && ; - $[5] = selectedSuggestion; - $[6] = suggestions; - $[7] = t3; - } else { - t3 = $[7]; - } - let t4; - if ($[8] !== error) { - t4 = error && {error}; - $[8] = error; - $[9] = t4; - } else { - t4 = $[9]; - } - let t5; - if ($[10] !== t2 || $[11] !== t3 || $[12] !== t4) { - t5 = {t1}{t2}{t3}{t4}; - $[10] = t2; - $[11] = t3; - $[12] = t4; - $[13] = t5; - } else { - t5 = $[13]; - } - return t5; + +function DirectoryInput({ + value, + onChange, + onSubmit, + error, + suggestions, + selectedSuggestion, +}: { + value: string + onChange: (value: string) => void + onSubmit: (value: string) => void + error: string | null + suggestions: SuggestionItem[] + selectedSuggestion: number +}): React.ReactNode { + return ( + + Enter the path to the directory: + + {}} + /> + + {suggestions.length > 0 && ( + + + + )} + {error && {error}} + + ) } -function _temp() {} -export function AddWorkspaceDirectory(t0) { - const $ = _c(34); - const { - onAddDirectory, - onCancel, - permissionContext, - directoryPath - } = t0; - const [directoryInput, setDirectoryInput] = useState(""); - const [error, setError] = useState(null); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = []; - $[0] = t1; - } else { - t1 = $[0]; - } - const [suggestions, setSuggestions] = useState(t1); - const [selectedSuggestion, setSelectedSuggestion] = useState(0); - let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = async path => { - if (!path) { - setSuggestions([]); - setSelectedSuggestion(0); - return; - } - const completions = await getDirectoryCompletions(path); - setSuggestions(completions); - setSelectedSuggestion(0); - }; - $[1] = t2; - } else { - t2 = $[1]; - } - const fetchSuggestions = t2; - const debouncedFetchSuggestions = useDebounceCallback(fetchSuggestions, 100); - let t3; - let t4; - if ($[2] !== debouncedFetchSuggestions || $[3] !== directoryInput) { - t3 = () => { - debouncedFetchSuggestions(directoryInput); - }; - t4 = [directoryInput, debouncedFetchSuggestions]; - $[2] = debouncedFetchSuggestions; - $[3] = directoryInput; - $[4] = t3; - $[5] = t4; - } else { - t3 = $[4]; - t4 = $[5]; - } - useEffect(t3, t4); - let t5; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t5 = suggestion => { - const newPath = suggestion.id + "/"; - setDirectoryInput(newPath); - setError(null); - }; - $[6] = t5; - } else { - t5 = $[6]; - } - const applySuggestion = t5; - let t6; - if ($[7] !== onAddDirectory || $[8] !== permissionContext) { - t6 = async newPath_0 => { - const result = await validateDirectoryForWorkspace(newPath_0, permissionContext); - if (result.resultType === "success") { - onAddDirectory(result.absolutePath, false); + +export function AddWorkspaceDirectory({ + onAddDirectory, + onCancel, + permissionContext, + directoryPath, +}: Props): React.ReactNode { + const [directoryInput, setDirectoryInput] = useState('') + const [error, setError] = useState(null) + const [suggestions, setSuggestions] = useState([]) + const [selectedSuggestion, setSelectedSuggestion] = useState(0) + const options = useMemo(() => REMEMBER_DIRECTORY_OPTIONS, []) + + // Fetch directory completions + const fetchSuggestions = useCallback(async (path: string) => { + if (!path) { + setSuggestions([]) + setSelectedSuggestion(0) + return + } + const completions = await getDirectoryCompletions(path) + setSuggestions(completions) + setSelectedSuggestion(0) + }, []) + + const debouncedFetchSuggestions = useDebounceCallback(fetchSuggestions, 100) + + useEffect(() => { + void debouncedFetchSuggestions(directoryInput) + }, [directoryInput, debouncedFetchSuggestions]) + + const applySuggestion = useCallback((suggestion: SuggestionItem) => { + const newPath = suggestion.id + '/' + setDirectoryInput(newPath) + setError(null) + // Suggestions will update via the useEffect + }, []) + + // Handle directory submission from input + const handleSubmit = useCallback( + async (newPath: string) => { + const result = await validateDirectoryForWorkspace( + newPath, + permissionContext, + ) + + if (result.resultType === 'success') { + onAddDirectory(result.absolutePath, false) } else { - setError(addDirHelpMessage(result)); + setError(addDirHelpMessage(result)) } - }; - $[7] = onAddDirectory; - $[8] = permissionContext; - $[9] = t6; - } else { - t6 = $[9]; - } - const handleSubmit = t6; - let t7; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t7 = { - context: "Settings" - }; - $[10] = t7; - } else { - t7 = $[10]; - } - useKeybinding("confirm:no", onCancel, t7); - let t8; - if ($[11] !== handleSubmit || $[12] !== selectedSuggestion || $[13] !== suggestions) { - t8 = e => { + }, + [permissionContext, onAddDirectory], + ) + + // Handle Esc to cancel (Ctrl+C handled by global keybindings) + // Use Settings context so 'n' key doesn't cancel (allows typing 'n' in input) + useKeybinding('confirm:no', onCancel, { context: 'Settings' }) + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { if (suggestions.length > 0) { - if (e.key === "tab") { - e.preventDefault(); - const suggestion_0 = suggestions[selectedSuggestion]; - if (suggestion_0) { - applySuggestion(suggestion_0); + // Tab: accept selected suggestion and continue (for drilling into subdirs) + if (e.key === 'tab') { + e.preventDefault() + const suggestion = suggestions[selectedSuggestion] + if (suggestion) { + applySuggestion(suggestion) } - return; + return } - if (e.key === "return") { - e.preventDefault(); - const suggestion_1 = suggestions[selectedSuggestion]; - if (suggestion_1) { - handleSubmit(suggestion_1.id + "/"); + + // Enter: apply selected suggestion and submit + if (e.key === 'return') { + e.preventDefault() + const suggestion = suggestions[selectedSuggestion] + if (suggestion) { + void handleSubmit(suggestion.id + '/') } - return; + return } - if (e.key === "up" || e.ctrl && e.key === "p") { - e.preventDefault(); - setSelectedSuggestion(prev => prev <= 0 ? suggestions.length - 1 : prev - 1); - return; + + if (e.key === 'up' || (e.ctrl && e.key === 'p')) { + e.preventDefault() + setSelectedSuggestion(prev => + prev <= 0 ? suggestions.length - 1 : prev - 1, + ) + return } - if (e.key === "down" || e.ctrl && e.key === "n") { - e.preventDefault(); - setSelectedSuggestion(prev_0 => prev_0 >= suggestions.length - 1 ? 0 : prev_0 + 1); - return; + + if (e.key === 'down' || (e.ctrl && e.key === 'n')) { + e.preventDefault() + setSelectedSuggestion(prev => + prev >= suggestions.length - 1 ? 0 : prev + 1, + ) + return } } - }; - $[11] = handleSubmit; - $[12] = selectedSuggestion; - $[13] = suggestions; - $[14] = t8; - } else { - t8 = $[14]; - } - const handleKeyDown = t8; - let t9; - if ($[15] !== directoryPath || $[16] !== onAddDirectory || $[17] !== onCancel) { - t9 = value => { - if (!directoryPath) { - return; + }, + [suggestions, selectedSuggestion, applySuggestion, handleSubmit], + ) + + const handleSelect = useCallback( + (value: string) => { + if (!directoryPath) return + + const selectionValue = value as RememberDirectoryOption + + switch (selectionValue) { + case 'yes-session': + onAddDirectory(directoryPath, false) + break + case 'yes-remember': + onAddDirectory(directoryPath, true) + break + case 'no': + onCancel() + break } - const selectionValue = value as RememberDirectoryOption; - bb64: switch (selectionValue) { - case "yes-session": - { - onAddDirectory(directoryPath, false); - break bb64; - } - case "yes-remember": - { - onAddDirectory(directoryPath, true); - break bb64; - } - case "no": - { - onCancel(); - } - } - }; - $[15] = directoryPath; - $[16] = onAddDirectory; - $[17] = onCancel; - $[18] = t9; - } else { - t9 = $[18]; - } - const handleSelect = t9; - const t10 = directoryPath ? undefined : _temp2; - let t11; - if ($[19] !== directoryInput || $[20] !== directoryPath || $[21] !== error || $[22] !== handleSelect || $[23] !== handleSubmit || $[24] !== selectedSuggestion || $[25] !== suggestions) { - t11 = directoryPath ? handleSelect('no')} + /> + + ) : ( + + + + + )} + + + ) } diff --git a/src/components/permissions/rules/PermissionRuleDescription.tsx b/src/components/permissions/rules/PermissionRuleDescription.tsx index 57cb34279..ac8f0cd23 100644 --- a/src/components/permissions/rules/PermissionRuleDescription.tsx +++ b/src/components/permissions/rules/PermissionRuleDescription.tsx @@ -1,75 +1,46 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Text } from '../../../ink.js'; -import { BashTool } from '../../../tools/BashTool/BashTool.js'; -import type { PermissionRuleValue } from '../../../utils/permissions/PermissionRule.js'; +import * as React from 'react' +import { Text } from '../../../ink.js' +import { BashTool } from '../../../tools/BashTool/BashTool.js' +import type { PermissionRuleValue } from '../../../utils/permissions/PermissionRule.js' + type RuleSubtitleProps = { - ruleValue: PermissionRuleValue; -}; -export function PermissionRuleDescription(t0) { - const $ = _c(9); - const { - ruleValue - } = t0; + ruleValue: PermissionRuleValue +} + +export function PermissionRuleDescription({ + ruleValue, +}: RuleSubtitleProps): React.ReactNode { switch (ruleValue.toolName) { - case BashTool.name: - { - if (ruleValue.ruleContent) { - if (ruleValue.ruleContent.endsWith(":*")) { - let t1; - if ($[0] !== ruleValue.ruleContent) { - t1 = ruleValue.ruleContent.slice(0, -2); - $[0] = ruleValue.ruleContent; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] !== t1) { - t2 = Any Bash command starting with{" "}{t1}; - $[2] = t1; - $[3] = t2; - } else { - t2 = $[3]; - } - return t2; - } else { - let t1; - if ($[4] !== ruleValue.ruleContent) { - t1 = The Bash command {ruleValue.ruleContent}; - $[4] = ruleValue.ruleContent; - $[5] = t1; - } else { - t1 = $[5]; - } - return t1; - } + case BashTool.name: { + if (ruleValue.ruleContent) { + if (ruleValue.ruleContent.endsWith(':*')) { + return ( + + Any Bash command starting with{' '} + {ruleValue.ruleContent.slice(0, -2)} + + ) } else { - let t1; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Any Bash command; - $[6] = t1; - } else { - t1 = $[6]; - } - return t1; + return ( + + The Bash command {ruleValue.ruleContent} + + ) } + } else { + return Any Bash command } - default: - { - if (!ruleValue.ruleContent) { - let t1; - if ($[7] !== ruleValue.toolName) { - t1 = Any use of the {ruleValue.toolName} tool; - $[7] = ruleValue.toolName; - $[8] = t1; - } else { - t1 = $[8]; - } - return t1; - } else { - return null; - } + } + default: { + if (!ruleValue.ruleContent) { + return ( + + Any use of the {ruleValue.toolName} tool + + ) + } else { + return null } + } } } diff --git a/src/components/permissions/rules/PermissionRuleInput.tsx b/src/components/permissions/rules/PermissionRuleInput.tsx index dbfca58c2..36dfb6b63 100644 --- a/src/components/permissions/rules/PermissionRuleInput.tsx +++ b/src/components/permissions/rules/PermissionRuleInput.tsx @@ -1,137 +1,107 @@ -import { c as _c } from "react/compiler-runtime"; -import figures from 'figures'; -import * as React from 'react'; -import { useState } from 'react'; -import TextInput from '../../../components/TextInput.js'; -import { useExitOnCtrlCDWithKeybindings } from '../../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { useTerminalSize } from '../../../hooks/useTerminalSize.js'; -import { Box, Newline, Text } from '../../../ink.js'; -import { useKeybinding } from '../../../keybindings/useKeybinding.js'; -import { BashTool } from '../../../tools/BashTool/BashTool.js'; -import { WebFetchTool } from '../../../tools/WebFetchTool/WebFetchTool.js'; -import type { PermissionBehavior, PermissionRuleValue } from '../../../utils/permissions/PermissionRule.js'; -import { permissionRuleValueFromString, permissionRuleValueToString } from '../../../utils/permissions/permissionRuleParser.js'; +import figures from 'figures' +import * as React from 'react' +import { useState } from 'react' +import TextInput from '../../../components/TextInput.js' +import { useExitOnCtrlCDWithKeybindings } from '../../../hooks/useExitOnCtrlCDWithKeybindings.js' +import { useTerminalSize } from '../../../hooks/useTerminalSize.js' +import { Box, Newline, Text } from '../../../ink.js' +import { useKeybinding } from '../../../keybindings/useKeybinding.js' +import { BashTool } from '../../../tools/BashTool/BashTool.js' +import { WebFetchTool } from '../../../tools/WebFetchTool/WebFetchTool.js' +import type { + PermissionBehavior, + PermissionRuleValue, +} from '../../../utils/permissions/PermissionRule.js' +import { + permissionRuleValueFromString, + permissionRuleValueToString, +} from '../../../utils/permissions/permissionRuleParser.js' + export type PermissionRuleInputProps = { - onCancel: () => void; - onSubmit: (ruleValue: PermissionRuleValue, ruleBehavior: PermissionBehavior) => void; - ruleBehavior: PermissionBehavior; -}; -export function PermissionRuleInput(t0) { - const $ = _c(24); - const { - onCancel, - onSubmit, - ruleBehavior - } = t0; - const [inputValue, setInputValue] = useState(""); - const [cursorOffset, setCursorOffset] = useState(0); - const exitState = useExitOnCtrlCDWithKeybindings(); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - context: "Settings" - }; - $[0] = t1; - } else { - t1 = $[0]; - } - useKeybinding("confirm:no", onCancel, t1); - const { - columns - } = useTerminalSize(); - const textInputColumns = columns - 6; - let t2; - if ($[1] !== onSubmit || $[2] !== ruleBehavior) { - t2 = value => { - const trimmedValue = value.trim(); - if (trimmedValue.length === 0) { - return; - } - const ruleValue = permissionRuleValueFromString(trimmedValue); - onSubmit(ruleValue, ruleBehavior); - }; - $[1] = onSubmit; - $[2] = ruleBehavior; - $[3] = t2; - } else { - t2 = $[3]; - } - const handleSubmit = t2; - let t3; - if ($[4] !== ruleBehavior) { - t3 = Add {ruleBehavior} permission rule; - $[4] = ruleBehavior; - $[5] = t3; - } else { - t3 = $[5]; - } - let t4; - if ($[6] === Symbol.for("react.memo_cache_sentinel")) { - t4 = ; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - let t6; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t5 = {permissionRuleValueToString({ - toolName: WebFetchTool.name - })}; - t6 = or ; - $[7] = t5; - $[8] = t6; - } else { - t5 = $[7]; - t6 = $[8]; - } - let t7; - if ($[9] === Symbol.for("react.memo_cache_sentinel")) { - t7 = Permission rules are a tool name, optionally followed by a specifier in parentheses.{t4}e.g.,{" "}{t5}{t6}{permissionRuleValueToString({ - toolName: BashTool.name, - ruleContent: "ls:*" - })}; - $[9] = t7; - } else { - t7 = $[9]; - } - let t8; - if ($[10] !== cursorOffset || $[11] !== handleSubmit || $[12] !== inputValue || $[13] !== textInputColumns) { - t8 = {t7}; - $[10] = cursorOffset; - $[11] = handleSubmit; - $[12] = inputValue; - $[13] = textInputColumns; - $[14] = t8; - } else { - t8 = $[14]; - } - let t9; - if ($[15] !== t3 || $[16] !== t8) { - t9 = {t3}{t8}; - $[15] = t3; - $[16] = t8; - $[17] = t9; - } else { - t9 = $[17]; - } - let t10; - if ($[18] !== exitState.keyName || $[19] !== exitState.pending) { - t10 = {exitState.pending ? Press {exitState.keyName} again to exit : Enter to submit · Esc to cancel}; - $[18] = exitState.keyName; - $[19] = exitState.pending; - $[20] = t10; - } else { - t10 = $[20]; - } - let t11; - if ($[21] !== t10 || $[22] !== t9) { - t11 = <>{t9}{t10}; - $[21] = t10; - $[22] = t9; - $[23] = t11; - } else { - t11 = $[23]; - } - return t11; + onCancel: () => void + onSubmit: ( + ruleValue: PermissionRuleValue, + ruleBehavior: PermissionBehavior, + ) => void + ruleBehavior: PermissionBehavior +} + +export function PermissionRuleInput({ + onCancel, + onSubmit, + ruleBehavior, +}: PermissionRuleInputProps): React.ReactNode { + const [inputValue, setInputValue] = useState('') + const [cursorOffset, setCursorOffset] = useState(0) + const exitState = useExitOnCtrlCDWithKeybindings() + + // Use configurable keybinding for ESC to cancel + // Use Settings context so 'n' key doesn't cancel (allows typing 'n' in input) + useKeybinding('confirm:no', onCancel, { context: 'Settings' }) + + const { columns } = useTerminalSize() + const textInputColumns = columns - 6 + + const handleSubmit = (value: string) => { + const trimmedValue = value.trim() + if (trimmedValue.length === 0) { + return + } + const ruleValue = permissionRuleValueFromString(trimmedValue) + onSubmit(ruleValue, ruleBehavior) + } + + return ( + <> + + + Add {ruleBehavior} permission rule + + + + Permission rules are a tool name, optionally followed by a specifier + in parentheses. + + e.g.,{' '} + + {permissionRuleValueToString({ toolName: WebFetchTool.name })} + + or + + {permissionRuleValueToString({ + toolName: BashTool.name, + ruleContent: 'ls:*', + })} + + + + + + + + + {exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + Enter to submit · Esc to cancel + )} + + + ) } diff --git a/src/components/permissions/rules/PermissionRuleList.tsx b/src/components/permissions/rules/PermissionRuleList.tsx index d935a8cc0..129b58083 100644 --- a/src/components/permissions/rules/PermissionRuleList.tsx +++ b/src/components/permissions/rules/PermissionRuleList.tsx @@ -1,272 +1,183 @@ -import { c as _c } from "react/compiler-runtime"; -import chalk from 'chalk'; -import figures from 'figures'; -import * as React from 'react'; -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useAppState, useSetAppState } from 'src/state/AppState.js'; -import { applyPermissionUpdate, persistPermissionUpdate } from 'src/utils/permissions/PermissionUpdate.js'; -import type { PermissionUpdateDestination } from 'src/utils/permissions/PermissionUpdateSchema.js'; -import type { CommandResultDisplay } from '../../../commands.js'; -import { Select } from '../../../components/CustomSelect/select.js'; -import { useExitOnCtrlCDWithKeybindings } from '../../../hooks/useExitOnCtrlCDWithKeybindings.js'; -import { useSearchInput } from '../../../hooks/useSearchInput.js'; -import type { KeyboardEvent } from '../../../ink/events/keyboard-event.js'; -import { Box, Text, useTerminalFocus } from '../../../ink.js'; -import { useKeybinding } from '../../../keybindings/useKeybinding.js'; -import { type AutoModeDenial, getAutoModeDenials } from '../../../utils/autoModeDenials.js'; -import type { PermissionBehavior, PermissionRule, PermissionRuleValue } from '../../../utils/permissions/PermissionRule.js'; -import { permissionRuleValueToString } from '../../../utils/permissions/permissionRuleParser.js'; -import { deletePermissionRule, getAllowRules, getAskRules, getDenyRules, permissionRuleSourceDisplayString } from '../../../utils/permissions/permissions.js'; -import type { UnreachableRule } from '../../../utils/permissions/shadowedRuleDetection.js'; -import { jsonStringify } from '../../../utils/slowOperations.js'; -import { Pane } from '../../design-system/Pane.js'; -import { Tab, Tabs, useTabHeaderFocus, useTabsWidth } from '../../design-system/Tabs.js'; -import { SearchBox } from '../../SearchBox.js'; -import type { Option } from '../../ui/option.js'; -import { AddPermissionRules } from './AddPermissionRules.js'; -import { AddWorkspaceDirectory } from './AddWorkspaceDirectory.js'; -import { PermissionRuleDescription } from './PermissionRuleDescription.js'; -import { PermissionRuleInput } from './PermissionRuleInput.js'; -import { RecentDenialsTab } from './RecentDenialsTab.js'; -import { RemoveWorkspaceDirectory } from './RemoveWorkspaceDirectory.js'; -import { WorkspaceTab } from './WorkspaceTab.js'; -type TabType = 'recent' | 'allow' | 'ask' | 'deny' | 'workspace'; +import chalk from 'chalk' +import figures from 'figures' +import * as React from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useAppState, useSetAppState } from 'src/state/AppState.js' +import { + applyPermissionUpdate, + persistPermissionUpdate, +} from 'src/utils/permissions/PermissionUpdate.js' +import type { PermissionUpdateDestination } from 'src/utils/permissions/PermissionUpdateSchema.js' +import type { CommandResultDisplay } from '../../../commands.js' +import { Select } from '../../../components/CustomSelect/select.js' +import { useExitOnCtrlCDWithKeybindings } from '../../../hooks/useExitOnCtrlCDWithKeybindings.js' +import { useSearchInput } from '../../../hooks/useSearchInput.js' +import type { KeyboardEvent } from '../../../ink/events/keyboard-event.js' +import { Box, Text, useTerminalFocus } from '../../../ink.js' +import { useKeybinding } from '../../../keybindings/useKeybinding.js' +import { + type AutoModeDenial, + getAutoModeDenials, +} from '../../../utils/autoModeDenials.js' +import type { + PermissionBehavior, + PermissionRule, + PermissionRuleValue, +} from '../../../utils/permissions/PermissionRule.js' +import { permissionRuleValueToString } from '../../../utils/permissions/permissionRuleParser.js' +import { + deletePermissionRule, + getAllowRules, + getAskRules, + getDenyRules, + permissionRuleSourceDisplayString, +} from '../../../utils/permissions/permissions.js' +import type { UnreachableRule } from '../../../utils/permissions/shadowedRuleDetection.js' +import { jsonStringify } from '../../../utils/slowOperations.js' +import { Pane } from '../../design-system/Pane.js' +import { + Tab, + Tabs, + useTabHeaderFocus, + useTabsWidth, +} from '../../design-system/Tabs.js' +import { SearchBox } from '../../SearchBox.js' +import type { Option } from '../../ui/option.js' +import { AddPermissionRules } from './AddPermissionRules.js' +import { AddWorkspaceDirectory } from './AddWorkspaceDirectory.js' +import { PermissionRuleDescription } from './PermissionRuleDescription.js' +import { PermissionRuleInput } from './PermissionRuleInput.js' +import { RecentDenialsTab } from './RecentDenialsTab.js' +import { RemoveWorkspaceDirectory } from './RemoveWorkspaceDirectory.js' +import { WorkspaceTab } from './WorkspaceTab.js' + +type TabType = 'recent' | 'allow' | 'ask' | 'deny' | 'workspace' + type RuleSourceTextProps = { - rule: PermissionRule; -}; -function RuleSourceText(t0) { - const $ = _c(4); - const { - rule - } = t0; - let t1; - if ($[0] !== rule.source) { - t1 = permissionRuleSourceDisplayString(rule.source); - $[0] = rule.source; - $[1] = t1; - } else { - t1 = $[1]; - } - const t2 = `From ${t1}`; - let t3; - if ($[2] !== t2) { - t3 = {t2}; - $[2] = t2; - $[3] = t3; - } else { - t3 = $[3]; - } - return t3; + rule: PermissionRule +} +function RuleSourceText({ rule }: RuleSourceTextProps): React.ReactNode { + return ( + {`From ${permissionRuleSourceDisplayString(rule.source)}`} + ) } // Helper function to get the appropriate label for rule behavior function getRuleBehaviorLabel(ruleBehavior: PermissionBehavior): string { switch (ruleBehavior) { case 'allow': - return 'allowed'; + return 'allowed' case 'deny': - return 'denied'; + return 'denied' case 'ask': - return 'ask'; + return 'ask' } } // Component for showing tool details and managing the interactive deletion workflow -function RuleDetails(t0) { - const $ = _c(42); - const { - rule, - onDelete, - onCancel - } = t0; - const exitState = useExitOnCtrlCDWithKeybindings(); - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = { - context: "Confirmation" - }; - $[0] = t1; - } else { - t1 = $[0]; +function RuleDetails({ + rule, + onDelete, + onCancel, +}: { + rule: PermissionRule + onDelete: () => void + onCancel: () => void +}): React.ReactNode { + const exitState = useExitOnCtrlCDWithKeybindings() + // Use configurable keybinding for ESC to cancel + useKeybinding('confirm:no', onCancel, { context: 'Confirmation' }) + + const ruleDescription = ( + + {permissionRuleValueToString(rule.ruleValue)} + + + + ) + + const footer = ( + + {exitState.pending ? ( + Press {exitState.keyName} again to exit + ) : ( + Esc to cancel + )} + + ) + + // Managed settings can't be edited + if (rule.source === 'policySettings') { + return ( + <> + + + Rule details + + {ruleDescription} + + This rule is configured by managed settings and cannot be modified. + {'\n'} + Contact your system administrator for more information. + + + {footer} + + ) } - useKeybinding("confirm:no", onCancel, t1); - let t2; - if ($[1] !== rule.ruleValue) { - t2 = permissionRuleValueToString(rule.ruleValue); - $[1] = rule.ruleValue; - $[2] = t2; - } else { - t2 = $[2]; - } - let t3; - if ($[3] !== t2) { - t3 = {t2}; - $[3] = t2; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== rule.ruleValue) { - t4 = ; - $[5] = rule.ruleValue; - $[6] = t4; - } else { - t4 = $[6]; - } - let t5; - if ($[7] !== rule) { - t5 = ; - $[7] = rule; - $[8] = t5; - } else { - t5 = $[8]; - } - let t6; - if ($[9] !== t3 || $[10] !== t4 || $[11] !== t5) { - t6 = {t3}{t4}{t5}; - $[9] = t3; - $[10] = t4; - $[11] = t5; - $[12] = t6; - } else { - t6 = $[12]; - } - const ruleDescription = t6; - let t7; - if ($[13] !== exitState.keyName || $[14] !== exitState.pending) { - t7 = {exitState.pending ? Press {exitState.keyName} again to exit : Esc to cancel}; - $[13] = exitState.keyName; - $[14] = exitState.pending; - $[15] = t7; - } else { - t7 = $[15]; - } - const footer = t7; - if (rule.source === "policySettings") { - let t8; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t8 = Rule details; - $[16] = t8; - } else { - t8 = $[16]; - } - let t9; - if ($[17] === Symbol.for("react.memo_cache_sentinel")) { - t9 = This rule is configured by managed settings and cannot be modified.{"\n"}Contact your system administrator for more information.; - $[17] = t9; - } else { - t9 = $[17]; - } - let t10; - if ($[18] !== ruleDescription) { - t10 = {t8}{ruleDescription}{t9}; - $[18] = ruleDescription; - $[19] = t10; - } else { - t10 = $[19]; - } - let t11; - if ($[20] !== footer || $[21] !== t10) { - t11 = <>{t10}{footer}; - $[20] = footer; - $[21] = t10; - $[22] = t11; - } else { - t11 = $[22]; - } - return t11; - } - let t8; - if ($[23] !== rule.ruleBehavior) { - t8 = getRuleBehaviorLabel(rule.ruleBehavior); - $[23] = rule.ruleBehavior; - $[24] = t8; - } else { - t8 = $[24]; - } - let t9; - if ($[25] !== t8) { - t9 = Delete {t8} tool?; - $[25] = t8; - $[26] = t9; - } else { - t9 = $[26]; - } - let t10; - if ($[27] === Symbol.for("react.memo_cache_sentinel")) { - t10 = Are you sure you want to delete this permission rule?; - $[27] = t10; - } else { - t10 = $[27]; - } - let t11; - if ($[28] !== onCancel || $[29] !== onDelete) { - t11 = _ => _ === "yes" ? onDelete() : onCancel(); - $[28] = onCancel; - $[29] = onDelete; - $[30] = t11; - } else { - t11 = $[30]; - } - let t12; - if ($[31] === Symbol.for("react.memo_cache_sentinel")) { - t12 = [{ - label: "Yes", - value: "yes" - }, { - label: "No", - value: "no" - }]; - $[31] = t12; - } else { - t12 = $[31]; - } - let t13; - if ($[32] !== onCancel || $[33] !== t11) { - t13 = (_ === 'yes' ? onDelete() : onCancel())} + onCancel={onCancel} + options={[ + { label: 'Yes', value: 'yes' }, + { label: 'No', value: 'no' }, + ]} + /> + + {footer} + + ) } + type RulesTabContentProps = { - options: Option[]; - searchQuery: string; - isSearchMode: boolean; - isFocused: boolean; - onSelect: (value: string) => void; - onCancel: () => void; - lastFocusedRuleKey: string | undefined; - cursorOffset?: number; - onHeaderFocusChange?: (focused: boolean) => void; -}; + options: Option[] + searchQuery: string + isSearchMode: boolean + isFocused: boolean + onSelect: (value: string) => void + onCancel: () => void + lastFocusedRuleKey: string | undefined + cursorOffset?: number + onHeaderFocusChange?: (focused: boolean) => void +} // Component for rendering rules tab content with full width support -function RulesTabContent(props) { - const $ = _c(26); +function RulesTabContent(props: RulesTabContentProps): React.ReactNode { const { options, searchQuery, @@ -276,903 +187,613 @@ function RulesTabContent(props) { onCancel, lastFocusedRuleKey, cursorOffset, - onHeaderFocusChange - } = props; - const tabWidth = useTabsWidth(); - const { - headerFocused, - focusHeader, - blurHeader - } = useTabHeaderFocus(); - let t0; - let t1; - if ($[0] !== blurHeader || $[1] !== headerFocused || $[2] !== isSearchMode) { - t0 = () => { - if (isSearchMode && headerFocused) { - blurHeader(); - } - }; - t1 = [isSearchMode, headerFocused, blurHeader]; - $[0] = blurHeader; - $[1] = headerFocused; - $[2] = isSearchMode; - $[3] = t0; - $[4] = t1; - } else { - t0 = $[3]; - t1 = $[4]; - } - useEffect(t0, t1); - let t2; - let t3; - if ($[5] !== headerFocused || $[6] !== onHeaderFocusChange) { - t2 = () => { - onHeaderFocusChange?.(headerFocused); - }; - t3 = [headerFocused, onHeaderFocusChange]; - $[5] = headerFocused; - $[6] = onHeaderFocusChange; - $[7] = t2; - $[8] = t3; - } else { - t2 = $[7]; - t3 = $[8]; - } - useEffect(t2, t3); - const t4 = isSearchMode && !headerFocused; - let t5; - if ($[9] !== cursorOffset || $[10] !== isFocused || $[11] !== searchQuery || $[12] !== t4 || $[13] !== tabWidth) { - t5 = ; - $[9] = cursorOffset; - $[10] = isFocused; - $[11] = searchQuery; - $[12] = t4; - $[13] = tabWidth; - $[14] = t5; - } else { - t5 = $[14]; - } - const t6 = Math.min(10, options.length); - const t7 = isSearchMode || headerFocused; - let t8; - if ($[15] !== focusHeader || $[16] !== lastFocusedRuleKey || $[17] !== onCancel || $[18] !== onSelect || $[19] !== options || $[20] !== t6 || $[21] !== t7) { - t8 = + + ) } // Composes the subtitle + search + Select for a single allow/ask/deny tab. -function PermissionRulesTab(t0) { - const $ = _c(27); - let T0; - let T1; - let handleToolSelect; - let rulesProps; - let t1; - let t2; - let t3; - let t4; - let tab; - if ($[0] !== t0) { - const { - tab: t5, - getRulesOptions, - handleToolSelect: t6, - ...t7 - } = t0; - tab = t5; - handleToolSelect = t6; - rulesProps = t7; - T1 = Box; - t2 = "column"; - t3 = tab === "allow" ? 0 : undefined; - let t8; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t8 = { - allow: "Claude Code won't ask before using allowed tools.", - ask: "Claude Code will always ask for confirmation before using these tools.", - deny: "Claude Code will always reject requests to use denied tools." - }; - $[10] = t8; - } else { - t8 = $[10]; - } - const t9 = t8[tab]; - if ($[11] !== t9) { - t4 = {t9}; - $[11] = t9; - $[12] = t4; - } else { - t4 = $[12]; - } - T0 = RulesTabContent; - t1 = getRulesOptions(tab, rulesProps.searchQuery); - $[0] = t0; - $[1] = T0; - $[2] = T1; - $[3] = handleToolSelect; - $[4] = rulesProps; - $[5] = t1; - $[6] = t2; - $[7] = t3; - $[8] = t4; - $[9] = tab; - } else { - T0 = $[1]; - T1 = $[2]; - handleToolSelect = $[3]; - rulesProps = $[4]; - t1 = $[5]; - t2 = $[6]; - t3 = $[7]; - t4 = $[8]; - tab = $[9]; - } - let t5; - if ($[13] !== handleToolSelect || $[14] !== tab) { - t5 = v => handleToolSelect(v, tab); - $[13] = handleToolSelect; - $[14] = tab; - $[15] = t5; - } else { - t5 = $[15]; - } - let t6; - if ($[16] !== T0 || $[17] !== rulesProps || $[18] !== t1.options || $[19] !== t5) { - t6 = ; - $[16] = T0; - $[17] = rulesProps; - $[18] = t1.options; - $[19] = t5; - $[20] = t6; - } else { - t6 = $[20]; - } - let t7; - if ($[21] !== T1 || $[22] !== t2 || $[23] !== t3 || $[24] !== t4 || $[25] !== t6) { - t7 = {t4}{t6}; - $[21] = T1; - $[22] = t2; - $[23] = t3; - $[24] = t4; - $[25] = t6; - $[26] = t7; - } else { - t7 = $[26]; - } - return t7; +function PermissionRulesTab({ + tab, + getRulesOptions, + handleToolSelect, + ...rulesProps +}: { + tab: 'allow' | 'ask' | 'deny' + getRulesOptions: (tab: TabType, query?: string) => { options: Option[] } + handleToolSelect: (value: string, tab: TabType) => void +} & Omit): React.ReactNode { + return ( + + + { + { + allow: "Claude Code won't ask before using allowed tools.", + ask: 'Claude Code will always ask for confirmation before using these tools.', + deny: 'Claude Code will always reject requests to use denied tools.', + }[tab] + } + + handleToolSelect(v, tab)} + {...rulesProps} + /> + + ) } + type Props = { - onExit: (result?: string, options?: { - display?: CommandResultDisplay; - shouldQuery?: boolean; - metaMessages?: string[]; - }) => void; - initialTab?: TabType; - onRetryDenials?: (commands: string[]) => void; -}; -export function PermissionRuleList(t0) { - const $ = _c(113); - const { - onExit, - initialTab, - onRetryDenials - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = getAutoModeDenials(); - $[0] = t1; - } else { - t1 = $[0]; - } - const hasDenials = t1.length > 0; - const defaultTab = initialTab ?? (hasDenials ? "recent" : "allow"); - let t2; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t2 = []; - $[1] = t2; - } else { - t2 = $[1]; - } - const [changes, setChanges] = useState(t2); - const toolPermissionContext = useAppState(_temp); - const setAppState = useSetAppState(); - const isTerminalFocused = useTerminalFocus(); - let t3; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t3 = { - approved: new Set(), - retry: new Set(), - denials: [] - }; - $[2] = t3; - } else { - t3 = $[2]; - } - const denialStateRef = useRef(t3); - let t4; - if ($[3] === Symbol.for("react.memo_cache_sentinel")) { - t4 = s_0 => { - denialStateRef.current = s_0; - }; - $[3] = t4; - } else { - t4 = $[3]; - } - const handleDenialStateChange = t4; - const [selectedRule, setSelectedRule] = useState(); - const [lastFocusedRuleKey, setLastFocusedRuleKey] = useState(); - const [addingRuleToTab, setAddingRuleToTab] = useState(null); - const [validatedRule, setValidatedRule] = useState(null); - const [isAddingWorkspaceDirectory, setIsAddingWorkspaceDirectory] = useState(false); - const [removingDirectory, setRemovingDirectory] = useState(null); - const [isSearchMode, setIsSearchMode] = useState(false); - const [headerFocused, setHeaderFocused] = useState(true); - let t5; - if ($[4] === Symbol.for("react.memo_cache_sentinel")) { - t5 = focused => { - setHeaderFocused(focused); - }; - $[4] = t5; - } else { - t5 = $[4]; - } - const handleHeaderFocusChange = t5; - let map; - if ($[5] !== toolPermissionContext) { - map = new Map(); + onExit: ( + result?: string, + options?: { + display?: CommandResultDisplay + shouldQuery?: boolean + metaMessages?: string[] + }, + ) => void + initialTab?: TabType + onRetryDenials?: (commands: string[]) => void +} + +export function PermissionRuleList({ + onExit, + initialTab, + onRetryDenials, +}: Props): React.ReactNode { + const hasDenials = getAutoModeDenials().length > 0 + const defaultTab: TabType = initialTab ?? (hasDenials ? 'recent' : 'allow') + const [changes, setChanges] = useState([]) + const toolPermissionContext = useAppState(s => s.toolPermissionContext) + const setAppState = useSetAppState() + const isTerminalFocused = useTerminalFocus() + + // Ref not state: RecentDenialsTab updates don't need to trigger parent + // re-render (only read on exit), and re-renders trip the modal ScrollBox + // collapse bug from #23592 in fullscreen. + const denialStateRef = useRef<{ + approved: Set + retry: Set + denials: readonly AutoModeDenial[] + }>({ approved: new Set(), retry: new Set(), denials: [] }) + const handleDenialStateChange = useCallback( + (s: typeof denialStateRef.current) => { + denialStateRef.current = s + }, + [], + ) + + const [selectedRule, setSelectedRule] = useState() + // Track the key of the last focused rule to restore position after deletion + const [lastFocusedRuleKey, setLastFocusedRuleKey] = useState< + string | undefined + >() + const [addingRuleToTab, setAddingRuleToTab] = useState(null) + const [validatedRule, setValidatedRule] = useState<{ + ruleBehavior: PermissionBehavior + ruleValue: PermissionRuleValue + } | null>(null) + const [isAddingWorkspaceDirectory, setIsAddingWorkspaceDirectory] = + useState(false) + const [removingDirectory, setRemovingDirectory] = useState( + null, + ) + const [isSearchMode, setIsSearchMode] = useState(false) + const [headerFocused, setHeaderFocused] = useState(true) + const handleHeaderFocusChange = useCallback((focused: boolean) => { + setHeaderFocused(focused) + }, []) + + const allowRulesByKey = useMemo(() => { + const map = new Map() getAllowRules(toolPermissionContext).forEach(rule => { - map.set(jsonStringify(rule), rule); - }); - $[5] = toolPermissionContext; - $[6] = map; - } else { - map = $[6]; - } - const allowRulesByKey = map; - let map_0; - if ($[7] !== toolPermissionContext) { - map_0 = new Map(); - getDenyRules(toolPermissionContext).forEach(rule_0 => { - map_0.set(jsonStringify(rule_0), rule_0); - }); - $[7] = toolPermissionContext; - $[8] = map_0; - } else { - map_0 = $[8]; - } - const denyRulesByKey = map_0; - let map_1; - if ($[9] !== toolPermissionContext) { - map_1 = new Map(); - getAskRules(toolPermissionContext).forEach(rule_1 => { - map_1.set(jsonStringify(rule_1), rule_1); - }); - $[9] = toolPermissionContext; - $[10] = map_1; - } else { - map_1 = $[10]; - } - const askRulesByKey = map_1; - let t6; - if ($[11] !== allowRulesByKey || $[12] !== askRulesByKey || $[13] !== denyRulesByKey) { - t6 = (tab, t7) => { - const query = t7 === undefined ? "" : t7; + map.set(jsonStringify(rule), rule) + }) + return map + }, [toolPermissionContext]) + + const denyRulesByKey = useMemo(() => { + const map = new Map() + getDenyRules(toolPermissionContext).forEach(rule => { + map.set(jsonStringify(rule), rule) + }) + return map + }, [toolPermissionContext]) + + const askRulesByKey = useMemo(() => { + const map = new Map() + getAskRules(toolPermissionContext).forEach(rule => { + map.set(jsonStringify(rule), rule) + }) + return map + }, [toolPermissionContext]) + + const getRulesOptions = useCallback( + (tab: TabType, query: string = '') => { const rulesByKey = (() => { switch (tab) { - case "allow": - { - return allowRulesByKey; - } - case "deny": - { - return denyRulesByKey; - } - case "ask": - { - return askRulesByKey; - } - case "workspace": - case "recent": - { - return new Map(); - } + case 'allow': + return allowRulesByKey + case 'deny': + return denyRulesByKey + case 'ask': + return askRulesByKey + case 'workspace': + case 'recent': + return new Map() } - })(); - const options = []; - if (tab !== "workspace" && tab !== "recent" && !query) { + })() + + const options: Option[] = [] + + // Only show "Add a new rule" for allow and deny tabs (and not when searching) + if (tab !== 'workspace' && tab !== 'recent' && !query) { options.push({ label: `Add a new rule${figures.ellipsis}`, - value: "add-new-rule" - }); + value: 'add-new-rule', + }) } + + // Get all rule keys and sort them alphabetically based on rule's formatted value const sortedRuleKeys = Array.from(rulesByKey.keys()).sort((a, b) => { - const ruleA = rulesByKey.get(a); - const ruleB = rulesByKey.get(b); + const ruleA = rulesByKey.get(a) + const ruleB = rulesByKey.get(b) if (ruleA && ruleB) { - const ruleAString = permissionRuleValueToString(ruleA.ruleValue).toLowerCase(); - const ruleBString = permissionRuleValueToString(ruleB.ruleValue).toLowerCase(); - return ruleAString.localeCompare(ruleBString); + const ruleAString = permissionRuleValueToString( + ruleA.ruleValue, + ).toLowerCase() + const ruleBString = permissionRuleValueToString( + ruleB.ruleValue, + ).toLowerCase() + return ruleAString.localeCompare(ruleBString) } - return 0; - }); - const lowerQuery = query.toLowerCase(); + return 0 + }) + + // Build options from sorted keys, filtering by search query + const lowerQuery = query.toLowerCase() for (const ruleKey of sortedRuleKeys) { - const rule_2 = rulesByKey.get(ruleKey); - if (rule_2) { - const ruleString = permissionRuleValueToString(rule_2.ruleValue); + const rule = rulesByKey.get(ruleKey) + if (rule) { + const ruleString = permissionRuleValueToString(rule.ruleValue) + // Filter by search query if provided if (query && !ruleString.toLowerCase().includes(lowerQuery)) { - continue; + continue } options.push({ label: ruleString, - value: ruleKey - }); + value: ruleKey, + }) } } - return { - options, - rulesByKey - }; - }; - $[11] = allowRulesByKey; - $[12] = askRulesByKey; - $[13] = denyRulesByKey; - $[14] = t6; - } else { - t6 = $[14]; - } - const getRulesOptions = t6; - const exitState = useExitOnCtrlCDWithKeybindings(); - const isSearchModeActive = !selectedRule && !addingRuleToTab && !validatedRule && !isAddingWorkspaceDirectory && !removingDirectory; - const t7 = isSearchModeActive && isSearchMode; - let t8; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t8 = () => { - setIsSearchMode(false); - }; - $[15] = t8; - } else { - t8 = $[15]; - } - let t9; - if ($[16] !== t7) { - t9 = { - isActive: t7, - onExit: t8 - }; - $[16] = t7; - $[17] = t9; - } else { - t9 = $[17]; - } + + return { options, rulesByKey } + }, + [allowRulesByKey, denyRulesByKey, askRulesByKey], + ) + + const exitState = useExitOnCtrlCDWithKeybindings() + + const isSearchModeActive = + !selectedRule && + !addingRuleToTab && + !validatedRule && + !isAddingWorkspaceDirectory && + !removingDirectory + const { query: searchQuery, setQuery: setSearchQuery, - cursorOffset: searchCursorOffset - } = useSearchInput(t9); - let t10; - if ($[18] !== isSearchMode || $[19] !== isSearchModeActive || $[20] !== setSearchQuery) { - t10 = e => { - if (!isSearchModeActive) { - return; + cursorOffset: searchCursorOffset, + } = useSearchInput({ + isActive: isSearchModeActive && isSearchMode, + onExit: () => { + setIsSearchMode(false) + }, + }) + + // Handle entering search mode + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!isSearchModeActive) return + if (isSearchMode) return + if (e.ctrl || e.meta) return + + // Enter search mode with '/' or any printable character. + // e.key.length === 1 filters out special keys (down, return, escape, + // etc.) — previously the raw escape sequence leaked through and + // triggered search mode with garbage on arrow-key press. + if (e.key === '/') { + e.preventDefault() + setIsSearchMode(true) + setSearchQuery('') + } else if ( + e.key.length === 1 && + // Don't enter search mode for vim-nav / space / retry key + e.key !== 'j' && + e.key !== 'k' && + e.key !== 'm' && + e.key !== 'i' && + e.key !== 'r' && + e.key !== ' ' + ) { + e.preventDefault() + setIsSearchMode(true) + setSearchQuery(e.key) } - if (isSearchMode) { - return; - } - if (e.ctrl || e.meta) { - return; - } - if (e.key === "/") { - e.preventDefault(); - setIsSearchMode(true); - setSearchQuery(""); + }, + [isSearchModeActive, isSearchMode, setSearchQuery], + ) + + const handleToolSelect = useCallback( + (selectedValue: string, tab: TabType) => { + const { rulesByKey } = getRulesOptions(tab) + if (selectedValue === 'add-new-rule') { + setAddingRuleToTab(tab) + return } else { - if (e.key.length === 1 && e.key !== "j" && e.key !== "k" && e.key !== "m" && e.key !== "i" && e.key !== "r" && e.key !== " ") { - e.preventDefault(); - setIsSearchMode(true); - setSearchQuery(e.key); - } + setSelectedRule(rulesByKey.get(selectedValue)) + return } - }; - $[18] = isSearchMode; - $[19] = isSearchModeActive; - $[20] = setSearchQuery; - $[21] = t10; - } else { - t10 = $[21]; - } - const handleKeyDown = t10; - let t11; - if ($[22] !== getRulesOptions) { - t11 = (selectedValue, tab_0) => { - const { - rulesByKey: rulesByKey_0 - } = getRulesOptions(tab_0); - if (selectedValue === "add-new-rule") { - setAddingRuleToTab(tab_0); - return; - } else { - setSelectedRule(rulesByKey_0.get(selectedValue)); - return; - } - }; - $[22] = getRulesOptions; - $[23] = t11; - } else { - t11 = $[23]; - } - const handleToolSelect = t11; - let t12; - if ($[24] === Symbol.for("react.memo_cache_sentinel")) { - t12 = () => { - setAddingRuleToTab(null); - }; - $[24] = t12; - } else { - t12 = $[24]; - } - const handleRuleInputCancel = t12; - let t13; - if ($[25] === Symbol.for("react.memo_cache_sentinel")) { - t13 = (ruleValue, ruleBehavior) => { - setValidatedRule({ - ruleValue, - ruleBehavior - }); - setAddingRuleToTab(null); - }; - $[25] = t13; - } else { - t13 = $[25]; - } - const handleRuleInputSubmit = t13; - let t14; - if ($[26] === Symbol.for("react.memo_cache_sentinel")) { - t14 = (rules, unreachable) => { - setValidatedRule(null); - for (const rule_3 of rules) { - setChanges(prev => [...prev, `Added ${rule_3.ruleBehavior} rule ${chalk.bold(permissionRuleValueToString(rule_3.ruleValue))}`]); + }, + [getRulesOptions], + ) + + const handleRuleInputCancel = useCallback(() => { + setAddingRuleToTab(null) + }, []) + + const handleRuleInputSubmit = useCallback( + (ruleValue: PermissionRuleValue, ruleBehavior: PermissionBehavior) => { + setValidatedRule({ ruleValue, ruleBehavior }) + setAddingRuleToTab(null) + }, + [], + ) + + const handleAddRulesSuccess = useCallback( + (rules: PermissionRule[], unreachable?: UnreachableRule[]) => { + setValidatedRule(null) + for (const rule of rules) { + setChanges(prev => [ + ...prev, + `Added ${rule.ruleBehavior} rule ${chalk.bold(permissionRuleValueToString(rule.ruleValue))}`, + ]) } + + // Show warnings for any unreachable rules we just added if (unreachable && unreachable.length > 0) { for (const u of unreachable) { - const severity = u.shadowType === "deny" ? "blocked" : "shadowed"; - setChanges(prev_0 => [...prev_0, chalk.yellow(`${figures.warning} Warning: ${permissionRuleValueToString(u.rule.ruleValue)} is ${severity}`), chalk.dim(` ${u.reason}`), chalk.dim(` Fix: ${u.fix}`)]); + const severity = u.shadowType === 'deny' ? 'blocked' : 'shadowed' + setChanges(prev => [ + ...prev, + chalk.yellow( + `${figures.warning} Warning: ${permissionRuleValueToString(u.rule.ruleValue)} is ${severity}`, + ), + chalk.dim(` ${u.reason}`), + chalk.dim(` Fix: ${u.fix}`), + ]) } } - }; - $[26] = t14; - } else { - t14 = $[26]; - } - const handleAddRulesSuccess = t14; - let t15; - if ($[27] === Symbol.for("react.memo_cache_sentinel")) { - t15 = () => { - setValidatedRule(null); - }; - $[27] = t15; - } else { - t15 = $[27]; - } - const handleAddRuleCancel = t15; - let t16; - if ($[28] === Symbol.for("react.memo_cache_sentinel")) { - t16 = () => setIsAddingWorkspaceDirectory(true); - $[28] = t16; - } else { - t16 = $[28]; - } - const handleRequestAddDirectory = t16; - let t17; - if ($[29] === Symbol.for("react.memo_cache_sentinel")) { - t17 = path => setRemovingDirectory(path); - $[29] = t17; - } else { - t17 = $[29]; - } - const handleRequestRemoveDirectory = t17; - let t18; - if ($[30] !== changes || $[31] !== onExit || $[32] !== onRetryDenials) { - t18 = () => { - const s_1 = denialStateRef.current; - const denialsFor = (set: Set) => Array.from(set).map(idx => s_1.denials[idx]).filter(_temp2); - const retryDenials = denialsFor(s_1.retry); - if (retryDenials.length > 0) { - const commands = retryDenials.map(_temp3); - onRetryDenials?.(commands); - onExit(undefined, { - shouldQuery: true, - metaMessages: [`Permission granted for: ${commands.join(", ")}. You may now retry ${commands.length === 1 ? "this command" : "these commands"} if you would like.`] - }); - return; + }, + [], + ) + + const handleAddRuleCancel = useCallback(() => { + setValidatedRule(null) + }, []) + + const handleRequestAddDirectory = useCallback( + () => setIsAddingWorkspaceDirectory(true), + [], + ) + const handleRequestRemoveDirectory = useCallback( + (path: string) => setRemovingDirectory(path), + [], + ) + const handleRulesCancel = useCallback(() => { + const s = denialStateRef.current + const denialsFor = (set: Set) => + Array.from(set) + .map(idx => s.denials[idx]) + .filter((d): d is AutoModeDenial => d !== undefined) + + const retryDenials = denialsFor(s.retry) + if (retryDenials.length > 0) { + const commands = retryDenials.map(d => d.display) + onRetryDenials?.(commands) + onExit(undefined, { + shouldQuery: true, + metaMessages: [ + `Permission granted for: ${commands.join(', ')}. You may now retry ${commands.length === 1 ? 'this command' : 'these commands'} if you would like.`, + ], + }) + return + } + + const approvedDenials = denialsFor(s.approved) + if (approvedDenials.length > 0 || changes.length > 0) { + const approvedMsg = + approvedDenials.length > 0 + ? [ + `Approved ${approvedDenials.map(d => chalk.bold(d.display)).join(', ')}`, + ] + : [] + onExit([...approvedMsg, ...changes].join('\n')) + } else { + onExit('Permissions dialog dismissed', { + display: 'system', + }) + } + }, [changes, onExit, onRetryDenials]) + + // Handle Escape at the top level so it works even when header is focused + // (which disables the Select component and its select:cancel keybinding). + // Mirrors the pattern in Settings.tsx. + useKeybinding('confirm:no', handleRulesCancel, { + context: 'Settings', + isActive: isSearchModeActive && !isSearchMode, + }) + + const handleDeleteRule = () => { + if (!selectedRule) return + + // Find the adjacent rule to focus on after deletion + const { options } = getRulesOptions(selectedRule.ruleBehavior as TabType) + const selectedKey = jsonStringify(selectedRule) + const ruleKeys = options + .filter(opt => opt.value !== 'add-new-rule') + .map(opt => opt.value) + const currentIndex = ruleKeys.indexOf(selectedKey) + + // Try to focus on the next rule, or the previous if deleting the last one + let nextFocusKey: string | undefined + if (currentIndex !== -1) { + if (currentIndex < ruleKeys.length - 1) { + // Focus on the next rule + nextFocusKey = ruleKeys[currentIndex + 1] + } else if (currentIndex > 0) { + // Focus on the previous rule (we're deleting the last one) + nextFocusKey = ruleKeys[currentIndex - 1] } - const approvedDenials = denialsFor(s_1.approved); - if (approvedDenials.length > 0 || changes.length > 0) { - const approvedMsg = approvedDenials.length > 0 ? [`Approved ${approvedDenials.map(_temp4).join(", ")}`] : []; - onExit([...approvedMsg, ...changes].join("\n")); - } else { - onExit("Permissions dialog dismissed", { - display: "system" - }); - } - }; - $[30] = changes; - $[31] = onExit; - $[32] = onRetryDenials; - $[33] = t18; - } else { - t18 = $[33]; + } + setLastFocusedRuleKey(nextFocusKey) + + void deletePermissionRule({ + rule: selectedRule, + initialContext: toolPermissionContext, + setToolPermissionContext(toolPermissionContext) { + setAppState(prev => ({ + ...prev, + toolPermissionContext, + })) + }, + }) + + setChanges(prev => [ + ...prev, + `Deleted ${selectedRule.ruleBehavior} rule ${chalk.bold(permissionRuleValueToString(selectedRule.ruleValue))}`, + ]) + setSelectedRule(undefined) } - const handleRulesCancel = t18; - const t19 = isSearchModeActive && !isSearchMode; - let t20; - if ($[34] !== t19) { - t20 = { - context: "Settings", - isActive: t19 - }; - $[34] = t19; - $[35] = t20; - } else { - t20 = $[35]; - } - useKeybinding("confirm:no", handleRulesCancel, t20); - let t21; - if ($[36] !== getRulesOptions || $[37] !== selectedRule || $[38] !== setAppState || $[39] !== toolPermissionContext) { - t21 = () => { - if (!selectedRule) { - return; - } - const { - options: options_0 - } = getRulesOptions(selectedRule.ruleBehavior as TabType); - const selectedKey = jsonStringify(selectedRule); - const ruleKeys = options_0.filter(_temp5).map(_temp6); - const currentIndex = ruleKeys.indexOf(selectedKey); - let nextFocusKey; - if (currentIndex !== -1) { - if (currentIndex < ruleKeys.length - 1) { - nextFocusKey = ruleKeys[currentIndex + 1]; - } else { - if (currentIndex > 0) { - nextFocusKey = ruleKeys[currentIndex - 1]; - } - } - } - setLastFocusedRuleKey(nextFocusKey); - deletePermissionRule({ - rule: selectedRule, - initialContext: toolPermissionContext, - setToolPermissionContext(toolPermissionContext_0) { - setAppState(prev_1 => ({ - ...prev_1, - toolPermissionContext: toolPermissionContext_0 - })); - } - }); - setChanges(prev_2 => [...prev_2, `Deleted ${selectedRule.ruleBehavior} rule ${chalk.bold(permissionRuleValueToString(selectedRule.ruleValue))}`]); - setSelectedRule(undefined); - }; - $[36] = getRulesOptions; - $[37] = selectedRule; - $[38] = setAppState; - $[39] = toolPermissionContext; - $[40] = t21; - } else { - t21 = $[40]; - } - const handleDeleteRule = t21; + if (selectedRule) { - let t22; - if ($[41] === Symbol.for("react.memo_cache_sentinel")) { - t22 = () => setSelectedRule(undefined); - $[41] = t22; - } else { - t22 = $[41]; - } - let t23; - if ($[42] !== handleDeleteRule || $[43] !== selectedRule) { - t23 = ; - $[42] = handleDeleteRule; - $[43] = selectedRule; - $[44] = t23; - } else { - t23 = $[44]; - } - return t23; + return ( + setSelectedRule(undefined)} + /> + ) } - if (addingRuleToTab && addingRuleToTab !== "workspace" && addingRuleToTab !== "recent") { - let t22; - if ($[45] !== addingRuleToTab) { - t22 = ; - $[45] = addingRuleToTab; - $[46] = t22; - } else { - t22 = $[46]; - } - return t22; + + if ( + addingRuleToTab && + addingRuleToTab !== 'workspace' && + addingRuleToTab !== 'recent' + ) { + return ( + + ) } + if (validatedRule) { - let t22; - if ($[47] !== validatedRule.ruleValue) { - t22 = [validatedRule.ruleValue]; - $[47] = validatedRule.ruleValue; - $[48] = t22; - } else { - t22 = $[48]; - } - let t23; - if ($[49] !== setAppState) { - t23 = toolPermissionContext_1 => { - setAppState(prev_3 => ({ - ...prev_3, - toolPermissionContext: toolPermissionContext_1 - })); - }; - $[49] = setAppState; - $[50] = t23; - } else { - t23 = $[50]; - } - let t24; - if ($[51] !== t22 || $[52] !== t23 || $[53] !== toolPermissionContext || $[54] !== validatedRule.ruleBehavior) { - t24 = ; - $[51] = t22; - $[52] = t23; - $[53] = toolPermissionContext; - $[54] = validatedRule.ruleBehavior; - $[55] = t24; - } else { - t24 = $[55]; - } - return t24; + return ( + { + setAppState(prev => ({ + ...prev, + toolPermissionContext, + })) + }} + /> + ) } + if (isAddingWorkspaceDirectory) { - let t22; - if ($[56] !== setAppState || $[57] !== toolPermissionContext) { - t22 = (path_0, remember) => { - const destination: PermissionUpdateDestination = remember ? "localSettings" : "session"; - const permissionUpdate = { - type: "addDirectories" as const, - directories: [path_0], - destination - }; - const updatedContext = applyPermissionUpdate(toolPermissionContext, permissionUpdate); - setAppState(prev_4 => ({ - ...prev_4, - toolPermissionContext: updatedContext - })); - if (remember) { - persistPermissionUpdate(permissionUpdate); - } - setChanges(prev_5 => [...prev_5, `Added directory ${chalk.bold(path_0)} to workspace${remember ? " and saved to local settings" : " for this session"}`]); - setIsAddingWorkspaceDirectory(false); - }; - $[56] = setAppState; - $[57] = toolPermissionContext; - $[58] = t22; - } else { - t22 = $[58]; - } - let t23; - if ($[59] === Symbol.for("react.memo_cache_sentinel")) { - t23 = () => setIsAddingWorkspaceDirectory(false); - $[59] = t23; - } else { - t23 = $[59]; - } - let t24; - if ($[60] !== t22 || $[61] !== toolPermissionContext) { - t24 = ; - $[60] = t22; - $[61] = toolPermissionContext; - $[62] = t24; - } else { - t24 = $[62]; - } - return t24; + return ( + { + // Apply the permission update to add the directory + const destination: PermissionUpdateDestination = remember + ? 'localSettings' + : 'session' + + const permissionUpdate = { + type: 'addDirectories' as const, + directories: [path], + destination, + } + + const updatedContext = applyPermissionUpdate( + toolPermissionContext, + permissionUpdate, + ) + setAppState(prev => ({ + ...prev, + toolPermissionContext: updatedContext, + })) + + // Persist if remember is true + if (remember) { + persistPermissionUpdate(permissionUpdate) + } + + setChanges(prev => [ + ...prev, + `Added directory ${chalk.bold(path)} to workspace${remember ? ' and saved to local settings' : ' for this session'}`, + ]) + setIsAddingWorkspaceDirectory(false) + }} + onCancel={() => setIsAddingWorkspaceDirectory(false)} + permissionContext={toolPermissionContext} + /> + ) } + if (removingDirectory) { - let t22; - if ($[63] !== removingDirectory) { - t22 = () => { - setChanges(prev_6 => [...prev_6, `Removed directory ${chalk.bold(removingDirectory)} from workspace`]); - setRemovingDirectory(null); - }; - $[63] = removingDirectory; - $[64] = t22; - } else { - t22 = $[64]; - } - let t23; - if ($[65] === Symbol.for("react.memo_cache_sentinel")) { - t23 = () => setRemovingDirectory(null); - $[65] = t23; - } else { - t23 = $[65]; - } - let t24; - if ($[66] !== setAppState) { - t24 = toolPermissionContext_2 => { - setAppState(prev_7 => ({ - ...prev_7, - toolPermissionContext: toolPermissionContext_2 - })); - }; - $[66] = setAppState; - $[67] = t24; - } else { - t24 = $[67]; - } - let t25; - if ($[68] !== removingDirectory || $[69] !== t22 || $[70] !== t24 || $[71] !== toolPermissionContext) { - t25 = ; - $[68] = removingDirectory; - $[69] = t22; - $[70] = t24; - $[71] = toolPermissionContext; - $[72] = t25; - } else { - t25 = $[72]; - } - return t25; + return ( + { + setChanges(prev => [ + ...prev, + `Removed directory ${chalk.bold(removingDirectory)} from workspace`, + ]) + setRemovingDirectory(null) + }} + onCancel={() => setRemovingDirectory(null)} + permissionContext={toolPermissionContext} + setPermissionContext={toolPermissionContext => { + setAppState(prev => ({ + ...prev, + toolPermissionContext, + })) + }} + /> + ) } - let t22; - if ($[73] !== getRulesOptions || $[74] !== handleRulesCancel || $[75] !== handleToolSelect || $[76] !== isSearchMode || $[77] !== isTerminalFocused || $[78] !== lastFocusedRuleKey || $[79] !== searchCursorOffset || $[80] !== searchQuery) { - t22 = { - searchQuery, - isSearchMode, - isFocused: isTerminalFocused, - onCancel: handleRulesCancel, - lastFocusedRuleKey, - cursorOffset: searchCursorOffset, - getRulesOptions, - handleToolSelect, - onHeaderFocusChange: handleHeaderFocusChange - }; - $[73] = getRulesOptions; - $[74] = handleRulesCancel; - $[75] = handleToolSelect; - $[76] = isSearchMode; - $[77] = isTerminalFocused; - $[78] = lastFocusedRuleKey; - $[79] = searchCursorOffset; - $[80] = searchQuery; - $[81] = t22; - } else { - t22 = $[81]; + + const sharedRulesProps = { + searchQuery, + isSearchMode, + isFocused: isTerminalFocused, + onCancel: handleRulesCancel, + lastFocusedRuleKey, + cursorOffset: searchCursorOffset, + getRulesOptions, + handleToolSelect, + onHeaderFocusChange: handleHeaderFocusChange, } - const sharedRulesProps = t22; - const isHidden = !!selectedRule || !!addingRuleToTab || !!validatedRule || isAddingWorkspaceDirectory || !!removingDirectory; - const t23 = !isSearchMode; - let t24; - if ($[82] === Symbol.for("react.memo_cache_sentinel")) { - t24 = ; - $[82] = t24; - } else { - t24 = $[82]; - } - let t25; - if ($[83] !== sharedRulesProps) { - t25 = ; - $[83] = sharedRulesProps; - $[84] = t25; - } else { - t25 = $[84]; - } - let t26; - if ($[85] !== sharedRulesProps) { - t26 = ; - $[85] = sharedRulesProps; - $[86] = t26; - } else { - t26 = $[86]; - } - let t27; - if ($[87] !== sharedRulesProps) { - t27 = ; - $[87] = sharedRulesProps; - $[88] = t27; - } else { - t27 = $[88]; - } - let t28; - if ($[89] === Symbol.for("react.memo_cache_sentinel")) { - t28 = Claude Code can read files in the workspace, and make edits when auto-accept edits is on.; - $[89] = t28; - } else { - t28 = $[89]; - } - let t29; - if ($[90] !== onExit || $[91] !== toolPermissionContext) { - t29 = {t28}; - $[90] = onExit; - $[91] = toolPermissionContext; - $[92] = t29; - } else { - t29 = $[92]; - } - let t30; - if ($[93] !== defaultTab || $[94] !== isHidden || $[95] !== t23 || $[96] !== t25 || $[97] !== t26 || $[98] !== t27 || $[99] !== t29) { - t30 = ; - $[93] = defaultTab; - $[94] = isHidden; - $[95] = t23; - $[96] = t25; - $[97] = t26; - $[98] = t27; - $[99] = t29; - $[100] = t30; - } else { - t30 = $[100]; - } - let t31; - if ($[101] !== defaultTab || $[102] !== exitState.keyName || $[103] !== exitState.pending || $[104] !== headerFocused || $[105] !== isSearchMode) { - t31 = {exitState.pending ? <>Press {exitState.keyName} again to exit : headerFocused ? <>←/→ tab switch · ↓ return · Esc cancel : isSearchMode ? <>Type to filter · Enter/↓ select · ↑ tabs · Esc clear : hasDenials && defaultTab === "recent" ? <>Enter approve · r retry · ↑↓ navigate · ←/→ switch · Esc cancel : <>↑↓ navigate · Enter select · Type to search · ←/→ switch · Esc cancel}; - $[101] = defaultTab; - $[102] = exitState.keyName; - $[103] = exitState.pending; - $[104] = headerFocused; - $[105] = isSearchMode; - $[106] = t31; - } else { - t31 = $[106]; - } - let t32; - if ($[107] !== t30 || $[108] !== t31) { - t32 = {t30}{t31}; - $[107] = t30; - $[108] = t31; - $[109] = t32; - } else { - t32 = $[109]; - } - let t33; - if ($[110] !== handleKeyDown || $[111] !== t32) { - t33 = {t32}; - $[110] = handleKeyDown; - $[111] = t32; - $[112] = t33; - } else { - t33 = $[112]; - } - return t33; -} -function _temp6(opt_0) { - return opt_0.value; -} -function _temp5(opt) { - return opt.value !== "add-new-rule"; -} -function _temp4(d_1) { - return chalk.bold(d_1.display); -} -function _temp3(d_0) { - return d_0.display; -} -function _temp2(d) { - return d !== undefined; -} -function _temp(s) { - return s.toolPermissionContext; + + const isHidden = + !!selectedRule || + !!addingRuleToTab || + !!validatedRule || + isAddingWorkspaceDirectory || + !!removingDirectory + + return ( + + + + + + {exitState.pending ? ( + <>Press {exitState.keyName} again to exit + ) : headerFocused ? ( + <>←/→ tab switch · ↓ return · Esc cancel + ) : isSearchMode ? ( + <>Type to filter · Enter/↓ select · ↑ tabs · Esc clear + ) : hasDenials && defaultTab === 'recent' ? ( + <> + Enter approve · r retry · ↑↓ navigate · ←/→ switch · Esc cancel + + ) : ( + <> + ↑↓ navigate · Enter select · Type to search · ←/→ switch · Esc + cancel + + )} + + + + + ) } diff --git a/src/components/permissions/rules/RecentDenialsTab.tsx b/src/components/permissions/rules/RecentDenialsTab.tsx index cba81a4ea..17c13844d 100644 --- a/src/components/permissions/rules/RecentDenialsTab.tsx +++ b/src/components/permissions/rules/RecentDenialsTab.tsx @@ -1,206 +1,118 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { useCallback, useEffect, useState } from 'react'; +import * as React from 'react' +import { useCallback, useEffect, useState } from 'react' // eslint-disable-next-line custom-rules/prefer-use-keybindings -- 'r' is a view-specific key, not a global keybinding -import { Box, Text, useInput } from '../../../ink.js'; -import { type AutoModeDenial, getAutoModeDenials } from '../../../utils/autoModeDenials.js'; -import { Select } from '../../CustomSelect/select.js'; -import { StatusIcon } from '../../design-system/StatusIcon.js'; -import { useTabHeaderFocus } from '../../design-system/Tabs.js'; +import { Box, Text, useInput } from '../../../ink.js' +import { + type AutoModeDenial, + getAutoModeDenials, +} from '../../../utils/autoModeDenials.js' +import { Select } from '../../CustomSelect/select.js' +import { StatusIcon } from '../../design-system/StatusIcon.js' +import { useTabHeaderFocus } from '../../design-system/Tabs.js' + type Props = { - onHeaderFocusChange?: (focused: boolean) => void; + onHeaderFocusChange?: (focused: boolean) => void /** Called when approved/retry state changes so parent can act on exit */ onStateChange: (state: { - approved: Set; - retry: Set; - denials: readonly AutoModeDenial[]; - }) => void; -}; -export function RecentDenialsTab(t0) { - const $ = _c(30); - const { - onHeaderFocusChange, - onStateChange - } = t0; - const { - headerFocused, - focusHeader - } = useTabHeaderFocus(); - let t1; - let t2; - if ($[0] !== headerFocused || $[1] !== onHeaderFocusChange) { - t1 = () => { - onHeaderFocusChange?.(headerFocused); - }; - t2 = [headerFocused, onHeaderFocusChange]; - $[0] = headerFocused; - $[1] = onHeaderFocusChange; - $[2] = t1; - $[3] = t2; - } else { - t1 = $[2]; - t2 = $[3]; - } - useEffect(t1, t2); - const [denials] = useState(_temp); - const [approved, setApproved] = useState(_temp2); - const [retry, setRetry] = useState(_temp3); - const [focusedIdx, setFocusedIdx] = useState(0); - let t3; - let t4; - if ($[4] !== approved || $[5] !== denials || $[6] !== onStateChange || $[7] !== retry) { - t3 = () => { - onStateChange({ - approved, - retry, - denials - }); - }; - t4 = [approved, retry, denials, onStateChange]; - $[4] = approved; - $[5] = denials; - $[6] = onStateChange; - $[7] = retry; - $[8] = t3; - $[9] = t4; - } else { - t3 = $[8]; - t4 = $[9]; - } - useEffect(t3, t4); - let t5; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t5 = value => { - const idx = Number(value); - setApproved(prev => { - const next = new Set(prev); - if (next.has(idx)) { - next.delete(idx); - } else { - next.add(idx); - } - return next; - }); - }; - $[10] = t5; - } else { - t5 = $[10]; - } - const handleSelect = t5; - let t6; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t6 = value_0 => { - setFocusedIdx(Number(value_0)); - }; - $[11] = t6; - } else { - t6 = $[11]; - } - const handleFocus = t6; - let t7; - if ($[12] !== focusedIdx) { - t7 = (input, _key) => { - if (input === "r") { - setRetry(prev_0 => { - const next_0 = new Set(prev_0); - if (next_0.has(focusedIdx)) { - next_0.delete(focusedIdx); - } else { - next_0.add(focusedIdx); - } - return next_0; - }); - setApproved(prev_1 => { - if (prev_1.has(focusedIdx)) { - return prev_1; - } - const next_1 = new Set(prev_1); - next_1.add(focusedIdx); - return next_1; - }); + approved: Set + retry: Set + denials: readonly AutoModeDenial[] + }) => void +} + +export function RecentDenialsTab({ + onHeaderFocusChange, + onStateChange, +}: Props): React.ReactNode { + const { headerFocused, focusHeader } = useTabHeaderFocus() + useEffect(() => { + onHeaderFocusChange?.(headerFocused) + }, [headerFocused, onHeaderFocusChange]) + + // Snapshot on mount — approved/retry Sets key by index, and the live store + // prepends. A concurrent denial would shift all indices mid-edit. + const [denials] = useState(() => getAutoModeDenials()) + + const [approved, setApproved] = useState>(() => new Set()) + const [retry, setRetry] = useState>(() => new Set()) + const [focusedIdx, setFocusedIdx] = useState(0) + + useEffect(() => { + onStateChange({ approved, retry, denials }) + }, [approved, retry, denials, onStateChange]) + + const handleSelect = useCallback((value: string) => { + const idx = Number(value) + setApproved(prev => { + const next = new Set(prev) + if (next.has(idx)) next.delete(idx) + else next.add(idx) + return next + }) + }, []) + + const handleFocus = useCallback((value: string) => { + setFocusedIdx(Number(value)) + }, []) + + useInput( + (input, _key) => { + if (input === 'r') { + setRetry(prev => { + const next = new Set(prev) + if (next.has(focusedIdx)) next.delete(focusedIdx) + else next.add(focusedIdx) + return next + }) + // Retry implies approve + setApproved(prev => { + if (prev.has(focusedIdx)) return prev + const next = new Set(prev) + next.add(focusedIdx) + return next + }) } - }; - $[12] = focusedIdx; - $[13] = t7; - } else { - t7 = $[13]; - } - const t8 = denials.length > 0; - let t9; - if ($[14] !== t8) { - t9 = { - isActive: t8 - }; - $[14] = t8; - $[15] = t9; - } else { - t9 = $[15]; - } - useInput(t7, t9); + }, + { isActive: denials.length > 0 }, + ) + if (denials.length === 0) { - let t10; - if ($[16] === Symbol.for("react.memo_cache_sentinel")) { - t10 = No recent denials. Commands denied by the auto mode classifier will appear here.; - $[16] = t10; - } else { - t10 = $[16]; + return ( + + No recent denials. Commands denied by the auto mode classifier will + appear here. + + ) + } + + const options = denials.map((d, idx) => { + const isApproved = approved.has(idx) + const suffix = retry.has(idx) ? ' (retry)' : '' + return { + label: ( + + + {d.display} + {suffix} + + ), + value: String(idx), } - return t10; - } - let t10; - if ($[17] !== approved || $[18] !== denials || $[19] !== retry) { - let t11; - if ($[21] !== approved || $[22] !== retry) { - t11 = (d, idx_0) => { - const isApproved = approved.has(idx_0); - const suffix = retry.has(idx_0) ? " (retry)" : ""; - return { - label: {d.display}{suffix}, - value: String(idx_0) - }; - }; - $[21] = approved; - $[22] = retry; - $[23] = t11; - } else { - t11 = $[23]; - } - t10 = denials.map(t11); - $[17] = approved; - $[18] = denials; - $[19] = retry; - $[20] = t10; - } else { - t10 = $[20]; - } - const options = t10; - let t11; - if ($[24] === Symbol.for("react.memo_cache_sentinel")) { - t11 = Commands recently denied by the auto mode classifier.; - $[24] = t11; - } else { - t11 = $[24]; - } - const t12 = Math.min(10, options.length); - let t13; - if ($[25] !== focusHeader || $[26] !== headerFocused || $[27] !== options || $[28] !== t12) { - t13 = {t11} + + + ) } diff --git a/src/components/permissions/rules/RemoveWorkspaceDirectory.tsx b/src/components/permissions/rules/RemoveWorkspaceDirectory.tsx index ffdf65799..e6eefade2 100644 --- a/src/components/permissions/rules/RemoveWorkspaceDirectory.tsx +++ b/src/components/permissions/rules/RemoveWorkspaceDirectory.tsx @@ -1,109 +1,68 @@ -import { c as _c } from "react/compiler-runtime"; -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 { applyPermissionUpdate } from '../../../utils/permissions/PermissionUpdate.js'; -import { Dialog } from '../../design-system/Dialog.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 { applyPermissionUpdate } from '../../../utils/permissions/PermissionUpdate.js' +import { Dialog } from '../../design-system/Dialog.js' + type Props = { - directoryPath: string; - onRemove: () => void; - onCancel: () => void; - permissionContext: ToolPermissionContext; - setPermissionContext: (context: ToolPermissionContext) => void; -}; -export function RemoveWorkspaceDirectory(t0) { - const $ = _c(19); - const { - directoryPath, - onRemove, - onCancel, - permissionContext, - setPermissionContext - } = t0; - let t1; - if ($[0] !== directoryPath || $[1] !== onRemove || $[2] !== permissionContext || $[3] !== setPermissionContext) { - t1 = () => { - const updatedContext = applyPermissionUpdate(permissionContext, { - type: "removeDirectories", - directories: [directoryPath], - destination: "session" - }); - setPermissionContext(updatedContext); - onRemove(); - }; - $[0] = directoryPath; - $[1] = onRemove; - $[2] = permissionContext; - $[3] = setPermissionContext; - $[4] = t1; - } else { - t1 = $[4]; - } - const handleRemove = t1; - let t2; - if ($[5] !== handleRemove || $[6] !== onCancel) { - t2 = value => { - if (value === "yes") { - handleRemove(); - } else { - onCancel(); - } - }; - $[5] = handleRemove; - $[6] = onCancel; - $[7] = t2; - } else { - t2 = $[7]; - } - const handleSelect = t2; - let t3; - if ($[8] !== directoryPath) { - t3 = {directoryPath}; - $[8] = directoryPath; - $[9] = t3; - } else { - t3 = $[9]; - } - let t4; - if ($[10] === Symbol.for("react.memo_cache_sentinel")) { - t4 = Claude Code will no longer have access to files in this directory.; - $[10] = t4; - } else { - t4 = $[10]; - } - let t5; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t5 = [{ - label: "Yes", - value: "yes" - }, { - label: "No", - value: "no" - }]; - $[11] = t5; - } else { - t5 = $[11]; - } - let t6; - if ($[12] !== handleSelect || $[13] !== onCancel) { - t6 = + + ) } diff --git a/src/components/permissions/rules/WorkspaceTab.tsx b/src/components/permissions/rules/WorkspaceTab.tsx index 8ed8a09c6..0dab0c7d0 100644 --- a/src/components/permissions/rules/WorkspaceTab.tsx +++ b/src/components/permissions/rules/WorkspaceTab.tsx @@ -1,149 +1,105 @@ -import { c as _c } from "react/compiler-runtime"; -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; -}; -export function WorkspaceTab(t0) { - const $ = _c(23); - const { - onExit, - toolPermissionContext, - onRequestAddDirectory, - onRequestRemoveDirectory, - onHeaderFocusChange - } = t0; - const { - headerFocused, - focusHeader - } = useTabHeaderFocus(); - let t1; - let t2; - if ($[0] !== headerFocused || $[1] !== onHeaderFocusChange) { - t1 = () => { - onHeaderFocusChange?.(headerFocused); - }; - t2 = [headerFocused, onHeaderFocusChange]; - $[0] = headerFocused; - $[1] = onHeaderFocusChange; - $[2] = t1; - $[3] = t2; - } else { - t1 = $[2]; - t2 = $[3]; - } - useEffect(t1, t2); - let t3; - if ($[4] !== toolPermissionContext.additionalWorkingDirectories) { - t3 = Array.from(toolPermissionContext.additionalWorkingDirectories.keys()).map(_temp); - $[4] = toolPermissionContext.additionalWorkingDirectories; - $[5] = t3; - } else { - t3 = $[5]; - } - const additionalDirectories = t3; - let t4; - if ($[6] !== additionalDirectories || $[7] !== onRequestAddDirectory || $[8] !== onRequestRemoveDirectory) { - t4 = selectedValue => { - if (selectedValue === "add-directory") { - onRequestAddDirectory(); - return; + path: string + isCurrent: boolean + isDeletable: boolean +} + +export function WorkspaceTab({ + onExit, + toolPermissionContext, + onRequestAddDirectory, + onRequestRemoveDirectory, + onHeaderFocusChange, +}: Props): React.ReactNode { + const { headerFocused, focusHeader } = useTabHeaderFocus() + useEffect(() => { + 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 => ({ + path, + isCurrent: false, + isDeletable: true, + })) + }, [toolPermissionContext.additionalWorkingDirectories]) + + const handleDirectorySelect = useCallback( + (selectedValue: string) => { + if (selectedValue === 'add-directory') { + 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) } - }; - $[6] = additionalDirectories; - $[7] = onRequestAddDirectory; - $[8] = onRequestRemoveDirectory; - $[9] = t4; - } else { - t4 = $[9]; - } - const handleDirectorySelect = t4; - let t5; - if ($[10] !== onExit) { - t5 = () => onExit("Workspace dialog dismissed", { - display: "system" - }); - $[10] = onExit; - $[11] = t5; - } else { - t5 = $[11]; - } - const handleCancel = t5; - let opts; - if ($[12] !== additionalDirectories) { - opts = additionalDirectories.map(_temp2); - let t6; - if ($[14] === Symbol.for("react.memo_cache_sentinel")) { - t6 = { - label: `Add directory${figures.ellipsis}`, - value: "add-directory" - }; - $[14] = t6; - } else { - t6 = $[14]; - } - opts.push(t6); - $[12] = additionalDirectories; - $[13] = opts; - } else { - opts = $[13]; - } - const options = opts; - let t6; - if ($[15] === Symbol.for("react.memo_cache_sentinel")) { - t6 = {`- ${getOriginalCwd()}`}(Original working directory); - $[15] = t6; - } else { - t6 = $[15]; - } - const t7 = Math.min(10, options.length); - let t8; - if ($[16] !== focusHeader || $[17] !== handleCancel || $[18] !== handleDirectorySelect || $[19] !== headerFocused || $[20] !== options || $[21] !== t7) { - t8 = {t6} + + ) } diff --git a/src/components/permissions/shellPermissionHelpers.tsx b/src/components/permissions/shellPermissionHelpers.tsx index e7b5ef621..2c7a2db95 100644 --- a/src/components/permissions/shellPermissionHelpers.tsx +++ b/src/components/permissions/shellPermissionHelpers.tsx @@ -1,59 +1,73 @@ -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 + return ( + {commands[0]} and {commands[1]} - ; + + ) default: - return + return ( + {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 + return ( + {names[0]} {sep} - ; + + ) } if (names.length === 2) { - return + return ( + {names[0]} {sep} and {names[1]} {sep} - ; + + ) } // For 3+, show first two with "and N more" - return + return ( + {names[0]} {sep}, {names[1]} {sep} and {paths.length - 2} more - ; + + ) } /** @@ -62,102 +76,138 @@ function formatPathList(paths: string[]): ReactNode { * and an optional command transform (e.g., Bash strips output redirections so * filenames don't show as commands). */ -export function generateShellSuggestionsLabel(suggestions: PermissionUpdate[], shellToolName: string, commandTransform?: (command: string) => string): ReactNode | null { +export function generateShellSuggestionsLabel( + suggestions: PermissionUpdate[], + shellToolName: string, + 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; - }))]; + const shellCommands = [ + ...new Set( + shellRules.flatMap(rule => { + 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; - return + const firstPath = readPaths[0]! + const dirName = basename(firstPath) || firstPath + return ( + Yes, allow reading from {dirName} {sep} from this project - ; + + ) } // Multiple read paths - return + 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; - return + const firstDir = directories[0]! + const dirName = basename(firstDir) || firstDir + return ( + Yes, and always allow access to {dirName} {sep} from this project - ; + + ) } // Multiple directories - return + return ( + Yes, and always allow access to {formatPathList(directories)} from this project - ; + + ) } + if (hasCommands && !hasDirectories && !hasReadPaths) { // Only shell command permissions - return + return ( + {"Yes, and don't ask again for "} {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 + 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 + return ( + Yes, and allow access to {formatPathList(allPaths)} and{' '} {commandListDisplayTruncated(shellCommands)} commands - ; + + ) } - return + + return ( + Yes, and allow {formatPathList(allPaths)} access and{' '} {commandListDisplayTruncated(shellCommands)} commands - ; + + ) } - return null; + + return null } diff --git a/src/components/sandbox/SandboxConfigTab.tsx b/src/components/sandbox/SandboxConfigTab.tsx index 50d77344b..58bfba688 100644 --- a/src/components/sandbox/SandboxConfigTab.tsx +++ b/src/components/sandbox/SandboxConfigTab.tsx @@ -1,44 +1,135 @@ -import { c as _c } from "react/compiler-runtime"; -import * as React from 'react'; -import { Box, Text } from '../../ink.js'; -import { SandboxManager, shouldAllowManagedSandboxDomainsOnly } from '../../utils/sandbox/sandbox-adapter.js'; -export function SandboxConfigTab() { - const $ = _c(3); - const isEnabled = SandboxManager.isSandboxingEnabled(); - let t0; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - const depCheck = SandboxManager.checkDependencies(); - t0 = depCheck.warnings.length > 0 ? {depCheck.warnings.map(_temp)} : null; - $[0] = t0; - } else { - t0 = $[0]; - } - const warningsNote = t0; +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() + + // Show warnings (e.g., seccomp not available on Linux) + const depCheck = SandboxManager.checkDependencies() + const warningsNote = + depCheck.warnings.length > 0 ? ( + + {depCheck.warnings.map((w, i) => ( + + {w} + + ))} + + ) : null + if (!isEnabled) { - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Sandbox is not enabled{warningsNote}; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; + return ( + + Sandbox is not enabled + {warningsNote} + + ) } - let t1; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - const fsReadConfig = SandboxManager.getFsReadConfig(); - const fsWriteConfig = SandboxManager.getFsWriteConfig(); - const networkConfig = SandboxManager.getNetworkRestrictionConfig(); - const allowUnixSockets = SandboxManager.getAllowUnixSockets(); - const excludedCommands = SandboxManager.getExcludedCommands(); - const globPatternWarnings = SandboxManager.getLinuxGlobPatternWarnings(); - t1 = Excluded Commands:{excludedCommands.length > 0 ? excludedCommands.join(", ") : "None"}{fsReadConfig.denyOnly.length > 0 && Filesystem Read Restrictions:Denied: {fsReadConfig.denyOnly.join(", ")}{fsReadConfig.allowWithinDeny && fsReadConfig.allowWithinDeny.length > 0 && Allowed within denied: {fsReadConfig.allowWithinDeny.join(", ")}}}{fsWriteConfig.allowOnly.length > 0 && Filesystem Write Restrictions:Allowed: {fsWriteConfig.allowOnly.join(", ")}{fsWriteConfig.denyWithinAllow.length > 0 && Denied within allowed: {fsWriteConfig.denyWithinAllow.join(", ")}}}{(networkConfig.allowedHosts && networkConfig.allowedHosts.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(", ")}}}{allowUnixSockets && allowUnixSockets.length > 0 && Allowed Unix Sockets:{allowUnixSockets.join(", ")}}{globPatternWarnings.length > 0 && ⚠ Warning: Glob patterns not fully supported on LinuxThe following patterns will be ignored:{" "}{globPatternWarnings.slice(0, 3).join(", ")}{globPatternWarnings.length > 3 && ` (${globPatternWarnings.length - 3} more)`}}{warningsNote}; - $[2] = t1; - } else { - t1 = $[2]; - } - return t1; -} -function _temp(w, i) { - return {w}; + + 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 ( + + {/* Excluded Commands */} + + + Excluded Commands: + + + {excludedCommands.length > 0 ? excludedCommands.join(', ') : 'None'} + + + + {/* Filesystem Read Restrictions */} + {fsReadConfig.denyOnly.length > 0 && ( + + + Filesystem Read Restrictions: + + Denied: {fsReadConfig.denyOnly.join(', ')} + {fsReadConfig.allowWithinDeny && + fsReadConfig.allowWithinDeny.length > 0 && ( + + Allowed within denied: {fsReadConfig.allowWithinDeny.join(', ')} + + )} + + )} + + {/* Filesystem Write Restrictions */} + {fsWriteConfig.allowOnly.length > 0 && ( + + + Filesystem Write Restrictions: + + Allowed: {fsWriteConfig.allowOnly.join(', ')} + {fsWriteConfig.denyWithinAllow.length > 0 && ( + + Denied within allowed: {fsWriteConfig.denyWithinAllow.join(', ')} + + )} + + )} + + {/* Network Restrictions */} + {((networkConfig.allowedHosts && networkConfig.allowedHosts.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(', ')} + + )} + + )} + + {/* Unix Sockets */} + {allowUnixSockets && allowUnixSockets.length > 0 && ( + + + Allowed Unix Sockets: + + {allowUnixSockets.join(', ')} + + )} + + {/* Linux Glob Pattern Warning */} + {globPatternWarnings.length > 0 && ( + + + ⚠ 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)`} + + + )} + + {warningsNote} + + ) } diff --git a/src/components/sandbox/SandboxDependenciesTab.tsx b/src/components/sandbox/SandboxDependenciesTab.tsx index 53cff39f4..75091910d 100644 --- a/src/components/sandbox/SandboxDependenciesTab.tsx +++ b/src/components/sandbox/SandboxDependenciesTab.tsx @@ -1,119 +1,124 @@ -import { c as _c } from "react/compiler-runtime"; -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; -}; -export function SandboxDependenciesTab(t0) { - const $ = _c(24); - const { - depCheck - } = t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = getPlatform(); - $[0] = t1; - } else { - t1 = $[0]; - } - const platform = t1; - const isMac = platform === "macos"; - let t2; - if ($[1] !== depCheck.errors) { - t2 = depCheck.errors.some(_temp); - $[1] = depCheck.errors; - $[2] = t2; - } else { - t2 = $[2]; - } - const rgMissing = t2; - let t3; - if ($[3] !== depCheck.errors) { - t3 = depCheck.errors.some(_temp2); - $[3] = depCheck.errors; - $[4] = t3; - } else { - t3 = $[4]; - } - const bwrapMissing = t3; - let t4; - if ($[5] !== depCheck.errors) { - t4 = depCheck.errors.some(_temp3); - $[5] = depCheck.errors; - $[6] = t4; - } else { - t4 = $[6]; - } - const socatMissing = t4; - const seccompMissing = depCheck.warnings.length > 0; - let t5; - if ($[7] !== bwrapMissing || $[8] !== depCheck.errors || $[9] !== rgMissing || $[10] !== seccompMissing || $[11] !== socatMissing) { - const otherErrors = depCheck.errors.filter(_temp4); - const rgInstallHint = isMac ? "brew install ripgrep" : "apt install ripgrep"; - let t6; - if ($[13] === Symbol.for("react.memo_cache_sentinel")) { - t6 = isMac && seatbelt: built-in (macOS); - $[13] = t6; - } else { - t6 = $[13]; - } - let t7; - let t8; - if ($[14] !== rgMissing) { - t7 = ripgrep (rg):{" "}{rgMissing ? not found : found}; - t8 = rgMissing && {" "}· {rgInstallHint}; - $[14] = rgMissing; - $[15] = t7; - $[16] = t8; - } else { - t7 = $[15]; - t8 = $[16]; - } - let t9; - if ($[17] !== t7 || $[18] !== t8) { - t9 = {t7}{t8}; - $[17] = t7; - $[18] = t8; - $[19] = t9; - } else { - t9 = $[19]; - } - let t10; - if ($[20] !== bwrapMissing || $[21] !== seccompMissing || $[22] !== socatMissing) { - t10 = !isMac && <>bubblewrap (bwrap):{" "}{bwrapMissing ? not installed : installed}{bwrapMissing && {" "}· apt install bubblewrap}socat:{" "}{socatMissing ? not installed : installed}{socatMissing && {" "}· apt install socat}seccomp filter:{" "}{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}; - $[20] = bwrapMissing; - $[21] = seccompMissing; - $[22] = socatMissing; - $[23] = t10; - } else { - t10 = $[23]; - } - t5 = {t6}{t9}{t10}{otherErrors.map(_temp5)}; - $[7] = bwrapMissing; - $[8] = depCheck.errors; - $[9] = rgMissing; - $[10] = seccompMissing; - $[11] = socatMissing; - $[12] = t5; - } else { - t5 = $[12]; - } - return t5; + depCheck: SandboxDependencyCheck } -function _temp5(err) { - return {err}; -} -function _temp4(e_2) { - return !e_2.includes("ripgrep") && !e_2.includes("bwrap") && !e_2.includes("socat"); -} -function _temp3(e_1) { - return e_1.includes("socat"); -} -function _temp2(e_0) { - return e_0.includes("bwrap"); -} -function _temp(e) { - return e.includes("ripgrep"); + +export function SandboxDependenciesTab({ depCheck }: Props): React.ReactNode { + 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. + // On Linux/WSL, bwrap + socat are required, seccomp is optional. + // + // #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 + + // 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' + + return ( + + {isMac && ( + + + seatbelt: built-in (macOS) + + + )} + + + + ripgrep (rg):{' '} + {rgMissing ? ( + not found + ) : ( + found + )} + + {rgMissing && ( + + {' '}· {rgInstallHint} + + )} + + + {!isMac && ( + <> + + + bubblewrap (bwrap):{' '} + {bwrapMissing ? ( + not installed + ) : ( + installed + )} + + {bwrapMissing && ( + {' '}· apt install bubblewrap + )} + + + + + socat:{' '} + {socatMissing ? ( + not installed + ) : ( + installed + )} + + {socatMissing && {' '}· apt install socat} + + + + + seccomp filter:{' '} + {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 + + + )} + + + )} + + {otherErrors.map(err => ( + + {err} + + ))} + + ) } diff --git a/src/components/sandbox/SandboxDoctorSection.tsx b/src/components/sandbox/SandboxDoctorSection.tsx index 747369108..5e7198c38 100644 --- a/src/components/sandbox/SandboxDoctorSection.tsx +++ b/src/components/sandbox/SandboxDoctorSection.tsx @@ -1,45 +1,48 @@ -import { c as _c } from "react/compiler-runtime"; -import React from 'react'; -import { Box, Text } from '../../ink.js'; -import { SandboxManager } from '../../utils/sandbox/sandbox-adapter.js'; -export function SandboxDoctorSection() { - const $ = _c(2); +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 } - let t0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Symbol.for("react.early_return_sentinel"); - bb0: { - const depCheck = SandboxManager.checkDependencies(); - const hasErrors = depCheck.errors.length > 0; - const hasWarnings = depCheck.warnings.length > 0; - if (!hasErrors && !hasWarnings) { - t1 = null; - break bb0; - } - const statusColor = hasErrors ? "error" as const : "warning" as const; - const statusText = hasErrors ? "Missing dependencies" : "Available (with warnings)"; - t0 = Sandbox└ Status: {statusText}{depCheck.errors.map(_temp)}{depCheck.warnings.map(_temp2)}{hasErrors && └ Run /sandbox for install instructions}; - } - $[0] = t0; - $[1] = t1; - } else { - t0 = $[0]; - t1 = $[1]; + + const depCheck = SandboxManager.checkDependencies() + const hasErrors = depCheck.errors.length > 0 + const hasWarnings = depCheck.warnings.length > 0 + + if (!hasErrors && !hasWarnings) { + return null } - if (t1 !== Symbol.for("react.early_return_sentinel")) { - return t1; - } - return t0; -} -function _temp2(w, i_0) { - return └ {w}; -} -function _temp(e, i) { - return └ {e}; + + const statusColor = hasErrors ? ('error' as const) : ('warning' as const) + const statusText = hasErrors + ? 'Missing dependencies' + : 'Available (with warnings)' + + return ( + + Sandbox + + └ Status: {statusText} + + {depCheck.errors.map((e, i) => ( + + └ {e} + + ))} + {depCheck.warnings.map((w, i) => ( + + └ {w} + + ))} + {hasErrors && ( + └ Run /sandbox for install instructions + )} + + ) } diff --git a/src/components/sandbox/SandboxOverridesTab.tsx b/src/components/sandbox/SandboxOverridesTab.tsx index c13eb0a8e..74c6d224b 100644 --- a/src/components/sandbox/SandboxOverridesTab.tsx +++ b/src/components/sandbox/SandboxOverridesTab.tsx @@ -1,192 +1,139 @@ -import { c as _c } from "react/compiler-runtime"; -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; -}; -type OverrideMode = 'open' | 'closed'; -export function SandboxOverridesTab(t0) { - const $ = _c(5); - const { - onComplete - } = t0; - const isEnabled = SandboxManager.isSandboxingEnabled(); - const isLocked = SandboxManager.areSandboxSettingsLockedByPolicy(); - const currentAllowUnsandboxed = SandboxManager.areUnsandboxedCommandsAllowed(); + onComplete: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void +} + +type OverrideMode = 'open' | 'closed' + +export function SandboxOverridesTab({ onComplete }: Props): React.ReactNode { + const isEnabled = SandboxManager.isSandboxingEnabled() + const isLocked = SandboxManager.areSandboxSettingsLockedByPolicy() + const currentAllowUnsandboxed = SandboxManager.areUnsandboxedCommandsAllowed() + if (!isEnabled) { - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Sandbox is not enabled. Enable sandbox to configure override settings.; - $[0] = t1; - } else { - t1 = $[0]; - } - return t1; + return ( + + + Sandbox is not enabled. Enable sandbox to configure override settings. + + + ) } + if (isLocked) { - let t1; - if ($[1] === Symbol.for("react.memo_cache_sentinel")) { - t1 = Override settings are managed by a higher-priority configuration and cannot be changed locally.; - $[1] = t1; - } else { - t1 = $[1]; - } - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = {t1}Current setting:{" "}{currentAllowUnsandboxed ? "Allow unsandboxed fallback" : "Strict sandbox mode"}; - $[2] = t2; - } else { - t2 = $[2]; - } - return t2; + return ( + + + Override settings are managed by a higher-priority configuration and + cannot be changed locally. + + + + Current setting:{' '} + {currentAllowUnsandboxed + ? 'Allow unsandboxed fallback' + : 'Strict sandbox mode'} + + + + ) } - let t1; - if ($[3] !== onComplete) { - t1 = ; - $[3] = onComplete; - $[4] = t1; - } else { - t1 = $[4]; - } - return t1; + + 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(t0) { - const $ = _c(25); - const { - onComplete, - currentMode - } = t0; - const [theme] = useTheme(); - const { - headerFocused, - focusHeader - } = useTabHeaderFocus(); - let t1; - if ($[0] !== theme) { - t1 = color("success", theme)("(current)"); - $[0] = theme; - $[1] = t1; - } else { - t1 = $[1]; +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', + value: 'open', + }, + { + label: + currentMode === 'closed' + ? `Strict sandbox mode ${currentIndicator}` + : 'Strict sandbox mode', + value: 'closed', + }, + ] + + async function handleSelect(value: string) { + 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' + + onComplete(message) } - const currentIndicator = t1; - const t2 = currentMode === "open" ? `Allow unsandboxed fallback ${currentIndicator}` : "Allow unsandboxed fallback"; - let t3; - if ($[2] !== t2) { - t3 = { - label: t2, - value: "open" - }; - $[2] = t2; - $[3] = t3; - } else { - t3 = $[3]; - } - const t4 = currentMode === "closed" ? `Strict sandbox mode ${currentIndicator}` : "Strict sandbox mode"; - let t5; - if ($[4] !== t4) { - t5 = { - label: t4, - value: "closed" - }; - $[4] = t4; - $[5] = t5; - } else { - t5 = $[5]; - } - let t6; - if ($[6] !== t3 || $[7] !== t5) { - t6 = [t3, t5]; - $[6] = t3; - $[7] = t5; - $[8] = t6; - } else { - t6 = $[8]; - } - const options = t6; - let t7; - if ($[9] !== onComplete) { - t7 = async function handleSelect(value) { - const mode = value as OverrideMode; - await SandboxManager.setSandboxSettings({ - allowUnsandboxedCommands: mode === "open" - }); - const message = mode === "open" ? "\u2713 Unsandboxed fallback allowed - commands can run outside sandbox when necessary" : "\u2713 Strict sandbox mode - all commands must run in sandbox or be excluded via the `excludedCommands` option"; - onComplete(message); - }; - $[9] = onComplete; - $[10] = t7; - } else { - t7 = $[10]; - } - const handleSelect = t7; - let t8; - if ($[11] === Symbol.for("react.memo_cache_sentinel")) { - t8 = Configure Overrides:; - $[11] = t8; - } else { - t8 = $[11]; - } - let t9; - if ($[12] !== onComplete) { - t9 = () => onComplete(undefined, { - display: "skip" - }); - $[12] = onComplete; - $[13] = t9; - } else { - t9 = $[13]; - } - let t10; - if ($[14] !== focusHeader || $[15] !== handleSelect || $[16] !== headerFocused || $[17] !== options || $[18] !== t9) { - t10 = onComplete(undefined, { display: 'skip' })} + onUpFromFirstItem={focusHeader} + isDisabled={headerFocused} + /> + + + + 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). + + + + Strict sandbox mode: + {' '} + All bash commands invoked by the model must run in the sandbox unless + they are explicitly listed in excludedCommands. + + + Learn more:{' '} + + code.claude.com/docs/en/sandboxing#configure-sandboxing + + + + + ) } diff --git a/src/components/sandbox/SandboxSettings.tsx b/src/components/sandbox/SandboxSettings.tsx index b8c403efb..05998577b 100644 --- a/src/components/sandbox/SandboxSettings.tsx +++ b/src/components/sandbox/SandboxSettings.tsx @@ -1,295 +1,211 @@ -import { c as _c } from "react/compiler-runtime"; -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(t0) { - const $ = _c(34); - const { - onComplete, - depCheck - } = t0; - const [theme] = useTheme(); - const currentEnabled = SandboxManager.isSandboxingEnabled(); - const currentAutoAllow = SandboxManager.isAutoAllowBashIfSandboxedEnabled(); - const hasWarnings = depCheck.warnings.length > 0; - let t1; - if ($[0] === Symbol.for("react.memo_cache_sentinel")) { - t1 = getSettings_DEPRECATED(); - $[0] = t1; - } else { - t1 = $[0]; - } - const settings = t1; - const allowAllUnixSockets = settings.sandbox?.network?.allowAllUnixSockets; - const showSocketWarning = hasWarnings && !allowAllUnixSockets; - const getCurrentMode = () => { - if (!currentEnabled) { - return "disabled"; - } - if (currentAutoAllow) { - return "auto-allow"; - } - return "regular"; - }; - const currentMode = getCurrentMode(); - let t2; - if ($[1] !== theme) { - t2 = color("success", theme)("(current)"); - $[1] = theme; - $[2] = t2; - } else { - t2 = $[2]; - } - const currentIndicator = t2; - const t3 = currentMode === "auto-allow" ? `Sandbox BashTool, with auto-allow ${currentIndicator}` : "Sandbox BashTool, with auto-allow"; - let t4; - if ($[3] !== t3) { - t4 = { - label: t3, - value: "auto-allow" - }; - $[3] = t3; - $[4] = t4; - } else { - t4 = $[4]; - } - const t5 = currentMode === "regular" ? `Sandbox BashTool, with regular permissions ${currentIndicator}` : "Sandbox BashTool, with regular permissions"; - let t6; - if ($[5] !== t5) { - t6 = { - label: t5, - value: "regular" - }; - $[5] = t5; - $[6] = t6; - } else { - t6 = $[6]; - } - const t7 = currentMode === "disabled" ? `No Sandbox ${currentIndicator}` : "No Sandbox"; - let t8; - if ($[7] !== t7) { - t8 = { - label: t7, - value: "disabled" - }; - $[7] = t7; - $[8] = t8; - } else { - t8 = $[8]; - } - let t9; - if ($[9] !== t4 || $[10] !== t6 || $[11] !== t8) { - t9 = [t4, t6, t8]; - $[9] = t4; - $[10] = t6; - $[11] = t8; - $[12] = t9; - } else { - t9 = $[12]; - } - const options = t9; - let t10; - if ($[13] !== onComplete) { - t10 = async function handleSelect(value) { - const mode = value as SandboxMode; - bb33: switch (mode) { - case "auto-allow": - { - await SandboxManager.setSandboxSettings({ - enabled: true, - autoAllowBashIfSandboxed: true - }); - onComplete("\u2713 Sandbox enabled with auto-allow for bash commands"); - break bb33; - } - case "regular": - { - await SandboxManager.setSandboxSettings({ - enabled: true, - autoAllowBashIfSandboxed: false - }); - onComplete("\u2713 Sandbox enabled with regular bash permissions"); - break bb33; - } - case "disabled": - { - await SandboxManager.setSandboxSettings({ - enabled: false, - autoAllowBashIfSandboxed: false - }); - onComplete("\u25CB Sandbox disabled"); - } - } - }; - $[13] = onComplete; - $[14] = t10; - } else { - t10 = $[14]; - } - const handleSelect = t10; - let t11; - if ($[15] !== onComplete) { - t11 = { - "confirm:no": () => onComplete(undefined, { - display: "skip" - }) - }; - $[15] = onComplete; - $[16] = t11; - } else { - t11 = $[16]; - } - let t12; - if ($[17] === Symbol.for("react.memo_cache_sentinel")) { - t12 = { - context: "Settings" - }; - $[17] = t12; - } else { - t12 = $[17]; - } - useKeybindings(t11, t12); - let t13; - if ($[18] !== handleSelect || $[19] !== onComplete || $[20] !== options || $[21] !== showSocketWarning) { - t13 = ; - $[18] = handleSelect; - $[19] = onComplete; - $[20] = options; - $[21] = showSocketWarning; - $[22] = t13; - } else { - t13 = $[22]; - } - const modeTab = t13; - let t14; - if ($[23] !== onComplete) { - t14 = ; - $[23] = onComplete; - $[24] = t14; - } else { - t14 = $[24]; - } - const overridesTab = t14; - let t15; - if ($[25] === Symbol.for("react.memo_cache_sentinel")) { - t15 = ; - $[25] = t15; - } else { - t15 = $[25]; - } - const configTab = t15; - const hasErrors = depCheck.errors.length > 0; - let t16; - if ($[26] !== depCheck || $[27] !== hasErrors || $[28] !== hasWarnings || $[29] !== modeTab || $[30] !== overridesTab) { - t16 = hasErrors ? [] : [modeTab, ...(hasWarnings ? [] : []), overridesTab, configTab]; - $[26] = depCheck; - $[27] = hasErrors; - $[28] = hasWarnings; - $[29] = modeTab; - $[30] = overridesTab; - $[31] = t16; - } else { - t16 = $[31]; - } - const tabs = t16; - let t17; - if ($[32] !== tabs) { - t17 = {tabs}; - $[32] = tabs; - $[33] = t17; - } else { - t17 = $[33]; - } - return t17; + onComplete: ( + result?: string, + options?: { display?: CommandResultDisplay }, + ) => void + depCheck: SandboxDependencyCheck } -function SandboxModeTab(t0) { - const $ = _c(16); - const { - showSocketWarning, - options, - onSelect, - onComplete - } = t0; - const { - headerFocused, - focusHeader - } = useTabHeaderFocus(); - let t1; - if ($[0] !== showSocketWarning) { - t1 = showSocketWarning && Cannot block unix domain sockets (see Dependencies tab); - $[0] = showSocketWarning; - $[1] = t1; - } else { - t1 = $[1]; + +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 + + // Determine current mode + const getCurrentMode = (): SandboxMode => { + if (!currentEnabled) return 'disabled' + if (currentAutoAllow) return 'auto-allow' + return 'regular' } - let t2; - if ($[2] === Symbol.for("react.memo_cache_sentinel")) { - t2 = Configure Mode:; - $[2] = t2; - } else { - t2 = $[2]; + + const currentMode = getCurrentMode() + const currentIndicator = color('success', theme)(`(current)`) + + const options = [ + { + label: + currentMode === 'auto-allow' + ? `Sandbox BashTool, with auto-allow ${currentIndicator}` + : 'Sandbox BashTool, with auto-allow', + value: 'auto-allow', + }, + { + label: + currentMode === 'regular' + ? `Sandbox BashTool, with regular permissions ${currentIndicator}` + : 'Sandbox BashTool, with regular permissions', + value: 'regular', + }, + { + label: + currentMode === 'disabled' + ? `No Sandbox ${currentIndicator}` + : 'No Sandbox', + value: 'disabled', + }, + ] + + async function handleSelect(value: string) { + 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 + case 'regular': + await SandboxManager.setSandboxSettings({ + enabled: true, + autoAllowBashIfSandboxed: false, + }) + onComplete('✓ Sandbox enabled with regular bash permissions') + break + case 'disabled': + await SandboxManager.setSandboxSettings({ + enabled: false, + autoAllowBashIfSandboxed: false, + }) + onComplete('○ Sandbox disabled') + break + } } - let t3; - if ($[3] !== onComplete) { - t3 = () => onComplete(undefined, { - display: "skip" - }); - $[3] = onComplete; - $[4] = t3; - } else { - t3 = $[4]; - } - let t4; - if ($[5] !== focusHeader || $[6] !== headerFocused || $[7] !== onSelect || $[8] !== options || $[9] !== t3) { - t4 = onComplete(undefined, { display: 'skip' })} + onUpFromFirstItem={focusHeader} + isDisabled={headerFocused} + /> + + + + 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. + + + Learn more:{' '} + + code.claude.com/docs/en/sandboxing + + + + + ) } diff --git a/src/components/shell/ExpandShellOutputContext.tsx b/src/components/shell/ExpandShellOutputContext.tsx index 271d9f313..cc6628b64 100644 --- a/src/components/shell/ExpandShellOutputContext.tsx +++ b/src/components/shell/ExpandShellOutputContext.tsx @@ -1,6 +1,5 @@ -import { c as _c } from "react/compiler-runtime"; -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). @@ -9,27 +8,24 @@ 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); -export function ExpandShellOutputProvider(t0) { - const $ = _c(2); - const { - children - } = t0; - let t1; - if ($[0] !== children) { - t1 = {children}; - $[0] = children; - $[1] = t1; - } else { - t1 = $[1]; - } - return t1; +const ExpandShellOutputContext = React.createContext(false) + +export function ExpandShellOutputProvider({ + children, +}: { + children: React.ReactNode +}): React.ReactNode { + return ( + + {children} + + ) } /** * Returns true if this component is rendered inside an ExpandShellOutputProvider, * indicating the shell output should be shown in full rather than truncated. */ -export function useExpandShellOutput() { - return useContext(ExpandShellOutputContext); +export function useExpandShellOutput(): boolean { + return useContext(ExpandShellOutputContext) } diff --git a/src/components/shell/OutputLine.tsx b/src/components/shell/OutputLine.tsx index 16832239d..cf72760db 100644 --- a/src/components/shell/OutputLine.tsx +++ b/src/components/shell/OutputLine.tsx @@ -1,106 +1,98 @@ -import { c as _c } from "react/compiler-runtime"; -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(t0) { - const $ = _c(11); - const { - content, - verbose, - isError, - isWarning, - linkifyUrls - } = t0; - const { - columns - } = useTerminalSize(); - const expandShellOutput = useExpandShellOutput(); - const inVirtualList = React.useContext(InVirtualListContext); - const shouldShowFull = verbose || expandShellOutput; - let t1; - if ($[0] !== columns || $[1] !== content || $[2] !== inVirtualList || $[3] !== linkifyUrls || $[4] !== shouldShowFull) { - bb0: { - let formatted = tryJsonFormatContent(content); - if (linkifyUrls) { - formatted = linkifyUrlsInText(formatted); - } - if (shouldShowFull) { - t1 = stripUnderlineAnsi(formatted); - break bb0; - } - t1 = stripUnderlineAnsi(renderTruncatedContent(formatted, columns, inVirtualList)); + +export function OutputLine({ + content, + verbose, + isError, + isWarning, + linkifyUrls, +}: { + content: string + verbose: boolean + isError?: boolean + isWarning?: boolean + linkifyUrls?: boolean +}): React.ReactNode { + const { columns } = useTerminalSize() + // Context-based expansion for latest user shell output (from ! commands) + 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 formattedContent = useMemo(() => { + let formatted = tryJsonFormatContent(content) + if (linkifyUrls) { + formatted = linkifyUrlsInText(formatted) } - $[0] = columns; - $[1] = content; - $[2] = inVirtualList; - $[3] = linkifyUrls; - $[4] = shouldShowFull; - $[5] = t1; - } else { - t1 = $[5]; - } - const formattedContent = t1; - const color = isError ? "error" : isWarning ? "warning" : undefined; - let t2; - if ($[6] !== formattedContent) { - t2 = {formattedContent}; - $[6] = formattedContent; - $[7] = t2; - } else { - t2 = $[7]; - } - let t3; - if ($[8] !== color || $[9] !== t2) { - t3 = {t2}; - $[8] = color; - $[9] = t2; - $[10] = t3; - } else { - t3 = $[10]; - } - return t3; + if (shouldShowFull) { + return stripUnderlineAnsi(formatted) + } + return stripUnderlineAnsi( + renderTruncatedContent(formatted, columns, inVirtualList), + ) + }, [content, shouldShowFull, columns, linkifyUrls, inVirtualList]) + + const color = isError ? 'error' : isWarning ? 'warning' : undefined + + return ( + + + {formattedContent} + + + ) } /** @@ -112,6 +104,8 @@ export function OutputLine(t0) { */ export function stripUnderlineAnsi(content: string): string { return content.replace( - // eslint-disable-next-line no-control-regex - /\u001b\[([0-9]+;)*4(;[0-9]+)*m|\u001b\[4(;[0-9]+)*m|\u001b\[([0-9]+;)*4m/g, ''); + // 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 45d07ff56..99da5ac3b 100644 --- a/src/components/shell/ShellProgressMessage.tsx +++ b/src/components/shell/ShellProgressMessage.tsx @@ -1,149 +1,87 @@ -import { c as _c } from "react/compiler-runtime"; -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; -}; -export function ShellProgressMessage(t0) { - const $ = _c(30); - const { - output, - fullOutput, - elapsedTimeSeconds, - totalLines, - totalBytes, - timeoutMs, - verbose - } = t0; - let t1; - if ($[0] !== fullOutput) { - t1 = stripAnsi(fullOutput.trim()); - $[0] = fullOutput; - $[1] = t1; - } else { - t1 = $[1]; - } - const strippedFullOutput = t1; - let lines; - let t2; - if ($[2] !== output || $[3] !== strippedFullOutput || $[4] !== verbose) { - const strippedOutput = stripAnsi(output.trim()); - lines = strippedOutput.split("\n").filter(_temp); - t2 = verbose ? strippedFullOutput : lines.slice(-5).join("\n"); - $[2] = output; - $[3] = strippedFullOutput; - $[4] = verbose; - $[5] = lines; - $[6] = t2; - } else { - lines = $[5]; - t2 = $[6]; - } - const displayLines = t2; + output: string + fullOutput: string + elapsedTimeSeconds?: number + totalLines?: number + totalBytes?: number + timeoutMs?: number + taskId?: string + verbose: boolean +} + +export function ShellProgressMessage({ + output, + fullOutput, + elapsedTimeSeconds, + totalLines, + totalBytes, + 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') + + // OffscreenFreeze: BashTool yields progress (elapsedTimeSeconds) every second. + // If this line scrolls into scrollback, each tick forces a full terminal reset. + // A foreground `sleep 600` on a 29-row terminal with 4000 rows of history + // produced 507 resets over 10 minutes (go/ccshare/maxk-20260226-190348). if (!lines.length) { - let t3; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t3 = Running… ; - $[7] = t3; - } else { - t3 = $[7]; - } - let t4; - if ($[8] !== elapsedTimeSeconds || $[9] !== timeoutMs) { - t4 = {t3}; - $[8] = elapsedTimeSeconds; - $[9] = timeoutMs; - $[10] = t4; - } else { - t4 = $[10]; - } - return t4; + return ( + + + Running… + + + + ) } - const extraLines = totalLines ? Math.max(0, totalLines - 5) : 0; - let lineStatus = ""; + + // 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 = '' if (!verbose && totalBytes && totalLines) { - lineStatus = `~${totalLines} lines`; - } else { - if (!verbose && extraLines > 0) { - lineStatus = `+${extraLines} lines`; - } + lineStatus = `~${totalLines} lines` + } else if (!verbose && extraLines > 0) { + lineStatus = `+${extraLines} lines` } - const t3 = verbose ? undefined : Math.min(5, lines.length); - let t4; - if ($[11] !== displayLines) { - t4 = {displayLines}; - $[11] = displayLines; - $[12] = t4; - } else { - t4 = $[12]; - } - let t5; - if ($[13] !== t3 || $[14] !== t4) { - t5 = {t4}; - $[13] = t3; - $[14] = t4; - $[15] = t5; - } else { - t5 = $[15]; - } - let t6; - if ($[16] !== lineStatus) { - t6 = lineStatus ? {lineStatus} : null; - $[16] = lineStatus; - $[17] = t6; - } else { - t6 = $[17]; - } - let t7; - if ($[18] !== elapsedTimeSeconds || $[19] !== timeoutMs) { - t7 = ; - $[18] = elapsedTimeSeconds; - $[19] = timeoutMs; - $[20] = t7; - } else { - t7 = $[20]; - } - let t8; - if ($[21] !== totalBytes) { - t8 = totalBytes ? {formatFileSize(totalBytes)} : null; - $[21] = totalBytes; - $[22] = t8; - } else { - t8 = $[22]; - } - let t9; - if ($[23] !== t6 || $[24] !== t7 || $[25] !== t8) { - t9 = {t6}{t7}{t8}; - $[23] = t6; - $[24] = t7; - $[25] = t8; - $[26] = t9; - } else { - t9 = $[26]; - } - let t10; - if ($[27] !== t5 || $[28] !== t9) { - t10 = {t5}{t9}; - $[27] = t5; - $[28] = t9; - $[29] = t10; - } else { - t10 = $[29]; - } - return t10; -} -function _temp(line) { - return line; + + return ( + + + + + {displayLines} + + + {lineStatus ? {lineStatus} : null} + + {totalBytes ? ( + {formatFileSize(totalBytes)} + ) : null} + + + + + ) } diff --git a/src/components/shell/ShellTimeDisplay.tsx b/src/components/shell/ShellTimeDisplay.tsx index 6830a3af7..7e619dfba 100644 --- a/src/components/shell/ShellTimeDisplay.tsx +++ b/src/components/shell/ShellTimeDisplay.tsx @@ -1,73 +1,28 @@ -import { c as _c } from "react/compiler-runtime"; -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; -}; -export function ShellTimeDisplay(t0) { - const $ = _c(10); - const { - elapsedTimeSeconds, - timeoutMs - } = t0; - if (elapsedTimeSeconds === undefined && !timeoutMs) { - return null; - } - let t1; - if ($[0] !== timeoutMs) { - t1 = timeoutMs ? formatDuration(timeoutMs, { - hideTrailingZeros: true - }) : undefined; - $[0] = timeoutMs; - $[1] = t1; - } else { - t1 = $[1]; - } - const timeout = t1; - if (elapsedTimeSeconds === undefined) { - const t2 = `(timeout ${timeout})`; - let t3; - if ($[2] !== t2) { - t3 = {t2}; - $[2] = t2; - $[3] = t3; - } else { - t3 = $[3]; - } - return t3; - } - const t2 = elapsedTimeSeconds * 1000; - let t3; - if ($[4] !== t2) { - t3 = formatDuration(t2); - $[4] = t2; - $[5] = t3; - } else { - t3 = $[5]; - } - const elapsed = t3; - if (timeout) { - const t4 = `(${elapsed} · timeout ${timeout})`; - let t5; - if ($[6] !== t4) { - t5 = {t4}; - $[6] = t4; - $[7] = t5; - } else { - t5 = $[7]; - } - return t5; - } - const t4 = `(${elapsed})`; - let t5; - if ($[8] !== t4) { - t5 = {t4}; - $[8] = t4; - $[9] = t5; - } else { - t5 = $[9]; - } - return t5; + elapsedTimeSeconds?: number + timeoutMs?: number +} + +export function ShellTimeDisplay({ + elapsedTimeSeconds, + timeoutMs, +}: Props): React.ReactNode { + if (elapsedTimeSeconds === undefined && !timeoutMs) { + return null + } + const timeout = timeoutMs + ? formatDuration(timeoutMs, { hideTrailingZeros: true }) + : undefined + if (elapsedTimeSeconds === undefined) { + return {`(timeout ${timeout})`} + } + const elapsed = formatDuration(elapsedTimeSeconds * 1000) + if (timeout) { + return {`(${elapsed} · timeout ${timeout})`} + } + return {`(${elapsed})`} }