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}'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 }