diff options
Diffstat (limited to 'extension/react-app/src/components')
-rw-r--r-- | extension/react-app/src/components/ComboBox.tsx | 446 | ||||
-rw-r--r-- | extension/react-app/src/components/EditableDiv.tsx | 84 | ||||
-rw-r--r-- | extension/react-app/src/components/Layout.tsx | 22 | ||||
-rw-r--r-- | extension/react-app/src/components/ProgressBar.tsx | 77 | ||||
-rw-r--r-- | extension/react-app/src/components/index.ts | 4 |
5 files changed, 550 insertions, 83 deletions
diff --git a/extension/react-app/src/components/ComboBox.tsx b/extension/react-app/src/components/ComboBox.tsx index 41b44684..c216e7d1 100644 --- a/extension/react-app/src/components/ComboBox.tsx +++ b/extension/react-app/src/components/ComboBox.tsx @@ -1,4 +1,5 @@ import React, { + useCallback, useContext, useEffect, useImperativeHandle, @@ -7,6 +8,8 @@ import React, { import { useCombobox } from "downshift"; import styled from "styled-components"; import { + StyledTooltip, + buttonColor, defaultBorderRadius, lightGray, secondaryDark, @@ -19,6 +22,9 @@ import { BookmarkIcon, DocumentPlusIcon, FolderArrowDownIcon, + ArrowLeftIcon, + PlusIcon, + ArrowRightIcon, } from "@heroicons/react/24/outline"; import { ContextItem } from "../../../schema/FullState"; import { postVscMessage } from "../vscode"; @@ -60,7 +66,7 @@ const EmptyPillDiv = styled.div` } `; -const MainTextInput = styled.textarea` +const MainTextInput = styled.textarea<{ inQueryForDynamicProvider: boolean }>` resize: none; padding: 8px; @@ -73,11 +79,16 @@ const MainTextInput = styled.textarea` background-color: ${secondaryDark}; color: ${vscForeground}; z-index: 1; - border: 1px solid transparent; + border: 1px solid + ${(props) => + props.inQueryForDynamicProvider ? buttonColor : "transparent"}; &:focus { - outline: 1px solid ${lightGray}; + outline: 1px solid + ${(props) => (props.inQueryForDynamicProvider ? buttonColor : lightGray)}; border: 1px solid transparent; + background-color: ${(props) => + props.inQueryForDynamicProvider ? `${buttonColor}22` : secondaryDark}; } &::placeholder { @@ -85,6 +96,37 @@ const MainTextInput = styled.textarea` } `; +const DynamicQueryTitleDiv = styled.div` + position: absolute; + right: 0px; + top: 0px; + height: fit-content; + padding: 2px 4px; + border-radius: ${defaultBorderRadius}; + z-index: 2; + color: white; + font-size: 12px; + + background-color: ${buttonColor}; +`; + +const StyledPlusIcon = styled(PlusIcon)` + position: absolute; + right: 0px; + top: 0px; + height: fit-content; + padding: 0; + cursor: pointer; + border-radius: ${defaultBorderRadius}; + z-index: 2; + + background-color: ${vscBackground}; + + &:hover { + background-color: ${secondaryDark}; + } +`; + const UlMaxHeight = 300; const Ul = styled.ul<{ hidden: boolean; @@ -129,7 +171,8 @@ const Li = styled.li<{ ${({ selected }) => selected && "font-weight: bold;"} padding: 0.5rem 0.75rem; display: flex; - flex-direction: column; + flex-direction: row; + align-items: center; ${({ isLastItem }) => isLastItem && "border-bottom: 1px solid gray;"} /* border-top: 1px solid gray; */ cursor: pointer; @@ -164,37 +207,66 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { const [items, setItems] = React.useState(props.items); const inputRef = React.useRef<HTMLInputElement>(null); - const [inputBoxHeight, setInputBoxHeight] = useState<string | undefined>( - undefined - ); // Whether the current input follows an '@' and should be treated as context query const [currentlyInContextQuery, setCurrentlyInContextQuery] = useState(false); + const [nestedContextProvider, setNestedContextProvider] = useState< + any | undefined + >(undefined); + const [inQueryForContextProvider, setInQueryForContextProvider] = useState< + any | undefined + >(undefined); - const { getInputProps, ...downshiftProps } = useCombobox({ - onSelectedItemChange: ({ selectedItem }) => { - if (selectedItem?.id) { - // Get the query from the input value - const segs = downshiftProps.inputValue.split("@"); - const query = segs[segs.length - 1]; - const restOfInput = segs.splice(0, segs.length - 1).join("@"); + useEffect(() => { + if (!currentlyInContextQuery) { + setNestedContextProvider(undefined); + setInQueryForContextProvider(undefined); + } + }, [currentlyInContextQuery]); - // Tell server the context item was selected - client?.selectContextItem(selectedItem.id, query); + const contextProviders = useSelector( + (state: RootStore) => state.serverState.context_providers + ) as any[]; - // Remove the '@' and the context query from the input - if (downshiftProps.inputValue.includes("@")) { - downshiftProps.setInputValue(restOfInput); - } - } - }, - onInputValueChange({ inputValue, highlightedIndex }) { + const goBackToContextProviders = () => { + setCurrentlyInContextQuery(false); + setNestedContextProvider(undefined); + setInQueryForContextProvider(undefined); + downshiftProps.setInputValue("@"); + }; + + useEffect(() => { + if (!nestedContextProvider) { + console.log("setting items", nestedContextProvider); + setItems( + contextProviders?.map((provider) => ({ + name: provider.display_title, + description: provider.description, + id: provider.title, + })) || [] + ); + } + }, [nestedContextProvider]); + + const onInputValueChangeCallback = useCallback( + ({ inputValue, highlightedIndex }: any) => { + // Clear the input if (!inputValue) { setItems([]); + setNestedContextProvider(undefined); + setCurrentlyInContextQuery(false); return; } + if ( + inQueryForContextProvider && + !inputValue.startsWith(`@${inQueryForContextProvider.title}`) + ) { + setInQueryForContextProvider(undefined); + } + props.onInputValueChange(inputValue); + // Handle context selection if (inputValue.endsWith("@") || currentlyInContextQuery) { const segs = inputValue?.split("@") || []; @@ -202,46 +274,124 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { // Get search results and return setCurrentlyInContextQuery(true); const providerAndQuery = segs[segs.length - 1] || ""; - // Only return context items from the current workspace - the index is currently shared between all sessions - const workspaceFilter = - workspacePaths && workspacePaths.length > 0 - ? `workspace_dir IN [ ${workspacePaths - .map((path) => `"${path}"`) - .join(", ")} ]` - : undefined; - searchClient - .index(SEARCH_INDEX_NAME) - .search(providerAndQuery, { - filter: workspaceFilter, - }) - .then((res) => { - setItems( - res.hits.map((hit) => { - return { - name: hit.name, - description: hit.description, - id: hit.id, - content: hit.content, - }; - }) - ); - }) - .catch(() => { - // Swallow errors, because this simply is not supported on Windows at the moment + + if (nestedContextProvider && !inputValue.endsWith("@")) { + // Search only within this specific context provider + getFilteredContextItemsForProvider( + nestedContextProvider.title, + providerAndQuery + ).then((res) => { + setItems(res); }); + } else { + // Search through the list of context providers + const filteredItems = + contextProviders + ?.filter( + (provider) => + `@${provider.title}` + .toLowerCase() + .startsWith(inputValue.toLowerCase()) || + `@${provider.display_title}` + .toLowerCase() + .startsWith(inputValue.toLowerCase()) + ) + .map((provider) => ({ + name: provider.display_title, + description: provider.description, + id: provider.title, + })) || []; + setItems(filteredItems); + setCurrentlyInContextQuery(true); + } return; } else { // Exit the '@' context menu setCurrentlyInContextQuery(false); - setItems; + setNestedContextProvider(undefined); } } + + setNestedContextProvider(undefined); + + // Handle slash commands setItems( props.items.filter((item) => item.name.toLowerCase().startsWith(inputValue.toLowerCase()) ) ); }, + [ + props.items, + currentlyInContextQuery, + nestedContextProvider, + inQueryForContextProvider, + ] + ); + + const onSelectedItemChangeCallback = useCallback( + ({ selectedItem }: any) => { + if (!selectedItem) return; + if (selectedItem.id) { + // Get the query from the input value + const segs = downshiftProps.inputValue.split("@"); + const query = segs[segs.length - 1]; + + // Tell server the context item was selected + client?.selectContextItem(selectedItem.id, query); + if (downshiftProps.inputValue.includes("@")) { + const selectedNestedContextProvider = contextProviders.find( + (provider) => provider.title === selectedItem.id + ); + if ( + !nestedContextProvider && + !selectedNestedContextProvider?.dynamic + ) { + downshiftProps.setInputValue(`@${selectedItem.id} `); + setNestedContextProvider(selectedNestedContextProvider); + } else { + downshiftProps.setInputValue(""); + } + } + } + }, + [nestedContextProvider, contextProviders, client] + ); + + const getFilteredContextItemsForProvider = async ( + provider: string, + query: string + ) => { + // Only return context items from the current workspace - the index is currently shared between all sessions + const workspaceFilter = + workspacePaths && workspacePaths.length > 0 + ? `workspace_dir IN [ ${workspacePaths + .map((path) => `"${path}"`) + .join(", ")} ] AND provider_name = '${provider}'` + : undefined; + try { + const res = await searchClient.index(SEARCH_INDEX_NAME).search(query, { + filter: workspaceFilter, + }); + return ( + res?.hits.map((hit) => { + return { + name: hit.name, + description: hit.description, + id: hit.id, + content: hit.content, + }; + }) || [] + ); + } catch (e) { + console.log("Error searching context items", e); + return []; + } + }; + + const { getInputProps, ...downshiftProps } = useCombobox({ + onSelectedItemChange: onSelectedItemChangeCallback, + onInputValueChange: onInputValueChangeCallback, items, itemToString(item) { return item ? item.name : ""; @@ -348,6 +498,42 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { }; }, [inputRef.current]); + const selectContextItemFromDropdown = useCallback( + (event: any) => { + const newProviderName = items[downshiftProps.highlightedIndex].name; + const newProvider = contextProviders.find( + (provider) => provider.display_title === newProviderName + ); + + if (!newProvider) { + (event.nativeEvent as any).preventDownshiftDefault = true; + return; + } else if (newProvider.dynamic && newProvider.requires_query) { + setInQueryForContextProvider(newProvider); + downshiftProps.setInputValue(`@${newProvider.title} `); + (event.nativeEvent as any).preventDownshiftDefault = true; + event.preventDefault(); + return; + } else if (newProvider.dynamic) { + return; + } + + setNestedContextProvider(newProvider); + downshiftProps.setInputValue(`@${newProvider.title} `); + (event.nativeEvent as any).preventDownshiftDefault = true; + event.preventDefault(); + getFilteredContextItemsForProvider(newProvider.title, "").then((items) => + setItems(items) + ); + }, + [ + items, + downshiftProps.highlightedIndex, + contextProviders, + nestedContextProvider, + ] + ); + const showSelectContextGroupDialog = () => { dispatch(setDialogMessage(<SelectContextGroupDialog />)); dispatch(setShowDialog(true)); @@ -409,21 +595,6 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { </HeaderButtonWithText> {props.selectedContextItems.length > 0 && ( <> - <HeaderButtonWithText - text="Bookmark context" - onClick={() => { - showDialogToSaveContextGroup(); - }} - className="pill-button focus:outline-none focus:border-red-600 focus:border focus:border-solid" - onKeyDown={(e: KeyboardEvent) => { - e.preventDefault(); - if (e.key === "Enter") { - showDialogToSaveContextGroup(); - } - }} - > - <BookmarkIcon width="1.4em" height="1.4em" /> - </HeaderButtonWithText> {props.addingHighlightedCode ? ( <EmptyPillDiv onClick={() => { @@ -449,11 +620,33 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { <DocumentPlusIcon width="1.4em" height="1.4em" /> </HeaderButtonWithText> )} + <HeaderButtonWithText + text="Bookmark context" + onClick={() => { + showDialogToSaveContextGroup(); + }} + className="pill-button focus:outline-none focus:border-red-600 focus:border focus:border-solid" + onKeyDown={(e: KeyboardEvent) => { + e.preventDefault(); + if (e.key === "Enter") { + showDialogToSaveContextGroup(); + } + }} + > + <BookmarkIcon width="1.4em" height="1.4em" /> + </HeaderButtonWithText> </> )} </div> - <div className="flex px-2" ref={divRef} hidden={!downshiftProps.isOpen}> + <div + className="flex px-2 relative" + ref={divRef} + hidden={!downshiftProps.isOpen} + > <MainTextInput + inQueryForDynamicProvider={ + typeof inQueryForContextProvider !== "undefined" + } disabled={props.disabled} placeholder={`Ask a question, give instructions, type '/' for slash commands, or '@' to add context`} {...getInputProps({ @@ -467,7 +660,6 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { target.scrollHeight, 300 ).toString()}px`; - setInputBoxHeight(target.style.height); // setShowContextDropdown(target.value.endsWith("@")); }, @@ -487,17 +679,34 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { !isComposing ) { const value = downshiftProps.inputValue; - if (value !== "") { - setPositionInHistory(history.length + 1); - setHistory([...history, value]); - } - // Prevent Downshift's default 'Enter' behavior. - (event.nativeEvent as any).preventDownshiftDefault = true; + if (inQueryForContextProvider) { + const segs = value.split("@"); + client?.selectContextItem( + inQueryForContextProvider.title, + segs[segs.length - 1] + ); + setCurrentlyInContextQuery(false); + downshiftProps.setInputValue(""); + return; + } else { + if (value !== "") { + setPositionInHistory(history.length + 1); + setHistory([...history, value]); + } + // Prevent Downshift's default 'Enter' behavior. + (event.nativeEvent as any).preventDownshiftDefault = true; - if (props.onEnter) { - props.onEnter(event); + if (props.onEnter) { + props.onEnter(event); + } } setCurrentlyInContextQuery(false); + } else if ( + event.key === "Enter" && + currentlyInContextQuery && + nestedContextProvider === undefined + ) { + selectContextItemFromDropdown(event); } else if (event.key === "Tab" && items.length > 0) { downshiftProps.setInputValue(items[0].name); event.preventDefault(); @@ -545,6 +754,16 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { ); setCurrentlyInContextQuery(false); } else if (event.key === "Escape") { + if (nestedContextProvider) { + goBackToContextProviders(); + (event.nativeEvent as any).preventDownshiftDefault = true; + return; + } else if (inQueryForContextProvider) { + goBackToContextProviders(); + (event.nativeEvent as any).preventDownshiftDefault = true; + return; + } + setCurrentlyInContextQuery(false); if (downshiftProps.isOpen && items.length > 0) { downshiftProps.closeMenu(); @@ -570,6 +789,27 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { ref: inputRef, })} /> + {inQueryForContextProvider ? ( + <DynamicQueryTitleDiv> + Enter {inQueryForContextProvider.display_title} Query + </DynamicQueryTitleDiv> + ) : ( + <> + <StyledPlusIcon + width="1.4em" + height="1.4em" + data-tooltip-id="add-context-button" + onClick={() => { + downshiftProps.setInputValue("@"); + inputRef.current?.focus(); + }} + /> + <StyledTooltip id="add-context-button" place="bottom"> + Add Context to Prompt + </StyledTooltip> + </> + )} + <Ul {...downshiftProps.getMenuProps({ ref: ulRef, @@ -578,20 +818,72 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => { ulHeightPixels={ulRef.current?.getBoundingClientRect().height || 0} hidden={!downshiftProps.isOpen || items.length === 0} > + {nestedContextProvider && ( + <div + style={{ + backgroundColor: secondaryDark, + borderBottom: `1px solid ${lightGray}`, + display: "flex", + gap: "4px", + position: "sticky", + top: "0px", + }} + className="py-2 px-4 my-0" + > + <ArrowLeftIcon + width="1.4em" + height="1.4em" + className="cursor-pointer" + onClick={() => { + goBackToContextProviders(); + }} + /> + {nestedContextProvider.display_title} -{" "} + {nestedContextProvider.description} + </div> + )} {downshiftProps.isOpen && items.map((item, index) => ( <Li - style={{ borderTop: index === 0 ? "none" : undefined }} + style={{ + borderTop: index === 0 ? "none" : undefined, + }} key={`${item.name}${index}`} {...downshiftProps.getItemProps({ item, index })} highlighted={downshiftProps.highlightedIndex === index} selected={downshiftProps.selectedItem === item} + onClick={(e) => { + // e.stopPropagation(); + // e.preventDefault(); + // (e.nativeEvent as any).preventDownshiftDefault = true; + // downshiftProps.selectItem(item); + selectContextItemFromDropdown(e); + onSelectedItemChangeCallback({ selectedItem: item }); + }} > <span> {item.name} {" "} - <span style={{ color: lightGray }}>{item.description}</span> + <span + style={{ + color: lightGray, + }} + > + {item.description} + </span> </span> + {contextProviders + .filter( + (provider) => !provider.dynamic || provider.requires_query + ) + .find((provider) => provider.title === item.id) && ( + <ArrowRightIcon + width="1.2em" + height="1.2em" + color={lightGray} + className="ml-2" + /> + )} </Li> ))} </Ul> diff --git a/extension/react-app/src/components/EditableDiv.tsx b/extension/react-app/src/components/EditableDiv.tsx new file mode 100644 index 00000000..a86bd692 --- /dev/null +++ b/extension/react-app/src/components/EditableDiv.tsx @@ -0,0 +1,84 @@ +import styled from "styled-components"; +import { + defaultBorderRadius, + lightGray, + secondaryDark, + vscForeground, +} from "."; + +const Div = styled.div` + resize: none; + + padding: 8px; + font-size: 13px; + font-family: inherit; + border-radius: ${defaultBorderRadius}; + margin: 8px auto; + height: auto; + width: 100%; + background-color: ${secondaryDark}; + color: ${vscForeground}; + z-index: 1; + border: 1px solid transparent; + + &:focus { + outline: 1px solid ${lightGray}; + border: 1px solid transparent; + } + + &::placeholder { + color: ${lightGray}80; + } +`; + +const Span = styled.span<{ color?: string }>` + background-color: ${(props) => props.color || "#2cf8"}; + border-radius: ${defaultBorderRadius}; + padding: 2px 4px; +`; + +interface EditableDivProps { + onChange: (e: any) => void; + value?: string; +} + +function EditableDiv(props: EditableDivProps) { + return ( + <Div + suppressContentEditableWarning={true} + contentEditable={true} + onChange={(e) => { + const target = e.target as HTMLTextAreaElement; + // Update the height of the textarea to match the content, up to a max of 200px. + target.style.height = "auto"; + target.style.height = `${Math.min( + target.scrollHeight, + 300 + ).toString()}px`; + + // setShowContextDropdown(target.value.endsWith("@")); + props.onChange(e); + }} + onKeyDown={(e) => { + // if (e.key === "Delete") { + // // Delete spans if they are last child + // const selection = window.getSelection(); + // const range = selection?.getRangeAt(0); + // const node = range?.startContainer; + // console.log("Del"); + // if (node?.nodeName === "SPAN") { + // console.log("span"); + // const parent = node.parentNode; + // if (parent?.childNodes.length === 1) { + // parent.removeChild(node); + // } + // } + // } + }} + > + {props.value ? props.value : <Span contentEditable={false}>testing</Span>} + </Div> + ); +} + +export default EditableDiv; diff --git a/extension/react-app/src/components/Layout.tsx b/extension/react-app/src/components/Layout.tsx index 17100c7f..6410db8a 100644 --- a/extension/react-app/src/components/Layout.tsx +++ b/extension/react-app/src/components/Layout.tsx @@ -21,8 +21,9 @@ import { Cog6ToothIcon, } from "@heroicons/react/24/outline"; import HeaderButtonWithText from "./HeaderButtonWithText"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useLocation } from "react-router-dom"; import ModelSelect from "./ModelSelect"; +import ProgressBar from "./ProgressBar"; // #region Styled Components const FOOTER_HEIGHT = "1.8em"; @@ -74,6 +75,7 @@ const GridDiv = styled.div` const Layout = () => { const navigate = useNavigate(); + const location = useLocation(); const client = useContext(GUIClientContext); const dispatch = useDispatch(); const dialogMessage = useSelector( @@ -82,10 +84,11 @@ const Layout = () => { const showDialog = useSelector( (state: RootStore) => state.uiState.showDialog ); - const dialogEntryOn = useSelector( - (state: RootStore) => state.uiState.dialogEntryOn - ); + const defaultModel = useSelector( + (state: RootStore) => + (state.serverState.config as any).models?.default?.class_name + ); // #region Selectors const bottomMessage = useSelector( @@ -175,6 +178,17 @@ const Layout = () => { )} <ModelSelect /> + {defaultModel === "MaybeProxyOpenAI" && + (location.pathname === "/settings" || + parseInt(localStorage.getItem("freeTrialCounter") || "0") >= + 125) && ( + <ProgressBar + completed={parseInt( + localStorage.getItem("freeTrialCounter") || "0" + )} + total={250} + /> + )} </div> <HeaderButtonWithText onClick={() => { diff --git a/extension/react-app/src/components/ProgressBar.tsx b/extension/react-app/src/components/ProgressBar.tsx new file mode 100644 index 00000000..b4a2efc9 --- /dev/null +++ b/extension/react-app/src/components/ProgressBar.tsx @@ -0,0 +1,77 @@ +import React from "react"; +import styled from "styled-components"; +import { StyledTooltip, lightGray, vscForeground } from "."; + +const ProgressBarWrapper = styled.div` + width: 100px; + height: 6px; + border-radius: 6px; + border: 0.5px solid ${lightGray}; + margin-top: 6px; +`; + +const ProgressBarFill = styled.div<{ completed: number; color?: string }>` + height: 100%; + background-color: ${(props) => props.color || vscForeground}; + border-radius: inherit; + transition: width 0.2s ease-in-out; + width: ${(props) => props.completed}%; +`; + +const GridDiv = styled.div` + display: grid; + grid-template-rows: 1fr auto; + align-items: center; + justify-items: center; +`; + +const P = styled.p` + margin: 0; + margin-top: 2px; + font-size: 12px; + color: ${lightGray}; + text-align: center; +`; + +interface ProgressBarProps { + completed: number; + total: number; +} + +const ProgressBar = ({ completed, total }: ProgressBarProps) => { + const fillPercentage = Math.min(100, Math.max(0, (completed / total) * 100)); + + return ( + <> + <a + href="https://continue.dev/docs/customization" + className="no-underline" + > + <GridDiv data-tooltip-id="usage_progress_bar"> + <ProgressBarWrapper> + <ProgressBarFill + completed={fillPercentage} + color={ + completed / total > 0.75 + ? completed / total > 0.95 + ? "#f00" + : "#fc0" + : undefined + } + /> + </ProgressBarWrapper> + <P> + Free Usage: {completed} / {total} + </P> + </GridDiv> + </a> + <StyledTooltip id="usage_progress_bar" place="bottom"> + { + "Continue allows you to use our OpenAI API key for up to 250 inputs. After this, you can either use your own API key, or use a local LLM. Click the progress bar to go to the docs and learn more." + } + </StyledTooltip> + </> + ); +}; + +export default ProgressBar; diff --git a/extension/react-app/src/components/index.ts b/extension/react-app/src/components/index.ts index 25e35dd1..1f418c94 100644 --- a/extension/react-app/src/components/index.ts +++ b/extension/react-app/src/components/index.ts @@ -6,8 +6,8 @@ export const lightGray = "#646464"; // export const secondaryDark = "rgb(45 45 45)"; // export const vscBackground = "rgb(30 30 30)"; export const vscBackgroundTransparent = "#1e1e1ede"; -export const buttonColor = "rgb(27 190 132)"; -export const buttonColorHover = "rgb(27 190 132 0.67)"; +export const buttonColor = "#1bbe84"; +export const buttonColorHover = "1bbe84a8"; export const secondaryDark = "var(--vscode-list-hoverBackground)"; export const vscBackground = "var(--vscode-editor-background)"; |