fix(terminal): skip resize snapshots and standardize key encoding
This commit is contained in:
@@ -175,6 +175,19 @@ const MountedTabSlot = memo(function MountedTabSlot({
|
|||||||
paneId,
|
paneId,
|
||||||
buildPaneContentModel,
|
buildPaneContentModel,
|
||||||
}: MountedTabSlotProps) {
|
}: 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(
|
const content = useMemo(
|
||||||
() =>
|
() =>
|
||||||
buildPaneContentModel({
|
buildPaneContentModel({
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use dom";
|
"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 type { DOMProps } from "expo/dom";
|
||||||
|
import { useDOMImperativeHandle, type DOMImperativeFactory } from "expo/dom";
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import type { ITheme } from "@xterm/xterm";
|
import type { ITheme } from "@xterm/xterm";
|
||||||
import type { TerminalState } from "@server/shared/messages";
|
import type { TerminalState } from "@server/shared/messages";
|
||||||
@@ -13,6 +14,12 @@ import {
|
|||||||
computeVerticalScrollbarGeometry,
|
computeVerticalScrollbarGeometry,
|
||||||
} from "./web-desktop-scrollbar.math";
|
} 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_IDLE = 6;
|
||||||
const SCROLLBAR_HANDLE_WIDTH_ACTIVE = 9;
|
const SCROLLBAR_HANDLE_WIDTH_ACTIVE = 9;
|
||||||
const SCROLLBAR_HANDLE_GRAB_WIDTH = 18;
|
const SCROLLBAR_HANDLE_GRAB_WIDTH = 18;
|
||||||
@@ -64,13 +71,14 @@ function buildXtermThemeKey(theme: ITheme): string {
|
|||||||
|
|
||||||
interface TerminalEmulatorProps {
|
interface TerminalEmulatorProps {
|
||||||
dom?: DOMProps;
|
dom?: DOMProps;
|
||||||
|
ref: Ref<TerminalEmulatorHandle>;
|
||||||
streamKey: string;
|
streamKey: string;
|
||||||
initialSnapshot: TerminalState | null;
|
|
||||||
testId?: string;
|
testId?: string;
|
||||||
xtermTheme?: ITheme;
|
xtermTheme?: ITheme;
|
||||||
swipeGesturesEnabled?: boolean;
|
swipeGesturesEnabled?: boolean;
|
||||||
onSwipeLeft?: () => void;
|
onSwipeLeft?: () => void;
|
||||||
onSwipeRight?: () => void;
|
onSwipeRight?: () => void;
|
||||||
|
initialSnapshot?: TerminalState | null;
|
||||||
onInput?: (data: string) => Promise<void> | void;
|
onInput?: (data: string) => Promise<void> | void;
|
||||||
onResize?: (input: { rows: number; cols: number }) => Promise<void> | void;
|
onResize?: (input: { rows: number; cols: number }) => Promise<void> | void;
|
||||||
onTerminalKey?: (input: {
|
onTerminalKey?: (input: {
|
||||||
@@ -86,12 +94,6 @@ interface TerminalEmulatorProps {
|
|||||||
resizeRequestToken?: number;
|
resizeRequestToken?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TerminalEmulatorHandle = {
|
|
||||||
writeOutput: (text: string) => void;
|
|
||||||
renderSnapshot: (state: TerminalState | null) => void;
|
|
||||||
clear: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {}
|
interface Window {}
|
||||||
}
|
}
|
||||||
@@ -124,9 +126,9 @@ function ensureTerminalScrollbarStyle(): void {
|
|||||||
document.head.appendChild(styleElement);
|
document.head.appendChild(styleElement);
|
||||||
}
|
}
|
||||||
|
|
||||||
const TerminalEmulator = forwardRef<TerminalEmulatorHandle, TerminalEmulatorProps>(function TerminalEmulator({
|
export default function TerminalEmulator({
|
||||||
|
ref,
|
||||||
streamKey,
|
streamKey,
|
||||||
initialSnapshot,
|
|
||||||
testId = "terminal-surface",
|
testId = "terminal-surface",
|
||||||
xtermTheme = {
|
xtermTheme = {
|
||||||
background: "#0b0b0b",
|
background: "#0b0b0b",
|
||||||
@@ -136,6 +138,7 @@ const TerminalEmulator = forwardRef<TerminalEmulatorHandle, TerminalEmulatorProp
|
|||||||
swipeGesturesEnabled = false,
|
swipeGesturesEnabled = false,
|
||||||
onSwipeLeft,
|
onSwipeLeft,
|
||||||
onSwipeRight,
|
onSwipeRight,
|
||||||
|
initialSnapshot = null,
|
||||||
onInput,
|
onInput,
|
||||||
onResize,
|
onResize,
|
||||||
onTerminalKey,
|
onTerminalKey,
|
||||||
@@ -143,7 +146,7 @@ const TerminalEmulator = forwardRef<TerminalEmulatorHandle, TerminalEmulatorProp
|
|||||||
pendingModifiers = { ctrl: false, shift: false, alt: false },
|
pendingModifiers = { ctrl: false, shift: false, alt: false },
|
||||||
focusRequestToken = 0,
|
focusRequestToken = 0,
|
||||||
resizeRequestToken = 0,
|
resizeRequestToken = 0,
|
||||||
}, ref) {
|
}: TerminalEmulatorProps) {
|
||||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||||
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
|
const runtimeRef = useRef<TerminalEmulatorRuntime | null>(null);
|
||||||
@@ -165,6 +168,23 @@ const TerminalEmulator = forwardRef<TerminalEmulatorHandle, TerminalEmulatorProp
|
|||||||
const [isScrollVisible, setIsScrollVisible] = useState(false);
|
const [isScrollVisible, setIsScrollVisible] = useState(false);
|
||||||
const [isScrollActive, setIsScrollActive] = 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(() => {
|
useEffect(() => {
|
||||||
mountedThemeRef.current = xtermTheme;
|
mountedThemeRef.current = xtermTheme;
|
||||||
runtimeRef.current?.setTheme({ theme: xtermTheme });
|
runtimeRef.current?.setTheme({ theme: xtermTheme });
|
||||||
@@ -338,22 +358,6 @@ const TerminalEmulator = forwardRef<TerminalEmulatorHandle, TerminalEmulatorProp
|
|||||||
runtimeRef.current?.setPendingModifiers({ pendingModifiers });
|
runtimeRef.current?.setPendingModifiers({ pendingModifiers });
|
||||||
}, [pendingModifiers]);
|
}, [pendingModifiers]);
|
||||||
|
|
||||||
useImperativeHandle(
|
|
||||||
ref,
|
|
||||||
() => ({
|
|
||||||
writeOutput: (text: string) => {
|
|
||||||
runtimeRef.current?.write({ text });
|
|
||||||
},
|
|
||||||
renderSnapshot: (state: TerminalState | null) => {
|
|
||||||
runtimeRef.current?.renderSnapshot({ state });
|
|
||||||
},
|
|
||||||
clear: () => {
|
|
||||||
runtimeRef.current?.clear();
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (focusRequestToken <= 0) {
|
if (focusRequestToken <= 0) {
|
||||||
return;
|
return;
|
||||||
@@ -670,6 +674,4 @@ const TerminalEmulator = forwardRef<TerminalEmulatorHandle, TerminalEmulatorProp
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
export default TerminalEmulator;
|
|
||||||
|
|||||||
@@ -107,13 +107,13 @@ export function TerminalPane({
|
|||||||
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
const scopeKey = useMemo(() => terminalScopeKey({ serverId, cwd }), [serverId, cwd]);
|
||||||
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
const lastReportedSizeRef = useRef<{ rows: number; cols: number } | null>(null);
|
||||||
const streamControllerRef = useRef<TerminalStreamController | null>(null);
|
const streamControllerRef = useRef<TerminalStreamController | null>(null);
|
||||||
const emulatorRef = useRef<TerminalEmulatorHandle | null>(null);
|
|
||||||
const workspaceTerminalSession = useMemo(() => getWorkspaceTerminalSession({ scopeKey }), [scopeKey]);
|
const workspaceTerminalSession = useMemo(() => getWorkspaceTerminalSession({ scopeKey }), [scopeKey]);
|
||||||
const [isAttaching, setIsAttaching] = useState(false);
|
const [isAttaching, setIsAttaching] = useState(false);
|
||||||
const [streamError, setStreamError] = useState<string | null>(null);
|
const [streamError, setStreamError] = useState<string | null>(null);
|
||||||
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
|
const [modifiers, setModifiers] = useState<ModifierState>(EMPTY_MODIFIERS);
|
||||||
const [focusRequestToken, setFocusRequestToken] = useState(0);
|
const [focusRequestToken, setFocusRequestToken] = useState(0);
|
||||||
const [resizeRequestToken, setResizeRequestToken] = useState(0);
|
const [resizeRequestToken, setResizeRequestToken] = useState(0);
|
||||||
|
const emulatorRef = useRef<TerminalEmulatorHandle>(null);
|
||||||
const terminalIdRef = useRef<string>(terminalId);
|
const terminalIdRef = useRef<string>(terminalId);
|
||||||
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
|
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
|
||||||
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
|
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
|
||||||
@@ -560,10 +560,10 @@ export function TerminalPane({
|
|||||||
contentInsetAdjustmentBehavior: "never",
|
contentInsetAdjustmentBehavior: "never",
|
||||||
}}
|
}}
|
||||||
streamKey={`${scopeKey}:${terminalId}`}
|
streamKey={`${scopeKey}:${terminalId}`}
|
||||||
initialSnapshot={initialSnapshot}
|
|
||||||
testId="terminal-surface"
|
testId="terminal-surface"
|
||||||
xtermTheme={xtermTheme}
|
xtermTheme={xtermTheme}
|
||||||
swipeGesturesEnabled={swipeGesturesEnabled}
|
swipeGesturesEnabled={swipeGesturesEnabled}
|
||||||
|
initialSnapshot={initialSnapshot}
|
||||||
onSwipeRight={() => {
|
onSwipeRight={() => {
|
||||||
if (!swipeGesturesEnabled) {
|
if (!swipeGesturesEnabled) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ export type TerminalStreamControllerOptions = {
|
|||||||
|
|
||||||
const TERMINAL_EXITED_ERROR = "Terminal exited";
|
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 {
|
export class TerminalStreamController {
|
||||||
private readonly decoder = new TextDecoder();
|
private readonly decoder = new TextDecoder();
|
||||||
private readonly unsubscribeStreamEvents: () => void;
|
private readonly unsubscribeStreamEvents: () => void;
|
||||||
@@ -65,10 +69,19 @@ export class TerminalStreamController {
|
|||||||
|
|
||||||
setTerminal(input: { terminalId: string | null }): void {
|
setTerminal(input: { terminalId: string | null }): void {
|
||||||
if (this.disposed || input.terminalId === this.terminalId) {
|
if (this.disposed || input.terminalId === this.terminalId) {
|
||||||
|
logTerminalStreamController("set_terminal_noop", {
|
||||||
|
disposed: this.disposed,
|
||||||
|
currentTerminalId: this.terminalId,
|
||||||
|
requestedTerminalId: input.terminalId,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextTerminalId = input.terminalId;
|
const nextTerminalId = input.terminalId;
|
||||||
const previousTerminalId = this.terminalId;
|
const previousTerminalId = this.terminalId;
|
||||||
|
logTerminalStreamController("set_terminal", {
|
||||||
|
previousTerminalId,
|
||||||
|
nextTerminalId,
|
||||||
|
});
|
||||||
this.terminalId = nextTerminalId;
|
this.terminalId = nextTerminalId;
|
||||||
this.decoder.decode();
|
this.decoder.decode();
|
||||||
if (previousTerminalId) {
|
if (previousTerminalId) {
|
||||||
@@ -82,6 +95,11 @@ export class TerminalStreamController {
|
|||||||
void this.options.client
|
void this.options.client
|
||||||
.subscribeTerminal(nextTerminalId)
|
.subscribeTerminal(nextTerminalId)
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
|
logTerminalStreamController("subscribe_resolved", {
|
||||||
|
nextTerminalId,
|
||||||
|
payloadTerminalId: payload.terminalId,
|
||||||
|
error: payload.error ?? null,
|
||||||
|
});
|
||||||
if (this.disposed || this.terminalId !== nextTerminalId) {
|
if (this.disposed || this.terminalId !== nextTerminalId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -95,6 +113,10 @@ export class TerminalStreamController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const preferredSize = this.options.getPreferredSize();
|
const preferredSize = this.options.getPreferredSize();
|
||||||
|
logTerminalStreamController("preferred_size_after_subscribe", {
|
||||||
|
nextTerminalId,
|
||||||
|
preferredSize,
|
||||||
|
});
|
||||||
if (preferredSize) {
|
if (preferredSize) {
|
||||||
this.options.client.sendTerminalInput(nextTerminalId, {
|
this.options.client.sendTerminalInput(nextTerminalId, {
|
||||||
type: "resize",
|
type: "resize",
|
||||||
@@ -109,6 +131,10 @@ export class TerminalStreamController {
|
|||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
|
logTerminalStreamController("subscribe_rejected", {
|
||||||
|
nextTerminalId,
|
||||||
|
error: error instanceof Error ? error.message : "Unable to subscribe to terminal",
|
||||||
|
});
|
||||||
if (this.disposed || this.terminalId !== nextTerminalId) {
|
if (this.disposed || this.terminalId !== nextTerminalId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -122,6 +148,10 @@ export class TerminalStreamController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
handleTerminalExit(input: { terminalId: string }): void {
|
handleTerminalExit(input: { terminalId: string }): void {
|
||||||
|
logTerminalStreamController("terminal_exit", {
|
||||||
|
currentTerminalId: this.terminalId,
|
||||||
|
exitedTerminalId: input.terminalId,
|
||||||
|
});
|
||||||
if (this.disposed || input.terminalId !== this.terminalId) {
|
if (this.disposed || input.terminalId !== this.terminalId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -138,6 +168,9 @@ export class TerminalStreamController {
|
|||||||
if (this.disposed) {
|
if (this.disposed) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
logTerminalStreamController("dispose", {
|
||||||
|
terminalId: this.terminalId,
|
||||||
|
});
|
||||||
this.disposed = true;
|
this.disposed = true;
|
||||||
this.decoder.decode();
|
this.decoder.decode();
|
||||||
const terminalId = this.terminalId;
|
const terminalId = this.terminalId;
|
||||||
|
|||||||
@@ -606,16 +606,10 @@ describe("daemon E2E terminal", () => {
|
|||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
test("resize sends a new snapshot with the updated dimensions", async () => {
|
test("resize updates server dimensions without sending a live snapshot", async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
const created = await ctx.client.createTerminal(cwd);
|
const created = await ctx.client.createTerminal(cwd);
|
||||||
const terminalId = created.terminal!.id;
|
const terminalId = created.terminal!.id;
|
||||||
|
|
||||||
const resizedSnapshot = waitForTerminalSnapshot(
|
|
||||||
ctx.client,
|
|
||||||
terminalId,
|
|
||||||
(state) => state.rows === 10 && state.cols === 40,
|
|
||||||
);
|
|
||||||
await ctx.client.subscribeTerminal(terminalId);
|
await ctx.client.subscribeTerminal(terminalId);
|
||||||
ctx.client.sendTerminalInput(terminalId, {
|
ctx.client.sendTerminalInput(terminalId, {
|
||||||
type: "resize",
|
type: "resize",
|
||||||
@@ -623,9 +617,11 @@ describe("daemon E2E terminal", () => {
|
|||||||
cols: 40,
|
cols: 40,
|
||||||
});
|
});
|
||||||
|
|
||||||
const snapshot = await resizedSnapshot;
|
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||||
expect(snapshot.rows).toBe(10);
|
|
||||||
expect(snapshot.cols).toBe(40);
|
const state = ctx.daemon.daemon.terminalManager?.getTerminal(terminalId)?.getState();
|
||||||
|
expect(state?.rows).toBe(10);
|
||||||
|
expect(state?.cols).toBe(40);
|
||||||
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|||||||
@@ -7729,6 +7729,13 @@ export class Session {
|
|||||||
}
|
}
|
||||||
this.ensureTerminalExitSubscription(session);
|
this.ensureTerminalExitSubscription(session);
|
||||||
|
|
||||||
|
if (msg.message.type === "resize") {
|
||||||
|
const currentSize = session.getSize();
|
||||||
|
if (currentSize.rows === msg.message.rows && currentSize.cols === msg.message.cols) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
session.send(msg.message);
|
session.send(msg.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,10 +23,11 @@ describe("encodeTerminalKeyInput", () => {
|
|||||||
expect(encodeTerminalKeyInput({ key: "Enter", shift: true })).toBe("\x1b[13;2u");
|
expect(encodeTerminalKeyInput({ key: "Enter", shift: true })).toBe("\x1b[13;2u");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("encodes enter with other modifiers as plain carriage return", () => {
|
it("encodes enter with modifiers using CSI u", () => {
|
||||||
expect(encodeTerminalKeyInput({ key: "Enter", ctrl: true })).toBe("\r");
|
expect(encodeTerminalKeyInput({ key: "Enter", ctrl: true })).toBe("\x1b[13;5u");
|
||||||
expect(encodeTerminalKeyInput({ key: "Enter", alt: true })).toBe("\x1b\r");
|
expect(encodeTerminalKeyInput({ key: "Enter", alt: true })).toBe("\x1b[13;3u");
|
||||||
expect(encodeTerminalKeyInput({ key: "Enter", shift: true, ctrl: true })).toBe("\r");
|
expect(encodeTerminalKeyInput({ key: "Enter", meta: true })).toBe("\x1b[13;9u");
|
||||||
|
expect(encodeTerminalKeyInput({ key: "Enter", shift: true, ctrl: true })).toBe("\x1b[13;6u");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns empty string for unsupported keys", () => {
|
it("returns empty string for unsupported keys", () => {
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ export interface TerminalKeyInput {
|
|||||||
function modifierParam(input: TerminalKeyInput): number {
|
function modifierParam(input: TerminalKeyInput): number {
|
||||||
let value = 1;
|
let value = 1;
|
||||||
if (input.shift) value += 1;
|
if (input.shift) value += 1;
|
||||||
if (input.alt || input.meta) value += 2;
|
if (input.alt) value += 2;
|
||||||
if (input.ctrl) value += 4;
|
if (input.ctrl) value += 4;
|
||||||
|
if (input.meta) value += 8;
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyAltLikePrefix(sequence: string, input: TerminalKeyInput): string {
|
function applyAltLikePrefix(sequence: string, input: TerminalKeyInput): string {
|
||||||
return input.alt || input.meta ? `\x1b${sequence}` : sequence;
|
return input.alt ? `\x1b${sequence}` : sequence;
|
||||||
}
|
}
|
||||||
|
|
||||||
function encodePrintableKey(input: TerminalKeyInput): string {
|
function encodePrintableKey(input: TerminalKeyInput): string {
|
||||||
@@ -85,11 +86,13 @@ export function encodeTerminalKeyInput(input: TerminalKeyInput): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "Enter":
|
case "Enter": {
|
||||||
if (input.shift && !input.ctrl && !input.alt && !input.meta) {
|
const mod = modifierParam(input);
|
||||||
return "\x1b[13;2u";
|
if (mod > 1) {
|
||||||
|
return `\x1b[13;${mod}u`;
|
||||||
|
}
|
||||||
|
return "\r";
|
||||||
}
|
}
|
||||||
return applyAltLikePrefix("\r", input);
|
|
||||||
case "Tab":
|
case "Tab":
|
||||||
if (input.shift && !input.ctrl && !input.alt && !input.meta) {
|
if (input.shift && !input.ctrl && !input.alt && !input.meta) {
|
||||||
return "\x1b[Z";
|
return "\x1b[Z";
|
||||||
|
|||||||
@@ -402,6 +402,25 @@ describe("Terminal", () => {
|
|||||||
expect(state.grid.length).toBe(10);
|
expect(state.grid.length).toBe(10);
|
||||||
expect(state.grid[0].length).toBe(40);
|
expect(state.grid[0].length).toBe(40);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("exposes the current size without extracting full state", async () => {
|
||||||
|
const session = trackSession(
|
||||||
|
await createTerminal({
|
||||||
|
cwd: "/tmp",
|
||||||
|
shell: "/bin/sh",
|
||||||
|
env: { PS1: "$ " },
|
||||||
|
rows: 24,
|
||||||
|
cols: 80,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(session.getSize()).toEqual({ rows: 24, cols: 80 });
|
||||||
|
|
||||||
|
session.send({ type: "resize", rows: 10, cols: 40 });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
|
||||||
|
expect(session.getSize()).toEqual({ rows: 10, cols: 40 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("subscribe", () => {
|
describe("subscribe", () => {
|
||||||
@@ -427,7 +446,7 @@ describe("Terminal", () => {
|
|||||||
unsubscribe();
|
unsubscribe();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("receives output and snapshot messages on updates", async () => {
|
it("receives output messages on updates without replay snapshots", async () => {
|
||||||
const session = trackSession(
|
const session = trackSession(
|
||||||
await createTerminal({
|
await createTerminal({
|
||||||
cwd: "/tmp",
|
cwd: "/tmp",
|
||||||
@@ -443,6 +462,7 @@ describe("Terminal", () => {
|
|||||||
messages.push(msg);
|
messages.push(msg);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
messages.length = 0;
|
messages.length = 0;
|
||||||
|
|
||||||
session.send({ type: "input", data: "echo test\r" });
|
session.send({ type: "input", data: "echo test\r" });
|
||||||
@@ -450,7 +470,35 @@ describe("Terminal", () => {
|
|||||||
await waitForLines(session, ["$ echo test", "test", "$"]);
|
await waitForLines(session, ["$ echo test", "test", "$"]);
|
||||||
|
|
||||||
expect(messages.some((message) => message.type === "output")).toBe(true);
|
expect(messages.some((message) => message.type === "output")).toBe(true);
|
||||||
expect(messages.some((message) => message.type === "snapshot")).toBe(true);
|
expect(messages.some((message) => message.type === "snapshot")).toBe(false);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not emit snapshot messages for resize-only updates", async () => {
|
||||||
|
const session = trackSession(
|
||||||
|
await createTerminal({
|
||||||
|
cwd: "/tmp",
|
||||||
|
shell: "/bin/sh",
|
||||||
|
env: { PS1: "$ " },
|
||||||
|
rows: 24,
|
||||||
|
cols: 80,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const messages: Array<{ type: string }> = [];
|
||||||
|
const unsubscribe = session.subscribe((msg) => {
|
||||||
|
messages.push(msg);
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
messages.length = 0;
|
||||||
|
|
||||||
|
session.send({ type: "resize", rows: 30, cols: 100 });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||||
|
|
||||||
|
expect(messages.some((message) => message.type === "snapshot")).toBe(false);
|
||||||
|
expect(session.getSize()).toEqual({ rows: 30, cols: 100 });
|
||||||
|
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import type { TerminalCell, TerminalState } from "../shared/messages.js";
|
|||||||
const { Terminal } = xterm;
|
const { Terminal } = xterm;
|
||||||
const require = createRequire(import.meta.url);
|
const require = createRequire(import.meta.url);
|
||||||
let nodePtySpawnHelperChecked = false;
|
let nodePtySpawnHelperChecked = false;
|
||||||
const STATE_BROADCAST_INTERVAL_MS = 33;
|
|
||||||
|
|
||||||
export type ClientMessage =
|
export type ClientMessage =
|
||||||
| { type: "input"; data: string }
|
| { type: "input"; data: string }
|
||||||
@@ -27,6 +26,7 @@ export interface TerminalSession {
|
|||||||
send(msg: ClientMessage): void;
|
send(msg: ClientMessage): void;
|
||||||
subscribe(listener: (msg: ServerMessage) => void): () => void;
|
subscribe(listener: (msg: ServerMessage) => void): () => void;
|
||||||
onExit(listener: () => void): () => void;
|
onExit(listener: () => void): () => void;
|
||||||
|
getSize(): { rows: number; cols: number };
|
||||||
getState(): TerminalState;
|
getState(): TerminalState;
|
||||||
kill(): void;
|
kill(): void;
|
||||||
}
|
}
|
||||||
@@ -246,8 +246,6 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
let killed = false;
|
let killed = false;
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let exitEmitted = false;
|
let exitEmitted = false;
|
||||||
let stateBroadcastScheduled = false;
|
|
||||||
let stateBroadcastTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
// Create xterm.js headless terminal
|
// Create xterm.js headless terminal
|
||||||
const terminal = new Terminal({
|
const terminal = new Terminal({
|
||||||
@@ -301,38 +299,11 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
disposed = true;
|
disposed = true;
|
||||||
if (stateBroadcastTimer) {
|
|
||||||
clearTimeout(stateBroadcastTimer);
|
|
||||||
stateBroadcastTimer = null;
|
|
||||||
}
|
|
||||||
stateBroadcastScheduled = false;
|
|
||||||
terminal.dispose();
|
terminal.dispose();
|
||||||
listeners.clear();
|
listeners.clear();
|
||||||
exitListeners.clear();
|
exitListeners.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitStateToListeners(): void {
|
|
||||||
stateBroadcastScheduled = false;
|
|
||||||
if (disposed || killed || listeners.size === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const state = getState();
|
|
||||||
for (const listener of listeners) {
|
|
||||||
listener({ type: "snapshot", state });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleStateBroadcast(): void {
|
|
||||||
if (disposed || killed || listeners.size === 0 || stateBroadcastScheduled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
stateBroadcastScheduled = true;
|
|
||||||
stateBroadcastTimer = setTimeout(() => {
|
|
||||||
stateBroadcastTimer = null;
|
|
||||||
emitStateToListeners();
|
|
||||||
}, STATE_BROADCAST_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pipe PTY output to terminal emulator
|
// Pipe PTY output to terminal emulator
|
||||||
ptyProcess.onData((data) => {
|
ptyProcess.onData((data) => {
|
||||||
if (killed) return;
|
if (killed) return;
|
||||||
@@ -343,7 +314,6 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
for (const listener of listeners) {
|
for (const listener of listeners) {
|
||||||
listener({ type: "output", data });
|
listener({ type: "output", data });
|
||||||
}
|
}
|
||||||
scheduleStateBroadcast();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -362,6 +332,14 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
cursor: extractCursorState(terminal),
|
cursor: extractCursorState(terminal),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getSize(): { rows: number; cols: number } {
|
||||||
|
return {
|
||||||
|
rows: terminal.rows,
|
||||||
|
cols: terminal.cols,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function send(msg: ClientMessage): void {
|
function send(msg: ClientMessage): void {
|
||||||
if (killed) return;
|
if (killed) return;
|
||||||
|
|
||||||
@@ -372,7 +350,6 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
case "resize":
|
case "resize":
|
||||||
terminal.resize(msg.cols, msg.rows);
|
terminal.resize(msg.cols, msg.rows);
|
||||||
ptyProcess.resize(msg.cols, msg.rows);
|
ptyProcess.resize(msg.cols, msg.rows);
|
||||||
scheduleStateBroadcast();
|
|
||||||
break;
|
break;
|
||||||
case "mouse":
|
case "mouse":
|
||||||
// Mouse events can be sent as escape sequences if terminal supports it
|
// Mouse events can be sent as escape sequences if terminal supports it
|
||||||
@@ -432,6 +409,7 @@ export async function createTerminal(options: CreateTerminalOptions): Promise<Te
|
|||||||
send,
|
send,
|
||||||
subscribe,
|
subscribe,
|
||||||
onExit,
|
onExit,
|
||||||
|
getSize,
|
||||||
getState,
|
getState,
|
||||||
kill,
|
kill,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user