mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-08-25 04:26:29 +00:00
Merge pull request #246 from vxcontrol/feature/frontend
UI terminal: modular architecture, Unicode fix, and security hardening
This commit is contained in:
@@ -1,786 +0,0 @@
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
|
||||
import type { ITerminalOptions, ITheme } from '@xterm/xterm';
|
||||
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { SearchAddon } from '@xterm/addon-search';
|
||||
import { Unicode11Addon } from '@xterm/addon-unicode11';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import { WebglAddon } from '@xterm/addon-webgl';
|
||||
import { Terminal as XTerminal } from '@xterm/xterm';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useCallback, useEffect, useImperativeHandle, useReducer, useRef } from 'react';
|
||||
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { Log } from '@/lib/log';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/**
|
||||
* Sanitizes terminal output by handling binary/non-printable characters.
|
||||
* Preserves ANSI escape sequences for colors and formatting.
|
||||
* Replaces all non-ASCII characters with dots to prevent xterm.js parser errors.
|
||||
*
|
||||
* This aggressive approach is necessary because binary data (like JPEG files)
|
||||
* gets interpreted as UTF-8 by JavaScript, creating "fake" Unicode characters
|
||||
* that cause xterm.js parser to fail.
|
||||
*
|
||||
* @param input - The raw string that may contain binary or non-printable characters
|
||||
* @returns Sanitized string safe for terminal display
|
||||
*/
|
||||
const sanitizeTerminalOutput = (input: string): string => {
|
||||
if (!input) {
|
||||
return input;
|
||||
}
|
||||
|
||||
const result: string[] = [];
|
||||
let index = 0;
|
||||
|
||||
while (index < input.length) {
|
||||
const charCode = input.charCodeAt(index);
|
||||
|
||||
// Check for ANSI escape sequence (ESC [ ... or ESC followed by other sequences)
|
||||
if (charCode === 0x1b) {
|
||||
// ESC character
|
||||
const escapeStart = index;
|
||||
index++;
|
||||
|
||||
if (index < input.length) {
|
||||
const nextChar = input.charAt(index);
|
||||
const nextCharCode = input.charCodeAt(index);
|
||||
|
||||
// CSI sequence: ESC [
|
||||
if (nextChar === '[') {
|
||||
index++;
|
||||
|
||||
// Read until we find the final byte (0x40-0x7E) or hit a problematic char
|
||||
let validSequence = true;
|
||||
|
||||
while (index < input.length) {
|
||||
const seqChar = input.charCodeAt(index);
|
||||
|
||||
// Only allow ASCII characters within CSI sequence
|
||||
if (seqChar > 0x7e || seqChar < 0x20) {
|
||||
validSequence = false;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
index++;
|
||||
|
||||
// Final byte of CSI sequence (letters and some symbols)
|
||||
if (seqChar >= 0x40 && seqChar <= 0x7e) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (validSequence) {
|
||||
result.push(input.slice(escapeStart, index));
|
||||
} else {
|
||||
// Invalid sequence - replace ESC with dot and continue from next char
|
||||
result.push('.');
|
||||
index = escapeStart + 1;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// OSC sequence: ESC ]
|
||||
if (nextChar === ']') {
|
||||
index++;
|
||||
|
||||
let validSequence = true;
|
||||
const maxOscLength = 256; // Reasonable limit for OSC sequences
|
||||
const startIdx = index;
|
||||
|
||||
while (index < input.length && index - startIdx < maxOscLength) {
|
||||
const seqChar = input.charCodeAt(index);
|
||||
|
||||
// BEL terminates OSC
|
||||
if (seqChar === 0x07) {
|
||||
index++;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// ST (ESC \) terminates OSC
|
||||
if (seqChar === 0x1b && index + 1 < input.length && input.charAt(index + 1) === '\\') {
|
||||
index += 2;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// Only allow printable ASCII in OSC sequences
|
||||
if (seqChar > 0x7e || (seqChar < 0x20 && seqChar !== 0x07)) {
|
||||
validSequence = false;
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
index++;
|
||||
}
|
||||
|
||||
// Check if we exceeded max length without finding terminator
|
||||
if (index - startIdx >= maxOscLength) {
|
||||
validSequence = false;
|
||||
}
|
||||
|
||||
if (validSequence) {
|
||||
result.push(input.slice(escapeStart, index));
|
||||
} else {
|
||||
result.push('.');
|
||||
index = escapeStart + 1;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Simple escape sequences: ESC followed by single ASCII char
|
||||
if (nextCharCode >= 0x20 && nextCharCode <= 0x7e) {
|
||||
// Common escape sequences
|
||||
if (/[78cDEHMNOPVWXZ\\^_=><()]/.test(nextChar)) {
|
||||
index++;
|
||||
result.push(input.slice(escapeStart, index));
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown or invalid escape - replace with dot
|
||||
result.push('.');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Preserve standard whitespace characters
|
||||
if (charCode === 0x09 || charCode === 0x0a || charCode === 0x0d) {
|
||||
result.push(input.charAt(index));
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ASCII printable range (0x20-0x7E) - safe to display
|
||||
if (charCode >= 0x20 && charCode <= 0x7e) {
|
||||
result.push(input.charAt(index));
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Everything else (control chars, high-bit chars, Unicode) -> dot
|
||||
// This includes:
|
||||
// - Control characters 0x00-0x1F (except tab, LF, CR)
|
||||
// - DEL (0x7F)
|
||||
// - C1 control characters (0x80-0x9F)
|
||||
// - All Unicode characters above 0x7F
|
||||
// - Surrogate pairs, emoji, CJK, Cyrillic, etc.
|
||||
result.push('.');
|
||||
index++;
|
||||
}
|
||||
|
||||
return result.join('');
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a string contains potentially problematic characters for xterm.js.
|
||||
* Returns true if the string needs sanitization.
|
||||
*
|
||||
* @param input - The string to check
|
||||
* @returns true if string contains problematic characters
|
||||
*/
|
||||
const needsSanitization = (input: string): boolean => {
|
||||
if (!input) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let index = 0; index < input.length; index++) {
|
||||
const charCode = input.charCodeAt(index);
|
||||
|
||||
// Allow standard whitespace (tab, LF, CR)
|
||||
if (charCode === 0x09 || charCode === 0x0a || charCode === 0x0d) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Allow ASCII printable range (0x20-0x7E)
|
||||
if (charCode >= 0x20 && charCode <= 0x7e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Allow ESC character (start of escape sequences)
|
||||
if (charCode === 0x1b) {
|
||||
// Quick validation of escape sequence
|
||||
if (index + 1 < input.length) {
|
||||
const nextChar = input.charAt(index + 1);
|
||||
|
||||
// Common valid escape sequences: ESC[, ESC], ESC(, ESC), etc.
|
||||
if ('[]()\\_'.includes(nextChar)) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Found problematic character (control chars, high-bit, Unicode)
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const terminalOptions: ITerminalOptions = {
|
||||
allowProposedApi: true,
|
||||
allowTransparency: true,
|
||||
convertEol: true,
|
||||
cursorBlink: false,
|
||||
customGlyphs: true,
|
||||
disableStdin: true,
|
||||
fastScrollSensitivity: 10,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
screenReaderMode: false,
|
||||
scrollback: 2500,
|
||||
smoothScrollDuration: 0, // Disable smooth scrolling
|
||||
} as const;
|
||||
|
||||
// Search decoration styles for dark theme - using HEX format as required
|
||||
const darkSearchDecorations = {
|
||||
activeMatchBackground: '#AAAAAA',
|
||||
activeMatchColorOverviewRuler: '#000000',
|
||||
matchBackground: '#666666',
|
||||
matchOverviewRuler: '#000000',
|
||||
} as const;
|
||||
|
||||
// Search decoration styles for light theme - using HEX format as required
|
||||
const lightSearchDecorations = {
|
||||
activeMatchBackground: '#555555',
|
||||
activeMatchColorOverviewRuler: '#000000',
|
||||
matchBackground: '#000000',
|
||||
matchOverviewRuler: '#000000',
|
||||
} as const;
|
||||
|
||||
const darkTheme: ITheme = {
|
||||
background: '#050c13',
|
||||
black: '#f4f4f5',
|
||||
blue: '#60a5fa',
|
||||
brightBlack: '#e4e4e7',
|
||||
brightBlue: '#93c5fd',
|
||||
brightCyan: '#67e8f9',
|
||||
brightGreen: '#86efac',
|
||||
brightMagenta: '#d8b4fe',
|
||||
brightRed: '#fca5a5',
|
||||
brightWhite: '#71717a',
|
||||
brightYellow: '#fde047',
|
||||
cursor: '#f4f4f5',
|
||||
cursorAccent: '#f4f4f5',
|
||||
cyan: '#22d3ee',
|
||||
foreground: '#f4f4f5',
|
||||
green: '#4ade80',
|
||||
magenta: '#c084fc',
|
||||
red: '#f87171',
|
||||
selectionBackground: 'rgba(96, 165, 250, 0.2)',
|
||||
white: '#050c13',
|
||||
yellow: '#facc15',
|
||||
} as const;
|
||||
|
||||
const lightTheme: ITheme = {
|
||||
background: '#ffffff',
|
||||
black: '#020817',
|
||||
blue: '#3b82f6',
|
||||
brightBlack: '#64748b',
|
||||
brightBlue: '#60a5fa',
|
||||
brightCyan: '#22d3ee',
|
||||
brightGreen: '#4ade80',
|
||||
brightMagenta: '#c084fc',
|
||||
brightRed: '#f87171',
|
||||
brightWhite: '#f1f5f9',
|
||||
brightYellow: '#facc15',
|
||||
cursor: '#020817',
|
||||
cursorAccent: '#020817',
|
||||
cyan: '#06b6d4',
|
||||
foreground: '#020817',
|
||||
green: '#22c55e',
|
||||
magenta: '#a855f7',
|
||||
red: '#ef4444',
|
||||
selectionBackground: 'rgba(59, 130, 246, 0.1)',
|
||||
white: '#e2e8f0',
|
||||
yellow: '#eab308',
|
||||
} as const;
|
||||
|
||||
const processLog = (log: string): string =>
|
||||
needsSanitization(log) ? sanitizeTerminalOutput(log) : log;
|
||||
|
||||
type TerminalLifecycle = 'idle' | 'opened' | 'ready';
|
||||
|
||||
interface TerminalProps {
|
||||
className?: string;
|
||||
logs: string[];
|
||||
searchValue?: string;
|
||||
}
|
||||
|
||||
interface TerminalRef {
|
||||
findNext: () => void;
|
||||
findPrevious: () => void;
|
||||
}
|
||||
|
||||
const Terminal = ({
|
||||
className,
|
||||
logs,
|
||||
ref,
|
||||
searchValue,
|
||||
}: TerminalProps & { ref?: React.RefObject<null | TerminalRef> }) => {
|
||||
const terminalRef = useRef<HTMLDivElement>(null);
|
||||
const xtermRef = useRef<null | XTerminal>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const searchAddonRef = useRef<null | SearchAddon>(null);
|
||||
const lastLogIndexRef = useRef<number>(0);
|
||||
const webglAddonRef = useRef<null | WebglAddon>(null);
|
||||
const unicodeAddonRef = useRef<null | Unicode11Addon>(null);
|
||||
const webLinksAddonRef = useRef<null | WebLinksAddon>(null);
|
||||
const contextLossDisposableRef = useRef<null | { dispose: () => void }>(null);
|
||||
const resizeObserverRef = useRef<null | ResizeObserver>(null);
|
||||
const debouncedFitRef = useRef<null | ReturnType<typeof debounce>>(null);
|
||||
const { theme } = useTheme();
|
||||
const [lifecycle, setLifecycle] = useReducer((_: TerminalLifecycle, next: TerminalLifecycle) => next, 'idle');
|
||||
const lifecycleRef = useRef<TerminalLifecycle>('idle');
|
||||
const prevLogsLengthRef = useRef<number>(0);
|
||||
const terminalInitializedRef = useRef(false);
|
||||
const isMountedRef = useRef(true);
|
||||
const initTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const fitTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Determine if current effective theme is dark (considering system preference)
|
||||
const isDarkTheme = useCallback(() => {
|
||||
if (theme === 'dark') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (theme === 'light') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For 'system' theme, check browser's system preference
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}, [theme]);
|
||||
|
||||
// Get search decorations based on current theme
|
||||
const getSearchDecorations = useCallback(() => {
|
||||
return isDarkTheme() ? darkSearchDecorations : lightSearchDecorations;
|
||||
}, [isDarkTheme]);
|
||||
|
||||
// Expose methods to parent component via ref
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
findNext: () => {
|
||||
if (searchAddonRef.current && searchValue?.trim()) {
|
||||
try {
|
||||
searchAddonRef.current.findNext(searchValue.trim(), {
|
||||
caseSensitive: false,
|
||||
decorations: getSearchDecorations(),
|
||||
regex: false,
|
||||
wholeWord: false,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
Log.error('Terminal findNext failed:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
findPrevious: () => {
|
||||
if (searchAddonRef.current && searchValue?.trim()) {
|
||||
try {
|
||||
searchAddonRef.current.findPrevious(searchValue.trim(), {
|
||||
caseSensitive: false,
|
||||
decorations: getSearchDecorations(),
|
||||
regex: false,
|
||||
wholeWord: false,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
Log.error('Terminal findPrevious failed:', error);
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
[searchValue, getSearchDecorations],
|
||||
);
|
||||
|
||||
// Safe terminal operations
|
||||
const safeTerminalOperation = (operation: () => void) => {
|
||||
try {
|
||||
if (isMountedRef.current && xtermRef.current) {
|
||||
operation();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
Log.error('Terminal operation failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Safe fit
|
||||
const safeFit = () => {
|
||||
try {
|
||||
if (
|
||||
isMountedRef.current &&
|
||||
fitAddonRef.current &&
|
||||
terminalRef.current &&
|
||||
terminalRef.current.offsetHeight > 0 &&
|
||||
xtermRef.current
|
||||
) {
|
||||
fitAddonRef.current.fit();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
Log.error('Terminal fit failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Clear all timeouts
|
||||
const clearAllTimeouts = () => {
|
||||
if (initTimeoutRef.current) {
|
||||
clearTimeout(initTimeoutRef.current);
|
||||
initTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
if (fitTimeoutRef.current) {
|
||||
clearTimeout(fitTimeoutRef.current);
|
||||
fitTimeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Track component mount/unmount
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
clearAllTimeouts();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Initialize terminal - only once
|
||||
useEffect(() => {
|
||||
if (!terminalRef.current || terminalInitializedRef.current || !isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
terminalInitializedRef.current = true;
|
||||
|
||||
try {
|
||||
// Create terminal instance with optimized settings
|
||||
const terminal = new XTerminal({
|
||||
...terminalOptions,
|
||||
theme: isDarkTheme() ? darkTheme : lightTheme,
|
||||
});
|
||||
|
||||
xtermRef.current = terminal;
|
||||
|
||||
// Add addons before opening terminal
|
||||
const fitAddon = new FitAddon();
|
||||
fitAddonRef.current = fitAddon;
|
||||
terminal.loadAddon(fitAddon);
|
||||
|
||||
const searchAddon = new SearchAddon();
|
||||
searchAddonRef.current = searchAddon;
|
||||
terminal.loadAddon(searchAddon);
|
||||
|
||||
const unicodeAddon = new Unicode11Addon();
|
||||
unicodeAddonRef.current = unicodeAddon;
|
||||
terminal.loadAddon(unicodeAddon);
|
||||
terminal.unicode.activeVersion = '11';
|
||||
|
||||
const webLinksAddon = new WebLinksAddon();
|
||||
webLinksAddonRef.current = webLinksAddon;
|
||||
terminal.loadAddon(webLinksAddon);
|
||||
|
||||
// Add WebGL addon last (and optionally)
|
||||
try {
|
||||
const webglAddon = new WebglAddon();
|
||||
webglAddonRef.current = webglAddon;
|
||||
terminal.loadAddon(webglAddon);
|
||||
contextLossDisposableRef.current = webglAddon.onContextLoss(() => {
|
||||
if (isMountedRef.current && webglAddonRef.current) {
|
||||
webglAddonRef.current.dispose();
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// Ignore WebGL errors
|
||||
}
|
||||
|
||||
// Set up resize handler
|
||||
const debouncedFit = debounce(() => {
|
||||
if (isMountedRef.current && lifecycleRef.current === 'ready') {
|
||||
safeFit();
|
||||
}
|
||||
}, 150);
|
||||
|
||||
debouncedFitRef.current = debouncedFit;
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (isMountedRef.current && lifecycleRef.current === 'ready') {
|
||||
debouncedFit();
|
||||
}
|
||||
});
|
||||
|
||||
resizeObserverRef.current = resizeObserver;
|
||||
|
||||
// Open terminal with delay
|
||||
// This approach ensures the DOM is ready for rendering
|
||||
initTimeoutRef.current = setTimeout(() => {
|
||||
if (!isMountedRef.current || !terminalRef.current || !xtermRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
terminal.open(terminalRef.current);
|
||||
lifecycleRef.current = 'opened';
|
||||
setLifecycle('opened');
|
||||
|
||||
// Observe size changes only after successful terminal opening
|
||||
if (terminalRef.current && resizeObserverRef.current) {
|
||||
resizeObserverRef.current.observe(terminalRef.current);
|
||||
}
|
||||
|
||||
// Set size with delay to allow DOM to render terminal
|
||||
fitTimeoutRef.current = setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
safeFit();
|
||||
lifecycleRef.current = 'ready';
|
||||
setLifecycle('ready');
|
||||
}
|
||||
}, 200);
|
||||
} catch (error: unknown) {
|
||||
Log.error('Failed to open terminal:', error);
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
// Cleanup on unmount
|
||||
if (initTimeoutRef.current) {
|
||||
clearTimeout(initTimeoutRef.current);
|
||||
}
|
||||
|
||||
if (fitTimeoutRef.current) {
|
||||
clearTimeout(fitTimeoutRef.current);
|
||||
}
|
||||
|
||||
clearAllTimeouts();
|
||||
|
||||
if (resizeObserverRef.current) {
|
||||
resizeObserverRef.current.disconnect();
|
||||
resizeObserverRef.current = null;
|
||||
}
|
||||
|
||||
if (debouncedFitRef.current) {
|
||||
debouncedFitRef.current.cancel();
|
||||
debouncedFitRef.current = null;
|
||||
}
|
||||
|
||||
if (unicodeAddonRef.current) {
|
||||
try {
|
||||
unicodeAddonRef.current.dispose();
|
||||
} catch {
|
||||
// Ignore errors during disposal
|
||||
}
|
||||
|
||||
unicodeAddonRef.current = null;
|
||||
}
|
||||
|
||||
if (webLinksAddonRef.current) {
|
||||
try {
|
||||
webLinksAddonRef.current.dispose();
|
||||
} catch {
|
||||
// Ignore errors during disposal
|
||||
}
|
||||
|
||||
webLinksAddonRef.current = null;
|
||||
}
|
||||
|
||||
if (searchAddonRef.current) {
|
||||
try {
|
||||
searchAddonRef.current.dispose();
|
||||
} catch {
|
||||
// Ignore errors during disposal
|
||||
}
|
||||
|
||||
searchAddonRef.current = null;
|
||||
}
|
||||
|
||||
if (contextLossDisposableRef.current) {
|
||||
try {
|
||||
contextLossDisposableRef.current.dispose();
|
||||
} catch {
|
||||
// Ignore errors during disposal
|
||||
}
|
||||
|
||||
contextLossDisposableRef.current = null;
|
||||
}
|
||||
|
||||
if (webglAddonRef.current) {
|
||||
try {
|
||||
webglAddonRef.current.dispose();
|
||||
} catch {
|
||||
// Ignore errors during disposal
|
||||
}
|
||||
|
||||
webglAddonRef.current = null;
|
||||
}
|
||||
|
||||
if (fitAddonRef.current) {
|
||||
try {
|
||||
fitAddonRef.current.dispose();
|
||||
} catch {
|
||||
// Ignore errors during disposal
|
||||
}
|
||||
|
||||
fitAddonRef.current = null;
|
||||
}
|
||||
|
||||
if (xtermRef.current) {
|
||||
try {
|
||||
xtermRef.current.dispose();
|
||||
} catch {
|
||||
// Ignore errors during disposal
|
||||
}
|
||||
|
||||
xtermRef.current = null;
|
||||
}
|
||||
|
||||
lastLogIndexRef.current = 0;
|
||||
prevLogsLengthRef.current = 0;
|
||||
terminalInitializedRef.current = false;
|
||||
lifecycleRef.current = 'idle';
|
||||
setLifecycle('idle');
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
Log.error('Terminal initialization failed:', error);
|
||||
terminalInitializedRef.current = false;
|
||||
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Handle search functionality with decorations
|
||||
useEffect(() => {
|
||||
if (!searchAddonRef.current || lifecycle !== 'ready' || !isMountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const searchAddon = searchAddonRef.current;
|
||||
|
||||
try {
|
||||
if (searchValue && searchValue.trim()) {
|
||||
// Perform search with theme-appropriate decorations
|
||||
searchAddon.findNext(searchValue.trim(), {
|
||||
caseSensitive: false,
|
||||
decorations: getSearchDecorations(),
|
||||
regex: false,
|
||||
wholeWord: false,
|
||||
});
|
||||
} else {
|
||||
// Clear search highlighting when search value is empty
|
||||
searchAddon.clearDecorations();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
Log.error('Terminal search failed:', error);
|
||||
}
|
||||
}, [searchValue, lifecycle, getSearchDecorations]);
|
||||
|
||||
// Update theme and listen to system theme changes
|
||||
useEffect(() => {
|
||||
const updateTerminalTheme = () => {
|
||||
safeTerminalOperation(() => {
|
||||
if (xtermRef.current) {
|
||||
xtermRef.current.options.theme = isDarkTheme() ? darkTheme : lightTheme;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Update theme immediately
|
||||
updateTerminalTheme();
|
||||
|
||||
// Listen to system theme changes only when theme is 'system'
|
||||
if (theme === 'system') {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
const handleSystemThemeChange = () => {
|
||||
updateTerminalTheme();
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleSystemThemeChange);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handleSystemThemeChange);
|
||||
};
|
||||
}
|
||||
}, [theme, isDarkTheme]);
|
||||
|
||||
// Update logs only when terminal is fully ready
|
||||
useEffect(() => {
|
||||
if (!isMountedRef.current || !xtermRef.current || lifecycle !== 'ready') {
|
||||
return;
|
||||
}
|
||||
|
||||
const terminal = xtermRef.current;
|
||||
|
||||
try {
|
||||
if (logs?.length === 0 && prevLogsLengthRef.current > 0) {
|
||||
safeTerminalOperation(() => {
|
||||
terminal.clear();
|
||||
});
|
||||
lastLogIndexRef.current = 0;
|
||||
prevLogsLengthRef.current = 0;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!logs?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (logs.length >= lastLogIndexRef.current) {
|
||||
const newLogs = logs.slice(lastLogIndexRef.current);
|
||||
|
||||
if (newLogs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
safeTerminalOperation(() => {
|
||||
const batch = newLogs.filter(Boolean).map(processLog).join('\r\n');
|
||||
|
||||
if (batch) {
|
||||
terminal.write(batch + '\r\n');
|
||||
terminal.scrollToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
lastLogIndexRef.current = logs.length;
|
||||
prevLogsLengthRef.current = logs.length;
|
||||
} else {
|
||||
safeTerminalOperation(() => {
|
||||
terminal.clear();
|
||||
|
||||
const batch = logs.filter(Boolean).map(processLog).join('\r\n');
|
||||
|
||||
if (batch) {
|
||||
terminal.write(batch + '\r\n');
|
||||
}
|
||||
|
||||
terminal.scrollToBottom();
|
||||
});
|
||||
|
||||
lastLogIndexRef.current = logs.length;
|
||||
prevLogsLengthRef.current = logs.length;
|
||||
}
|
||||
} catch (error) {
|
||||
Log.error('Terminal log update failed:', error);
|
||||
}
|
||||
}, [logs, lifecycle]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('overflow-hidden', className)}
|
||||
ref={terminalRef}
|
||||
style={{ contain: 'strict' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Terminal.displayName = 'Terminal';
|
||||
|
||||
export default Terminal;
|
||||
@@ -0,0 +1,2 @@
|
||||
export type { TerminalRef } from './terminal';
|
||||
export { default } from './terminal';
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { ITerminalOptions, ITheme } from '@xterm/xterm';
|
||||
|
||||
export const TERMINAL_OPTIONS: ITerminalOptions = {
|
||||
allowProposedApi: true,
|
||||
allowTransparency: true,
|
||||
convertEol: true,
|
||||
cursorBlink: false,
|
||||
customGlyphs: true,
|
||||
disableStdin: true,
|
||||
fastScrollSensitivity: 10,
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
logLevel: 'off',
|
||||
screenReaderMode: false,
|
||||
scrollback: 10_000,
|
||||
smoothScrollDuration: 0,
|
||||
} as const;
|
||||
|
||||
const DARK_THEME: ITheme = {
|
||||
background: '#050c13',
|
||||
black: '#f4f4f5',
|
||||
blue: '#60a5fa',
|
||||
brightBlack: '#e4e4e7',
|
||||
brightBlue: '#93c5fd',
|
||||
brightCyan: '#67e8f9',
|
||||
brightGreen: '#86efac',
|
||||
brightMagenta: '#d8b4fe',
|
||||
brightRed: '#fca5a5',
|
||||
brightWhite: '#71717a',
|
||||
brightYellow: '#fde047',
|
||||
cursor: '#f4f4f5',
|
||||
cursorAccent: '#f4f4f5',
|
||||
cyan: '#22d3ee',
|
||||
foreground: '#f4f4f5',
|
||||
green: '#4ade80',
|
||||
magenta: '#c084fc',
|
||||
red: '#f87171',
|
||||
selectionBackground: 'rgba(96, 165, 250, 0.2)',
|
||||
white: '#050c13',
|
||||
yellow: '#facc15',
|
||||
} as const;
|
||||
|
||||
const LIGHT_THEME: ITheme = {
|
||||
background: '#ffffff',
|
||||
black: '#020817',
|
||||
blue: '#3b82f6',
|
||||
brightBlack: '#64748b',
|
||||
brightBlue: '#60a5fa',
|
||||
brightCyan: '#22d3ee',
|
||||
brightGreen: '#4ade80',
|
||||
brightMagenta: '#c084fc',
|
||||
brightRed: '#f87171',
|
||||
brightWhite: '#f1f5f9',
|
||||
brightYellow: '#facc15',
|
||||
cursor: '#020817',
|
||||
cursorAccent: '#020817',
|
||||
cyan: '#06b6d4',
|
||||
foreground: '#020817',
|
||||
green: '#22c55e',
|
||||
magenta: '#a855f7',
|
||||
red: '#ef4444',
|
||||
selectionBackground: 'rgba(59, 130, 246, 0.1)',
|
||||
white: '#e2e8f0',
|
||||
yellow: '#eab308',
|
||||
} as const;
|
||||
|
||||
const DARK_SEARCH_DECORATIONS = {
|
||||
activeMatchBackground: '#AAAAAA',
|
||||
activeMatchColorOverviewRuler: '#000000',
|
||||
matchBackground: '#666666',
|
||||
matchOverviewRuler: '#000000',
|
||||
} as const;
|
||||
|
||||
const LIGHT_SEARCH_DECORATIONS = {
|
||||
activeMatchBackground: '#555555',
|
||||
activeMatchColorOverviewRuler: '#000000',
|
||||
matchBackground: '#000000',
|
||||
matchOverviewRuler: '#000000',
|
||||
} as const;
|
||||
|
||||
export function getSearchDecorations(isDark: boolean) {
|
||||
return isDark ? DARK_SEARCH_DECORATIONS : LIGHT_SEARCH_DECORATIONS;
|
||||
}
|
||||
|
||||
export function getTerminalTheme(isDark: boolean): ITheme {
|
||||
return isDark ? DARK_THEME : LIGHT_THEME;
|
||||
}
|
||||
|
||||
export function isDarkMode(theme: 'dark' | 'light' | 'system'): boolean {
|
||||
if (theme === 'dark') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (theme === 'light') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
@@ -0,0 +1,728 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { needsSanitization, processLog, sanitizeTerminalOutput } from './terminal-sanitizer';
|
||||
|
||||
// Helper: check that no C1 control bytes (0x80-0x9F) remain in output
|
||||
function hasC1(s: string): boolean {
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const c = s.charCodeAt(i);
|
||||
|
||||
if (c >= 0x80 && c <= 0x9f) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Helper: check that no ESC byte (0x1B) remains in output
|
||||
function hasEsc(s: string): boolean {
|
||||
return s.includes('\x1b');
|
||||
}
|
||||
|
||||
describe('needsSanitization', () => {
|
||||
it('returns false for pure ASCII', () => {
|
||||
expect(needsSanitization('Hello World 123 !@#')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty/null', () => {
|
||||
expect(needsSanitization('')).toBe(false);
|
||||
expect(needsSanitization(null as unknown as string)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for whitespace (TAB, LF, CR)', () => {
|
||||
expect(needsSanitization('line1\nline2\ttab\rcarriage')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for valid Unicode (Cyrillic, CJK, Latin Extended)', () => {
|
||||
expect(needsSanitization('Привет мир')).toBe(false);
|
||||
expect(needsSanitization('你好世界')).toBe(false);
|
||||
expect(needsSanitization('café résumé')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for box-drawing characters', () => {
|
||||
expect(needsSanitization('━━━┃━━━')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for valid emoji (surrogate pairs)', () => {
|
||||
expect(needsSanitization('Hello 🌍 World')).toBe(false);
|
||||
expect(needsSanitization('😀😁😂')).toBe(false);
|
||||
expect(needsSanitization('🇺🇸')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for ESC sequences', () => {
|
||||
expect(needsSanitization('\x1b[31m')).toBe(true);
|
||||
expect(needsSanitization('text\x1b')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for C0 control characters', () => {
|
||||
expect(needsSanitization('\x00')).toBe(true);
|
||||
expect(needsSanitization('\x07')).toBe(true);
|
||||
expect(needsSanitization('text\x01more')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for C1 control characters', () => {
|
||||
expect(needsSanitization('\x80')).toBe(true);
|
||||
expect(needsSanitization('\x90')).toBe(true);
|
||||
expect(needsSanitization('\x9b')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for DEL', () => {
|
||||
expect(needsSanitization('\x7f')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for U+FFFD', () => {
|
||||
expect(needsSanitization('\ufffd')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for lone surrogates', () => {
|
||||
expect(needsSanitization('\uD83C')).toBe(true);
|
||||
expect(needsSanitization('\uDFFF')).toBe(true);
|
||||
expect(needsSanitization('text\uD800')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeTerminalOutput', () => {
|
||||
describe('CSI SGR (colors/styles) — preserved', () => {
|
||||
it('preserves SGR red', () => {
|
||||
expect(processLog('\x1b[31m')).toBe('\x1b[31m');
|
||||
});
|
||||
|
||||
it('preserves SGR reset', () => {
|
||||
expect(processLog('\x1b[0m')).toBe('\x1b[0m');
|
||||
});
|
||||
|
||||
it('preserves SGR bold+red', () => {
|
||||
expect(processLog('\x1b[1;31m')).toBe('\x1b[1;31m');
|
||||
});
|
||||
|
||||
it('preserves SGR 256-color', () => {
|
||||
expect(processLog('\x1b[38;5;196m')).toBe('\x1b[38;5;196m');
|
||||
});
|
||||
|
||||
it('preserves SGR RGB', () => {
|
||||
expect(processLog('\x1b[38;2;255;0;0m')).toBe('\x1b[38;2;255;0;0m');
|
||||
});
|
||||
|
||||
it('preserves SGR in context with surrounding text', () => {
|
||||
const input = 'hello \x1b[31mred\x1b[0m normal';
|
||||
|
||||
expect(processLog(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('preserves multiple SGR sequences', () => {
|
||||
const input = '\x1b[1m\x1b[31mBOLD RED\x1b[0m';
|
||||
|
||||
expect(processLog(input)).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSI non-SGR — blocked with body consumed', () => {
|
||||
it('consumes ED: erase display', () => {
|
||||
expect(processLog('a\x1b[2Jb')).toBe('a.b');
|
||||
});
|
||||
|
||||
it('consumes ED: erase scrollback', () => {
|
||||
expect(processLog('a\x1b[3Jb')).toBe('a.b');
|
||||
});
|
||||
|
||||
it('consumes CUP: cursor home', () => {
|
||||
expect(processLog('a\x1b[Hb')).toBe('a.b');
|
||||
});
|
||||
|
||||
it('consumes SU: scroll up', () => {
|
||||
expect(processLog('a\x1b[999Sb')).toBe('a.b');
|
||||
});
|
||||
|
||||
it('consumes SD: scroll down', () => {
|
||||
expect(processLog('a\x1b[999Tb')).toBe('a.b');
|
||||
});
|
||||
|
||||
it('consumes DECSET: alternate buffer', () => {
|
||||
expect(processLog('a\x1b[?1049hb')).toBe('a.b');
|
||||
});
|
||||
|
||||
it('consumes DECSET: hide cursor', () => {
|
||||
expect(processLog('a\x1b[?25lb')).toBe('a.b');
|
||||
});
|
||||
|
||||
it('consumes DL: delete lines', () => {
|
||||
expect(processLog('a\x1b[10Mb')).toBe('a.b');
|
||||
});
|
||||
|
||||
it('consumes IL: insert lines', () => {
|
||||
expect(processLog('a\x1b[10Lb')).toBe('a.b');
|
||||
});
|
||||
|
||||
it('consumes ICH: insert characters', () => {
|
||||
expect(processLog('a\x1b[10@b')).toBe('a.b');
|
||||
});
|
||||
|
||||
it('preserves surrounding text when consuming CSI', () => {
|
||||
expect(processLog('before\x1b[2Jafter')).toBe('before.after');
|
||||
});
|
||||
|
||||
it('does not consume invalid CSI (no final byte)', () => {
|
||||
const out = processLog('\x1b[');
|
||||
|
||||
expect(out).not.toContain('\x1b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CSI length limit', () => {
|
||||
it('strips CSI exceeding MAX_CSI_LENGTH', () => {
|
||||
const input = '\x1b[' + '1;'.repeat(1000) + 'm';
|
||||
|
||||
expect(processLog(input)).toMatch(/^\./);
|
||||
expect(processLog(input)).not.toContain('\x1b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OSC 8 hyperlinks — safe protocols preserved', () => {
|
||||
it('preserves https link', () => {
|
||||
const input = '\x1b]8;;https://example.com\x07Click\x1b]8;;\x07';
|
||||
|
||||
expect(processLog(input)).toContain('\x1b]8;;https://example.com\x07');
|
||||
});
|
||||
|
||||
it('preserves http link', () => {
|
||||
const input = '\x1b]8;;http://example.com\x07Click\x1b]8;;\x07';
|
||||
|
||||
expect(processLog(input)).toContain('\x1b]8;;http://');
|
||||
});
|
||||
|
||||
it('preserves mailto link', () => {
|
||||
const input = '\x1b]8;;mailto:user@example.com\x07Click\x1b]8;;\x07';
|
||||
|
||||
expect(processLog(input)).toContain('\x1b]8;;mailto:');
|
||||
});
|
||||
|
||||
it('preserves close tag (BEL terminated)', () => {
|
||||
expect(processLog('\x1b]8;;\x07')).toBe('\x1b]8;;\x07');
|
||||
});
|
||||
|
||||
it('preserves close tag (ST terminated)', () => {
|
||||
expect(processLog('\x1b]8;;\x1b\\')).toBe('\x1b]8;;\x1b\\');
|
||||
});
|
||||
|
||||
it('preserves OSC 8 with id param', () => {
|
||||
const input = '\x1b]8;id=foo;https://example.com\x07Click\x1b]8;;\x07';
|
||||
|
||||
expect(processLog(input)).toContain('\x1b]8;id=foo;https://example.com');
|
||||
});
|
||||
|
||||
it('preserves ssh link', () => {
|
||||
const input = '\x1b]8;;ssh://user@host.com\x07Click\x1b]8;;\x07';
|
||||
|
||||
expect(processLog(input)).toContain('\x1b]8;;ssh://');
|
||||
});
|
||||
|
||||
it('preserves telnet link', () => {
|
||||
const input = '\x1b]8;;telnet://host:23\x07Click\x1b]8;;\x07';
|
||||
|
||||
expect(processLog(input)).toContain('\x1b]8;;telnet://');
|
||||
});
|
||||
|
||||
it('preserves full link with ST terminator', () => {
|
||||
const input = '\x1b]8;;https://example.com\x1b\\Click\x1b]8;;\x1b\\';
|
||||
|
||||
expect(processLog(input)).toContain('\x1b]8;;https://example.com\x1b\\');
|
||||
});
|
||||
|
||||
it('preserves long URL up to MAX_OSC_LENGTH (2048)', () => {
|
||||
const longPath = '/path/' + 'a'.repeat(500);
|
||||
const input = '\x1b]8;;https://example.com' + longPath + '\x07Click\x1b]8;;\x07';
|
||||
|
||||
expect(processLog(input)).toContain('https://example.com' + longPath);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OSC 8 hyperlinks — dangerous protocols blocked', () => {
|
||||
it('consumes javascript: URI with body', () => {
|
||||
const input = '\x1b]8;;javascript:alert(1)\x07Click\x1b]8;;\x07';
|
||||
const out = processLog(input);
|
||||
|
||||
expect(out).not.toContain('javascript:');
|
||||
expect(out).toContain('Click');
|
||||
expect(out).toContain('\x1b]8;;\x07');
|
||||
});
|
||||
|
||||
it('consumes data: URI with body', () => {
|
||||
const input = '\x1b]8;;data:text/html,<h1>XSS</h1>\x07Click\x1b]8;;\x07';
|
||||
const out = processLog(input);
|
||||
|
||||
expect(out).not.toContain('data:');
|
||||
});
|
||||
|
||||
it('consumes ftp: URI with body', () => {
|
||||
const input = '\x1b]8;;ftp://evil.com/shell\x07Click\x1b]8;;\x07';
|
||||
const out = processLog(input);
|
||||
|
||||
expect(out).not.toContain('ftp:');
|
||||
});
|
||||
|
||||
it('consumes file: URI with body', () => {
|
||||
const input = '\x1b]8;;file:///etc/passwd\x07Click\x1b]8;;\x07';
|
||||
const out = processLog(input);
|
||||
|
||||
expect(out).not.toContain('file:');
|
||||
});
|
||||
|
||||
it('preserves safe close tag even when open tag is consumed', () => {
|
||||
const input = '\x1b]8;;javascript:x\x07Click\x1b]8;;\x07';
|
||||
const out = processLog(input);
|
||||
|
||||
expect(out).toContain('\x1b]8;;\x07');
|
||||
});
|
||||
|
||||
it('consumes OSC 8 with missing second semicolon', () => {
|
||||
const input = '\x1b]8;https://example.com\x07Click\x1b]8;;\x07';
|
||||
const out = processLog(input);
|
||||
|
||||
expect(out).not.toContain('8;https:');
|
||||
});
|
||||
|
||||
it('consumes OSC 8 with unknown protocol', () => {
|
||||
const input = '\x1b]8;;custom://something\x07Click\x1b]8;;\x07';
|
||||
const out = processLog(input);
|
||||
|
||||
expect(out).not.toContain('custom:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OSC non-hyperlink — blocked with body consumed', () => {
|
||||
it('consumes OSC 0: window title', () => {
|
||||
expect(processLog('\x1b]0;HACKED\x07')).toBe('.');
|
||||
});
|
||||
|
||||
it('consumes OSC 2: window title', () => {
|
||||
expect(processLog('\x1b]2;HACKED\x07')).toBe('.');
|
||||
});
|
||||
|
||||
it('consumes OSC 10: foreground color', () => {
|
||||
expect(processLog('\x1b]10;#ff0000\x07')).toBe('.');
|
||||
});
|
||||
|
||||
it('consumes OSC 11: background color', () => {
|
||||
expect(processLog('\x1b]11;#00ff00\x07')).toBe('.');
|
||||
});
|
||||
|
||||
it('consumes OSC 12: cursor color', () => {
|
||||
expect(processLog('\x1b]12;#0000ff\x07')).toBe('.');
|
||||
});
|
||||
|
||||
it('consumes OSC 4: palette color', () => {
|
||||
expect(processLog('\x1b]4;1;#ff0000\x07')).toBe('.');
|
||||
});
|
||||
|
||||
it('preserves surrounding text when consuming OSC', () => {
|
||||
expect(processLog('before\x1b]0;TITLE\x07after')).toBe('before.after');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ESC simple sequences — all blocked', () => {
|
||||
it('strips ESC 7 (save cursor)', () => {
|
||||
expect(hasEsc(processLog('\x1b7'))).toBe(false);
|
||||
});
|
||||
|
||||
it('strips ESC 8 (restore cursor)', () => {
|
||||
expect(hasEsc(processLog('\x1b8'))).toBe(false);
|
||||
});
|
||||
|
||||
it('strips ESC D (index)', () => {
|
||||
expect(hasEsc(processLog('\x1bD'))).toBe(false);
|
||||
});
|
||||
|
||||
it('strips ESC E (next line)', () => {
|
||||
expect(hasEsc(processLog('\x1bE'))).toBe(false);
|
||||
});
|
||||
|
||||
it('strips ESC M (reverse index)', () => {
|
||||
expect(hasEsc(processLog('\x1bM'))).toBe(false);
|
||||
});
|
||||
|
||||
it('strips ESC _ (APC) with body consumed', () => {
|
||||
const out = processLog('\x1b_payload\x1b\\');
|
||||
|
||||
expect(hasEsc(out)).toBe(false);
|
||||
expect(out).not.toContain('payload');
|
||||
});
|
||||
|
||||
it('strips ESC ^ (PM) with body consumed', () => {
|
||||
const out = processLog('\x1b^payload\x1b\\');
|
||||
|
||||
expect(hasEsc(out)).toBe(false);
|
||||
expect(out).not.toContain('payload');
|
||||
});
|
||||
|
||||
it('strips ESC X (SOS) with body consumed', () => {
|
||||
const out = processLog('\x1bXpayload\x1b\\');
|
||||
|
||||
expect(hasEsc(out)).toBe(false);
|
||||
expect(out).not.toContain('payload');
|
||||
});
|
||||
|
||||
it('strips ESC P (DCS) with body consumed', () => {
|
||||
const out = processLog('\x1bP0;1|data\x1b\\');
|
||||
|
||||
expect(hasEsc(out)).toBe(false);
|
||||
expect(out).not.toContain('data');
|
||||
});
|
||||
|
||||
it('strips ESC ( (charset G0)', () => {
|
||||
expect(hasEsc(processLog('\x1b(B'))).toBe(false);
|
||||
});
|
||||
|
||||
it('strips truncated ESC at end of input', () => {
|
||||
expect(processLog('text\x1b')).toBe('text.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('C0 control characters', () => {
|
||||
it('preserves TAB', () => {
|
||||
expect(processLog('a\tb')).toBe('a\tb');
|
||||
});
|
||||
|
||||
it('preserves LF', () => {
|
||||
expect(processLog('a\nb')).toBe('a\nb');
|
||||
});
|
||||
|
||||
it('preserves CR', () => {
|
||||
expect(processLog('a\rb')).toBe('a\rb');
|
||||
});
|
||||
|
||||
it('strips NUL via binary detection', () => {
|
||||
expect(processLog('x\x00y')).toBe('[binary data]');
|
||||
});
|
||||
|
||||
it('strips BEL', () => {
|
||||
expect(processLog('before\x07after')).toBe('before.after');
|
||||
});
|
||||
|
||||
it('strips BS', () => {
|
||||
expect(processLog('before\x08after')).toBe('before.after');
|
||||
});
|
||||
|
||||
it('strips SOH, STX, ETX', () => {
|
||||
expect(processLog('a\x01\x02\x03b')).toBe('a...b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('C1 control characters (0x80-0x9F)', () => {
|
||||
it('strips all C1 bytes', () => {
|
||||
const padding = 'A'.repeat(400);
|
||||
const c1Chars = String.fromCharCode(...Array.from({ length: 32 }, (_, i) => 0x80 + i));
|
||||
const input = padding + c1Chars + 'end';
|
||||
const out = processLog(input);
|
||||
|
||||
expect(hasC1(out)).toBe(false);
|
||||
expect(out).toContain('end');
|
||||
});
|
||||
|
||||
it('strips C1 DCS (0x90)', () => {
|
||||
expect(processLog('a\x90b')).toBe('a.b');
|
||||
});
|
||||
|
||||
it('strips C1 CSI (0x9B)', () => {
|
||||
expect(processLog('a\x9bb')).toBe('a.b');
|
||||
});
|
||||
|
||||
it('strips C1 OSC (0x9D)', () => {
|
||||
expect(processLog('a\x9db')).toBe('a.b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DEL and U+FFFD', () => {
|
||||
it('strips DEL (0x7F)', () => {
|
||||
expect(processLog('a\x7fb')).toBe('a.b');
|
||||
});
|
||||
|
||||
it('strips U+FFFD', () => {
|
||||
expect(processLog('a\ufffdb')).toBe('a.b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Unicode preservation', () => {
|
||||
it('preserves Cyrillic', () => {
|
||||
expect(processLog('Привет мир')).toBe('Привет мир');
|
||||
});
|
||||
|
||||
it('preserves CJK', () => {
|
||||
expect(processLog('你好世界')).toBe('你好世界');
|
||||
});
|
||||
|
||||
it('preserves Latin Extended', () => {
|
||||
expect(processLog('café résumé')).toBe('café résumé');
|
||||
});
|
||||
|
||||
it('preserves emoji', () => {
|
||||
expect(processLog('Hello 🌍 World')).toBe('Hello 🌍 World');
|
||||
});
|
||||
|
||||
it('preserves box-drawing', () => {
|
||||
expect(processLog('━━━┃━━━')).toBe('━━━┃━━━');
|
||||
});
|
||||
|
||||
it('preserves mixed Unicode with SGR', () => {
|
||||
const input = '\x1b[31mПривет\x1b[0m 🌍';
|
||||
|
||||
expect(processLog(input)).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe('binary content detection', () => {
|
||||
it('detects null byte as binary', () => {
|
||||
expect(processLog('text\x00more')).toBe('[binary data]');
|
||||
});
|
||||
|
||||
it('detects lone surrogates as binary', () => {
|
||||
const input = 'A'.repeat(100) + '\uD800' + 'B'.repeat(100);
|
||||
|
||||
expect(processLog(input)).toBe('[binary data]');
|
||||
});
|
||||
|
||||
it('detects high control char density as binary', () => {
|
||||
const input = Array.from({ length: 100 }, (_, i) => String.fromCharCode(i < 15 ? 0x01 + i : 0x41)).join('');
|
||||
|
||||
expect(processLog(input)).toBe('[binary data]');
|
||||
});
|
||||
|
||||
it('does not false-positive on short strings with BEL', () => {
|
||||
expect(processLog('\x1b]8;;\x07')).not.toBe('[binary data]');
|
||||
});
|
||||
|
||||
it('does not false-positive on short strings without null', () => {
|
||||
expect(processLog('\x07\x08\x01')).not.toBe('[binary data]');
|
||||
});
|
||||
|
||||
it('passes through valid content after 512-byte sample window', () => {
|
||||
const input = 'A'.repeat(600) + '\x90';
|
||||
const out = processLog(input);
|
||||
|
||||
expect(out).not.toBe('[binary data]');
|
||||
expect(hasC1(out)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles empty string', () => {
|
||||
expect(sanitizeTerminalOutput('')).toBe('');
|
||||
});
|
||||
|
||||
it('handles null', () => {
|
||||
expect(sanitizeTerminalOutput(null as unknown as string)).toBe(null);
|
||||
});
|
||||
|
||||
it('handles double ESC', () => {
|
||||
expect(processLog('\x1b\x1b')).toBe('..');
|
||||
});
|
||||
|
||||
it('handles ESC followed by non-printable', () => {
|
||||
expect(processLog('\x1b\x01')).toBe('..');
|
||||
});
|
||||
|
||||
it('handles ESC [ without final byte', () => {
|
||||
const out = processLog('\x1b[');
|
||||
|
||||
expect(out).not.toContain('\x1b');
|
||||
});
|
||||
|
||||
it('handles very long input without stack overflow', () => {
|
||||
const input = '\x1b[31m' + 'A'.repeat(1_000_000) + '\x1b[0m';
|
||||
const out = processLog(input);
|
||||
|
||||
expect(out).toContain('\x1b[31m');
|
||||
expect(out.length).toBe(input.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('processLog fast path', () => {
|
||||
it('skips sanitization for clean ASCII', () => {
|
||||
const input = 'Hello World';
|
||||
|
||||
expect(processLog(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('skips sanitization for clean Unicode', () => {
|
||||
const input = 'Привет мир 🌍';
|
||||
|
||||
expect(processLog(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('runs sanitization for strings with ESC', () => {
|
||||
const input = '\x1b[31mred\x1b[0m';
|
||||
|
||||
expect(processLog(input)).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
describe('string sequence body consumption (DCS/APC/PM/SOS)', () => {
|
||||
it('consumes DCS body: ESC P body ESC \\ → single dot', () => {
|
||||
expect(processLog('before\x1bPbody\x1b\\after')).toBe('before.after');
|
||||
});
|
||||
|
||||
it('consumes APC body: ESC _ body ESC \\ → single dot', () => {
|
||||
expect(processLog('before\x1b_body\x1b\\after')).toBe('before.after');
|
||||
});
|
||||
|
||||
it('consumes PM body: ESC ^ body ESC \\ → single dot', () => {
|
||||
expect(processLog('before\x1b^body\x1b\\after')).toBe('before.after');
|
||||
});
|
||||
|
||||
it('consumes SOS body: ESC X body ESC \\ → single dot', () => {
|
||||
expect(processLog('before\x1bXbody\x1b\\after')).toBe('before.after');
|
||||
});
|
||||
|
||||
it('consumes DCS with C1 ST terminator (0x9C)', () => {
|
||||
expect(processLog('before\x1bPbody\x9cafter')).toBe('before.after');
|
||||
});
|
||||
|
||||
it('handles unterminated DCS — consumes until end of input', () => {
|
||||
const out = processLog('before\x1bPbody');
|
||||
|
||||
expect(out).toBe('before.');
|
||||
expect(out).not.toContain('body');
|
||||
});
|
||||
|
||||
it('handles unterminated APC — consumes until end of input', () => {
|
||||
const out = processLog('before\x1b_body');
|
||||
|
||||
expect(out).toBe('before.');
|
||||
expect(out).not.toContain('body');
|
||||
});
|
||||
|
||||
it('consumes DCS with ESC sequences inside body', () => {
|
||||
const out = processLog('before\x1bP\x1b[31m\x1b\\after');
|
||||
|
||||
expect(out).toBe('before.after');
|
||||
});
|
||||
|
||||
it('consumes long DCS body without hanging', () => {
|
||||
const longBody = 'X'.repeat(50_000);
|
||||
const out = processLog('a\x1bP' + longBody + '\x1b\\b');
|
||||
|
||||
expect(out).toBe('a.b');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bidi override characters — stripped', () => {
|
||||
it('strips RTL override U+202E', () => {
|
||||
expect(processLog('hello\u202Eworld')).toBe('hello.world');
|
||||
});
|
||||
|
||||
it('strips LTR mark U+200E', () => {
|
||||
expect(processLog('a\u200Eb')).toBe('a.b');
|
||||
});
|
||||
|
||||
it('strips RTL mark U+200F', () => {
|
||||
expect(processLog('a\u200Fb')).toBe('a.b');
|
||||
});
|
||||
|
||||
it('strips LTR embedding U+202A', () => {
|
||||
expect(processLog('a\u202Ab')).toBe('a.b');
|
||||
});
|
||||
|
||||
it('strips first-strong isolate U+2068', () => {
|
||||
expect(processLog('a\u2068b')).toBe('a.b');
|
||||
});
|
||||
|
||||
it('strips pop directional isolate U+2069', () => {
|
||||
expect(processLog('a\u2069b')).toBe('a.b');
|
||||
});
|
||||
|
||||
it('strips LTR isolate U+2066', () => {
|
||||
expect(processLog('a\u2066b')).toBe('a.b');
|
||||
});
|
||||
|
||||
it('strips multiple bidi controls in sequence', () => {
|
||||
expect(processLog('a\u202E\u202D\u200Fb')).toBe('a...b');
|
||||
});
|
||||
|
||||
it('preserves normal Unicode alongside bidi stripping', () => {
|
||||
expect(processLog('Привет\u202Eмир')).toBe('Привет.мир');
|
||||
});
|
||||
|
||||
it('preserves SGR alongside bidi stripping', () => {
|
||||
const out = processLog('\x1b[31m\u202Etext\x1b[0m');
|
||||
|
||||
expect(out).toBe('\x1b[31m.text\x1b[0m');
|
||||
});
|
||||
});
|
||||
|
||||
describe('needsSanitization — bidi detection', () => {
|
||||
it('returns true for RTL override U+202E', () => {
|
||||
expect(needsSanitization('hello\u202Eworld')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for LTR mark U+200E', () => {
|
||||
expect(needsSanitization('text\u200E')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for RTL mark U+200F', () => {
|
||||
expect(needsSanitization('text\u200F')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for isolate U+2066', () => {
|
||||
expect(needsSanitization('text\u2066')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for non-bidi Unicode above 0xA0', () => {
|
||||
expect(needsSanitization('\u2010\u2014\u2026')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OSC 8 with Unicode URI', () => {
|
||||
it('preserves OSC 8 link with Cyrillic path', () => {
|
||||
const input = '\x1b]8;;https://example.com/путь\x07Click\x1b]8;;\x07';
|
||||
const out = processLog(input);
|
||||
|
||||
expect(out).toContain('https://example.com/путь');
|
||||
expect(out).toContain('\x1b]8;;https://example.com/путь\x07');
|
||||
});
|
||||
|
||||
it('preserves OSC 8 link with CJK path', () => {
|
||||
const input = '\x1b]8;;https://example.com/文档\x07Link\x1b]8;;\x07';
|
||||
const out = processLog(input);
|
||||
|
||||
expect(out).toContain('https://example.com/文档');
|
||||
});
|
||||
|
||||
it('rejects OSC with C1 control in content', () => {
|
||||
const input = '\x1b]8;;\x90https://x\x07Click\x1b]8;;\x07';
|
||||
const out = processLog(input);
|
||||
|
||||
expect(out).not.toContain('\x1b]8;;\x90');
|
||||
});
|
||||
|
||||
it('rejects OSC with DEL in content', () => {
|
||||
const input = '\x1b]8;;\x7fhttps://x\x07Click\x1b]8;;\x07';
|
||||
const out = processLog(input);
|
||||
|
||||
expect(out).not.toContain('\x1b]8;;\x7f');
|
||||
});
|
||||
});
|
||||
|
||||
describe('performance', () => {
|
||||
it('processes 1M chars in under 500ms', () => {
|
||||
const input = '\x1b[31m' + 'A'.repeat(100_000) + '\x1b[0m';
|
||||
const start = performance.now();
|
||||
|
||||
for (let i = 0; i < 100; i++) {
|
||||
sanitizeTerminalOutput(input);
|
||||
}
|
||||
|
||||
const duration = performance.now() - start;
|
||||
|
||||
expect(duration).toBeLessThan(500);
|
||||
});
|
||||
|
||||
it('handles 1M ESC bytes without ReDoS', () => {
|
||||
const input = '\x1b'.repeat(1_000_000);
|
||||
const start = performance.now();
|
||||
sanitizeTerminalOutput(input);
|
||||
const duration = performance.now() - start;
|
||||
|
||||
expect(duration).toBeLessThan(1000);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,414 @@
|
||||
const BINARY_SAMPLE_SIZE = 512;
|
||||
const BINARY_CONTROL_RATIO = 0.1;
|
||||
const MAX_CSI_LENGTH = 256;
|
||||
const MAX_OSC_LENGTH = 2048;
|
||||
const MAX_STRING_SEQUENCE_LENGTH = 100_000;
|
||||
|
||||
export const SAFE_PROTOCOLS = ['http://', 'https://', 'mailto:', 'ssh://', 'telnet://'];
|
||||
|
||||
/**
|
||||
* Quick scan to determine if a string needs full sanitization.
|
||||
* Returns false for strings that are purely ASCII printable + whitespace + valid Unicode.
|
||||
*/
|
||||
export function needsSanitization(input: string): boolean {
|
||||
if (!input) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
const code = input.charCodeAt(i);
|
||||
|
||||
if (code === 0x09 || code === 0x0a || code === 0x0d) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (code >= 0x20 && code <= 0x7e) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (code >= 0xa0) {
|
||||
if (code >= 0xd800 && code <= 0xdbff) {
|
||||
if (i + 1 < input.length) {
|
||||
const next = input.charCodeAt(i + 1);
|
||||
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
i++;
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if ((code >= 0xdc00 && code <= 0xdfff) || code === 0xfffd) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isBidiControl(code)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function processLog(log: string): string {
|
||||
return needsSanitization(log) ? sanitizeTerminalOutput(log) : log;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes terminal output for safe display in a read-only xterm.js terminal.
|
||||
*
|
||||
* Uses a slice-based approach: tracks ranges of safe characters and extracts
|
||||
* them via input.slice() instead of pushing individual characters, reducing
|
||||
* allocations from millions to thousands on large inputs.
|
||||
*
|
||||
* Preserves:
|
||||
* - Valid Unicode text (Cyrillic, CJK, Latin Extended, emoji, etc.)
|
||||
* - CSI SGR sequences (text color/style: ESC[...m)
|
||||
* - OSC 8 hyperlinks with safe protocols (http, https, mailto, ssh, telnet)
|
||||
*
|
||||
* Strips:
|
||||
* - C0/C1 control characters (except TAB, LF, CR)
|
||||
* - All non-SGR CSI sequences (cursor movement, erase, scroll, DECSET)
|
||||
* - OSC color/title/palette changes (OSC 0, 4, 10, 11, 12)
|
||||
* - OSC 8 links with dangerous protocols (javascript:, data:, etc.)
|
||||
* - All simple ESC sequences (cursor save/restore, index, charset)
|
||||
* - DCS, APC, PM, SOS string sequences (body consumed until ST)
|
||||
* - Binary content (replaced with placeholder)
|
||||
*/
|
||||
export function sanitizeTerminalOutput(input: string): string {
|
||||
if (!input) {
|
||||
return input;
|
||||
}
|
||||
|
||||
if (isBinaryContent(input)) {
|
||||
return '[binary data]';
|
||||
}
|
||||
|
||||
const result: string[] = [];
|
||||
let i = 0;
|
||||
let safeStart = 0;
|
||||
|
||||
while (i < input.length) {
|
||||
const code = input.charCodeAt(i);
|
||||
|
||||
// Fast path: ASCII printable (0x20-0x7E) and safe whitespace
|
||||
if ((code >= 0x20 && code <= 0x7e) || code === 0x09 || code === 0x0a || code === 0x0d) {
|
||||
i++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Valid Unicode (>= 0xA0, excluding lone surrogates, U+FFFD, and bidi controls)
|
||||
if (code >= 0xa0) {
|
||||
// Valid surrogate pair (emoji, supplementary chars) — skip both units
|
||||
if (code >= 0xd800 && code <= 0xdbff) {
|
||||
if (i + 1 < input.length) {
|
||||
const next = input.charCodeAt(i + 1);
|
||||
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
i += 2;
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Lone/invalid surrogate — falls through to replacement below
|
||||
} else if (code !== 0xfffd && !(code >= 0xdc00 && code <= 0xdfff) && !isBidiControl(code)) {
|
||||
i++;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// Lone surrogates, U+FFFD, and bidi controls fall through to replacement below
|
||||
}
|
||||
|
||||
// We hit a character that needs handling — flush the safe range
|
||||
if (i > safeStart) {
|
||||
result.push(input.slice(safeStart, i));
|
||||
}
|
||||
|
||||
if (code === 0x1b) {
|
||||
i++;
|
||||
|
||||
if (i >= input.length) {
|
||||
result.push('.');
|
||||
safeStart = i;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
const next = input.charCodeAt(i);
|
||||
|
||||
// CSI sequence (ESC [) — only allow SGR (text style/color)
|
||||
if (next === 0x5b) {
|
||||
const parsed = parseCsiSequence(input, i + 1);
|
||||
|
||||
if (parsed.end !== -1 && parsed.final === 'm') {
|
||||
result.push(input.slice(i - 1, parsed.end));
|
||||
i = parsed.end;
|
||||
safeStart = i;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (parsed.end !== -1) {
|
||||
i = parsed.end;
|
||||
}
|
||||
|
||||
result.push('.');
|
||||
safeStart = i;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// OSC sequence (ESC ]) — only allow safe OSC 8 hyperlinks
|
||||
if (next === 0x5d) {
|
||||
const end = parseOscSequence(input, i + 1);
|
||||
|
||||
if (end !== -1) {
|
||||
const termLen = input.charCodeAt(end - 1) === 0x07 ? 1 : 2;
|
||||
const oscContent = input.slice(i + 1, end - termLen);
|
||||
|
||||
if (isOscSafe(oscContent)) {
|
||||
result.push(input.slice(i - 1, end));
|
||||
i = end;
|
||||
safeStart = i;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
i = end;
|
||||
}
|
||||
|
||||
result.push('.');
|
||||
safeStart = i;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// DCS (ESC P), SOS (ESC X), PM (ESC ^), APC (ESC _) —
|
||||
// consume the entire body up to ST terminator
|
||||
if (next === 0x50 || next === 0x58 || next === 0x5e || next === 0x5f) {
|
||||
i = skipToST(input, i + 1);
|
||||
result.push('.');
|
||||
safeStart = i;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// All other ESC sequences (cursor save/restore, index, charset, etc.)
|
||||
result.push('.');
|
||||
safeStart = i;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// C0 controls, DEL, C1 controls, lone surrogates, U+FFFD
|
||||
result.push('.');
|
||||
i++;
|
||||
safeStart = i;
|
||||
}
|
||||
|
||||
if (safeStart < input.length) {
|
||||
result.push(input.slice(safeStart));
|
||||
}
|
||||
|
||||
return result.join('');
|
||||
}
|
||||
|
||||
function isBidiControl(code: number): boolean {
|
||||
return (
|
||||
(code >= 0x200e && code <= 0x200f) || (code >= 0x202a && code <= 0x202e) || (code >= 0x2066 && code <= 0x2069)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristic detection of binary content in a JS string.
|
||||
* Binary data (e.g. JPEG) interpreted as UTF-16 produces null bytes,
|
||||
* lone surrogates, and dense clusters of control characters.
|
||||
*
|
||||
* For short strings (< 32 chars), only checks for null bytes to avoid
|
||||
* false positives on terminal sequences containing BEL or other C0 codes.
|
||||
*/
|
||||
function isBinaryContent(input: string): boolean {
|
||||
// Short strings: only null byte is a reliable binary indicator
|
||||
if (input.length < 32) {
|
||||
return input.includes('\x00');
|
||||
}
|
||||
|
||||
let controlCount = 0;
|
||||
const sampleSize = Math.min(input.length, BINARY_SAMPLE_SIZE);
|
||||
|
||||
for (let i = 0; i < sampleSize; i++) {
|
||||
const code = input.charCodeAt(i);
|
||||
|
||||
// Null byte — immediate binary indicator
|
||||
if (code === 0x00) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Lone surrogates indicate corrupted/binary data
|
||||
if (code >= 0xd800 && code <= 0xdfff) {
|
||||
if (code <= 0xdbff && i + 1 < input.length) {
|
||||
const next = input.charCodeAt(i + 1);
|
||||
|
||||
// Valid surrogate pair — skip
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
i++;
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Count C0 controls (except TAB, LF, CR, ESC which are common in terminal data)
|
||||
if (code < 0x20 && code !== 0x09 && code !== 0x0a && code !== 0x0d && code !== 0x1b) {
|
||||
controlCount++;
|
||||
}
|
||||
|
||||
// Count C1 controls (0x80-0x9F)
|
||||
if (code >= 0x80 && code <= 0x9f) {
|
||||
controlCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// High density of control characters indicates binary content
|
||||
return controlCount / sampleSize > BINARY_CONTROL_RATIO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a parsed OSC sequence content is safe for display.
|
||||
* Only allows OSC 8 hyperlinks with safe protocols (http, https, mailto).
|
||||
* Blocks OSC 0 (title), OSC 4/10/11/12 (color changes), and unsafe URIs.
|
||||
*
|
||||
* OSC 8 format: "8;params;uri" where params is optional key=value pairs.
|
||||
* Empty URI (close tag "8;;") is always safe.
|
||||
*/
|
||||
function isOscSafe(content: string): boolean {
|
||||
// Must be OSC 8 (hyperlink)
|
||||
if (!content.startsWith('8;')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find the URI after the second semicolon: "8;params;uri"
|
||||
const secondSemicolon = content.indexOf(';', 2);
|
||||
|
||||
if (secondSemicolon === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const uri = content.slice(secondSemicolon + 1);
|
||||
|
||||
// Empty URI = close tag (ESC]8;;BEL) — always safe
|
||||
if (!uri) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Validate protocol against whitelist (blocks javascript:, data:, ftp:, file:, etc.)
|
||||
const uriLower = uri.toLowerCase();
|
||||
|
||||
return SAFE_PROTOCOLS.some((protocol) => uriLower.startsWith(protocol));
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a CSI sequence (ESC [). Returns the end index and final byte,
|
||||
* or end=-1 if invalid. Enforces a length limit to prevent unbounded scanning.
|
||||
*
|
||||
* CSI format: ESC [ [params] [intermediates] final_byte
|
||||
* - Parameter bytes: 0x30-0x3F (digits, semicolons, etc.)
|
||||
* - Intermediate bytes: 0x20-0x2F (space, !, ", etc.)
|
||||
* - Final byte: 0x40-0x7E (letters — 'm' for SGR, 'H' for CUP, etc.)
|
||||
*/
|
||||
function parseCsiSequence(input: string, start: number): { end: number; final: string } {
|
||||
let i = start;
|
||||
|
||||
while (i < input.length && i - start < MAX_CSI_LENGTH) {
|
||||
const code = input.charCodeAt(i);
|
||||
|
||||
if (code < 0x20 || code > 0x7e) {
|
||||
return { end: -1, final: '' };
|
||||
}
|
||||
|
||||
i++;
|
||||
|
||||
if (code >= 0x40 && code <= 0x7e) {
|
||||
return { end: i, final: String.fromCharCode(code) };
|
||||
}
|
||||
}
|
||||
|
||||
return { end: -1, final: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an OSC sequence (ESC ]). Terminated by BEL (0x07) or ST (ESC \).
|
||||
* Returns end index (after terminator), or -1 if invalid.
|
||||
*
|
||||
* OSC format: ESC ] content BEL or ESC ] content ESC \
|
||||
* Allows ASCII printables (0x20-0x7E) and Unicode >= 0xA0 per xterm.js parser
|
||||
* (which accepts "any codepoint greater than C1 as printable").
|
||||
* Blocks C0 controls (except BEL as terminator), DEL, and C1 controls (0x80-0x9F).
|
||||
*/
|
||||
function parseOscSequence(input: string, start: number): number {
|
||||
let i = start;
|
||||
|
||||
while (i < input.length && i - start < MAX_OSC_LENGTH) {
|
||||
const code = input.charCodeAt(i);
|
||||
|
||||
if (code === 0x07) {
|
||||
return i + 1;
|
||||
}
|
||||
|
||||
if (code === 0x1b && i + 1 < input.length && input.charAt(i + 1) === '\\') {
|
||||
return i + 2;
|
||||
}
|
||||
|
||||
if (code < 0x20 && code !== 0x07) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (code === 0x7f || (code >= 0x80 && code <= 0x9f)) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skips a string-type sequence body (DCS, SOS, PM, APC) until the ST terminator.
|
||||
* ST is either ESC \ (7-bit) or the C1 byte 0x9C (8-bit).
|
||||
* Returns the index after ST, or the end of input if unterminated.
|
||||
* Enforces a length limit to prevent unbounded scanning.
|
||||
*/
|
||||
function skipToST(input: string, start: number): number {
|
||||
let i = start;
|
||||
const limit = Math.min(input.length, start + MAX_STRING_SEQUENCE_LENGTH);
|
||||
|
||||
while (i < limit) {
|
||||
const code = input.charCodeAt(i);
|
||||
|
||||
if (code === 0x9c) {
|
||||
return i + 1;
|
||||
}
|
||||
|
||||
if (code === 0x1b && i + 1 < input.length && input.charCodeAt(i + 1) === 0x5c) {
|
||||
return i + 2;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return i;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useImperativeHandle, useRef } from 'react';
|
||||
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { processLog } from './terminal-sanitizer';
|
||||
import { useTerminalSearch } from './use-terminal-search';
|
||||
import { useXterm } from './use-xterm';
|
||||
|
||||
interface TerminalProps {
|
||||
className?: string;
|
||||
logs: string[];
|
||||
searchValue?: string;
|
||||
}
|
||||
|
||||
interface TerminalRef {
|
||||
findNext: () => void;
|
||||
findPrevious: () => void;
|
||||
}
|
||||
|
||||
const Terminal = ({
|
||||
className,
|
||||
logs,
|
||||
ref,
|
||||
searchValue,
|
||||
}: TerminalProps & { ref?: React.RefObject<null | TerminalRef> }) => {
|
||||
const { theme } = useTheme();
|
||||
const { clear, containerRef, isReady, scrollToBottom, searchAddon, write } = useXterm({ theme });
|
||||
const { findNext, findPrevious } = useTerminalSearch(searchAddon, isReady, searchValue, theme);
|
||||
|
||||
const lastLogIndexRef = useRef(0);
|
||||
const prevLogsLengthRef = useRef(0);
|
||||
|
||||
useImperativeHandle(ref, () => ({ findNext, findPrevious }), [findNext, findPrevious]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (logs.length === 0 && prevLogsLengthRef.current > 0) {
|
||||
clear();
|
||||
lastLogIndexRef.current = 0;
|
||||
prevLogsLengthRef.current = 0;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (logs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (logs.length >= lastLogIndexRef.current) {
|
||||
const newLogs = logs.slice(lastLogIndexRef.current);
|
||||
|
||||
if (newLogs.length > 0) {
|
||||
const batch = newLogs.filter(Boolean).map(processLog).join('\r\n');
|
||||
|
||||
if (batch) {
|
||||
write(batch + '\r\n');
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
clear();
|
||||
|
||||
const batch = logs.filter(Boolean).map(processLog).join('\r\n');
|
||||
|
||||
if (batch) {
|
||||
write(batch + '\r\n');
|
||||
}
|
||||
|
||||
scrollToBottom();
|
||||
}
|
||||
|
||||
lastLogIndexRef.current = logs.length;
|
||||
prevLogsLengthRef.current = logs.length;
|
||||
}, [logs, isReady, write, clear, scrollToBottom]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('overflow-hidden', className)}
|
||||
ref={containerRef}
|
||||
style={{ contain: 'strict' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Terminal.displayName = 'Terminal';
|
||||
|
||||
export type { TerminalRef };
|
||||
export default Terminal;
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { SearchAddon } from '@xterm/addon-search';
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
|
||||
import { Log } from '@/lib/log';
|
||||
|
||||
import { getSearchDecorations, isDarkMode } from './terminal-config';
|
||||
|
||||
interface UseTerminalSearchResult {
|
||||
findNext: () => void;
|
||||
findPrevious: () => void;
|
||||
}
|
||||
|
||||
export function useTerminalSearch(
|
||||
searchAddon: null | SearchAddon,
|
||||
isReady: boolean,
|
||||
searchValue: string | undefined,
|
||||
theme: 'dark' | 'light' | 'system',
|
||||
): UseTerminalSearchResult {
|
||||
useEffect(() => {
|
||||
if (!searchAddon || !isReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const trimmed = searchValue?.trim();
|
||||
|
||||
if (trimmed) {
|
||||
searchAddon.findNext(trimmed, buildSearchOptions(isDarkMode(theme)));
|
||||
} else {
|
||||
searchAddon.clearDecorations();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
Log.error('Terminal search failed:', error);
|
||||
}
|
||||
}, [searchAddon, isReady, searchValue, theme]);
|
||||
|
||||
const findNext = useCallback(() => {
|
||||
const trimmed = searchValue?.trim();
|
||||
|
||||
if (!searchAddon || !trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
searchAddon.findNext(trimmed, buildSearchOptions(isDarkMode(theme)));
|
||||
} catch (error: unknown) {
|
||||
Log.error('Terminal findNext failed:', error);
|
||||
}
|
||||
}, [searchAddon, searchValue, theme]);
|
||||
|
||||
const findPrevious = useCallback(() => {
|
||||
const trimmed = searchValue?.trim();
|
||||
|
||||
if (!searchAddon || !trimmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
searchAddon.findPrevious(trimmed, buildSearchOptions(isDarkMode(theme)));
|
||||
} catch (error: unknown) {
|
||||
Log.error('Terminal findPrevious failed:', error);
|
||||
}
|
||||
}, [searchAddon, searchValue, theme]);
|
||||
|
||||
return { findNext, findPrevious };
|
||||
}
|
||||
|
||||
function buildSearchOptions(isDark: boolean) {
|
||||
return {
|
||||
caseSensitive: false,
|
||||
decorations: getSearchDecorations(isDark),
|
||||
regex: false,
|
||||
wholeWord: false,
|
||||
} as const;
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import type { ILinkHandler } from '@xterm/xterm';
|
||||
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
import { SearchAddon } from '@xterm/addon-search';
|
||||
import { Unicode11Addon } from '@xterm/addon-unicode11';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import { WebglAddon } from '@xterm/addon-webgl';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { Log } from '@/lib/log';
|
||||
import { isMac } from '@/lib/utils/platform';
|
||||
|
||||
import { getTerminalTheme, isDarkMode, TERMINAL_OPTIONS } from './terminal-config';
|
||||
import { SAFE_PROTOCOLS } from './terminal-sanitizer';
|
||||
|
||||
const FLOW_CONTROL_CHUNK_SIZE = 64 * 1024;
|
||||
const DEBOUNCE_DELAY = 150;
|
||||
const TOOLTIP_CLASS = 'terminal-link-tooltip';
|
||||
|
||||
export interface UseXtermResult {
|
||||
clear: () => void;
|
||||
containerRef: React.RefObject<HTMLDivElement | null>;
|
||||
isReady: boolean;
|
||||
scrollToBottom: () => void;
|
||||
searchAddon: null | SearchAddon;
|
||||
write: (data: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages the full xterm.js lifecycle: creation, addon loading,
|
||||
* resize handling, WebGL fallback, theme sync, and cleanup.
|
||||
*
|
||||
* Implements chunked flow control for large writes per
|
||||
* https://xtermjs.org/docs/guides/flowcontrol/
|
||||
*
|
||||
* Requires Ctrl+Click (Cmd+Click on Mac) to open links per
|
||||
* https://xtermjs.org/docs/guides/link-handling/
|
||||
*/
|
||||
export function useXterm({ theme }: { theme: 'dark' | 'light' | 'system' }): UseXtermResult {
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const terminalRef = useRef<null | Terminal>(null);
|
||||
const themeRef = useRef(theme);
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [searchAddon, setSearchAddon] = useState<null | SearchAddon>(null);
|
||||
|
||||
useEffect(() => {
|
||||
themeRef.current = theme;
|
||||
}, [theme]);
|
||||
|
||||
const write = useCallback((data: string) => {
|
||||
const terminal = terminalRef.current;
|
||||
|
||||
if (!terminal) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
writeWithFlowControl(terminal, data);
|
||||
} catch (error: unknown) {
|
||||
Log.error('Terminal write failed:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
try {
|
||||
terminalRef.current?.clear();
|
||||
} catch (error: unknown) {
|
||||
Log.error('Terminal clear failed:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
try {
|
||||
terminalRef.current?.scrollToBottom();
|
||||
} catch (error: unknown) {
|
||||
Log.error('Terminal scrollToBottom failed:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
|
||||
const terminal = new Terminal({
|
||||
...TERMINAL_OPTIONS,
|
||||
theme: getTerminalTheme(isDarkMode(themeRef.current)),
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
const search = new SearchAddon();
|
||||
const unicodeAddon = new Unicode11Addon();
|
||||
|
||||
const mac = isMac();
|
||||
|
||||
const openLink = (event: MouseEvent, uri: string) => {
|
||||
const uriLower = uri.toLowerCase();
|
||||
|
||||
if (
|
||||
(mac ? event.metaKey : event.ctrlKey) &&
|
||||
SAFE_PROTOCOLS.some((p) => uriLower.startsWith(p))
|
||||
) {
|
||||
window.open(uri, '_blank', 'noopener,noreferrer');
|
||||
}
|
||||
};
|
||||
|
||||
const linkHandler: ILinkHandler = {
|
||||
activate: (event, text) => openLink(event, text),
|
||||
allowNonHttpProtocols: true,
|
||||
hover: (event, text) => showLinkTooltip(container, event, text, mac),
|
||||
leave: () => removeLinkTooltip(container),
|
||||
};
|
||||
|
||||
terminal.options.linkHandler = linkHandler;
|
||||
|
||||
const webLinksAddon = new WebLinksAddon(openLink, {
|
||||
hover: (event, text) => showLinkTooltip(container, event, text, mac),
|
||||
leave: () => removeLinkTooltip(container),
|
||||
});
|
||||
|
||||
terminal.loadAddon(fitAddon);
|
||||
terminal.loadAddon(search);
|
||||
terminal.loadAddon(unicodeAddon);
|
||||
terminal.unicode.activeVersion = '11';
|
||||
terminal.loadAddon(webLinksAddon);
|
||||
|
||||
const disposables: Array<{ dispose: () => void }> = [unicodeAddon, webLinksAddon];
|
||||
|
||||
try {
|
||||
const webglAddon = new WebglAddon();
|
||||
terminal.loadAddon(webglAddon);
|
||||
disposables.push(webglAddon);
|
||||
disposables.push(
|
||||
webglAddon.onContextLoss(() => {
|
||||
try {
|
||||
webglAddon.dispose();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
/* WebGL not available — canvas renderer is used as fallback */
|
||||
}
|
||||
|
||||
const safeFit = () => {
|
||||
try {
|
||||
if (container.offsetHeight > 0) {
|
||||
fitAddon.fit();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
Log.error('Terminal fit failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
let debounceTimer: null | ReturnType<typeof setTimeout> = null;
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
|
||||
debounceTimer = setTimeout(safeFit, DEBOUNCE_DELAY);
|
||||
});
|
||||
|
||||
terminalRef.current = terminal;
|
||||
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
terminal.open(container);
|
||||
resizeObserver.observe(container);
|
||||
safeFit();
|
||||
setSearchAddon(search);
|
||||
setIsReady(true);
|
||||
} catch (error: unknown) {
|
||||
Log.error('Failed to open terminal:', error);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
cancelAnimationFrame(rafId);
|
||||
|
||||
if (debounceTimer) {
|
||||
clearTimeout(debounceTimer);
|
||||
}
|
||||
|
||||
removeLinkTooltip(container);
|
||||
resizeObserver.disconnect();
|
||||
|
||||
for (const d of disposables) {
|
||||
try {
|
||||
d.dispose();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
search.dispose();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
try {
|
||||
fitAddon.dispose();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
try {
|
||||
terminal.dispose();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
|
||||
terminalRef.current = null;
|
||||
setSearchAddon(null);
|
||||
setIsReady(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const terminal = terminalRef.current;
|
||||
|
||||
if (!terminal) {
|
||||
return;
|
||||
}
|
||||
|
||||
const applyTheme = () => {
|
||||
try {
|
||||
terminal.options.theme = getTerminalTheme(isDarkMode(theme));
|
||||
} catch (error: unknown) {
|
||||
Log.error('Terminal theme update failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
applyTheme();
|
||||
|
||||
if (theme === 'system') {
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = () => applyTheme();
|
||||
mediaQuery.addEventListener('change', handler);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', handler);
|
||||
};
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
return { clear, containerRef, isReady, scrollToBottom, searchAddon, write };
|
||||
}
|
||||
|
||||
function removeLinkTooltip(container: HTMLElement): void {
|
||||
const existing = container.querySelector(`.${TOOLTIP_CLASS}`);
|
||||
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function showLinkTooltip(container: HTMLElement, event: MouseEvent, uri: string, mac: boolean): void {
|
||||
removeLinkTooltip(container);
|
||||
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.className = `${TOOLTIP_CLASS} xterm-hover`;
|
||||
tooltip.style.cssText =
|
||||
'position:absolute;z-index:10;padding:4px 8px;max-width:80%;font-size:12px;' +
|
||||
'line-height:1.4;border-radius:4px;pointer-events:none;word-break:break-all;' +
|
||||
'background:var(--popover);color:var(--popover-foreground);' +
|
||||
'border:1px solid var(--border);box-shadow:0 2px 8px rgba(0,0,0,.15)';
|
||||
|
||||
const urlSpan = document.createElement('span');
|
||||
urlSpan.textContent = uri;
|
||||
tooltip.appendChild(urlSpan);
|
||||
|
||||
const hint = document.createElement('div');
|
||||
hint.style.cssText = 'opacity:0.6;font-size:11px;margin-top:2px';
|
||||
hint.textContent = `${mac ? 'Cmd' : 'Ctrl'}+Click to open`;
|
||||
tooltip.appendChild(hint);
|
||||
|
||||
container.style.position = 'relative';
|
||||
container.appendChild(tooltip);
|
||||
|
||||
const rect = container.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
let y = event.clientY - rect.top + 20;
|
||||
|
||||
tooltip.style.left = `${Math.min(x, rect.width - tooltip.offsetWidth - 8)}px`;
|
||||
|
||||
if (y + tooltip.offsetHeight > rect.height) {
|
||||
y = event.clientY - rect.top - tooltip.offsetHeight - 8;
|
||||
}
|
||||
|
||||
tooltip.style.top = `${Math.max(0, y)}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes data to the terminal with chunked flow control.
|
||||
* For large payloads, splits into chunks and uses xterm's write callback
|
||||
* to avoid overwhelming the parser and blocking the UI thread.
|
||||
*
|
||||
* Chunk boundaries are adjusted to avoid splitting UTF-16 surrogate pairs:
|
||||
* if the last code unit of a chunk is a high surrogate (0xD800-0xDBFF),
|
||||
* the boundary is moved back by one so the pair stays intact.
|
||||
*
|
||||
* See: https://xtermjs.org/docs/guides/flowcontrol/
|
||||
*/
|
||||
function writeWithFlowControl(terminal: Terminal, data: string): void {
|
||||
if (data.length <= FLOW_CONTROL_CHUNK_SIZE) {
|
||||
terminal.write(data);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let offset = 0;
|
||||
|
||||
const writeNext = () => {
|
||||
let end = Math.min(offset + FLOW_CONTROL_CHUNK_SIZE, data.length);
|
||||
|
||||
if (end < data.length) {
|
||||
const code = data.charCodeAt(end - 1);
|
||||
|
||||
if (code >= 0xd800 && code <= 0xdbff) {
|
||||
end--;
|
||||
}
|
||||
}
|
||||
|
||||
const chunk = data.slice(offset, end);
|
||||
offset = end;
|
||||
|
||||
if (offset < data.length) {
|
||||
terminal.write(chunk, writeNext);
|
||||
} else {
|
||||
terminal.write(chunk);
|
||||
}
|
||||
};
|
||||
|
||||
writeNext();
|
||||
}
|
||||
Reference in New Issue
Block a user