/** * Chat input with [[wikilink]] autocomplete. * * When the user types `[[`, a dropdown appears with vault note suggestions. * Selecting a note inserts a colored pill in the input and records a reference. */ import { useState, useRef, useMemo, useEffect } from 'react' import type { VaultEntry } from '../types' import type { NoteReference } from '../utils/ai-context' import { getTypeColor, getTypeLightColor, buildTypeEntryMap } from '../utils/typeColors' import { bestSearchRank } from '../utils/fuzzyMatch' const MAX_SUGGESTIONS = 20 const MIN_QUERY_LENGTH = 1 const DEBOUNCE_MS = 100 interface WikilinkChatInputProps { entries: VaultEntry[] value: string onChange: (value: string) => void onSend: (text: string, references: NoteReference[]) => void disabled?: boolean placeholder?: string inputRef?: React.RefObject } interface Pill { title: string path: string type: string | null color?: string lightColor?: string } interface SuggestionEntry { title: string path: string isA: string | null color?: string lightColor?: string } function matchEntries( entries: VaultEntry[], query: string, typeEntryMap: Record, ): SuggestionEntry[] { if (query.length < MIN_QUERY_LENGTH) return [] const lower = query.toLowerCase() const matches = entries .filter(e => !e.archived && ( e.title.toLowerCase().includes(lower) || e.aliases.some(a => a.toLowerCase().includes(lower)) ), ) .map(e => ({ entry: e, rank: bestSearchRank(query, e.title, e.aliases) })) .sort((a, b) => a.rank - b.rank) return matches.slice(0, MAX_SUGGESTIONS).map(({ entry: e }) => { const te = typeEntryMap[e.isA ?? ''] return { title: e.title, path: e.path, isA: e.isA, color: e.isA ? getTypeColor(e.isA, te?.color) : undefined, lightColor: e.isA ? getTypeLightColor(e.isA, te?.color) : undefined, } }) } export function WikilinkChatInput({ entries, value, onChange, onSend, disabled, placeholder, inputRef: externalRef, }: WikilinkChatInputProps) { const [pills, setPills] = useState([]) const [showMenu, setShowMenu] = useState(false) const [selectedIndex, setSelectedIndex] = useState(0) const [debouncedQuery, setDebouncedQuery] = useState('') const internalRef = useRef(null) const inputRefToUse = externalRef ?? internalRef const menuRef = useRef(null) const debounceTimer = useRef>(undefined) const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries]) const suggestions = useMemo( () => showMenu ? matchEntries(entries, debouncedQuery, typeEntryMap) : [], [entries, debouncedQuery, typeEntryMap, showMenu], ) // Clamp selection to valid range const clampedIndex = suggestions.length > 0 ? Math.min(selectedIndex, suggestions.length - 1) : 0 useEffect(() => { if (!menuRef.current || clampedIndex < 0) return const el = menuRef.current.children[clampedIndex] as HTMLElement | undefined el?.scrollIntoView?.({ block: 'nearest' }) }, [clampedIndex]) function selectSuggestion(suggestion: SuggestionEntry) { const cursor = inputRefToUse.current?.selectionStart ?? value.length const textBefore = value.slice(0, cursor) const bracketIdx = textBefore.lastIndexOf('[[') if (bracketIdx < 0) return const textAfter = value.slice(cursor) const newValue = textBefore.slice(0, bracketIdx) + textAfter onChange(newValue) setPills(prev => { if (prev.some(p => p.path === suggestion.path)) return prev return [...prev, { title: suggestion.title, path: suggestion.path, type: suggestion.isA, color: suggestion.color, lightColor: suggestion.lightColor, }] }) setShowMenu(false) setTimeout(() => inputRefToUse.current?.focus(), 0) } function handleChange(e: React.ChangeEvent) { const newValue = e.target.value onChange(newValue) const cursor = e.target.selectionStart ?? newValue.length const textBefore = newValue.slice(0, cursor) const bracketIdx = textBefore.lastIndexOf('[[') if (bracketIdx >= 0 && !textBefore.slice(bracketIdx).includes(']]')) { const query = textBefore.slice(bracketIdx + 2) setShowMenu(true) setSelectedIndex(0) clearTimeout(debounceTimer.current) debounceTimer.current = setTimeout(() => setDebouncedQuery(query), DEBOUNCE_MS) } else { setShowMenu(false) } } function handleKeyDown(e: React.KeyboardEvent) { if (showMenu && suggestions.length > 0) { if (e.key === 'ArrowDown') { e.preventDefault() setSelectedIndex(i => (i + 1) % suggestions.length) return } if (e.key === 'ArrowUp') { e.preventDefault() setSelectedIndex(i => (i <= 0 ? suggestions.length - 1 : i - 1)) return } if (e.key === 'Enter') { e.preventDefault() selectSuggestion(suggestions[clampedIndex]) return } if (e.key === 'Escape') { e.preventDefault() setShowMenu(false) return } } if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault() if (!value.trim() && pills.length === 0) return const references: NoteReference[] = pills.map(p => ({ title: p.title, path: p.path, type: p.type, })) onSend(value, references) setPills([]) } } return (
{pills.length > 0 && (
{pills.map(pill => ( {pill.title} ))}
)} {showMenu && suggestions.length > 0 && (
{suggestions.map((s, i) => (
e.preventDefault()} onClick={() => selectSuggestion(s)} onMouseEnter={() => setSelectedIndex(i)} > {s.title} {s.isA && s.isA !== 'Note' && ( {s.isA} )}
))}
)}
) }