refactor: codebase audit cleanup — dead code, dedup, module splits
Multi-agent audit + aggressive cleanup across server/web/coder/booterm, delivered behind a DEFER discipline so none of the in-flight files were touched. Removes dead code/deps/columns, dedups server + coder helpers, and splits the oversized modules (tools.ts, opencode-server.ts, sentinel-summaries, turn.ts, TerminalPane.tsx) behind stable contracts. Adds 78 parity/unit tests (server 587, coder 323); fixes two latent bugs (ChatPane queue keys, FileViewerOverlay blank-line parity). Intended tag: v2.7.12-audit-cleanup. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -25,7 +25,8 @@ interface Props {
|
||||
export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange, sessionChats, webSearchEnabled }: Props) {
|
||||
const stream = useSessionStream(sessionId);
|
||||
const lastErrorRef = useRef<string | null>(null);
|
||||
const [queue, setQueue] = useState<string[]>([]);
|
||||
const [queue, setQueue] = useState<{ id: string; text: string }[]>([]);
|
||||
const queueIdRef = useRef(0);
|
||||
const processingRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -84,7 +85,7 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
|
||||
processingRef.current = true;
|
||||
const next = queue[0]!;
|
||||
setQueue((prev) => prev.slice(1));
|
||||
api.messages.send(chatId, next)
|
||||
api.messages.send(chatId, next.text)
|
||||
.catch((err) => toast.error(err instanceof Error ? err.message : 'queue send failed'))
|
||||
.finally(() => { processingRef.current = false; });
|
||||
}, [streaming, queue, chatId]);
|
||||
@@ -101,7 +102,7 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
|
||||
return;
|
||||
}
|
||||
if (streaming) {
|
||||
setQueue((prev) => [...prev, trimmed]);
|
||||
setQueue((prev) => [...prev, { id: String(++queueIdRef.current), text: trimmed }]);
|
||||
return;
|
||||
}
|
||||
await api.messages.send(chatId, trimmed);
|
||||
@@ -185,18 +186,18 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
|
||||
// into ChatInput via sendToChat. ChatInput appends (or sets, if empty) and
|
||||
// focuses; user re-sends, which re-queues if streaming is still active.
|
||||
function editQueued(idx: number) {
|
||||
const msg = queue[idx];
|
||||
if (!msg) return;
|
||||
const item = queue[idx];
|
||||
if (!item) return;
|
||||
setQueue((prev) => prev.filter((_, i) => i !== idx));
|
||||
sendToChat.emit({ chat_id: chatId, text: msg });
|
||||
sendToChat.emit({ chat_id: chatId, text: item.text });
|
||||
}
|
||||
|
||||
async function forceSendQueued(idx: number) {
|
||||
const msg = queue[idx];
|
||||
if (!msg) return;
|
||||
const item = queue[idx];
|
||||
if (!item) return;
|
||||
setQueue((prev) => prev.filter((_, i) => i !== idx));
|
||||
try {
|
||||
await api.chats.forceSend(chatId, msg);
|
||||
await api.chats.forceSend(chatId, item.text);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'force send failed');
|
||||
}
|
||||
@@ -211,10 +212,10 @@ export function ChatPane({ sessionId, chatId, projectId, agentId, onAgentChange,
|
||||
{queue.length > 0 && (
|
||||
<div className="border-t">
|
||||
<div className="max-w-[1000px] mx-auto w-full px-4 py-1 space-y-1">
|
||||
{queue.map((msg, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/30 rounded px-2 py-1">
|
||||
{queue.map((item, i) => (
|
||||
<div key={item.id} className="flex items-center gap-2 text-xs text-muted-foreground bg-muted/30 rounded px-2 py-1">
|
||||
<span className="font-medium shrink-0">Queued:</span>
|
||||
<span className="truncate flex-1">{msg}</span>
|
||||
<span className="truncate flex-1">{item.text}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editQueued(i)}
|
||||
|
||||
@@ -266,9 +266,6 @@ function SessionSection({ session, project }: { session: Session; project: Proje
|
||||
Inherit project default ({project.default_web_search_enabled ? 'on' : 'off'})
|
||||
</label>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Plumbed for Batch 8 (web_search tool). No effect yet.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<AllowedReadPathsSection session={session} />
|
||||
@@ -532,7 +529,7 @@ function ProjectSection({ project }: { project: Project }) {
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground italic">
|
||||
Applies to new sessions only. Plumbed for Batch 8.
|
||||
Applies to new sessions only.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
155
apps/web/src/components/panes/terminal/FloatingMenu.tsx
Normal file
155
apps/web/src/components/panes/terminal/FloatingMenu.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { type ChatInputRegistration } from '@/lib/events';
|
||||
|
||||
// ============================================================
|
||||
// FloatingMenu — kept from v1.10.4 (mobile long-press + desktop right-click)
|
||||
// ============================================================
|
||||
interface FloatingMenuProps {
|
||||
x: number;
|
||||
y: number;
|
||||
hasSelection: boolean;
|
||||
chatInputs: ChatInputRegistration[];
|
||||
onCopy: () => void;
|
||||
onPaste: () => void;
|
||||
onSelectAll: () => void;
|
||||
onSearch: () => void;
|
||||
onSendToChat: (chatId: string) => void;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
export function FloatingMenu({
|
||||
x,
|
||||
y,
|
||||
hasSelection,
|
||||
chatInputs,
|
||||
onCopy,
|
||||
onPaste,
|
||||
onSelectAll,
|
||||
onSearch,
|
||||
onSendToChat,
|
||||
onDismiss,
|
||||
}: FloatingMenuProps) {
|
||||
const [submenu, setSubmenu] = useState(false);
|
||||
const MENU_W = 200;
|
||||
const MENU_H = 220;
|
||||
const left = Math.min(x, window.innerWidth - MENU_W - 8);
|
||||
const top = Math.min(y, window.innerHeight - MENU_H - 8);
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(ev: KeyboardEvent): void {
|
||||
if (ev.key === 'Escape') onDismiss();
|
||||
}
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onDismiss]);
|
||||
|
||||
const flatChat = chatInputs.length === 1 ? chatInputs[0] : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-term-menu="1"
|
||||
role="menu"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top,
|
||||
left,
|
||||
background: '#1a1d24',
|
||||
border: '1px solid #2a2d34',
|
||||
borderRadius: 8,
|
||||
padding: 4,
|
||||
fontSize: 14,
|
||||
minWidth: MENU_W,
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.5)',
|
||||
zIndex: 50,
|
||||
color: '#d6deeb',
|
||||
}}
|
||||
>
|
||||
<MenuItem disabled={!hasSelection} onClick={onCopy}>Copy</MenuItem>
|
||||
<MenuItem onClick={onPaste}>Paste</MenuItem>
|
||||
<MenuItem onClick={onSelectAll}>Select All</MenuItem>
|
||||
<MenuItem onClick={onSearch}>Search</MenuItem>
|
||||
{flatChat && (
|
||||
<MenuItem disabled={!hasSelection} onClick={() => onSendToChat(flatChat.chatId)}>
|
||||
Send to {flatChat.label}
|
||||
</MenuItem>
|
||||
)}
|
||||
{chatInputs.length > 1 && (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!hasSelection}
|
||||
onClick={() => setSubmenu((v) => !v)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
minHeight: 44,
|
||||
background: 'transparent',
|
||||
border: 0,
|
||||
color: hasSelection ? '#d6deeb' : '#575656',
|
||||
cursor: hasSelection ? 'pointer' : 'not-allowed',
|
||||
borderRadius: 6,
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<span>Send to chat</span>
|
||||
{submenu ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
{submenu && hasSelection && (
|
||||
<div style={{ paddingLeft: 8 }}>
|
||||
{chatInputs.map((c) => (
|
||||
<MenuItem key={c.chatId} onClick={() => onSendToChat(c.chatId)}>
|
||||
{c.label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ height: 1, background: '#2a2d34', margin: '4px 0' }} />
|
||||
<MenuItem onClick={onDismiss}>Dismiss</MenuItem>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuItem({
|
||||
children,
|
||||
onClick,
|
||||
disabled = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
minHeight: 44,
|
||||
background: 'transparent',
|
||||
border: 0,
|
||||
color: disabled ? '#575656' : '#d6deeb',
|
||||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||||
textAlign: 'left',
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
}}
|
||||
onMouseEnter={(ev) => {
|
||||
if (!disabled) (ev.currentTarget as HTMLButtonElement).style.background = '#2a2d34';
|
||||
}}
|
||||
onMouseLeave={(ev) => {
|
||||
(ev.currentTarget as HTMLButtonElement).style.background = 'transparent';
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
163
apps/web/src/components/panes/terminal/SearchBar.tsx
Normal file
163
apps/web/src/components/panes/terminal/SearchBar.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import type { SearchAddon } from '@xterm/addon-search';
|
||||
import { ChevronDown, ChevronUp, X } from 'lucide-react';
|
||||
import { type TermTheme } from './theme';
|
||||
|
||||
// ============================================================
|
||||
// SearchBar — kept from v1.10.4
|
||||
// ============================================================
|
||||
interface SearchBarProps {
|
||||
searchRef: React.MutableRefObject<SearchAddon | null>;
|
||||
theme: TermTheme;
|
||||
onClose: () => void;
|
||||
}
|
||||
export function SearchBar({ searchRef, theme, onClose }: SearchBarProps) {
|
||||
const [q, setQ] = useState('');
|
||||
const [counts, setCounts] = useState<{ idx: number; total: number }>({ idx: -1, total: 0 });
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const addon = searchRef.current;
|
||||
if (!addon) return;
|
||||
const sub = addon.onDidChangeResults(({ resultIndex, resultCount }) => {
|
||||
setCounts({ idx: resultIndex, total: resultCount });
|
||||
});
|
||||
return () => sub.dispose();
|
||||
}, [searchRef]);
|
||||
useEffect(() => {
|
||||
const addon = searchRef.current;
|
||||
if (!addon) return;
|
||||
if (q.length === 0) {
|
||||
addon.clearDecorations?.();
|
||||
setCounts({ idx: -1, total: 0 });
|
||||
return;
|
||||
}
|
||||
addon.findNext(q, {
|
||||
incremental: true,
|
||||
decorations: {
|
||||
matchBackground: theme.selectionBackground,
|
||||
matchOverviewRuler: theme.cursor,
|
||||
activeMatchBackground: theme.cursor,
|
||||
activeMatchColorOverviewRuler: theme.cursor,
|
||||
},
|
||||
});
|
||||
}, [q, searchRef, theme]);
|
||||
|
||||
function findNext(): void {
|
||||
if (!q) return;
|
||||
searchRef.current?.findNext(q);
|
||||
}
|
||||
function findPrev(): void {
|
||||
if (!q) return;
|
||||
searchRef.current?.findPrevious(q);
|
||||
}
|
||||
function onKey(ev: React.KeyboardEvent<HTMLInputElement>): void {
|
||||
if (ev.key === 'Escape') {
|
||||
ev.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (ev.key === 'Enter') {
|
||||
ev.preventDefault();
|
||||
if (ev.shiftKey) findPrev();
|
||||
else findNext();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
background: '#1a1d24',
|
||||
border: '1px solid #2a2d34',
|
||||
borderRadius: 8,
|
||||
padding: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
boxShadow: '0 4px 12px rgba(0,0,0,0.4)',
|
||||
zIndex: 40,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={q}
|
||||
onChange={(ev) => setQ(ev.target.value)}
|
||||
onKeyDown={onKey}
|
||||
placeholder="Search…"
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 0,
|
||||
outline: 'none',
|
||||
color: '#d6deeb',
|
||||
padding: '8px 8px',
|
||||
fontSize: 13,
|
||||
width: 160,
|
||||
minHeight: 36,
|
||||
}}
|
||||
/>
|
||||
{q.length > 0 && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: counts.total === 0 ? '#ef5350' : '#575656',
|
||||
minWidth: 56,
|
||||
textAlign: 'right',
|
||||
padding: '0 4px',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{counts.total === 0
|
||||
? 'No match'
|
||||
: counts.idx === -1
|
||||
? `${counts.total}+`
|
||||
: `${counts.idx + 1} of ${counts.total}`}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={findPrev}
|
||||
aria-label="Previous match"
|
||||
title="Previous (Shift+Enter)"
|
||||
style={iconBtnStyle}
|
||||
>
|
||||
<ChevronUp size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={findNext}
|
||||
aria-label="Next match"
|
||||
title="Next (Enter)"
|
||||
style={iconBtnStyle}
|
||||
>
|
||||
<ChevronDown size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close search"
|
||||
title="Close (Esc)"
|
||||
style={iconBtnStyle}
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const iconBtnStyle: React.CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 44,
|
||||
height: 44,
|
||||
background: 'transparent',
|
||||
border: 0,
|
||||
color: '#d6deeb',
|
||||
cursor: 'pointer',
|
||||
borderRadius: 6,
|
||||
};
|
||||
115
apps/web/src/components/panes/terminal/TerminalHotkeyBar.tsx
Normal file
115
apps/web/src/components/panes/terminal/TerminalHotkeyBar.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Maximize2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// ============================================================
|
||||
// TerminalHotkeyBar — v1.10.8d port of boolab's TerminalHotkeyBar.jsx +
|
||||
// terminalHotkeysStore.js DEFAULT_BAR. The catalog is hardcoded inline (no
|
||||
// zustand store, no settings UI) — single-user homelab doesn't need either.
|
||||
// Add new entries by extending HOTKEY_BAR below.
|
||||
// ============================================================
|
||||
type Hotkey =
|
||||
| { id: string; label: string; bytes: string; sticky?: undefined }
|
||||
| { id: string; label: string; sticky: 'ctrl'; bytes?: undefined };
|
||||
|
||||
const HOTKEY_BAR: Hotkey[] = [
|
||||
{ id: 'esc', label: 'Esc', bytes: '\x1b' },
|
||||
{ id: 'shift-tab', label: '⇧Tab', bytes: '\x1b[Z' },
|
||||
{ id: 'tab', label: 'Tab', bytes: '\t' },
|
||||
{ id: 'ctrl', label: 'Ctrl', sticky: 'ctrl' },
|
||||
{ id: 'ctrl-c', label: 'Ctrl+C', bytes: '\x03' },
|
||||
{ id: 'arrow-up', label: '↑', bytes: '\x1b[A' },
|
||||
{ id: 'arrow-down', label: '↓', bytes: '\x1b[B' },
|
||||
{ id: 'arrow-left', label: '←', bytes: '\x1b[D' },
|
||||
{ id: 'arrow-right', label: '→', bytes: '\x1b[C' },
|
||||
];
|
||||
|
||||
interface TerminalHotkeyBarProps {
|
||||
ctrlArmed: boolean;
|
||||
onSendBytes: (bytes: string) => void;
|
||||
onArmCtrl: () => void;
|
||||
onFit: () => void;
|
||||
}
|
||||
|
||||
export function TerminalHotkeyBar({
|
||||
ctrlArmed,
|
||||
onSendBytes,
|
||||
onArmCtrl,
|
||||
onFit,
|
||||
}: TerminalHotkeyBarProps) {
|
||||
// Stop the touch from reaching the terminal pane below (which calls
|
||||
// preventDefault on touchmove to suppress page-scroll). Without this a
|
||||
// tap-and-drag on a hotkey button would also scroll the terminal buffer.
|
||||
const stopTouch = useCallback((e: React.TouchEvent) => e.stopPropagation(), []);
|
||||
const press = useCallback(
|
||||
(entry: Hotkey) => {
|
||||
if (entry.sticky === 'ctrl') {
|
||||
onArmCtrl();
|
||||
} else if (entry.bytes !== undefined) {
|
||||
onSendBytes(entry.bytes);
|
||||
}
|
||||
},
|
||||
[onArmCtrl, onSendBytes],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="Terminal hotkeys"
|
||||
className="flex shrink-0 items-center gap-1 overflow-x-auto border-b border-border bg-muted/30 px-2 py-1"
|
||||
style={{
|
||||
scrollbarWidth: 'thin',
|
||||
WebkitOverflowScrolling: 'touch',
|
||||
// Suppress iOS native swipe-back gesture starting on the bar; pinch
|
||||
// and other multi-touch gestures still pass through.
|
||||
touchAction: 'pan-x',
|
||||
}}
|
||||
onTouchStart={stopTouch}
|
||||
onTouchMove={stopTouch}
|
||||
>
|
||||
{HOTKEY_BAR.map((entry) => {
|
||||
const isCtrl = entry.sticky === 'ctrl';
|
||||
const armed = isCtrl && ctrlArmed;
|
||||
return (
|
||||
<button
|
||||
key={entry.id}
|
||||
type="button"
|
||||
onClick={() => press(entry)}
|
||||
aria-pressed={isCtrl ? armed : undefined}
|
||||
aria-label={entry.label}
|
||||
className={cn(
|
||||
'shrink-0 rounded border px-2 py-0.5 text-xs font-mono transition-colors',
|
||||
armed
|
||||
? 'border-primary bg-primary text-primary-foreground'
|
||||
: 'border-border text-foreground hover:bg-muted',
|
||||
)}
|
||||
style={{
|
||||
minHeight: 28,
|
||||
minWidth: 36,
|
||||
WebkitTouchCallout: 'none',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{entry.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onFit}
|
||||
aria-label="Fit terminal"
|
||||
title="Fit terminal to container"
|
||||
className="shrink-0 inline-flex items-center justify-center rounded border border-border text-foreground hover:bg-muted"
|
||||
style={{
|
||||
minHeight: 28,
|
||||
minWidth: 36,
|
||||
paddingInline: 8,
|
||||
WebkitTouchCallout: 'none',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
apps/web/src/components/panes/terminal/theme.ts
Normal file
32
apps/web/src/components/panes/terminal/theme.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// xterm color theme + background, shared by the Terminal construction
|
||||
// (TerminalPane) and the SearchBar decorations. Extracted verbatim from the
|
||||
// pre-Phase-9 TerminalPane.
|
||||
|
||||
// Terminal background matches the pane container's `bg-[#0b0f14]` so any
|
||||
// sub-cell rounding remainder is invisible. Update both if the theme changes.
|
||||
export const TERM_BG = '#0b0f14';
|
||||
|
||||
export const XTERM_THEME = {
|
||||
background: TERM_BG,
|
||||
foreground: '#d6deeb',
|
||||
cursor: '#82aaff',
|
||||
selectionBackground: '#1d3b53',
|
||||
black: '#011627',
|
||||
red: '#ef5350',
|
||||
green: '#22da6e',
|
||||
yellow: '#c5e478',
|
||||
blue: '#82aaff',
|
||||
magenta: '#c792ea',
|
||||
cyan: '#7fdbca',
|
||||
white: '#d6deeb',
|
||||
brightBlack: '#575656',
|
||||
brightRed: '#ef5350',
|
||||
brightGreen: '#22da6e',
|
||||
brightYellow: '#ffeb95',
|
||||
brightBlue: '#82aaff',
|
||||
brightMagenta: '#c792ea',
|
||||
brightCyan: '#7fdbca',
|
||||
brightWhite: '#ffffff',
|
||||
};
|
||||
|
||||
export type TermTheme = typeof XTERM_THEME;
|
||||
Reference in New Issue
Block a user