summaryrefslogtreecommitdiff
path: root/extension/react-app/src
diff options
context:
space:
mode:
Diffstat (limited to 'extension/react-app/src')
-rw-r--r--extension/react-app/src/App.tsx8
-rw-r--r--extension/react-app/src/components/ComboBox.tsx102
-rw-r--r--extension/react-app/src/components/InputAndButton.tsx10
-rw-r--r--extension/react-app/src/components/Onboarding.tsx19
-rw-r--r--extension/react-app/src/components/PillButton.tsx172
-rw-r--r--extension/react-app/src/components/StepContainer.tsx116
-rw-r--r--extension/react-app/src/components/TextDialog.tsx71
-rw-r--r--extension/react-app/src/components/index.ts23
-rw-r--r--extension/react-app/src/hooks/AbstractContinueGUIClientProtocol.ts35
-rw-r--r--extension/react-app/src/hooks/ContinueGUIClientProtocol.ts93
-rw-r--r--extension/react-app/src/hooks/useContinueGUIProtocol.ts91
-rw-r--r--extension/react-app/src/hooks/useWebsocket.ts2
-rw-r--r--extension/react-app/src/index.css4
-rw-r--r--extension/react-app/src/pages/gui.tsx84
-rw-r--r--extension/react-app/src/util/index.ts43
15 files changed, 482 insertions, 391 deletions
diff --git a/extension/react-app/src/App.tsx b/extension/react-app/src/App.tsx
index c9bd42e0..aa462171 100644
--- a/extension/react-app/src/App.tsx
+++ b/extension/react-app/src/App.tsx
@@ -2,7 +2,7 @@ import DebugPanel from "./components/DebugPanel";
import GUI from "./pages/gui";
import { createContext } from "react";
import useContinueGUIProtocol from "./hooks/useWebsocket";
-import ContinueGUIClientProtocol from "./hooks/useContinueGUIProtocol";
+import ContinueGUIClientProtocol from "./hooks/ContinueGUIClientProtocol";
export const GUIClientContext = createContext<
ContinueGUIClientProtocol | undefined
@@ -13,11 +13,7 @@ function App() {
return (
<GUIClientContext.Provider value={client}>
- <DebugPanel
- tabs={[
- { element: <GUI />, title: "GUI" }
- ]}
- />
+ <DebugPanel tabs={[{ element: <GUI />, title: "GUI" }]} />
</GUIClientContext.Provider>
);
}
diff --git a/extension/react-app/src/components/ComboBox.tsx b/extension/react-app/src/components/ComboBox.tsx
index 7d6541c7..1e2ca135 100644
--- a/extension/react-app/src/components/ComboBox.tsx
+++ b/extension/react-app/src/components/ComboBox.tsx
@@ -1,30 +1,20 @@
-import React, {
- useCallback,
- useEffect,
- useImperativeHandle,
- useState,
-} from "react";
+import React, { useEffect, useImperativeHandle, useState } from "react";
import { useCombobox } from "downshift";
import styled from "styled-components";
import {
- buttonColor,
defaultBorderRadius,
lightGray,
secondaryDark,
vscBackground,
+ vscForeground,
} from ".";
import CodeBlock from "./CodeBlock";
-import { RangeInFile } from "../../../src/client";
import PillButton from "./PillButton";
import HeaderButtonWithText from "./HeaderButtonWithText";
-import {
- Trash,
- LockClosed,
- LockOpen,
- Plus,
- DocumentPlus,
-} from "@styled-icons/heroicons-outline";
+import { DocumentPlus } from "@styled-icons/heroicons-outline";
import { HighlightedRangeContext } from "../../../schema/FullState";
+import { postVscMessage } from "../vscode";
+import { getMetaKeyLabel } from "../util";
// #region styled components
const mainInputFontSize = 13;
@@ -48,21 +38,6 @@ const EmptyPillDiv = styled.div`
}
`;
-const ContextDropdown = styled.div`
- position: absolute;
- padding: 4px;
- width: calc(100% - 16px - 8px);
- background-color: ${secondaryDark};
- color: white;
- border-bottom-right-radius: ${defaultBorderRadius};
- border-bottom-left-radius: ${defaultBorderRadius};
- /* border: 1px solid white; */
- border-top: none;
- margin: 8px;
- outline: 1px solid orange;
- z-index: 5;
-`;
-
const MainTextInput = styled.textarea`
resize: none;
@@ -74,7 +49,7 @@ const MainTextInput = styled.textarea`
height: auto;
width: 100%;
background-color: ${secondaryDark};
- color: white;
+ color: ${vscForeground};
z-index: 1;
border: 1px solid transparent;
@@ -96,15 +71,15 @@ const Ul = styled.ul<{
: `transform: translateY(${2 * mainInputFontSize}px);`}
position: absolute;
background: ${vscBackground};
- background-color: ${secondaryDark};
- color: white;
+ color: ${vscForeground};
max-height: ${UlMaxHeight}px;
+ width: calc(100% - 16px);
overflow-y: scroll;
overflow-x: hidden;
padding: 0;
${({ hidden }) => hidden && "display: none;"}
border-radius: ${defaultBorderRadius};
- border: 0.5px solid gray;
+ outline: 0.5px solid gray;
z-index: 2;
// Get rid of scrollbar and its padding
scrollbar-width: none;
@@ -120,6 +95,7 @@ const Li = styled.li<{
selected: boolean;
isLastItem: boolean;
}>`
+ background-color: ${vscBackground};
${({ highlighted }) => highlighted && "background: #ff000066;"}
${({ selected }) => selected && "font-weight: bold;"}
padding: 0.5rem 0.75rem;
@@ -149,10 +125,6 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => {
// The position of the current command you are typing now, so the one that will be appended to history once you press enter
const [positionInHistory, setPositionInHistory] = React.useState<number>(0);
const [items, setItems] = React.useState(props.items);
- const [hoveringButton, setHoveringButton] = React.useState(false);
- const [hoveringContextDropdown, setHoveringContextDropdown] =
- React.useState(false);
- const [pinned, setPinned] = useState(false);
const [highlightedCodeSections, setHighlightedCodeSections] = React.useState(
props.highlightedCodeSections || []
);
@@ -181,6 +153,7 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => {
useImperativeHandle(ref, () => downshiftProps, [downshiftProps]);
const [metaKeyPressed, setMetaKeyPressed] = useState(false);
+ const [focused, setFocused] = useState(false);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Meta") {
@@ -241,6 +214,14 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => {
)} */}
{highlightedCodeSections.map((section, idx) => (
<PillButton
+ warning={
+ section.range.contents.length > 4000 && section.editing
+ ? "Editing such a large range may be slow"
+ : undefined
+ }
+ onlyShowDelete={
+ highlightedCodeSections.length <= 1 || section.editing
+ }
editing={section.editing}
pinned={section.pinned}
index={idx}
@@ -258,15 +239,6 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => {
return newSections;
});
}}
- onHover={(val: boolean) => {
- if (val) {
- setHoveringButton(val);
- } else {
- setTimeout(() => {
- setHoveringButton(val);
- }, 100);
- }
- }}
/>
))}
{props.highlightedCodeSections.length > 0 &&
@@ -276,11 +248,11 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => {
props.onToggleAddContext();
}}
>
- Highlight to Add Context
+ Highlight code section
</EmptyPillDiv>
) : (
<HeaderButtonWithText
- text="Add to Context"
+ text="Add more code to context"
onClick={() => {
props.onToggleAddContext();
}}
@@ -292,7 +264,7 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => {
<div className="flex px-2" ref={divRef} hidden={!downshiftProps.isOpen}>
<MainTextInput
disabled={props.disabled}
- placeholder="Ask a question, give instructions, or type '/' to see slash commands. ⌘⏎ to edit."
+ placeholder={`Ask a question, give instructions, or type '/' to see slash commands`}
{...getInputProps({
onChange: (e) => {
const target = e.target as HTMLTextAreaElement;
@@ -305,6 +277,13 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => {
// setShowContextDropdown(target.value.endsWith("@"));
},
+ onFocus: (e) => {
+ setFocused(true);
+ },
+ onBlur: (e) => {
+ setFocused(false);
+ postVscMessage("blurContinueInput", {});
+ },
onKeyDown: (event) => {
if (event.key === "Enter" && event.shiftKey) {
// Prevent Downshift's default 'Enter' behavior.
@@ -359,6 +338,7 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => {
})}
showAbove={showAbove()}
ulHeightPixels={ulRef.current?.getBoundingClientRect().height || 0}
+ hidden={!downshiftProps.isOpen || items.length === 0}
>
{downshiftProps.isOpen &&
items.map((item, index) => (
@@ -378,29 +358,13 @@ const ComboBox = React.forwardRef((props: ComboBoxProps, ref) => {
</div>
{highlightedCodeSections.length === 0 &&
(downshiftProps.inputValue?.startsWith("/edit") ||
- (metaKeyPressed && downshiftProps.inputValue?.length > 0)) && (
+ (focused &&
+ metaKeyPressed &&
+ downshiftProps.inputValue?.length > 0)) && (
<div className="text-trueGray-400 pr-4 text-xs text-right">
Inserting at cursor
</div>
)}
- <ContextDropdown
- onMouseEnter={() => {
- setHoveringContextDropdown(true);
- }}
- onMouseLeave={() => {
- setHoveringContextDropdown(false);
- }}
- hidden={true || (!hoveringContextDropdown && !hoveringButton)}
- >
- {highlightedCodeSections.map((section, idx) => (
- <>
- <p>{section.display_name}</p>
- <CodeBlock showCopy={false} key={idx}>
- {section.range.contents}
- </CodeBlock>
- </>
- ))}
- </ContextDropdown>
</>
);
});
diff --git a/extension/react-app/src/components/InputAndButton.tsx b/extension/react-app/src/components/InputAndButton.tsx
index 0a8592f2..8019d014 100644
--- a/extension/react-app/src/components/InputAndButton.tsx
+++ b/extension/react-app/src/components/InputAndButton.tsx
@@ -1,6 +1,6 @@
import React, { useRef } from "react";
import styled from "styled-components";
-import { vscBackground } from ".";
+import { vscBackground, vscForeground } from ".";
interface InputAndButtonProps {
onUserInput: (input: string) => void;
@@ -16,7 +16,7 @@ const Input = styled.input`
padding: 0.5rem;
border: 1px solid white;
background-color: ${vscBackground};
- color: white;
+ color: ${vscForeground};
border-radius: 4px;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
@@ -27,7 +27,7 @@ const Button = styled.button`
padding: 0.5rem;
border: 1px solid white;
background-color: ${vscBackground};
- color: white;
+ color: ${vscForeground};
border-radius: 4px;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
@@ -35,8 +35,8 @@ const Button = styled.button`
cursor: pointer;
&:hover {
- background-color: white;
- color: black;
+ background-color: ${vscForeground};
+ color: ${vscBackground};
}
`;
diff --git a/extension/react-app/src/components/Onboarding.tsx b/extension/react-app/src/components/Onboarding.tsx
index 7772a25e..231c1e93 100644
--- a/extension/react-app/src/components/Onboarding.tsx
+++ b/extension/react-app/src/components/Onboarding.tsx
@@ -22,22 +22,17 @@ const StyledSpan = styled.span`
&:hover {
background-color: #ffffff33;
}
+ white-space: nowrap;
`;
const Onboarding = () => {
const [counter, setCounter] = useState(4);
- const gifs = ["intro", "explain", "edit", "generate", "intro"];
+ const gifs = ["intro", "highlight", "question", "help"];
const topMessages = [
- "Welcome to Continue!",
- "Answer coding questions",
- "Edit in natural language",
- "Generate files from scratch",
- ];
- const bottomMessages = [
- "",
- "Ask Continue about a part of your code to get another perspective",
- "Highlight a section of code and instruct Continue to refactor it",
- "Let Continue build the scaffolding of Python scripts, React components, and more",
+ "Welcome!",
+ "Highlight code",
+ "Ask a question",
+ "Use /help to learn more",
];
useEffect(() => {
@@ -107,7 +102,6 @@ const Onboarding = () => {
/>
)}
</div>
- <p>{bottomMessages[counter]}</p>
<p
style={{
paddingLeft: "50px",
@@ -115,6 +109,7 @@ const Onboarding = () => {
paddingBottom: "50px",
textAlign: "center",
cursor: "pointer",
+ whiteSpace: "nowrap",
}}
>
<StyledSpan
diff --git a/extension/react-app/src/components/PillButton.tsx b/extension/react-app/src/components/PillButton.tsx
index 31d98c0f..5929d06a 100644
--- a/extension/react-app/src/components/PillButton.tsx
+++ b/extension/react-app/src/components/PillButton.tsx
@@ -3,15 +3,20 @@ import styled from "styled-components";
import {
StyledTooltip,
defaultBorderRadius,
- lightGray,
secondaryDark,
+ vscBackground,
+ vscForeground,
} from ".";
-import { Trash, PaintBrush, MapPin } from "@styled-icons/heroicons-outline";
+import {
+ Trash,
+ PaintBrush,
+ ExclamationTriangle,
+} from "@styled-icons/heroicons-outline";
import { GUIClientContext } from "../App";
const Button = styled.button`
border: none;
- color: white;
+ color: ${vscForeground};
background-color: ${secondaryDark};
border-radius: ${defaultBorderRadius};
padding: 8px;
@@ -28,10 +33,8 @@ const GridDiv = styled.div`
height: 100%;
display: grid;
grid-gap: 0;
- grid-template-columns: 1fr 1fr;
align-items: center;
border-radius: ${defaultBorderRadius};
- overflow: hidden;
background-color: ${secondaryDark};
`;
@@ -48,6 +51,21 @@ const ButtonDiv = styled.div<{ backgroundColor: string }>`
}
`;
+const CircleDiv = styled.div`
+ position: absolute;
+ top: -10px;
+ right: -10px;
+ width: 20px;
+ height: 20px;
+ border-radius: 50%;
+ background-color: red;
+ color: white;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 2px;
+`;
+
interface PillButtonProps {
onHover?: (arg0: boolean) => void;
onDelete?: () => void;
@@ -55,6 +73,8 @@ interface PillButtonProps {
index: number;
editing: boolean;
pinned: boolean;
+ warning?: string;
+ onlyShowDelete?: boolean;
}
const PillButton = (props: PillButtonProps) => {
@@ -63,75 +83,103 @@ const PillButton = (props: PillButtonProps) => {
return (
<>
- <Button
- style={{
- position: "relative",
- borderColor: props.editing
- ? "#8800aa"
- : props.pinned
- ? "#ffff0099"
- : "transparent",
- borderWidth: "1px",
- borderStyle: "solid",
- }}
- onMouseEnter={() => {
- setIsHovered(true);
- if (props.onHover) {
- props.onHover(true);
- }
- }}
- onMouseLeave={() => {
- setIsHovered(false);
- if (props.onHover) {
- props.onHover(false);
- }
- }}
- >
- {isHovered && (
- <GridDiv>
- <ButtonDiv
- data-tooltip-id={`edit-${props.index}`}
- backgroundColor={"#8800aa55"}
- onClick={() => {
- client?.setEditingAtIndices([props.index]);
+ <div style={{ position: "relative" }}>
+ <Button
+ style={{
+ position: "relative",
+ borderColor: props.warning
+ ? "red"
+ : props.editing
+ ? "#8800aa"
+ : props.pinned
+ ? "#ffff0099"
+ : "transparent",
+ borderWidth: "1px",
+ borderStyle: "solid",
+ }}
+ onMouseEnter={() => {
+ setIsHovered(true);
+ if (props.onHover) {
+ props.onHover(true);
+ }
+ }}
+ onMouseLeave={() => {
+ setIsHovered(false);
+ if (props.onHover) {
+ props.onHover(false);
+ }
+ }}
+ >
+ {isHovered && (
+ <GridDiv
+ style={{
+ gridTemplateColumns: props.onlyShowDelete ? "1fr" : "1fr 1fr",
+ backgroundColor: vscBackground,
}}
>
- <PaintBrush style={{ margin: "auto" }} width="1.6em"></PaintBrush>
- </ButtonDiv>
+ {props.onlyShowDelete || (
+ <ButtonDiv
+ data-tooltip-id={`edit-${props.index}`}
+ backgroundColor={"#8800aa55"}
+ onClick={() => {
+ client?.setEditingAtIndices([props.index]);
+ }}
+ >
+ <PaintBrush
+ style={{ margin: "auto" }}
+ width="1.6em"
+ ></PaintBrush>
+ </ButtonDiv>
+ )}
- {/* <ButtonDiv
+ {/* <ButtonDiv
data-tooltip-id={`pin-${props.index}`}
backgroundColor={"#ffff0055"}
onClick={() => {
client?.setPinnedAtIndices([props.index]);
}}
- >
+ >
<MapPin style={{ margin: "auto" }} width="1.6em"></MapPin>
</ButtonDiv> */}
- <StyledTooltip id={`pin-${props.index}`}>
- Edit this range
+ <StyledTooltip id={`pin-${props.index}`}>
+ Edit this range
+ </StyledTooltip>
+ <ButtonDiv
+ data-tooltip-id={`delete-${props.index}`}
+ backgroundColor={"#cc000055"}
+ onClick={() => {
+ if (props.onDelete) {
+ props.onDelete();
+ }
+ }}
+ >
+ <Trash style={{ margin: "auto" }} width="1.6em"></Trash>
+ </ButtonDiv>
+ </GridDiv>
+ )}
+ {props.title}
+ </Button>
+ <StyledTooltip id={`edit-${props.index}`}>
+ {props.editing
+ ? "Editing this section (with entire file as context)"
+ : "Edit this section"}
+ </StyledTooltip>
+ <StyledTooltip id={`delete-${props.index}`}>Delete</StyledTooltip>
+ {props.warning && (
+ <>
+ <CircleDiv data-tooltip-id={`circle-div-${props.title}`}>
+ <ExclamationTriangle
+ style={{ margin: "auto" }}
+ width="1.0em"
+ strokeWidth={2}
+ />
+ </CircleDiv>
+ <StyledTooltip id={`circle-div-${props.title}`}>
+ {props.warning}
</StyledTooltip>
- <ButtonDiv
- data-tooltip-id={`delete-${props.index}`}
- backgroundColor={"#cc000055"}
- onClick={() => {
- if (props.onDelete) {
- props.onDelete();
- }
- }}
- >
- <Trash style={{ margin: "auto" }} width="1.6em"></Trash>
- </ButtonDiv>
- </GridDiv>
+ </>
)}
- {props.title}
- </Button>
- <StyledTooltip id={`edit-${props.index}`}>
- {props.editing
- ? "Editing this range (with rest of file as context)"
- : "Edit this range"}
- </StyledTooltip>
- <StyledTooltip id={`delete-${props.index}`}>Delete</StyledTooltip>
+ </div>
</>
);
};
diff --git a/extension/react-app/src/components/StepContainer.tsx b/extension/react-app/src/components/StepContainer.tsx
index d1a8a46a..bc8665fd 100644
--- a/extension/react-app/src/components/StepContainer.tsx
+++ b/extension/react-app/src/components/StepContainer.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useContext, useEffect, useRef, useState } from "react";
import styled, { keyframes } from "styled-components";
import {
appear,
@@ -6,18 +6,21 @@ import {
secondaryDark,
vscBackground,
vscBackgroundTransparent,
+ vscForeground,
} from ".";
import {
ChevronDown,
ChevronRight,
ArrowPath,
XMark,
+ MagnifyingGlass,
} from "@styled-icons/heroicons-outline";
import { StopCircle } from "@styled-icons/heroicons-solid";
import { HistoryNode } from "../../../schema/HistoryNode";
-import ReactMarkdown from "react-markdown";
import HeaderButtonWithText from "./HeaderButtonWithText";
-import CodeBlock from "./CodeBlock";
+import MarkdownPreview from "@uiw/react-markdown-preview";
+import { getMetaKeyLabel, isMetaEquivalentKeyPressed } from "../util";
+import { GUIClientContext } from "../App";
interface StepContainerProps {
historyNode: HistoryNode;
@@ -31,6 +34,7 @@ interface StepContainerProps {
onToggle: () => void;
isFirst: boolean;
isLast: boolean;
+ index: number;
}
// #region styled components
@@ -38,7 +42,6 @@ interface StepContainerProps {
const MainDiv = styled.div<{ stepDepth: number; inFuture: boolean }>`
opacity: ${(props) => (props.inFuture ? 0.3 : 1)};
animation: ${appear} 0.3s ease-in-out;
- /* padding-left: ${(props) => props.stepDepth * 20}px; */
overflow: hidden;
margin-left: 0px;
margin-right: 0px;
@@ -52,12 +55,7 @@ const StepContainerDiv = styled.div<{ open: boolean }>`
`;
const HeaderDiv = styled.div<{ error: boolean; loading: boolean }>`
- background-color: ${(props) =>
- props.error
- ? "#522"
- : props.loading
- ? vscBackgroundTransparent
- : vscBackground};
+ background-color: ${(props) => (props.error ? "#522" : vscBackground)};
display: grid;
grid-template-columns: 1fr auto auto;
grid-gap: 8px;
@@ -72,19 +70,6 @@ const ContentDiv = styled.div<{ isUserInput: boolean }>`
font-size: 13px;
`;
-const MarkdownPre = styled.pre`
- background-color: ${secondaryDark};
- padding: 10px;
- border-radius: ${defaultBorderRadius};
- border: 0.5px solid white;
-`;
-
-const StyledCode = styled.code`
- word-wrap: break-word;
- color: #f69292;
- background: transparent;
-`;
-
const gradient = keyframes`
0% {
background-position: 0px 0;
@@ -124,6 +109,33 @@ const GradientBorder = styled.div<{
background-size: 200% 200%;
`;
+const StyledMarkdownPreview = styled(MarkdownPreview)`
+ pre {
+ background-color: ${secondaryDark};
+ padding: 1px;
+ border-radius: ${defaultBorderRadius};
+ border: 0.5px solid white;
+ }
+
+ code {
+ color: #f78383;
+ word-wrap: break-word;
+ border-radius: ${defaultBorderRadius};
+ background-color: ${secondaryDark};
+ }
+
+ pre > code {
+ background-color: ${secondaryDark};
+ color: ${vscForeground};
+ }
+
+ background-color: ${vscBackground};
+ font-family: "Lexend", sans-serif;
+ font-size: 13px;
+ padding: 8px;
+ color: ${vscForeground};
+`;
+
// #endregion
function StepContainer(props: StepContainerProps) {
@@ -131,6 +143,7 @@ function StepContainer(props: StepContainerProps) {
const naturalLanguageInputRef = useRef<HTMLTextAreaElement>(null);
const userInputRef = useRef<HTMLInputElement>(null);
const isUserInput = props.historyNode.step.name === "UserInputStep";
+ const client = useContext(GUIClientContext);
useEffect(() => {
if (userInputRef?.current) {
@@ -158,7 +171,7 @@ function StepContainer(props: StepContainerProps) {
>
<StepContainerDiv open={props.open}>
<GradientBorder
- loading={props.historyNode.active as boolean || false}
+ loading={(props.historyNode.active as boolean) || false}
isFirst={props.isFirst}
isLast={props.isLast}
borderColor={
@@ -170,7 +183,7 @@ function StepContainer(props: StepContainerProps) {
}
className="overflow-hidden cursor-pointer"
onClick={(e) => {
- if (e.metaKey) {
+ if (isMetaEquivalentKeyPressed(e)) {
props.onToggleAll();
} else {
props.onToggle();
@@ -178,7 +191,7 @@ function StepContainer(props: StepContainerProps) {
}}
>
<HeaderDiv
- loading={props.historyNode.active as boolean || false}
+ loading={(props.historyNode.active as boolean) || false}
error={props.historyNode.observation?.error ? true : false}
>
<div className="m-2">
@@ -201,12 +214,27 @@ function StepContainer(props: StepContainerProps) {
</HeaderButton> */}
<>
+ {(props.historyNode.logs as any)?.length > 0 && (
+ <HeaderButtonWithText
+ text="Logs"
+ onClick={(e) => {
+ e.stopPropagation();
+ client?.showLogsAtIndex(props.index);
+ }}
+ >
+ <MagnifyingGlass size="1.4em" />
+ </HeaderButtonWithText>
+ )}
<HeaderButtonWithText
onClick={(e) => {
e.stopPropagation();
props.onDelete();
}}
- text={props.historyNode.active ? "Stop (⌘⌫)" : "Delete"}
+ text={
+ props.historyNode.active
+ ? `Stop (${getMetaKeyLabel()}⌫)`
+ : "Delete"
+ }
>
{props.historyNode.active ? (
<StopCircle size="1.6em" onClick={props.onDelete} />
@@ -242,31 +270,19 @@ function StepContainer(props: StepContainerProps) {
)}
{props.historyNode.observation?.error ? (
- <pre className="overflow-x-scroll">
- {props.historyNode.observation.error as string}
- </pre>
+ <details>
+ <summary>View Traceback</summary>
+ <pre className="overflow-x-scroll">
+ {props.historyNode.observation.error as string}
+ </pre>
+ </details>
) : (
- <ReactMarkdown
- key={1}
- className="overflow-x-scroll"
- components={{
- pre: ({ node, ...props }) => {
- return (
- <CodeBlock
- children={(props.children[0] as any).props.children[0]}
- />
- );
- },
- code: ({ node, ...props }) => {
- return <StyledCode children={props.children[0] as any} />;
- },
- ul: ({ node, ...props }) => {
- return <ul className="ml-0" {...props} />;
- },
+ <StyledMarkdownPreview
+ source={props.historyNode.step.description || ""}
+ wrapperElement={{
+ "data-color-mode": "dark",
}}
- >
- {props.historyNode.step.description as any}
- </ReactMarkdown>
+ />
)}
</ContentDiv>
</StepContainerDiv>
diff --git a/extension/react-app/src/components/TextDialog.tsx b/extension/react-app/src/components/TextDialog.tsx
index ea5727f0..9597b578 100644
--- a/extension/react-app/src/components/TextDialog.tsx
+++ b/extension/react-app/src/components/TextDialog.tsx
@@ -1,7 +1,9 @@
// Write a component that displays a dialog box with a text field and a button.
import React, { useEffect, useState } from "react";
import styled from "styled-components";
-import { Button, buttonColor, secondaryDark, vscBackground } from ".";
+import { Button, secondaryDark, vscBackground, vscForeground } from ".";
+import { isMetaEquivalentKeyPressed } from "../util";
+import { ReactMarkdown } from "react-markdown/lib/react-markdown";
const ScreenCover = styled.div`
position: absolute;
@@ -20,13 +22,13 @@ const DialogContainer = styled.div`
`;
const Dialog = styled.div`
- background-color: white;
+ color: ${vscForeground};
+ background-color: ${vscBackground};
border-radius: 8px;
padding: 8px;
display: flex;
flex-direction: column;
- /* box-shadow: 0 0 10px 0 rgba(255, 255, 255, 0.5); */
- border: 2px solid ${buttonColor};
+ box-shadow: 0 0 10px 0 ${vscForeground};
width: fit-content;
margin: auto;
`;
@@ -37,14 +39,16 @@ const TextArea = styled.textarea`
padding: 8px;
outline: 1px solid black;
resize: none;
+ background-color: ${secondaryDark};
+ color: ${vscForeground};
&:focus {
- outline: 1px solid ${buttonColor};
+ outline: 1px solid ${vscForeground};
}
`;
const P = styled.p`
- color: black;
+ color: ${vscForeground};
margin: 8px auto;
`;
@@ -53,6 +57,7 @@ const TextDialog = (props: {
onEnter: (text: string) => void;
onClose: () => void;
message?: string;
+ entryOn?: boolean;
}) => {
const [text, setText] = useState("");
const textAreaRef = React.createRef<HTMLTextAreaElement>();
@@ -76,29 +81,37 @@ const TextDialog = (props: {
}}
>
<Dialog>
- <P>{props.message || ""}</P>
- <TextArea
- rows={10}
- ref={textAreaRef}
- onKeyDown={(e) => {
- if (e.key === "Enter" && e.metaKey && textAreaRef.current) {
- props.onEnter(textAreaRef.current.value);
- setText("");
- } else if (e.key === "Escape") {
- props.onClose();
- }
- }}
- />
- <Button
- onClick={() => {
- if (textAreaRef.current) {
- props.onEnter(textAreaRef.current.value);
- setText("");
- }
- }}
- >
- Enter
- </Button>
+ <ReactMarkdown>{props.message || ""}</ReactMarkdown>
+ {props.entryOn && (
+ <>
+ <TextArea
+ rows={10}
+ ref={textAreaRef}
+ onKeyDown={(e) => {
+ if (
+ e.key === "Enter" &&
+ isMetaEquivalentKeyPressed(e) &&
+ textAreaRef.current
+ ) {
+ props.onEnter(textAreaRef.current.value);
+ setText("");
+ } else if (e.key === "Escape") {
+ props.onClose();
+ }
+ }}
+ />
+ <Button
+ onClick={() => {
+ if (textAreaRef.current) {
+ props.onEnter(textAreaRef.current.value);
+ setText("");
+ }
+ }}
+ >
+ Enter
+ </Button>
+ </>
+ )}
</Dialog>
</DialogContainer>
</ScreenCover>
diff --git a/extension/react-app/src/components/index.ts b/extension/react-app/src/components/index.ts
index 9ae0f097..cb5e7915 100644
--- a/extension/react-app/src/components/index.ts
+++ b/extension/react-app/src/components/index.ts
@@ -3,12 +3,16 @@ import styled, { keyframes } from "styled-components";
export const defaultBorderRadius = "5px";
export const lightGray = "rgb(100 100 100)";
-export const secondaryDark = "rgb(45 45 45)";
-export const vscBackground = "rgb(30 30 30)";
+// export const secondaryDark = "rgb(45 45 45)";
+// export const vscBackground = "rgb(30 30 30)";
export const vscBackgroundTransparent = "#1e1e1ede";
export const buttonColor = "rgb(113 28 59)";
export const buttonColorHover = "rgb(113 28 59 0.67)";
+export const secondaryDark = "var(--vscode-textBlockQuote-background)";
+export const vscBackground = "var(--vscode-editor-background)";
+export const vscForeground = "var(--vscode-editor-foreground)";
+
export const Button = styled.button`
padding: 10px 12px;
margin: 8px 0;
@@ -46,8 +50,8 @@ export const TextArea = styled.textarea`
resize: vertical;
padding: 4px;
- caret-color: white;
- color: white;
+ caret-color: ${vscForeground};
+ color: #{vscForeground};
&:focus {
outline: 1px solid ${buttonColor};
@@ -120,7 +124,7 @@ export const MainTextInput = styled.textarea`
border: 1px solid #ccc;
margin: 8px 8px;
background-color: ${vscBackground};
- color: white;
+ color: ${vscForeground};
outline: 1px solid orange;
resize: none;
`;
@@ -137,8 +141,9 @@ export const appear = keyframes`
`;
export const HeaderButton = styled.button<{ inverted: boolean | undefined }>`
- background-color: ${({ inverted }) => (inverted ? "white" : "transparent")};
- color: ${({ inverted }) => (inverted ? "black" : "white")};
+ background-color: ${({ inverted }) =>
+ inverted ? vscForeground : "transparent"};
+ color: ${({ inverted }) => (inverted ? vscBackground : vscForeground)};
border: none;
border-radius: ${defaultBorderRadius};
@@ -146,7 +151,9 @@ export const HeaderButton = styled.button<{ inverted: boolean | undefined }>`
&:hover {
background-color: ${({ inverted }) =>
- typeof inverted === "undefined" || inverted ? lightGray : "transparent"};
+ typeof inverted === "undefined" || inverted
+ ? secondaryDark
+ : "transparent"};
}
display: flex;
align-items: center;
diff --git a/extension/react-app/src/hooks/AbstractContinueGUIClientProtocol.ts b/extension/react-app/src/hooks/AbstractContinueGUIClientProtocol.ts
new file mode 100644
index 00000000..6c0df8fc
--- /dev/null
+++ b/extension/react-app/src/hooks/AbstractContinueGUIClientProtocol.ts
@@ -0,0 +1,35 @@
+abstract class AbstractContinueGUIClientProtocol {
+ abstract sendMainInput(input: string): void;
+
+ abstract reverseToIndex(index: number): void;
+
+ abstract sendRefinementInput(input: string, index: number): void;
+
+ abstract sendStepUserInput(input: string, index: number): void;
+
+ abstract onStateUpdate(state: any): void;
+
+ abstract onAvailableSlashCommands(
+ callback: (commands: { name: string; description: string }[]) => void
+ ): void;
+
+ abstract changeDefaultModel(model: string): void;
+
+ abstract sendClear(): void;
+
+ abstract retryAtIndex(index: number): void;
+
+ abstract deleteAtIndex(index: number): void;
+
+ abstract deleteContextAtIndices(indices: number[]): void;
+
+ abstract setEditingAtIndices(indices: number[]): void;
+
+ abstract setPinnedAtIndices(indices: number[]): void;
+
+ abstract toggleAddingHighlightedCode(): void;
+
+ abstract showLogsAtIndex(index: number): void;
+}
+
+export default AbstractContinueGUIClientProtocol;
diff --git a/extension/react-app/src/hooks/ContinueGUIClientProtocol.ts b/extension/react-app/src/hooks/ContinueGUIClientProtocol.ts
index a179c2bf..7d6c2a71 100644
--- a/extension/react-app/src/hooks/ContinueGUIClientProtocol.ts
+++ b/extension/react-app/src/hooks/ContinueGUIClientProtocol.ts
@@ -1,33 +1,92 @@
-abstract class AbstractContinueGUIClientProtocol {
- abstract sendMainInput(input: string): void;
+import AbstractContinueGUIClientProtocol from "./AbstractContinueGUIClientProtocol";
+import { Messenger, WebsocketMessenger } from "./messenger";
+import { VscodeMessenger } from "./vscodeMessenger";
- abstract reverseToIndex(index: number): void;
+class ContinueGUIClientProtocol extends AbstractContinueGUIClientProtocol {
+ messenger: Messenger;
+ // Server URL must contain the session ID param
+ serverUrlWithSessionId: string;
- abstract sendRefinementInput(input: string, index: number): void;
+ constructor(
+ serverUrlWithSessionId: string,
+ useVscodeMessagePassing: boolean
+ ) {
+ super();
+ this.serverUrlWithSessionId = serverUrlWithSessionId;
+ this.messenger = useVscodeMessagePassing
+ ? new VscodeMessenger(serverUrlWithSessionId)
+ : new WebsocketMessenger(serverUrlWithSessionId);
+ }
- abstract sendStepUserInput(input: string, index: number): void;
+ sendMainInput(input: string) {
+ this.messenger.send("main_input", { input });
+ }
- abstract onStateUpdate(state: any): void;
+ reverseToIndex(index: number) {
+ this.messenger.send("reverse_to_index", { index });
+ }
- abstract onAvailableSlashCommands(
+ sendRefinementInput(input: string, index: number) {
+ this.messenger.send("refinement_input", { input, index });
+ }
+
+ sendStepUserInput(input: string, index: number) {
+ this.messenger.send("step_user_input", { input, index });
+ }
+
+ onStateUpdate(callback: (state: any) => void) {
+ this.messenger.onMessageType("state_update", (data: any) => {
+ if (data.state) {
+ callback(data.state);
+ }
+ });
+ }
+
+ onAvailableSlashCommands(
callback: (commands: { name: string; description: string }[]) => void
- ): void;
+ ) {
+ this.messenger.onMessageType("available_slash_commands", (data: any) => {
+ if (data.commands) {
+ callback(data.commands);
+ }
+ });
+ }
+
+ changeDefaultModel(model: string) {
+ this.messenger.send("change_default_model", { model });
+ }
- abstract changeDefaultModel(model: string): void;
+ sendClear() {
+ this.messenger.send("clear_history", {});
+ }
- abstract sendClear(): void;
+ retryAtIndex(index: number) {
+ this.messenger.send("retry_at_index", { index });
+ }
- abstract retryAtIndex(index: number): void;
+ deleteAtIndex(index: number) {
+ this.messenger.send("delete_at_index", { index });
+ }
- abstract deleteAtIndex(index: number): void;
+ deleteContextAtIndices(indices: number[]) {
+ this.messenger.send("delete_context_at_indices", { indices });
+ }
- abstract deleteContextAtIndices(indices: number[]): void;
+ setEditingAtIndices(indices: number[]) {
+ this.messenger.send("set_editing_at_indices", { indices });
+ }
- abstract setEditingAtIndices(indices: number[]): void;
+ setPinnedAtIndices(indices: number[]) {
+ this.messenger.send("set_pinned_at_indices", { indices });
+ }
- abstract setPinnedAtIndices(indices: number[]): void;
+ toggleAddingHighlightedCode(): void {
+ this.messenger.send("toggle_adding_highlighted_code", {});
+ }
- abstract toggleAddingHighlightedCode(): void;
+ showLogsAtIndex(index: number): void {
+ this.messenger.send("show_logs_at_index", { index });
+ }
}
-export default AbstractContinueGUIClientProtocol;
+export default ContinueGUIClientProtocol;
diff --git a/extension/react-app/src/hooks/useContinueGUIProtocol.ts b/extension/react-app/src/hooks/useContinueGUIProtocol.ts
deleted file mode 100644
index 2060dd7f..00000000
--- a/extension/react-app/src/hooks/useContinueGUIProtocol.ts
+++ /dev/null
@@ -1,91 +0,0 @@
-import AbstractContinueGUIClientProtocol from "./ContinueGUIClientProtocol";
-// import { Messenger, WebsocketMessenger } from "../../../src/util/messenger";
-import { Messenger, WebsocketMessenger } from "./messenger";
-import { VscodeMessenger } from "./vscodeMessenger";
-
-class ContinueGUIClientProtocol extends AbstractContinueGUIClientProtocol {
- messenger: Messenger;
- // Server URL must contain the session ID param
- serverUrlWithSessionId: string;
-
- constructor(
- serverUrlWithSessionId: string,
- useVscodeMessagePassing: boolean
- ) {
- super();
- this.serverUrlWithSessionId = serverUrlWithSessionId;
- if (useVscodeMessagePassing) {
- this.messenger = new VscodeMessenger(serverUrlWithSessionId);
- } else {
- this.messenger = new WebsocketMessenger(serverUrlWithSessionId);
- }
- }
-
- sendMainInput(input: string) {
- this.messenger.send("main_input", { input });
- }
-
- reverseToIndex(index: number) {
- this.messenger.send("reverse_to_index", { index });
- }
-
- sendRefinementInput(input: string, index: number) {
- this.messenger.send("refinement_input", { input, index });
- }
-
- sendStepUserInput(input: string, index: number) {
- this.messenger.send("step_user_input", { input, index });
- }
-
- onStateUpdate(callback: (state: any) => void) {
- this.messenger.onMessageType("state_update", (data: any) => {
- if (data.state) {
- callback(data.state);
- }
- });
- }
-
- onAvailableSlashCommands(
- callback: (commands: { name: string; description: string }[]) => void
- ) {
- this.messenger.onMessageType("available_slash_commands", (data: any) => {
- if (data.commands) {
- callback(data.commands);
- }
- });
- }
-
- changeDefaultModel(model: string) {
- this.messenger.send("change_default_model", { model });
- }
-
- sendClear() {
- this.messenger.send("clear_history", {});
- }
-
- retryAtIndex(index: number) {
- this.messenger.send("retry_at_index", { index });
- }
-
- deleteAtIndex(index: number) {
- this.messenger.send("delete_at_index", { index });
- }
-
- deleteContextAtIndices(indices: number[]) {
- this.messenger.send("delete_context_at_indices", { indices });
- }
-
- setEditingAtIndices(indices: number[]) {
- this.messenger.send("set_editing_at_indices", { indices });
- }
-
- setPinnedAtIndices(indices: number[]) {
- this.messenger.send("set_pinned_at_indices", { indices });
- }
-
- toggleAddingHighlightedCode(): void {
- this.messenger.send("toggle_adding_highlighted_code", {});
- }
-}
-
-export default ContinueGUIClientProtocol;
diff --git a/extension/react-app/src/hooks/useWebsocket.ts b/extension/react-app/src/hooks/useWebsocket.ts
index e762666f..6b36be97 100644
--- a/extension/react-app/src/hooks/useWebsocket.ts
+++ b/extension/react-app/src/hooks/useWebsocket.ts
@@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import { RootStore } from "../redux/store";
import { useSelector } from "react-redux";
-import ContinueGUIClientProtocol from "./useContinueGUIProtocol";
+import ContinueGUIClientProtocol from "./ContinueGUIClientProtocol";
import { postVscMessage } from "../vscode";
function useContinueGUIProtocol(useVscodeMessagePassing: boolean = true) {
diff --git a/extension/react-app/src/index.css b/extension/react-app/src/index.css
index 6e33c89c..bac7fe97 100644
--- a/extension/react-app/src/index.css
+++ b/extension/react-app/src/index.css
@@ -14,13 +14,13 @@ html,
body,
#root {
height: 100%;
- background-color: var(--vsc-background);
+ background-color: var(--vscode-editor-background);
font-family: "Lexend", sans-serif;
}
body {
padding: 0;
- color: white;
+ color: var(--vscode-editor-foreground);
padding: 0px;
margin: 0px;
height: 100%;
diff --git a/extension/react-app/src/pages/gui.tsx b/extension/react-app/src/pages/gui.tsx
index 4ff260fa..49f41dcf 100644
--- a/extension/react-app/src/pages/gui.tsx
+++ b/extension/react-app/src/pages/gui.tsx
@@ -1,5 +1,9 @@
import styled from "styled-components";
-import { defaultBorderRadius } from "../components";
+import {
+ defaultBorderRadius,
+ vscBackground,
+ vscForeground,
+} from "../components";
import Loader from "../components/Loader";
import ContinueButton from "../components/ContinueButton";
import { FullState, HighlightedRangeContext } from "../../../schema/FullState";
@@ -23,6 +27,7 @@ import { RootStore } from "../redux/store";
import { postVscMessage } from "../vscode";
import UserInputContainer from "../components/UserInputContainer";
import Onboarding from "../components/Onboarding";
+import { isMetaEquivalentKeyPressed } from "../util";
const TopGUIDiv = styled.div`
overflow: hidden;
@@ -70,7 +75,6 @@ function GUI(props: GUIProps) {
}
}, [dataSwitchOn]);
- const [usingFastModel, setUsingFastModel] = useState(false);
const [waitingForSteps, setWaitingForSteps] = useState(false);
const [userInputQueue, setUserInputQueue] = useState<string[]>([]);
const [highlightedRanges, setHighlightedRanges] = useState<
@@ -95,11 +99,8 @@ function GUI(props: GUIProps) {
name: "Welcome to Continue",
hide: false,
description: `- Highlight code and ask a question or give instructions
-- Use \`cmd+k\` (Mac) / \`ctrl+k\` (Windows) to open Continue
-- Use \`cmd+shift+e\` / \`ctrl+shift+e\` to open file Explorer
-- Add your own OpenAI API key to VS Code Settings with \`cmd+,\`
-- Use slash commands when you want fine-grained control
-- Past steps are included as part of the context by default`,
+ - Use \`cmd+m\` (Mac) / \`ctrl+m\` (Windows) to open Continue
+ - Use \`/help\` to ask questions about how to use Continue`,
system_message: null,
chat_context: [],
manage_own_chat_context: false,
@@ -115,6 +116,7 @@ function GUI(props: GUIProps) {
const [showFeedbackDialog, setShowFeedbackDialog] = useState(false);
const [feedbackDialogMessage, setFeedbackDialogMessage] = useState("");
+ const [feedbackEntryOn, setFeedbackEntryOn] = useState(true);
const topGuiDivRef = useRef<HTMLDivElement>(null);
@@ -138,14 +140,11 @@ function GUI(props: GUIProps) {
}, [topGuiDivRef.current, scrollTimeout]);
useEffect(() => {
+ // Cmd + Backspace to delete current step
const listener = (e: any) => {
- // Cmd + i to toggle fast model
- if (e.key === "i" && e.metaKey && e.shiftKey) {
- setUsingFastModel((prev) => !prev);
- // Cmd + backspace to stop currently running step
- } else if (
+ if (
e.key === "Backspace" &&
- e.metaKey &&
+ isMetaEquivalentKeyPressed(e) &&
typeof history?.current_index !== "undefined" &&
history.timeline[history.current_index]?.active
) {
@@ -162,7 +161,6 @@ function GUI(props: GUIProps) {
useEffect(() => {
client?.onStateUpdate((state: FullState) => {
// Scroll only if user is at very bottom of the window.
- setUsingFastModel(state.default_model === "gpt-3.5-turbo");
const shouldScrollToBottom =
topGuiDivRef.current &&
topGuiDivRef.current?.offsetHeight - window.scrollY < 100;
@@ -223,7 +221,7 @@ function GUI(props: GUIProps) {
if (mainTextInputRef.current) {
let input = (mainTextInputRef.current as any).inputValue;
// cmd+enter to /edit
- if (event?.metaKey) {
+ if (isMetaEquivalentKeyPressed(event)) {
input = `/edit ${input}`;
}
(mainTextInputRef.current as any).setInputValue("");
@@ -269,15 +267,18 @@ function GUI(props: GUIProps) {
return (
<>
<Onboarding />
- <TextDialog showDialog={showFeedbackDialog}
- onEnter={(text) => {
- client?.sendMainInput(`/feedback ${text}`);
- setShowFeedbackDialog(false);
- }}
- onClose={() => {
- setShowFeedbackDialog(false);
- }}
- message={feedbackDialogMessage} />
+ <TextDialog
+ showDialog={showFeedbackDialog}
+ onEnter={(text) => {
+ client?.sendMainInput(`/feedback ${text}`);
+ setShowFeedbackDialog(false);
+ }}
+ onClose={() => {
+ setShowFeedbackDialog(false);
+ }}
+ message={feedbackDialogMessage}
+ entryOn={feedbackEntryOn}
+ />
<TopGUIDiv
ref={topGuiDivRef}
@@ -307,6 +308,7 @@ function GUI(props: GUIProps) {
)
) : (
<StepContainer
+ index={index}
isLast={index === history.timeline.length - 1}
isFirst={index === 0}
open={stepsOpen[index]}
@@ -371,12 +373,13 @@ function GUI(props: GUIProps) {
style={{
position: "fixed",
bottom: "50px",
- backgroundColor: "white",
- color: "black",
+ backgroundColor: vscBackground,
+ color: vscForeground,
borderRadius: defaultBorderRadius,
padding: "16px",
margin: "16px",
zIndex: 100,
+ boxShadow: `0px 0px 10px 0px ${vscForeground}`,
}}
hidden={!showDataSharingInfo}
>
@@ -425,24 +428,26 @@ function GUI(props: GUIProps) {
</div>
<HeaderButtonWithText
onClick={() => {
- // client?.changeDefaultModel(
- // usingFastModel ? "gpt-4" : "gpt-3.5-turbo"
- // );
- if (!usingFastModel) {
- // Show the dialog
- setFeedbackDialogMessage(
- "We don't yet support local models, but we're working on it! If privacy is a concern of yours, please write a short note to let us know."
- );
- setShowFeedbackDialog(true);
- }
- setUsingFastModel((prev) => !prev);
+ // Show the dialog
+ setFeedbackDialogMessage(
+ `Continue uses GPT-4 by default, but works with any model. If you'd like to keep your code completely private, there are few options:
+
+Run a local model with ggml: [5 minute quickstart](https://github.com/continuedev/ggml-server-example)
+
+Use Azure OpenAI service, which is GDPR and HIPAA compliant: [Tutorial](https://continue.dev/docs/customization#azure-openai-service)
+
+If you already have an LLM deployed on your own infrastructure, or would like to do so, please contact us at hi@continue.dev.
+ `
+ );
+ setFeedbackEntryOn(false);
+ setShowFeedbackDialog(true);
}}
- text={usingFastModel ? "local" : "gpt-4"}
+ text={"Use Private Model"}
>
<div
style={{ fontSize: "18px", marginLeft: "2px", marginRight: "2px" }}
>
- {usingFastModel ? "🔒" : "🧠"}
+ 🔒
</div>
</HeaderButtonWithText>
<HeaderButtonWithText
@@ -467,6 +472,7 @@ function GUI(props: GUIProps) {
setFeedbackDialogMessage(
"Having trouble using Continue? Want a new feature? Let us know! This box is anonymous, but we will promptly address your feedback."
);
+ setFeedbackEntryOn(true);
setShowFeedbackDialog(true);
}}
text="Feedback"
diff --git a/extension/react-app/src/util/index.ts b/extension/react-app/src/util/index.ts
new file mode 100644
index 00000000..c4168e13
--- /dev/null
+++ b/extension/react-app/src/util/index.ts
@@ -0,0 +1,43 @@
+type Platform = "mac" | "linux" | "windows" | "unknown";
+
+export function getPlatform(): Platform {
+ const platform = window.navigator.platform.toUpperCase();
+ if (platform.indexOf("MAC") >= 0) {
+ return "mac";
+ } else if (platform.indexOf("LINUX") >= 0) {
+ return "linux";
+ } else if (platform.indexOf("WIN") >= 0) {
+ return "windows";
+ } else {
+ return "unknown";
+ }
+}
+
+export function isMetaEquivalentKeyPressed(event: {
+ metaKey: boolean;
+ ctrlKey: boolean;
+}): boolean {
+ const platform = getPlatform();
+ switch (platform) {
+ case "mac":
+ return event.metaKey;
+ case "linux":
+ case "windows":
+ return event.ctrlKey;
+ default:
+ return event.metaKey;
+ }
+}
+
+export function getMetaKeyLabel(): string {
+ const platform = getPlatform();
+ switch (platform) {
+ case "mac":
+ return "⌘";
+ case "linux":
+ case "windows":
+ return "^";
+ default:
+ return "⌘";
+ }
+}