Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 24 additions & 109 deletions src/web-ui/src/tools/terminal/components/Terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@
*/

import React, { useEffect, useRef, useCallback, useState, forwardRef, useImperativeHandle } from 'react';
import type { ITheme } from '@xterm/xterm';
import { Terminal as XTerm } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { WebLinksAddon } from '@xterm/addon-web-links';
import { WebglAddon } from '@xterm/addon-webgl';
import { TerminalResizeDebouncer } from '../utils';
import {
TerminalResizeDebouncer,
buildXtermTheme,
getXtermFontWeights,
DEFAULT_XTERM_MINIMUM_CONTRAST_RATIO,
} from '../utils';
import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI';
import { themeService } from '@/infrastructure/theme/core/ThemeService';
import { createLogger } from '@/shared/utils/logger';
Expand Down Expand Up @@ -62,6 +68,7 @@ export interface TerminalOptions {
fontSize?: number;
fontFamily?: string;
lineHeight?: number;
minimumContrastRatio?: number;
cursorStyle?: 'block' | 'underline' | 'bar';
cursorBlink?: boolean;
scrollback?: number;
Expand Down Expand Up @@ -143,60 +150,15 @@ export interface TerminalRef {
* Calling this at XTerm construction time prevents the initial black-background flash
* that occurs when the theme is applied asynchronously via useEffect.
*/
function buildXtermTheme(): TerminalOptions['theme'] {
const theme = themeService.getCurrentTheme();
const isDark = theme.type === 'dark';

const ansiColors = isDark ? {
black: '#000000',
red: '#cd3131',
green: '#0dbc79',
yellow: '#e5e510',
blue: '#2472c8',
magenta: '#bc3fbc',
cyan: '#11a8cd',
white: '#e5e5e5',
brightBlack: '#666666',
brightRed: '#f14c4c',
brightGreen: '#23d18b',
brightYellow: '#f5f543',
brightBlue: '#3b8eea',
brightMagenta: '#d670d6',
brightCyan: '#29b8db',
brightWhite: '#ffffff',
} : {
black: '#000000',
red: '#c91b00',
green: '#007a3d',
yellow: '#b58900',
blue: '#0037da',
magenta: '#881798',
cyan: '#0e7490',
white: '#586e75',
brightBlack: '#555555',
brightRed: '#e74856',
brightGreen: '#16a34a',
brightYellow: '#a16207',
brightBlue: '#0078d4',
brightMagenta: '#b4009e',
brightCyan: '#0891b2',
brightWhite: '#1e293b',
};

return {
background: theme.colors.background.scene,
foreground: theme.colors.text.primary,
cursor: theme.colors.text.primary,
cursorAccent: theme.colors.background.secondary,
selectionBackground: isDark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(59, 130, 246, 0.3)',
...ansiColors,
};
function getInitialXtermTheme(overrides: TerminalOptions['theme'] = {}): ITheme {
return buildXtermTheme(themeService.getCurrentTheme(), overrides);
}

const DEFAULT_OPTIONS: TerminalOptions = {
fontSize: 14,
fontFamily: "'Fira Code', 'Noto Sans SC', Consolas, 'Courier New', monospace",
lineHeight: 1.2,
minimumContrastRatio: DEFAULT_XTERM_MINIMUM_CONTRAST_RATIO,
cursorStyle: 'block',
cursorBlink: true,
scrollback: 10000,
Expand Down Expand Up @@ -227,6 +189,8 @@ const Terminal = forwardRef<TerminalRef, TerminalProps>(({
const wasVisibleRef = useRef(false);
const lastBackendSizeRef = useRef<{ cols: number; rows: number } | null>(null);
const [isReady, setIsReady] = useState(false);
const currentTheme = themeService.getCurrentTheme();
const initialFontWeights = getXtermFontWeights(currentTheme.type);

// Merge options. Theme is resolved from ThemeService at render time so that the
// initial XTerm instance is created with the correct background color and avoids
Expand All @@ -235,7 +199,7 @@ const Terminal = forwardRef<TerminalRef, TerminalProps>(({
...DEFAULT_OPTIONS,
...options,
theme: {
...buildXtermTheme(),
...getInitialXtermTheme(),
...options.theme,
},
};
Expand Down Expand Up @@ -383,7 +347,10 @@ const Terminal = forwardRef<TerminalRef, TerminalProps>(({
const terminal = new XTerm({
fontSize: mergedOptions.fontSize,
fontFamily: mergedOptions.fontFamily,
fontWeight: initialFontWeights.fontWeight,
fontWeightBold: initialFontWeights.fontWeightBold,
lineHeight: mergedOptions.lineHeight,
minimumContrastRatio: mergedOptions.minimumContrastRatio,
cursorStyle: mergedOptions.cursorStyle,
cursorBlink: mergedOptions.cursorBlink,
scrollback: mergedOptions.scrollback,
Expand Down Expand Up @@ -611,6 +578,7 @@ const Terminal = forwardRef<TerminalRef, TerminalProps>(({
terminal.options.fontSize = mergedOptions.fontSize;
terminal.options.fontFamily = mergedOptions.fontFamily;
terminal.options.lineHeight = mergedOptions.lineHeight;
terminal.options.minimumContrastRatio = mergedOptions.minimumContrastRatio;
terminal.options.cursorStyle = mergedOptions.cursorStyle;
terminal.options.cursorBlink = mergedOptions.cursorBlink;
terminal.options.scrollback = mergedOptions.scrollback;
Expand All @@ -621,6 +589,7 @@ const Terminal = forwardRef<TerminalRef, TerminalProps>(({
mergedOptions.fontSize,
mergedOptions.fontFamily,
mergedOptions.lineHeight,
mergedOptions.minimumContrastRatio,
mergedOptions.cursorStyle,
mergedOptions.cursorBlink,
mergedOptions.scrollback,
Expand All @@ -635,68 +604,14 @@ const Terminal = forwardRef<TerminalRef, TerminalProps>(({
const updateXtermTheme = () => {
(() => {
const theme = themeService.getCurrentTheme();
const isDark = theme.type === 'dark';

// Dark-theme ANSI palette: bright, saturated colors for dark backgrounds.
// Light-theme ANSI palette: deeper, higher-contrast colors for white backgrounds.
// Without separate palettes, colors like yellow (#e5e510) and white (#ffffff)
// become nearly invisible on light backgrounds due to insufficient contrast.
const ansiColors = isDark ? {
black: '#000000',
red: '#cd3131',
green: '#0dbc79',
yellow: '#e5e510',
blue: '#2472c8',
magenta: '#bc3fbc',
cyan: '#11a8cd',
white: '#e5e5e5',
brightBlack: '#666666',
brightRed: '#f14c4c',
brightGreen: '#23d18b',
brightYellow: '#f5f543',
brightBlue: '#3b8eea',
brightMagenta: '#d670d6',
brightCyan: '#29b8db',
brightWhite: '#ffffff',
} : {
black: '#000000',
red: '#c91b00',
green: '#007a3d',
yellow: '#b58900',
blue: '#0037da',
magenta: '#881798',
cyan: '#0e7490',
white: '#586e75', // Visible gray on light bg (not #fff!)
brightBlack: '#555555',
brightRed: '#e74856',
brightGreen: '#16a34a',
brightYellow: '#a16207',
brightBlue: '#0078d4',
brightMagenta: '#b4009e',
brightCyan: '#0891b2',
brightWhite: '#1e293b', // Near-black for maximum contrast
};

const xtermTheme = {
background: theme.colors.background.scene,
foreground: theme.colors.text.primary,
cursor: theme.colors.text.primary,
cursorAccent: theme.colors.background.secondary,
// Selection must be clearly visible against the background.
// Dark: semi-transparent white; Light: tinted blue highlight (matches
// typical OS text selection color) with enough opacity to stand out.
// NOTE: xterm.js ITheme uses "selectionBackground", NOT "selection".
selectionBackground: isDark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(59, 130, 246, 0.3)',
...ansiColors,
};

terminal.options.theme = xtermTheme;
terminal.options.theme = buildXtermTheme(theme, options.theme);

// Light-on-dark text appears bolder due to irradiation (optical illusion);
// dark-on-light text looks thinner in comparison. Bump fontWeight in light
// mode to compensate.
terminal.options.fontWeight = isDark ? 'normal' : '500';
terminal.options.fontWeightBold = isDark ? 'bold' : '700';
const fontWeights = getXtermFontWeights(theme.type);
terminal.options.fontWeight = fontWeights.fontWeight;
terminal.options.fontWeightBold = fontWeights.fontWeightBold;

forceRefresh(terminal);
})();
Expand All @@ -708,7 +623,7 @@ const Terminal = forwardRef<TerminalRef, TerminalProps>(({
return () => {
unsubscribe?.();
};
}, [isReady, forceRefresh]);
}, [isReady, forceRefresh, options.theme]);

return (
<div
Expand Down
83 changes: 26 additions & 57 deletions src/web-ui/src/tools/terminal/components/TerminalOutputRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ import React, { useEffect, useRef, useCallback, memo, useId } from 'react';
import { Terminal as XTerm } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import { registerTerminalActions, unregisterTerminalActions } from '../services/TerminalActionManager';
import { themeService } from '@/infrastructure/theme/core/ThemeService';
import {
buildXtermTheme,
getXtermFontWeights,
DEFAULT_XTERM_MINIMUM_CONTRAST_RATIO,
} from '../utils';
import '@xterm/xterm/css/xterm.css';

interface TerminalOutputRendererProps {
Expand Down Expand Up @@ -81,40 +87,27 @@ export const TerminalOutputRenderer: React.FC<TerminalOutputRendererProps> = mem
useEffect(() => {
if (!containerRef.current) return;

const currentTheme = themeService.getCurrentTheme();
const fontWeights = getXtermFontWeights(currentTheme.type);
const terminal = new XTerm({
disableStdin: true, // Disable input for read-only rendering.
cursorBlink: false,
cursorStyle: 'bar',
cursorInactiveStyle: 'none',
fontSize: 12,
fontFamily: "'Fira Code', 'Noto Sans SC', Consolas, 'Courier New', monospace",
fontWeight: fontWeights.fontWeight,
fontWeightBold: fontWeights.fontWeightBold,
lineHeight: 1.4,
minimumContrastRatio: DEFAULT_XTERM_MINIMUM_CONTRAST_RATIO,
scrollback: 5000,
convertEol: true,
allowTransparency: true,
theme: {
theme: buildXtermTheme(currentTheme, {
background: 'transparent',
foreground: '#d4d4d4',
cursor: 'transparent', // Hide cursor in read-only mode.
cursorAccent: 'transparent',
selectionBackground: 'rgba(255, 255, 255, 0.2)',
black: '#1e1e1e',
red: '#e06c75',
green: '#98c379',
yellow: '#e5c07b',
blue: '#61afef',
magenta: '#c678dd',
cyan: '#56b6c2',
white: '#abb2bf',
brightBlack: '#5c6370',
brightRed: '#e06c75',
brightGreen: '#98c379',
brightYellow: '#e5c07b',
brightBlue: '#61afef',
brightMagenta: '#c678dd',
brightCyan: '#56b6c2',
brightWhite: '#ffffff',
},
}),
});

const fitAddon = new FitAddon();
Expand Down Expand Up @@ -170,49 +163,25 @@ export const TerminalOutputRenderer: React.FC<TerminalOutputRendererProps> = mem
if (!terminal) return;

const updateTheme = () => {
import('@/infrastructure/theme/core/ThemeService').then(({ themeService }) => {
const theme = themeService.getCurrentTheme();
const isDark = theme.type === 'dark';
const theme = themeService.getCurrentTheme();
const fontWeights = getXtermFontWeights(theme.type);

terminal.options.theme = {
background: theme.colors.background.scene,
foreground: theme.colors.text.primary,
cursor: 'transparent',
cursorAccent: theme.colors.background.secondary,
selectionBackground: isDark ? 'rgba(255, 255, 255, 0.3)' : 'rgba(0, 0, 0, 0.3)',

// Keep ANSI colors aligned with Terminal.tsx.
black: '#000000',
red: '#cd3131',
green: '#0dbc79',
yellow: '#e5e510',
blue: '#2472c8',
magenta: '#bc3fbc',
cyan: '#11a8cd',
white: isDark ? '#e5e5e5' : '#ffffff',
brightBlack: '#666666',
brightRed: '#f14c4c',
brightGreen: '#23d18b',
brightYellow: '#f5f543',
brightBlue: '#3b8eea',
brightMagenta: '#d670d6',
brightCyan: '#29b8db',
brightWhite: '#ffffff',
};

terminal.refresh(0, terminal.rows - 1);
terminal.options.theme = buildXtermTheme(theme, {
background: 'transparent',
cursor: 'transparent',
cursorAccent: 'transparent',
});
terminal.options.fontWeight = fontWeights.fontWeight;
terminal.options.fontWeightBold = fontWeights.fontWeightBold;
terminal.refresh(0, terminal.rows - 1);
};

updateTheme();

import('@/infrastructure/theme/core/ThemeService').then(({ themeService }) => {
const unsubscribe = themeService.on('theme:after-change', updateTheme);

return () => {
unsubscribe?.();
};
});
const unsubscribe = themeService.on('theme:after-change', updateTheme);
return () => {
unsubscribe?.();
};
}, []);

// Incremental write when content extends existing output.
Expand Down
6 changes: 6 additions & 0 deletions src/web-ui/src/tools/terminal/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,10 @@

export { TerminalResizeDebouncer } from './TerminalResizeDebouncer';
export type { ResizeCallback, ResizeDebounceOptions } from './TerminalResizeDebouncer';
export {
buildXtermTheme,
getXtermAnsiPalette,
getXtermFontWeights,
DEFAULT_XTERM_MINIMUM_CONTRAST_RATIO,
} from './xtermTheme';

Loading
Loading