import { useState, useRef, useEffect, useCallback } from 'react' import { ChevronDown, Code, X } from 'lucide-react' export interface AvailableField { path: string type: string nodeName: string nodeId: string displayName?: string isDirectUpstream?: boolean // true if this is an immediate predecessor node } interface ExpressionInputProps { value: string onChange: (value: string) => void placeholder?: string availableFields?: AvailableField[] multiline?: boolean // Set for fields that hold source code, which want a monospace face and room // to work in whatever their current contents happen to look like. codeMode?: boolean className?: string } type ContentKind = 'plain' | 'json' | 'broken-json' | 'markdown' // What someone typed into a field says what it is. A prompt, a JSON body and a // note are all "string" to the schema, so the shape of the text is the only // signal available - and it is a reliable one. function detectContentKind(value: string): ContentKind { const text = value.trim() if (!text) return 'plain' const looksStructured = /^[[{]/.test(text) && /[\]}]$/.test(text) if (looksStructured) { try { JSON.parse(text) return 'json' } catch { return 'broken-json' } } // Two or more markdown constructs, so a lone hyphen or a URL in prose does // not get called markdown. const markers = [ /^#{1,6}\s+\S/m, // heading /^\s*[-*+]\s+\S/m, // bullet list /^\s*\d+\.\s+\S/m, // numbered list /\*\*[^*\n]+\*\*/, // bold /\[[^\]\n]+\]\([^)\n]+\)/, // link /^>\s+\S/m, // quote /```/, // fenced code ].filter((pattern) => pattern.test(text)).length return markers >= 2 ? 'markdown' : 'plain' } const KIND_LABELS: Record = { plain: null, json: { label: 'JSON', className: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300', }, 'broken-json': { label: 'JSON with a syntax error', className: 'bg-amber-100 dark:bg-amber-900/30 text-amber-800 dark:text-amber-300', }, markdown: { label: 'Markdown', className: 'bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300', }, } const TYPE_COLORS: Record = { string: 'bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300', number: 'bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300', boolean: 'bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300', object: 'bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300', array: 'bg-pink-100 dark:bg-pink-900/30 text-pink-800 dark:text-pink-300', any: 'bg-gray-100 dark:bg-gray-800 text-gray-800 dark:text-gray-300', } export function ExpressionInput({ value, onChange, placeholder, availableFields = [], multiline = false, codeMode = false, className = '', }: ExpressionInputProps) { const [showAutocomplete, setShowAutocomplete] = useState(false) const [autocompletePosition, setAutocompletePosition] = useState({ top: 0, left: 0 }) const [filterText, setFilterText] = useState('') const [selectedIndex, setSelectedIndex] = useState(0) const [cursorPosition, setCursorPosition] = useState(0) const inputRef = useRef(null) const autocompleteRef = useRef(null) // Check if cursor is inside an expression {{ }} const isInsideExpression = useCallback((text: string, position: number): { inside: boolean; start: number; filter: string } => { const beforeCursor = text.slice(0, position) const lastOpenBrace = beforeCursor.lastIndexOf('{{') const lastCloseBrace = beforeCursor.lastIndexOf('}}') if (lastOpenBrace > lastCloseBrace) { const filter = beforeCursor.slice(lastOpenBrace + 2).trim() return { inside: true, start: lastOpenBrace + 2, filter } } return { inside: false, start: 0, filter: '' } }, []) // Filter fields based on input const filteredFields = availableFields.filter((field) => { const searchText = filterText.toLowerCase() return ( field.path.toLowerCase().includes(searchText) || field.nodeName.toLowerCase().includes(searchText) || (field.displayName?.toLowerCase().includes(searchText) ?? false) ) }) // Handle input change const handleChange = (e: React.ChangeEvent) => { const newValue = e.target.value const newPosition = e.target.selectionStart || 0 onChange(newValue) setCursorPosition(newPosition) const { inside, filter } = isInsideExpression(newValue, newPosition) if (inside) { setFilterText(filter) setShowAutocomplete(true) setSelectedIndex(0) // Calculate autocomplete position if (inputRef.current) { const rect = inputRef.current.getBoundingClientRect() setAutocompletePosition({ top: rect.height + 4, left: 0, }) } } else { setShowAutocomplete(false) } } // Handle cursor position changes const handleSelect = (e: React.SyntheticEvent) => { const target = e.target as HTMLTextAreaElement | HTMLInputElement const newPosition = target.selectionStart || 0 setCursorPosition(newPosition) const { inside, filter } = isInsideExpression(value, newPosition) if (inside && availableFields.length > 0) { setFilterText(filter) setShowAutocomplete(true) } else { setShowAutocomplete(false) } } // Insert selected field const insertField = useCallback((field: AvailableField) => { const { inside, start } = isInsideExpression(value, cursorPosition) if (!inside) return // Build the expression based on whether this is a direct upstream node // Direct upstream: raw path (no $node accessor) // Non-direct upstream: full expression with node accessor const expression = field.isDirectUpstream ? field.path : `$node["${field.nodeName}"].${field.path}` // Find the end of current filter text const beforeFilter = value.slice(0, start) const afterCursor = value.slice(cursorPosition) // Check if there's a closing }} const closingBrace = afterCursor.indexOf('}}') const afterExpression = closingBrace >= 0 ? afterCursor.slice(closingBrace) : '}}' const newValue = beforeFilter + expression + afterExpression onChange(newValue) setShowAutocomplete(false) // Focus back on input setTimeout(() => { if (inputRef.current) { inputRef.current.focus() const newPosition = beforeFilter.length + expression.length inputRef.current.setSelectionRange(newPosition, newPosition) } }, 0) }, [value, cursorPosition, isInsideExpression, onChange]) // Keyboard navigation const handleKeyDown = (e: React.KeyboardEvent) => { if (!showAutocomplete) return switch (e.key) { case 'ArrowDown': e.preventDefault() setSelectedIndex((prev) => Math.min(prev + 1, filteredFields.length - 1)) break case 'ArrowUp': e.preventDefault() setSelectedIndex((prev) => Math.max(prev - 1, 0)) break case 'Enter': if (filteredFields[selectedIndex]) { e.preventDefault() insertField(filteredFields[selectedIndex]) } break case 'Escape': e.preventDefault() setShowAutocomplete(false) break case 'Tab': if (filteredFields[selectedIndex]) { e.preventDefault() insertField(filteredFields[selectedIndex]) } break } } // Close autocomplete when clicking outside useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if ( autocompleteRef.current && !autocompleteRef.current.contains(e.target as Node) && inputRef.current && !inputRef.current.contains(e.target as Node) ) { setShowAutocomplete(false) } } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) }, []) // Insert expression template const insertExpressionTemplate = () => { const template = '{{ }}' const newValue = value + template onChange(newValue) setTimeout(() => { if (inputRef.current) { inputRef.current.focus() const position = value.length + 3 // Position cursor between {{ and }} inputRef.current.setSelectionRange(position, position) setCursorPosition(position) setShowAutocomplete(true) setFilterText('') } }, 0) } const kind = detectContentKind(value || '') const kindLabel = KIND_LABELS[kind] const isCode = codeMode || kind === 'json' || kind === 'broken-json' // The field is always a textarea so it can grow with what is typed into it // without swapping the element under the cursor, which would lose focus and // the caret mid-sentence. useEffect(() => { const el = inputRef.current as HTMLTextAreaElement | null if (!el) return el.style.height = 'auto' const minHeight = codeMode ? 200 : multiline ? 80 : 38 el.style.height = Math.min(Math.max(el.scrollHeight, minHeight), 420) + 'px' }, [value, multiline, codeMode]) const formatJson = () => { try { onChange(JSON.stringify(JSON.parse(value), null, 2)) } catch { // Only offered when the value parses, so there is nothing to report here. } } return (