/ components / permissions / FilePermissionDialog / useFilePermissionDialog.ts
useFilePermissionDialog.ts
  1  import { useCallback, useMemo, useState } from 'react'
  2  import { useAppState } from 'src/state/AppState.js'
  3  import { useKeybindings } from '../../../keybindings/useKeybinding.js'
  4  import {
  5    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  6    logEvent,
  7  } from '../../../services/analytics/index.js'
  8  import { sanitizeToolNameForAnalytics } from '../../../services/analytics/metadata.js'
  9  import type { PermissionUpdate } from '../../../utils/permissions/PermissionUpdateSchema.js'
 10  import type { CompletionType } from '../../../utils/unaryLogging.js'
 11  import type { ToolUseConfirm } from '../PermissionRequest.js'
 12  import {
 13    type FileOperationType,
 14    getFilePermissionOptions,
 15    type PermissionOption,
 16    type PermissionOptionWithLabel,
 17  } from './permissionOptions.js'
 18  import {
 19    PERMISSION_HANDLERS,
 20    type PermissionHandlerParams,
 21  } from './usePermissionHandler.js'
 22  
 23  export interface ToolInput {
 24    [key: string]: unknown
 25  }
 26  
 27  export type UseFilePermissionDialogProps<T extends ToolInput> = {
 28    filePath: string
 29    completionType: CompletionType
 30    languageName: string | Promise<string>
 31    toolUseConfirm: ToolUseConfirm
 32    onDone: () => void
 33    onReject: () => void
 34    parseInput: (input: unknown) => T
 35    operationType?: FileOperationType
 36  }
 37  
 38  export type UseFilePermissionDialogResult<T> = {
 39    options: PermissionOptionWithLabel[]
 40    onChange: (option: PermissionOption, input: T, feedback?: string) => void
 41    acceptFeedback: string
 42    rejectFeedback: string
 43    focusedOption: string
 44    setFocusedOption: (option: string) => void
 45    handleInputModeToggle: (value: string) => void
 46    yesInputMode: boolean
 47    noInputMode: boolean
 48  }
 49  
 50  /**
 51   * Hook for handling file permission dialogs with common logic
 52   */
 53  export function useFilePermissionDialog<T extends ToolInput>({
 54    filePath,
 55    completionType,
 56    languageName,
 57    toolUseConfirm,
 58    onDone,
 59    onReject,
 60    parseInput,
 61    operationType = 'write',
 62  }: UseFilePermissionDialogProps<T>): UseFilePermissionDialogResult<T> {
 63    const toolPermissionContext = useAppState(s => s.toolPermissionContext)
 64    const [acceptFeedback, setAcceptFeedback] = useState('')
 65    const [rejectFeedback, setRejectFeedback] = useState('')
 66    const [focusedOption, setFocusedOption] = useState('yes')
 67    const [yesInputMode, setYesInputMode] = useState(false)
 68    const [noInputMode, setNoInputMode] = useState(false)
 69    // Track whether user ever entered feedback mode (persists after collapse)
 70    const [yesFeedbackModeEntered, setYesFeedbackModeEntered] = useState(false)
 71    const [noFeedbackModeEntered, setNoFeedbackModeEntered] = useState(false)
 72  
 73    // Generate options based on context
 74    const options = useMemo(
 75      () =>
 76        getFilePermissionOptions({
 77          filePath,
 78          toolPermissionContext,
 79          operationType,
 80          onRejectFeedbackChange: setRejectFeedback,
 81          onAcceptFeedbackChange: setAcceptFeedback,
 82          yesInputMode,
 83          noInputMode,
 84        }),
 85      [filePath, toolPermissionContext, operationType, yesInputMode, noInputMode],
 86    )
 87  
 88    // Handle option selection using shared handlers
 89    const onChange = useCallback(
 90      (option: PermissionOption, input: T, feedback?: string) => {
 91        const params: PermissionHandlerParams = {
 92          messageId: toolUseConfirm.assistantMessage.message.id,
 93          path: filePath,
 94          toolUseConfirm,
 95          toolPermissionContext,
 96          onDone,
 97          onReject,
 98          completionType,
 99          languageName,
100          operationType,
101        }
102  
103        // Override the input in toolUseConfirm to pass the parsed input
104        const originalOnAllow = toolUseConfirm.onAllow
105        toolUseConfirm.onAllow = (
106          _input: unknown,
107          permissionUpdates: PermissionUpdate[],
108          feedback?: string,
109        ) => {
110          originalOnAllow(input, permissionUpdates, feedback)
111        }
112  
113        const handler = PERMISSION_HANDLERS[option.type]
114        handler(params, {
115          feedback,
116          hasFeedback: !!feedback,
117          enteredFeedbackMode:
118            option.type === 'accept-once'
119              ? yesFeedbackModeEntered
120              : noFeedbackModeEntered,
121          scope: option.type === 'accept-session' ? option.scope : undefined,
122        })
123      },
124      [
125        filePath,
126        completionType,
127        languageName,
128        toolUseConfirm,
129        toolPermissionContext,
130        onDone,
131        onReject,
132        operationType,
133        yesFeedbackModeEntered,
134        noFeedbackModeEntered,
135      ],
136    )
137  
138    // Handler for confirm:cycleMode - select accept-session option
139    const handleCycleMode = useCallback(() => {
140      const sessionOption = options.find(o => o.option.type === 'accept-session')
141      if (sessionOption) {
142        const parsedInput = parseInput(toolUseConfirm.input)
143        onChange(sessionOption.option, parsedInput)
144      }
145    }, [options, parseInput, toolUseConfirm.input, onChange])
146  
147    // Register keyboard shortcut handler via keybindings system
148    useKeybindings(
149      { 'confirm:cycleMode': handleCycleMode },
150      { context: 'Confirmation' },
151    )
152  
153    // Wrap setFocusedOption and reset input mode when navigating away
154    const handleFocusedOptionChange = useCallback(
155      (value: string) => {
156        // Reset input mode when navigating away, but only if no text typed
157        if (value !== 'yes' && yesInputMode && !acceptFeedback.trim()) {
158          setYesInputMode(false)
159        }
160        if (value !== 'no' && noInputMode && !rejectFeedback.trim()) {
161          setNoInputMode(false)
162        }
163        setFocusedOption(value)
164      },
165      [yesInputMode, noInputMode, acceptFeedback, rejectFeedback],
166    )
167  
168    // Handle Tab key toggling input mode for Yes/No options
169    const handleInputModeToggle = useCallback(
170      (value: string) => {
171        const analyticsProps = {
172          toolName: sanitizeToolNameForAnalytics(
173            toolUseConfirm.tool.name,
174          ) as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
175          isMcp: toolUseConfirm.tool.isMcp ?? false,
176        }
177  
178        if (value === 'yes') {
179          if (yesInputMode) {
180            setYesInputMode(false)
181            logEvent('tengu_accept_feedback_mode_collapsed', analyticsProps)
182          } else {
183            setYesInputMode(true)
184            setYesFeedbackModeEntered(true)
185            logEvent('tengu_accept_feedback_mode_entered', analyticsProps)
186          }
187        } else if (value === 'no') {
188          if (noInputMode) {
189            setNoInputMode(false)
190            logEvent('tengu_reject_feedback_mode_collapsed', analyticsProps)
191          } else {
192            setNoInputMode(true)
193            setNoFeedbackModeEntered(true)
194            logEvent('tengu_reject_feedback_mode_entered', analyticsProps)
195          }
196        }
197      },
198      [yesInputMode, noInputMode, toolUseConfirm],
199    )
200  
201    return {
202      options,
203      onChange,
204      acceptFeedback,
205      rejectFeedback,
206      focusedOption,
207      setFocusedOption: handleFocusedOptionChange,
208      handleInputModeToggle,
209      yesInputMode,
210      noInputMode,
211    }
212  }