import { ComposerPrimitive, useAssistantApi, useAssistantState } from '@assistant-ui/react' import { type ChangeEvent as ReactChangeEvent, type KeyboardEvent as ReactKeyboardEvent, type SyntheticEvent as ReactSyntheticEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { AgentState, ModelMode, PermissionMode } from '@/types/api' import type { Suggestion } from '@/hooks/useActiveSuggestions' import { useActiveWord } from '@/hooks/useActiveWord' import { useActiveSuggestions } from '@/hooks/useActiveSuggestions' import { applySuggestion } from '@/utils/applySuggestion' import { getTelegramWebApp } from '@/hooks/useTelegram' import { FloatingOverlay } from '@/components/ChatInput/FloatingOverlay' import { Autocomplete } from '@/components/ChatInput/Autocomplete' import { StatusBar } from '@/components/AssistantChat/StatusBar' import { ComposerButtons } from '@/components/AssistantChat/ComposerButtons' export interface TextInputState { text: string selection: { start: number; end: number } } const PERMISSION_MODES = ['default', 'acceptEdits', 'plan', 'bypassPermissions'] as const const PERMISSION_MODE_LABELS: Record = { default: 'Default', acceptEdits: 'Accept Edits', plan: 'Plan Mode', bypassPermissions: 'Bypass All' } const MODEL_MODES = ['default', 'sonnet', 'opus'] as const const MODEL_MODE_LABELS: Record = { default: 'Default', sonnet: 'Sonnet', opus: 'Opus' } const defaultSuggestionHandler = async (): Promise => [] export function HappyComposer(props: { disabled?: boolean permissionMode?: PermissionMode modelMode?: ModelMode active?: boolean thinking?: boolean agentState?: AgentState | null contextSize?: number onPermissionModeChange?: (mode: PermissionMode) => void onModelModeChange?: (mode: ModelMode) => void autocompletePrefixes?: string[] autocompleteSuggestions?: (query: string) => Promise }) { const { disabled = false, permissionMode = 'default', modelMode = 'default', active = true, thinking = false, agentState, contextSize, onPermissionModeChange, onModelModeChange, autocompletePrefixes = ['@', '/'], autocompleteSuggestions = defaultSuggestionHandler } = props const api = useAssistantApi() const composerText = useAssistantState(({ composer }) => composer.text) const threadIsRunning = useAssistantState(({ thread }) => thread.isRunning) const threadIsDisabled = useAssistantState(({ thread }) => thread.isDisabled) const controlsDisabled = disabled || !active || threadIsDisabled const trimmed = composerText.trim() const hasText = trimmed.length > 0 const canSend = hasText && !controlsDisabled && !threadIsRunning const [inputState, setInputState] = useState({ text: '', selection: { start: 0, end: 0 } }) const [showSettings, setShowSettings] = useState(false) const [isAborting, setIsAborting] = useState(false) const textareaRef = useRef(null) useEffect(() => { setInputState((prev) => { if (prev.text === composerText) return prev return { ...prev, text: composerText } }) }, [composerText]) const activeWord = useActiveWord(inputState.text, inputState.selection, autocompletePrefixes) const [suggestions, selectedIndex, moveUp, moveDown, clearSuggestions] = useActiveSuggestions( activeWord, autocompleteSuggestions, { clampSelection: true, wrapAround: true } ) const haptic = useCallback((type: 'light' | 'success' | 'error' = 'light') => { const tg = getTelegramWebApp() if (type === 'light') { tg?.HapticFeedback?.impactOccurred('light') } else if (type === 'success') { tg?.HapticFeedback?.notificationOccurred('success') } else { tg?.HapticFeedback?.notificationOccurred('error') } }, []) const handleSuggestionSelect = useCallback((index: number) => { const suggestion = suggestions[index] if (!suggestion || !textareaRef.current) return const result = applySuggestion( inputState.text, inputState.selection, suggestion.text, autocompletePrefixes, true ) api.composer().setText(result.text) setInputState({ text: result.text, selection: { start: result.cursorPosition, end: result.cursorPosition } }) setTimeout(() => { const el = textareaRef.current if (!el) return el.setSelectionRange(result.cursorPosition, result.cursorPosition) try { el.focus({ preventScroll: true }) } catch { el.focus() } }, 0) haptic('light') }, [api, suggestions, inputState, autocompletePrefixes, haptic]) const abortDisabled = controlsDisabled || isAborting || !threadIsRunning useEffect(() => { if (!isAborting) return if (threadIsRunning) return setIsAborting(false) }, [isAborting, threadIsRunning]) const handleAbort = useCallback(() => { if (abortDisabled) return haptic('error') setIsAborting(true) api.thread().cancelRun() }, [abortDisabled, api, haptic]) const handleKeyDown = useCallback((e: ReactKeyboardEvent) => { const key = e.key // Avoid intercepting IME composition keystrokes (Enter, arrows, etc.) if (e.nativeEvent.isComposing) { return } if (suggestions.length > 0) { if (key === 'ArrowUp') { e.preventDefault() moveUp() return } if (key === 'ArrowDown') { e.preventDefault() moveDown() return } if ((key === 'Enter' || key === 'Tab') && !e.shiftKey) { e.preventDefault() const indexToSelect = selectedIndex >= 0 ? selectedIndex : 0 handleSuggestionSelect(indexToSelect) return } if (key === 'Escape') { e.preventDefault() clearSuggestions() return } } if (key === 'Escape' && threadIsRunning) { e.preventDefault() handleAbort() return } if (key === 'Tab' && e.shiftKey && onPermissionModeChange) { e.preventDefault() const currentIndex = PERMISSION_MODES.indexOf(permissionMode as typeof PERMISSION_MODES[number]) const nextIndex = (currentIndex + 1) % PERMISSION_MODES.length onPermissionModeChange(PERMISSION_MODES[nextIndex]) haptic('light') } }, [ suggestions, selectedIndex, moveUp, moveDown, clearSuggestions, handleSuggestionSelect, threadIsRunning, handleAbort, onPermissionModeChange, permissionMode, haptic ]) useEffect(() => { const handleGlobalKeyDown = (e: globalThis.KeyboardEvent) => { if (e.key === 'm' && (e.metaKey || e.ctrlKey) && onModelModeChange) { e.preventDefault() const currentIndex = MODEL_MODES.indexOf(modelMode as typeof MODEL_MODES[number]) const nextIndex = (currentIndex + 1) % MODEL_MODES.length onModelModeChange(MODEL_MODES[nextIndex]) haptic('light') } } window.addEventListener('keydown', handleGlobalKeyDown) return () => window.removeEventListener('keydown', handleGlobalKeyDown) }, [modelMode, onModelModeChange, haptic]) const handleChange = useCallback((e: ReactChangeEvent) => { const selection = { start: e.target.selectionStart, end: e.target.selectionEnd } setInputState({ text: e.target.value, selection }) }, []) const handleSelect = useCallback((e: ReactSyntheticEvent) => { const target = e.target as HTMLTextAreaElement setInputState(prev => ({ ...prev, selection: { start: target.selectionStart, end: target.selectionEnd } })) }, []) const handleSettingsToggle = useCallback(() => { haptic('light') setShowSettings(prev => !prev) }, [haptic]) const handlePermissionChange = useCallback((mode: PermissionMode) => { if (!onPermissionModeChange || controlsDisabled) return onPermissionModeChange(mode) setShowSettings(false) haptic('light') }, [onPermissionModeChange, controlsDisabled, haptic]) const handleModelChange = useCallback((mode: ModelMode) => { if (!onModelModeChange || controlsDisabled) return onModelModeChange(mode) setShowSettings(false) haptic('light') }, [onModelModeChange, controlsDisabled, haptic]) const showPermissionSettings = Boolean(onPermissionModeChange) const showModelSettings = Boolean(onModelModeChange) const showSettingsButton = Boolean(onPermissionModeChange || onModelModeChange) const showAbortButton = true const overlays = useMemo(() => { if (showSettings && (showPermissionSettings || showModelSettings)) { return (
{showPermissionSettings ? (
Permission Mode
{PERMISSION_MODES.map((mode) => ( ))}
) : null} {showPermissionSettings && showModelSettings ? (
) : null} {showModelSettings ? (
Model
{MODEL_MODES.map((mode) => ( ))}
) : null}
) } if (suggestions.length > 0) { return (
handleSuggestionSelect(index)} />
) } return null }, [ showSettings, showPermissionSettings, showModelSettings, suggestions, selectedIndex, controlsDisabled, permissionMode, modelMode, handlePermissionChange, handleModelChange, handleSuggestionSelect ]) return (
{overlays}
) }