From c9b572f44f3625e385531b3c16d92255deb8a433 Mon Sep 17 00:00:00 2001 From: wsp Date: Thu, 30 Jul 2026 15:22:17 +0800 Subject: [PATCH] fix(web-ui): prevent terminal output flicker on expansion - Prevent xterm row-height feedback loops during resize. - Keep fallback output visible until xterm renders its first content frame. - Share output normalization and height calculation between fallback and xterm. - Add regression coverage for normalized fallback rows. --- .../LazyTerminalOutputRenderer.test.tsx | 15 ++ .../components/LazyTerminalOutputRenderer.tsx | 86 +++----- .../components/TerminalOutputRenderer.tsx | 198 ++++++++---------- .../components/terminalOutputPresentation.ts | 144 +++++++++++++ 4 files changed, 280 insertions(+), 163 deletions(-) create mode 100644 src/web-ui/src/tools/terminal/components/terminalOutputPresentation.ts diff --git a/src/web-ui/src/tools/terminal/components/LazyTerminalOutputRenderer.test.tsx b/src/web-ui/src/tools/terminal/components/LazyTerminalOutputRenderer.test.tsx index 765f9f7960..164060e7b5 100644 --- a/src/web-ui/src/tools/terminal/components/LazyTerminalOutputRenderer.test.tsx +++ b/src/web-ui/src/tools/terminal/components/LazyTerminalOutputRenderer.test.tsx @@ -40,4 +40,19 @@ describe('TerminalOutputFallback', () => { expect(fallback?.style.height).toBe('34px'); expect(fallback?.style.overflow).toBe('hidden'); }); + + it('uses the same normalized rows as the xterm renderer', () => { + act(() => { + root.render( + + ); + }); + + const fallback = container.querySelector('pre.terminal-output-pre'); + expect(fallback?.textContent).toBe('one'); + expect(fallback?.style.height).toBe('17px'); + }); }); diff --git a/src/web-ui/src/tools/terminal/components/LazyTerminalOutputRenderer.tsx b/src/web-ui/src/tools/terminal/components/LazyTerminalOutputRenderer.tsx index b6d98ae5ab..32eef795c5 100644 --- a/src/web-ui/src/tools/terminal/components/LazyTerminalOutputRenderer.tsx +++ b/src/web-ui/src/tools/terminal/components/LazyTerminalOutputRenderer.tsx @@ -1,60 +1,18 @@ -import React, { forwardRef, Suspense } from 'react'; +import React, { forwardRef, Suspense, useMemo } from 'react'; import type { TerminalOutputRendererHandle, TerminalOutputRendererProps, } from './TerminalOutputRenderer'; +import { + buildTerminalOutputFallbackModel, + type TerminalOutputFallbackModel, +} from './terminalOutputPresentation'; const DeferredTerminalOutputRenderer = React.lazy(() => import('./TerminalOutputRenderer').then((module) => ({ default: module.TerminalOutputRenderer, })) ); -const FALLBACK_OUTPUT_FONT_SIZE = 12; -const FALLBACK_OUTPUT_LINE_HEIGHT = 1.4; -const FALLBACK_OUTPUT_ROW_HEIGHT = Math.ceil(FALLBACK_OUTPUT_FONT_SIZE * FALLBACK_OUTPUT_LINE_HEIGHT); - -function stripTerminalControlSequences(content: string): string { - return content - // eslint-disable-next-line no-control-regex -- terminal control sequences are expected in command output. - .replace(/\x1b[\]PX_^][\s\S]*?(?:\x07|\x1b\\)/g, '') - // eslint-disable-next-line no-control-regex -- terminal control sequences are expected in command output. - .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') - // eslint-disable-next-line no-control-regex -- terminal control sequences are expected in command output. - .replace(/\x1b[ -/]*[@-~]/g, ''); -} - -function takeLastRows(content: string, maxRows?: number): string { - if (!maxRows || maxRows <= 0) { - return content; - } - - let rowCount = 0; - let cursor = content.length; - while (cursor > 0 && rowCount < maxRows) { - const previousBreak = content.lastIndexOf('\n', cursor - 1); - if (previousBreak < 0) { - return content; - } - rowCount += 1; - cursor = previousBreak; - } - - return content.slice(cursor + 1); -} - -function calculateFallbackHeight(content: string, maxRows?: number, minHeight = FALLBACK_OUTPUT_ROW_HEIGHT, maxHeight = 300): number { - const lines = content ? content.split(/\r\n|\r|\n/) : ['']; - const visibleRows = maxRows != null && maxRows > 0 - ? Math.min(lines.length, maxRows) - : lines.length; - const estimatedHeight = Math.max(1, visibleRows) * FALLBACK_OUTPUT_ROW_HEIGHT; - const effectiveMaxHeight = maxRows != null && maxRows > 0 - ? maxRows * FALLBACK_OUTPUT_ROW_HEIGHT - : maxHeight; - - return Math.min(Math.max(estimatedHeight, minHeight), Math.max(minHeight, effectiveMaxHeight)); -} - export function TerminalOutputFallback({ className, content, @@ -62,19 +20,18 @@ export function TerminalOutputFallback({ maxHeight, maxRows, }: Pick) { - const preview = stripTerminalControlSequences(takeLastRows(content, maxRows)); - const height = calculateFallbackHeight(preview, maxRows, minHeight, maxHeight); + const fallback = buildTerminalOutputFallbackModel(content, { minHeight, maxHeight, maxRows }); return (
-      {preview}
+      {fallback.content}
     
); } @@ -82,11 +39,26 @@ export function TerminalOutputFallback({ export const LazyTerminalOutputRenderer = forwardRef< TerminalOutputRendererHandle, TerminalOutputRendererProps ->((props, ref) => ( - }> - - -)); +>((props, ref) => { + const initialFallback = useMemo( + () => buildTerminalOutputFallbackModel(props.content, { + minHeight: props.minHeight, + maxHeight: props.maxHeight, + maxRows: props.maxRows, + }), + [props.content, props.maxHeight, props.maxRows, props.minHeight], + ); + + return ( + }> + + + ); +}); LazyTerminalOutputRenderer.displayName = 'LazyTerminalOutputRenderer'; diff --git a/src/web-ui/src/tools/terminal/components/TerminalOutputRenderer.tsx b/src/web-ui/src/tools/terminal/components/TerminalOutputRenderer.tsx index 69b3ef5ac5..e63b72ddf8 100644 --- a/src/web-ui/src/tools/terminal/components/TerminalOutputRenderer.tsx +++ b/src/web-ui/src/tools/terminal/components/TerminalOutputRenderer.tsx @@ -1,52 +1,8 @@ /** * Terminal output renderer based on xterm.js (read-only). * Uses TerminalActionManager to avoid per-instance EventBus listeners. - * - * Raw PTY output may contain absolute cursor-position sequences (ESC[row;colH) - * that assume existing content on screen. When replayed in a fresh xterm.js - * these sequences leave blank rows at the top. We strip them before writing - * so content flows sequentially; colors and relative movements are preserved. */ - -/** - * Normalize absolute cursor-position sequences for fresh-context rendering. - * - * ESC[row;colH (CUP) and ESC[row;colf (HVP) reposition the cursor to an - * absolute screen coordinate. In a live terminal the rows above that - * coordinate already contain shell prompts and prior output, so no blank space - * appears. In a fresh xterm.js context those rows are empty, producing a - * large blank area before the first line of real content. - * - * We replace each such sequence with CR+LF so the two sections it separates - * stay on different lines (plain deletion would cause them to run together), - * while avoiding the blank-row artifact from coordinate-based positioning. - * - * Colors, bold, relative cursor movements and all other sequences are left - * untouched. - */ -function normalizeAbsoluteCursorPositions(content: string): string { - // Matches ESC [ ; H|f - // e.g. ESC[14;35H ESC[18;1H ESC[5;1H ESC[H ESC[;1H - // eslint-disable-next-line no-control-regex -- ESC sequences are intentional terminal control codes. - return content.replace(/\x1b\[\d*;?\d*[Hf]/g, '\r\n'); -} - -function trimTrailingLineBreaksBeforeAnsiTail(content: string): string { - // A final newline moves the xterm cursor to an extra blank row, which can - // push useful content into scrollback in compact read-only previews. Preserve - // trailing CSI state-reset sequences such as ESC[?25h while dropping only the - // blank line break before them. - // eslint-disable-next-line no-control-regex -- ESC sequences are intentional terminal control codes. - return content.replace(/(?:\r\n|\r|\n)+((?:\x1b\[[0-?]*[ -/]*[@-~])*)$/g, '$1'); -} - -function prepareReadOnlyTerminalOutput(content: string): string { - return trimTrailingLineBreaksBeforeAnsiTail( - normalizeAbsoluteCursorPositions(content), - ); -} - -import { forwardRef, memo, useCallback, useEffect, useId, useImperativeHandle, useRef, useState } from 'react'; +import { forwardRef, memo, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useRef, useState } from 'react'; import { Terminal as XTerm } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; import { registerTerminalActions, unregisterTerminalActions } from '../services/TerminalActionManager'; @@ -56,12 +12,17 @@ import { getXtermFontWeights, DEFAULT_XTERM_MINIMUM_CONTRAST_RATIO, } from '../utils'; +import { + calculateTerminalOutputHeight, + getEstimatedTerminalOutputRowHeight, + prepareReadOnlyTerminalOutput, + TERMINAL_OUTPUT_FONT_FAMILY, + TERMINAL_OUTPUT_FONT_SIZE, + TERMINAL_OUTPUT_LINE_HEIGHT, + type TerminalOutputFallbackModel, +} from './terminalOutputPresentation'; import '@xterm/xterm/css/xterm.css'; -const OUTPUT_FONT_SIZE = 12; -const OUTPUT_LINE_HEIGHT = 1.4; -const FALLBACK_OUTPUT_ROW_HEIGHT = Math.ceil(OUTPUT_FONT_SIZE * OUTPUT_LINE_HEIGHT); - export interface TerminalOutputRendererProps { /** Output content to render. */ content: string; @@ -75,6 +36,8 @@ export interface TerminalOutputRendererProps { maxHeight?: number; /** Maximum visible terminal rows. Takes precedence over maxHeight. */ maxRows?: number; + /** Plain-text preview retained until xterm has rendered its first content frame. */ + initialFallback?: TerminalOutputFallbackModel; } export interface TerminalOutputRendererHandle { @@ -117,9 +80,10 @@ const TerminalOutputRendererComponent = forwardRef { const autoId = useId(); const terminalId = propTerminalId || `terminal-output-${autoId}`; @@ -128,50 +92,28 @@ const TerminalOutputRendererComponent = forwardRef(null); const resizeObserverRef = useRef(null); const lastRenderedContentRef = useRef(''); - const [rowHeight, setRowHeight] = useState(FALLBACK_OUTPUT_ROW_HEIGHT); + const revealInitialFallbackOnRenderRef = useRef(false); + const initialRenderReadyRef = useRef(initialFallback == null); + const [rowHeight, setRowHeight] = useState(getEstimatedTerminalOutputRowHeight); const [hasScrollableBuffer, setHasScrollableBuffer] = useState(false); + const [isInitialRenderReady, setIsInitialRenderReady] = useState(initialFallback == null); const preparedContent = prepareReadOnlyTerminalOutput(content); useImperativeHandle(ref, () => ({ getVisibleText: () => getTerminalVisibleText(terminalRef.current), }), []); - const heightForRows = useCallback((rows: number): number => { - return Math.ceil(Math.max(rowHeight, rows * rowHeight)); - }, [rowHeight]); - - const alignHeightToRows = useCallback((height: number, mode: 'floor' | 'ceil'): number => { - const rows = mode === 'ceil' - ? Math.ceil(height / rowHeight) - : Math.floor(height / rowHeight); - return heightForRows(Math.max(1, rows)); - }, [heightForRows, rowHeight]); - - // Estimate height from content, keeping the container aligned to full xterm rows. - const calculateHeight = useCallback((text: string): number => { - const effectiveMinHeight = alignHeightToRows(minHeight, 'ceil'); - const effectiveMaxHeight = maxRows != null - ? heightForRows(maxRows) - : alignHeightToRows(maxHeight, 'floor'); - const boundedMaxHeight = Math.max(effectiveMinHeight, effectiveMaxHeight); - - if (!text) return Math.min(effectiveMinHeight, boundedMaxHeight); - - const lines = text.split(/\r\n|\r|\n/); - const visibleRows = maxRows != null - ? Math.min(lines.length, maxRows) - : lines.length; - const estimatedHeight = heightForRows(Math.max(1, visibleRows)); - - return Math.min(Math.max(estimatedHeight, effectiveMinHeight), boundedMaxHeight); - }, [alignHeightToRows, heightForRows, maxHeight, maxRows, minHeight]); - - const height = calculateHeight(preparedContent); + const height = calculateTerminalOutputHeight(preparedContent, { + rowHeight, + minHeight, + maxHeight, + maxRows, + }); const updateScrollableBufferState = useCallback(() => { setHasScrollableBuffer(hasScrollableTerminalBuffer(terminalRef.current)); }, []); - useEffect(() => { + useLayoutEffect(() => { if (!containerRef.current) return; const currentTheme = themeService.getCurrentTheme(); @@ -181,11 +123,11 @@ const TerminalOutputRendererComponent = forwardRef { - try { - const nextRowHeight = terminal.dimensions?.css.cell.height; - if (typeof nextRowHeight === 'number' && nextRowHeight > 0) { - setRowHeight(nextRowHeight); - } - fitAddon.fit(); - updateScrollableBufferState(); - } catch { - // Ignore fit errors. + const renderDisposable = terminal.onRender(() => { + if (!revealInitialFallbackOnRenderRef.current || initialRenderReadyRef.current) { + return; } + + revealInitialFallbackOnRenderRef.current = false; + initialRenderReadyRef.current = true; + setIsInitialRenderReady(true); }); + try { + const nextRowHeight = terminal.dimensions?.css.cell.height; + if (typeof nextRowHeight === 'number' && nextRowHeight > 0) { + setRowHeight(nextRowHeight); + } + fitAddon.fit(); + updateScrollableBufferState(); + } catch { + // Ignore fit errors. + } + const resizeObserver = new ResizeObserver(() => { requestAnimationFrame(() => { try { - const nextRowHeight = terminal.dimensions?.css.cell.height; - if (typeof nextRowHeight === 'number' && nextRowHeight > 0) { - setRowHeight(nextRowHeight); - } + // The observed cell height depends on this container's current + // rounded height. Writing it back here feeds xterm's fit result into + // React's height calculation, which can oscillate between rows. fitAddon.fit(); updateScrollableBufferState(); } catch { @@ -234,6 +182,7 @@ const TerminalOutputRendererComponent = forwardRef { + renderDisposable.dispose(); resizeObserver.disconnect(); terminal.dispose(); terminalRef.current = null; @@ -288,16 +237,28 @@ const TerminalOutputRendererComponent = forwardRef { + if (initialRenderReadyRef.current) { + return; + } + + revealInitialFallbackOnRenderRef.current = true; + terminal.refresh(0, Math.max(0, terminal.rows - 1)); + }; + if (preparedContent.startsWith(lastRenderedContent) && lastRenderedContent.length > 0) { const newPart = preparedContent.slice(lastRenderedContent.length); if (newPart) { - terminal.write(newPart); + terminal.write(newPart, revealInitialFallbackAfterRender); } } else { terminal.clear(); terminal.reset(); if (preparedContent) { - terminal.write(preparedContent); + terminal.write(preparedContent, revealInitialFallbackAfterRender); + } else if (!initialRenderReadyRef.current) { + initialRenderReadyRef.current = true; + setIsInitialRenderReady(true); } } updateScrollableBufferState(); @@ -315,8 +276,7 @@ const TerminalOutputRendererComponent = forwardRef + > +
+ {!isInitialRenderReady && initialFallback && ( +
+          {initialFallback.content}
+        
+ )} +
); }); diff --git a/src/web-ui/src/tools/terminal/components/terminalOutputPresentation.ts b/src/web-ui/src/tools/terminal/components/terminalOutputPresentation.ts new file mode 100644 index 0000000000..734d01b01f --- /dev/null +++ b/src/web-ui/src/tools/terminal/components/terminalOutputPresentation.ts @@ -0,0 +1,144 @@ +export const TERMINAL_OUTPUT_FONT_SIZE = 12; +export const TERMINAL_OUTPUT_LINE_HEIGHT = 1.4; +export const TERMINAL_OUTPUT_FONT_FAMILY = "'Fira Code', 'Noto Sans SC', Consolas, 'Courier New', monospace"; + +const DEFAULT_OUTPUT_ROW_HEIGHT = Math.ceil( + TERMINAL_OUTPUT_FONT_SIZE * TERMINAL_OUTPUT_LINE_HEIGHT, +); + +let cachedDevicePixelRatio = 0; +let cachedRowHeight = 0; + +export function getEstimatedTerminalOutputRowHeight(): number { + if (typeof window === 'undefined') { + return DEFAULT_OUTPUT_ROW_HEIGHT; + } + + const devicePixelRatio = window.devicePixelRatio || 1; + if (cachedRowHeight > 0 && cachedDevicePixelRatio === devicePixelRatio) { + return cachedRowHeight; + } + + try { + if (typeof OffscreenCanvas === 'undefined') { + return DEFAULT_OUTPUT_ROW_HEIGHT; + } + + const context = new OffscreenCanvas(100, 100).getContext('2d'); + if (!context) { + return DEFAULT_OUTPUT_ROW_HEIGHT; + } + + context.font = `${TERMINAL_OUTPUT_FONT_SIZE}px ${TERMINAL_OUTPUT_FONT_FAMILY}`; + const metrics = context.measureText('W'); + const fontHeight = metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent; + if (!Number.isFinite(fontHeight) || fontHeight <= 0) { + return DEFAULT_OUTPUT_ROW_HEIGHT; + } + + const deviceCharHeight = Math.ceil(fontHeight * devicePixelRatio); + const deviceCellHeight = Math.floor(deviceCharHeight * TERMINAL_OUTPUT_LINE_HEIGHT); + cachedDevicePixelRatio = devicePixelRatio; + cachedRowHeight = deviceCellHeight / devicePixelRatio; + return cachedRowHeight; + } catch { + return DEFAULT_OUTPUT_ROW_HEIGHT; + } +} + +export function prepareReadOnlyTerminalOutput(content: string): string { + return content + // A fresh xterm has no prior rows for absolute cursor positions to target. + // eslint-disable-next-line no-control-regex -- terminal control sequences are expected here. + .replace(/\x1b\[\d*;?\d*[Hf]/g, '\r\n') + // Avoid moving the cursor to an otherwise empty trailing row. + // eslint-disable-next-line no-control-regex -- terminal control sequences are expected here. + .replace(/(?:\r\n|\r|\n)+((?:\x1b\[[0-?]*[ -/]*[@-~])*)$/g, '$1'); +} + +export function stripTerminalControlSequences(content: string): string { + return content + // eslint-disable-next-line no-control-regex -- terminal control sequences are expected here. + .replace(/\x1b[\]PX_^][\s\S]*?(?:\x07|\x1b\\)/g, '') + // eslint-disable-next-line no-control-regex -- terminal control sequences are expected here. + .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') + // eslint-disable-next-line no-control-regex -- terminal control sequences are expected here. + .replace(/\x1b[ -/]*[@-~]/g, ''); +} + +export function takeLastTerminalRows(content: string, maxRows?: number): string { + if (!maxRows || maxRows <= 0) { + return content; + } + + let rowCount = 0; + let cursor = content.length; + while (cursor > 0 && rowCount < maxRows) { + const previousBreak = content.lastIndexOf('\n', cursor - 1); + if (previousBreak < 0) { + return content; + } + rowCount += 1; + cursor = previousBreak; + } + + return content.slice(cursor + 1); +} + +interface TerminalOutputHeightOptions { + rowHeight: number; + minHeight: number; + maxHeight: number; + maxRows?: number; +} + +export function calculateTerminalOutputHeight( + content: string, + { rowHeight, minHeight, maxHeight, maxRows }: TerminalOutputHeightOptions, +): number { + const heightForRows = (rows: number) => Math.ceil(Math.max(rowHeight, rows * rowHeight)); + const alignHeightToRows = (height: number, mode: 'floor' | 'ceil') => { + const rows = mode === 'ceil' + ? Math.ceil(height / rowHeight) + : Math.floor(height / rowHeight); + return heightForRows(Math.max(1, rows)); + }; + const effectiveMinHeight = alignHeightToRows(minHeight, 'ceil'); + const effectiveMaxHeight = maxRows != null && maxRows > 0 + ? heightForRows(maxRows) + : alignHeightToRows(maxHeight, 'floor'); + const boundedMaxHeight = Math.max(effectiveMinHeight, effectiveMaxHeight); + const lineCount = content ? content.split(/\r\n|\r|\n/).length : 1; + const visibleRows = maxRows != null && maxRows > 0 + ? Math.min(lineCount, maxRows) + : lineCount; + const estimatedHeight = heightForRows(Math.max(1, visibleRows)); + + return Math.min(Math.max(estimatedHeight, effectiveMinHeight), boundedMaxHeight); +} + +export interface TerminalOutputFallbackModel { + content: string; + height: number; +} + +export function buildTerminalOutputFallbackModel( + content: string, + options: { minHeight?: number; maxHeight?: number; maxRows?: number }, +): TerminalOutputFallbackModel { + const rowHeight = getEstimatedTerminalOutputRowHeight(); + const preparedContent = prepareReadOnlyTerminalOutput(content); + const preview = stripTerminalControlSequences( + takeLastTerminalRows(preparedContent, options.maxRows), + ); + + return { + content: preview, + height: calculateTerminalOutputHeight(preview, { + rowHeight, + minHeight: options.minHeight ?? rowHeight, + maxHeight: options.maxHeight ?? 300, + maxRows: options.maxRows, + }), + }; +}