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 }); const { yesInputMode, noInputMode, yesFeedbackModeEntered, noFeedbackModeEntered, acceptFeedback, rejectFeedback, setAcceptFeedback, setRejectFeedback, focusedOption, handleInputModeToggle, handleReject, 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); // Editable prefix — compute static prefix locally (no LLM call). // Initialize synchronously to the raw command for single-line commands so // the editable input renders immediately, then refine to the extracted prefix // once the AST parser resolves. Multiline commands (`# comment\n...`, // foreach loops) get undefined → powershellToolUseOptions:64 hides the // "don't ask again" option — those literals are one-time-use (settings // 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); useEffect(() => { 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(() => {}); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [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]); // Toggle permission debug info with keybinding const handleToggleDebug = useCallback(() => { setShowPermissionDebug(prev => !prev); }, []); useKeybinding('permission:toggleDebug', handleToggleDebug, { context: 'Confirmation' }); function onSelect(value: string) { // Map options to numeric values for analytics (strings not allowed in logEvent) const optionIndex: Record = { yes: 1, 'yes-apply-suggestions': 2, 'yes-prefix-edited': 2, no: 3 }; logEvent('tengu_permission_request_option_selected', { option_index: optionIndex[value], explainer_visible: explainerState.visible }); const toolNameForAnalytics = sanitizeToolNameForAnalytics(toolUseConfirm.tool.name) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS; if (value === 'yes-prefix-edited') { const trimmedPrefix = (editablePrefix ?? '').trim(); logUnaryPermissionEvent('tool_use_single', toolUseConfirm, 'accept'); if (!trimmedPrefix) { 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); } 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(); // 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; } } } return {PowerShellTool.renderToolUseMessage({ command, description }, { theme, verbose: true } // always show the full command )} {!explainerState.visible && {toolUseConfirm.description}} {showPermissionDebug ? <> {toolUseContext.options.debug && Ctrl-D to hide debug info } : <> {destructiveWarning && {destructiveWarning} } Do you want to proceed?