diff --git a/src/web-ui/src/tools/terminal/components/Terminal.tsx b/src/web-ui/src/tools/terminal/components/Terminal.tsx index aafbd1c1..5be9b0f9 100644 --- a/src/web-ui/src/tools/terminal/components/Terminal.tsx +++ b/src/web-ui/src/tools/terminal/components/Terminal.tsx @@ -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'; @@ -62,6 +68,7 @@ export interface TerminalOptions { fontSize?: number; fontFamily?: string; lineHeight?: number; + minimumContrastRatio?: number; cursorStyle?: 'block' | 'underline' | 'bar'; cursorBlink?: boolean; scrollback?: number; @@ -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, @@ -227,6 +189,8 @@ const Terminal = forwardRef(({ 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 @@ -235,7 +199,7 @@ const Terminal = forwardRef(({ ...DEFAULT_OPTIONS, ...options, theme: { - ...buildXtermTheme(), + ...getInitialXtermTheme(), ...options.theme, }, }; @@ -383,7 +347,10 @@ const Terminal = forwardRef(({ 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, @@ -611,6 +578,7 @@ const Terminal = forwardRef(({ 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; @@ -621,6 +589,7 @@ const Terminal = forwardRef(({ mergedOptions.fontSize, mergedOptions.fontFamily, mergedOptions.lineHeight, + mergedOptions.minimumContrastRatio, mergedOptions.cursorStyle, mergedOptions.cursorBlink, mergedOptions.scrollback, @@ -635,68 +604,14 @@ const Terminal = forwardRef(({ 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); })(); @@ -708,7 +623,7 @@ const Terminal = forwardRef(({ return () => { unsubscribe?.(); }; - }, [isReady, forceRefresh]); + }, [isReady, forceRefresh, options.theme]); return (
= 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, @@ -88,33 +96,18 @@ export const TerminalOutputRenderer: React.FC = mem 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(); @@ -170,49 +163,25 @@ export const TerminalOutputRenderer: React.FC = 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. diff --git a/src/web-ui/src/tools/terminal/utils/index.ts b/src/web-ui/src/tools/terminal/utils/index.ts index d74927ea..0f0d5940 100644 --- a/src/web-ui/src/tools/terminal/utils/index.ts +++ b/src/web-ui/src/tools/terminal/utils/index.ts @@ -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'; diff --git a/src/web-ui/src/tools/terminal/utils/xtermTheme.ts b/src/web-ui/src/tools/terminal/utils/xtermTheme.ts new file mode 100644 index 00000000..5ca3fe51 --- /dev/null +++ b/src/web-ui/src/tools/terminal/utils/xtermTheme.ts @@ -0,0 +1,124 @@ +import type { ITheme } from '@xterm/xterm'; +import type { ThemeConfig, ThemeType } from '@/infrastructure/theme/types'; + +export const DEFAULT_XTERM_MINIMUM_CONTRAST_RATIO = 6; + +const LIGHT_ANSI: Required> = { + black: '#000000', + red: '#cd3131', + green: '#107C10', + yellow: '#949800', + blue: '#0451a5', + magenta: '#bc05bc', + cyan: '#0598bc', + white: '#555555', + brightBlack: '#666666', + brightRed: '#cd3131', + brightGreen: '#14CE14', + brightYellow: '#b5ba00', + brightBlue: '#0451a5', + brightMagenta: '#bc05bc', + brightCyan: '#0598bc', + brightWhite: '#a5a5a5', +}; + +const DARK_ANSI: Required> = { + 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: '#e5e5e5', +}; + +export function getXtermAnsiPalette(themeType: ThemeType): Required> { + return themeType === 'dark' ? DARK_ANSI : LIGHT_ANSI; +} + +export function getXtermFontWeights(themeType: ThemeType): { + fontWeight: 'normal' | '500'; + fontWeightBold: 'bold' | '700'; +} { + return themeType === 'dark' + ? { fontWeight: 'normal', fontWeightBold: 'bold' } + : { fontWeight: '500', fontWeightBold: '700' }; +} + +export function buildXtermTheme( + theme: ThemeConfig, + overrides: Partial = {}, +): ITheme { + const isDark = theme.type === 'dark'; + + 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.30)' : 'rgba(173, 214, 255, 0.45)', + selectionInactiveBackground: isDark ? 'rgba(255, 255, 255, 0.16)' : 'rgba(173, 214, 255, 0.25)', + ...getXtermAnsiPalette(theme.type), + ...overrides, + }; +}