/ hooks / useIssueFlagBanner.ts
useIssueFlagBanner.ts
  1  import { useMemo, useRef } from 'react'
  2  import { BASH_TOOL_NAME } from '../tools/BashTool/toolName.js'
  3  import type { Message } from '../types/message.js'
  4  import { getUserMessageText } from '../utils/messages.js'
  5  
  6  const EXTERNAL_COMMAND_PATTERNS = [
  7    /\bcurl\b/,
  8    /\bwget\b/,
  9    /\bssh\b/,
 10    /\bkubectl\b/,
 11    /\bsrun\b/,
 12    /\bdocker\b/,
 13    /\bbq\b/,
 14    /\bgsutil\b/,
 15    /\bgcloud\b/,
 16    /\baws\b/,
 17    /\bgit\s+push\b/,
 18    /\bgit\s+pull\b/,
 19    /\bgit\s+fetch\b/,
 20    /\bgh\s+(pr|issue)\b/,
 21    /\bnc\b/,
 22    /\bncat\b/,
 23    /\btelnet\b/,
 24    /\bftp\b/,
 25  ]
 26  
 27  const FRICTION_PATTERNS = [
 28    // "No," or "No!" at start — comma/exclamation implies correction tone
 29    // (avoids "No problem", "No thanks", "No I think we should...")
 30    /^no[,!]\s/i,
 31    // Direct corrections about Claude's output
 32    /\bthat'?s (wrong|incorrect|not (what|right|correct))\b/i,
 33    /\bnot what I (asked|wanted|meant|said)\b/i,
 34    // Referencing prior instructions Claude missed
 35    /\bI (said|asked|wanted|told you|already said)\b/i,
 36    // Questioning Claude's actions
 37    /\bwhy did you\b/i,
 38    /\byou should(n'?t| not)? have\b/i,
 39    /\byou were supposed to\b/i,
 40    // Explicit retry/revert of Claude's work
 41    /\btry again\b/i,
 42    /\b(undo|revert) (that|this|it|what you)\b/i,
 43  ]
 44  
 45  export function isSessionContainerCompatible(messages: Message[]): boolean {
 46    for (const msg of messages) {
 47      if (msg.type !== 'assistant') {
 48        continue
 49      }
 50      const content = msg.message.content
 51      if (!Array.isArray(content)) {
 52        continue
 53      }
 54      for (const block of content) {
 55        if (block.type !== 'tool_use' || !('name' in block)) {
 56          continue
 57        }
 58        const toolName = block.name as string
 59        if (toolName.startsWith('mcp__')) {
 60          return false
 61        }
 62        if (toolName === BASH_TOOL_NAME) {
 63          const input = (block as { input?: Record<string, unknown> }).input
 64          const command = (input?.command as string) || ''
 65          if (EXTERNAL_COMMAND_PATTERNS.some(p => p.test(command))) {
 66            return false
 67          }
 68        }
 69      }
 70    }
 71    return true
 72  }
 73  
 74  export function hasFrictionSignal(messages: Message[]): boolean {
 75    for (let i = messages.length - 1; i >= 0; i--) {
 76      const msg = messages[i]!
 77      if (msg.type !== 'user') {
 78        continue
 79      }
 80      const text = getUserMessageText(msg)
 81      if (!text) {
 82        continue
 83      }
 84      return FRICTION_PATTERNS.some(p => p.test(text))
 85    }
 86    return false
 87  }
 88  
 89  const MIN_SUBMIT_COUNT = 3
 90  const COOLDOWN_MS = 30 * 60 * 1000
 91  
 92  export function useIssueFlagBanner(
 93    messages: Message[],
 94    submitCount: number,
 95  ): boolean {
 96    if (process.env.USER_TYPE !== 'ant') {
 97      return false
 98    }
 99  
100    // biome-ignore lint/correctness/useHookAtTopLevel: process.env.USER_TYPE is a compile-time constant
101    const lastTriggeredAtRef = useRef(0)
102    // biome-ignore lint/correctness/useHookAtTopLevel: process.env.USER_TYPE is a compile-time constant
103    const activeForSubmitRef = useRef(-1)
104  
105    // Memoize the O(messages) scans. This hook runs on every REPL render
106    // (including every keystroke), but messages is stable during typing.
107    // isSessionContainerCompatible walks all messages + regex-tests each
108    // bash command — by far the heaviest work here.
109    // biome-ignore lint/correctness/useHookAtTopLevel: process.env.USER_TYPE is a compile-time constant
110    const shouldTrigger = useMemo(
111      () => isSessionContainerCompatible(messages) && hasFrictionSignal(messages),
112      [messages],
113    )
114  
115    // Keep showing the banner until the user submits another message
116    if (activeForSubmitRef.current === submitCount) {
117      return true
118    }
119  
120    if (Date.now() - lastTriggeredAtRef.current < COOLDOWN_MS) {
121      return false
122    }
123    if (submitCount < MIN_SUBMIT_COUNT) {
124      return false
125    }
126    if (!shouldTrigger) {
127      return false
128    }
129  
130    lastTriggeredAtRef.current = Date.now()
131    activeForSubmitRef.current = submitCount
132    return true
133  }