From 9aed8ec10c29037c5e4be6d177b087936b69d2fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=CE=A3rebe=20-=20Romain=20GERARD?= Date: Fri, 10 Jul 2026 18:20:39 +0200 Subject: [PATCH] fix(shell): Take a bigger margin for terminal size * Use the shell container size instead of the global one * increase the cell height to avoid line wrapping --- .../lib/cluster-terminal/cluster-terminal.tsx | 22 ++++-- .../lib/service-terminal/service-terminal.tsx | 20 +++-- libs/shared/util-hooks/src/index.ts | 1 + .../use-terminal-dimensions.spec.tsx | 59 ++++++++++++++ .../use-terminal-dimensions.ts | 78 +++++++++++++++++++ 5 files changed, 165 insertions(+), 15 deletions(-) create mode 100644 libs/shared/util-hooks/src/lib/use-terminal-dimensions/use-terminal-dimensions.spec.tsx create mode 100644 libs/shared/util-hooks/src/lib/use-terminal-dimensions/use-terminal-dimensions.ts diff --git a/libs/domains/clusters/feature/src/lib/cluster-terminal/cluster-terminal.tsx b/libs/domains/clusters/feature/src/lib/cluster-terminal/cluster-terminal.tsx index e4f3a02581a..7ff67119793 100644 --- a/libs/domains/clusters/feature/src/lib/cluster-terminal/cluster-terminal.tsx +++ b/libs/domains/clusters/feature/src/lib/cluster-terminal/cluster-terminal.tsx @@ -9,7 +9,7 @@ import { memo, useCallback, useEffect, useMemo, useState } from 'react' import { XTerm } from 'react-xtermjs' import { match } from 'ts-pattern' import { LoaderSpinner, toast } from '@qovery/shared/ui' -import { useTerminalReadiness } from '@qovery/shared/util-hooks' +import { useTerminalDimensions, useTerminalReadiness } from '@qovery/shared/util-hooks' import { QOVERY_WS } from '@qovery/shared/util-node-env' import { useReactQueryWsSubscription } from '@qovery/state/util-queries' @@ -76,12 +76,18 @@ export function ClusterTerminal({ organizationId, clusterId, cloudProvider }: Cl [detachWebSocket] ) - // Necesssary to calculate the number of rows and columns (tty) for the terminal - // https://github.com/xtermjs/xterm.js/issues/1412#issuecomment-724421101 - // 14 is the font height for better k9s compatibility - const rows = Math.ceil(document.body.clientHeight / 14) - // 9 is the font width for better k9s compatibility, with a minimum width of 80 columns - const cols = Math.max(80, Math.ceil(document.body.clientWidth / 9)) + // Measure the actual terminal container (not document.body) so the TTY size sent to the + // backend matches what is rendered. https://github.com/xtermjs/xterm.js/issues/1412#issuecomment-724421101 + // 9x17 is the font cell size for better k9s compatibility, with a minimum width of 80 columns. + const { + ref: terminalContainerRef, + cols, + rows, + } = useTerminalDimensions({ + cellWidth: 9, + cellHeight: 17, + minCols: 80, + }) useReactQueryWsSubscription({ url: QOVERY_WS + '/shell/debug', @@ -104,7 +110,7 @@ export function ClusterTerminal({ organizationId, clusterId, cloudProvider }: Cl return (
-
+
{isTerminalLoading ? (
diff --git a/libs/domains/services/feature/src/lib/service-terminal/service-terminal.tsx b/libs/domains/services/feature/src/lib/service-terminal/service-terminal.tsx index 513f5ebb386..26034b24740 100644 --- a/libs/domains/services/feature/src/lib/service-terminal/service-terminal.tsx +++ b/libs/domains/services/feature/src/lib/service-terminal/service-terminal.tsx @@ -19,7 +19,7 @@ import { toast, useModal, } from '@qovery/shared/ui' -import { useTerminalReadiness } from '@qovery/shared/util-hooks' +import { useTerminalDimensions, useTerminalReadiness } from '@qovery/shared/util-hooks' import { QOVERY_WS } from '@qovery/shared/util-node-env' import { useReactQueryWsSubscription } from '@qovery/state/util-queries' import { useRunningStatus, useService } from '../..' @@ -236,11 +236,17 @@ export function ServiceTerminal({ [runningStatuses?.state] ) - // Necesssary to calculate the number of rows and columns (tty) for the terminal - // https://github.com/xtermjs/xterm.js/issues/1412#issuecomment-724421101 - // 16 is the font height - const rows = Math.ceil(document.body.clientHeight / 16) - const cols = Math.ceil(document.body.clientWidth / 8) + // Measure the actual terminal container (not document.body) so the TTY size sent to the + // backend matches what is rendered. https://github.com/xtermjs/xterm.js/issues/1412#issuecomment-724421101 + // 9x17 is the font cell size. + const { + ref: terminalContainerRef, + cols, + rows, + } = useTerminalDimensions({ + cellWidth: 9, + cellHeight: 17, + }) useReactQueryWsSubscription({ url: QOVERY_WS + (ephemeralConfig ? '/shell/ephemeral' : '/shell/exec'), @@ -321,7 +327,7 @@ export function ServiceTerminal({
-
+
{terminalLaunchError ? (

Request ID: {requestId}

diff --git a/libs/shared/util-hooks/src/index.ts b/libs/shared/util-hooks/src/index.ts index c3daf7a75de..8131105e9ba 100644 --- a/libs/shared/util-hooks/src/index.ts +++ b/libs/shared/util-hooks/src/index.ts @@ -9,6 +9,7 @@ export * from './lib/use-local-storage/use-local-storage' export * from './lib/use-support-chat/use-support-chat' export * from './lib/use-pod-color/use-pod-color' export * from './lib/use-terminal-readiness/use-terminal-readiness' +export * from './lib/use-terminal-dimensions/use-terminal-dimensions' export * from './lib/use-interval-tick/use-interval-tick' export * from './lib/use-utm-params/use-utm-params' export * from './lib/use-os/use-os' diff --git a/libs/shared/util-hooks/src/lib/use-terminal-dimensions/use-terminal-dimensions.spec.tsx b/libs/shared/util-hooks/src/lib/use-terminal-dimensions/use-terminal-dimensions.spec.tsx new file mode 100644 index 00000000000..ce6c79d2ff1 --- /dev/null +++ b/libs/shared/util-hooks/src/lib/use-terminal-dimensions/use-terminal-dimensions.spec.tsx @@ -0,0 +1,59 @@ +import { render, screen } from '@qovery/shared/util-tests' +import { useTerminalDimensions } from './use-terminal-dimensions' + +function mockElementBox(width: number, height: number, padding = 0) { + // jsdom does not lay out elements, so we stub the geometry the hook reads. + jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(width) + jest.spyOn(HTMLElement.prototype, 'clientHeight', 'get').mockReturnValue(height) + jest.spyOn(window, 'getComputedStyle').mockReturnValue({ + paddingLeft: `${padding}px`, + paddingRight: `${padding}px`, + paddingTop: `${padding}px`, + paddingBottom: `${padding}px`, + } as CSSStyleDeclaration) +} + +function TestComponent(props: Parameters[0]) { + const { ref, cols, rows } = useTerminalDimensions(props) + return ( +
+ {cols} + {rows} +
+ ) +} + +function renderDimensions(props: Parameters[0]) { + render() + return { + cols: Number(screen.getByTestId('cols').textContent), + rows: Number(screen.getByTestId('rows').textContent), + } +} + +describe('useTerminalDimensions', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + it('computes cols and rows from the measured container box', () => { + mockElementBox(800, 400) + + // 800 / 8 = 100 cols, 400 / 16 = 25 rows + expect(renderDimensions({ cellWidth: 8, cellHeight: 16 })).toEqual({ cols: 100, rows: 25 }) + }) + + it('subtracts container padding before dividing by the cell size', () => { + mockElementBox(800, 400, 20) + + // (800 - 40) / 8 = 95 cols, (400 - 40) / 16 = 22.5 -> 22 rows + expect(renderDimensions({ cellWidth: 8, cellHeight: 16 })).toEqual({ cols: 95, rows: 22 }) + }) + + it('respects the configured minimum columns and rows', () => { + mockElementBox(100, 8) + + // 100 / 9 = 11 cols but min is 80; 8 / 14 = 0 rows but min is 1 + expect(renderDimensions({ cellWidth: 9, cellHeight: 14, minCols: 80, minRows: 1 })).toEqual({ cols: 80, rows: 1 }) + }) +}) diff --git a/libs/shared/util-hooks/src/lib/use-terminal-dimensions/use-terminal-dimensions.ts b/libs/shared/util-hooks/src/lib/use-terminal-dimensions/use-terminal-dimensions.ts new file mode 100644 index 00000000000..0857db55c70 --- /dev/null +++ b/libs/shared/util-hooks/src/lib/use-terminal-dimensions/use-terminal-dimensions.ts @@ -0,0 +1,78 @@ +import { useLayoutEffect, useRef, useState } from 'react' + +export interface UseTerminalDimensionsOptions { + /** Width of a single character cell in pixels (matches the terminal font). */ + cellWidth: number + /** Height of a single character cell in pixels (matches the terminal font). */ + cellHeight: number + /** Minimum number of columns, useful to keep TUIs (e.g. k9s) usable on narrow layouts. */ + minCols?: number + /** Minimum number of rows. */ + minRows?: number +} + +export interface TerminalDimensions { + cols: number + rows: number +} + +function computeDimensions( + element: HTMLElement, + { cellWidth, cellHeight, minCols = 2, minRows = 1 }: UseTerminalDimensionsOptions +): TerminalDimensions { + const styles = window.getComputedStyle(element) + const paddingX = parseFloat(styles.paddingLeft) + parseFloat(styles.paddingRight) + const paddingY = parseFloat(styles.paddingTop) + parseFloat(styles.paddingBottom) + const availableWidth = Math.max(0, element.clientWidth - paddingX) + const availableHeight = Math.max(0, element.clientHeight - paddingY) + + return { + cols: Math.max(minCols, Math.floor(availableWidth / cellWidth)), + rows: Math.max(minRows, Math.floor(availableHeight / cellHeight)), + } +} + +/** + * Measures the terminal container element to derive the TTY `cols`/`rows` that + * actually fit, instead of guessing from `document.body`. The value is sent to + * the backend at websocket connect time as `tty_width`/`tty_height`. + * + * Returns a `ref` to attach to the element wrapping the terminal, and the + * current dimensions. Dimensions recompute on element resize so a connection + * opened after layout settles gets the real size. + */ +export function useTerminalDimensions(options: UseTerminalDimensionsOptions) { + const ref = useRef(null) + const [dimensions, setDimensions] = useState({ + cols: options.minCols ?? 2, + rows: options.minRows ?? 1, + }) + // Keep the latest options without re-subscribing the observer on every render. + const optionsRef = useRef(options) + optionsRef.current = options + + useLayoutEffect(() => { + const element = ref.current + if (!element) { + return + } + + const update = () => { + setDimensions((previous) => { + const next = computeDimensions(element, optionsRef.current) + return previous.cols === next.cols && previous.rows === next.rows ? previous : next + }) + } + + update() + + const resizeObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(update) + resizeObserver?.observe(element) + + return () => resizeObserver?.disconnect() + }, []) + + return { ref, ...dimensions } +} + +export default useTerminalDimensions