This commit is contained in:
Ishaan Jaffer 2026-01-30 15:42:37 -08:00
parent f07ef8af00
commit 5f07635310
5 changed files with 190 additions and 173 deletions

View file

@ -2,6 +2,7 @@ import { Button, Tag, Tooltip, Typography } from "antd";
import { CloseOutlined, CopyOutlined, UpOutlined, DownOutlined } from "@ant-design/icons"; import { CloseOutlined, CopyOutlined, UpOutlined, DownOutlined } from "@ant-design/icons";
import moment from "moment"; import moment from "moment";
import { LogEntry } from "../columns"; import { LogEntry } from "../columns";
import { getProviderLogoAndName } from "../../provider_info_helpers";
import { import {
DRAWER_HEADER_PADDING, DRAWER_HEADER_PADDING,
COLOR_BORDER, COLOR_BORDER,
@ -29,7 +30,7 @@ interface DrawerHeaderProps {
/** /**
* Header component for the log details drawer. * Header component for the log details drawer.
* Displays request ID, navigation controls, status, environment, and timestamp. * Displays model/provider, request ID, navigation controls, status, environment, and timestamp.
*/ */
export function DrawerHeader({ export function DrawerHeader({
log, log,
@ -41,6 +42,9 @@ export function DrawerHeader({
statusColor, statusColor,
environment, environment,
}: DrawerHeaderProps) { }: DrawerHeaderProps) {
const provider = log.custom_llm_provider || "";
const providerInfo = provider ? getProviderLogoAndName(provider) : null;
return ( return (
<div <div
style={{ style={{
@ -52,6 +56,9 @@ export function DrawerHeader({
zIndex: 10, zIndex: 10,
}} }}
> >
{/* Row 0: Model + Provider with Logo */}
<ModelProviderSection model={log.model} providerLogo={providerInfo?.logo} providerName={providerInfo?.displayName} />
{/* Row 1: Request ID + Actions */} {/* Row 1: Request ID + Actions */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: SPACING_MEDIUM }}> <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: SPACING_MEDIUM }}>
<RequestIdSection requestId={log.request_id} onCopy={onCopyRequestId} /> <RequestIdSection requestId={log.request_id} onCopy={onCopyRequestId} />
@ -64,6 +71,45 @@ export function DrawerHeader({
); );
} }
/**
* Model and Provider display with logo
*/
function ModelProviderSection({
model,
providerLogo,
providerName,
}: {
model: string;
providerLogo?: string;
providerName?: string;
}) {
return (
<div style={{ display: "flex", alignItems: "center", gap: SPACING_MEDIUM, marginBottom: SPACING_MEDIUM }}>
{providerLogo && (
<img
src={providerLogo}
alt={providerName || "Provider"}
style={{ width: 24, height: 24 }}
onError={(e) => {
const target = e.target as HTMLImageElement;
target.style.display = "none";
}}
/>
)}
<div>
<Text strong style={{ fontSize: 14 }}>
{model}
</Text>
{providerName && (
<Text type="secondary" style={{ fontSize: 12, marginLeft: SPACING_MEDIUM }}>
{providerName}
</Text>
)}
</div>
</div>
);
}
/** /**
* Request ID display with copy button * Request ID display with copy button
*/ */
@ -93,6 +139,7 @@ function RequestIdSection({ requestId, onCopy }: { requestId: string; onCopy: ()
/** /**
* Navigation controls (previous, next, close) * Navigation controls (previous, next, close)
* Shows keyboard shortcuts with bounding boxes for visibility
*/ */
function NavigationSection({ function NavigationSection({
onPrevious, onPrevious,
@ -103,18 +150,32 @@ function NavigationSection({
onNext: () => void; onNext: () => void;
onClose: () => void; onClose: () => void;
}) { }) {
const keyboardShortcutStyle = {
border: "1px solid #d9d9d9",
borderRadius: 4,
padding: "0 4px",
fontSize: 12,
fontFamily: "monospace",
marginLeft: 4,
background: "#fafafa",
};
return ( return (
<div style={{ display: "flex", alignItems: "center", gap: SPACING_SMALL }}> <div style={{ display: "flex", alignItems: "center", gap: SPACING_SMALL }}>
<Tooltip title="Previous (K)"> <Button type="text" size="small" onClick={onPrevious}>
<Button type="text" size="small" icon={<UpOutlined />} onClick={onPrevious} /> <UpOutlined />
</Tooltip> <span style={keyboardShortcutStyle}>K</span>
<Tooltip title="Next (J)"> </Button>
<Button type="text" size="small" icon={<DownOutlined />} onClick={onNext} /> <Button type="text" size="small" onClick={onNext}>
</Tooltip> <DownOutlined />
<span style={keyboardShortcutStyle}>J</span>
</Button>
<div style={{ width: 1, height: 20, background: COLOR_BORDER, margin: `0 ${SPACING_MEDIUM}px` }} /> <div style={{ width: 1, height: 20, background: COLOR_BORDER, margin: `0 ${SPACING_MEDIUM}px` }} />
<Button type="text" icon={<CloseOutlined />} onClick={onClose} /> <Tooltip title="ESC to close">
<Button type="text" icon={<CloseOutlined />} onClick={onClose} />
</Tooltip>
</div> </div>
); );
} }

View file

@ -1,53 +1,22 @@
import { Typography } from "antd"; import { Typography } from "antd";
import { JsonView, defaultStyles } from "react-json-view-lite"; import { JsonView, defaultStyles } from "react-json-view-lite";
import "react-json-view-lite/dist/index.css"; import "react-json-view-lite/dist/index.css";
import { import { JSON_MAX_HEIGHT, COLOR_BG_LIGHT, SPACING_LARGE } from "./constants";
JSON_MAX_HEIGHT,
FONT_SIZE_SMALL,
COLOR_BG_LIGHT,
SPACING_LARGE,
FONT_FAMILY_MONO,
VIEW_MODE_JSON,
} from "./constants";
const { Text } = Typography; const { Text } = Typography;
export type ViewMode = "formatted" | "json";
interface JsonViewerProps { interface JsonViewerProps {
data: any; data: any;
mode: ViewMode; mode: "formatted";
} }
/** /**
* Displays JSON data in either formatted tree view or raw JSON format. * Displays JSON data in formatted tree view.
* Formatted view uses an interactive tree, JSON view shows raw stringified output. * Uses an interactive tree component for easy navigation.
*/ */
export function JsonViewer({ data, mode }: JsonViewerProps) { export function JsonViewer({ data }: JsonViewerProps) {
if (!data) return <Text type="secondary">No data</Text>; if (!data) return <Text type="secondary">No data</Text>;
if (mode === VIEW_MODE_JSON) {
return (
<pre
style={{
margin: 0,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
maxHeight: JSON_MAX_HEIGHT,
overflow: "auto",
fontSize: FONT_SIZE_SMALL,
background: COLOR_BG_LIGHT,
padding: SPACING_LARGE,
borderRadius: 4,
fontFamily: FONT_FAMILY_MONO,
}}
>
{JSON.stringify(data, null, 2)}
</pre>
);
}
// Formatted tree view
return ( return (
<div <div
style={{ style={{

View file

@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { Drawer, Typography, Button, Descriptions, Card, Tag, Tabs, Radio, Alert, message } from "antd"; import { Drawer, Typography, Button, Descriptions, Card, Tag, Tabs, Alert, message, Collapse } from "antd";
import { CopyOutlined } from "@ant-design/icons"; import { CopyOutlined, DownOutlined } from "@ant-design/icons";
import moment from "moment"; import moment from "moment";
import { LogEntry } from "../columns"; import { LogEntry } from "../columns";
import { formatNumberWithCommas } from "@/utils/dataUtils"; import { formatNumberWithCommas } from "@/utils/dataUtils";
@ -10,7 +10,7 @@ import { ConfigInfoMessage } from "../ConfigInfoMessage";
import { VectorStoreViewer } from "../VectorStoreViewer"; import { VectorStoreViewer } from "../VectorStoreViewer";
import { TruncatedValue } from "./TruncatedValue"; import { TruncatedValue } from "./TruncatedValue";
import { TokenFlow } from "./TokenFlow"; import { TokenFlow } from "./TokenFlow";
import { JsonViewer, ViewMode } from "./JsonViewer"; import { JsonViewer } from "./JsonViewer";
import { DrawerHeader } from "./DrawerHeader"; import { DrawerHeader } from "./DrawerHeader";
import { copyToClipboard } from "./clipboardUtils"; import { copyToClipboard } from "./clipboardUtils";
import { useKeyboardNavigation } from "./useKeyboardNavigation"; import { useKeyboardNavigation } from "./useKeyboardNavigation";
@ -21,7 +21,6 @@ import {
METADATA_MAX_HEIGHT, METADATA_MAX_HEIGHT,
TAB_REQUEST, TAB_REQUEST,
TAB_RESPONSE, TAB_RESPONSE,
VIEW_MODE_FORMATTED,
FONT_SIZE_SMALL, FONT_SIZE_SMALL,
FONT_FAMILY_MONO, FONT_FAMILY_MONO,
SPACING_XLARGE, SPACING_XLARGE,
@ -58,7 +57,6 @@ export function LogDetailsDrawer({
onSelectLog, onSelectLog,
}: LogDetailsDrawerProps) { }: LogDetailsDrawerProps) {
const [activeTab, setActiveTab] = useState<typeof TAB_REQUEST | typeof TAB_RESPONSE>(TAB_REQUEST); const [activeTab, setActiveTab] = useState<typeof TAB_REQUEST | typeof TAB_RESPONSE>(TAB_REQUEST);
const [jsonViewMode, setJsonViewMode] = useState<ViewMode>(VIEW_MODE_FORMATTED);
// Keyboard navigation // Keyboard navigation
const { selectNextLog, selectPreviousLog } = useKeyboardNavigation({ const { selectNextLog, selectPreviousLog } = useKeyboardNavigation({
@ -162,8 +160,9 @@ export function LogDetailsDrawer({
)} )}
{/* Request Details Section */} {/* Request Details Section */}
<Card title="Request Details" size="small" style={{ marginBottom: SPACING_XLARGE }}> <div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden" style={{ marginBottom: SPACING_XLARGE }}>
<Descriptions column={2} size="small"> <Card title="Request Details" size="small" bordered={false} style={{ marginBottom: 0 }}>
<Descriptions column={2} size="small">
<Descriptions.Item label="Model">{logEntry.model}</Descriptions.Item> <Descriptions.Item label="Model">{logEntry.model}</Descriptions.Item>
<Descriptions.Item label="Provider">{logEntry.custom_llm_provider || "-"}</Descriptions.Item> <Descriptions.Item label="Provider">{logEntry.custom_llm_provider || "-"}</Descriptions.Item>
<Descriptions.Item label="Call Type">{logEntry.call_type}</Descriptions.Item> <Descriptions.Item label="Call Type">{logEntry.call_type}</Descriptions.Item>
@ -183,6 +182,7 @@ export function LogDetailsDrawer({
)} )}
</Descriptions> </Descriptions>
</Card> </Card>
</div>
{/* Metrics Section */} {/* Metrics Section */}
<MetricsSection logEntry={logEntry} metadata={metadata} /> <MetricsSection logEntry={logEntry} metadata={metadata} />
@ -193,31 +193,19 @@ export function LogDetailsDrawer({
{/* Configuration Info Message - Show when data is missing */} {/* Configuration Info Message - Show when data is missing */}
<ConfigInfoMessage show={missingData} onOpenSettings={onOpenSettings} /> <ConfigInfoMessage show={missingData} onOpenSettings={onOpenSettings} />
{/* Request/Response JSON - Using Tabs with View Toggle */} {/* Request/Response JSON - Collapsible */}
<RequestResponseSection <RequestResponseSection
activeTab={activeTab}
jsonViewMode={jsonViewMode}
hasResponse={hasResponse} hasResponse={hasResponse}
onTabChange={setActiveTab}
onViewModeChange={setJsonViewMode}
onCopy={(data, label) => copyToClipboard(JSON.stringify(data, null, 2), label)} onCopy={(data, label) => copyToClipboard(JSON.stringify(data, null, 2), label)}
getRawRequest={getRawRequest} getRawRequest={getRawRequest}
getFormattedResponse={getFormattedResponse} getFormattedResponse={getFormattedResponse}
/> />
{/* Guardrail Data - Show only if present */} {/* Guardrail Data - Show only if present */}
{hasGuardrailData && ( {hasGuardrailData && <GuardrailViewer data={guardrailInfo} />}
<div style={{ marginBottom: SPACING_XLARGE }}>
<GuardrailViewer data={guardrailInfo} />
</div>
)}
{/* Vector Store Request Data - Show only if present */} {/* Vector Store Request Data - Show only if present */}
{hasVectorStoreData && ( {hasVectorStoreData && <VectorStoreViewer data={metadata.vector_store_request_metadata} />}
<div style={{ marginBottom: SPACING_XLARGE }}>
<VectorStoreViewer data={metadata.vector_store_request_metadata} />
</div>
)}
{/* Metadata Card - Only show if there's metadata */} {/* Metadata Card - Only show if there's metadata */}
{logEntry.metadata && Object.keys(logEntry.metadata).length > 0 && ( {logEntry.metadata && Object.keys(logEntry.metadata).length > 0 && (
@ -251,8 +239,8 @@ function ErrorDescription({ errorInfo }: { errorInfo: any }) {
function TagsSection({ tags }: { tags: Record<string, any> }) { function TagsSection({ tags }: { tags: Record<string, any> }) {
return ( return (
<div style={{ marginBottom: SPACING_XLARGE }}> <div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden p-4" style={{ marginBottom: SPACING_XLARGE }}>
<Text strong style={{ display: "block", marginBottom: 8 }}> <Text strong style={{ display: "block", marginBottom: 8, fontSize: 16 }}>
Tags Tags
</Text> </Text>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}> <div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
@ -286,8 +274,9 @@ function MetricsSection({ logEntry, metadata }: { logEntry: LogEntry; metadata:
metadata.additional_usage_values.cache_read_input_tokens > 0); metadata.additional_usage_values.cache_read_input_tokens > 0);
return ( return (
<Card title="Metrics" size="small" style={{ marginBottom: SPACING_XLARGE }}> <div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden" style={{ marginBottom: SPACING_XLARGE }}>
<Descriptions column={2} size="small"> <Card title="Metrics" size="small" bordered={false} style={{ marginBottom: 0 }}>
<Descriptions column={2} size="small">
<Descriptions.Item label="Tokens"> <Descriptions.Item label="Tokens">
<TokenFlow <TokenFlow
prompt={logEntry.prompt_tokens} prompt={logEntry.prompt_tokens}
@ -317,7 +306,7 @@ function MetricsSection({ logEntry, metadata }: { logEntry: LogEntry; metadata:
</> </>
)} )}
{metadata?.litellm_overhead_time_ms !== undefined && ( {metadata?.litellm_overhead_time_ms !== undefined && metadata.litellm_overhead_time_ms !== null && (
<Descriptions.Item label="LiteLLM Overhead"> <Descriptions.Item label="LiteLLM Overhead">
{metadata.litellm_overhead_time_ms.toFixed(2)} ms {metadata.litellm_overhead_time_ms.toFixed(2)} ms
</Descriptions.Item> </Descriptions.Item>
@ -331,30 +320,26 @@ function MetricsSection({ logEntry, metadata }: { logEntry: LogEntry; metadata:
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
</Card> </Card>
</div>
); );
} }
interface RequestResponseSectionProps { interface RequestResponseSectionProps {
activeTab: typeof TAB_REQUEST | typeof TAB_RESPONSE;
jsonViewMode: ViewMode;
hasResponse: boolean; hasResponse: boolean;
onTabChange: (key: typeof TAB_REQUEST | typeof TAB_RESPONSE) => void;
onViewModeChange: (mode: ViewMode) => void;
onCopy: (data: any, label: string) => void; onCopy: (data: any, label: string) => void;
getRawRequest: () => any; getRawRequest: () => any;
getFormattedResponse: () => any; getFormattedResponse: () => any;
} }
function RequestResponseSection({ function RequestResponseSection({
activeTab,
jsonViewMode,
hasResponse, hasResponse,
onTabChange,
onViewModeChange,
onCopy, onCopy,
getRawRequest, getRawRequest,
getFormattedResponse, getFormattedResponse,
}: RequestResponseSectionProps) { }: RequestResponseSectionProps) {
const [activeTab, setActiveTab] = useState<typeof TAB_REQUEST | typeof TAB_RESPONSE>(TAB_REQUEST);
const [isOpen, setIsOpen] = useState<boolean>(true);
const handleCopy = () => { const handleCopy = () => {
const data = activeTab === TAB_REQUEST ? getRawRequest() : getFormattedResponse(); const data = activeTab === TAB_REQUEST ? getRawRequest() : getFormattedResponse();
const label = activeTab === TAB_REQUEST ? "Request" : "Response"; const label = activeTab === TAB_REQUEST ? "Request" : "Response";
@ -362,98 +347,109 @@ function RequestResponseSection({
}; };
return ( return (
<Card <div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden" style={{ marginBottom: SPACING_XLARGE }}>
title="Request & Response" <Collapse
size="small" activeKey={isOpen ? ["request-response"] : []}
style={{ marginBottom: SPACING_XLARGE }} onChange={(keys) => setIsOpen(keys.includes("request-response"))}
styles={{ body: { padding: 0 } }} expandIcon={({ isActive }) => <DownOutlined rotate={isActive ? 180 : 0} />}
> bordered={false}
<Tabs
activeKey={activeTab}
onChange={(key) => onTabChange(key as typeof TAB_REQUEST | typeof TAB_RESPONSE)}
tabBarExtraContent={
<div style={{ display: "flex", alignItems: "center", gap: 8, paddingRight: SPACING_XLARGE }}>
{/* View Mode Toggle */}
<Radio.Group size="small" value={jsonViewMode} onChange={(e) => onViewModeChange(e.target.value)}>
<Radio.Button value={VIEW_MODE_FORMATTED}>Formatted</Radio.Button>
<Radio.Button value="json">JSON</Radio.Button>
</Radio.Group>
{/* Copy Button */}
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={handleCopy}
disabled={activeTab === TAB_RESPONSE && !hasResponse}
>
Copy
</Button>
</div>
}
items={[ items={[
{ {
key: TAB_REQUEST, key: "request-response",
label: "Request", label: <span style={{ fontSize: 16, fontWeight: 500 }}>Request & Response</span>,
children: ( children: (
<div style={{ padding: SPACING_XLARGE }}> <Tabs
<JsonViewer data={getRawRequest()} mode={jsonViewMode} /> activeKey={activeTab}
</div> onChange={(key) => setActiveTab(key as typeof TAB_REQUEST | typeof TAB_RESPONSE)}
), tabBarExtraContent={
}, <Button
{ type="text"
key: TAB_RESPONSE, size="small"
label: "Response", icon={<CopyOutlined />}
children: ( onClick={handleCopy}
<div style={{ padding: SPACING_XLARGE }}> disabled={activeTab === TAB_RESPONSE && !hasResponse}
{hasResponse ? ( >
<JsonViewer data={getFormattedResponse()} mode={jsonViewMode} /> Copy
) : ( </Button>
<div style={{ textAlign: "center", padding: 20, color: "#999", fontStyle: "italic" }}> }
Response data not available items={[
</div> {
)} key: TAB_REQUEST,
</div> label: "Request",
children: (
<div style={{ paddingTop: SPACING_XLARGE }}>
<JsonViewer data={getRawRequest()} mode="formatted" />
</div>
),
},
{
key: TAB_RESPONSE,
label: "Response",
children: (
<div style={{ paddingTop: SPACING_XLARGE }}>
{hasResponse ? (
<JsonViewer data={getFormattedResponse()} mode="formatted" />
) : (
<div style={{ textAlign: "center", padding: 20, color: "#999", fontStyle: "italic" }}>
Response data not available
</div>
)}
</div>
),
},
]}
/>
), ),
}, },
]} ]}
style={{ padding: `0 ${SPACING_XLARGE}px` }} styles={{
header: {
padding: "16px",
borderBottom: "1px solid #f0f0f0",
},
body: {
padding: 0,
},
}}
/> />
</Card> </div>
); );
} }
function MetadataSection({ metadata, onCopy }: { metadata: Record<string, any>; onCopy: (data: string) => void }) { function MetadataSection({ metadata, onCopy }: { metadata: Record<string, any>; onCopy: (data: string) => void }) {
return ( return (
<Card <div className="bg-white rounded-lg shadow w-full max-w-full overflow-hidden" style={{ marginBottom: SPACING_XLARGE }}>
title="Metadata" <Card
size="small" title="Metadata"
style={{ marginBottom: SPACING_XLARGE }} size="small"
extra={ bordered={false}
<Button style={{ marginBottom: 0 }}
type="text" extra={
size="small" <Button
icon={<CopyOutlined />} type="text"
onClick={() => onCopy(JSON.stringify(metadata, null, 2))} size="small"
> icon={<CopyOutlined />}
Copy onClick={() => onCopy(JSON.stringify(metadata, null, 2))}
</Button> >
} Copy
> </Button>
<pre }
style={{
maxHeight: METADATA_MAX_HEIGHT,
overflowY: "auto",
fontSize: FONT_SIZE_SMALL,
fontFamily: FONT_FAMILY_MONO,
whiteSpace: "pre-wrap",
wordBreak: "break-all",
margin: 0,
}}
> >
{JSON.stringify(metadata, null, 2)} <pre
</pre> style={{
</Card> maxHeight: METADATA_MAX_HEIGHT,
overflowY: "auto",
fontSize: FONT_SIZE_SMALL,
fontFamily: FONT_FAMILY_MONO,
whiteSpace: "pre-wrap",
wordBreak: "break-all",
margin: 0,
}}
>
{JSON.stringify(metadata, null, 2)}
</pre>
</Card>
</div>
); );
} }

View file

@ -1,5 +1,4 @@
import { Typography } from "antd"; import { Typography } from "antd";
import { COLOR_SECONDARY, FONT_FAMILY_MONO, FONT_SIZE_MEDIUM, SPACING_SMALL, SPACING_MEDIUM } from "./constants";
const { Text } = Typography; const { Text } = Typography;
@ -10,18 +9,14 @@ interface TokenFlowProps {
} }
/** /**
* Displays token usage in a flow format: "prompt → completion (Σ total)" * Displays token usage in LiteLLM format: "12 (9 prompt tokens + 3 completion tokens)"
* Makes it easy to see the relationship between input, output, and total tokens. * Shows total with breakdown of prompt and completion tokens.
*/ */
export function TokenFlow({ prompt = 0, completion = 0, total = 0 }: TokenFlowProps) { export function TokenFlow({ prompt = 0, completion = 0, total = 0 }: TokenFlowProps) {
return ( return (
<Text style={{ fontFamily: FONT_FAMILY_MONO, fontSize: FONT_SIZE_MEDIUM }}> <Text>
{prompt.toLocaleString()} {total.toLocaleString()} ({prompt.toLocaleString()} prompt tokens + {completion.toLocaleString()} completion
<span style={{ color: COLOR_SECONDARY, margin: `0 ${SPACING_SMALL}px` }}></span> tokens)
{completion.toLocaleString()}
<span style={{ color: COLOR_SECONDARY, marginLeft: SPACING_MEDIUM }}>
(Σ {total.toLocaleString()})
</span>
</Text> </Text>
); );
} }

View file

@ -9,14 +9,10 @@ export const API_BASE_MAX_WIDTH = 200;
export const JSON_MAX_HEIGHT = 400; export const JSON_MAX_HEIGHT = 400;
export const METADATA_MAX_HEIGHT = 300; export const METADATA_MAX_HEIGHT = 300;
// Tab keys // Tab keys (kept for backwards compatibility if needed)
export const TAB_REQUEST = "request" as const; export const TAB_REQUEST = "request" as const;
export const TAB_RESPONSE = "response" as const; export const TAB_RESPONSE = "response" as const;
// View modes
export const VIEW_MODE_FORMATTED = "formatted" as const;
export const VIEW_MODE_JSON = "json" as const;
// Keyboard shortcuts // Keyboard shortcuts
export const KEY_ESCAPE = "Escape"; export const KEY_ESCAPE = "Escape";
export const KEY_J_LOWER = "j"; export const KEY_J_LOWER = "j";