/ components / permissions / useShellPermissionFeedback.ts
useShellPermissionFeedback.ts
  1  import { useState } from 'react'
  2  import {
  3    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  4    logEvent,
  5  } from '../../services/analytics/index.js'
  6  import { sanitizeToolNameForAnalytics } from '../../services/analytics/metadata.js'
  7  import { useSetAppState } from '../../state/AppState.js'
  8  import type { ToolUseConfirm } from './PermissionRequest.js'
  9  import { logUnaryPermissionEvent } from './utils.js'
 10  
 11  /**
 12   * Shared feedback-mode state + handlers for shell permission dialogs (Bash,
 13   * PowerShell). Encapsulates the yes/no input-mode toggle, feedback text state,
 14   * focus tracking, and reject handling.
 15   */
 16  export function useShellPermissionFeedback({
 17    toolUseConfirm,
 18    onDone,
 19    onReject,
 20    explainerVisible,
 21  }: {
 22    toolUseConfirm: ToolUseConfirm
 23    onDone: () => void
 24    onReject: () => void
 25    explainerVisible: boolean
 26  }): {
 27    yesInputMode: boolean
 28    noInputMode: boolean
 29    yesFeedbackModeEntered: boolean
 30    noFeedbackModeEntered: boolean
 31    acceptFeedback: string
 32    rejectFeedback: string
 33    setAcceptFeedback: (v: string) => void
 34    setRejectFeedback: (v: string) => void
 35    focusedOption: string
 36    handleInputModeToggle: (option: string) => void
 37    handleReject: (feedback?: string) => void
 38    handleFocus: (value: string) => void
 39  } {
 40    const setAppState = useSetAppState()
 41    const [rejectFeedback, setRejectFeedback] = useState('')
 42    const [acceptFeedback, setAcceptFeedback] = useState('')
 43    const [yesInputMode, setYesInputMode] = useState(false)
 44    const [noInputMode, setNoInputMode] = useState(false)
 45    const [focusedOption, setFocusedOption] = useState('yes')
 46    // Track whether user ever entered feedback mode (persists after collapse)
 47    const [yesFeedbackModeEntered, setYesFeedbackModeEntered] = useState(false)
 48    const [noFeedbackModeEntered, setNoFeedbackModeEntered] = useState(false)
 49  
 50    // Handle Tab key toggling input mode for Yes/No options
 51    function handleInputModeToggle(option: string) {
 52      // Notify that user is interacting with the dialog
 53      toolUseConfirm.onUserInteraction()
 54      const analyticsProps = {
 55        toolName: sanitizeToolNameForAnalytics(
 56          toolUseConfirm.tool.name,
 57        ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
 58        isMcp: toolUseConfirm.tool.isMcp ?? false,
 59      }
 60  
 61      if (option === 'yes') {
 62        if (yesInputMode) {
 63          setYesInputMode(false)
 64          logEvent('tengu_accept_feedback_mode_collapsed', analyticsProps)
 65        } else {
 66          setYesInputMode(true)
 67          setYesFeedbackModeEntered(true)
 68          logEvent('tengu_accept_feedback_mode_entered', analyticsProps)
 69        }
 70      } else if (option === 'no') {
 71        if (noInputMode) {
 72          setNoInputMode(false)
 73          logEvent('tengu_reject_feedback_mode_collapsed', analyticsProps)
 74        } else {
 75          setNoInputMode(true)
 76          setNoFeedbackModeEntered(true)
 77          logEvent('tengu_reject_feedback_mode_entered', analyticsProps)
 78        }
 79      }
 80    }
 81  
 82    function handleReject(feedback?: string) {
 83      const trimmedFeedback = feedback?.trim()
 84      const hasFeedback = !!trimmedFeedback
 85  
 86      // Log escape if no feedback was provided (user pressed ESC)
 87      if (!hasFeedback) {
 88        logEvent('tengu_permission_request_escape', {
 89          explainer_visible: explainerVisible,
 90        })
 91        // Increment escape count for attribution tracking
 92        setAppState(prev => ({
 93          ...prev,
 94          attribution: {
 95            ...prev.attribution,
 96            escapeCount: prev.attribution.escapeCount + 1,
 97          },
 98        }))
 99      }
100  
101      logUnaryPermissionEvent(
102        'tool_use_single',
103        toolUseConfirm,
104        'reject',
105        hasFeedback,
106      )
107  
108      if (trimmedFeedback) {
109        toolUseConfirm.onReject(trimmedFeedback)
110      } else {
111        toolUseConfirm.onReject()
112      }
113  
114      onReject()
115      onDone()
116    }
117  
118    function handleFocus(value: string) {
119      // Notify that user is interacting with the dialog (only if focus changed)
120      // This prevents triggering on the initial mount/render
121      if (value !== focusedOption) {
122        toolUseConfirm.onUserInteraction()
123      }
124      // Reset input mode when navigating away, but only if no text typed
125      if (value !== 'yes' && yesInputMode && !acceptFeedback.trim()) {
126        setYesInputMode(false)
127      }
128      if (value !== 'no' && noInputMode && !rejectFeedback.trim()) {
129        setNoInputMode(false)
130      }
131      setFocusedOption(value)
132    }
133  
134    return {
135      yesInputMode,
136      noInputMode,
137      yesFeedbackModeEntered,
138      noFeedbackModeEntered,
139      acceptFeedback,
140      rejectFeedback,
141      setAcceptFeedback,
142      setRejectFeedback,
143      focusedOption,
144      handleInputModeToggle,
145      handleReject,
146      handleFocus,
147    }
148  }