/ src / components / Bug.tsx
Bug.tsx
  1  import { Box, Text, useInput } from 'ink'
  2  import * as React from 'react'
  3  import { useState, useCallback, useEffect } from 'react'
  4  import { getTheme } from '../utils/theme.js'
  5  import { getMessagesGetter } from '../messages.js'
  6  import type { Message } from '../query.js'
  7  import TextInput from './TextInput.js'
  8  import { logError, getInMemoryErrors } from '../utils/log.js'
  9  import { env } from '../utils/env.js'
 10  import { getGitState, getIsGit, GitRepoState } from '../utils/git.js'
 11  import { useTerminalSize } from '../hooks/useTerminalSize.js'
 12  import { getAnthropicApiKey } from '../utils/config.js'
 13  import { USER_AGENT } from '../utils/http.js'
 14  import { logEvent } from '../services/statsig.js'
 15  import { PRODUCT_NAME } from '../constants/product.js'
 16  import { API_ERROR_MESSAGE_PREFIX, queryHaiku } from '../services/claude.js'
 17  import { openBrowser } from '../utils/browser.js'
 18  import { useExitOnCtrlCD } from '../hooks/useExitOnCtrlCD.js'
 19  
 20  const GITHUB_ISSUES_REPO_URL =
 21    'https://github.com/anthropics/claude-code/issues'
 22  
 23  type Props = {
 24    onDone(result: string): void
 25  }
 26  
 27  type Step = 'userInput' | 'consent' | 'submitting' | 'done'
 28  
 29  type FeedbackData = {
 30    // Removing because of privacy concerns. Add this back in when we have a more
 31    // robust tool for viewing feedback data that can de-identify users
 32    // user_id: string
 33    // session_id: string
 34    message_count: number
 35    datetime: string
 36    description: string
 37    platform: string
 38    gitRepo: boolean
 39    version: string | null
 40    transcript: Message[]
 41  }
 42  
 43  export function Bug({ onDone }: Props): React.ReactNode {
 44    const [step, setStep] = useState<Step>('userInput')
 45    const [cursorOffset, setCursorOffset] = useState(0)
 46    const [description, setDescription] = useState('')
 47    const [feedbackId, setFeedbackId] = useState<string | null>(null)
 48    const [error, setError] = useState<string | null>(null)
 49    const [envInfo, setEnvInfo] = useState<{
 50      isGit: boolean
 51      gitState: GitRepoState | null
 52    }>({ isGit: false, gitState: null })
 53    const [title, setTitle] = useState<string | null>(null)
 54    const textInputColumns = useTerminalSize().columns - 4
 55    const messages = getMessagesGetter()()
 56  
 57    useEffect(() => {
 58      async function loadEnvInfo() {
 59        const isGit = await getIsGit()
 60        let gitState: GitRepoState | null = null
 61        if (isGit) {
 62          gitState = await getGitState()
 63        }
 64        setEnvInfo({ isGit, gitState })
 65      }
 66      void loadEnvInfo()
 67    }, [])
 68  
 69    const exitState = useExitOnCtrlCD(() => process.exit(0))
 70  
 71    const submitReport = useCallback(async () => {
 72      setStep('submitting')
 73      setError(null)
 74      setFeedbackId(null)
 75  
 76      const reportData = {
 77        message_count: messages.length,
 78        datetime: new Date().toISOString(),
 79        description,
 80        platform: env.platform,
 81        gitRepo: envInfo.isGit,
 82        terminal: env.terminal,
 83        version: MACRO.VERSION,
 84        transcript: messages,
 85        errors: getInMemoryErrors(),
 86      }
 87  
 88      const [result, t] = await Promise.all([
 89        submitFeedback(reportData),
 90        generateTitle(description),
 91      ])
 92  
 93      setTitle(t)
 94  
 95      if (result.success) {
 96        if (result.feedbackId) {
 97          setFeedbackId(result.feedbackId)
 98          logEvent('tengu_bug_report_submitted', {
 99            feedback_id: result.feedbackId,
100          })
101        }
102        setStep('done')
103      } else {
104        setError('Could not submit feedback. Please try again later.')
105        setStep('userInput')
106      }
107    }, [description, envInfo.isGit, messages])
108  
109    useInput((input, key) => {
110      // Allow any key press to close the dialog when done or when there's an error
111      if (step === 'done') {
112        if (key.return && feedbackId && title) {
113          // Open GitHub issue URL when Enter is pressed
114          const issueUrl = createGitHubIssueUrl(feedbackId, title, description)
115          void openBrowser(issueUrl)
116        }
117        onDone('<bash-stdout>Bug report submitted</bash-stdout>')
118        return
119      }
120  
121      if (error) {
122        onDone('<bash-stderr>Error submitting bug report</bash-stderr>')
123        return
124      }
125  
126      if (key.escape) {
127        onDone('<bash-stderr>Bug report cancelled</bash-stderr>')
128        return
129      }
130  
131      if (step === 'consent' && (key.return || input === ' ')) {
132        void submitReport()
133      }
134    })
135  
136    const theme = getTheme()
137  
138    return (
139      <>
140        <Box
141          flexDirection="column"
142          borderStyle="round"
143          borderColor={theme.permission}
144          paddingX={1}
145          paddingBottom={1}
146          gap={1}
147        >
148          <Text bold color={theme.permission}>
149            Submit Bug Report
150          </Text>
151          {step === 'userInput' && (
152            <Box flexDirection="column" gap={1}>
153              <Text>Describe the issue below:</Text>
154              <TextInput
155                value={description}
156                onChange={setDescription}
157                columns={textInputColumns}
158                onSubmit={() => setStep('consent')}
159                onExitMessage={() =>
160                  onDone('<bash-stderr>Bug report cancelled</bash-stderr>')
161                }
162                cursorOffset={cursorOffset}
163                onChangeCursorOffset={setCursorOffset}
164              />
165              {error && (
166                <Box flexDirection="column" gap={1}>
167                  <Text color="red">{error}</Text>
168                  <Text dimColor>Press any key to close</Text>
169                </Box>
170              )}
171            </Box>
172          )}
173  
174          {step === 'consent' && (
175            <Box flexDirection="column">
176              <Text>This report will include:</Text>
177              <Box marginLeft={2} flexDirection="column">
178                <Text>
179                  - Your bug description: <Text dimColor>{description}</Text>
180                </Text>
181                <Text>
182                  - Environment info:{' '}
183                  <Text dimColor>
184                    {env.platform}, {env.terminal}, v{MACRO.VERSION}
185                  </Text>
186                </Text>
187                {envInfo.gitState && (
188                  <Text>
189                    - Git repo metadata:{' '}
190                    <Text dimColor>
191                      {envInfo.gitState.branchName}
192                      {envInfo.gitState.commitHash
193                        ? `, ${envInfo.gitState.commitHash.slice(0, 7)}`
194                        : ''}
195                      {envInfo.gitState.remoteUrl
196                        ? ` @ ${envInfo.gitState.remoteUrl}`
197                        : ''}
198                      {!envInfo.gitState.isHeadOnRemote && ', not synced'}
199                      {!envInfo.gitState.isClean && ', has local changes'}
200                    </Text>
201                  </Text>
202                )}
203                <Text>- Current session transcript</Text>
204              </Box>
205              <Box marginTop={1}>
206                <Text wrap="wrap" dimColor>
207                  We will use your feedback to debug related issues or to improve{' '}
208                  {PRODUCT_NAME}&apos;s functionality (eg. to reduce the risk of
209                  bugs occurring in the future). Anthropic will not train
210                  generative models using feedback from {PRODUCT_NAME}.
211                </Text>
212              </Box>
213              <Box marginTop={1}>
214                <Text>
215                  Press <Text bold>Enter</Text> to confirm and submit.
216                </Text>
217              </Box>
218            </Box>
219          )}
220  
221          {step === 'submitting' && (
222            <Box flexDirection="row" gap={1}>
223              <Text>Submitting report…</Text>
224            </Box>
225          )}
226  
227          {step === 'done' && (
228            <Box flexDirection="column">
229              <Text color={getTheme().success}>Thank you for your report!</Text>
230              {feedbackId && <Text dimColor>Feedback ID: {feedbackId}</Text>}
231              <Box marginTop={1}>
232                <Text>Press </Text>
233                <Text bold>Enter </Text>
234                <Text>
235                  to also create a GitHub issue, or any other key to close.
236                </Text>
237              </Box>
238            </Box>
239          )}
240        </Box>
241  
242        <Box marginLeft={3}>
243          <Text dimColor>
244            {exitState.pending ? (
245              <>Press {exitState.keyName} again to exit</>
246            ) : step === 'userInput' ? (
247              <>Enter to continue · Esc to cancel</>
248            ) : step === 'consent' ? (
249              <>Enter to submit · Esc to cancel</>
250            ) : null}
251          </Text>
252        </Box>
253      </>
254    )
255  }
256  
257  function createGitHubIssueUrl(
258    feedbackId: string,
259    title: string,
260    description: string,
261  ): string {
262    const body = encodeURIComponent(
263      `**Bug Description**\n${description}\n\n` +
264        `**Environment Info**\n` +
265        `- Platform: ${env.platform}\n` +
266        `- Terminal: ${env.terminal}\n` +
267        `- Version: ${MACRO.VERSION || 'unknown'}\n` +
268        `- Feedback ID: ${feedbackId}\n`,
269    )
270    return `${GITHUB_ISSUES_REPO_URL}/new?title=${encodeURIComponent(title)}&body=${body}&labels=user-reported,bug`
271  }
272  
273  async function generateTitle(description: string): Promise<string> {
274    const response = await queryHaiku({
275      systemPrompt: [
276        'Generate a concise issue title (max 80 chars) that captures the key point of this feedback. Do not include quotes or prefixes like "Feedback:" or "Issue:". If you cannot generate a title, just use "User Feedback".',
277      ],
278      userPrompt: description,
279    })
280    const title =
281      response.message.content[0]?.type === 'text'
282        ? response.message.content[0].text
283        : 'Bug Report'
284    if (title.startsWith(API_ERROR_MESSAGE_PREFIX)) {
285      return `Bug Report: ${description.slice(0, 60)}${description.length > 60 ? '...' : ''}`
286    }
287    return title
288  }
289  
290  async function submitFeedback(
291    data: FeedbackData,
292  ): Promise<{ success: boolean; feedbackId?: string }> {
293    try {
294      const apiKey = getAnthropicApiKey()
295      if (!apiKey) {
296        return { success: false }
297      }
298  
299      const response = await fetch(
300        'https://api.anthropic.com/api/claude_cli_feedback',
301        {
302          method: 'POST',
303          headers: {
304            'Content-Type': 'application/json',
305            'User-Agent': USER_AGENT,
306            'x-api-key': apiKey,
307          },
308          body: JSON.stringify({
309            content: JSON.stringify(data),
310          }),
311        },
312      )
313  
314      if (response.ok) {
315        const result = await response.json()
316        if (result?.feedback_id) {
317          return { success: true, feedbackId: result.feedback_id }
318        }
319        logError('Failed to submit feedback: request did not return feedback_id')
320        return { success: false }
321      }
322  
323      logError('Failed to submit feedback:' + response.status)
324      return { success: false }
325    } catch (err) {
326      logError(
327        'Error submitting feedback: ' +
328          (err instanceof Error ? err.message : 'Unknown error'),
329      )
330      return { success: false }
331    }
332  }