fix(app): stabilize native terminal rendering

Scale custom glyph paths in measured cell coordinates so box-drawing runs fill their cells on Android. Debounce daemon resize claims at the terminal pane boundary so keyboard layout animation sends only the settled dimensions.
This commit is contained in:
Mohamed Boudra
2026-08-04 18:43:46 +02:00
parent dac255cb99
commit adf508a1c8
6 changed files with 212 additions and 45 deletions
+66 -43
View File
@@ -53,6 +53,10 @@ import { useSessionStore } from "@/stores/session-store";
import { toXtermTheme } from "@/utils/to-xterm-theme";
import TerminalEmulator, { type TerminalEmulatorHandle } from "./terminal-emulator";
import { TerminalFloatingCopyAction, TerminalPasteAction } from "./terminal-copy-paste-actions";
import {
createTerminalResizeDebouncer,
type TerminalResizeRequest,
} from "./terminal-resize-debouncer";
import { useIsCompactFormFactor } from "@/constants/layout";
import { isNative } from "@/constants/platform";
import {
@@ -85,6 +89,7 @@ interface TerminalPaneProps {
}
const TERMINAL_REFIT_DELAYS_MS = [0, 48, 144, 320];
const TERMINAL_RESIZE_DEBOUNCE_MS = 100;
const MODIFIER_LABELS = {
ctrl: "Ctrl",
@@ -759,52 +764,70 @@ export function TerminalPane({
],
);
const handleTerminalResize = useStableEvent(
(input: { rows: number; cols: number; shouldClaim: boolean; forceClaim?: boolean }) => {
const { rows, cols } = input;
if (rows <= 0 || cols <= 0) {
return;
}
const normalizedRows = Math.floor(rows);
const normalizedCols = Math.floor(cols);
const nextSize = { rows: normalizedRows, cols: normalizedCols };
measuredTerminalSizeRef.current = nextSize;
const claim = resolveTerminalResizeClaim({
size: nextSize,
previousSentSize: lastSentTerminalSizeRef.current,
shouldClaim: input.shouldClaim,
forceClaim: input.forceClaim ?? false,
supportsTerminalSizeOwnership,
readiness: {
isWorkspaceFocused: isTerminalActive,
isPaneFocused,
isAppActivelyVisible,
isClientReady: client !== null,
isConnected,
isRendererReady: rendererReadyStreamKey === terminalStreamKey,
},
const sendTerminalResize = useStableEvent((input: TerminalResizeRequest) => {
const nextSize = { rows: input.rows, cols: input.cols };
const claim = resolveTerminalResizeClaim({
size: nextSize,
previousSentSize: lastSentTerminalSizeRef.current,
shouldClaim: input.shouldClaim,
forceClaim: input.forceClaim ?? false,
supportsTerminalSizeOwnership,
readiness: {
isWorkspaceFocused: isTerminalActive,
isPaneFocused,
isAppActivelyVisible,
isClientReady: client !== null,
isConnected,
isRendererReady: rendererReadyStreamKey === terminalStreamKey,
},
});
let sent = false;
if (client && terminalId && claim.shouldSend) {
lastSentTerminalSizeRef.current = nextSize;
client.sendTerminalInput(terminalId, {
type: "resize",
rows: input.rows,
cols: input.cols,
intent: claim.intent,
});
let sent = false;
if (client && terminalId && claim.shouldSend) {
lastSentTerminalSizeRef.current = nextSize;
client.sendTerminalInput(terminalId, {
type: "resize",
rows: normalizedRows,
cols: normalizedCols,
intent: claim.intent,
});
sent = true;
}
const requestedKey = paneFocusResizeClaimRef.current.requestedKey;
if (requestedKey && claim.intent === "claim") {
paneFocusResizeClaimRef.current = settleFocusClaim(paneFocusResizeClaimRef.current, {
key: requestedKey,
sent,
});
}
},
sent = true;
}
const requestedKey = paneFocusResizeClaimRef.current.requestedKey;
if (requestedKey && claim.intent === "claim") {
paneFocusResizeClaimRef.current = settleFocusClaim(paneFocusResizeClaimRef.current, {
key: requestedKey,
sent,
});
}
});
const terminalResizeDebouncer = useMemo(
() =>
createTerminalResizeDebouncer({
delayMs: TERMINAL_RESIZE_DEBOUNCE_MS,
emit: sendTerminalResize,
}),
[sendTerminalResize],
);
useEffect(
() => () => terminalResizeDebouncer.cancel(),
[terminalResizeDebouncer, terminalStreamKey],
);
const handleTerminalResize = useStableEvent((input: TerminalResizeRequest) => {
if (input.rows <= 0 || input.cols <= 0) {
return;
}
const nextResize = {
...input,
rows: Math.floor(input.rows),
cols: Math.floor(input.cols),
};
measuredTerminalSizeRef.current = { rows: nextResize.rows, cols: nextResize.cols };
terminalResizeDebouncer.schedule(nextResize);
});
const handleTerminalKey = useCallback(
async (input: { key: string; ctrl: boolean; shift: boolean; alt: boolean; meta: boolean }) => {
sendTerminalKey(input);
@@ -0,0 +1,68 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createTerminalResizeDebouncer } from "./terminal-resize-debouncer";
describe("terminal resize debouncer", () => {
afterEach(() => {
vi.useRealTimers();
});
it("emits only the latest size after resize callbacks settle", () => {
vi.useFakeTimers();
const emitted: Array<{
rows: number;
cols: number;
shouldClaim: boolean;
forceClaim?: boolean;
}> = [];
const debouncer = createTerminalResizeDebouncer({
delayMs: 100,
emit: (resize) => emitted.push(resize),
});
debouncer.schedule({ rows: 40, cols: 100, shouldClaim: false });
vi.advanceTimersByTime(50);
debouncer.schedule({ rows: 35, cols: 100, shouldClaim: false });
vi.advanceTimersByTime(99);
expect(emitted).toEqual([]);
vi.advanceTimersByTime(1);
expect(emitted).toEqual([{ rows: 35, cols: 100, shouldClaim: false }]);
});
it("preserves a claim while taking the latest dimensions", () => {
vi.useFakeTimers();
const emitted: Array<{
rows: number;
cols: number;
shouldClaim: boolean;
forceClaim?: boolean;
}> = [];
const debouncer = createTerminalResizeDebouncer({
delayMs: 100,
emit: (resize) => emitted.push(resize),
});
debouncer.schedule({ rows: 40, cols: 100, shouldClaim: true, forceClaim: true });
debouncer.schedule({ rows: 35, cols: 100, shouldClaim: false });
vi.runAllTimers();
expect(emitted).toEqual([{ rows: 35, cols: 100, shouldClaim: true, forceClaim: true }]);
});
it("cancels a pending resize", () => {
vi.useFakeTimers();
const emitted: unknown[] = [];
const debouncer = createTerminalResizeDebouncer({
delayMs: 100,
emit: (resize) => emitted.push(resize),
});
debouncer.schedule({ rows: 40, cols: 100, shouldClaim: false });
debouncer.cancel();
vi.runAllTimers();
expect(emitted).toEqual([]);
});
});
@@ -0,0 +1,47 @@
export interface TerminalResizeRequest {
rows: number;
cols: number;
shouldClaim: boolean;
forceClaim?: boolean;
}
export function createTerminalResizeDebouncer(input: {
delayMs: number;
emit: (resize: TerminalResizeRequest) => void;
}): {
schedule: (resize: TerminalResizeRequest) => void;
cancel: () => void;
} {
let timeout: ReturnType<typeof setTimeout> | null = null;
let pending: TerminalResizeRequest | null = null;
const cancel = () => {
if (timeout !== null) {
clearTimeout(timeout);
timeout = null;
}
pending = null;
};
return {
schedule: (resize) => {
pending = {
...resize,
shouldClaim: (pending?.shouldClaim ?? false) || resize.shouldClaim,
forceClaim: (pending?.forceClaim ?? false) || resize.forceClaim || undefined,
};
if (timeout !== null) {
clearTimeout(timeout);
}
timeout = setTimeout(() => {
timeout = null;
const resizeToEmit = pending;
pending = null;
if (resizeToEmit) {
input.emit(resizeToEmit);
}
}, input.delayMs);
},
cancel,
};
}
@@ -1,12 +1,23 @@
import { describe, expect, it } from "vitest";
import {
resolveTerminalCustomGlyphCellTransform,
resolveTerminalGridMetricsMeasurement,
resolveMeasuredTerminalCellMetrics,
resolveTerminalCursorOffset,
} from "./terminal-grid-metrics";
describe("native terminal grid metrics", () => {
it("places each normalized custom glyph in run viewport coordinates", () => {
expect(
resolveTerminalCustomGlyphCellTransform({
cellOffset: 57,
cellWidth: 7.4,
cellHeight: 16.2,
}),
).toBe("matrix(7.4 0 0 16.2 421.8 0)");
});
it("preserves fractional measured cell width for horizontal layout", () => {
expect(
resolveMeasuredTerminalCellMetrics({
@@ -21,6 +21,19 @@ export interface TerminalCursorOffset {
y: number;
}
export interface TerminalCustomGlyphCellTransformInput {
cellOffset: number;
cellWidth: number;
cellHeight: number;
}
export function resolveTerminalCustomGlyphCellTransform(
input: TerminalCustomGlyphCellTransformInput,
): string {
const translateX = input.cellOffset * input.cellWidth;
return `matrix(${input.cellWidth} 0 0 ${input.cellHeight} ${translateX} 0)`;
}
export function resolveMeasuredTerminalCellMetrics(
input: TerminalGridCellMetricsInput,
): TerminalGridCellMetrics {
@@ -22,6 +22,7 @@ import { resolveNativeTerminalFontFamily } from "./font.native";
import type { TerminalViewportState } from "./headless-terminal-state";
import {
resolveMeasuredTerminalCellMetrics,
resolveTerminalCustomGlyphCellTransform,
resolveTerminalGridMetricsMeasurement,
resolveTerminalCursorOffset,
type TerminalGridCellMetrics,
@@ -192,7 +193,7 @@ function TerminalGridCustomGlyphRun({
pointerEvents="none"
preserveAspectRatio="none"
style={styles.customGlyphSvg}
viewBox={`0 0 ${run.cellCount} 1`}
viewBox={`0 0 ${run.cellCount * cellWidth} ${cellHeight}`}
width={run.cellCount * cellWidth}
>
<G fill="none" stroke={run.foregroundColor}>
@@ -204,7 +205,11 @@ function TerminalGridCustomGlyphRun({
strokeLinecap="butt"
strokeLinejoin="miter"
strokeWidth={cell.glyph.strokeWidth}
transform={`translate(${cell.offset} 0)`}
transform={resolveTerminalCustomGlyphCellTransform({
cellOffset: cell.offset,
cellWidth,
cellHeight,
})}
vectorEffect="non-scaling-stroke"
/>
) : null,