/ hooks / usePromptSuggestion.ts
usePromptSuggestion.ts
  1  import { useCallback, useRef } from 'react'
  2  import { useTerminalFocus } from '../ink/hooks/use-terminal-focus.js'
  3  import {
  4    type AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
  5    logEvent,
  6  } from '../services/analytics/index.js'
  7  import { abortSpeculation } from '../services/PromptSuggestion/speculation.js'
  8  import { useAppState, useSetAppState } from '../state/AppState.js'
  9  
 10  type Props = {
 11    inputValue: string
 12    isAssistantResponding: boolean
 13  }
 14  
 15  export function usePromptSuggestion({
 16    inputValue,
 17    isAssistantResponding,
 18  }: Props): {
 19    suggestion: string | null
 20    markAccepted: () => void
 21    markShown: () => void
 22    logOutcomeAtSubmission: (
 23      finalInput: string,
 24      opts?: { skipReset: boolean },
 25    ) => void
 26  } {
 27    const promptSuggestion = useAppState(s => s.promptSuggestion)
 28    const setAppState = useSetAppState()
 29    const isTerminalFocused = useTerminalFocus()
 30    const {
 31      text: suggestionText,
 32      promptId,
 33      shownAt,
 34      acceptedAt,
 35      generationRequestId,
 36    } = promptSuggestion
 37  
 38    const suggestion =
 39      isAssistantResponding || inputValue.length > 0 ? null : suggestionText
 40  
 41    const isValidSuggestion = suggestionText && shownAt > 0
 42  
 43    // Track engagement depth for telemetry
 44    const firstKeystrokeAt = useRef<number>(0)
 45    const wasFocusedWhenShown = useRef<boolean>(true)
 46    const prevShownAt = useRef<number>(0)
 47  
 48    // Capture focus state when a new suggestion appears (shownAt changes)
 49    if (shownAt > 0 && shownAt !== prevShownAt.current) {
 50      prevShownAt.current = shownAt
 51      wasFocusedWhenShown.current = isTerminalFocused
 52      firstKeystrokeAt.current = 0
 53    } else if (shownAt === 0) {
 54      prevShownAt.current = 0
 55    }
 56  
 57    // Record first keystroke while suggestion is visible
 58    if (
 59      inputValue.length > 0 &&
 60      firstKeystrokeAt.current === 0 &&
 61      isValidSuggestion
 62    ) {
 63      firstKeystrokeAt.current = Date.now()
 64    }
 65  
 66    const resetSuggestion = useCallback(() => {
 67      abortSpeculation(setAppState)
 68  
 69      setAppState(prev => ({
 70        ...prev,
 71        promptSuggestion: {
 72          text: null,
 73          promptId: null,
 74          shownAt: 0,
 75          acceptedAt: 0,
 76          generationRequestId: null,
 77        },
 78      }))
 79    }, [setAppState])
 80  
 81    const markAccepted = useCallback(() => {
 82      if (!isValidSuggestion) return
 83      setAppState(prev => ({
 84        ...prev,
 85        promptSuggestion: {
 86          ...prev.promptSuggestion,
 87          acceptedAt: Date.now(),
 88        },
 89      }))
 90    }, [isValidSuggestion, setAppState])
 91  
 92    const markShown = useCallback(() => {
 93      // Check shownAt inside setAppState callback to avoid depending on it
 94      // (depending on shownAt causes infinite loop when this callback is called)
 95      setAppState(prev => {
 96        // Only mark shown if not already shown and suggestion exists
 97        if (prev.promptSuggestion.shownAt !== 0 || !prev.promptSuggestion.text) {
 98          return prev
 99        }
100        return {
101          ...prev,
102          promptSuggestion: {
103            ...prev.promptSuggestion,
104            shownAt: Date.now(),
105          },
106        }
107      })
108    }, [setAppState])
109  
110    const logOutcomeAtSubmission = useCallback(
111      (finalInput: string, opts?: { skipReset: boolean }) => {
112        if (!isValidSuggestion) return
113  
114        // Determine if accepted: either Tab was pressed (acceptedAt set) OR
115        // final input matches suggestion (empty Enter case)
116        const tabWasPressed = acceptedAt > shownAt
117        const wasAccepted = tabWasPressed || finalInput === suggestionText
118        const timeMs = wasAccepted ? acceptedAt || Date.now() : Date.now()
119  
120        logEvent('tengu_prompt_suggestion', {
121          source:
122            'cli' as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
123          outcome: (wasAccepted
124            ? 'accepted'
125            : 'ignored') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
126          prompt_id:
127            promptId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
128          ...(generationRequestId && {
129            generationRequestId:
130              generationRequestId as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
131          }),
132          ...(wasAccepted && {
133            acceptMethod: (tabWasPressed
134              ? 'tab'
135              : 'enter') as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
136          }),
137          ...(wasAccepted && {
138            timeToAcceptMs: timeMs - shownAt,
139          }),
140          ...(!wasAccepted && {
141            timeToIgnoreMs: timeMs - shownAt,
142          }),
143          ...(firstKeystrokeAt.current > 0 && {
144            timeToFirstKeystrokeMs: firstKeystrokeAt.current - shownAt,
145          }),
146          wasFocusedWhenShown: wasFocusedWhenShown.current,
147          similarity:
148            Math.round(
149              (finalInput.length / (suggestionText?.length || 1)) * 100,
150            ) / 100,
151          ...(process.env.USER_TYPE === 'ant' && {
152            suggestion:
153              suggestionText as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
154            userInput:
155              finalInput as AnalyticsMetadata_I_VERIFIED_THIS_IS_NOT_CODE_OR_FILEPATHS,
156          }),
157        })
158        if (!opts?.skipReset) resetSuggestion()
159      },
160      [
161        isValidSuggestion,
162        acceptedAt,
163        shownAt,
164        suggestionText,
165        promptId,
166        generationRequestId,
167        resetSuggestion,
168      ],
169    )
170  
171    return {
172      suggestion,
173      markAccepted,
174      markShown,
175      logOutcomeAtSubmission,
176    }
177  }