import * as React from 'react' import { UseChatHelpers } from 'ai/react' import { debounce, has, isEqual } from 'lodash-es' import useSWR from 'swr' import { useEnterSubmit } from '@/lib/hooks/use-enter-submit' import { useAuthenticatedApi, useSession } from '@/lib/tabby/auth' import fetcher from '@/lib/tabby/fetcher' import type { ISearchHit, SearchReponse } from '@/lib/types' import { cn } from '@/lib/utils' import { Button, buttonVariants } from '@/components/ui/button' import { Combobox, ComboboxAnchor, ComboboxContent, ComboboxOption, ComboboxTextarea } from '@/components/ui/combobox' import { IconArrowElbow, IconEdit, IconSymbolFunction } from '@/components/ui/icons' import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' export interface PromptProps extends Pick { onSubmit: (value: string) => Promise isLoading: boolean } export interface PromptFormRef { focus: () => void } function PromptFormRenderer( { onSubmit, input, setInput, isLoading }: PromptProps, ref: React.ForwardedRef ) { const { formRef, onKeyDown } = useEnterSubmit() const [queryCompletionUrl, setQueryCompletionUrl] = React.useState< string | null >(null) const inputRef = React.useRef(null) // store the input selection for replacing inputValue const prevInputSelectionEnd = React.useRef() // for updating the input selection after replacing const nextInputSelectionRange = React.useRef<[number, number]>() const [options, setOptions] = React.useState([]) const [selectedCompletionsMap, setSelectedCompletionsMap] = React.useState< Record >({}) const { data: completionData } = useSWR( useAuthenticatedApi(queryCompletionUrl), fetcher, { revalidateOnFocus: false, dedupingInterval: 0, errorRetryCount: 0 } ) React.useEffect(() => { setOptions(completionData?.hits ?? []) }, [completionData?.hits]) React.useImperativeHandle(ref, () => { return { focus: () => { inputRef.current?.focus() } } }) React.useEffect(() => { if ( input && inputRef.current && inputRef.current !== document.activeElement ) { inputRef.current.focus() } }, [input]) React.useLayoutEffect(() => { if (nextInputSelectionRange.current?.length) { inputRef.current?.setSelectionRange?.( nextInputSelectionRange.current[0], nextInputSelectionRange.current[1] ) nextInputSelectionRange.current = undefined } }) const handleSearchCompletion = React.useMemo(() => { return debounce((e: React.ChangeEvent) => { const value = e.target?.value ?? '' const end = e.target?.selectionEnd ?? 0 const queryNameMatches = getSearchCompletionQueryName(value, end) const queryName = queryNameMatches?.[1] if (queryName) { const query = encodeURIComponent(`name:${queryName} AND kind:function`) const url = `/v1beta/search?q=${query}` setQueryCompletionUrl(url) } else { setOptions([]) } }, 200) }, []) const handleCompletionSelect = (item: ISearchHit) => { const selectionEnd = prevInputSelectionEnd.current ?? 0 const queryNameMatches = getSearchCompletionQueryName(input, selectionEnd) if (queryNameMatches) { setSelectedCompletionsMap({ ...selectedCompletionsMap, [`@${item.doc?.name}`]: item }) const replaceString = `@${item?.doc?.name} ` const prevInput = input .substring(0, selectionEnd) .replace(new RegExp(queryNameMatches[0]), '') const nextSelectionEnd = prevInput.length + replaceString.length // store the selection range and update it when layout nextInputSelectionRange.current = [nextSelectionEnd, nextSelectionEnd] // insert a space to break the search query setInput(prevInput + replaceString + input.slice(selectionEnd)) } setOptions([]) } const handlePromptSubmit: React.FormEventHandler< HTMLFormElement > = async e => { e.preventDefault() if (!input?.trim()) { return } let finalInput = input // replace queryname to doc.body of selected completions Object.keys(selectedCompletionsMap).forEach(key => { const completion = selectedCompletionsMap[key] if (!completion?.doc) return finalInput = finalInput.replaceAll( key, `\n${'```'}${completion.doc?.language ?? ''}\n${ completion.doc.body ?? '' }\n${'```'}\n` ) }) setInput('') await onSubmit(finalInput) } const handleTextareaKeyDown = ( e: React.KeyboardEvent, isOpen: boolean ) => { if (isOpen && ['ArrowRight', 'ArrowLeft', 'Home', 'End'].includes(e.key)) { setOptions([]) } else { onKeyDown(e) } } return (
{({ open, highlightedIndex }) => { const highlightedOption = options?.[highlightedIndex] return ( <>
{ if (has(e, 'target.value')) { prevInputSelectionEnd.current = e.target.selectionEnd setInput(e.target.value) handleSearchCompletion(e) } else { prevInputSelectionEnd.current = undefined } }} onKeyDown={e => handleTextareaKeyDown(e, open)} />
Send message
e.preventDefault()} className="w-[60vw] md:w-[430px]" >
{open && !!options?.length && options.map((item, index) => (
{item?.doc?.name}(...)
{item?.doc?.body}
))}
e.preventDefault()} onKeyDownCapture={e => e.preventDefault()} className="rounded-none" collisionPadding={{ bottom: 120 }} >
{highlightedOption?.doc?.kind ? `(${highlightedOption?.doc?.kind}) ` : ''} {highlightedOption?.doc?.name}
{highlightedOption?.doc?.body}
) }}
) } export const PromptForm = React.forwardRef( PromptFormRenderer ) /** * Retrieves the name of the completion query from a given string@. * @param {string} val - The input string to search for the completion query name. * @param {number | undefined} selectionEnd - The index at which the selection ends in the input string. * @return {string | undefined} - The name of the completion query if found, otherwise undefined. */ export function getSearchCompletionQueryName( val: string, selectionEnd: number | undefined ): RegExpExecArray | null { const queryString = val.substring(0, selectionEnd) const matches = /@(\w+)$/.exec(queryString) return matches } function IconForCompletionKind({ kind, ...rest }: { kind: string | undefined } & React.ComponentProps<'svg'>) { switch (kind) { case 'function': return default: return } }