From d61898723d8273ccc1f27c0972e5228197e8a0b5 Mon Sep 17 00:00:00 2001 From: "David L. Qiu" Date: Fri, 15 Aug 2025 15:00:12 -0700 Subject: [PATCH 1/2] implement SimpleAutocomplete and use it in ModelIdInput --- .../mui-extras/simple-autocomplete.tsx | 307 ++++++++++++++++++ .../components/settings/model-id-input.tsx | 91 +----- 2 files changed, 319 insertions(+), 79 deletions(-) create mode 100644 packages/jupyter-ai/src/components/mui-extras/simple-autocomplete.tsx diff --git a/packages/jupyter-ai/src/components/mui-extras/simple-autocomplete.tsx b/packages/jupyter-ai/src/components/mui-extras/simple-autocomplete.tsx new file mode 100644 index 000000000..3c67cffc1 --- /dev/null +++ b/packages/jupyter-ai/src/components/mui-extras/simple-autocomplete.tsx @@ -0,0 +1,307 @@ +import React, { useState, useRef, useEffect, useMemo } from 'react'; +import { + TextField, + MenuItem, + Paper, + Popper, + ClickAwayListener, + TextFieldProps +} from '@mui/material'; +import { styled } from '@mui/material/styles'; + +const StyledPopper = styled(Popper)(({ theme }) => ({ + zIndex: theme.zIndex.modal, + '& .MuiPaper-root': { + maxHeight: '200px', + overflow: 'auto', + border: `1px solid ${theme.palette.divider}`, + boxShadow: theme.shadows[8] + } +})); + +export type AutocompleteOption = { + label: string; + value: string; +}; + +export type SimpleAutocompleteProps = { + /** + * List of options to show. Each option value should be unique. + */ + options: AutocompleteOption[]; + /** + * (optional) Controls the value of the `Autocomplete` component. + */ + value?: string; + /** + * (optional) Callback fired when the input changes. + */ + onChange?: (value: string) => void; + /** + * (optional) Placeholder string shown in the text input while it is empty. + * This can be used to provide a short example blurb. + */ + placeholder?: string; + /** + * (optional) Function that filters the list of options based on the input + * value. By default, options whose labels do not contain the input value as a + * substring are filtered and hidden. The default filter only filters the list + * of options if the input contains >1 non-whitespace character. + */ + optionsFilter?: ( + options: AutocompleteOption[], + inputValue: string + ) => AutocompleteOption[]; + /** + * (optional) Additional props passed directly to the `TextField` child + * component. + */ + textFieldProps?: Omit; + /** + * (optional) Controls the number of options shown in the autocomplete menu. + * Defaults to unlimited. + */ + maxOptions?: number; + /** + * (optional) If true, the component will treat options as case-sensitive when + * the default options filter is used (i.e. `props.optionsFilter` is unset). + */ + caseSensitive?: boolean; + /** + * (optional) If true, the component will bold the substrings matching the + * current input on each option. The input must contain >1 non-whitespace + * character for this prop to take effect. + */ + boldMatches?: boolean; +}; + +function defaultOptionsFilter( + options: AutocompleteOption[], + inputValue: string, + caseSensitive = false +): AutocompleteOption[] { + // Do nothing if the input contains <=1 non-whitespace character + if (inputValue.trim().length <= 1) { + return options; + } + + const searchValue = caseSensitive ? inputValue : inputValue.toLowerCase(); + + return options.filter(option => { + const optionLabel = caseSensitive + ? option.label + : option.label.toLowerCase(); + return optionLabel.includes(searchValue); + }); +} + +function highlightMatches( + text: string, + searchValue: string, + caseSensitive = false +): React.ReactNode { + // Do nothing if the input contains <=1 non-whitespace character + if (searchValue.trim().length <= 1) { + return text; + } + + const searchText = caseSensitive ? searchValue : searchValue.toLowerCase(); + const targetText = caseSensitive ? text : text.toLowerCase(); + + const parts: React.ReactNode[] = []; + let lastIndex = 0; + let matchIndex = targetText.indexOf(searchText); + + while (matchIndex !== -1) { + if (matchIndex > lastIndex) { + parts.push(text.slice(lastIndex, matchIndex)); + } + + parts.push( + + {text.slice(matchIndex, matchIndex + searchText.length)} + + ); + + lastIndex = matchIndex + searchText.length; + matchIndex = targetText.indexOf(searchText, lastIndex); + } + + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + + return parts.length > 1 ? <>{parts} : text; +} + +/** + * A simple `Autocomplete` component with an emphasis on being bug-free and + * performant. Notes: + * + * - By default, options are filtered using case-insensitive substring matching. + * + * - Clicking an option sets the value of this component and fires + * `props.onChange()` if passed. It is treated identically to a user typing the + * option literally. + * + * - Matched substrings will be shown in bold on each option when the + * `boldMatches` prop is set. + */ +export function SimpleAutocomplete( + props: SimpleAutocompleteProps +): React.ReactElement { + const [inputValue, setInputValue] = useState(props.value || ''); + const [isOpen, setIsOpen] = useState(false); + const [focusedIndex, setFocusedIndex] = useState(-1); + const textFieldRef = useRef(null); + const inputRef = useRef(null); + + // Filter and limit options + const filteredOptions = useMemo(() => { + const filterFn = props.optionsFilter || defaultOptionsFilter; + const filtered = filterFn(props.options, inputValue, props.caseSensitive); + return filtered.slice(0, props.maxOptions ?? props.options.length); + }, [ + props.options, + inputValue, + props.optionsFilter, + props.maxOptions, + props.caseSensitive + ]); + + // Sync external value changes + useEffect(() => { + setInputValue(props.value || ''); + }, [props.value]); + + // Determine if menu should be open + const shouldShowMenu = isOpen && filteredOptions.length > 0; + + function handleInputChange(event: React.ChangeEvent): void { + const newValue = event.target.value; + setInputValue(newValue); + setFocusedIndex(-1); + + if (!isOpen && newValue.trim() !== '') { + setIsOpen(true); + } + + if (props.onChange) { + props.onChange(newValue); + } + } + + function handleInputFocus(): void { + setIsOpen(true); + } + + function handleOptionClick(option: AutocompleteOption): void { + setInputValue(option.value); + setIsOpen(false); + setFocusedIndex(-1); + + if (props.onChange) { + props.onChange(option.value); + } + + if (inputRef.current) { + inputRef.current.blur(); + } + } + + function handleKeyDown(event: React.KeyboardEvent): void { + if (!shouldShowMenu) { + return; + } + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + setFocusedIndex(prev => { + return prev < filteredOptions.length - 1 ? prev + 1 : 0; + }); + break; + + case 'ArrowUp': + event.preventDefault(); + setFocusedIndex(prev => { + return prev > 0 ? prev - 1 : filteredOptions.length - 1; + }); + break; + + case 'Enter': + event.preventDefault(); + if (focusedIndex >= 0 && focusedIndex < filteredOptions.length) { + handleOptionClick(filteredOptions[focusedIndex]); + } + break; + + case 'Escape': + setIsOpen(false); + setFocusedIndex(-1); + break; + } + } + + function handleClickAway(): void { + setIsOpen(false); + setFocusedIndex(-1); + } + + return ( + +
+ + + + + {filteredOptions.map((option, index) => { + const displayLabel = props.boldMatches + ? highlightMatches( + option.label, + inputValue, + props.caseSensitive + ) + : option.label; + + return ( + { + handleOptionClick(option); + }} + sx={{ + '&.Mui-selected': { + backgroundColor: 'action.hover' + }, + '&.Mui-selected:hover': { + backgroundColor: 'action.selected' + } + }} + > + {displayLabel} + + ); + })} + + +
+
+ ); +} diff --git a/packages/jupyter-ai/src/components/settings/model-id-input.tsx b/packages/jupyter-ai/src/components/settings/model-id-input.tsx index 30b497ac7..a61449f78 100644 --- a/packages/jupyter-ai/src/components/settings/model-id-input.tsx +++ b/packages/jupyter-ai/src/components/settings/model-id-input.tsx @@ -1,39 +1,9 @@ -import React, { useState, useEffect } from 'react'; -import { - TextField, - Button, - Box, - Typography, - Autocomplete -} from '@mui/material'; +import React, { useState, useEffect, useMemo } from 'react'; +import { Button, Box } from '@mui/material'; import { AiService } from '../../handler'; import { useStackingAlert } from '../mui-extras/stacking-alert'; import Save from '@mui/icons-material/Save'; - -/** - * Highlights matched substrings in a given text by wrapping them in bold tags. - */ -const highlightMatches = (text: string, inputValue: string) => { - const trimmedInput = inputValue.trim(); - if (!trimmedInput) { - return text; - } // If input is empty, return original text - - const parts = text.split(new RegExp(`(${trimmedInput})`, 'gi')); - return ( - - {parts.map((part, index) => - part.toLowerCase() === trimmedInput.toLowerCase() ? ( - - {part} - - ) : ( - part - ) - )} - - ); -}; +import { SimpleAutocomplete } from '../mui-extras/simple-autocomplete'; export type ModelIdInputProps = { /** @@ -81,7 +51,6 @@ export function ModelIdInput(props: ModelIdInputProps): JSX.Element { const [updating, setUpdating] = useState(false); const [input, setInput] = useState(''); - const [isOpen, setIsOpen] = useState(false); const alert = useStackingAlert(); /** @@ -155,54 +124,18 @@ export function ModelIdInput(props: ModelIdInputProps): JSX.Element { } }; + const modelsAsOptions = useMemo(() => { + return models.map(m => ({ label: m, value: m })); + }, [models]); + return ( - { - setInput(newValue || ''); - // Close dropdown after selection - if (newValue && models.includes(newValue)) { - setIsOpen(false); - } - }} - onInputChange={(_, newValue, reason) => { - setInput(newValue); - // Show dropdown when typing, hide on selection - setIsOpen(reason === 'input' && Boolean(newValue?.trim())); - }} - filterOptions={(options, { inputValue }) => { - const searchTerm = inputValue.trim().toLowerCase(); - if (!searchTerm || searchTerm.length < 2) { - return []; // Don't filter if input is empty or too short - } - return options.filter(option => - option.toLowerCase().includes(searchTerm) - ); - }} - open={ - isOpen && - input.trim().length >= 2 && // Only show dropdown if input is at least 2 characters, reduces fuzziness of search - Boolean( - models.filter(model => - model.toLowerCase().includes(input.trim().toLowerCase()) - ).length > 0 - ) - } - renderInput={params => ( - - )} - renderOption={(props, option) => ( -
  • {highlightMatches(option, input)}
  • - )} + onChange={v => setInput(v)} + placeholder={props.placeholder} + boldMatches />