) {
- 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,
+ }),
+ };
+}