(props: Props)
| 37 | }; |
| 38 | |
| 39 | export default function TextInput(props: Props): React.ReactNode { |
| 40 | const [theme] = useTheme(); |
| 41 | const isTerminalFocused = useTerminalFocus(); |
| 42 | // Hoisted to mount-time — this component re-renders on every keystroke. |
| 43 | const accessibilityEnabled = useMemo(() => isEnvTruthy(process.env.CLAUDE_CODE_ACCESSIBILITY), []); |
| 44 | const settings = useSettings(); |
| 45 | const reducedMotion = settings.prefersReducedMotion ?? false; |
| 46 | |
| 47 | const voiceStateRaw = useVoiceState(s => s.voiceState); |
| 48 | const voiceState = feature('VOICE_MODE') ? voiceStateRaw : ('idle' as const); |
| 49 | const isVoiceRecording = voiceState === 'recording'; |
| 50 | |
| 51 | const audioLevelsRaw = useVoiceState(s => s.voiceAudioLevels); |
| 52 | const audioLevels = feature('VOICE_MODE') ? audioLevelsRaw : []; |
| 53 | const smoothedRef = useRef<number[]>(new Array(CURSOR_WAVEFORM_WIDTH).fill(0)); |
| 54 | |
| 55 | const needsAnimation = isVoiceRecording && !reducedMotion; |
| 56 | const [animRefRaw, animTimeRaw] = useAnimationFrame(needsAnimation ? 50 : null); |
| 57 | const animRef = feature('VOICE_MODE') ? animRefRaw : () => {}; |
| 58 | const animTime = feature('VOICE_MODE') ? animTimeRaw : 0; |
| 59 | |
| 60 | // Show hint when terminal regains focus and clipboard has an image |
| 61 | useClipboardImageHint(isTerminalFocused, !!props.onImagePaste); |
| 62 | |
| 63 | // Cursor invert function: mini waveform during voice recording, |
| 64 | // standard chalk.inverse otherwise. No warmup pulse — the ~120ms |
| 65 | // warmup window is too short for a 1s-period pulse to register, and |
| 66 | // driving TextInput re-renders at 50ms during warmup (while spaces |
| 67 | // are simultaneously arriving every 30-80ms) causes visible stutter. |
| 68 | const canShowCursor = isTerminalFocused && !accessibilityEnabled; |
| 69 | let invert: (text: string) => string; |
| 70 | if (!canShowCursor) { |
| 71 | invert = (text: string) => text; |
| 72 | } else if (isVoiceRecording && !reducedMotion) { |
| 73 | // Single-bar waveform from the latest audio level |
| 74 | const smoothed = smoothedRef.current; |
| 75 | const raw = audioLevels.length > 0 ? (audioLevels[audioLevels.length - 1] ?? 0) : 0; |
| 76 | const target = Math.min(raw * LEVEL_BOOST, 1); |
| 77 | smoothed[0] = (smoothed[0] ?? 0) * SMOOTH + target * (1 - SMOOTH); |
| 78 | const displayLevel = smoothed[0] ?? 0; |
| 79 | const barIndex = Math.max(1, Math.min(Math.round(displayLevel * (BARS.length - 1)), BARS.length - 1)); |
| 80 | const isSilent = raw < SILENCE_THRESHOLD; |
| 81 | const hue = ((animTime / 1000) * 90) % 360; |
| 82 | const { r, g, b } = isSilent ? { r: 128, g: 128, b: 128 } : hueToRgb(hue); |
| 83 | invert = () => chalk.rgb(r, g, b)(BARS[barIndex]!); |
| 84 | } else { |
| 85 | invert = chalk.inverse; |
| 86 | } |
| 87 | |
| 88 | const textInputState = useTextInput({ |
| 89 | value: props.value, |
| 90 | onChange: props.onChange, |
| 91 | onSubmit: props.onSubmit, |
| 92 | onExit: props.onExit, |
| 93 | onExitMessage: props.onExitMessage, |
| 94 | onHistoryReset: props.onHistoryReset, |
| 95 | onHistoryUp: props.onHistoryUp, |
| 96 | onHistoryDown: props.onHistoryDown, |
nothing calls this directly
no test coverage detected