fix(terminal): skip resize snapshots and standardize key encoding

This commit is contained in:
Mohamed Boudra
2026-03-23 14:47:27 +07:00
parent b862f7b72d
commit b873523eab
10 changed files with 167 additions and 86 deletions
@@ -175,6 +175,19 @@ const MountedTabSlot = memo(function MountedTabSlot({
paneId,
buildPaneContentModel,
}: MountedTabSlotProps) {
useEffect(() => {
if (tabDescriptor.target.kind !== "terminal") {
return;
}
console.log("[terminal-tab-slot]", {
paneId,
tabId: tabDescriptor.tabId,
terminalId: tabDescriptor.target.terminalId,
isVisible,
isPaneFocused,
});
}, [isPaneFocused, isVisible, paneId, tabDescriptor]);
const content = useMemo(
() =>
buildPaneContentModel({
@@ -1,7 +1,8 @@
"use dom";
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState, type Ref } from "react";
import type { DOMProps } from "expo/dom";
import { useDOMImperativeHandle, type DOMImperativeFactory } from "expo/dom";
import "@xterm/xterm/css/xterm.css";
import type { ITheme } from "@xterm/xterm";
import type { TerminalState } from "@server/shared/messages";
@@ -13,6 +14,12 @@ import {
computeVerticalScrollbarGeometry,
} from "./web-desktop-scrollbar.math";
export interface TerminalEmulatorHandle {
writeOutput: (text: string) => void;
renderSnapshot: (state: TerminalState | null) => void;
clear: () => void;
}
const SCROLLBAR_HANDLE_WIDTH_IDLE = 6;
const SCROLLBAR_HANDLE_WIDTH_ACTIVE = 9;
const SCROLLBAR_HANDLE_GRAB_WIDTH = 18;
@@ -64,13 +71,14 @@ function buildXtermThemeKey(theme: ITheme): string {
interface TerminalEmulatorProps {
dom?: DOMProps;
ref: Ref<TerminalEmulatorHandle>;
streamKey: string;
initialSnapshot: TerminalState | null;
testId?: string;
xtermTheme?: ITheme;
swipeGesturesEnabled?: boolean;
onSwipeLeft?: () => void;
onSwipeRight?: () => void;
initialSnapshot?: TerminalState | null;
onInput?: (data: string) => Promise<void> | void;
onResize?: (input: { rows: number; cols: number }) => Promise<void> | void;
onTerminalKey?: (input: {
@@ -86,12 +94,6 @@ interface TerminalEmulatorProps {
resizeRequestToken?: number;
}
export type TerminalEmulatorHandle = {
writeOutput: (text: string) => void;
renderSnapshot: (state: TerminalState | null) => void;
clear: () => void;
};
declare global {
interface Window {}
}
@@ -124,9 +126,9 @@ function ensureTerminalScrollbarStyle(): void {
document.head.appendChild(styleElement);
}
const TerminalEmulator = forwardRef<TerminalEmulatorHandle, TerminalEmulatorProps>(function TerminalEmulator({
export default function TerminalEmulator({
ref,
streamKey,
initialSnapshot,
testId = "terminal-surface",
xtermTheme = {
background: "#0b0b0b",
@@ -136,6 +138,7 @@ const TerminalEmulator = forwardRef<TerminalEmulatorHandle, TerminalEmulatorProp
swipeGesturesEnabled = false,
onSwipeLeft,
onSwipeRight,
initialSnapshot = null,
onInput,
onResize,
onTerminalKey,
@@ -143,7 +146,7 @@ const TerminalEmulator = forwardRef<TerminalEmulatorHandle, TerminalEmulatorProp
pendingModifiers = { ctrl: false, shift: false, alt: false },
focusRequestToken = 0,
resizeRequestToken = 0,
}, ref) {
}: TerminalEmulatorProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const hostRef = useRef<HTMLDivElement | null>(null);
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
@@ -165,6 +168,23 @@ const TerminalEmulator = forwardRef<TerminalEmulatorHandle, TerminalEmulatorProp
const [isScrollVisible, setIsScrollVisible] = useState(false);
const [isScrollActive, setIsScrollActive] = useState(false);
useDOMImperativeHandle(
ref as Ref<DOMImperativeFactory>,
() =>
({
writeOutput: (text: string) => {
runtimeRef.current?.write({ text });
},
renderSnapshot: (state: TerminalState | null) => {
runtimeRef.current?.renderSnapshot({ state });
},
clear: () => {
runtimeRef.current?.clear();
},
}) as unknown as DOMImperativeFactory,
[],
);
useEffect(() => {
mountedThemeRef.current = xtermTheme;
runtimeRef.current?.setTheme({ theme: xtermTheme });
@@ -338,22 +358,6 @@ const TerminalEmulator = forwardRef<TerminalEmulatorHandle, TerminalEmulatorProp
runtimeRef.current?.setPendingModifiers({ pendingModifiers });
}, [pendingModifiers]);
useImperativeHandle(
ref,
() => ({
writeOutput: (text: string) => {
runtimeRef.current?.write({ text });
},
renderSnapshot: (state: TerminalState | null) => {
runtimeRef.current?.renderSnapshot({ state });
},
clear: () => {
runtimeRef.current?.clear();
},
}),
[],
);
useEffect(() => {
if (focusRequestToken <= 0) {
return;
@@ -670,6 +674,4 @@ const TerminalEmulator = forwardRef<TerminalEmulatorHandle, TerminalEmulatorProp
) : null}
</div>
);
});
export default TerminalEmulator;
}
@@ -107,13 +107,13 @@ export function TerminalPane({
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
const streamControllerRef = useRef<TerminalStreamController | null>(null);
const emulatorRef = useRef<TerminalEmulatorHandle | null>(null);
const workspaceTerminalSession = useMemo(() => getWorkspaceTerminalSession({ scopeKey }), [scopeKey]);
const [isAttaching, setIsAttaching] = useState(false);
const [streamError, setStreamError] = useState<string | null>(null);
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
const [focusRequestToken, setFocusRequestToken] = useState(0);
const [resizeRequestToken, setResizeRequestToken] = useState(0);
const emulatorRef = useRef<TerminalEmulatorHandle>(null);
const terminalIdRef = useRef<string>(terminalId);
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
@@ -560,10 +560,10 @@ export function TerminalPane({
contentInsetAdjustmentBehavior: "never",
}}
streamKey={`${scopeKey}:${terminalId}`}
initialSnapshot={initialSnapshot}
testId="terminal-surface"
xtermTheme={xtermTheme}
swipeGesturesEnabled={swipeGesturesEnabled}
initialSnapshot={initialSnapshot}
onSwipeRight={() => {
if (!swipeGesturesEnabled) {
return;
@@ -40,6 +40,10 @@ export type TerminalStreamControllerOptions = {
const TERMINAL_EXITED_ERROR = "Terminal exited";
function logTerminalStreamController(event: string, details: Record<string, unknown>): void {
console.log("[terminal-stream-controller]", event, details);
}
export class TerminalStreamController {
private readonly decoder = new TextDecoder();
private readonly unsubscribeStreamEvents: () => void;
@@ -65,10 +69,19 @@ export class TerminalStreamController {
setTerminal(input: { terminalId: string | null }): void {
if (this.disposed || input.terminalId === this.terminalId) {
logTerminalStreamController("set_terminal_noop", {
disposed: this.disposed,
currentTerminalId: this.terminalId,
requestedTerminalId: input.terminalId,
});
return;
}
const nextTerminalId = input.terminalId;
const previousTerminalId = this.terminalId;
logTerminalStreamController("set_terminal", {
previousTerminalId,
nextTerminalId,
});
this.terminalId = nextTerminalId;
this.decoder.decode();
if (previousTerminalId) {
@@ -82,6 +95,11 @@ export class TerminalStreamController {
void this.options.client
.subscribeTerminal(nextTerminalId)
.then((payload) => {
logTerminalStreamController("subscribe_resolved", {
nextTerminalId,
payloadTerminalId: payload.terminalId,
error: payload.error ?? null,
});
if (this.disposed || this.terminalId !== nextTerminalId) {
return;
}
@@ -95,6 +113,10 @@ export class TerminalStreamController {
return;
}
const preferredSize = this.options.getPreferredSize();
logTerminalStreamController("preferred_size_after_subscribe", {
nextTerminalId,
preferredSize,
});
if (preferredSize) {
this.options.client.sendTerminalInput(nextTerminalId, {
type: "resize",
@@ -109,6 +131,10 @@ export class TerminalStreamController {
});
})
.catch((error: unknown) => {
logTerminalStreamController("subscribe_rejected", {
nextTerminalId,
error: error instanceof Error ? error.message : "Unable to subscribe to terminal",
});
if (this.disposed || this.terminalId !== nextTerminalId) {
return;
}
@@ -122,6 +148,10 @@ export class TerminalStreamController {
}
handleTerminalExit(input: { terminalId: string }): void {
logTerminalStreamController("terminal_exit", {
currentTerminalId: this.terminalId,
exitedTerminalId: input.terminalId,
});
if (this.disposed || input.terminalId !== this.terminalId) {
return;
}
@@ -138,6 +168,9 @@ export class TerminalStreamController {
if (this.disposed) {
return;
}
logTerminalStreamController("dispose", {
terminalId: this.terminalId,
});
this.disposed = true;
this.decoder.decode();
const terminalId = this.terminalId;