639 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
useMantineTheme,
Flex,
Stack,
ActionIcon,
ScrollArea,
Popover,
Textarea,
Text,
Indicator,
Drawer,
Group,
Button,
Menu,
createStyles,
} from "@mantine/core";
import { useDisclosure, useResizeObserver } from "@mantine/hooks";
import {
IconGitBranch,
IconSend,
IconPaperclip,
IconChevronDown,
IconTextPlus,
IconRobot,
} from "@tabler/icons-react";
import React, { useState, useEffect, useRef } from "react";
import {
IconGitBranchChecked,
IconShellCommand,
} from "@/views/components/ChatIcons";
import messageUtil from "@/util/MessageUtil";
import InputContexts from "./InputContexts";
import Topic from "./Topic";
import AddTopic from "./AddTopic";
import { observer } from "mobx-react-lite";
import { useMst } from "@/views/stores/RootStore";
import { ChatContext } from "@/views/stores/InputStore";
import { Trans, useTranslation } from "react-i18next";
const useStyles = createStyles((theme) => ({
actionIcon: {
color: "var(--vscode-dropdown-foreground)",
borderColor: "var(--vscode-dropdown-border)",
backgroundColor: "var(--vscode-dropdown-background)",
"&:hover": {
color: "var(--vscode-dropdown-foreground)",
borderColor: "var(--vscode-dropdown-border)",
backgroundColor: "var(--vscode-dropdown-background)",
},
"&[data-disabled]": {
borderColor: "transparent",
backgroundColor: "#e9ecef",
color: "#adb5bd",
cursor: "not-allowed",
backgroundImage: "none",
pointervents: "none",
},
},
}));
const InputMessage = observer((props: any) => {
const { classes } = useStyles();
const { input, chat } = useMst();
const { t } = useTranslation();
const {
contexts,
menuOpend,
menuType,
currentMenuIndex,
contextMenus,
commandMenus,
modelMenus,
} = input;
const { generating } = chat;
const showTopic = process.env.platform === "idea";
const viewport = useRef<HTMLDivElement>(null);
const [drawerOpened, { open: openDrawer, close: closeDrawer }] =
useDisclosure(false);
const theme = useMantineTheme();
const [commandMenusNode, setCommandMenusNode] = useState<any>(null);
const [inputRef, inputRect] = useResizeObserver();
const handleInputChange = (event: React.ChangeEvent<HTMLTextAreaElement>) => {
const value = event.target.value;
// if value start with '/' command show menu
if (value.startsWith("/")) {
input.openMenu("commands");
input.setCurrentMenuIndex(0);
} else {
input.closeMenu();
}
input.setValue(value);
};
const handleSendClick = (event: React.MouseEvent<HTMLButtonElement>) => {
const inputValue = input.value;
if (inputValue) {
if (inputValue.trim() === "/help") {
chat.helpMessage();
} else {
const text = inputValue;
const chatContexts = contexts
? [...contexts].map((item) => ({ ...item }))
: undefined;
if (inputValue.trim().startsWith("/ask-code")) {
chat.devchatAsk(text, chatContexts);
} else {
chat.commonMessage(text, chatContexts);
}
}
// Clear the input field
input.setValue("");
input.clearContexts();
// event.preventDefault();
}
};
const handleContextClick = (contextName: string) => {
// Process and send the message to the extension
messageUtil.sendMessage({
command: "addContext",
selected: contextName,
});
};
const handleKeyDown = (event: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (menuOpend) {
if (event.key === "Escape") {
input.closeMenu();
}
if (menuType === "commands") {
if (event.key === "ArrowDown") {
event.preventDefault();
const newIndex = currentMenuIndex + 1;
input.setCurrentMenuIndex(
newIndex < commandMenusNode.length ? newIndex : 0
);
viewport.current
?.querySelectorAll("[data-list-item]")
?.[newIndex]?.scrollIntoView({ block: "nearest" });
}
if (event.key === "ArrowUp") {
event.preventDefault();
const newIndex = currentMenuIndex - 1;
input.setCurrentMenuIndex(
newIndex < 0 ? commandMenusNode.length - 1 : newIndex
);
viewport.current
?.querySelectorAll("[data-list-item]")
?.[newIndex]?.scrollIntoView({ block: "nearest" });
}
if ((event.key === "Enter" || event.key === "Tab") && !event.shiftKey) {
const commandNode = commandMenusNode[currentMenuIndex];
const commandPattern = commandNode.props["data-pattern"];
if (commandPattern === "help") {
chat.helpMessage();
input.setValue("");
} else {
input.setValue(`/${commandPattern} `);
}
input.closeMenu();
event.preventDefault();
}
}
} else {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
handleSendClick(event as any);
}
}
};
const contextMenuIcon = (name: string) => {
if (name === "git diff --cached") {
return (
<IconGitBranchChecked size={14} color="var(--vscode-menu-foreground)" />
);
}
if (name === "git diff HEAD") {
return <IconGitBranch size={14} color="var(--vscode-menu-foreground)" />;
}
return <IconShellCommand size={14} color="var(--vscode-menu-foreground)" />;
};
useEffect(() => {
input.fetchContextMenus().then();
input.fetchCommandMenus().then();
input.fetchModelMenus().then();
messageUtil.registerHandler(
"regCommandList",
(message: { result: object[] }) => {
input.updateCommands(message.result);
}
);
messageUtil.registerHandler(
"chatWithDevChat",
(message: { command: string; message: string }) => {
chat.commonMessage(message.message, []);
}
);
messageUtil.registerHandler(
"appendContext",
(message: { command: string; context: string }) => {
// context is a temp file path
const match = /\|([^]+?)\]/.exec(message.context);
// Process and send the message to the extension
messageUtil.sendMessage({
command: "contextDetail",
file: match && match[1],
});
}
);
messageUtil.registerHandler(
"contextDetailResponse",
(message: { command: string; file: string; result: string }) => {
//result is a content json
// 1. diff json structure
// {
// languageId: languageId,
// path: fileSelected,
// content: codeSelected
// };
// 2. command json structure
// {
// command: commandString,
// content: stdout
// };
const context = JSON.parse(message.result);
if (typeof context !== "undefined" && context) {
const chatContext = ChatContext.create({
file: message.file,
path: context.path,
command: context.command,
content: context.content,
});
input.newContext(chatContext);
}
}
);
inputRef.current.focus();
}, []);
const getModelShowName = (modelName: string) => {
const nameMap = {
"gpt-3.5-turbo": "GPT-3.5",
"gpt-4": "GPT-4",
"gpt-4-1106-preview": "GPT-4-turbo",
"claude-2.1": "CLAUDE 2.1",
};
if (modelName in nameMap) {
return nameMap[modelName];
} else if (modelName.lastIndexOf("/") > -1) {
return modelName
.substring(modelName.lastIndexOf("/") + 1)
.toLocaleUpperCase();
} else {
return modelName.toUpperCase();
}
};
useEffect(() => {
let filtered;
if (input.value) {
filtered = commandMenus.filter((item) =>
`/${item.pattern}`.startsWith(input.value)
);
} else {
filtered = commandMenus;
}
const node = filtered.map(({ pattern, description, name }, index) => {
return (
<Flex
key={`command-menus-${index}`}
data-list-item
mih={40}
gap="md"
justify="flex-start"
align="flex-start"
direction="row"
wrap="wrap"
sx={{
padding: "5px 0",
"&:hover,&[aria-checked=true]": {
cursor: "pointer",
color: "var(--vscode-commandCenter-activeForeground)",
backgroundColor: "var(--vscode-commandCenter-activeBackground)",
},
}}
onClick={() => {
input.setValue(`/${pattern} `);
input.closeMenu();
}}
aria-checked={index === currentMenuIndex}
data-pattern={pattern}
>
<Stack
spacing={0}
sx={{
paddingLeft: 10,
}}
>
<Text
sx={{
fontSize: "sm",
fontWeight: "bolder",
color: "var(--vscode-menu-foreground)",
}}
>
/{pattern}
</Text>
<Text
sx={{
fontSize: "sm",
color: theme.colors.gray[6],
}}
>
<Trans>{description}</Trans>
</Text>
</Stack>
</Flex>
);
});
setCommandMenusNode(node);
if (node.length === 0) {
input.closeMenu();
}
}, [input.value, commandMenus, currentMenuIndex]);
useEffect(() => {
if (drawerOpened && (!contexts || contexts.length === 0)) {
closeDrawer();
}
}, [contexts.length]);
const changeModel = (value) => {
chat.changeChatModel(value);
messageUtil.sendMessage({
command: "updateSetting",
key1: "devchat",
key2: "defaultModel",
value: value,
});
};
const menuStyles = {
arrow: {
borderColor: "var(--vscode-menu-border)",
},
dropdown: {
borderColor: "var(--vscode-menu-border)",
backgroundColor: "var(--vscode-menu-background)",
},
itemLabel: {
color: "var(--vscode-menu-foreground)",
},
item: {
padding: 5,
backgroundColor: "var(--vscode-menu-background)",
"&:hover,&[data-hovered=true]": {
color: "var(--vscode-commandCenter-activeForeground)",
borderColor: "var(--vscode-commandCenter-border)",
backgroundColor: "var(--vscode-commandCenter-activeBackground)",
},
},
};
const buttonStyles = {
root: {
color: "var(--vscode-dropdown-foreground)",
borderColor: "var(--vscode-dropdown-border)",
backgroundColor: "var(--vscode-dropdown-background)",
"&:hover": {
color: "var(--vscode-dropdown-foreground)",
borderColor: "var(--vscode-dropdown-border)",
backgroundColor: "var(--vscode-dropdown-background)",
},
},
};
return (
<Stack
spacing={0}
sx={{
padding: "0 5px",
}}
>
<Group
spacing={5}
sx={{
marginTop: 5,
}}
>
{!showTopic && (
<Menu
width={chat.chatPanelWidth - 10}
position="bottom-start"
shadow="sm"
withArrow
styles={menuStyles}
disabled={contextMenus.length === 0}
>
<Menu.Target>
<ActionIcon
radius="xl"
variant="default"
disabled={generating || chat.disabled}
className={classes.actionIcon}
>
<IconTextPlus size="1rem" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<ScrollArea.Autosize placeholder="" type="always" mah={240}>
{[...contextMenus]
.sort((a, b) => {
if (a.name === "<custom command>") {
return 1; // Placing '<custom command>' at the end
} else if (b.name === "<custom command>") {
return -1; // Placing '<custom command>' at the front
} else {
return (a.name || "").localeCompare(b.name || ""); // Sorting alphabetically for other cases
}
})
.map(({ pattern, description, name }, index) => {
return (
<Menu.Item
key={`contexts-menus-${index}`}
icon={contextMenuIcon(name)}
onClick={() => {
handleContextClick(name);
}}
>
{name}
<Text
sx={{ fontSize: "9pt", color: theme.colors.gray[6] }}
>
{description}
</Text>
</Menu.Item>
);
})}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
)}
<Menu
position="bottom-start"
withArrow
shadow="md"
styles={menuStyles}
disabled={modelMenus.length === 0}
>
<Menu.Target>
<Button
disabled={generating || chat.disabled}
variant="default"
size="xs"
radius="xl"
leftIcon={<IconRobot size="1rem" />}
styles={buttonStyles}
>
{getModelShowName(chat.chatModel)}
</Button>
</Menu.Target>
<Menu.Dropdown>
<ScrollArea.Autosize placeholder="" type="always" mah={240}>
{modelMenus.map((modelName) => {
return (
<Menu.Item
key={modelName}
onClick={() => changeModel(modelName)}
>
{getModelShowName(modelName)}
</Menu.Item>
);
})}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
{showTopic && (
<>
<AddTopic
buttonStyles={buttonStyles}
disabled={generating || chat.disabled}
/>
<Topic
styleName={classes.actionIcon}
disabled={generating || chat.disabled}
/>
</>
)}
</Group>
{contexts && contexts.length > 0 && (
<Drawer
opened={drawerOpened}
onClose={closeDrawer}
position="bottom"
title="DevChat Contexts"
overlayProps={{ opacity: 0.5, blur: 4 }}
closeButtonProps={{ children: <IconChevronDown size="1rem" /> }}
styles={{
content: {
background: "var(--vscode-sideBar-background)",
color: "var(--vscode-editor-foreground)",
},
header: {
background: "var(--vscode-sideBar-background)",
color: "var(--vscode-editor-foreground)",
},
}}
>
<InputContexts />
</Drawer>
)}
<Popover
position="top-start"
shadow="sm"
width={chat.chatPanelWidth - 10}
opened={menuOpend}
onChange={() => {
input.closeMenu();
inputRef.current.focus();
}}
>
<Popover.Target>
<Textarea
id="chat-textarea"
disabled={generating || chat.disabled}
value={input.value}
ref={inputRef}
onKeyDown={handleKeyDown}
onChange={handleInputChange}
autosize
minRows={1}
maxRows={10}
radius="md"
size="xs"
sx={{
pointerEvents: "all",
marginTop: 5,
marginBottom: 5,
}}
placeholder={t("Ask DevChat a question or type / for workflow")}
styles={{
rightSection: {
alignItems: "flex-end",
marginBottom: "6px",
marginRight: contexts.length > 0 ? "24px" : "10px",
},
input: {
fontSize: "var(--vscode-editor-font-size)",
backgroundColor: "var(--vscode-input-background)",
borderColor: "var(--vscode-input-border)",
color: "var(--vscode-input-foreground)",
"&[data-disabled]": {
color: "var(--vscode-disabledForeground)",
},
},
}}
rightSection={
<>
{contexts.length > 0 && (
<Indicator label={contexts.length} size={12}>
<ActionIcon
size="md"
radius="md"
variant="default"
disabled={generating || chat.disabled}
onClick={openDrawer}
className={classes.actionIcon}
sx={{
pointerEvents: "all",
"&[data-disabled]": {
borderColor: "var(--vscode-input-border)",
backgroundColor:
"var(--vscode-toolbar-activeBackground)",
},
}}
>
<IconPaperclip size="1rem" />
</ActionIcon>
</Indicator>
)}
<ActionIcon
size="md"
radius="md"
variant="default"
disabled={generating || chat.disabled}
onClick={handleSendClick}
className={classes.actionIcon}
sx={{
marginLeft: "10px",
pointerEvents: "all",
backgroundColor: "#ED6A45",
border: "0",
color: "#FFFFFF",
"&:hover": {
backgroundColor: "#ED6A45",
color: "#FFFFFF",
opacity: 0.7,
},
}}
>
<IconSend size="1rem" />
</ActionIcon>
</>
}
/>
</Popover.Target>
<Popover.Dropdown
sx={{
padding: 0,
color: "var(--vscode-menu-foreground)",
borderColor: "var(--vscode-menu-border)",
backgroundColor: "var(--vscode-menu-background)",
}}
>
<Text sx={{ padding: "5px 5px 5px 10px" }}>DevChat Workflows</Text>
<ScrollArea.Autosize
mah={240}
type="always"
placeholder=""
viewportRef={viewport}
>
{commandMenusNode}
</ScrollArea.Autosize>
</Popover.Dropdown>
</Popover>
</Stack>
);
});
export default InputMessage;