[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
import test from "node:test";
import assert from "node:assert/strict";
import { terminalAltKeyOptions } from "./altKeyOptions";
// Issue #1078: with "Use Option as Meta key" enabled, macOS Option must send
// ESC-prefixed (Meta) sequences. xterm.js gates that on `macOptionIsMeta`. The
// flag was read from settings but only ever wired to the mouse alt-click
// behavior, so Option kept emitting layout characters (ƒ, ∫, …) instead of Meta.
test("Option-as-Meta enabled: Option emits Meta and alt-click cursor move is disabled", () => {
assert.deepEqual(terminalAltKeyOptions(true), {
macOptionIsMeta: true,
altClickMovesCursor: false,
});
});
test("Option-as-Meta disabled: xterm keeps default macOS Option behavior", () => {
assert.deepEqual(terminalAltKeyOptions(false), {
macOptionIsMeta: false,
altClickMovesCursor: true,
});
});

View File

@@ -0,0 +1,20 @@
export interface TerminalAltKeyOptions {
/** xterm.js: treat macOS Option as the Meta key (emit ESC-prefixed sequences). */
macOptionIsMeta: boolean;
/** xterm.js: Option+click moves the cursor. Must be off when Option is Meta. */
altClickMovesCursor: boolean;
}
/**
* Map the user's "Use Option as Meta key" setting to xterm.js options.
*
* Kept in one place so terminal init (createXTermRuntime) and the live settings
* sync (Terminal.tsx) can't drift — that drift is what left `macOptionIsMeta`
* unset everywhere and broke Option/Meta shortcuts on macOS (issue #1078).
*/
export function terminalAltKeyOptions(altAsMeta: boolean): TerminalAltKeyOptions {
return {
macOptionIsMeta: altAsMeta,
altClickMovesCursor: !altAsMeta,
};
}

View File

@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
test("clearBuffer uses the guarded viewport and ConPTY sync helper", () => {
const runtimeSource = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
const clearCaseIndex = runtimeSource.indexOf('case "clearBuffer"');
assert.notEqual(clearCaseIndex, -1);
const clearCase = runtimeSource.slice(clearCaseIndex, clearCaseIndex + 500);
assert.match(clearCase, /clearTerminalViewportAndSyncPty\(term,/);
assert.match(clearCase, /clearSessionPtyBuffer\?\.\(clearId\)/);
});
test("context-menu clear also uses the guarded viewport and ConPTY sync helper", () => {
const actionsSource = readFileSync(
new URL("../hooks/useTerminalContextActions.ts", import.meta.url),
"utf8",
);
const onClearIndex = actionsSource.indexOf("const onClear = useCallback");
assert.notEqual(onClearIndex, -1);
const onClear = actionsSource.slice(onClearIndex, onClearIndex + 650);
assert.match(onClear, /clearTerminalViewportAndSyncPty\(term,/);
assert.match(onClear, /clearSessionPtyBuffer\?\.\(id\)/);
});

View File

@@ -0,0 +1,474 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createTerminalSessionStarters } from "./createTerminalSessionStarters";
const noop = () => undefined;
const armSudoPrompt = (
autofill: { armForCommand: (command: string) => void } | null,
): string => {
autofill?.armForCommand("sudo whoami");
return "[sudo] password for alice: ";
};
const makeBackend = (
onStartEt: (options: Record<string, unknown>) => void = noop,
) => ({
backendAvailable: () => true,
telnetAvailable: () => true,
moshAvailable: () => true,
etAvailable: () => true,
localAvailable: () => true,
serialAvailable: () => true,
execAvailable: () => true,
startSSHSession: async () => "ssh-session",
startTelnetSession: async () => "telnet-session",
startMoshSession: async () => "mosh-session",
startEtSession: async (options: Record<string, unknown>) => {
onStartEt(options);
return "et-session";
},
startLocalSession: async () => "local-session",
startSerialSession: async () => "serial-session",
execCommand: async () => ({}),
onSessionData: () => noop,
onSessionExit: () => noop,
onChainProgress: () => noop,
writeToSession: noop,
resizeSession: noop,
});
const makeCtx = (
host: Record<string, unknown>,
resolvedChainHosts: Array<Record<string, unknown>>,
terminalBackend: ReturnType<typeof makeBackend>,
sinks: { setError?: (m: string) => void } = {},
) => ({
host: {
id: "host-1",
label: "Target",
hostname: "target.example.test",
username: "alice",
etEnabled: true,
...host,
},
keys: [],
identities: [],
resolvedChainHosts,
sessionId: "session-1",
terminalSettings: {},
terminalBackend,
sessionRef: { current: null },
hasConnectedRef: { current: false },
hasRunStartupCommandRef: { current: false },
disposeDataRef: { current: null },
disposeExitRef: { current: null },
fitAddonRef: { current: null },
serializeAddonRef: { current: null },
pendingAuthRef: { current: null },
updateStatus: noop,
setStatus: noop,
setError: sinks.setError ?? noop,
setNeedsAuth: noop,
setAuthRetryMessage: noop,
setAuthPassword: noop,
setProgressLogs: noop,
setProgressValue: noop,
setChainProgress: noop,
});
const term = {
cols: 120,
rows: 32,
write: noop,
writeln: noop,
scrollToBottom: noop,
};
test("startEt enables sudo autofill with the host saved password", async () => {
let onData: ((data: string) => void) | null = null;
const sent: string[] = [];
const backend = {
...makeBackend(),
onSessionData: (_id: string, cb: (data: string) => void) => {
onData = cb;
return noop;
},
writeToSession: (_id: string, data: string) => sent.push(data),
};
const sudoAutofillRef = { current: null };
const ctx = {
...makeCtx({
password: "saved-secret",
}, [], backend),
sudoAutofillRef,
sudoAutofillPassword: "saved-secret",
onSudoHint: () => true,
};
await createTerminalSessionStarters(ctx as never).startEt(term as never);
onData?.(armSudoPrompt(sudoAutofillRef.current));
sudoAutofillRef.current?.confirmFill();
assert.deepEqual(sent, ["saved-secret\n"]);
});
test("startEt fails loudly when a configured jump host cannot be resolved", async () => {
let started = false;
let error = "";
const backend = makeBackend(() => { started = true; });
// hostChain references jump-1, but resolvedChainHosts is empty (missing).
const ctx = makeCtx(
{ hostChain: { hostIds: ["jump-1"] } },
[],
backend,
{ setError: (m) => { error = m; } },
);
await createTerminalSessionStarters(ctx as never).startEt(term as never);
// Must NOT silently fall back to a direct connection.
assert.equal(started, false);
assert.match(error, /jump host is missing/i);
assert.match(error, /jump-1/);
});
test("startEt rejects a configured chain with more than one jump host even if under-resolved", async () => {
let started = false;
let error = "";
const backend = makeBackend(() => { started = true; });
// Two configured hops but only one resolved — a resolved-length check alone
// would wrongly let this through.
const ctx = makeCtx(
{ hostChain: { hostIds: ["jump-1", "jump-2"] } },
[{
id: "jump-1",
label: "Jump",
hostname: "jump.example.test",
username: "jumper",
}],
backend,
{ setError: (m) => { error = m; } },
);
await createTerminalSessionStarters(ctx as never).startEt(term as never);
assert.equal(started, false);
assert.match(error, /at most one jump host/i);
});
test("startEt rejects missing proxy identities on the target host before unsupported proxy", async () => {
let started = false;
let error = "";
const backend = makeBackend(() => { started = true; });
const ctx = makeCtx(
{
proxyConfig: {
type: "http",
host: "proxy.example.test",
port: 3128,
identityId: "missing-identity",
},
},
[],
backend,
{ setError: (m) => { error = m; } },
);
await createTerminalSessionStarters(ctx as never).startEt(term as never);
assert.equal(started, false);
assert.match(error, /Proxy identity for "Target" is missing/);
});
test("startEt rejects incomplete proxy identities on the target host before unsupported proxy", async () => {
let started = false;
let error = "";
const backend = makeBackend(() => { started = true; });
const ctx = {
...makeCtx(
{
proxyConfig: {
type: "http",
host: "proxy.example.test",
port: 3128,
identityId: "identity-1",
},
},
[],
backend,
{ setError: (m) => { error = m; } },
),
identities: [{
id: "identity-1",
label: "Proxy login",
username: "proxy-user",
authMethod: "password",
created: 1,
}],
};
await createTerminalSessionStarters(ctx as never).startEt(term as never);
assert.equal(started, false);
assert.match(error, /Proxy identity for "Target" is incomplete/);
});
test("startEt rejects missing saved proxy profiles on jump hosts", async () => {
let started = false;
let error = "";
const backend = makeBackend(() => { started = true; });
const ctx = makeCtx(
{ hostChain: { hostIds: ["jump-1"] } },
[{
id: "jump-1",
label: "Jump",
hostname: "jump.example.test",
username: "jumper",
proxyProfileId: "missing-proxy",
}],
backend,
{ setError: (m) => { error = m; } },
);
await createTerminalSessionStarters(ctx as never).startEt(term as never);
assert.equal(started, false);
assert.match(error, /Saved proxy for jump host "Jump" is missing/);
});
test("startEt rejects missing proxy identities on jump hosts", async () => {
let started = false;
let error = "";
const backend = makeBackend(() => { started = true; });
const ctx = makeCtx(
{ hostChain: { hostIds: ["jump-1"] } },
[{
id: "jump-1",
label: "Jump",
hostname: "jump.example.test",
username: "jumper",
proxyConfig: {
type: "http",
host: "proxy.example.test",
port: 3128,
identityId: "missing-identity",
},
}],
backend,
{ setError: (m) => { error = m; } },
);
await createTerminalSessionStarters(ctx as never).startEt(term as never);
assert.equal(started, false);
assert.match(error, /Proxy identity for "Jump" is missing/);
});
test("startEt rejects incomplete proxy identities on jump hosts", async () => {
let started = false;
let error = "";
const backend = makeBackend(() => { started = true; });
const ctx = {
...makeCtx(
{ hostChain: { hostIds: ["jump-1"] } },
[{
id: "jump-1",
label: "Jump",
hostname: "jump.example.test",
username: "jumper",
proxyConfig: {
type: "http",
host: "proxy.example.test",
port: 3128,
identityId: "identity-1",
},
}],
backend,
{ setError: (m) => { error = m; } },
),
identities: [{
id: "identity-1",
label: "Proxy login",
username: "proxy-user",
authMethod: "password",
created: 1,
}],
};
await createTerminalSessionStarters(ctx as never).startEt(term as never);
assert.equal(started, false);
assert.match(error, /Proxy identity for "Jump" is incomplete/);
});
test("startEt connects with a single resolved jump host", async () => {
let captured: Record<string, unknown> | null = null;
let error = "";
const backend = makeBackend((options) => { captured = options; });
const ctx = makeCtx(
{ hostChain: { hostIds: ["jump-1"] } },
[{
id: "jump-1",
label: "Jump",
hostname: "jump.example.test",
username: "jumper",
// key auth with no saved key reference → local identity file fallback
authMethod: "key",
identityFilePaths: ["/Users/alice/.ssh/jump_ed25519"],
}],
backend,
{ setError: (m) => { error = m; } },
);
await createTerminalSessionStarters(ctx as never).startEt(term as never);
assert.equal(error, "");
assert.ok(captured);
const jumpHosts = captured.jumpHosts as Array<Record<string, unknown>>;
assert.equal(jumpHosts.length, 1);
assert.equal(jumpHosts[0]?.hostname, "jump.example.test");
// Local identity file fallback is forwarded for the hop.
assert.deepEqual(jumpHosts[0]?.identityFilePaths, ["/Users/alice/.ssh/jump_ed25519"]);
});
test("startEt forwards a jump host's custom ET port", async () => {
let captured: Record<string, unknown> | null = null;
const backend = makeBackend((options) => { captured = options; });
const ctx = makeCtx(
{ hostChain: { hostIds: ["jump-1"] } },
[{
id: "jump-1",
label: "Jump",
hostname: "jump.example.test",
username: "jumper",
etPort: 9022,
}],
backend,
);
await createTerminalSessionStarters(ctx as never).startEt(term as never);
const jumpHosts = (captured as Record<string, unknown>).jumpHosts as Array<Record<string, unknown>>;
assert.equal(jumpHosts[0]?.etPort, 9022);
});
test("startEt forwards a jump host reference key path as an identity file", async () => {
let captured: Record<string, unknown> | null = null;
const backend = makeBackend((options) => { captured = options; });
const ctx = {
...makeCtx(
{ hostChain: { hostIds: ["jump-1"] } },
[{
id: "jump-1",
label: "Jump",
hostname: "jump.example.test",
username: "jumper",
authMethod: "key",
identityFileId: "ref-key",
}],
backend,
),
keys: [{
id: "ref-key",
label: "Reference key",
source: "reference",
filePath: "/Users/alice/.ssh/jump_reference_ed25519",
// reference keys carry no inline privateKey material
}],
};
await createTerminalSessionStarters(ctx as never).startEt(term as never);
const jumpHosts = (captured as Record<string, unknown>).jumpHosts as Array<Record<string, unknown>>;
// privateKey must be omitted for a reference key, and the on-disk path
// forwarded as an IdentityFile instead of being dropped.
assert.equal(jumpHosts[0]?.privateKey, undefined);
assert.deepEqual(jumpHosts[0]?.identityFilePaths, ["/Users/alice/.ssh/jump_reference_ed25519"]);
});
test("startEt keeps a jump private key when agent filtering is unavailable", async () => {
let captured: Record<string, unknown> | null = null;
const backend = makeBackend((options) => { captured = options; });
const ctx = {
...makeCtx(
{ hostChain: { hostIds: ["jump-1"] } },
[{
id: "jump-1",
label: "Jump",
hostname: "jump.example.test",
username: "jumper",
authMethod: "key",
identityFileId: "jump-key",
useSshAgent: true,
}],
backend,
),
keys: [{
id: "jump-key",
label: "Jump key",
type: "ED25519",
category: "key",
source: "imported",
created: 1,
privateKey: "JUMP PRIVATE KEY",
passphrase: "jump-passphrase",
}],
};
await createTerminalSessionStarters(ctx as never).startEt(term as never);
const jumpHosts = (captured as Record<string, unknown>).jumpHosts as Array<Record<string, unknown>>;
assert.equal(jumpHosts[0]?.useSshAgent, false);
assert.equal(jumpHosts[0]?.privateKey, "JUMP PRIVATE KEY");
assert.equal(jumpHosts[0]?.passphrase, "jump-passphrase");
});
test("startEt connects directly when no jump host is configured", async () => {
let captured: Record<string, unknown> | null = null;
let error = "";
const backend = makeBackend((options) => { captured = options; });
const ctx = makeCtx({}, [], backend, { setError: (m) => { error = m; } });
await createTerminalSessionStarters(ctx as never).startEt(term as never);
assert.equal(error, "");
assert.ok(captured);
assert.equal(captured.jumpHosts, undefined);
});
test("startEt forwards known hosts and algorithm options for stats companion parity", async () => {
let captured: Record<string, unknown> | null = null;
const knownHosts = [{
id: "kh-1",
hostname: "target.example.test",
port: 22,
keyType: "ssh-ed25519",
fingerprint: "SHA256:trusted",
publicKey: "",
discoveredAt: 1,
}];
const algorithms = { cipher: ["aes128-cbc"] };
const backend = makeBackend((options) => { captured = options; });
const ctx = {
...makeCtx(
{
legacyAlgorithms: true,
skipEcdsaHostKey: true,
algorithms,
},
[],
backend,
),
knownHosts,
};
await createTerminalSessionStarters(ctx as never).startEt(term as never);
assert.ok(captured);
assert.equal(captured.knownHosts, knownHosts);
assert.equal(captured.legacyAlgorithms, true);
assert.equal(captured.skipEcdsaHostKey, true);
assert.equal(captured.algorithmOverrides, algorithms);
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,281 @@
import type { FitAddon } from "@xterm/addon-fit";
import type { SerializeAddon } from "@xterm/addon-serialize";
import type { Dispatch, MutableRefObject, RefObject, SetStateAction } from "react";
import type { Host, Identity, KnownHost, SerialConfig, SSHKey, TerminalSession, TerminalSettings } from "../../../types";
import type { PromptLineBreakState } from "./promptLineBreak";
import type {
PasswordPromptPickerState,
SudoPasswordAutofill,
SudoPasswordAutofillCandidate,
} from "./terminalSudoAutofill";
import type { ProgrammaticCommandLogRewrite } from "../programmaticCommandLog";
import type { TerminalSessionExitEvent } from "../../../application/state/resolveTerminalSessionExitIntent";
export type TerminalBackendApi = {
backendAvailable: () => boolean;
telnetAvailable: () => boolean;
moshAvailable: () => boolean;
etAvailable: () => boolean;
localAvailable: () => boolean;
serialAvailable: () => boolean;
pluginConnectionAvailable: () => boolean;
execAvailable: () => boolean;
startSSHSession: (options: NetcattySSHOptions) => Promise<string>;
startTelnetSession: (
options: Parameters<NonNullable<NetcattyBridge["startTelnetSession"]>>[0],
) => Promise<string>;
startMoshSession: (
options: Parameters<NonNullable<NetcattyBridge["startMoshSession"]>>[0],
) => Promise<string>;
startEtSession: (
options: Parameters<NonNullable<NetcattyBridge["startEtSession"]>>[0],
) => Promise<string>;
startLocalSession: (
options: Parameters<NonNullable<NetcattyBridge["startLocalSession"]>>[0],
) => Promise<string>;
startSerialSession: (
options: Parameters<NonNullable<NetcattyBridge["startSerialSession"]>>[0],
) => Promise<string>;
startPluginConnection: (options: NetcattyPluginConnectionStartRequest & { signal?: AbortSignal }) => Promise<{
sessionId: string;
providerId: string;
status: "connecting" | "connected";
diagnostics: ReadonlyArray<import("@netcatty/plugin-contract").ProviderValidationIssue>;
}>;
cancelPluginExtensionRequest?: (requestId: string) => Promise<boolean> | boolean;
signalPluginConnection?: (
sessionId: string,
signal?: "interrupt" | "terminate" | "kill" | "eof" | "break",
) => Promise<unknown>;
execCommand: (options: Parameters<NetcattyBridge["execCommand"]>[0]) => Promise<{
stdout?: string;
stderr?: string;
}>;
getSessionRemoteInfo?: (sessionId: string) => Promise<{
success: boolean;
remoteSshVersion?: string;
error?: string;
}>;
getSessionDistroInfo?: (sessionId: string) => Promise<{
success: boolean;
stdout?: string;
stderr?: string;
error?: string;
}>;
onSessionData: (
sessionId: string,
cb: (data: string, meta?: TerminalSessionDataMeta) => void,
options?: { replayBacklog?: boolean },
) => () => void;
onSessionExit: (
sessionId: string,
cb: (evt: TerminalSessionExitEvent) => void,
) => () => void;
onTelnetAutoLoginComplete?: (
sessionId: string,
cb: (evt: { sessionId: string; bootEpoch?: number }) => void,
) => (() => void) | undefined;
onTelnetAutoLoginCancelled?: (
sessionId: string,
cb: (evt: { sessionId: string; bootEpoch?: number }) => void,
) => (() => void) | undefined;
onMoshSessionReady?: (
sessionId: string,
cb: (evt: { sessionId: string; bootEpoch?: number }) => void,
) => (() => void) | undefined;
onTelnetEchoMode?: (
sessionId: string,
cb: (evt: { sessionId: string; remoteEcho: boolean; localEcho: boolean }) => void,
) => (() => void) | undefined;
getTelnetEchoMode?: (sessionId: string) => Promise<{
success: boolean;
sessionId?: string;
remoteEcho?: boolean;
localEcho?: boolean;
error?: string;
}>;
onChainProgress: (
cb: (sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void,
) => (() => void) | undefined;
onConnectionReuseFallback?: (
cb: (sessionId: string, sourceSessionId?: string) => void,
) => (() => void) | undefined;
writeToSession: (sessionId: string, data: string, options?: { automated?: boolean; sensitive?: boolean; lineDelayMs?: number; logRewrite?: ProgrammaticCommandLogRewrite }) => void;
interruptSession?: (sessionId: string, trace?: NetcattyTerminalInterruptTrace) => void;
resizeSession: (sessionId: string, cols: number, rows: number) => void;
closeSession: (sessionId: string, options?: { bootEpoch?: number }) => void | Promise<void>;
/** Pause/resume the source stream for output back-pressure (optional). */
setSessionFlowPaused?: (sessionId: string, paused: boolean) => void;
/** Acknowledge rendered terminal output bytes for main-process IPC back-pressure. */
ackSessionFlow?: (sessionId: string, bytes: number) => void;
notifyTerminalSessionDisplayReady?: (sessionId: string) => void;
};
export type PendingAuth = {
authMethod: "password" | "key" | "certificate";
username: string;
password?: string;
keyId?: string;
passphrase?: string;
savedToHost?: boolean;
} | null;
type ChainProgressState = {
currentHop: number;
totalHops: number;
currentHostLabel: string;
connectionPhase: string;
} | null;
export type SessionLogConfig = {
enabled: boolean;
directory: string;
format: string;
timestampsEnabled?: boolean;
};
export type TerminalSessionStartersContext = {
host: Host & Pick<Partial<TerminalSession>, "localStartDir">;
/**
* Live host snapshot updated every render. Session data handlers close over
* boot-time ctx, so mid-session host toggles (e.g. line timestamps) must be
* read from this ref rather than the frozen `host` field.
*/
hostRef?: RefObject<Host & Pick<Partial<TerminalSession>, "localStartDir">>;
keys: SSHKey[];
identities?: Identity[];
knownHosts?: KnownHost[];
resolvedChainHosts: Host[];
sessionId: string;
// One-shot source session intent for Copy/Split. Consumed by the first SSH
// attempt so later reconnects do not skip the initial login sequence.
reuseConnectionFromSessionIdRef?: MutableRefObject<string | undefined>;
// Duplicate Session clones carry this marker for their whole lifetime: every
// SSH attempt must send `reuseTransport: false` so the bridge never borrows
// the source's live or any other pooled transport.
requireFreshConnection?: boolean;
// Set by the reconnect path (manual retry / auto-reconnect) for the rest of
// the pane's lifetime: every SSH attempt must dial a brand-new connection
// instead of borrowing a live or idle pooled transport. Reusing an
// already-authenticated connection skips the server-side login, so remote
// supplementary-group changes (e.g. `usermod -aG`) stay invisible until the
// whole app quits (#3293).
requireFreshConnectionOnReconnectRef?: MutableRefObject<boolean>;
// Persists across renderer auth retries after the one-shot source intent is
// consumed. Cleared only after a backend session starts successfully.
reuseConnectionSourceAttemptedRef?: MutableRefObject<boolean>;
// Mirrors the source actually consumed by the current SSH attempt so the UI
// only hides its connecting dialog while Copy/Split reuse is being tried.
setConnectionReuseAttemptSourceId?: (sourceSessionId: string | undefined) => void;
// Connect automation and still-unhandled pending scripts need the initial
// login output. Evaluate per attempt because pending work is one-shot.
shouldUseFreshSshConnection?: () => boolean;
// Commit the no-automation snapshot only after the corresponding backend
// session actually starts, so failed auth attempts do not consume scripts.
onConnectAutomationSnapshotCommitted?: () => void;
isNetworkDevice?: boolean;
startupCommand?: string;
noAutoRun?: boolean;
multiLineRunMode?: TerminalSession["multiLineRunMode"];
shellType?: TerminalSession["shellType"];
suppressHostStartupCommandRef?: RefObject<boolean>;
terminalSettings?: TerminalSettings;
terminalSettingsRef?: RefObject<TerminalSettings | undefined>;
terminalBackend: TerminalBackendApi;
serialConfig?: SerialConfig;
telnetLocalEchoRef?: RefObject<boolean>;
sessionLog?: SessionLogConfig;
sshDebugLogEnabled?: boolean;
sudoAutofillPassword?: string;
sudoAutofillPasswordRef?: RefObject<string | undefined>;
sudoAutofillCandidates?: SudoPasswordAutofillCandidate[];
sudoAutofillCandidatesRef?: RefObject<SudoPasswordAutofillCandidate[] | undefined>;
onSudoHint?: (active: boolean) => boolean;
onPasswordPromptPicker?: (
active: boolean,
state: PasswordPromptPickerState | null,
) => boolean;
/** Actual tab/pane visibility; the renderer may remain active while hidden. */
isPaneVisibleRef?: RefObject<boolean>;
isVisibleRef?: RefObject<boolean>;
/** False after unmount/teardown so in-flight session starts skip attach. */
isBootActiveRef?: RefObject<boolean>;
/**
* Monotonic boot epoch. Disconnect / a newer reconnect bumps this so an
* older in-flight start cannot become current again when boot is re-armed.
*/
bootEpochRef?: RefObject<number>;
pendingOutputScrollRef?: RefObject<boolean>;
sessionRef: RefObject<string | null>;
hasConnectedRef: RefObject<boolean>;
hasRunStartupCommandRef: RefObject<boolean>;
disposeDataRef: RefObject<(() => void) | null>;
disposeExitRef: RefObject<(() => void) | null>;
/**
* Track an async cleanup (e.g. cancelled plugin start → finishExternalSession)
* so Disconnect/Reconnect can await it before starting a replacement boot.
*/
trackSessionCleanup?: (promise: Promise<unknown>) => void;
disposeTelnetEchoModeRef?: RefObject<(() => void) | null>;
fitAddonRef: RefObject<FitAddon | null>;
serializeAddonRef: RefObject<SerializeAddon | null>;
prepareKeywordHighlightSerialization?: () => Promise<void>;
pendingAuthRef: RefObject<PendingAuth>;
promptLineBreakStateRef?: RefObject<PromptLineBreakState>;
sudoAutofillRef?: RefObject<SudoPasswordAutofill | null>;
restoreCwdIntentRef?: RefObject<{ cwd: string; command: string } | null>;
updateStatus: (next: TerminalSession["status"]) => void;
setStatus: Dispatch<SetStateAction<TerminalSession["status"]>>;
setError: Dispatch<SetStateAction<string | null>>;
setNeedsAuth: Dispatch<SetStateAction<boolean>>;
setAuthRetryMessage: Dispatch<SetStateAction<string | null>>;
setAuthPassword: Dispatch<SetStateAction<string>>;
setProgressLogs: Dispatch<SetStateAction<string[]>>;
setProgressValue: Dispatch<SetStateAction<number>>;
setChainProgress: Dispatch<SetStateAction<ChainProgressState>>;
setIsConnectionAwaitingUserInput?: Dispatch<SetStateAction<boolean>>;
setIsConnectionPastTcpDial?: Dispatch<SetStateAction<boolean>>;
t?: (key: string) => string;
onSessionAttached?: (sessionId: string) => void;
onRestoreCwdIntentConsumed?: (cwd: string) => void;
onSessionExit?: (sessionId: string, evt: TerminalSessionExitEvent) => void;
onTerminalDataCapture?: (sessionId: string, data: string) => void;
onTerminalLogData?: (data: string) => void;
onProgrammaticCommandLogRewrite?: (rewrite: ProgrammaticCommandLogRewrite) => void;
onTerminalOutput?: (chunk: string, meta?: TerminalSessionDataMeta) => void;
onOsDetected?: (hostId: string, distro: string) => void;
onCommandExecuted?: (
command: string,
hostId: string,
hostLabel: string,
sessionId: string,
) => void;
onCommandSubmitted?: (
command: string,
hostId: string,
hostLabel: string,
sessionId: string,
) => void;
onCommandCompleted?: () => void;
};
export type TerminalSessionDataMeta = {
droppedOutputMayAffectTerminalState?: boolean;
droppedOutputAlternateScreenAction?: 'enter' | 'leave';
/** True while Mosh is still on the ephemeral SSH handshake PTY. */
moshHandshake?: boolean;
/** The Mosh SSH bootstrap is blocked on input that Netcatty cannot answer automatically. */
moshHandshakeRequiresUserInput?: boolean;
terminalPerf?: NetcattyTerminalOutputPerfMeta;
/** Original host output units acknowledged even when an interceptor changes display length. */
pluginPipelineIngressBytes?: number;
/** Host-owned provenance marker for output already processed by an interceptor. */
pluginPipelineProcessed?: boolean;
/** Host-owned classification from original output; plugins cannot mask it. */
pluginPipelineSensitiveInput?: boolean;
/** Host-owned marker that a Plugin connection Provider has explicitly reached connected status. */
pluginConnectionReady?: boolean;
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,498 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { CursorLineHighlighter } from './cursorLineHighlight.ts';
type Handler = () => void;
type FakeElement = {
style: Record<string, string>;
attributes: Record<string, string>;
setAttribute: (name: string, value: string) => void;
};
const createFakeTerm = (cols = 80) => {
let cursorY = 0;
let cursorX = 0;
let hasSelection = false;
let baseY = 0;
let bufferType: 'normal' | 'alternate' = 'normal';
let lineLength = cols;
const coloredBackgrounds = new Set<number>();
const coloredForegrounds = new Set<number>();
const inverseCells = new Set<number>();
const cursorMoveHandlers: Handler[] = [];
const writeParsedHandlers: Handler[] = [];
const resizeHandlers: Handler[] = [];
const bufferChangeHandlers: Handler[] = [];
const renderHandlers: Handler[] = [];
const selectionChangeHandlers: Handler[] = [];
const decorations: Array<{
options: Record<string, unknown>;
disposed: boolean;
element: FakeElement;
dispose: () => void;
onRender: (handler: (element: FakeElement) => void) => { dispose: () => void };
onDispose: (handler: Handler) => { dispose: () => void };
}> = [];
const markers: Array<{ line: number; disposed: boolean }> = [];
const term = {
cols,
buffer: {
active: {
get type() {
return bufferType;
},
get baseY() {
return baseY;
},
get cursorY() {
return cursorY;
},
get cursorX() {
return cursorX;
},
getLine() {
return {
length: cols,
getCell(x: number) {
return {
getChars: () => (x < lineLength ? 'x' : ''),
getWidth: () => 1,
isAttributeDefault: () => x >= lineLength,
isBgDefault: () => !coloredBackgrounds.has(x),
isFgDefault: () => !coloredForegrounds.has(x),
isInverse: () => (inverseCells.has(x) ? 1 : 0),
};
},
};
},
getNullCell() {
return { isBgDefault: () => true };
},
},
onBufferChange(handler: Handler) {
bufferChangeHandlers.push(handler);
return { dispose() {} };
},
},
onCursorMove(handler: Handler) {
cursorMoveHandlers.push(handler);
return { dispose() {} };
},
onResize(handler: Handler) {
resizeHandlers.push(handler);
return { dispose() {} };
},
onWriteParsed(handler: Handler) {
writeParsedHandlers.push(handler);
return { dispose() {} };
},
onRender(handler: Handler) {
renderHandlers.push(handler);
return { dispose() {} };
},
onSelectionChange(handler: Handler) {
selectionChangeHandlers.push(handler);
return { dispose() {} };
},
hasSelection() {
return hasSelection;
},
registerMarker(offset: number) {
const marker = {
line: baseY + cursorY + offset,
disposed: false,
get isDisposed() {
return this.disposed;
},
dispose() {
this.disposed = true;
},
};
markers.push(marker);
return marker;
},
registerDecoration(options: Record<string, unknown>) {
const disposeHandlers = new Set<Handler>();
const element: FakeElement = {
style: {},
attributes: {},
setAttribute(name, value) {
this.attributes[name] = value;
},
};
const decoration = {
options,
disposed: false,
element,
dispose() {
if (this.disposed) return;
this.disposed = true;
for (const handler of disposeHandlers) handler();
disposeHandlers.clear();
},
onDispose(handler: Handler) {
disposeHandlers.add(handler);
return { dispose: () => disposeHandlers.delete(handler) };
},
onRender(handler: (element: FakeElement) => void) {
handler(element);
return { dispose() {} };
},
};
decorations.push(decoration);
return decoration;
},
moveCursor(nextY: number) {
cursorY = nextY;
for (const handler of cursorMoveHandlers) handler();
for (const handler of renderHandlers) handler();
},
scrollOutput(lines: number) {
baseY += lines;
for (const handler of writeParsedHandlers) handler();
for (const handler of renderHandlers) handler();
},
writeHiddenOutput(lines: number) {
baseY += lines;
for (const handler of writeParsedHandlers) handler();
},
trimScrollback(lines: number) {
for (const marker of markers) {
if (!marker.disposed) marker.line -= lines;
}
for (const handler of writeParsedHandlers) handler();
for (const handler of renderHandlers) handler();
},
setBufferType(nextType: 'normal' | 'alternate') {
bufferType = nextType;
for (const handler of bufferChangeHandlers) handler();
},
setColoredBackgrounds(columns: number[]) {
coloredBackgrounds.clear();
for (const column of columns) coloredBackgrounds.add(column);
for (const handler of writeParsedHandlers) handler();
for (const handler of renderHandlers) handler();
},
setLineLength(nextLength: number) {
lineLength = nextLength;
for (const handler of writeParsedHandlers) handler();
for (const handler of renderHandlers) handler();
},
setCursorX(nextX: number) {
cursorX = nextX;
for (const handler of cursorMoveHandlers) handler();
for (const handler of renderHandlers) handler();
},
setSelection(next: boolean) {
hasSelection = next;
for (const handler of selectionChangeHandlers) handler();
},
render() {
for (const handler of renderHandlers) handler();
},
setColoredForegrounds(columns: number[]) {
coloredForegrounds.clear();
for (const column of columns) coloredForegrounds.add(column);
for (const handler of writeParsedHandlers) handler();
for (const handler of renderHandlers) handler();
},
setInverseCells(columns: number[]) {
inverseCells.clear();
for (const column of columns) inverseCells.add(column);
for (const handler of writeParsedHandlers) handler();
for (const handler of renderHandlers) handler();
},
resetDecorations() {
for (const decoration of decorations) decoration.dispose();
},
setCols(nextCols: number) {
term.cols = nextCols;
for (const handler of resizeHandlers) handler();
},
decorations,
markers,
};
return term;
};
test('CursorLineHighlighter paints an opaque background without changing text', () => {
const term = createFakeTerm(100);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setBackgroundColor('#263449');
highlighter.setEnabled(true);
assert.equal(term.decorations.length, 1);
assert.equal(term.decorations[0]?.options.width, 100);
assert.equal(term.decorations[0]?.options.backgroundColor, '#263449');
assert.equal(term.decorations[0]?.options.layer, 'bottom');
highlighter.dispose();
});
test('CursorLineHighlighter gives selection and search matches priority', () => {
const term = createFakeTerm(10);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setEnabled(true);
assert.equal(term.decorations.at(-1)?.disposed, false);
term.setSelection(true);
assert.equal(term.decorations.at(-1)?.disposed, true);
term.setSelection(false);
assert.equal(term.decorations.at(-1)?.disposed, false);
highlighter.dispose();
});
test('CursorLineHighlighter refreshes after hidden writes become visible', () => {
const term = createFakeTerm(10);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setEnabled(true);
assert.equal(term.markers.at(-1)?.line, 0);
term.writeHiddenOutput(1);
assert.equal(term.decorations.length, 1);
term.render();
assert.equal(term.decorations.length, 2);
assert.equal(term.decorations[0]?.disposed, true);
assert.equal(term.markers.at(-1)?.line, 1);
highlighter.dispose();
});
test('CursorLineHighlighter keeps a continuous background under colored keyword text', () => {
const term = createFakeTerm(10);
const keywordMarker = term.registerMarker(0);
term.registerDecoration({
marker: keywordMarker,
x: 2,
width: 2,
foregroundColor: '#F87171',
});
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setBackgroundColor('#263449');
highlighter.setEnabled(true);
assert.equal(term.decorations[0]?.options.foregroundColor, '#F87171');
assert.equal(term.decorations[0]?.options.backgroundColor, undefined);
assert.equal(term.decorations[1]?.options.x, 0);
assert.equal(term.decorations[1]?.options.width, 10);
assert.equal(term.decorations[1]?.options.backgroundColor, '#263449');
assert.equal(term.decorations[1]?.options.layer, 'bottom');
highlighter.dispose();
});
test('CursorLineHighlighter leaves ANSI background cells untouched', () => {
const term = createFakeTerm(10);
term.setColoredBackgrounds([2, 3, 7]);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setBackgroundColor('#263449');
highlighter.setEnabled(true);
assert.deepEqual(
term.decorations.map(({ options }) => ({ x: options.x, width: options.width })),
[
{ x: 0, width: 2 },
{ x: 4, width: 3 },
{ x: 8, width: 2 },
],
);
highlighter.dispose();
});
test('CursorLineHighlighter fills the blank tail after short output', () => {
const term = createFakeTerm(10);
term.setLineLength(4);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setBackgroundColor('#263449');
highlighter.setEnabled(true);
assert.equal(term.decorations.length, 2);
assert.equal(term.decorations[0]?.options.width, 4);
assert.equal(term.decorations[1]?.options.x, 4);
assert.equal(term.decorations[1]?.options.width, 6);
assert.equal(term.decorations[1]?.options.backgroundColor, '#263449');
assert.equal(term.decorations[1]?.options.layer, 'bottom');
highlighter.dispose();
});
test('CursorLineHighlighter fills the blank tail through the cursor cell', () => {
const term = createFakeTerm(10);
term.setLineLength(4);
term.setCursorX(5);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setBackgroundColor('#263449');
highlighter.setEnabled(true);
assert.deepEqual(
term.decorations.map(({ options }) => ({ x: options.x, width: options.width })),
[
{ x: 0, width: 4 },
{ x: 4, width: 6 },
],
);
highlighter.dispose();
});
test('CursorLineHighlighter fills a wrap-pending final cell', () => {
const term = createFakeTerm(10);
term.setLineLength(4);
term.setCursorX(10);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setBackgroundColor('#263449');
highlighter.setEnabled(true);
assert.deepEqual(
term.decorations.map(({ options }) => ({ x: options.x, width: options.width })),
[
{ x: 0, width: 4 },
{ x: 4, width: 6 },
],
);
highlighter.dispose();
});
test('CursorLineHighlighter keeps foreground colors and leaves inverse cells untouched', () => {
const term = createFakeTerm(10);
term.setColoredForegrounds([2, 3]);
term.setInverseCells([7]);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setBackgroundColor('#263449');
highlighter.setEnabled(true);
assert.deepEqual(
term.decorations.map(({ options }) => ({ x: options.x, width: options.width })),
[
{ x: 0, width: 7 },
{ x: 8, width: 2 },
],
);
highlighter.dispose();
});
test('CursorLineHighlighter follows cursor moves and clears when disabled', () => {
const term = createFakeTerm(80);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setEnabled(true);
assert.equal(term.decorations.length, 1);
term.moveCursor(3);
assert.equal(term.decorations.length, 2);
assert.equal(term.decorations[0]?.disposed, true);
assert.equal(term.decorations[1]?.disposed, false);
highlighter.setEnabled(false);
assert.equal(term.decorations[1]?.disposed, true);
highlighter.dispose();
});
test('CursorLineHighlighter swaps decorations atomically on refresh', () => {
const term = createFakeTerm(80);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setEnabled(true);
const firstDecoration = term.decorations[0];
assert.ok(firstDecoration);
assert.equal(firstDecoration.disposed, false);
let sawOverlap = false;
const originalRegisterMarker = term.registerMarker.bind(term);
term.registerMarker = (offset: number) => {
// New marker must be created while the previous decoration is still live.
if (!firstDecoration.disposed) sawOverlap = true;
return originalRegisterMarker(offset);
};
term.moveCursor(2);
assert.equal(sawOverlap, true, 'new marker should register before old decoration disposal');
assert.equal(firstDecoration.disposed, true);
assert.equal(term.decorations.at(-1)?.disposed, false);
highlighter.dispose();
});
test('CursorLineHighlighter recreates on resize and overlay color changes', () => {
const term = createFakeTerm(40);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setEnabled(true);
highlighter.setBackgroundColor('#112233');
assert.equal(
term.decorations.at(-1)?.options.backgroundColor,
'#112233',
);
assert.equal(term.decorations.at(-1)?.options.width, 40);
term.setCols(120);
assert.equal(term.decorations.at(-2)?.options.width, 40);
assert.equal(term.decorations.at(-1)?.options.width, 80);
highlighter.setBackgroundColor('#445566');
assert.equal(
term.decorations.at(-2)?.options.backgroundColor,
'#445566',
);
highlighter.dispose();
});
test('CursorLineHighlighter follows bottom-row output when only baseY changes', () => {
const term = createFakeTerm(80);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setEnabled(true);
assert.equal(term.markers.at(-1)?.line, 0);
term.scrollOutput(3);
assert.equal(term.decorations.length, 2);
assert.equal(term.decorations[0]?.disposed, true);
assert.equal(term.markers.at(-1)?.line, 3);
assert.equal(term.decorations.at(-1)?.disposed, false);
highlighter.dispose();
});
test('CursorLineHighlighter refreshes when saturated scrollback moves its marker', () => {
const term = createFakeTerm(80);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setEnabled(true);
const originalMarker = term.markers.at(-1);
assert.equal(originalMarker?.line, 0);
term.trimScrollback(1);
assert.equal(originalMarker?.disposed, true);
assert.equal(term.decorations.length, 2);
assert.equal(term.markers.at(-1)?.line, 0);
assert.equal(term.decorations.at(-1)?.disposed, false);
highlighter.dispose();
});
test('CursorLineHighlighter restores after the terminal resets its decorations', () => {
const term = createFakeTerm(80);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setEnabled(true);
assert.equal(term.decorations.length, 1);
term.resetDecorations();
assert.equal(term.decorations[0]?.disposed, true);
term.scrollOutput(0);
assert.equal(term.decorations.length, 2);
assert.equal(term.decorations.at(-1)?.disposed, false);
highlighter.dispose();
});
test('CursorLineHighlighter clears in the alternate buffer and restores in normal buffer', () => {
const term = createFakeTerm(80);
const highlighter = new CursorLineHighlighter(term as never);
highlighter.setEnabled(true);
assert.equal(term.decorations.at(-1)?.disposed, false);
term.setBufferType('alternate');
assert.equal(term.decorations.at(-1)?.disposed, true);
const decorationCount = term.decorations.length;
term.moveCursor(4);
term.scrollOutput(1);
assert.equal(term.decorations.length, decorationCount);
term.setBufferType('normal');
assert.equal(term.decorations.length, decorationCount + 1);
assert.equal(term.decorations.at(-1)?.disposed, false);
highlighter.dispose();
});

View File

@@ -0,0 +1,273 @@
import type {
IDecoration,
IDisposable,
IMarker,
Terminal as XTerm,
} from '@xterm/xterm';
type CursorLineTerminal = Pick<
XTerm,
| 'cols'
| 'buffer'
| 'registerMarker'
| 'registerDecoration'
| 'onCursorMove'
| 'onResize'
| 'onWriteParsed'
| 'onRender'
| 'onSelectionChange'
| 'hasSelection'
>;
type HighlightRange = { x: number; width: number };
/**
* Highlights the buffer row under the cursor without tinting its glyphs.
* Explicit ANSI backgrounds remain untouched. Foreground-only colors still
* receive the opaque theme background so colored text does not leave holes.
*/
export class CursorLineHighlighter implements IDisposable {
private enabled = false;
private backgroundColor = '#263449';
private marker: IMarker | null = null;
private decorations: IDecoration[] = [];
private decorationDisposeListeners: IDisposable[] = [];
private activeLine: number | null = null;
private activeCols: number | null = null;
private activeColor: string | null = null;
private activeRanges: HighlightRange[] = [];
private activeTailRanges: HighlightRange[] = [];
private readonly disposables: IDisposable[] = [];
private pendingRefresh = false;
private disposed = false;
constructor(private readonly term: CursorLineTerminal) {
this.disposables.push(
this.term.onCursorMove(() => this.markPendingRefresh()),
this.term.onWriteParsed(() => this.markPendingRefresh()),
this.term.onRender(() => {
if (this.pendingRefresh) {
this.pendingRefresh = false;
this.refresh();
}
}),
this.term.onSelectionChange(() => this.refresh({ force: true })),
this.term.onResize(() => this.refresh({ force: true })),
this.term.buffer.onBufferChange(() => this.refresh({ force: true })),
);
}
setEnabled(enabled: boolean): void {
if (this.disposed) return;
if (this.enabled === enabled) {
if (enabled) this.refresh();
return;
}
this.enabled = enabled;
if (!enabled) {
this.clear();
return;
}
this.refresh({ force: true });
}
setBackgroundColor(color: string): void {
if (this.disposed) return;
const next = color.trim();
if (!next || next === this.backgroundColor) return;
this.backgroundColor = next;
if (this.enabled) this.refresh({ force: true });
}
refresh(options: { force?: boolean } = {}): void {
if (this.disposed || !this.enabled) return;
this.pendingRefresh = false;
if (this.term.hasSelection()) {
this.clear();
return;
}
const buffer = this.term.buffer.active;
if (buffer.type === 'alternate') {
this.clear();
return;
}
const absoluteLine = buffer.baseY + buffer.cursorY;
const cols = Math.max(1, this.term.cols || 1);
const color = this.backgroundColor;
const line = buffer.getLine(absoluteLine);
const { ranges, contentEnd } = this.getDefaultBackgroundRanges(
line,
cols,
buffer.getNullCell(),
);
const tailRanges = this.getTailRanges(contentEnd, cols);
if (
!options.force &&
absoluteLine === this.activeLine &&
cols === this.activeCols &&
color === this.activeColor &&
rangesEqual(ranges, this.activeRanges) &&
rangesEqual(tailRanges, this.activeTailRanges) &&
this.marker &&
!this.marker.isDisposed &&
this.marker.line === absoluteLine
) {
return;
}
// Register the next marker/decorations before disposing the previous set so
// Enter / cursor moves never leave an empty frame (clear-then-create flash).
const previousMarker = this.marker;
const previousDecorations = this.decorations;
const previousDisposeListeners = this.decorationDisposeListeners;
const marker = this.term.registerMarker(0);
if (!marker) {
this.clearOwned(
previousMarker,
previousDecorations,
previousDisposeListeners,
);
return;
}
const decorations: IDecoration[] = [];
for (const range of ranges) {
const decoration = this.term.registerDecoration({
marker,
x: range.x,
width: range.width,
backgroundColor: color,
layer: 'bottom',
});
if (decoration) decorations.push(decoration);
}
for (const tailRange of tailRanges) {
const tailDecoration = this.term.registerDecoration({
marker,
x: tailRange.x,
width: tailRange.width,
backgroundColor: color,
layer: 'bottom',
});
if (tailDecoration) {
decorations.push(tailDecoration);
}
}
this.marker = marker;
this.decorations = decorations;
this.decorationDisposeListeners = decorations.map((decoration) =>
decoration.onDispose(() => {
if (this.decorations.includes(decoration)) {
this.activeLine = null;
this.activeCols = null;
this.activeColor = null;
this.activeRanges = [];
}
}),
);
this.activeLine = absoluteLine;
this.activeCols = cols;
this.activeColor = color;
this.activeRanges = ranges;
this.activeTailRanges = tailRanges;
this.clearOwned(
previousMarker,
previousDecorations,
previousDisposeListeners,
);
}
dispose(): void {
if (this.disposed) return;
this.disposed = true;
this.clear();
for (const disposable of this.disposables) {
disposable.dispose();
}
this.disposables.length = 0;
}
private getDefaultBackgroundRanges(
line: ReturnType<CursorLineTerminal['buffer']['active']['getLine']>,
cols: number,
cell: ReturnType<CursorLineTerminal['buffer']['active']['getNullCell']>,
): { ranges: HighlightRange[]; contentEnd: number } {
const ranges: HighlightRange[] = [];
let rangeStart: number | null = null;
let contentEnd = 0;
for (let x = 0; x < cols; x += 1) {
const currentCell = line?.getCell(x, cell);
if (
currentCell &&
(currentCell.getChars() !== '' || !currentCell.isAttributeDefault())
) {
contentEnd = Math.min(cols, x + Math.max(1, currentCell.getWidth()));
}
const isHighlightable =
currentCell === undefined ||
(currentCell.isBgDefault() && !currentCell.isInverse());
if (isHighlightable && rangeStart === null) rangeStart = x;
if ((!isHighlightable || x === cols - 1) && rangeStart !== null) {
const end = isHighlightable && x === cols - 1 ? x + 1 : x;
ranges.push({ x: rangeStart, width: end - rangeStart });
rangeStart = null;
}
}
return {
ranges: ranges
.map((range) => {
const end = Math.min(range.x + range.width, contentEnd);
return { x: range.x, width: end - range.x };
})
.filter((range) => range.width > 0),
contentEnd,
};
}
private getTailRanges(contentEnd: number, cols: number): HighlightRange[] {
if (contentEnd >= cols) return [];
return [{ x: contentEnd, width: cols - contentEnd }];
}
private clear(): void {
this.clearOwned(
this.marker,
this.decorations,
this.decorationDisposeListeners,
);
this.marker = null;
this.decorations = [];
this.decorationDisposeListeners = [];
this.activeLine = null;
this.activeCols = null;
this.activeColor = null;
this.activeRanges = [];
this.activeTailRanges = [];
}
private clearOwned(
marker: IMarker | null,
decorations: IDecoration[],
disposeListeners: IDisposable[],
): void {
for (const disposable of disposeListeners) disposable.dispose();
for (const decoration of decorations) decoration.dispose();
marker?.dispose();
}
private markPendingRefresh(): void {
if (!this.disposed && this.enabled) this.pendingRefresh = true;
}
}
const rangesEqual = (left: HighlightRange[], right: HighlightRange[]): boolean =>
left.length === right.length && left.every((range, index) => {
const other = right[index];
return other?.x === range.x && other.width === range.width;
});

View File

@@ -0,0 +1,160 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
applyUserCursorBlinkPreference,
applyUserCursorPreference,
installUserCursorPreferenceGuard,
resolveUserCursorPreference,
} from "./cursorPreference";
test("resolveUserCursorPreference defaults to a blinking block cursor", () => {
assert.deepEqual(resolveUserCursorPreference(undefined), {
cursorShape: "block",
cursorBlink: true,
});
});
test("applyUserCursorPreference clears terminal-side cursor overrides before applying user settings", () => {
const term = {
options: {
cursorStyle: "block" as const,
cursorBlink: false,
},
_core: {
coreService: {
decPrivateModes: {
cursorStyle: "bar" as const,
cursorBlink: false,
},
},
},
};
applyUserCursorPreference(term, {
cursorShape: "underline",
cursorBlink: true,
});
assert.equal(term.options.cursorStyle, "underline");
assert.equal(term.options.cursorBlink, true);
assert.equal(term._core.coreService.decPrivateModes.cursorStyle, undefined);
assert.equal(term._core.coreService.decPrivateModes.cursorBlink, undefined);
});
test("applyUserCursorBlinkPreference keeps remote cursor shape overrides intact", () => {
const term = {
options: {
cursorStyle: "block" as const,
cursorBlink: false,
},
_core: {
coreService: {
decPrivateModes: {
cursorStyle: "bar" as const,
cursorBlink: false,
},
},
},
};
applyUserCursorBlinkPreference(term, {
cursorShape: "underline",
cursorBlink: true,
});
assert.equal(term.options.cursorStyle, "block");
assert.equal(term.options.cursorBlink, true);
assert.equal(term._core.coreService.decPrivateModes.cursorStyle, "bar");
assert.equal(term._core.coreService.decPrivateModes.cursorBlink, undefined);
});
test("installUserCursorPreferenceGuard restores blink without consuming cursor-style overrides", async () => {
const handlers = new Map<string, (params: readonly (number | number[])[]) => boolean>();
const parser = {
registerCsiHandler(this: typeof parser, id: { prefix?: string; intermediates?: string; final: string }, callback: (params: readonly (number | number[])[]) => boolean) {
assert.equal(this, parser);
handlers.set(`${id.prefix ?? ""}|${id.intermediates ?? ""}|${id.final}`, callback);
return { dispose: () => undefined };
},
};
const term = {
options: {
cursorStyle: "block" as const,
cursorBlink: false,
},
parser,
_core: {
coreService: {
decPrivateModes: {
cursorStyle: "block" as const,
cursorBlink: false,
},
},
},
};
const settingsRef = {
current: {
cursorShape: "bar",
cursorBlink: true,
},
};
installUserCursorPreferenceGuard(term, settingsRef);
const handled = handlers.get("| |q")?.([2]);
assert.equal(handled, false);
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
assert.equal(term.options.cursorStyle, "block");
assert.equal(term.options.cursorBlink, true);
assert.equal(term._core.coreService.decPrivateModes.cursorStyle, "block");
assert.equal(term._core.coreService.decPrivateModes.cursorBlink, undefined);
});
test("installUserCursorPreferenceGuard restores cursor blink after private mode changes", async () => {
const handlers = new Map<string, (params: readonly (number | number[])[]) => boolean>();
const term = {
options: {
cursorStyle: "block" as const,
cursorBlink: false,
},
parser: {
registerCsiHandler: (id: { prefix?: string; intermediates?: string; final: string }, callback: (params: readonly (number | number[])[]) => boolean) => {
handlers.set(`${id.prefix ?? ""}|${id.intermediates ?? ""}|${id.final}`, callback);
return { dispose: () => undefined };
},
},
_core: {
coreService: {
decPrivateModes: {
cursorStyle: "block" as const,
cursorBlink: false,
},
},
},
};
const settingsRef = {
current: {
cursorShape: "underline",
cursorBlink: true,
},
};
installUserCursorPreferenceGuard(term, settingsRef);
const handled = handlers.get("?||l")?.([12]);
assert.equal(handled, false);
await new Promise((resolve) => {
setTimeout(resolve, 0);
});
assert.equal(term.options.cursorStyle, "block");
assert.equal(term.options.cursorBlink, true);
assert.equal(term._core.coreService.decPrivateModes.cursorStyle, "block");
assert.equal(term._core.coreService.decPrivateModes.cursorBlink, undefined);
});

View File

@@ -0,0 +1,118 @@
import type { IDisposable, Terminal as XTerm } from "@xterm/xterm";
import type { RefObject } from "react";
import type { TerminalSettings } from "../../../types";
type CursorPreferenceSettings = Pick<TerminalSettings, "cursorShape" | "cursorBlink">;
type MutableCursorOptions = {
cursorStyle?: "block" | "bar" | "underline";
cursorBlink?: boolean;
};
type TerminalLike = {
options: MutableCursorOptions;
parser?: {
registerCsiHandler?: (
id: { prefix?: string; intermediates?: string; final: string },
callback: (params: readonly (number | number[])[]) => boolean,
) => IDisposable;
};
_core?: {
coreService?: {
decPrivateModes?: {
cursorStyle?: "block" | "bar" | "underline";
cursorBlink?: boolean;
};
};
};
};
const scheduleAfterDefaultHandler = (callback: () => void): void => {
if (typeof queueMicrotask === "function") {
queueMicrotask(callback);
return;
}
setTimeout(callback, 0);
};
const hasCursorBlinkPrivateModeParam = (params: readonly (number | number[])[]): boolean => (
params.some((param) => (
Array.isArray(param)
? param.includes(12)
: param === 12
))
);
export const resolveUserCursorPreference = (
settings: Partial<CursorPreferenceSettings> | undefined,
): Required<CursorPreferenceSettings> => ({
cursorShape: settings?.cursorShape ?? "block",
cursorBlink: settings?.cursorBlink ?? true,
});
export const applyUserCursorPreference = (
term: TerminalLike,
settings: Partial<CursorPreferenceSettings> | undefined,
): void => {
const preference = resolveUserCursorPreference(settings);
const privateModes = term._core?.coreService?.decPrivateModes;
if (privateModes) {
privateModes.cursorStyle = undefined;
privateModes.cursorBlink = undefined;
}
term.options.cursorStyle = preference.cursorShape;
term.options.cursorBlink = preference.cursorBlink;
};
export const applyUserCursorBlinkPreference = (
term: TerminalLike,
settings: Partial<CursorPreferenceSettings> | undefined,
): void => {
const preference = resolveUserCursorPreference(settings);
const privateModes = term._core?.coreService?.decPrivateModes;
if (privateModes) {
privateModes.cursorBlink = undefined;
}
term.options.cursorBlink = preference.cursorBlink;
};
export const installUserCursorPreferenceGuard = (
term: XTerm | TerminalLike,
terminalSettingsRef: RefObject<TerminalSettings | undefined>,
): IDisposable | null => {
const terminal = term as TerminalLike;
const parser = terminal.parser;
if (!parser?.registerCsiHandler) return null;
const registerCsiHandler = parser.registerCsiHandler.bind(parser);
const applyBlinkPreference = () => applyUserCursorBlinkPreference(terminal, terminalSettingsRef.current);
const cursorStyleDisposable = registerCsiHandler({ intermediates: " ", final: "q" }, () => {
scheduleAfterDefaultHandler(applyBlinkPreference);
return false;
});
const cursorBlinkSetDisposable = registerCsiHandler({ prefix: "?", final: "h" }, (params) => {
if (hasCursorBlinkPrivateModeParam(params)) {
scheduleAfterDefaultHandler(applyBlinkPreference);
}
return false;
});
const cursorBlinkResetDisposable = registerCsiHandler({ prefix: "?", final: "l" }, (params) => {
if (hasCursorBlinkPrivateModeParam(params)) {
scheduleAfterDefaultHandler(applyBlinkPreference);
}
return false;
});
return {
dispose: () => {
cursorStyleDisposable.dispose();
cursorBlinkSetDisposable.dispose();
cursorBlinkResetDisposable.dispose();
},
};
};

View File

@@ -0,0 +1,244 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
createSyncBlockFilterState,
filterSyncBlockClears,
isTerminalViewportScrolledUp,
SYNC_BLOCK_SCROLLBACK_STRIP_MIN_ROWS,
} from "./filterSyncBlockClears.ts";
const SYNC_START = "\x1b[?2026h";
const SYNC_END = "\x1b[?2026l";
const CLEAR = "\x1b[2J";
const CURSOR_HOME = "\x1b[H";
const scrolledUpTerm = {
rows: 24,
buffer: { active: { type: "normal" as const, viewportY: 0, baseY: 5 } },
};
const liveBottomTerm = {
rows: 24,
buffer: { active: { type: "normal" as const, viewportY: 10, baseY: 10 } },
};
/** One row behind bottom — sticky lag / trackpad jitter, must not strip (#2291). */
const nearBottomLagTerm = {
rows: 24,
buffer: { active: { type: "normal" as const, viewportY: 9, baseY: 10 } },
};
/** Exact strip threshold: baseY - viewportY === SYNC_BLOCK_SCROLLBACK_STRIP_MIN_ROWS. */
const thresholdScrollTerm = {
rows: 24,
buffer: { active: { type: "normal" as const, viewportY: 8, baseY: 10 } },
};
const alternateScreenTerm = {
rows: 24,
buffer: { active: { type: "alternate" as const, viewportY: 0, baseY: 5 } },
};
test("passes through data with no synchronized-output sequences", () => {
const state = createSyncBlockFilterState();
const input = "hello\r\n\x1b[2Jworld\r\n";
assert.equal(filterSyncBlockClears(input, state), input);
assert.equal(state.inSyncBlock, false);
});
test("keeps cursor-home and strips clear for full-screen redraw while scrolled up", () => {
const state = createSyncBlockFilterState();
const input = `${SYNC_START}${CURSOR_HOME}${CLEAR}frame${SYNC_END}`;
assert.equal(
filterSyncBlockClears(input, state, scrolledUpTerm as never),
`${SYNC_START}${CURSOR_HOME}frame${SYNC_END}`,
);
assert.equal(state.inSyncBlock, false);
});
test("passes full-screen redraw clears through at the live bottom", () => {
const state = createSyncBlockFilterState();
const input = `${SYNC_START}${CURSOR_HOME}${CLEAR}frame${SYNC_END}`;
assert.equal(filterSyncBlockClears(input, state, liveBottomTerm as never), input);
});
test("does not strip full redraw when only one row behind the live bottom (#2291)", () => {
const state = createSyncBlockFilterState();
const input = `${SYNC_START}${CURSOR_HOME}${CLEAR}frame${SYNC_END}`;
assert.equal(filterSyncBlockClears(input, state, nearBottomLagTerm as never), input);
});
test("strips clear at exactly the scrollback threshold boundary", () => {
const state = createSyncBlockFilterState();
const input = `${SYNC_START}${CURSOR_HOME}${CLEAR}frame${SYNC_END}`;
assert.equal(isTerminalViewportScrolledUp(thresholdScrollTerm as never), true);
assert.equal(
filterSyncBlockClears(input, state, thresholdScrollTerm as never),
`${SYNC_START}${CURSOR_HOME}frame${SYNC_END}`,
);
});
test("re-checks scroll when clear arrives after held home (return to bottom)", () => {
const mutableTerm = {
rows: 24,
buffer: { active: { type: "normal" as const, viewportY: 0, baseY: 5 } },
};
const state = createSyncBlockFilterState();
assert.equal(filterSyncBlockClears(SYNC_START, state, mutableTerm as never), SYNC_START);
assert.equal(filterSyncBlockClears(CURSOR_HOME, state, mutableTerm as never), "");
assert.equal(state.pendingCursorHome, CURSOR_HOME);
// User returns to the live bottom before the clear chunk arrives.
mutableTerm.buffer.active.viewportY = 5;
mutableTerm.buffer.active.baseY = 5;
assert.equal(
filterSyncBlockClears(`${CLEAR}frame${SYNC_END}`, state, mutableTerm as never),
`${CURSOR_HOME}${CLEAR}frame${SYNC_END}`,
);
});
test("does not strip full redraw on the alternate screen", () => {
const state = createSyncBlockFilterState();
const input = `${SYNC_START}${CURSOR_HOME}${CLEAR}frame${SYNC_END}`;
assert.equal(filterSyncBlockClears(input, state, alternateScreenTerm as never), input);
});
test("stacked agent frames keep home so the second frame cannot append under the first (#2291)", () => {
const state = createSyncBlockFilterState();
const frameA = `${SYNC_START}${CURSOR_HOME}${CLEAR}AAA${SYNC_END}`;
const frameB = `${SYNC_START}${CURSOR_HOME}${CLEAR}BBB${SYNC_END}`;
assert.equal(
filterSyncBlockClears(frameA, state, scrolledUpTerm as never),
`${SYNC_START}${CURSOR_HOME}AAA${SYNC_END}`,
);
assert.equal(
filterSyncBlockClears(frameB, state, scrolledUpTerm as never),
`${SYNC_START}${CURSOR_HOME}BBB${SYNC_END}`,
);
});
test("passes incremental sync blocks through unchanged", () => {
const state = createSyncBlockFilterState();
const rowMove = "\x1b[5;1H";
const input = `${SYNC_START}${rowMove}partial${SYNC_END}`;
assert.equal(filterSyncBlockClears(input, state), input);
});
test("passes clear-screen outside synchronized-output blocks", () => {
const state = createSyncBlockFilterState();
assert.equal(filterSyncBlockClears(CLEAR, state), CLEAR);
});
test("passes standalone clear inside sync blocks that are not full redraws", () => {
const state = createSyncBlockFilterState();
const input = `${SYNC_START}${CLEAR}frame${SYNC_END}`;
assert.equal(filterSyncBlockClears(input, state), input);
});
test("tracks full redraw state across chunks", () => {
const state = createSyncBlockFilterState();
assert.equal(filterSyncBlockClears(SYNC_START, state, scrolledUpTerm as never), SYNC_START);
assert.equal(filterSyncBlockClears(CURSOR_HOME, state, scrolledUpTerm as never), "");
assert.equal(state.pendingCursorHome, CURSOR_HOME);
assert.equal(filterSyncBlockClears(CLEAR, state, scrolledUpTerm as never), CURSOR_HOME);
assert.equal(
filterSyncBlockClears(`frame${SYNC_END}`, state, scrolledUpTerm as never),
`frame${SYNC_END}`,
);
});
test("releases held cursor-home when sync block ends without clear", () => {
const state = createSyncBlockFilterState();
assert.equal(filterSyncBlockClears(SYNC_START, state, scrolledUpTerm as never), SYNC_START);
assert.equal(filterSyncBlockClears(CURSOR_HOME, state, scrolledUpTerm as never), "");
assert.equal(
filterSyncBlockClears(`partial${SYNC_END}`, state, scrolledUpTerm as never),
`${CURSOR_HOME}partial${SYNC_END}`,
);
});
test("handles sync markers split across chunks", () => {
const state = createSyncBlockFilterState();
const startPrefix = SYNC_START.slice(0, -1);
const startSuffix = SYNC_START.slice(-1);
assert.equal(filterSyncBlockClears(startPrefix, state, scrolledUpTerm as never), "");
assert.equal(
filterSyncBlockClears(
`${startSuffix}${CURSOR_HOME}${CLEAR}frame${SYNC_END}`,
state,
scrolledUpTerm as never,
),
`${SYNC_START}${CURSOR_HOME}frame${SYNC_END}`,
);
});
test("handles clear-screen marker split across chunks inside full redraw block", () => {
const state = createSyncBlockFilterState();
const clearPrefix = CLEAR.slice(0, -1);
const clearSuffix = CLEAR.slice(-1);
assert.equal(filterSyncBlockClears(SYNC_START, state, scrolledUpTerm as never), SYNC_START);
assert.equal(filterSyncBlockClears(CURSOR_HOME, state, scrolledUpTerm as never), "");
assert.equal(filterSyncBlockClears(clearPrefix, state, scrolledUpTerm as never), "");
assert.equal(
filterSyncBlockClears(`${clearSuffix}frame${SYNC_END}`, state, scrolledUpTerm as never),
`${CURSOR_HOME}frame${SYNC_END}`,
);
});
test("keeps explicit home and strips clear inside full redraw blocks while scrolled up", () => {
const state = createSyncBlockFilterState();
const cursorHome = "\x1b[1;1H";
const input = `${SYNC_START}${cursorHome}${CLEAR}text${SYNC_END}`;
assert.equal(
filterSyncBlockClears(input, state, scrolledUpTerm as never),
`${SYNC_START}${cursorHome}text${SYNC_END}`,
);
});
test("isTerminalViewportScrolledUp is false at the live bottom", () => {
assert.equal(isTerminalViewportScrolledUp(liveBottomTerm as never), false);
});
test("isTerminalViewportScrolledUp is false for a one-row lag (#2291)", () => {
assert.equal(isTerminalViewportScrolledUp(nearBottomLagTerm as never), false);
});
test("isTerminalViewportScrolledUp becomes true at the configured row threshold", () => {
assert.equal(
thresholdScrollTerm.buffer.active.baseY - thresholdScrollTerm.buffer.active.viewportY,
SYNC_BLOCK_SCROLLBACK_STRIP_MIN_ROWS,
);
assert.equal(isTerminalViewportScrolledUp(thresholdScrollTerm as never), true);
assert.equal(isTerminalViewportScrolledUp(nearBottomLagTerm as never), false);
});
test("isTerminalViewportScrolledUp is true when reading scrollback", () => {
assert.equal(isTerminalViewportScrolledUp(scrolledUpTerm as never), true);
});
test("isTerminalViewportScrolledUp is false on alternate screen", () => {
assert.equal(isTerminalViewportScrolledUp(alternateScreenTerm as never), false);
});
test("isTerminalViewportScrolledUp is false when buffer is missing", () => {
assert.equal(isTerminalViewportScrolledUp({ rows: 24 } as never), false);
});

View File

@@ -0,0 +1,351 @@
import type { Terminal as XTerm } from "@xterm/xterm";
/**
* Soften full-screen redraw clears inside DEC Mode 2026 synchronized-output
* blocks before data reaches xterm.js.
*
* Codex and Claude Code emit `\x1b[H` + `\x1b[2J` inside sync blocks for
* full-screen frames. xterm.js resets viewportY on `\x1b[2J`, which yanks
* scroll position when the user is reading scrollback (xterm.js#5801).
* Incremental sync blocks must pass through untouched.
*
* Detection follows anthropics/claude-code#35580: only blocks that contain
* both cursor-home and erase-display are treated as full redraws. Pane (#120)
* strips `\x1b[2J` only. We hold the leading `\x1b[H` until `\x1b[2J` confirms
* the redraw, then:
* - emit the held cursor-home (so the new frame still starts at the origin)
* - strip only `\x1b[2J` (to avoid viewport yank)
*
* Stripping both home and clear used to stack whole TUI frames when
* `viewportY < baseY` was a false positive (overflow / sticky lag, #2291).
*
* @see https://github.com/Dcouple-Inc/Pane/pull/120
* @see https://github.com/anthropics/claude-code/issues/35580
* @see https://github.com/xtermjs/xterm.js/issues/5801
*/
export type SyncBlockFilterState = {
inSyncBlock: boolean;
pending: string;
/** Leading `\x1b[H` held until `\x1b[2J` confirms a full redraw. */
pendingCursorHome: string | null;
/**
* null = unknown;
* true = strip further clears in this block (home already emitted);
* false = pass remaining block through.
*/
fullRedrawBlock: boolean | null;
};
/**
* Minimum rows into scrollback before we treat the viewport as "reading
* history" and soft-strip full-redraw clears.
*
* Intentional 1-row peeks still receive `\x1b[2J` (possible viewport yank);
* that is deliberate so a 1-row sticky-bottom / trackpad lag cannot strip
* agent TUI frames (#2291).
*/
export const SYNC_BLOCK_SCROLLBACK_STRIP_MIN_ROWS = 2;
export type SyncBlockClearFilterResult = {
output: string;
startedSyncBlock: boolean;
};
const SYNC_START = "\x1b[?2026h";
const SYNC_END = "\x1b[?2026l";
const CLEAR = "\x1b[2J";
const CURSOR_HOME = "\x1b[H";
const CURSOR_HOME_EXPLICIT = "\x1b[1;1H";
const MARKERS = [SYNC_START, SYNC_END, CLEAR, CURSOR_HOME, CURSOR_HOME_EXPLICIT] as const;
/** Shared prefix of SYNC_START / SYNC_END, used to hop across plain spans. */
const SYNC_PREFIX = "\x1b[?2026";
const maxMarkerPrefixLength = Math.max(...MARKERS.map((marker) => marker.length)) - 1;
const isIncompleteEscapePrefix = (suffix: string): boolean => {
if (!suffix.startsWith("\x1b")) {
return false;
}
const isCsiFinal = (ch: string): boolean => ch >= "@" && ch <= "~";
let index = 0;
while (index < suffix.length) {
if (suffix.startsWith("\x1b[", index)) {
let hasFinal = false;
for (let i = index + 2; i < suffix.length; i += 1) {
if (isCsiFinal(suffix[i])) {
index = i + 1;
hasFinal = true;
break;
}
}
if (!hasFinal) {
return true;
}
continue;
}
if (suffix[index] === "\x1b") {
if (index === suffix.length - 1) {
return true;
}
index += 1;
continue;
}
index += 1;
}
return false;
};
const hasCsiFinalByte = (input: string, from: number): boolean => {
for (let index = from; index < input.length; index += 1) {
const code = input.charCodeAt(index);
if (code >= 0x40 && code <= 0x7e) {
return true;
}
}
return false;
};
/**
* True when some suffix of `input` could parse as an incomplete escape
* sequence. An incomplete parse requires either a trailing lone ESC, or an
* `\x1b[` occurrence with no CSI final byte after it. Since `[` itself is in
* the final-byte range (0x40-0x7e), it is sufficient to check the last
* `\x1b[`: every earlier one scans a superset that includes that `[`.
*/
const mayEndWithIncompleteEscape = (input: string): boolean => {
if (input.length === 0) {
return false;
}
if (input.charCodeAt(input.length - 1) === 0x1b) {
return true;
}
const lastCsiIntro = input.lastIndexOf("\x1b[");
return lastCsiIntro !== -1 && !hasCsiFinalByte(input, lastCsiIntro + 2);
};
const splitPendingMarkerSuffix = (input: string): { emit: string; pending: string } => {
const markerMax = Math.min(input.length, maxMarkerPrefixLength);
for (let length = markerMax; length > 0; length -= 1) {
const suffix = input.slice(-length);
if (MARKERS.some((marker) => marker.startsWith(suffix) && marker.length > suffix.length)) {
return {
emit: input.slice(0, -length),
pending: suffix,
};
}
}
// Without this gate, every ESC-bearing chunk pays an O(n * escapes) scan
// below (quadratic on colored output floods); the gate settles the common
// complete-tail case with one native lastIndexOf.
if (!mayEndWithIncompleteEscape(input)) {
return { emit: input, pending: "" };
}
// Only suffixes that start with ESC can qualify; skip other start positions
// with a charCode probe so no substring is allocated for them.
for (let length = input.length; length > 0; length -= 1) {
if (input.charCodeAt(input.length - length) !== 0x1b) {
continue;
}
const suffix = input.slice(-length);
if (isIncompleteEscapePrefix(suffix)) {
return {
emit: input.slice(0, -length),
pending: suffix,
};
}
}
return { emit: input, pending: "" };
};
const readBlockCursorHome = (
input: string,
index: number,
): { raw: string; end: number } | null => {
if (input.startsWith(CURSOR_HOME_EXPLICIT, index)) {
return { raw: CURSOR_HOME_EXPLICIT, end: index + CURSOR_HOME_EXPLICIT.length };
}
if (input.startsWith(CURSOR_HOME, index)) {
return { raw: CURSOR_HOME, end: index + CURSOR_HOME.length };
}
return null;
};
const releasePendingCursorHome = (state: SyncBlockFilterState, result: string): string => {
if (!state.pendingCursorHome) {
return result;
}
const released = `${result}${state.pendingCursorHome}`;
state.pendingCursorHome = null;
return released;
};
const resetSyncBlockState = (state: SyncBlockFilterState): void => {
state.inSyncBlock = false;
state.pendingCursorHome = null;
state.fullRedrawBlock = null;
};
/**
* True when the user is reading scrollback far enough that a full-redraw
* `\x1b[2J` would yank the viewport. Alternate screen never strips.
*/
export const isTerminalViewportScrolledUp = (term: XTerm): boolean => {
const buffer = term.buffer?.active;
if (!buffer || buffer.type !== "normal") {
return false;
}
const scrolledRows = buffer.baseY - buffer.viewportY;
return scrolledRows >= SYNC_BLOCK_SCROLLBACK_STRIP_MIN_ROWS;
};
const shouldStripFullRedrawClear = (term?: XTerm): boolean =>
term !== undefined && isTerminalViewportScrolledUp(term);
const scanSyncBlockClears = (
input: string,
state: SyncBlockFilterState,
term?: XTerm,
): SyncBlockClearFilterResult => {
let result = "";
let startedSyncBlock = false;
let index = 0;
while (index < input.length) {
if (input.startsWith(SYNC_START, index)) {
resetSyncBlockState(state);
state.inSyncBlock = true;
startedSyncBlock = true;
result += SYNC_START;
index += SYNC_START.length;
continue;
}
if (input.startsWith(SYNC_END, index)) {
result = releasePendingCursorHome(state, result);
resetSyncBlockState(state);
result += SYNC_END;
index += SYNC_END.length;
continue;
}
if (!state.inSyncBlock || state.fullRedrawBlock === false) {
// Pass-through span: nothing to rewrite until the next possible sync
// marker. Hop there with a native scan instead of copying per char.
// The current position is known not to start SYNC_START/SYNC_END, so
// consuming at least one character here is safe.
const nextMarker = input.indexOf(SYNC_PREFIX, index + 1);
const end = nextMarker === -1 ? input.length : nextMarker;
result += input.slice(index, end);
index = end;
continue;
}
const cursorHome = readBlockCursorHome(input, index);
if (cursorHome) {
if (state.fullRedrawBlock === true) {
// Home already applied for this full redraw; drop redundant homes so
// we do not re-hold and re-pair with a later clear incorrectly.
index = cursorHome.end;
continue;
}
if (!shouldStripFullRedrawClear(term)) {
result += cursorHome.raw;
index = cursorHome.end;
continue;
}
// Hold until `\x1b[2J` confirms a full redraw; home is re-emitted then.
state.pendingCursorHome = cursorHome.raw;
index = cursorHome.end;
continue;
}
if (input.startsWith(CLEAR, index)) {
if (state.pendingCursorHome !== null) {
// Full redraw pair: always re-emit the held home so the frame starts
// at the origin (#2291). Re-check scroll at CLEAR time — home may have
// been held while scrolled, then the user returned to the live bottom
// before 2J arrived in a later PTY chunk.
result += state.pendingCursorHome;
state.pendingCursorHome = null;
if (shouldStripFullRedrawClear(term)) {
state.fullRedrawBlock = true;
} else {
result += CLEAR;
state.fullRedrawBlock = null;
}
index += CLEAR.length;
continue;
}
if (state.fullRedrawBlock === true) {
index += CLEAR.length;
continue;
}
if (!shouldStripFullRedrawClear(term)) {
result += CLEAR;
index += CLEAR.length;
continue;
}
// Standalone clear (no leading home) is not a full redraw pair.
state.fullRedrawBlock = false;
result += CLEAR;
index += CLEAR.length;
continue;
}
if (state.pendingCursorHome !== null) {
result += state.pendingCursorHome;
state.pendingCursorHome = null;
}
// Inside an active sync block every marker starts with ESC; hop to the
// next ESC and copy the plain span in one slice.
const nextEsc = input.indexOf("\x1b", index + 1);
const end = nextEsc === -1 ? input.length : nextEsc;
result += input.slice(index, end);
index = end;
}
return { output: result, startedSyncBlock };
};
export const filterSyncBlockClearsWithMeta = (
data: string,
state: SyncBlockFilterState,
term?: XTerm,
): SyncBlockClearFilterResult => {
if (!state.inSyncBlock && !state.pending && !data.includes("\x1b")) {
return { output: data, startedSyncBlock: false };
}
const { emit, pending } = splitPendingMarkerSuffix(`${state.pending}${data}`);
state.pending = pending;
if (!emit) {
return { output: "", startedSyncBlock: false };
}
return scanSyncBlockClears(emit, state, term);
};
export const filterSyncBlockClears = (
data: string,
state: SyncBlockFilterState,
term?: XTerm,
): string => filterSyncBlockClearsWithMeta(data, state, term).output;
export const createSyncBlockFilterState = (): SyncBlockFilterState => ({
inSyncBlock: false,
pending: "",
pendingCursorHome: null,
fullRedrawBlock: null,
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,448 @@
import {
encodeKittyCompositionText,
encodeKittyKeyEvent,
encodeLegacyKeyboardEvent,
shouldEncodeKittyCompositionText,
type KittyKeyboardEvent,
type KittyKeyboardModeState,
} from "./kittyKeyboardProtocol";
import type { TerminalSettings } from "../../../domain/models";
import {
isBareShiftEnterLineEnding,
resolveShiftEnterText,
shouldSendShiftEnterText,
} from "./shiftEnterText";
export type KittyKeyboardBroadcastInput =
| {
kind: "key";
event: KittyKeyboardEvent;
fallbackToLegacy?: boolean;
urgentInterrupt?: boolean;
}
| {
kind: "win32";
data: string;
event: KittyKeyboardEvent;
fallbackToLegacy?: boolean;
urgentInterrupt?: boolean;
}
| { kind: "legacy"; data: string; keyIdentity: string; urgentInterrupt?: boolean }
| { kind: "text"; text: string };
type KittyKeyboardBroadcastDispatchOptions = {
beforeUrgentInterrupt?: () => void;
};
type KittyKeyboardBroadcastHandler = (
input: KittyKeyboardBroadcastInput,
dispatchOptions?: KittyKeyboardBroadcastDispatchOptions,
) => void;
export type ResolvedKittyKeyboardBroadcastInput = {
data: string;
kittyEncoded: boolean;
urgentInterrupt: boolean;
logicalData?: string | null;
/** Let the target xterm encode this event with its active Win32 mode. */
win32Event?: KittyKeyboardEvent;
};
export const createKittyKeyboardBroadcastForwarder = (options: {
sourceSessionId: string;
isHandlingBroadcast: () => boolean;
isBroadcastEnabled: () => boolean;
isSensitiveInput?: () => boolean;
getDispatcher: () => ((
data: string,
sourceSessionId: string,
options: {
kittyKeyboardInput: KittyKeyboardBroadcastInput;
kittyKeyboardTargetSessionIds?: string[];
},
) => string[] | void) | null | undefined;
}) => {
let lastDispatcher: ReturnType<typeof options.getDispatcher>;
return (
input: KittyKeyboardBroadcastInput,
forcePairedRelease = false,
targetSessionIds?: string[],
): { targetSessionIds: string[] } | null => {
const currentDispatcher = options.getDispatcher();
if (currentDispatcher) lastDispatcher = currentDispatcher;
const dispatcher = currentDispatcher ?? (forcePairedRelease ? lastDispatcher : undefined);
if (
options.isHandlingBroadcast() ||
(!forcePairedRelease && options.isSensitiveInput?.() === true) ||
(!forcePairedRelease && !options.isBroadcastEnabled()) ||
!dispatcher
) return null;
const deliveredSessionIds = dispatcher("", options.sourceSessionId, {
kittyKeyboardInput: input,
...(targetSessionIds ? { kittyKeyboardTargetSessionIds: targetSessionIds } : {}),
});
return { targetSessionIds: deliveredSessionIds ?? targetSessionIds ?? [] };
};
};
export const clearKittyKeyboardBroadcastPairingState = (
encodedKeys: Set<string>,
legacySuppressedKeys: Set<string>,
): void => {
encodedKeys.clear();
legacySuppressedKeys.clear();
};
const SNAPSHOT_MODIFIERS = [
"AltGraph", "CapsLock", "Hyper", "KittyMeta", "NumLock",
] as const;
export const createKittyKeyboardSyntheticRelease = (
event: KittyKeyboardEvent,
remainingPresses: Iterable<KittyKeyboardEvent> = [],
lockState?: { capsLock: boolean; numLock: boolean },
): KittyKeyboardEvent => {
const remaining = Array.from(remainingPresses);
const hasRemainingKey = (keys: string[], codePrefix?: string) => remaining.some((press) => (
keys.includes(press.key) || (codePrefix ? press.code?.startsWith(codePrefix) === true : false)
));
const releasedShift = event.key === "Shift" || event.code?.startsWith("Shift") === true;
const releasedControl = event.key === "Control" || event.code?.startsWith("Control") === true;
const releasedAlt = event.key === "Alt" || event.code?.startsWith("Alt") === true;
const releasedAltGraph = event.key === "AltGraph";
const releasedMeta = ["Meta", "Super"].includes(event.key) || event.code?.startsWith("Meta") === true;
const remainingShift = hasRemainingKey(["Shift"], "Shift");
const remainingControl = hasRemainingKey(["Control"], "Control");
const remainingAlt = hasRemainingKey(["Alt"], "Alt");
const remainingMeta = hasRemainingKey(["Meta", "Super"], "Meta");
const remainingAltGraph = hasRemainingKey(["AltGraph"]);
const remainingHyper = hasRemainingKey(["Hyper"]);
const remainingKittyMeta = hasRemainingKey(["KittyMeta"]);
const modifierStates = new Map<string, boolean>(
SNAPSHOT_MODIFIERS.map((name) => [name, event.getModifierState?.(name) === true]),
);
const releasedKey = event.key;
return {
...event,
type: "keyup",
repeat: false,
shiftKey: releasedShift ? remainingShift : (event.shiftKey || remainingShift),
altKey: releasedAlt || releasedAltGraph ? remainingAlt : (event.altKey || remainingAlt),
ctrlKey: releasedControl || releasedAltGraph
? remainingControl
: (event.ctrlKey || remainingControl),
metaKey: releasedMeta ? remainingMeta : (event.metaKey || remainingMeta),
getModifierState: (name) => {
if (name === "CapsLock" && lockState) return lockState.capsLock;
if (name === "NumLock" && lockState) return lockState.numLock;
if (name === "Hyper") {
return releasedKey === "Hyper" ? remainingHyper : (
modifierStates.get(name) === true || remainingHyper
);
}
if (name === "KittyMeta") {
return releasedKey === "KittyMeta" ? remainingKittyMeta : (
modifierStates.get(name) === true || remainingKittyMeta
);
}
if (name === "AltGraph") {
return releasedAltGraph ? remainingAltGraph : (
modifierStates.get(name) === true || remainingAltGraph
);
}
return modifierStates.get(name) === true;
},
};
};
export type KittyKeyboardForwardedPress = {
event: KittyKeyboardEvent;
targetSessionIds: string[];
};
export const upsertKittyKeyboardForwardedPress = (
releases: Map<string, KittyKeyboardForwardedPress>,
identity: string,
event: KittyKeyboardEvent,
targetSessionIds: string[],
): void => {
const existing = releases.get(identity);
releases.set(identity, {
event,
targetSessionIds: Array.from(new Set([
...(existing?.targetSessionIds ?? []),
...targetSessionIds,
])),
});
};
export const flushKittyKeyboardBroadcastReleases = (
releases: Map<string, KittyKeyboardForwardedPress>,
forward: (
input: KittyKeyboardBroadcastInput,
forcePairedRelease?: boolean,
targetSessionIds?: string[],
) => unknown,
currentLockState?: { capsLock: boolean; numLock: boolean },
): void => {
const pending = new Map(releases);
const entries = Array.from(pending.entries()).reverse();
const latestPress = entries[0]?.[1].event;
const lockState = currentLockState ?? {
capsLock: latestPress?.getModifierState?.("CapsLock") === true,
numLock: latestPress?.getModifierState?.("NumLock") === true,
};
for (const [identity, forwardedPress] of entries) {
pending.delete(identity);
forward({
kind: "key",
event: createKittyKeyboardSyntheticRelease(
forwardedPress.event,
Array.from(pending.values(), (pendingPress) => pendingPress.event),
lockState,
),
}, true, forwardedPress.targetSessionIds);
}
releases.clear();
};
type ResolveKittyKeyboardBroadcastOptions = {
kittyProtocolEnabled: boolean;
kittyMode: KittyKeyboardModeState;
applicationCursorMode: boolean;
encodedKeys: Set<string>;
legacySuppressedKeys?: Set<string>;
win32InputMode?: boolean;
shiftEnterSettings?: Pick<
TerminalSettings,
"shiftEnterNewlineEnabled" | "shiftEnterNewlineText"
>;
};
export const resolveWin32InputLogicalData = (
event: KittyKeyboardEvent,
applicationCursorMode: boolean,
): string | null => {
if (event.type === "keyup") return null;
if (
event.key === "Enter" &&
(event.shiftKey || event.altKey || event.ctrlKey || event.metaKey)
) {
// The native record carries semantics that a legacy CR cannot express.
// Treating a modified Enter as CR would make Netcatty record a command
// submission even when the TUI only inserted a line break.
return null;
}
return encodeLegacyKeyboardEvent(event, applicationCursorMode);
};
const resolveShiftEnterBroadcastPayload = (
input: Extract<KittyKeyboardBroadcastInput, { kind: "key" }>,
options: ResolveKittyKeyboardBroadcastOptions,
candidate: string | null,
): ResolvedKittyKeyboardBroadcastInput | null => {
if (
!shouldSendShiftEnterText(input.event, options.shiftEnterSettings) ||
candidate === null ||
!isBareShiftEnterLineEnding(candidate)
) {
return null;
}
const data = resolveShiftEnterText(options.shiftEnterSettings);
if (!data) return null;
return {
data,
kittyEncoded: false,
urgentInterrupt: false,
};
};
export const resolveKittyKeyboardBroadcastInput = (
input: KittyKeyboardBroadcastInput,
options: ResolveKittyKeyboardBroadcastOptions,
): ResolvedKittyKeyboardBroadcastInput | null => {
if (input.kind === "text") {
if (options.kittyProtocolEnabled && shouldEncodeKittyCompositionText(options.kittyMode)) {
const encoded = encodeKittyCompositionText(options.kittyMode, input.text);
if (encoded) return { data: encoded, kittyEncoded: true, urgentInterrupt: false };
}
return { data: input.text, kittyEncoded: false, urgentInterrupt: false };
}
if (input.kind === "legacy") {
if ((options.legacySuppressedKeys ?? options.encodedKeys).delete(input.keyIdentity)) return null;
options.encodedKeys.add(input.keyIdentity);
return {
data: input.data,
kittyEncoded: false,
urgentInterrupt: input.urgentInterrupt === true,
};
}
if (input.kind === "win32") {
if (options.win32InputMode) {
const identity = input.event.code || input.event.key;
const legacySuppressedKeys = options.legacySuppressedKeys ?? options.encodedKeys;
if (input.event.type === "keyup") {
const hasPairedKeyDown = options.encodedKeys.delete(identity);
legacySuppressedKeys.delete(identity);
if (!hasPairedKeyDown) return null;
} else {
options.encodedKeys.add(identity);
legacySuppressedKeys.add(identity);
}
return {
data: input.data,
kittyEncoded: false,
urgentInterrupt: input.urgentInterrupt === true,
logicalData: resolveWin32InputLogicalData(
input.event,
options.applicationCursorMode,
),
};
}
return resolveKittyKeyboardBroadcastInput({
kind: "key",
event: input.event,
fallbackToLegacy: input.fallbackToLegacy,
urgentInterrupt: input.urgentInterrupt,
}, options);
}
const identity = input.event.code || input.event.key;
const legacySuppressedKeys = options.legacySuppressedKeys ?? options.encodedKeys;
const hasPairedKeyDown = input.event.type === "keyup"
? options.encodedKeys.delete(identity)
: false;
if (input.event.type === "keyup") legacySuppressedKeys.delete(identity);
if (input.event.type === "keyup" && !hasPairedKeyDown) return null;
if (options.win32InputMode) {
if (input.event.type !== "keyup") {
options.encodedKeys.add(identity);
legacySuppressedKeys.add(identity);
}
return {
data: "",
kittyEncoded: false,
urgentInterrupt: false,
logicalData: resolveWin32InputLogicalData(
input.event,
options.applicationCursorMode,
),
win32Event: input.event,
};
}
const encoded = options.kittyProtocolEnabled
? encodeKittyKeyEvent(options.kittyMode, {
...input.event,
applicationCursorMode: options.applicationCursorMode,
})
: null;
if (encoded) {
if (input.event.type === "keyup") options.encodedKeys.delete(identity);
else {
options.encodedKeys.add(identity);
legacySuppressedKeys.add(identity);
}
const remapped = resolveShiftEnterBroadcastPayload(input, options, encoded);
if (remapped) return remapped;
return { data: encoded, kittyEncoded: true, urgentInterrupt: false };
}
if (!input.fallbackToLegacy || input.event.type === "keyup") return null;
const legacy = encodeLegacyKeyboardEvent(input.event, options.applicationCursorMode);
if (!legacy) return null;
options.encodedKeys.add(identity);
legacySuppressedKeys.add(identity);
const remapped = resolveShiftEnterBroadcastPayload(input, options, legacy);
if (remapped) return remapped;
return {
data: legacy,
kittyEncoded: false,
urgentInterrupt: input.urgentInterrupt === true,
};
};
export const createKittyKeyboardBroadcastHandler = (options: {
resolveOptions: () => ResolveKittyKeyboardBroadcastOptions;
getSessionId: () => string | null;
isSensitiveInput?: () => boolean;
isConnected: () => boolean;
isRuntimeDisposed: () => boolean;
interruptSession?: (sessionId: string) => void;
writeDisposed: (sessionId: string, data: string) => void;
writeActive: (data: string, logicalData?: string | null) => void;
writeWin32Event?: (event: KittyKeyboardEvent, logicalData: string | null) => void;
}): KittyKeyboardBroadcastHandler => (input, dispatchOptions) => {
const sessionId = options.getSessionId();
if (!sessionId || !options.isConnected()) return;
const isPairedRelease =
(input.kind === "key" || input.kind === "win32") &&
input.event.type === "keyup";
if (!isPairedRelease && options.isSensitiveInput?.() === true) return;
const resolved = resolveKittyKeyboardBroadcastInput(input, options.resolveOptions());
if (!resolved) return;
if (resolved.urgentInterrupt && options.interruptSession) {
dispatchOptions?.beforeUrgentInterrupt?.();
options.interruptSession(sessionId);
return;
}
if (resolved.win32Event) {
if (!options.isRuntimeDisposed()) {
options.writeWin32Event?.(
resolved.win32Event,
resolved.logicalData ?? null,
);
}
return;
}
if (options.isRuntimeDisposed()) {
options.writeDisposed(sessionId, resolved.data);
return;
}
options.writeActive(resolved.data, resolved.logicalData);
};
const handlers = new Map<string, KittyKeyboardBroadcastHandler>();
const pendingInputs = new Map<string, Array<{
input: KittyKeyboardBroadcastInput;
dispatchOptions?: KittyKeyboardBroadcastDispatchOptions;
}>>();
export const registerKittyKeyboardBroadcastHandler = (
sessionId: string,
handler: KittyKeyboardBroadcastHandler,
): (() => void) => {
handlers.set(sessionId, handler);
const pending = pendingInputs.get(sessionId);
if (pending) {
pendingInputs.delete(sessionId);
for (const pendingInput of pending) {
handler(pendingInput.input, pendingInput.dispatchOptions);
}
}
return () => {
if (handlers.get(sessionId) === handler) handlers.delete(sessionId);
};
};
export const clearKittyKeyboardBroadcastSession = (sessionId: string): void => {
handlers.delete(sessionId);
pendingInputs.delete(sessionId);
};
export const dispatchKittyKeyboardBroadcastInput = (
sessionId: string,
input: KittyKeyboardBroadcastInput,
dispatchOptions?: KittyKeyboardBroadcastDispatchOptions,
): boolean => {
const handler = handlers.get(sessionId);
if (!handler) {
const pending = pendingInputs.get(sessionId) ?? [];
pending.push({ input, dispatchOptions });
if (pending.length > 128) pending.shift();
pendingInputs.set(sessionId, pending);
return true;
}
handler(input, dispatchOptions);
return true;
};

View File

@@ -0,0 +1,907 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
buildKittyKeyboardModeQueryResponse,
createKittyKeyboardModeState,
createKittyKeyboardSessionStateStore,
encodeKittyCompositionText,
encodeKittyKeyEvent,
encodeLegacyKeyboardEvent,
popKittyKeyboardModeFlags,
pushKittyKeyboardModeFlags,
restoreKittyKeyboardModeState,
setKittyKeyboardAlternateScreenActive,
setKittyKeyboardModeFlags,
snapshotKittyKeyboardModeState,
shouldExpectLegacyKeyboardData,
shouldMarkKittyTextInputEvent,
shouldTreatKittyAltAsText,
shouldTrackKittyKeyRelease,
} from "./kittyKeyboardProtocol";
import {
installKittyKeyboardProtocolHandlers,
installKittyKeyboardProtocolHandlersIfEnabled,
readKittyKeyboardCsiParam,
type KittyKeyboardCsiParams,
} from "./kittyKeyboardRuntime";
type CsiHandlerId = { prefix?: string; intermediates?: string; final: string };
type CsiHandler = (params: KittyKeyboardCsiParams) => boolean;
const csiKey = (id: CsiHandlerId): string => `${id.prefix ?? ""}|${id.intermediates ?? ""}|${id.final}`;
const createFakeCsiParser = () => {
const handlers = new Map<string, CsiHandler[]>();
return {
parser: {
registerCsiHandler(id: CsiHandlerId, callback: CsiHandler) {
const key = csiKey(id);
const list = handlers.get(key) ?? [];
list.push(callback);
handlers.set(key, list);
return { dispose: () => handlers.set(key, (handlers.get(key) ?? []).filter((item) => item !== callback)) };
},
registerEscHandler(id: { intermediates?: string; final: string }, callback: () => boolean) {
const key = `ESC|${id.intermediates ?? ""}|${id.final}`;
const list = handlers.get(key) ?? [];
list.push(callback);
handlers.set(key, list);
return { dispose: () => handlers.set(key, (handlers.get(key) ?? []).filter((item) => item !== callback)) };
},
},
dispatch(id: CsiHandlerId, params: KittyKeyboardCsiParams = []) {
const list = handlers.get(csiKey(id));
assert.ok(list?.length, `missing CSI handler for ${csiKey(id)}`);
for (let index = list.length - 1; index >= 0; index -= 1) {
if (list[index](params)) return true;
}
return false;
},
hasHandler(id: CsiHandlerId) {
return (handlers.get(csiKey(id))?.length ?? 0) > 0;
},
dispatchEsc(id: { intermediates?: string; final: string }) {
const list = handlers.get(`ESC|${id.intermediates ?? ""}|${id.final}`);
assert.ok(list?.length, `missing ESC handler for ${id.final}`);
return list.at(-1)!([]);
},
};
};
const withFlags = (flags: number) => {
const state = createKittyKeyboardModeState();
setKittyKeyboardModeFlags(state, flags);
return state;
};
const event = (key: string, overrides: Record<string, unknown> = {}) => ({
type: "keydown",
key,
code: key.length === 1 && /[a-z]/i.test(key) ? `Key${key.toUpperCase()}` : key,
getModifierState: () => false,
...overrides,
});
test("negotiates all five enhancement flags and masks unknown bits", () => {
const state = createKittyKeyboardModeState();
setKittyKeyboardModeFlags(state, 0xff);
assert.equal(buildKittyKeyboardModeQueryResponse(state), "\u001b[?31u");
setKittyKeyboardModeFlags(state, 8, 3);
assert.equal(buildKittyKeyboardModeQueryResponse(state), "\u001b[?23u");
setKittyKeyboardModeFlags(state, 8, 2);
assert.equal(buildKittyKeyboardModeQueryResponse(state), "\u001b[?31u");
});
test("maintains bounded independent main and alternate screen stacks", () => {
const state = createKittyKeyboardModeState();
setKittyKeyboardModeFlags(state, 1);
for (let flags = 0; flags < 40; flags += 1) pushKittyKeyboardModeFlags(state, flags);
assert.equal(state.mainStack.length, 32);
setKittyKeyboardAlternateScreenActive(state, true);
setKittyKeyboardModeFlags(state, 16);
pushKittyKeyboardModeFlags(state, 8);
assert.equal(popKittyKeyboardModeFlags(state), 16);
assert.equal(popKittyKeyboardModeFlags(state), 0);
setKittyKeyboardAlternateScreenActive(state, false);
assert.notEqual(buildKittyKeyboardModeQueryResponse(state), "\u001b[?16u");
});
test("snapshots and restores negotiated state across renderer handoffs", () => {
const source = createKittyKeyboardModeState();
setKittyKeyboardModeFlags(source, 1 | 8);
pushKittyKeyboardModeFlags(source, 2 | 4);
setKittyKeyboardAlternateScreenActive(source, true);
setKittyKeyboardModeFlags(source, 16);
pushKittyKeyboardModeFlags(source, 1 | 2 | 8);
const restored = createKittyKeyboardModeState();
restoreKittyKeyboardModeState(restored, snapshotKittyKeyboardModeState(source));
assert.deepEqual(restored, source);
restored.mainStack.push(31);
assert.notDeepEqual(restored, source);
});
test("disambiguates ambiguous ASCII and modified control keys", () => {
const state = withFlags(1);
assert.equal(encodeKittyKeyEvent(state, event("a")), null);
assert.equal(encodeKittyKeyEvent(state, event("Escape")), "\u001b[27u");
assert.equal(encodeKittyKeyEvent(state, event("c", { ctrlKey: true })), "\u001b[99;5u");
const disambiguate = withFlags(1);
assert.equal(
encodeKittyKeyEvent(disambiguate, event("c", {
ctrlKey: true,
getModifierState: (name: string) => name === "CapsLock" || name === "NumLock",
})),
"\u001b[99;197u",
);
assert.equal(encodeKittyKeyEvent(state, event("[", { code: "BracketLeft", altKey: true })), "\u001b[91;3u");
assert.equal(encodeKittyKeyEvent(state, event("Enter", { shiftKey: true })), "\u001b[13;2u");
});
test("alternate-key reporting alone never changes which events are encoded", () => {
const state = withFlags(4);
assert.equal(encodeKittyKeyEvent(state, event("Escape")), null);
assert.equal(encodeKittyKeyEvent(state, event("c", { ctrlKey: true })), null);
assert.equal(encodeKittyKeyEvent(state, event("Enter", { shiftKey: true })), "\r");
assert.equal(encodeKittyKeyEvent(state, event("Tab", { shiftKey: true })), "\u001b[Z");
assert.equal(
encodeKittyKeyEvent(state, event("с", { code: "KeyC", ctrlKey: true })),
"\u001b[1089::99;5u",
);
assert.equal(
encodeKittyKeyEvent(state, event("c", { code: "KeyJ", ctrlKey: true })),
"\u001b[99::106;5u",
);
assert.equal(
encodeKittyKeyEvent(state, event(";", { code: "KeyQ", ctrlKey: true })),
"\u001b[59::113;5u",
);
});
test("baseline mode preserves Ctrl+Shift combinations that legacy encoding loses", () => {
const state = withFlags(0);
assert.equal(
encodeKittyKeyEvent(state, event("I", { code: "KeyI", ctrlKey: true, shiftKey: true })),
"\u001b[105;6u",
);
assert.equal(encodeKittyKeyEvent(state, event("i", { ctrlKey: true })), null);
assert.equal(
encodeKittyKeyEvent(state, event(" ", { code: "Space", ctrlKey: true, shiftKey: true })),
"\0",
);
assert.equal(encodeKittyKeyEvent(state, event("F13")), "\u001b[57376u");
assert.equal(encodeKittyKeyEvent(state, event("F13", { repeat: true })), "\u001b[57376u");
assert.equal(encodeKittyKeyEvent(state, event("PrintScreen")), "\u001b[57361u");
assert.equal(encodeKittyKeyEvent(state, event("ContextMenu")), "\u001b[29~");
assert.equal(
encodeKittyKeyEvent(state, event("ContextMenu", { repeat: true })),
"\u001b[29~",
);
assert.equal(
encodeKittyKeyEvent(state, event("a", { metaKey: true })),
"\u001b[97;9u",
);
assert.equal(
encodeKittyKeyEvent(state, event("i", { ctrlKey: true, shiftKey: true, altKey: true })),
"\u001b[105;8u",
);
assert.equal(
encodeKittyKeyEvent(state, event(" ", {
code: "Space",
ctrlKey: true,
shiftKey: true,
getModifierState: (name: string) => name === "Hyper",
})),
"\u001b[32;22u",
);
assert.equal(
encodeKittyKeyEvent(state, event("Clear", { code: "Numpad5" })),
"\u001b[E",
);
assert.equal(
encodeKittyKeyEvent(state, event("Clear", {
code: "Numpad5",
applicationCursorMode: true,
})),
"\u001bOE",
);
assert.equal(
encodeKittyKeyEvent(state, event("Clear", {
code: "Numpad5",
ctrlKey: true,
applicationCursorMode: true,
})),
"\u001b[1;5E",
);
assert.equal(
encodeKittyKeyEvent(state, event("Tab", { altKey: true })),
"\u001b\t",
);
assert.equal(
encodeKittyKeyEvent(state, event("Tab", { ctrlKey: true, altKey: true })),
"\u001b\t",
);
assert.equal(
encodeKittyKeyEvent(state, event("Tab", { shiftKey: true, altKey: true })),
"\u001b\u001b[Z",
);
assert.equal(
encodeKittyKeyEvent(state, event("Tab", {
shiftKey: true,
ctrlKey: true,
altKey: true,
})),
"\u001b[9;8u",
);
assert.equal(
encodeKittyKeyEvent(state, event("Insert", { shiftKey: true })),
"\u001b[2;2~",
);
assert.equal(
encodeKittyKeyEvent(state, event("PageUp", { altKey: true })),
"\u001b[5;3~",
);
assert.equal(
encodeKittyKeyEvent(state, event("ArrowUp", { metaKey: true })),
"\u001b[1;9A",
);
assert.equal(
encodeKittyKeyEvent(state, event("ArrowUp", {
getModifierState: (name: string) => name === "Hyper",
})),
"\u001b[1;17A",
);
assert.equal(
encodeKittyKeyEvent(state, event("ArrowUp", {
getModifierState: (name: string) => name === "KittyMeta",
})),
"\u001b[1;33A",
);
const alternateOnly = withFlags(4);
assert.equal(encodeKittyKeyEvent(alternateOnly, event("F13")), "\u001b[57376u");
assert.equal(encodeKittyKeyEvent(alternateOnly, event("PrintScreen")), "\u001b[57361u");
assert.equal(encodeKittyKeyEvent(alternateOnly, event("ContextMenu")), "\u001b[29~");
assert.equal(
encodeKittyKeyEvent(alternateOnly, event("ContextMenu", {
getModifierState: (name: string) => name === "CapsLock",
})),
"\u001b[29;65~",
);
assert.equal(
encodeKittyKeyEvent(alternateOnly, event("Clear", {
code: "Numpad5",
getModifierState: (name: string) => name === "CapsLock",
})),
"\u001b[1;65E",
);
assert.equal(
encodeKittyKeyEvent(alternateOnly, event("ArrowUp", {
getModifierState: (name: string) => name === "CapsLock",
})),
"\u001b[1;65A",
);
assert.equal(
encodeKittyKeyEvent(alternateOnly, event("ArrowUp", {
altKey: true,
getModifierState: (name: string) => name === "CapsLock",
})),
"\u001b[1;67A",
);
assert.equal(
encodeKittyKeyEvent(alternateOnly, event("Insert", {
shiftKey: true,
getModifierState: (name: string) => name === "NumLock",
})),
"\u001b[2;130~",
);
assert.equal(
encodeKittyKeyEvent(alternateOnly, event("Tab", {
altKey: true,
getModifierState: (name: string) => name === "CapsLock",
})),
"\u001b\t",
);
for (const [key, expected] of [
["Enter", "\u001b\r"],
["Backspace", "\u001b\u007f"],
["Escape", "\u001b\u001b"],
] as const) {
assert.equal(
encodeKittyKeyEvent(alternateOnly, event(key, {
altKey: true,
getModifierState: (name: string) => name === "CapsLock",
})),
expected,
);
}
for (const flags of [2, 4, 16]) {
for (const [lock, modifier] of [
["CapsLock", 69],
["NumLock", 133],
] as const) {
assert.equal(
encodeKittyKeyEvent(withFlags(flags), event("c", {
code: "KeyC",
ctrlKey: true,
getModifierState: (name: string) => name === lock,
})),
`\u001b[99;${modifier}u`,
);
}
}
assert.equal(
encodeKittyKeyEvent(alternateOnly, event("a", {
code: "KeyA",
altKey: true,
getModifierState: (name: string) => name === "CapsLock",
})),
"\u001b[97;67u",
);
assert.equal(
encodeKittyKeyEvent(alternateOnly, event(";", {
code: "Semicolon",
ctrlKey: true,
getModifierState: (name: string) => name === "CapsLock",
})),
"\u001b[59;69u",
);
assert.equal(
encodeKittyKeyEvent(alternateOnly, event(" ", {
code: "Space",
ctrlKey: true,
getModifierState: (name: string) => name === "CapsLock",
})),
"\u001b[32;69u",
);
});
test("legacy fallback preserves text, controls, and application cursor mode", () => {
assert.equal(encodeLegacyKeyboardEvent(event("a")), "a");
assert.equal(encodeLegacyKeyboardEvent(event("c", { ctrlKey: true })), "\x03");
assert.equal(
encodeLegacyKeyboardEvent(event("3", { code: "Digit3", ctrlKey: true })),
"\u001b",
);
assert.equal(
encodeLegacyKeyboardEvent(event("c", { ctrlKey: true, altKey: true })),
"\u001b\x03",
);
assert.equal(encodeLegacyKeyboardEvent(event("ArrowUp"), true), "\u001bOA");
assert.equal(encodeLegacyKeyboardEvent(event("ArrowUp", { ctrlKey: true }), true), "\u001b[1;5A");
assert.equal(encodeLegacyKeyboardEvent(event("F5")), "\u001b[15~");
for (const [key, code] of [
[";", "Semicolon"],
["'", "Quote"],
[",", "Comma"],
[".", "Period"],
["0", "Digit0"],
["1", "Digit1"],
["9", "Digit9"],
] as const) {
assert.equal(encodeLegacyKeyboardEvent(event(key, { code, ctrlKey: true })), key);
}
assert.equal(
encodeLegacyKeyboardEvent(event(";", { code: "Semicolon", ctrlKey: true, altKey: true })),
"\u001b;",
);
});
test("baseline and event-type press preserve unmapped legacy Ctrl ASCII", () => {
for (const flags of [0, 2, 4, 16]) {
assert.equal(
encodeKittyKeyEvent(withFlags(flags), event(";", { code: "Semicolon", ctrlKey: true })),
";",
);
}
assert.equal(
encodeKittyKeyEvent(withFlags(1), event(";", { code: "Semicolon", ctrlKey: true })),
"\u001b[59;5u",
);
for (const [key, code, expected] of [
["ж", "Semicolon", ";"],
["э", "Quote", "'"],
["б", "Comma", ","],
["ю", "Period", "."],
] as const) {
assert.equal(
encodeKittyKeyEvent(withFlags(0), event(key, { code, ctrlKey: true })),
expected,
);
assert.equal(
encodeKittyKeyEvent(withFlags(2), event(key, { code, ctrlKey: true })),
expected,
);
assert.equal(
encodeLegacyKeyboardEvent(event(key, { code, ctrlKey: true })),
expected,
);
}
assert.equal(
encodeKittyKeyEvent(withFlags(0), event("c", { code: "KeyJ", ctrlKey: true })),
"\x03",
);
assert.equal(
encodeKittyKeyEvent(withFlags(0), event("c", {
code: "KeyJ",
ctrlKey: true,
altKey: true,
})),
"\u001b\x03",
);
assert.equal(
encodeLegacyKeyboardEvent(event("c", { code: "KeyJ", ctrlKey: true })),
"\x03",
);
assert.equal(
encodeLegacyKeyboardEvent(event("3", { code: "Semicolon", ctrlKey: true })),
"\u001b",
);
assert.equal(
encodeKittyKeyEvent(withFlags(0), event("j", { code: "KeyC", ctrlKey: true })),
"\x0a",
);
assert.equal(
encodeKittyKeyEvent(withFlags(0), event("j", {
code: "KeyC",
ctrlKey: true,
altKey: true,
})),
"\u001b\x0a",
);
assert.equal(
encodeKittyKeyEvent(withFlags(0), event("a", {
code: "IntlBackslash",
keyCode: 226,
ctrlKey: true,
})),
"\x01",
);
assert.equal(
encodeKittyKeyEvent(withFlags(2), event("a", {
code: "IntlBackslash",
keyCode: 226,
ctrlKey: true,
altKey: true,
})),
"\u001b\x01",
);
assert.equal(
encodeKittyKeyEvent(withFlags(0), event("ж", {
code: "IntlBackslash",
keyCode: 226,
ctrlKey: true,
})),
"\u001b[1078;5u",
);
assert.equal(
encodeKittyKeyEvent(withFlags(2), event("ж", {
code: "IntlBackslash",
keyCode: 226,
ctrlKey: true,
altKey: true,
})),
"\u001b[1078;7u",
);
});
test("macOS Option text does not depend on the asynchronous layout map", () => {
assert.equal(shouldTreatKittyAltAsText({ key: "å", altKey: true }, true, false), true);
assert.equal(shouldTreatKittyAltAsText({ key: "a", altKey: true }, true, false), true);
assert.equal(shouldTreatKittyAltAsText({ key: "Dead", altKey: true }, true, false), true);
assert.equal(shouldTreatKittyAltAsText({ key: "ArrowLeft", altKey: true }, true, false), false);
assert.equal(shouldTreatKittyAltAsText({ key: "å", altKey: true }, true, true), false);
assert.equal(shouldTreatKittyAltAsText({ key: "Dead", altKey: true }, true, true), false);
assert.equal(shouldTreatKittyAltAsText({ key: "å", altKey: true }, false, false), false);
});
test("macOS Option-as-Meta encodes physical dead keys while text-producing Option defers", () => {
const state = withFlags(1 | 2);
const optionN = event("Dead", {
code: "KeyN",
altKey: true,
altKeyProducesText: false,
});
assert.equal(encodeKittyKeyEvent(state, optionN), "\u001b[110;3u");
assert.equal(
encodeKittyKeyEvent(state, { ...optionN, type: "keyup" }),
"\u001b[110;3:3u",
);
assert.equal(
encodeKittyKeyEvent(state, { ...optionN, altKeyProducesText: true }),
null,
);
assert.equal(
encodeKittyKeyEvent(state, {
...optionN,
ctrlKey: true,
getModifierState: (name: string) => name === "AltGraph",
}),
null,
);
});
test("baseline modes leave shifted AltGraph text to the browser input path", () => {
const shiftedAltGraph = event("€", {
code: "KeyE",
ctrlKey: true,
altKey: true,
shiftKey: true,
getModifierState: (name: string) => name === "AltGraph",
});
assert.equal(encodeKittyKeyEvent(withFlags(0), shiftedAltGraph), null);
assert.equal(encodeKittyKeyEvent(withFlags(4), shiftedAltGraph), null);
});
test("legacy broadcast pairing ignores Meta shortcuts that produce no terminal data", () => {
assert.equal(shouldExpectLegacyKeyboardData(event("a", { metaKey: true })), false);
assert.equal(shouldExpectLegacyKeyboardData(event("a")), true);
assert.equal(shouldExpectLegacyKeyboardData(event("Enter")), true);
});
test("reports press, repeat, and release only for eligible keys", () => {
const state = withFlags(2);
assert.equal(encodeKittyKeyEvent(state, event("Escape")), null);
for (const [overrides, modifier] of [
[{ shiftKey: true }, 2],
[{ altKey: true }, 3],
[{ ctrlKey: true }, 5],
[{ metaKey: true }, 9],
[{ getModifierState: (name: string) => name === "CapsLock" }, 65],
[{ getModifierState: (name: string) => name === "NumLock" }, 129],
] as const) {
assert.equal(
encodeKittyKeyEvent(state, event("Escape", overrides)),
`\u001b[27;${modifier}u`,
);
}
assert.equal(
encodeKittyKeyEvent(state, event(" ", {
code: "Space",
ctrlKey: true,
shiftKey: true,
})),
"\0",
);
assert.equal(
encodeKittyKeyEvent(state, event(" ", {
code: "Space",
ctrlKey: true,
shiftKey: true,
repeat: true,
})),
"\u001b[32;6:2u",
);
assert.equal(
encodeKittyKeyEvent(state, event(" ", {
type: "keyup",
code: "Space",
ctrlKey: true,
shiftKey: true,
})),
"\u001b[32;6:3u",
);
assert.equal(encodeKittyKeyEvent(state, event("ArrowUp")), "\u001b[A");
assert.equal(encodeKittyKeyEvent(state, event("ArrowUp", { repeat: true })), "\u001b[1;1:2A");
assert.equal(encodeKittyKeyEvent(state, event("ArrowUp", { type: "keyup" })), "\u001b[1;1:3A");
assert.equal(encodeKittyKeyEvent(state, event("a", { repeat: true })), null);
assert.equal(
encodeKittyKeyEvent(state, event("A", { code: "KeyA", shiftKey: true, repeat: true })),
null,
);
assert.equal(encodeKittyKeyEvent(state, event("a", { type: "keyup" })), "\u001b[97;1:3u");
assert.equal(
encodeKittyKeyEvent(state, event("A", { code: "KeyA", shiftKey: true, type: "keyup" })),
"\u001b[97;2:3u",
);
assert.equal(encodeKittyKeyEvent(state, event("Enter", { type: "keyup" })), null);
assert.equal(encodeKittyKeyEvent(state, event("c", { ctrlKey: true })), null);
assert.equal(
encodeKittyKeyEvent(state, event("c", { ctrlKey: true, repeat: true })),
"\u001b[99;5:2u",
);
assert.equal(
encodeKittyKeyEvent(state, event("c", { type: "keyup", ctrlKey: true })),
"\u001b[99;5:3u",
);
assert.equal(
encodeKittyKeyEvent(state, event(" ", { code: "Space", ctrlKey: true })),
null,
);
assert.equal(
encodeKittyKeyEvent(state, event(" ", { code: "Space", ctrlKey: true, repeat: true })),
"\u001b[32;5:2u",
);
assert.equal(
encodeKittyKeyEvent(state, event(" ", { code: "Space", ctrlKey: true, type: "keyup" })),
"\u001b[32;5:3u",
);
assert.equal(encodeKittyKeyEvent(state, event("Enter", { repeat: true })), null);
assert.equal(encodeKittyKeyEvent(state, event("Tab", { repeat: true })), null);
assert.equal(encodeKittyKeyEvent(state, event("Backspace", { repeat: true })), null);
assert.equal(shouldTrackKittyKeyRelease(state, event("c", { ctrlKey: true })), true);
assert.equal(shouldTrackKittyKeyRelease(state, event("Enter")), false);
});
test("event-type mode matches Kitty's printable-key release behavior", () => {
const state = withFlags(2);
assert.equal(encodeKittyKeyEvent(state, event("a")), null);
assert.equal(encodeKittyKeyEvent(state, event("a", { repeat: true })), null);
assert.equal(
encodeKittyKeyEvent(state, event("a", { type: "keyup" })),
"\u001b[97;1:3u",
);
assert.equal(
encodeKittyKeyEvent(state, event("A", { code: "KeyA", shiftKey: true, type: "keyup" })),
"\u001b[97;2:3u",
);
});
test("reports alternate shifted and PC-101 layout key values", () => {
const state = withFlags(1 | 4);
assert.equal(
encodeKittyKeyEvent(state, event("+", { code: "Equal", shiftKey: true, ctrlKey: true })),
"\u001b[61:43;6u",
);
assert.equal(
encodeKittyKeyEvent(state, event("с", { code: "KeyC", ctrlKey: true })),
"\u001b[1089::99;5u",
);
});
test("report-all encodes text, controls, modifiers, repeat, and release", () => {
const state = withFlags(8);
assert.equal(encodeKittyKeyEvent(state, event("a")), "\u001b[97u");
assert.equal(encodeKittyKeyEvent(state, event("Enter")), "\u001b[13u");
assert.equal(
encodeKittyKeyEvent(state, event("Shift", { code: "ShiftLeft", shiftKey: true })),
"\u001b[57441;2u",
);
setKittyKeyboardModeFlags(state, 2, 2);
assert.equal(encodeKittyKeyEvent(state, event("a", { repeat: true })), "\u001b[97;1:2u");
assert.equal(encodeKittyKeyEvent(state, event("a", { type: "keyup" })), "\u001b[97;1:3u");
assert.equal(encodeKittyKeyEvent(state, event("Enter", { type: "keyup" })), "\u001b[13;1:3u");
});
test("associated text supports multiple code points and pure composition text", () => {
const state = withFlags(8 | 16);
assert.equal(
encodeKittyKeyEvent(state, event("A", { code: "KeyA", shiftKey: true })),
"\u001b[97;2;65u",
);
assert.equal(encodeKittyKeyEvent(state, event("Enter")), "\u001b[13u");
assert.equal(encodeKittyKeyEvent(state, event("F13")), "\u001b[57376u");
assert.equal(
encodeKittyKeyEvent(state, event("7", { code: "Numpad7" })),
"\u001b[57406;;55u",
);
assert.equal(
encodeKittyKeyEvent(state, event("+", { code: "NumpadAdd" })),
"\u001b[57413;;43u",
);
assert.equal(encodeKittyKeyEvent(state, event("Dead", { code: "Quote" })), null);
assert.equal(
encodeKittyKeyEvent(state, event("é", { code: "KeyE" })),
"\u001b[101;;101:769u",
);
assert.equal(encodeKittyCompositionText(state, "你😀"), "\u001b[0;;20320:128512u");
assert.equal(encodeKittyCompositionText(state, "\n\u0085"), null);
});
test("report-all without associated text leaves composition text for literal fallback", () => {
assert.equal(encodeKittyCompositionText(withFlags(8), "你"), null);
assert.equal(encodeKittyCompositionText(withFlags(8), ""), null);
});
test("encodes the complete functional, keypad, media, and modifier ranges", () => {
const state = withFlags(8);
const cases = [
[event("F13"), "\u001b[57376u"],
[event("F35"), "\u001b[57398u"],
[event("7", { code: "Numpad7" }), "\u001b[57406u"],
[event("Home", { code: "Numpad7" }), "\u001b[57423u"],
[event("Delete", { code: "NumpadDecimal" }), "\u001b[57426u"],
[event("Clear", { code: "Numpad5" }), "\u001b[57427~"],
[event(",", { code: "NumpadComma" }), "\u001b[57416u"],
[event("MediaPlay"), "\u001b[57428u"],
[event("MediaRecord"), "\u001b[57437u"],
[event("AudioVolumeMute"), "\u001b[57440u"],
[event("ContextMenu"), "\u001b[57363u"],
[event("Control", { code: "ControlRight", ctrlKey: true }), "\u001b[57448;5u"],
[event("ISOLevel5Shift"), "\u001b[57454u"],
] as const;
for (const [input, expected] of cases) assert.equal(encodeKittyKeyEvent(state, input), expected);
});
test("uses official enhanced F-key encodings and covers every F-key code", () => {
const state = withFlags(8);
assert.equal(encodeKittyKeyEvent(state, event("F1")), "\u001b[P");
assert.equal(encodeKittyKeyEvent(state, event("F2")), "\u001b[Q");
assert.equal(encodeKittyKeyEvent(state, event("F3")), "\u001b[13~");
assert.equal(encodeKittyKeyEvent(state, event("F4")), "\u001b[S");
assert.equal(
encodeKittyKeyEvent(state, event("F3", { ctrlKey: true })),
"\u001b[13;5~",
);
const legacyTilde = [15, 17, 18, 19, 20, 21, 23, 24];
for (let number = 5; number <= 12; number += 1) {
assert.equal(encodeKittyKeyEvent(state, event(`F${number}`)), `\u001b[${legacyTilde[number - 5]}~`);
}
for (let number = 13; number <= 35; number += 1) {
assert.equal(encodeKittyKeyEvent(state, event(`F${number}`)), `\u001b[${57363 + number}u`);
}
});
test("uses the F3 tilde form for modified baseline events", () => {
const state = withFlags(0);
assert.equal(encodeKittyKeyEvent(state, event("F3", { ctrlKey: true })), "\u001b[13;5~");
assert.equal(encodeKittyKeyEvent(state, event("F3", { shiftKey: true })), "\u001b[13;2~");
assert.equal(encodeKittyKeyEvent(state, event("F3", { altKey: true })), "\u001b[13;3~");
});
test("uses application cursor mode only for unmodified cursor keys", () => {
const state = withFlags(8);
assert.equal(
encodeKittyKeyEvent(state, event("ArrowUp", { applicationCursorMode: true })),
"\u001b[A",
);
assert.equal(
encodeKittyKeyEvent(state, event("Home", { applicationCursorMode: true })),
"\u001b[H",
);
assert.equal(
encodeKittyKeyEvent(state, event("End", { applicationCursorMode: true })),
"\u001b[F",
);
assert.equal(
encodeKittyKeyEvent(state, event("ArrowUp", {
applicationCursorMode: true,
ctrlKey: true,
})),
"\u001b[1;5A",
);
});
test("recognizes xterm input-only text without treating paste as a key event", () => {
assert.equal(shouldMarkKittyTextInputEvent({ data: "😀", inputType: "insertText" }), true);
assert.equal(shouldMarkKittyTextInputEvent({ data: "hello", inputType: "insertFromPaste" }), false);
assert.equal(shouldMarkKittyTextInputEvent({ data: null, inputType: "insertText" }), false);
});
test("preserves negotiated state only for renderer hibernation", () => {
const store = createKittyKeyboardSessionStateStore();
const sessionOwner = {};
const initial = store.resolve(sessionOwner, false);
setKittyKeyboardModeFlags(initial, 31);
setKittyKeyboardAlternateScreenActive(initial, true);
pushKittyKeyboardModeFlags(initial, 8);
const awakened = store.resolve(sessionOwner, true);
assert.equal(awakened, initial);
assert.equal(awakened.alternateScreenActive, true);
assert.equal(awakened.mainFlags, 31);
assert.deepEqual(awakened.alternateStack, [0]);
const reconnected = store.resolve(sessionOwner, false);
assert.notEqual(reconnected, initial);
assert.equal(buildKittyKeyboardModeQueryResponse(reconnected), "\u001b[?0u");
assert.notEqual(store.resolve({}, true), reconnected);
setKittyKeyboardModeFlags(reconnected, 8);
store.reset(sessionOwner);
assert.equal(buildKittyKeyboardModeQueryResponse(reconnected), "\u001b[?0u");
});
test("includes lock modifiers and excludes associated control text", () => {
const state = withFlags(8 | 16);
assert.equal(
encodeKittyKeyEvent(state, event("a", {
getModifierState: (name: string) => name === "CapsLock" || name === "NumLock",
})),
"\u001b[97;193;97u",
);
const altGraph = withFlags(1);
assert.equal(
encodeKittyKeyEvent(altGraph, event("@", {
code: "KeyQ",
ctrlKey: true,
altKey: true,
unshiftedKey: "q",
getModifierState: (name: string) => name === "AltGraph",
})),
null,
);
assert.equal(
encodeKittyKeyEvent(altGraph, event("å", {
code: "KeyA",
altKey: true,
unshiftedKey: "a",
altKeyProducesText: true,
})),
null,
);
assert.equal(encodeKittyKeyEvent(state, event("c", { ctrlKey: true })), "\u001b[99;5u");
assert.equal(
encodeKittyKeyEvent(state, event("a", {
getModifierState: (name: string) => name === "Hyper" || name === "KittyMeta",
})),
"\u001b[97;49u",
);
assert.equal(encodeKittyKeyEvent(state, event("a", { metaKey: true })), "\u001b[97;9u");
});
test("baseline protocol excludes lock state from Ctrl+Shift disambiguation", () => {
assert.equal(
encodeKittyKeyEvent(withFlags(0), event("I", {
code: "KeyI",
ctrlKey: true,
shiftKey: true,
getModifierState: (name: string) => name === "CapsLock" || name === "NumLock",
})),
"\u001b[105;6u",
);
});
test("disambiguation leaves text-producing keypad keys on their text path", () => {
assert.equal(
encodeKittyKeyEvent(withFlags(1), event("7", { code: "Numpad7" })),
null,
);
assert.equal(
encodeKittyKeyEvent(withFlags(1), event("Home", { code: "Numpad7" })),
"\u001b[57423u",
);
assert.equal(
encodeKittyKeyEvent(withFlags(1), event("7", {
code: "Numpad7",
ctrlKey: true,
})),
"\u001b[57406;5u",
);
});
test("keypad begin preserves baseline and event-type forms", () => {
const state = withFlags(2);
assert.equal(encodeKittyKeyEvent(state, event("Clear", { code: "Numpad5" })), "\u001b[E");
assert.equal(
encodeKittyKeyEvent(state, event("Clear", { code: "Numpad5", repeat: true })),
"\u001b[1;1:2E",
);
assert.equal(
encodeKittyKeyEvent(state, event("Clear", { code: "Numpad5", type: "keyup" })),
"\u001b[1;1:3E",
);
});
test("CSI handlers negotiate, query, stack, and track alternate screen", () => {
const state = createKittyKeyboardModeState();
const fake = createFakeCsiParser();
const replies: string[] = [];
const disposable = installKittyKeyboardProtocolHandlers(fake.parser, state, (payload) => replies.push(payload));
fake.dispatch({ prefix: "=", final: "u" }, [31]);
fake.dispatch({ prefix: "?", final: "u" });
assert.equal(replies.at(-1), "\u001b[?31u");
assert.equal(fake.dispatchEsc({ final: "c" }), false);
fake.dispatch({ prefix: "?", final: "u" });
assert.equal(replies.at(-1), "\u001b[?0u");
fake.dispatch({ prefix: "=", final: "u" }, [31]);
fake.dispatch({ prefix: ">", final: "u" }, [1]);
fake.dispatch({ prefix: "<", final: "u" });
assert.equal(fake.dispatch({ prefix: "?", final: "h" }, [1049]), false);
fake.dispatch({ prefix: "=", final: "u" }, [8]);
assert.equal(fake.dispatch({ prefix: "?", final: "l" }, [[1049]]), false);
fake.dispatch({ prefix: "?", final: "u" });
assert.equal(replies.at(-1), "\u001b[?31u");
disposable.dispose();
assert.equal(fake.hasHandler({ prefix: "?", final: "u" }), false);
});
test("CSI parser helpers retain explicit opt-in policy", () => {
assert.equal(readKittyKeyboardCsiParam([], 0, 7), 7);
assert.equal(readKittyKeyboardCsiParam([[8, 9]], 0, 7), 8);
const fake = createFakeCsiParser();
const state = createKittyKeyboardModeState();
assert.equal(installKittyKeyboardProtocolHandlersIfEnabled(false, fake.parser, state, () => {}), undefined);
assert.equal(fake.hasHandler({ prefix: "?", final: "u" }), false);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,138 @@
import type { IDisposable } from "@xterm/xterm";
import {
buildKittyKeyboardModeQueryResponse,
popKittyKeyboardModeFlags,
pushKittyKeyboardModeFlags,
setKittyKeyboardAlternateScreenActive,
setKittyKeyboardModeFlags,
resetKittyKeyboardModeState,
type KittyKeyboardModeApplyMode,
type KittyKeyboardModeState,
} from "./kittyKeyboardProtocol";
export type KittyKeyboardCsiParams = readonly (number | number[])[];
type CsiHandlerId = {
prefix?: string;
intermediates?: string;
final: string;
};
type KittyKeyboardParser = {
registerCsiHandler: (
id: CsiHandlerId,
callback: (params: KittyKeyboardCsiParams) => boolean,
) => IDisposable;
registerEscHandler: (
id: { intermediates?: string; final: string },
callback: () => boolean,
) => IDisposable;
};
export const readKittyKeyboardCsiParam = (
params: KittyKeyboardCsiParams,
index: number,
fallback: number,
): number => {
const value = params[index];
if (Array.isArray(value)) return typeof value[0] === "number" ? value[0] : fallback;
return typeof value === "number" && value > 0 ? value : fallback;
};
const normalizeKittyKeyboardApplyMode = (mode: number): KittyKeyboardModeApplyMode => {
return mode === 2 || mode === 3 ? mode : 1;
};
const paramsIncludeAny = (
params: KittyKeyboardCsiParams,
targets: readonly number[],
): boolean => {
return params.some((param) => (
Array.isArray(param)
? param.some((value) => targets.includes(value))
: targets.includes(param)
));
};
export const installKittyKeyboardProtocolHandlers = (
parser: KittyKeyboardParser,
state: KittyKeyboardModeState,
writeReply: (payload: string) => void,
): IDisposable => {
const disposables = [
parser.registerCsiHandler(
{ prefix: "?", final: "u" },
() => {
writeReply(buildKittyKeyboardModeQueryResponse(state));
return true;
},
),
parser.registerEscHandler(
{ final: "c" },
() => {
resetKittyKeyboardModeState(state);
return false;
},
),
parser.registerCsiHandler(
{ prefix: "=", final: "u" },
(params) => {
const flags = readKittyKeyboardCsiParam(params, 0, 0);
const mode = normalizeKittyKeyboardApplyMode(readKittyKeyboardCsiParam(params, 1, 1));
setKittyKeyboardModeFlags(state, flags, mode);
return true;
},
),
parser.registerCsiHandler(
{ prefix: ">", final: "u" },
(params) => {
pushKittyKeyboardModeFlags(state, readKittyKeyboardCsiParam(params, 0, 0));
return true;
},
),
parser.registerCsiHandler(
{ prefix: "<", final: "u" },
(params) => {
popKittyKeyboardModeFlags(state, readKittyKeyboardCsiParam(params, 0, 1));
return true;
},
),
parser.registerCsiHandler(
{ prefix: "?", final: "h" },
(params) => {
if (paramsIncludeAny(params, [47, 1047, 1049])) {
setKittyKeyboardAlternateScreenActive(state, true);
}
return false;
},
),
parser.registerCsiHandler(
{ prefix: "?", final: "l" },
(params) => {
if (paramsIncludeAny(params, [47, 1047, 1049])) {
setKittyKeyboardAlternateScreenActive(state, false);
}
return false;
},
),
];
return {
dispose: () => {
for (const disposable of disposables) {
disposable.dispose();
}
},
};
};
export const installKittyKeyboardProtocolHandlersIfEnabled = (
enabled: boolean | undefined,
parser: KittyKeyboardParser,
state: KittyKeyboardModeState,
writeReply: (payload: string) => void,
): IDisposable | undefined => {
if (enabled !== true) return undefined;
return installKittyKeyboardProtocolHandlers(parser, state, writeReply);
};

View File

@@ -0,0 +1,480 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
createRightClickMouseTrackingPressClaim,
isMiddleClickContextMenuEvent,
isShiftSelectionReplayMouseEvent,
markMiddleClickContextMenuEvent,
markShiftSelectionReplayMouseEvent,
captureMiddleClickTerminalMouseEvent,
resolveMiddleClickBehavior,
shouldInterceptMouseTrackingContextMenu,
shouldReplayShiftMouseSelectionAsMacOption,
shouldStopRightClickMouseTrackingMouseUp,
shouldStopShiftRightClickMouseTrackingMouseDown,
} from "./middleClickBehavior";
test("resolveMiddleClickBehavior uses the explicit middle-click behavior", () => {
assert.equal(resolveMiddleClickBehavior({ middleClickBehavior: "context-menu" }), "context-menu");
assert.equal(resolveMiddleClickBehavior({ middleClickBehavior: "disabled" }), "disabled");
});
test("resolveMiddleClickBehavior ignores unsupported middle-click behavior values", () => {
assert.equal(
resolveMiddleClickBehavior({ middleClickBehavior: "select-word" as never }),
"paste",
);
});
test("resolveMiddleClickBehavior falls back to the legacy middle-click paste flag", () => {
assert.equal(resolveMiddleClickBehavior({ middleClickPaste: true }), "paste");
assert.equal(resolveMiddleClickBehavior({ middleClickPaste: false }), "disabled");
assert.equal(resolveMiddleClickBehavior(undefined), "paste");
});
test("middle-click context menu events are identifiable", () => {
const event = {} as MouseEvent;
assert.equal(isMiddleClickContextMenuEvent(event), false);
assert.equal(isMiddleClickContextMenuEvent(markMiddleClickContextMenuEvent(event)), true);
});
test("mouse-tracking context menu capture lets middle-click menu events pass through", () => {
assert.equal(
shouldInterceptMouseTrackingContextMenu({
event: markMiddleClickContextMenuEvent({} as MouseEvent),
mouseTracking: true,
status: "connected",
}),
false,
);
assert.equal(
shouldInterceptMouseTrackingContextMenu({
event: {} as MouseEvent,
mouseTracking: true,
status: "connected",
}),
true,
);
});
test("mouse-tracking context menu capture lets Shift-modified mouse events pass through", () => {
assert.equal(
shouldInterceptMouseTrackingContextMenu({
event: { shiftKey: true } as MouseEvent,
mouseTracking: true,
status: "connected",
}),
false,
);
});
test("mouse-tracking context menu capture prefers the terminal's current mode over stale cached state", () => {
assert.equal(
shouldInterceptMouseTrackingContextMenu({
event: { shiftKey: false } as MouseEvent,
mouseTracking: false,
terminalMouseTrackingMode: "vt200",
status: "connected",
}),
true,
);
assert.equal(
shouldInterceptMouseTrackingContextMenu({
event: { shiftKey: false } as MouseEvent,
mouseTracking: true,
terminalMouseTrackingMode: "none",
status: "connected",
}),
false,
);
});
test("mouse-tracking context menu capture yields to the fullscreen-apps menu setting for context-menu clicks", () => {
// Setting on + context-menu behavior: do NOT intercept, so Radix opens the menu.
assert.equal(
shouldInterceptMouseTrackingContextMenu({
event: { shiftKey: false } as MouseEvent,
mouseTracking: true,
status: "connected",
rightClickBehavior: "context-menu",
forceMenuInAlternateScreen: true,
}),
false,
);
// Setting on but paste behavior: still intercept (setting is menu-only).
assert.equal(
shouldInterceptMouseTrackingContextMenu({
event: { shiftKey: false } as MouseEvent,
mouseTracking: true,
status: "connected",
rightClickBehavior: "paste",
forceMenuInAlternateScreen: true,
}),
true,
);
// Setting off (default): still intercept even for context-menu behavior.
assert.equal(
shouldInterceptMouseTrackingContextMenu({
event: { shiftKey: false } as MouseEvent,
mouseTracking: true,
status: "connected",
rightClickBehavior: "context-menu",
forceMenuInAlternateScreen: false,
}),
true,
);
});
test("Shift selection replay events are identifiable", () => {
const event = {} as MouseEvent;
assert.equal(isShiftSelectionReplayMouseEvent(event), false);
assert.equal(isShiftSelectionReplayMouseEvent(markShiftSelectionReplayMouseEvent(event)), true);
});
test("macOS mouse tracking replays plain Shift left-click as xterm option selection", () => {
const event = {
button: 0,
shiftKey: true,
altKey: false,
ctrlKey: false,
metaKey: false,
} as MouseEvent;
assert.equal(
shouldReplayShiftMouseSelectionAsMacOption({
event,
mouseTracking: true,
status: "connected",
isMacPlatform: true,
}),
true,
);
});
test("Shift selection replay is limited to the macOS connected mouse-tracking case", () => {
const baseEvent = {
button: 0,
shiftKey: true,
altKey: false,
ctrlKey: false,
metaKey: false,
} as MouseEvent;
assert.equal(
shouldReplayShiftMouseSelectionAsMacOption({
event: baseEvent,
mouseTracking: true,
status: "connected",
isMacPlatform: false,
}),
false,
);
assert.equal(
shouldReplayShiftMouseSelectionAsMacOption({
event: baseEvent,
mouseTracking: false,
status: "connected",
isMacPlatform: true,
}),
false,
);
assert.equal(
shouldReplayShiftMouseSelectionAsMacOption({
event: baseEvent,
mouseTracking: true,
status: "disconnected",
isMacPlatform: true,
}),
false,
);
assert.equal(
shouldReplayShiftMouseSelectionAsMacOption({
event: { ...baseEvent, button: 2 } as MouseEvent,
mouseTracking: true,
status: "connected",
isMacPlatform: true,
}),
false,
);
assert.equal(
shouldReplayShiftMouseSelectionAsMacOption({
event: { ...baseEvent, shiftKey: false } as MouseEvent,
mouseTracking: true,
status: "connected",
isMacPlatform: true,
}),
false,
);
});
test("Shift selection replay ignores modified and already replayed mouse events", () => {
const baseEvent = {
button: 0,
shiftKey: true,
altKey: false,
ctrlKey: false,
metaKey: false,
} as MouseEvent;
for (const event of [
{ ...baseEvent, altKey: true },
{ ...baseEvent, ctrlKey: true },
{ ...baseEvent, metaKey: true },
markShiftSelectionReplayMouseEvent({ ...baseEvent } as MouseEvent),
]) {
assert.equal(
shouldReplayShiftMouseSelectionAsMacOption({
event: event as MouseEvent,
mouseTracking: true,
status: "connected",
isMacPlatform: true,
}),
false,
);
}
});
test("Shift right-click mousedown is stopped while connected mouse tracking is active", () => {
assert.equal(
shouldStopShiftRightClickMouseTrackingMouseDown({
event: {
button: 2,
shiftKey: true,
} as MouseEvent,
mouseTracking: true,
status: "connected",
}),
true,
);
});
test("right-click mousedown is stopped when the fullscreen-apps menu setting forces the context menu", () => {
// Unmodified right-click + setting on + context-menu behavior: stop it, like Shift+right-click.
assert.equal(
shouldStopShiftRightClickMouseTrackingMouseDown({
event: { button: 2, shiftKey: false } as MouseEvent,
mouseTracking: true,
status: "connected",
rightClickBehavior: "context-menu",
forceMenuInAlternateScreen: true,
}),
true,
);
// Setting on but paste behavior: do not stop (menu-only setting).
assert.equal(
shouldStopShiftRightClickMouseTrackingMouseDown({
event: { button: 2, shiftKey: false } as MouseEvent,
mouseTracking: true,
status: "connected",
rightClickBehavior: "paste",
forceMenuInAlternateScreen: true,
}),
false,
);
});
test("right-click mousedown also uses the terminal's current mode", () => {
assert.equal(
shouldStopShiftRightClickMouseTrackingMouseDown({
event: { button: 2, shiftKey: false } as MouseEvent,
mouseTracking: false,
terminalMouseTrackingMode: "vt200",
status: "connected",
rightClickBehavior: "context-menu",
forceMenuInAlternateScreen: true,
}),
true,
);
assert.equal(
shouldStopShiftRightClickMouseTrackingMouseDown({
event: { button: 2, shiftKey: false } as MouseEvent,
mouseTracking: true,
terminalMouseTrackingMode: "none",
status: "connected",
rightClickBehavior: "context-menu",
forceMenuInAlternateScreen: true,
}),
false,
);
});
test("Shift right-click mousedown capture is limited to connected mouse tracking", () => {
const baseEvent = {
button: 2,
shiftKey: true,
} as MouseEvent;
assert.equal(
shouldStopShiftRightClickMouseTrackingMouseDown({
event: baseEvent,
mouseTracking: false,
status: "connected",
}),
false,
);
assert.equal(
shouldStopShiftRightClickMouseTrackingMouseDown({
event: baseEvent,
mouseTracking: true,
status: "disconnected",
}),
false,
);
assert.equal(
shouldStopShiftRightClickMouseTrackingMouseDown({
event: {
button: 2,
shiftKey: false,
} as MouseEvent,
mouseTracking: true,
status: "connected",
}),
false,
);
assert.equal(
shouldStopShiftRightClickMouseTrackingMouseDown({
event: {
button: 0,
shiftKey: true,
} as MouseEvent,
mouseTracking: true,
status: "connected",
}),
false,
);
});
test("right-click mouseup reaches mouse-tracking apps when Netcatty did not claim the press", () => {
// Herdr / Terminal.app: button-down was delivered, so button-up must be too.
// Swallowing mouseup leaves the TUI stuck thinking the right button is held (#2721).
const claim = createRightClickMouseTrackingPressClaim();
assert.equal(
claim.noteMouseDown({
event: { button: 2, shiftKey: false } as MouseEvent,
mouseTracking: true,
status: "connected",
rightClickBehavior: "context-menu",
forceMenuInAlternateScreen: false,
}),
false,
);
assert.equal(
shouldStopRightClickMouseTrackingMouseUp({
event: { button: 2, shiftKey: false } as MouseEvent,
claimedMatchingMouseDown: claim.consumeMouseUpClaim({ button: 2 } as MouseEvent),
}),
false,
);
});
test("right-click mouseup is stopped only when Netcatty claimed the matching mousedown", () => {
const shiftClaim = createRightClickMouseTrackingPressClaim();
assert.equal(
shiftClaim.noteMouseDown({
event: { button: 2, shiftKey: true } as MouseEvent,
mouseTracking: true,
status: "connected",
}),
true,
);
assert.equal(
shouldStopRightClickMouseTrackingMouseUp({
event: { button: 2, shiftKey: true } as MouseEvent,
claimedMatchingMouseDown: shiftClaim.consumeMouseUpClaim({ button: 2 } as MouseEvent),
}),
true,
);
const forceMenuClaim = createRightClickMouseTrackingPressClaim();
assert.equal(
forceMenuClaim.noteMouseDown({
event: { button: 2, shiftKey: false } as MouseEvent,
mouseTracking: true,
status: "connected",
rightClickBehavior: "context-menu",
forceMenuInAlternateScreen: true,
}),
true,
);
assert.equal(
shouldStopRightClickMouseTrackingMouseUp({
event: { button: 2, shiftKey: false } as MouseEvent,
claimedMatchingMouseDown: forceMenuClaim.consumeMouseUpClaim({ button: 2 } as MouseEvent),
}),
true,
);
assert.equal(
shouldStopRightClickMouseTrackingMouseUp({
event: { button: 0, shiftKey: false } as MouseEvent,
claimedMatchingMouseDown: false,
}),
false,
);
});
test("right-click mouseup pairs with the claimed mousedown, not the release modifiers", () => {
// Shift+right press claimed by Netcatty, then Shift released before mouseup:
// still swallow the release so xterm never sees a lone button-up.
const claimedThenShiftReleased = createRightClickMouseTrackingPressClaim();
assert.equal(
claimedThenShiftReleased.noteMouseDown({
event: { button: 2, shiftKey: true } as MouseEvent,
mouseTracking: true,
status: "connected",
}),
true,
);
assert.equal(
shouldStopRightClickMouseTrackingMouseUp({
event: { button: 2, shiftKey: false } as MouseEvent,
claimedMatchingMouseDown: claimedThenShiftReleased.consumeMouseUpClaim(
{ button: 2, shiftKey: false } as MouseEvent,
),
}),
true,
);
// App-owned press, then Shift held on release: release must still reach xterm.
const appOwnedThenShiftAdded = createRightClickMouseTrackingPressClaim();
assert.equal(
appOwnedThenShiftAdded.noteMouseDown({
event: { button: 2, shiftKey: false } as MouseEvent,
mouseTracking: true,
status: "connected",
}),
false,
);
assert.equal(
shouldStopRightClickMouseTrackingMouseUp({
event: { button: 2, shiftKey: true } as MouseEvent,
claimedMatchingMouseDown: appOwnedThenShiftAdded.consumeMouseUpClaim(
{ button: 2, shiftKey: true } as MouseEvent,
),
}),
false,
);
});
test("middle-click terminal mouse down/up events are captured before xterm sees them", () => {
const calls: string[] = [];
const middleClickEvent = {
button: 1,
preventDefault: () => calls.push("preventDefault"),
stopImmediatePropagation: () => calls.push("stopImmediatePropagation"),
} as unknown as MouseEvent;
assert.equal(captureMiddleClickTerminalMouseEvent(middleClickEvent), true);
assert.deepEqual(calls, ["preventDefault", "stopImmediatePropagation"]);
calls.length = 0;
assert.equal(captureMiddleClickTerminalMouseEvent({
button: 0,
preventDefault: () => calls.push("preventDefault"),
stopImmediatePropagation: () => calls.push("stopImmediatePropagation"),
} as unknown as MouseEvent), false);
assert.deepEqual(calls, []);
});

View File

@@ -0,0 +1,215 @@
import type { MiddleClickBehavior, RightClickBehavior, TerminalSettings } from "../../../domain/models";
type MiddleClickSettings = Partial<Pick<TerminalSettings, "middleClickBehavior" | "middleClickPaste">>;
const MIDDLE_CONTEXT_MENU_EVENT_KEY = "__netcattyMiddleContextMenu";
type MiddleClickContextMenuEvent = MouseEvent & {
[MIDDLE_CONTEXT_MENU_EVENT_KEY]?: boolean;
};
const SHIFT_SELECTION_REPLAY_EVENT_KEY = "__netcattyShiftSelectionReplay";
type ShiftSelectionReplayMouseEvent = MouseEvent & {
[SHIFT_SELECTION_REPLAY_EVENT_KEY]?: boolean;
};
export interface MouseTrackingContextMenuCaptureState {
event: MouseEvent;
mouseTracking: boolean;
/** Current xterm mouse tracking mode, when available at event time. */
terminalMouseTrackingMode?: string;
status?: string | null;
/** The user's configured right-click action. */
rightClickBehavior?: RightClickBehavior;
/** When true, show the app context menu over fullscreen apps (tmux/vim). */
forceMenuInAlternateScreen?: boolean;
}
export interface ShiftMouseSelectionReplayState {
event: MouseEvent;
mouseTracking: boolean;
status?: string | null;
isMacPlatform: boolean;
}
export interface ShiftRightClickMouseDownCaptureState {
event: MouseEvent;
mouseTracking: boolean;
/** Current xterm mouse tracking mode, when available at event time. */
terminalMouseTrackingMode?: string;
status?: string | null;
/** The user's configured right-click action. */
rightClickBehavior?: RightClickBehavior;
/** When true, show the app context menu over fullscreen apps (tmux/vim). */
forceMenuInAlternateScreen?: boolean;
}
export const resolveMiddleClickBehavior = (
settings?: MiddleClickSettings | null,
): MiddleClickBehavior => {
const behavior = settings?.middleClickBehavior;
if (
behavior === "context-menu" ||
behavior === "paste" ||
behavior === "disabled"
) {
return behavior;
}
return settings?.middleClickPaste === false ? "disabled" : "paste";
};
export const markMiddleClickContextMenuEvent = (event: MouseEvent): MouseEvent => {
Object.defineProperty(event, MIDDLE_CONTEXT_MENU_EVENT_KEY, {
value: true,
configurable: true,
});
return event;
};
export const isMiddleClickContextMenuEvent = (event: MouseEvent): boolean =>
(event as MiddleClickContextMenuEvent)[MIDDLE_CONTEXT_MENU_EVENT_KEY] === true;
export const markShiftSelectionReplayMouseEvent = (event: MouseEvent): MouseEvent => {
Object.defineProperty(event, SHIFT_SELECTION_REPLAY_EVENT_KEY, {
value: true,
configurable: true,
});
return event;
};
export const isShiftSelectionReplayMouseEvent = (event: MouseEvent): boolean =>
(event as ShiftSelectionReplayMouseEvent)[SHIFT_SELECTION_REPLAY_EVENT_KEY] === true;
// When the "show context menu over fullscreen apps" setting is on and the
// right-click action is the context menu, an unmodified right-click should
// behave like Shift+right-click: let the contextmenu event through so Radix
// opens the app menu, and stop the button press from reaching the TUI. Paste /
// select-word actions are unaffected — the setting is about the menu only.
const forcesMenuOverMouseTracking = ({
rightClickBehavior,
forceMenuInAlternateScreen,
}: {
rightClickBehavior?: RightClickBehavior;
forceMenuInAlternateScreen?: boolean;
}): boolean => Boolean(forceMenuInAlternateScreen && rightClickBehavior === "context-menu");
export const isMouseTrackingActive = ({
mouseTracking,
terminalMouseTrackingMode,
}: Pick<MouseTrackingContextMenuCaptureState, "mouseTracking" | "terminalMouseTrackingMode">): boolean =>
terminalMouseTrackingMode === undefined
? mouseTracking
: terminalMouseTrackingMode !== "none";
export const shouldInterceptMouseTrackingContextMenu = ({
event,
mouseTracking,
terminalMouseTrackingMode,
status,
rightClickBehavior,
forceMenuInAlternateScreen,
}: MouseTrackingContextMenuCaptureState): boolean =>
isMouseTrackingActive({ mouseTracking, terminalMouseTrackingMode })
&& status === "connected"
&& !event.shiftKey
&& !isMiddleClickContextMenuEvent(event)
&& !forcesMenuOverMouseTracking({ rightClickBehavior, forceMenuInAlternateScreen });
export const shouldReplayShiftMouseSelectionAsMacOption = ({
event,
mouseTracking,
status,
isMacPlatform,
}: ShiftMouseSelectionReplayState): boolean =>
isMacPlatform
&& mouseTracking
&& status === "connected"
&& event.button === 0
&& event.shiftKey
&& !event.altKey
&& !event.ctrlKey
&& !event.metaKey
&& !isShiftSelectionReplayMouseEvent(event);
export const shouldStopShiftRightClickMouseTrackingMouseDown = ({
event,
mouseTracking,
terminalMouseTrackingMode,
status,
rightClickBehavior,
forceMenuInAlternateScreen,
}: ShiftRightClickMouseDownCaptureState): boolean =>
isMouseTrackingActive({ mouseTracking, terminalMouseTrackingMode })
&& status === "connected"
&& event.button === 2
&& (event.shiftKey || forcesMenuOverMouseTracking({ rightClickBehavior, forceMenuInAlternateScreen }));
// Pair mouseup with mousedown ownership. When Netcatty claims the press
// (Shift / fullscreen-apps menu), also swallow the release so xterm never
// reports a lone button-up. When the TUI owns the press (Herdr, tmux menus,
// vim, ...), the release must reach xterm too - otherwise the app stays stuck
// with the right button held and mouse UI dies until restart (#2721).
// Ownership is remembered from the actual mousedown claim — never re-derived
// from mouseup modifiers (Shift may change between press and release).
export interface RightClickMouseTrackingPressClaim {
/** Evaluate + record whether this right-button mousedown was claimed. */
noteMouseDown: (state: ShiftRightClickMouseDownCaptureState) => boolean;
/** Consume the pending claim for a right-button mouseup (clears state). */
consumeMouseUpClaim: (event: MouseEvent) => boolean;
}
export const createRightClickMouseTrackingPressClaim = (): RightClickMouseTrackingPressClaim => {
let claimedMatchingMouseDown = false;
return {
noteMouseDown(state) {
const shouldStop = shouldStopShiftRightClickMouseTrackingMouseDown(state);
if (state.event.button === 2) {
claimedMatchingMouseDown = shouldStop;
}
return shouldStop;
},
consumeMouseUpClaim(event) {
if (event.button !== 2) return false;
const claimed = claimedMatchingMouseDown;
claimedMatchingMouseDown = false;
return claimed;
},
};
};
export const shouldStopRightClickMouseTrackingMouseUp = ({
event,
claimedMatchingMouseDown,
}: {
event: MouseEvent;
claimedMatchingMouseDown: boolean;
}): boolean => event.button === 2 && claimedMatchingMouseDown;
export const createMacOptionForcedSelectionMouseEvent = (event: MouseEvent): MouseEvent =>
markShiftSelectionReplayMouseEvent(new MouseEvent(event.type, {
bubbles: event.bubbles,
cancelable: event.cancelable,
composed: event.composed,
detail: event.detail,
view: event.view,
screenX: event.screenX,
screenY: event.screenY,
clientX: event.clientX,
clientY: event.clientY,
ctrlKey: event.ctrlKey,
altKey: true,
shiftKey: false,
metaKey: event.metaKey,
button: event.button,
buttons: event.buttons,
relatedTarget: event.relatedTarget,
}));
export const captureMiddleClickTerminalMouseEvent = (event: MouseEvent): boolean => {
if (event.button !== 1) return false;
event.preventDefault();
event.stopImmediatePropagation();
return true;
};

View File

@@ -0,0 +1,51 @@
import test from "node:test";
import assert from "node:assert/strict";
import { optionArrowWordJumpSequence } from "./optionArrowWordJump";
// Discussion #826: on macOS, Option+←/→ defaults to xterm's ^[[1;3D / ^[[1;3C,
// which most shells don't bind. When enabled, remap them to Meta-b / Meta-f so
// readline/zle does backward-word / forward-word out of the box (Termius-style).
// Gated to macOS so the syncable setting can't rewrite Alt+←/→ on other platforms.
const ev = (over: Partial<Parameters<typeof optionArrowWordJumpSequence>[0]> = {}) => ({
key: "ArrowLeft",
altKey: true,
ctrlKey: false,
metaKey: false,
shiftKey: false,
...over,
});
test("Option+Left → Meta-b (backward-word) when enabled on macOS", () => {
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowLeft" }), true, true), "\x1bb");
});
test("Option+Right → Meta-f (forward-word) when enabled on macOS", () => {
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowRight" }), true, true), "\x1bf");
});
test("not macOS → null (don't rewrite Alt+←/→ on Linux/Windows even if synced on)", () => {
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowLeft" }), true, false), null);
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowRight" }), true, false), null);
});
test("disabled → null (xterm default ^[[1;3D/C is kept)", () => {
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowLeft" }), false, true), null);
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowRight" }), false, true), null);
});
test("no Option held → null", () => {
assert.equal(optionArrowWordJumpSequence(ev({ altKey: false }), true, true), null);
});
test("extra modifiers with Option → null (don't hijack Shift/Ctrl/Cmd combos)", () => {
assert.equal(optionArrowWordJumpSequence(ev({ shiftKey: true }), true, true), null);
assert.equal(optionArrowWordJumpSequence(ev({ ctrlKey: true }), true, true), null);
assert.equal(optionArrowWordJumpSequence(ev({ metaKey: true }), true, true), null);
});
test("non-arrow keys → null", () => {
assert.equal(optionArrowWordJumpSequence(ev({ key: "ArrowUp" }), true, true), null);
assert.equal(optionArrowWordJumpSequence(ev({ key: "f" }), true, true), null);
});

View File

@@ -0,0 +1,33 @@
export interface OptionArrowKeyEvent {
key: string;
altKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
shiftKey: boolean;
}
/**
* macOS Option+←/→ word-jump (discussion #826).
*
* When enabled, maps a bare Option+Left/Right to the Meta-b / Meta-f sequence so
* readline/zle does backward-word / forward-word without per-host bindkey setup.
* Returns the bytes to send, or null when the mapping doesn't apply (disabled,
* non-macOS, not an arrow, or other modifiers held) — in which case xterm's
* default ^[[1;3D / ^[[1;3C is left untouched.
*
* Gated to macOS (`isMac`): the setting is syncable, so without the gate,
* enabling it on a Mac would also rewrite Alt+←/→ on synced Linux/Windows
* devices (discussion #826 review).
*/
export function optionArrowWordJumpSequence(
e: OptionArrowKeyEvent,
enabled: boolean,
isMac: boolean,
): string | null {
if (!enabled || !isMac) return null;
// Only a bare Option+Arrow — leave Shift/Ctrl/Cmd combos to xterm's defaults.
if (!e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return null;
if (e.key === "ArrowLeft") return "\x1bb"; // Meta-b → backward-word
if (e.key === "ArrowRight") return "\x1bf"; // Meta-f → forward-word
return null;
}

View File

@@ -0,0 +1,74 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { optionYankLastArgSequence } from "./optionYankLastArg";
// Issue #2364: Esc+. / Alt+. is readline yank-last-arg (zsh insert-last-word).
// Traditional terminals just pass Meta-. through. On macOS, Option+. types "≥"
// unless Option is Meta, so map the physical period / underscore keys to ESC+.
// / ESC+_ — same idea as Option+←/→ word-jump, but always-on because ≥ is
// almost never wanted in a terminal.
const ev = (over: Partial<Parameters<typeof optionYankLastArgSequence>[0]> = {}) => ({
key: ".",
code: "Period",
altKey: true,
ctrlKey: false,
metaKey: false,
shiftKey: false,
...over,
});
test("Option+. → ESC+. (yank-last-arg) on macOS", () => {
assert.equal(optionYankLastArgSequence(ev(), true), "\x1b.");
});
test("Option+. with composed ≥ (US layout) still maps to ESC+.", () => {
assert.equal(
optionYankLastArgSequence(ev({ key: "≥", code: "Period" }), true),
"\x1b.",
);
});
test("Option+_ → ESC+_ (yank-last-arg synonym) on macOS", () => {
assert.equal(
optionYankLastArgSequence(ev({ key: "_", code: "Minus", shiftKey: true }), true),
"\x1b_",
);
});
test("not macOS → null (Linux/Windows Alt+. already sends Meta via xterm)", () => {
assert.equal(optionYankLastArgSequence(ev(), false), null);
assert.equal(
optionYankLastArgSequence(ev({ key: "_", code: "Minus", shiftKey: true }), false),
null,
);
});
test("no Option held → null", () => {
assert.equal(optionYankLastArgSequence(ev({ altKey: false }), true), null);
});
test("Ctrl/Cmd with Option → null (don't hijack other chords)", () => {
assert.equal(optionYankLastArgSequence(ev({ ctrlKey: true }), true), null);
assert.equal(optionYankLastArgSequence(ev({ metaKey: true }), true), null);
});
test("Option+Shift+. (>) → null", () => {
assert.equal(optionYankLastArgSequence(ev({ shiftKey: true }), true), null);
});
test("other Option keys → null", () => {
assert.equal(optionYankLastArgSequence(ev({ key: "f", code: "KeyF" }), true), null);
assert.equal(optionYankLastArgSequence(ev({ key: "ArrowLeft", code: "ArrowLeft" }), true), null);
});
test("runtime sends Option+. after kitty mode, same as word-jump", () => {
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
assert.match(source, /from "\.\/optionYankLastArg"/);
assert.match(
source,
/optionArrowWordJumpSequence\([\s\S]*?const yankLastArgSequence = isKittyKeyboardModeActive\(kittyKeyboardMode\)\s*\? null\s*: optionYankLastArgSequence\(/s,
);
});

View File

@@ -0,0 +1,37 @@
export interface OptionYankLastArgKeyEvent {
key: string;
code?: string;
altKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
shiftKey: boolean;
}
const isPeriodKey = (e: OptionYankLastArgKeyEvent): boolean => (
e.code === "Period" || e.key === "." || e.key === "≥"
);
const isUnderscoreKey = (e: OptionYankLastArgKeyEvent): boolean => (
e.key === "_" || (e.code === "Minus" && e.shiftKey)
);
/**
* macOS Option+. / Option+_ → readline yank-last-arg (issue #2364).
*
* Ghostty/iTerm2/kitty/VS Code do not implement this themselves — they pass
* Meta-. through to bash/zsh. On macOS, Option+. types "≥" unless Option is
* Meta, so map those two physical keys to ESC+. / ESC+_ without turning every
* Option chord into Meta (unlike `altAsMeta`).
*
* Gated to macOS: Linux/Windows Alt+. already sends the ESC prefix via xterm.
*/
export function optionYankLastArgSequence(
e: OptionYankLastArgKeyEvent,
isMac: boolean,
): string | null {
if (!isMac) return null;
if (!e.altKey || e.ctrlKey || e.metaKey) return null;
if (isPeriodKey(e) && !e.shiftKey) return "\x1b.";
if (isUnderscoreKey(e)) return "\x1b_";
return null;
}

View File

@@ -0,0 +1,88 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createOutputFlowController } from "./outputFlowController.ts";
function make(high = 100, low = 30) {
const events: string[] = [];
const controller = createOutputFlowController({
highWaterMark: high,
lowWaterMark: low,
onPause: () => events.push("pause"),
onResume: () => events.push("resume"),
});
return { controller, events };
}
test("does not pause while below the high watermark", () => {
const { controller, events } = make(100, 30);
controller.received(50);
controller.received(49); // 99 < 100
assert.deepEqual(events, []);
assert.equal(controller.isPaused(), false);
});
test("pauses once when crossing the high watermark", () => {
const { controller, events } = make(100, 30);
controller.received(60);
controller.received(60); // 120 >= 100 -> pause
assert.deepEqual(events, ["pause"]);
assert.equal(controller.isPaused(), true);
// Further received while already paused must not re-fire pause.
controller.received(100);
assert.deepEqual(events, ["pause"]);
});
test("resumes once when draining to at/below the low watermark", () => {
const { controller, events } = make(100, 30);
controller.received(120); // pause
controller.written(50); // 70 still > 30, no resume
assert.deepEqual(events, ["pause"]);
controller.written(50); // 20 <= 30 -> resume
assert.deepEqual(events, ["pause", "resume"]);
assert.equal(controller.isPaused(), false);
});
test("does not resume when still above the low watermark", () => {
const { controller, events } = make(100, 30);
controller.received(120); // pause
controller.written(80); // 40 > 30
assert.deepEqual(events, ["pause"]);
assert.equal(controller.isPaused(), true);
});
test("never lets pending go negative", () => {
const { controller } = make(100, 30);
controller.received(10);
controller.written(50); // over-written
assert.equal(controller.pendingBytes(), 0);
});
test("supports repeated pause/resume cycles", () => {
const { controller, events } = make(100, 30);
controller.received(120); // pause
controller.written(120); // resume (0 <= 30)
controller.received(120); // pause again
controller.written(120); // resume again
assert.deepEqual(events, ["pause", "resume", "pause", "resume"]);
});
test("reset clears state and resumes when paused", () => {
const { controller, events } = make(100, 30);
controller.received(120); // pause
controller.reset();
assert.equal(controller.isPaused(), false);
assert.equal(controller.pendingBytes(), 0);
assert.deepEqual(events, ["pause", "resume"]);
controller.received(120);
assert.deepEqual(events, ["pause", "resume", "pause"]);
});
test("ignores non-positive amounts", () => {
const { controller, events } = make(100, 30);
controller.received(0);
controller.written(0);
controller.received(-5);
assert.equal(controller.pendingBytes(), 0);
assert.deepEqual(events, []);
});

View File

@@ -0,0 +1,74 @@
/**
* Watermark-based flow control for terminal output.
*
* SSH/PTY output has no back-pressure by default: the source streams as fast as
* it can, the main process forwards it over IPC, and the renderer queues every
* chunk into xterm. When output outpaces rendering (e.g. `cat` of a big file, a
* noisy build, `tail -f`, `yes`), the renderer-side backlog and xterm's internal
* buffer grow without bound — memory climbs and the whole UI, typing included,
* janks.
*
* This tracks bytes that have been received but not yet acknowledged by xterm's
* write callback. When the backlog crosses `highWaterMark` it asks the caller to
* pause the source; once it drains back to `lowWaterMark` it asks to resume. The
* hysteresis gap avoids rapid pause/resume flapping. During interactive use the
* backlog hovers near zero, so this never engages.
*/
export interface OutputFlowController {
/** Account bytes handed to xterm (call when a chunk is received). */
received(bytes: number): void;
/** Account bytes whose xterm write callback has fired. */
written(bytes: number): void;
/** Clear pending state; calls `onResume` if currently paused. */
reset(options?: { resume?: boolean }): void;
pendingBytes(): number;
isPaused(): boolean;
}
export interface OutputFlowControllerOptions {
highWaterMark: number;
lowWaterMark: number;
/** Asked to pause the source when the backlog crosses the high watermark. */
onPause: () => void;
/** Asked to resume the source when the backlog drains to the low watermark. */
onResume: () => void;
}
export function createOutputFlowController(
options: OutputFlowControllerOptions,
): OutputFlowController {
const { highWaterMark, lowWaterMark, onPause, onResume } = options;
let pending = 0;
let paused = false;
return {
received(bytes: number): void {
if (bytes <= 0) return;
pending += bytes;
if (!paused && pending >= highWaterMark) {
paused = true;
onPause();
}
},
written(bytes: number): void {
if (bytes <= 0) return;
pending -= bytes;
if (pending < 0) pending = 0;
if (paused && pending <= lowWaterMark) {
paused = false;
onResume();
}
},
reset(options?: { resume?: boolean }): void {
if (paused && options?.resume !== false) onResume();
pending = 0;
paused = false;
},
pendingBytes(): number {
return pending;
},
isPaused(): boolean {
return paused;
},
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,756 @@
import type { Terminal as XTerm } from "@xterm/xterm";
import type { RefObject } from "react";
import {
detectPrompt,
getAlignedPrompt,
isNonPromptLine,
reconcilePromptWithExternalCommand,
} from "../autocomplete/promptDetector";
export type PromptLineBreakState = {
lastPromptText: string;
pendingCommand: boolean;
suppressNextPromptCache: boolean;
pendingCommandCompletions: number;
};
type VisibleTextMap = {
text: string;
rawStartByTextIndex: number[];
rawIndexByTextIndex: number[];
};
const ESC = "\x1b";
const BEL = "\x07";
const isCsiFinalByte = (char: string): boolean => {
const code = char.charCodeAt(0);
return code >= 0x40 && code <= 0x7e;
};
const mapVisibleText = (data: string): VisibleTextMap => {
let text = "";
const rawStartByTextIndex: number[] = [];
const rawIndexByTextIndex: number[] = [];
let nextVisibleSegmentStart = 0;
const appendVisible = (index: number, char: string) => {
rawStartByTextIndex.push(nextVisibleSegmentStart);
rawIndexByTextIndex.push(index);
text += char;
nextVisibleSegmentStart = index + char.length;
};
for (let index = 0; index < data.length; index += 1) {
const char = data[index];
if (char !== ESC) {
appendVisible(index, char);
continue;
}
const nextChar = data[index + 1];
if (nextChar === "[") {
index += 2;
while (index < data.length && !isCsiFinalByte(data[index])) {
index += 1;
}
continue;
}
if (nextChar === "]") {
index += 2;
while (index < data.length) {
if (data[index] === BEL) break;
if (data[index] === ESC && data[index + 1] === "\\") {
index += 1;
break;
}
index += 1;
}
continue;
}
if (nextChar) {
index += 1;
}
}
return { text, rawStartByTextIndex, rawIndexByTextIndex };
};
const endsWithLineBreak = (text: string): boolean => {
const last = text[text.length - 1];
return last === "\n" || last === "\r";
};
type CsiSequence = {
body: string;
end: number;
final: string;
};
const readCsiSequence = (data: string, index: number): CsiSequence | null => {
const parameterStart = data[index] === ESC ? index + 2 : index + 1;
if (data[index] === ESC && data[index + 1] !== "[") return null;
for (let end = parameterStart; end < data.length; end += 1) {
if (!isCsiFinalByte(data[end])) continue;
return {
body: data.slice(parameterStart, end),
end,
final: data[end],
};
}
return null;
};
const readControlStringEnd = (data: string, start: number): number | null => {
for (let index = start; index < data.length; index += 1) {
if (data[index] === BEL) return index;
if (data[index] === ESC && data[index + 1] === "\\") return index + 1;
}
return null;
};
const parseCsiParams = (body: string): number[] => {
const parameterText = body.match(/^[0-9;:]*/)?.[0] ?? "";
if (!parameterText) return [];
return parameterText.split(";").map((part) => {
const value = Number.parseInt(part.split(":", 1)[0] ?? "", 10);
return Number.isFinite(value) ? value : 0;
});
};
const CURSOR_PREFIX_CSI_FINALS = new Set([
"@", "A", "B", "C", "D", "E", "F", "G", "H", "I", "L", "M",
"P", "S", "T", "X", "Z", "`", "a", "d", "e", "f", "r",
"s", "u",
]);
const CURSOR_AFFECTING_PRIVATE_MODES = new Set([3, 6, 47, 1047, 1048, 1049]);
const isCursorAffectingCsiSequence = (sequence: CsiSequence): boolean => {
if (CURSOR_PREFIX_CSI_FINALS.has(sequence.final)) return true;
if ((sequence.final !== "h" && sequence.final !== "l") || !sequence.body.startsWith("?")) {
return false;
}
return sequence.body.slice(1).split(";").some((part) => {
const mode = Number.parseInt(part.split(":", 1)[0] ?? "", 10);
return CURSOR_AFFECTING_PRIVATE_MODES.has(mode);
});
};
const advancePromptBreakPastLeadingCursorControls = (
data: string,
rawStart: number,
firstVisibleRawIndex: number,
): number => {
let breakIndex = rawStart;
for (let index = rawStart; index < firstVisibleRawIndex; index += 1) {
const char = data[index];
if (char === ESC || char === "\x9b") {
const isCsi = char === "\x9b" || data[index + 1] === "[";
if (isCsi) {
const sequence = readCsiSequence(data, index);
if (!sequence || sequence.end >= firstVisibleRawIndex) break;
if (isCursorAffectingCsiSequence(sequence)) {
breakIndex = sequence.end + 1;
}
index = sequence.end;
continue;
}
const next = data[index + 1];
if (next === "]" || next === "P" || next === "X" || next === "^" || next === "_") {
const end = readControlStringEnd(data, index + 2);
if (end === null || end >= firstVisibleRawIndex) break;
index = end;
continue;
}
if (["7", "8", "D", "E", "H", "M", "c"].includes(next)) {
breakIndex = index + 2;
}
if (next) index += 1;
}
}
return breakIndex;
};
type PromptPrefixMeasurement = {
column: number;
separated: boolean;
};
const measurePromptPrefixColumn = (
term: XTerm,
data: string,
startColumn: number,
convertEol: boolean,
): PromptPrefixMeasurement | null => {
const maxColumn = Number.isFinite(term.cols) && term.cols > 0
? term.cols - 1
: Number.MAX_SAFE_INTEGER;
const clampColumn = (value: number) => Math.max(0, Math.min(maxColumn, value));
const parameterCount = (params: readonly number[], index = 0) => Math.max(1, params[index] || 1);
let column = clampColumn(startColumn);
let columnKnown = true;
let newlineMode = convertEol;
let hasSavedColumn = false;
let savedColumn: number | null = null;
let lastPrintableWidth: number | null = null;
let separated = false;
for (let index = 0; index < data.length; index += 1) {
const char = data[index];
if (char === ESC || char === "\x9b") {
const isCsi = char === "\x9b" || data[index + 1] === "[";
if (isCsi) {
const sequence = readCsiSequence(data, index);
if (!sequence) return null;
const params = parseCsiParams(sequence.body);
const privateOrIntermediate = sequence.body.slice(
sequence.body.match(/^[0-9;:]*/)?.[0].length ?? 0,
);
const count = parameterCount(params);
switch (sequence.final) {
case "C":
case "a":
if (privateOrIntermediate) return null;
// CUF clamps at the margin in xterm; it does not wrap.
if (columnKnown) column = clampColumn(column + count);
break;
case "D":
if (privateOrIntermediate) return null;
if (columnKnown) column = clampColumn(column - count);
break;
case "G":
case "`":
if (privateOrIntermediate) return null;
column = clampColumn(count - 1);
columnKnown = true;
break;
case "H":
case "f":
if (privateOrIntermediate) return null;
column = clampColumn(parameterCount(params, 1) - 1);
columnKnown = true;
separated = true;
break;
case "E":
case "F":
if (privateOrIntermediate) return null;
column = 0;
columnKnown = true;
separated = true;
break;
case "I":
case "Z":
if (privateOrIntermediate) return null;
// HTS/TBC can replace the default 8-column stops. Without reading
// xterm's private tab map, the resulting column is unknown.
columnKnown = false;
break;
case "s":
if (privateOrIntermediate || params.length > 0) return null;
hasSavedColumn = true;
savedColumn = columnKnown ? column : null;
break;
case "u":
if (privateOrIntermediate || params.length > 0 || !hasSavedColumn) return null;
columnKnown = savedColumn !== null;
if (savedColumn !== null) column = savedColumn;
break;
case "b":
if (privateOrIntermediate || lastPrintableWidth === null) return null;
if (columnKnown) {
// REP repeats a printable; model simple line wrap like printables.
for (let rep = 0; rep < count; rep += 1) {
for (let width = 0; width < lastPrintableWidth; width += 1) {
if (column >= maxColumn) {
column = 0;
separated = true;
} else {
column += 1;
}
}
}
}
break;
case "r":
if (privateOrIntermediate) return null;
column = 0;
columnKnown = true;
separated = true;
break;
case "A":
case "B":
if (privateOrIntermediate) return null;
separated = true;
break;
case "J":
case "K":
case "P":
case "S":
case "T":
case "X":
case "@":
case "c":
case "m":
case "n":
case "q":
break;
case "d":
case "e":
if (privateOrIntermediate) return null;
separated = true;
break;
case "L":
case "M":
if (privateOrIntermediate) return null;
column = 0;
columnKnown = true;
break;
case "h":
case "l":
if (!privateOrIntermediate && params.includes(20)) {
newlineMode = sequence.final === "h";
}
break;
default:
columnKnown = false;
break;
}
index = sequence.end;
continue;
}
const next = data[index + 1];
if (next === "]" || next === "P" || next === "X" || next === "^" || next === "_") {
const end = readControlStringEnd(data, index + 2);
if (end === null) return null;
index = end;
continue;
}
if (next === "7") {
hasSavedColumn = true;
savedColumn = columnKnown ? column : null;
index += 1;
continue;
}
if (next === "8") {
if (!hasSavedColumn) return null;
columnKnown = savedColumn !== null;
if (savedColumn !== null) column = savedColumn;
index += 1;
continue;
}
if (next === "E" || next === "c") {
column = 0;
columnKnown = true;
separated = true;
index += 1;
continue;
}
if (next === "D" || next === "M" || next === "=" || next === ">" || next === "H") {
index += 1;
continue;
}
if (["(", ")", "*", "+", "-", ".", "/"].includes(next) && data[index + 2]) {
index += 2;
continue;
}
return null;
}
if (char === "\n" || char === "\v" || char === "\f") {
separated = true;
if (newlineMode) {
column = 0;
columnKnown = true;
}
continue;
}
if (char === "\r") {
column = 0;
columnKnown = true;
separated = true;
continue;
}
if (char === "\b") {
if (columnKnown) column = Math.max(0, column - 1);
continue;
}
if (char === "\t") {
columnKnown = false;
continue;
}
const code = char.charCodeAt(0);
if (code < 0x20 || code === 0x7f) {
if (code === 0 || code === 7 || code === 14 || code === 15) continue;
columnKnown = false;
continue;
}
if (code > 0x7e) {
columnKnown = false;
lastPrintableWidth = null;
continue;
}
lastPrintableWidth = 1;
if (columnKnown) {
if (column >= maxColumn) {
// Simple wrap: a full-width line leaves the cursor at column 0 of the
// next row. Clamping at cols-1 falsely looked mid-line and inserted an
// extra blank before the following prompt.
column = 0;
separated = true;
} else {
column = column + 1;
}
}
}
return columnKnown ? { column, separated } : null;
};
const endsAtKnownColumnZero = (
term: XTerm,
rawText: string,
visibleText: string,
cursorXBeforeWrite: number,
convertEol: boolean,
): boolean => {
const measured = measurePromptPrefixColumn(term, rawText, cursorXBeforeWrite, convertEol);
return measured?.column === 0 && (measured.separated || endsWithLineBreak(visibleText));
};
const containsLineReset = (text: string): boolean =>
text.includes("\n") || text.includes("\r");
const hasAmbiguousPromptSuffix = (data: string, promptText: string): boolean => {
const mapped = mapVisibleText(data);
if (!mapped.text.endsWith(promptText)) return false;
const promptTextStart = mapped.text.length - promptText.length;
const prefixText = mapped.text.slice(0, promptTextStart);
return prefixText.length > 0 && !endsWithLineBreak(prefixText);
};
const isDistinctPromptText = (promptText: string): boolean => {
const trimmed = promptText.trim();
if (trimmed.length >= 8) return true;
return trimmed.length >= 6 && /[@:\\/]/.test(trimmed);
};
const getCursorX = (term: XTerm): number => {
try {
return term.buffer.active.cursorX;
} catch {
return 0;
}
};
const getConvertEol = (term: XTerm): boolean => {
try {
return term.options.convertEol === true;
} catch {
return false;
}
};
export function createPromptLineBreakState(): PromptLineBreakState {
return {
lastPromptText: "",
pendingCommand: false,
suppressNextPromptCache: false,
pendingCommandCompletions: 0,
};
}
export function markTerminalCommandCompletionPending(
stateRef?: RefObject<PromptLineBreakState>,
): void {
if (!stateRef?.current) return;
stateRef.current.pendingCommandCompletions = Math.min(
64,
stateRef.current.pendingCommandCompletions + 1,
);
}
export function consumeTerminalCommandCompletion(
state: PromptLineBreakState | undefined,
): boolean {
if (!state || state.pendingCommandCompletions < 1) return false;
state.pendingCommandCompletions -= 1;
return true;
}
export function consumeOsc133CommandCompletion(
data: string,
state: PromptLineBreakState | undefined,
): boolean {
return data.split(";", 1)[0] === "D" && consumeTerminalCommandCompletion(state);
}
export function detectTerminalCommandCompletions(
term: XTerm,
state: PromptLineBreakState | undefined,
): number {
if (!state || state.pendingCommandCompletions < 1) return 0;
const prompt = detectPrompt(term);
if (!prompt.isAtPrompt || prompt.userInput.length > 0) return 0;
const completed = state.pendingCommandCompletions;
state.pendingCommandCompletions = 0;
return completed;
}
export function markPromptLineBreakCommandPending(
stateRef?: RefObject<PromptLineBreakState>,
term?: XTerm | null,
command?: string,
): void {
if (!stateRef?.current) return;
if (term) {
const cachedFromCommand = command
? cachePromptLineBreakPromptFromCommand(term, stateRef.current, command)
: false;
if (!cachedFromCommand) {
cachePromptLineBreakPrompt(term, stateRef.current);
}
}
stateRef.current.pendingCommand = true;
stateRef.current.suppressNextPromptCache = false;
}
function cachePromptLineBreakPromptFromCommand(
term: XTerm,
state: PromptLineBreakState | undefined,
command: string,
): boolean {
const trimmedCommand = command.trim();
if (!state || trimmedCommand.length === 0) return false;
const aligned = getAlignedPrompt(term, trimmedCommand, true);
if (!aligned.prompt.isAtPrompt) {
state.lastPromptText = "";
state.suppressNextPromptCache = false;
return false;
}
if (isNonPromptLine(`${aligned.prompt.promptText}${trimmedCommand}`)) {
state.lastPromptText = "";
state.suppressNextPromptCache = false;
return true;
}
const prompt =
aligned.alignedTyped === trimmedCommand
? aligned.prompt
: reconcilePromptWithExternalCommand(aligned.prompt, trimmedCommand);
if (!prompt) {
state.lastPromptText = "";
state.suppressNextPromptCache = false;
return false;
}
state.lastPromptText = prompt.promptText;
state.suppressNextPromptCache = false;
return true;
}
export function cachePromptLineBreakPrompt(
term: XTerm,
state: PromptLineBreakState | undefined,
): void {
if (!state) return;
const prompt = detectPrompt(term);
if (!prompt.isAtPrompt) return;
if (prompt.userInput.length > 0) return;
state.lastPromptText = prompt.promptText;
state.suppressNextPromptCache = false;
}
export function insertPromptLineBreakBeforePrompt(
data: string,
promptText: string,
cursorXBeforeWrite: number,
promptStartsAtSourceChunk = false,
): string {
if (!data || !promptText) return data;
const mapped = mapVisibleText(data);
if (!mapped.text.endsWith(promptText)) return data;
const promptTextStart = mapped.text.length - promptText.length;
const prefixText = mapped.text.slice(0, promptTextStart);
const promptRawStart = mapped.rawStartByTextIndex[promptTextStart] ?? 0;
if (prefixText.length === 0 && cursorXBeforeWrite <= 0) return data;
if (prefixText.length > 0) {
if (endsWithLineBreak(prefixText)) return data;
if (!isDistinctPromptText(promptText) && !promptStartsAtSourceChunk) return data;
}
return `${data.slice(0, promptRawStart)}\r\n${data.slice(promptRawStart)}`;
}
const lowerBoundRawIndex = (rawIndexes: readonly number[], target: number): number => {
let low = 0;
let high = rawIndexes.length;
while (low < high) {
const middle = low + Math.floor((high - low) / 2);
if (rawIndexes[middle] < target) {
low = middle + 1;
} else {
high = middle;
}
}
return low;
};
export function findTerminalPromptSourceChunkVisibleStarts(
data: string,
promptText: string,
sourceChunkBoundaries: readonly number[] = [],
): number[] {
if (!data || !promptText) return [];
const mapped = mapVisibleText(data);
const boundaries = [
0,
...sourceChunkBoundaries.filter(
(boundary, index) => (
boundary > 0
&& boundary < data.length
&& (index === 0 || boundary > sourceChunkBoundaries[index - 1])
),
),
data.length,
];
const promptVisibleStarts: number[] = [];
for (let index = 0; index < boundaries.length - 1; index += 1) {
const chunkVisibleStart = lowerBoundRawIndex(
mapped.rawIndexByTextIndex,
boundaries[index],
);
const chunkVisibleEnd = lowerBoundRawIndex(
mapped.rawIndexByTextIndex,
boundaries[index + 1],
);
if (chunkVisibleEnd <= chunkVisibleStart) continue;
const chunkText = mapped.text.slice(chunkVisibleStart, chunkVisibleEnd);
if (!chunkText.endsWith(promptText)) continue;
const promptVisibleStart = chunkVisibleEnd - promptText.length;
const chunkPrefix = mapped.text.slice(chunkVisibleStart, promptVisibleStart);
if (chunkPrefix.length > 0 && !isDistinctPromptText(promptText)) continue;
promptVisibleStarts.push(promptVisibleStart);
}
return promptVisibleStarts;
}
const insertPromptLineBreaksAtVisibleStarts = (
term: XTerm,
data: string,
promptText: string,
cursorXBeforeWrite: number,
promptVisibleStarts: readonly number[],
convertEol: boolean,
): string => {
const mapped = mapVisibleText(data);
const rawStarts = [...new Set(promptVisibleStarts)]
.sort((left, right) => left - right)
.flatMap((visibleStart) => {
if (mapped.text.slice(visibleStart, visibleStart + promptText.length) !== promptText) {
return [];
}
const leadingControlsRawStart = mapped.rawStartByTextIndex[visibleStart];
const firstVisibleRawIndex = mapped.rawIndexByTextIndex[visibleStart];
if (leadingControlsRawStart === undefined || firstVisibleRawIndex === undefined) return [];
const rawStart = advancePromptBreakPastLeadingCursorControls(
data,
leadingControlsRawStart,
firstVisibleRawIndex,
);
const prefixText = mapped.text.slice(0, visibleStart);
const lastColumnResetVisibleIndex = prefixText.lastIndexOf("\r");
const lastColumnResetRawIndex = lastColumnResetVisibleIndex >= 0
? mapped.rawIndexByTextIndex[lastColumnResetVisibleIndex]
: undefined;
const measuredRawStart = lastColumnResetRawIndex === undefined
? 0
: lastColumnResetRawIndex + 1;
const measuredRawText = data.slice(measuredRawStart, rawStart);
if (endsAtKnownColumnZero(
term,
measuredRawText,
prefixText,
lastColumnResetRawIndex === undefined ? cursorXBeforeWrite : 0,
convertEol,
)) return [];
if (
prefixText.length === 0
&& measuredRawText.length === 0
&& cursorXBeforeWrite <= 0
) return [];
return [rawStart];
});
if (rawStarts.length === 0) return data;
let result = "";
let lastRawIndex = 0;
for (const rawStart of rawStarts) {
result += `${data.slice(lastRawIndex, rawStart)}\r\n`;
lastRawIndex = rawStart;
}
return `${result}${data.slice(lastRawIndex)}`;
};
export function prepareTerminalDataForPromptLineBreak(
term: XTerm,
data: string,
state: PromptLineBreakState | undefined,
enabled: boolean,
promptVisibleStarts: readonly number[] = [],
): string {
if (!enabled || !state?.pendingCommand || !state.lastPromptText) return data;
const cursorXBeforeWrite = getCursorX(term);
const nextData = promptVisibleStarts.length > 0
? insertPromptLineBreaksAtVisibleStarts(
term,
data,
state.lastPromptText,
cursorXBeforeWrite,
promptVisibleStarts,
getConvertEol(term),
)
: insertPromptLineBreakBeforePrompt(
data,
state.lastPromptText,
cursorXBeforeWrite,
);
const visibleText = mapVisibleText(data).text;
const ambiguousPromptSuffix = hasAmbiguousPromptSuffix(data, state.lastPromptText);
state.suppressNextPromptCache =
nextData === data &&
(ambiguousPromptSuffix ||
(cursorXBeforeWrite > 0 && !containsLineReset(visibleText)));
return nextData;
}
export function syncPromptLineBreakState(term: XTerm, state?: PromptLineBreakState): void {
if (!state) return;
const prompt = detectPrompt(term);
if (!prompt.isAtPrompt || prompt.userInput.length > 0) return;
if (state.pendingCommand && state.suppressNextPromptCache) {
state.suppressNextPromptCache = false;
return;
}
state.lastPromptText = prompt.promptText;
state.suppressNextPromptCache = false;
state.pendingCommand = false;
}

View File

@@ -0,0 +1,158 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
type MediaQueryListLike,
watchDevicePixelRatio,
} from "./rendererDprWatch";
class FakeMediaQueryList implements MediaQueryListLike {
readonly query: string;
modernListeners: Array<() => void> = [];
legacyListeners: Array<() => void> = [];
private readonly supportsModern: boolean;
constructor(query: string, supportsModern = true) {
this.query = query;
this.supportsModern = supportsModern;
if (!supportsModern) {
// Strip the modern API to emulate legacy environments.
this.addEventListener = undefined;
this.removeEventListener = undefined;
}
}
addEventListener? = (_type: "change", listener: () => void) => {
this.modernListeners.push(listener);
};
removeEventListener? = (_type: "change", listener: () => void) => {
this.modernListeners = this.modernListeners.filter((l) => l !== listener);
};
addListener = (listener: () => void) => {
this.legacyListeners.push(listener);
};
removeListener = (listener: () => void) => {
this.legacyListeners = this.legacyListeners.filter((l) => l !== listener);
};
trigger() {
for (const l of [...this.modernListeners, ...this.legacyListeners]) l();
}
get listenerCount() {
return this.modernListeners.length + this.legacyListeners.length;
}
}
function makeEnv(initialDpr: number, supportsModern = true) {
let dpr = initialDpr;
const created: FakeMediaQueryList[] = [];
return {
created,
getDevicePixelRatio: () => dpr,
matchMedia: (query: string) => {
const mql = new FakeMediaQueryList(query, supportsModern);
created.push(mql);
return mql;
},
setDpr: (value: number) => {
dpr = value;
},
};
}
test("registers a change listener for the current devicePixelRatio", () => {
const env = makeEnv(1);
watchDevicePixelRatio({
getDevicePixelRatio: env.getDevicePixelRatio,
matchMedia: env.matchMedia,
onChange: () => {},
});
assert.equal(env.created.length, 1);
assert.equal(env.created[0].query, "(resolution: 1dppx)");
assert.equal(env.created[0].listenerCount, 1);
});
test("invokes onChange when the media query reports a change", () => {
const env = makeEnv(1);
let calls = 0;
watchDevicePixelRatio({
getDevicePixelRatio: env.getDevicePixelRatio,
matchMedia: env.matchMedia,
onChange: () => {
calls += 1;
},
});
env.setDpr(2);
env.created[0].trigger();
assert.equal(calls, 1);
});
test("re-registers for the new ratio so subsequent changes still fire", () => {
const env = makeEnv(1);
let calls = 0;
watchDevicePixelRatio({
getDevicePixelRatio: env.getDevicePixelRatio,
matchMedia: env.matchMedia,
onChange: () => {
calls += 1;
},
});
env.setDpr(2);
env.created[0].trigger();
assert.equal(env.created.length, 2);
assert.equal(env.created[1].query, "(resolution: 2dppx)");
// The stale listener must be detached so it cannot double-fire.
assert.equal(env.created[0].listenerCount, 0);
env.setDpr(3);
env.created[1].trigger();
assert.equal(calls, 2);
});
test("cleanup stops further onChange callbacks", () => {
const env = makeEnv(1);
let calls = 0;
const stop = watchDevicePixelRatio({
getDevicePixelRatio: env.getDevicePixelRatio,
matchMedia: env.matchMedia,
onChange: () => {
calls += 1;
},
});
stop();
assert.equal(env.created[0].listenerCount, 0);
env.created[0].trigger();
assert.equal(calls, 0);
});
test("falls back to addListener/removeListener when addEventListener is unavailable", () => {
const env = makeEnv(1, /* supportsModern */ false);
let calls = 0;
const stop = watchDevicePixelRatio({
getDevicePixelRatio: env.getDevicePixelRatio,
matchMedia: env.matchMedia,
onChange: () => {
calls += 1;
},
});
assert.equal(env.created[0].legacyListeners.length, 1);
env.created[0].trigger();
assert.equal(calls, 1);
stop();
// After cleanup the most recently registered query has no listeners.
const latest = env.created[env.created.length - 1];
assert.equal(latest.listenerCount, 0);
});

View File

@@ -0,0 +1,72 @@
/**
* Watches for devicePixelRatio changes (e.g. moving the window between monitors
* with different DPI, or changing the OS display scaling on Windows) and invokes
* a callback so the renderer can be repaired.
*
* The WebGL renderer caches rasterized glyphs in a texture atlas keyed to the
* device pixel ratio at creation time. When the ratio changes the cached glyphs
* are drawn at the wrong scale, producing the persistent "garbled / 花屏"
* corruption reported in issue #1049 that only goes away when a brand-new
* terminal is opened. xterm.js recommends calling `clearTextureAtlas()` on DPR
* change so glyphs re-rasterize at the new scale.
*
* `matchMedia('(resolution: Ndppx)')` only matches a single ratio, so after each
* change we must re-register the listener against the new ratio.
*/
export interface MediaQueryListLike {
addEventListener?: (type: "change", listener: () => void) => void;
removeEventListener?: (type: "change", listener: () => void) => void;
// Legacy API (older Safari / Electron) where addEventListener is unavailable.
addListener?: (listener: () => void) => void;
removeListener?: (listener: () => void) => void;
}
export interface WatchDevicePixelRatioOptions {
getDevicePixelRatio: () => number;
matchMedia: (query: string) => MediaQueryListLike;
onChange: () => void;
}
/**
* Start watching for devicePixelRatio changes. Returns a cleanup function that
* removes the active listener.
*/
export function watchDevicePixelRatio(
options: WatchDevicePixelRatioOptions,
): () => void {
const { getDevicePixelRatio, matchMedia, onChange } = options;
let current: { mql: MediaQueryListLike; listener: () => void } | null = null;
const detach = () => {
if (!current) return;
const { mql, listener } = current;
if (mql.removeEventListener) {
mql.removeEventListener("change", listener);
} else if (mql.removeListener) {
mql.removeListener(listener);
}
current = null;
};
const attach = () => {
const dpr = getDevicePixelRatio();
const mql = matchMedia(`(resolution: ${dpr}dppx)`);
const listener = () => {
// A media query only matches the ratio it was created with, so detach the
// stale listener and re-register against the new ratio before notifying.
detach();
attach();
onChange();
};
if (mql.addEventListener) {
mql.addEventListener("change", listener);
} else if (mql.addListener) {
mql.addListener(listener);
}
current = { mql, listener };
};
attach();
return detach;
}

View File

@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import test from "node:test";
import { handleSerialLineModeInput } from "./serialLineInput";
test("serial line mode sends completed lines from a multi-line paste chunk", () => {
const writes: string[] = [];
const echoes: string[] = [];
const bufferRef = { current: "" };
handleSerialLineModeInput("show version\rshow clock", {
bufferRef,
writeToSession: (data) => writes.push(data),
writeToTerminal: (data) => echoes.push(data),
});
assert.deepEqual(writes, ["show version\r"]);
assert.equal(bufferRef.current, "show clock");
assert.deepEqual(echoes, []);
});
test("serial line mode sends every completed line when pasted text ends with enter", () => {
const writes: string[] = [];
const echoes: string[] = [];
const bufferRef = { current: "" };
handleSerialLineModeInput("show version\rshow clock\r", {
bufferRef,
localEcho: true,
writeToSession: (data) => writes.push(data),
writeToTerminal: (data) => echoes.push(data),
});
assert.deepEqual(writes, ["show version\r", "show clock\r"]);
assert.equal(bufferRef.current, "");
assert.deepEqual(echoes, ["show version", "\r\n", "show clock", "\r\n"]);
});

View File

@@ -0,0 +1,86 @@
type StringRef = {
current: string;
};
type SerialLineModeInputOptions = {
bufferRef: StringRef;
localEcho?: boolean;
writeToSession: (data: string) => void;
writeToTerminal: (data: string) => void;
};
const submitLine = ({
bufferRef,
localEcho,
writeToSession,
writeToTerminal,
}: SerialLineModeInputOptions) => {
const line = `${bufferRef.current}\r`;
writeToSession(line);
bufferRef.current = "";
if (localEcho) writeToTerminal("\r\n");
};
const appendText = (
text: string,
{ bufferRef, localEcho, writeToTerminal }: SerialLineModeInputOptions,
) => {
if (!text) return;
bufferRef.current += text;
if (localEcho) writeToTerminal(text);
};
const clearLine = ({
bufferRef,
localEcho,
writeToTerminal,
}: SerialLineModeInputOptions) => {
if (localEcho && bufferRef.current.length > 0) {
writeToTerminal("\b \b".repeat(bufferRef.current.length));
}
bufferRef.current = "";
};
export function handleSerialLineModeInput(
data: string,
options: SerialLineModeInputOptions,
): void {
if (data === "\r" || data === "\n") {
submitLine(options);
return;
}
if (data === "\x7f" || data === "\b") {
if (options.bufferRef.current.length > 0) {
options.bufferRef.current = options.bufferRef.current.slice(0, -1);
if (options.localEcho) options.writeToTerminal("\b \b");
}
return;
}
if (data === "\x03") {
options.bufferRef.current = "";
options.writeToSession(data);
if (options.localEcho) options.writeToTerminal("^C\r\n");
return;
}
if (data === "\x15") {
clearLine(options);
return;
}
const normalizedData = data.replace(/\r\n/g, "\r").replace(/\n/g, "\r");
if (normalizedData.includes("\r")) {
const parts = normalizedData.split("\r");
parts.forEach((part, index) => {
appendText(part, options);
if (index < parts.length - 1) submitLine(options);
});
return;
}
if (data.charCodeAt(0) >= 32 || data.length > 1) {
appendText(data, options);
}
}

View File

@@ -0,0 +1,22 @@
import test from "node:test";
import assert from "node:assert/strict";
import { formatSerialLocalEcho } from "./serialLocalEcho";
test("formatSerialLocalEcho echoes printable input and normalizes newlines", () => {
assert.equal(formatSerialLocalEcho("show version"), "show version");
assert.equal(formatSerialLocalEcho("\r"), "\r\n");
assert.equal(formatSerialLocalEcho("\n"), "\r\n");
assert.equal(formatSerialLocalEcho("\r\n"), "\r\n");
assert.equal(formatSerialLocalEcho("one\ntwo"), "one\r\ntwo");
});
test("formatSerialLocalEcho renders local editing control keys", () => {
assert.equal(formatSerialLocalEcho("\x7f"), "\b \b");
assert.equal(formatSerialLocalEcho("\b"), "\b \b");
assert.equal(formatSerialLocalEcho("\x03"), "^C");
});
test("formatSerialLocalEcho ignores single non-display control input", () => {
assert.equal(formatSerialLocalEcho("\x15"), "");
});

View File

@@ -0,0 +1,25 @@
function normalizeSerialLocalEchoLineEndings(data: string): string {
let output = "";
for (let i = 0; i < data.length; i += 1) {
const ch = data[i];
if (ch === "\r") {
output += "\r\n";
if (data[i + 1] === "\n") i += 1;
} else if (ch === "\n") {
output += "\r\n";
} else {
output += ch;
}
}
return output;
}
export function formatSerialLocalEcho(data: string): string {
if (!data) return "";
if (data === "\x7f" || data === "\b") return "\b \b";
if (data === "\x03") return "^C";
if (data === "\r" || data === "\n" || data.charCodeAt(0) >= 32 || data.length > 1) {
return normalizeSerialLocalEchoLineEndings(data);
}
return "";
}

View File

@@ -0,0 +1,195 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import {
decodeTerminalTextEscapes,
doesKittyEncodingPreserveShiftEnter,
getShiftEnterSubmittedInput,
isBareShiftEnterLineEnding,
isShiftEnterLineContinuationText,
resolveShiftEnterText,
SHIFT_ENTER_CSI_U_SEQUENCE,
shouldSendShiftEnterText,
} from "./shiftEnterText";
const keyEvent = (overrides: Partial<KeyboardEvent> = {}) => ({
type: "keydown",
key: "Enter",
shiftKey: true,
altKey: false,
ctrlKey: false,
metaKey: false,
isComposing: false,
...overrides,
}) as KeyboardEvent;
test("shift enter text defaults to newline", () => {
assert.equal(resolveShiftEnterText(), "\n");
});
test("shift enter text decodes newline, tab, carriage return, and backslash escapes", () => {
assert.equal(
decodeTerminalTextEscapes("line\\nnext\\tindent\\rreturn\\\\slash"),
"line\nnext\tindent\rreturn\\slash",
);
});
test("shift enter text can represent Tabby-style shell continuation", () => {
assert.equal(decodeTerminalTextEscapes(" \\\\\\n"), " \\\n");
});
test("shift enter continuation detection only matches backslash-newline endings", () => {
assert.equal(isShiftEnterLineContinuationText(" \\\n"), true);
assert.equal(isShiftEnterLineContinuationText(" \\\r\n"), true);
assert.equal(isShiftEnterLineContinuationText(" \\\r"), true);
assert.equal(isShiftEnterLineContinuationText("foo\n"), false);
assert.equal(isShiftEnterLineContinuationText("\r\n"), false);
});
test("shift enter submitted input detects single command text with a line ending", () => {
assert.deepEqual(getShiftEnterSubmittedInput("\n"), {
text: "",
lineEnding: "\n",
});
assert.deepEqual(getShiftEnterSubmittedInput("\r\n"), {
text: "",
lineEnding: "\r\n",
});
assert.deepEqual(getShiftEnterSubmittedInput("sudo whoami\n"), {
text: "sudo whoami",
lineEnding: "\n",
});
assert.equal(getShiftEnterSubmittedInput(" \\\n"), null);
assert.equal(getShiftEnterSubmittedInput("foo\nbar\n"), null);
});
test("shift enter handler only matches plain Shift+Enter keydown", () => {
assert.equal(shouldSendShiftEnterText(keyEvent()), true);
assert.equal(shouldSendShiftEnterText(keyEvent({ type: "keyup" })), false);
assert.equal(shouldSendShiftEnterText(keyEvent({ key: "NumpadEnter" })), false);
assert.equal(shouldSendShiftEnterText(keyEvent({ ctrlKey: true })), false);
assert.equal(shouldSendShiftEnterText(keyEvent({ metaKey: true })), false);
assert.equal(shouldSendShiftEnterText(keyEvent({ altKey: true })), false);
assert.equal(shouldSendShiftEnterText(keyEvent({ shiftKey: false })), false);
assert.equal(shouldSendShiftEnterText(keyEvent({ isComposing: true })), false);
});
test("shift enter handler respects the terminal setting toggle", () => {
assert.equal(
shouldSendShiftEnterText(keyEvent(), { shiftEnterNewlineEnabled: false }),
false,
);
});
test("bare line-ending Shift+Enter text is detected for TUI passthrough", () => {
assert.equal(isBareShiftEnterLineEnding("\n"), true);
assert.equal(isBareShiftEnterLineEnding("\r"), true);
assert.equal(isBareShiftEnterLineEnding("\r\n"), true);
assert.equal(isBareShiftEnterLineEnding(" \\\n"), false);
assert.equal(isBareShiftEnterLineEnding("sudo whoami\n"), false);
assert.equal(isBareShiftEnterLineEnding(""), false);
});
test("Kitty encodings that collapse Shift+Enter to CR/LF do not preserve the chord", () => {
assert.equal(doesKittyEncodingPreserveShiftEnter(null), false);
assert.equal(doesKittyEncodingPreserveShiftEnter(""), false);
assert.equal(doesKittyEncodingPreserveShiftEnter("\r"), false);
assert.equal(doesKittyEncodingPreserveShiftEnter("\n"), false);
assert.equal(doesKittyEncodingPreserveShiftEnter(SHIFT_ENTER_CSI_U_SEQUENCE), true);
});
test("runtime routes Shift+Enter text through the shared input handler", () => {
const source = readFileSync(
new URL("./createXTermRuntime.ts", import.meta.url),
"utf8",
);
assert.match(
source,
/const handleTerminalInputData = \(\s+data: string,\s+options\?: \{\s+source\?: "terminal" \| "shift-enter" \| "kitty";\s+[\s\S]*?skipBroadcast\?: boolean;\s+[\s\S]*?perCharacterWrites\?: boolean;\s+\},\s+\) => \{/s,
);
// Remap when Kitty encoding does not preserve Shift+Enter (not merely flags===0).
assert.match(
source,
/if \(\s*shouldSendShiftEnterText\([\s\S]*?\) &&\s*!term\.modes\.win32InputMode &&\s*!doesKittyEncodingPreserveShiftEnter\(kittySequenceForKeyDown\)\s*\) \{[\s\S]*?const shiftEnterText = resolveShiftEnterText\([\s\S]*?\)[\s\S]*?\}\s*if \(kittySequenceForKeyDown\)/s,
);
assert.match(
source,
/vtExtensions: \{\s*win32InputMode: windowsPty\?\.backend === "conpty",\s*\},/s,
);
assert.match(
source,
/const kittySequenceForKeyDown =\s*!term\.modes\.win32InputMode &&\s*kittyKeyboardProtocolEnabled/s,
);
assert.match(
source,
/const shiftEnterText = resolveShiftEnterText\([\s\S]*?if \(shiftEnterText\) \{[\s\S]*?handleTerminalInputData\(shiftEnterText, \{\s*source: "shift-enter",\s*skipBroadcast: true,\s*\}\);\s*const forwarded = broadcastKittyInput\(\{\s*kind: "key",\s*event: kittyEvent,\s*fallbackToLegacy: true,\s*\}\);/s,
);
assert.match(
source,
/const canBroadcastInput = !sensitive &&[\s\S]*?const willBroadcastInput = canBroadcastInput && options\?\.skipBroadcast !== true;[\s\S]*?if \(!canBroadcastInput && !handlingKittyBroadcast\) \{\s*prepareSudoAutofillInput/s,
);
assert.match(
source,
/resolveOptions: \(\) => \(\{[\s\S]*?shiftEnterSettings: ctx\.terminalSettingsRef\.current,[\s\S]*?\}\),/s,
);
assert.doesNotMatch(source, /resolveShiftEnterText\([\s\S]*?alternateScreen:/s);
assert.match(source, /getShiftEnterSubmittedInput\(logicalData\)/);
assert.match(source, /inputSource !== "shift-enter"/);
assert.match(
source,
/if \(shouldSendShiftEnterText\(e, ctx\.terminalSettingsRef\.current\)\) \{\s+sudoAutofill\.cancelHint\(\);/s,
);
assert.match(
source,
/term\.onData\(\(data\) => \{[\s\S]*const sanitizedRawData = sanitizeTerminalInput\(data\);[\s\S]*handleTerminalInputData\(sanitizedRawData, \{\s*perCharacterWrites: shouldSplitRawPasteInputForWire\(sanitizedRawData\),?\s*\}\);\s+\}\);/,
);
assert.match(
source,
/const sanitizedData = sanitizeTerminalInput\(data\);[\s\S]*const encoded = term\.modes\.win32InputMode\s*\? null\s*:\s*encodeKittyCompositionText\(kittyKeyboardMode, sanitizedData\);[\s\S]*if \(encoded\) \{[\s\S]*handleTerminalInputData\(encoded, \{ source: "kitty" \}\);[\s\S]*\} else \{[\s\S]*handleTerminalInputData\(sanitizedData, \{\s*perCharacterWrites: shouldSplitImeTextInputForWire\(sanitizedData\),?\s*\}\);[\s\S]*broadcastKittyInput\(\{ kind: "text", text: sanitizedData \}\);/,
);
assert.match(
source,
/if \(term\.modes\.win32InputMode\) \{[\s\S]*win32InputModePendingEvent = \{\s*event: normalizedKittyEvent,\s*logicalData: resolveWin32InputLogicalData\(/s,
);
assert.match(
source,
/const broadcastInput: KittyKeyboardBroadcastInput = \{\s*kind: "win32",\s*data,\s*event: win32Input\.event,[\s\S]*handleTerminalInputData\(data, \{\s*logicalData: win32Input\.logicalData,\s*skipBroadcast: true,/s,
);
assert.match(
source,
/const hasForwardedWin32KeyDown = win32InputModeForwardedKeys\.delete\(identity\);[\s\S]*if \(term\.modes\.win32InputMode\) \{[\s\S]*releaseForwardedKittyPress\([\s\S]*if \(!hasForwardedWin32KeyDown\) \{[\s\S]*win32InputModePendingEvent = null;[\s\S]*return false;[\s\S]*logicalData: null,[\s\S]*return true;[\s\S]*releaseForwardedKittyPress/s,
);
assert.match(
source,
/if \(win32Input\.event\.type === "keydown"\) \{\s*upsertKittyKeyboardForwardedPress\(\s*win32InputModeForwardedKeys,\s*win32Input\.event\.code \|\| win32Input\.event\.key,\s*win32Input\.event,\s*\[\],/s,
);
assert.match(
source,
/if \(term\.modes\.win32InputMode\) \{[\s\S]*flushKittyKeyboardBroadcastReleases\(\s*win32InputModeForwardedKeys,[\s\S]*writeWin32InputModeEvent\(input\.event, null\);/s,
);
assert.match(source, /win32InputMode: term\.modes\.win32InputMode,/);
assert.match(
source,
/const win32BroadcastForwardedKeys = new Map<string, KittyKeyboardForwardedPress>\(\);/,
);
assert.match(
source,
/const forwardedPress = win32BroadcastForwardedKeys\.get\(identity\);[\s\S]*broadcastKittyInput\(\s*broadcastInput,\s*true,\s*forwardedPress\.targetSessionIds,/s,
);
assert.match(
source,
/upsertKittyKeyboardForwardedPress\(\s*win32BroadcastForwardedKeys,[\s\S]*forwarded\.targetSessionIds,/s,
);
assert.match(source, /ctx\.container\.addEventListener\("input", markKittyTextInput, true\);/);
assert.match(
source,
/if \(shouldMarkKittyTextInputEvent\(event\)\) markKittyCompositionPending\(true\);/,
);
assert.match(
source,
/flushKittyKeyboardBroadcastReleases\(\s+kittyForwardedKeys,[\s\S]*encodeKittyKeyEvent\(kittyKeyboardMode, input\.event\)[\s\S]*handleTerminalInputData\(sequence, \{ source: "kitty" \}\)/,
);
assert.doesNotMatch(source, /writeToSession\(id, textToSend\)/);
});

View File

@@ -0,0 +1,113 @@
import type { TerminalSettings } from "../../../domain/models";
export const DEFAULT_SHIFT_ENTER_TEXT = "\\n";
/** Kitty CSI-u encoding for Shift+Enter (keycode 13, modifier shift → 2). */
export const SHIFT_ENTER_CSI_U_SEQUENCE = "\u001b[13;2u";
type ShiftEnterEvent = Pick<
KeyboardEvent,
"altKey" | "ctrlKey" | "key" | "metaKey" | "shiftKey" | "type"
> & {
isComposing?: boolean;
};
export function decodeTerminalTextEscapes(text: string): string {
let decoded = "";
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (char !== "\\" || index >= text.length - 1) {
decoded += char;
continue;
}
const next = text[index + 1];
switch (next) {
case "n":
decoded += "\n";
index += 1;
break;
case "r":
decoded += "\r";
index += 1;
break;
case "t":
decoded += "\t";
index += 1;
break;
case "\\":
decoded += "\\";
index += 1;
break;
default:
decoded += char;
break;
}
}
return decoded;
}
export function shouldSendShiftEnterText(
event: ShiftEnterEvent,
settings?: Pick<TerminalSettings, "shiftEnterNewlineEnabled">,
): boolean {
return (
settings?.shiftEnterNewlineEnabled !== false &&
event.type === "keydown" &&
event.key === "Enter" &&
event.shiftKey &&
!event.altKey &&
!event.ctrlKey &&
!event.metaKey &&
!event.isComposing
);
}
export function resolveShiftEnterText(
settings?: Pick<TerminalSettings, "shiftEnterNewlineText">,
): string {
const configured = settings?.shiftEnterNewlineText;
return decodeTerminalTextEscapes(
typeof configured === "string" ? configured : DEFAULT_SHIFT_ENTER_TEXT,
);
}
export function isBareShiftEnterLineEnding(text: string): boolean {
return text === "\n" || text === "\r" || text === "\r\n";
}
/**
* True when Kitty encoding already keeps Shift+Enter distinct from plain Enter.
* Non-preserving flag sets (e.g. alternate-key or associated-text alone) still
* encode Shift+Enter as a bare CR/LF, so the alternate-screen remap must run.
*/
export function doesKittyEncodingPreserveShiftEnter(
encoded: string | null | undefined,
): boolean {
return typeof encoded === "string"
&& encoded.length > 0
&& !isBareShiftEnterLineEnding(encoded);
}
export function isShiftEnterLineContinuationText(text: string): boolean {
return /\\(?:\r\n|\r|\n)$/.test(text);
}
export type ShiftEnterSubmittedInput = {
text: string;
lineEnding: "\r\n" | "\r" | "\n";
};
export function getShiftEnterSubmittedInput(
text: string,
): ShiftEnterSubmittedInput | null {
if (isShiftEnterLineContinuationText(text)) return null;
const match = text.match(/^([^\r\n]*)(\r\n|\r|\n)$/);
if (!match) return null;
return {
text: match[1],
lineEnding: match[2] as ShiftEnterSubmittedInput["lineEnding"],
};
}

View File

@@ -0,0 +1,21 @@
import test from "node:test";
import assert from "node:assert/strict";
import { formatTelnetLocalEcho } from "./telnetLocalEcho";
test("formatTelnetLocalEcho echoes printable input and newlines", () => {
assert.equal(formatTelnetLocalEcho("ps\r"), "ps\r\n");
assert.equal(formatTelnetLocalEcho("one\ntwo"), "one\r\ntwo");
});
test("formatTelnetLocalEcho renders local editing control keys", () => {
assert.equal(formatTelnetLocalEcho("\x7f"), "\b \b");
assert.equal(formatTelnetLocalEcho("\b"), "\b \b");
assert.equal(formatTelnetLocalEcho("\x03"), "^C");
});
test("formatTelnetLocalEcho ignores non-display escape input", () => {
assert.equal(formatTelnetLocalEcho("\x1b[A"), "");
assert.equal(formatTelnetLocalEcho("\x1bOP"), "");
assert.equal(formatTelnetLocalEcho("\x1bb"), "");
});

View File

@@ -0,0 +1,30 @@
export function formatTelnetLocalEcho(data: string): string {
let output = "";
for (let i = 0; i < data.length; i += 1) {
const ch = data[i];
if (ch === "\r") {
output += "\r\n";
if (data[i + 1] === "\n") i += 1;
} else if (ch === "\n") {
output += "\r\n";
} else if (ch === "\x1b") {
if (data[i + 1] === "[" || data[i + 1] === "O") {
i += 1;
while (i + 1 < data.length) {
i += 1;
const code = data.charCodeAt(i);
if (code >= 0x40 && code <= 0x7e) break;
}
} else if (i + 1 < data.length) {
i += 1;
}
} else if (ch === "\x7f" || ch === "\b") {
output += "\b \b";
} else if (ch === "\x03") {
output += "^C";
} else if (ch >= " ") {
output += ch;
}
}
return output;
}

View File

@@ -0,0 +1,25 @@
import assert from "node:assert/strict";
import test from "node:test";
import { mapTerminalBackspaceInput } from "./terminalBackspaceInput";
const applyNetworkDeviceInput = (initial: string, input: string): string => {
let line = initial;
for (const byte of input) {
if (byte === "\x08") line = line.slice(0, -1);
else if (byte.charCodeAt(0) >= 32) line += byte;
}
return line;
};
test("Ctrl-H mode makes Backspace delete on network-device serial consoles", () => {
const sent = mapTerminalBackspaceInput("\x7f", "ctrl-h");
assert.equal(sent, "\x08");
assert.equal(applyNetworkDeviceInput("abc", sent), "ab");
});
test("default mode preserves xterm Backspace and ordinary input", () => {
assert.equal(mapTerminalBackspaceInput("\x7f", undefined), "\x7f");
assert.equal(mapTerminalBackspaceInput("show version", "ctrl-h"), "show version");
});

View File

@@ -0,0 +1,8 @@
import type { Host } from "../../../domain/models";
export function mapTerminalBackspaceInput(
data: string,
behavior: Host["backspaceBehavior"],
): string {
return data === "\x7f" && behavior === "ctrl-h" ? "\x08" : data;
}

View File

@@ -0,0 +1,169 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { runInNewContext } from "node:vm";
import ts from "typescript";
import { resolveKittyKeyboardBroadcastInput, type KittyKeyboardBroadcastInput } from "./kittyKeyboardBroadcast";
import { createKittyKeyboardModeState, setKittyKeyboardModeFlags, shouldEncodeKittyCompositionText, shouldMarkKittyTextInputEvent, encodeKittyCompositionText } from "./kittyKeyboardProtocol";
import { shouldBlockKeyPressForImeTextInput, shouldCommitDeferredImeTextInput } from "./terminalImeTextInput";
import { sanitizeTerminalInput } from "./terminalInputSanitize";
// Execute the actual registered key/data callbacks, without constructing the
// renderer and its WebGL/addon stack. In particular, retain their real timer
// cleanup and raw-broadcast suppression rather than reproducing that logic here.
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
function section(start: string, end: string): string {
const from = source.indexOf(start);
const to = source.indexOf(end, from);
assert.ok(from >= 0 && to > from, `runtime section missing: ${start}`);
return source.slice(from, to);
}
const declarations = section(" let suppressNextTerminalDataBroadcast =", " const broadcastKittyInput =");
const keyboardCallback = section(" term.attachCustomKeyEventHandler((e: KeyboardEvent) => {", " const handleMiddleClick =");
const dataCallback = section(" term.onData((data) => {", " const handleKittyKeyboardBroadcast =");
const compositionMarker = section(" const markKittyCompositionPending =", " const finishKittyComposition =");
const textInputMarker = section(" const markKittyTextInput =", ' textarea?.addEventListener("compositionstart"');
const suppression = section(" const suppressTerminalBroadcast =", " // skipBroadcast");
const callbackCode = ts.transpileModule(`
${declarations}
let win32InputModePendingEvent = null;
let kittyCompositionPending = false;
let kittyCompositionClearTimer;
const handleTerminalInputData = (data) => {
const inputSource = "terminal";
${suppression}
if (!suppressTerminalBroadcast) raw(data);
};
const textarea = null;
${compositionMarker}
${textInputMarker}
${keyboardCallback}
${dataCallback}
globalThis.controls = { mark: markBroadcastLegacyDataPending, clear: clearBroadcastLegacyDataPending, input: markKittyTextInput };
`, { compilerOptions: { target: ts.ScriptTarget.ES2022 } }).outputText;
function setup(flags = 0) {
const writes: string[] = [];
const timers = new Map<number, () => void>();
let timerId = 0;
let receive!: (data: string) => void;
let keyboard!: (event: Partial<KeyboardEvent>) => boolean;
const forwarded = new Map<string, { targetSessionIds: string[] }>();
const mode = createKittyKeyboardModeState();
setKittyKeyboardModeFlags(mode, flags);
const options = {
kittyProtocolEnabled: flags !== 0, kittyMode: mode, applicationCursorMode: false,
encodedKeys: new Set<string>(), legacySuppressedKeys: new Set<string>(),
};
const normalized = (input: KittyKeyboardBroadcastInput) => {
const result = resolveKittyKeyboardBroadcastInput(input, options);
if (result) writes.push(result.data);
};
const context = {
controls: undefined as unknown as { mark: (identity: string) => void; clear: () => void; input: (event: { data: string; inputType: string }) => void },
window: {
setTimeout(fn: () => void) { const id = ++timerId; timers.set(id, fn); return id; },
clearTimeout(id: number) { timers.delete(id); },
},
term: {
modes: { win32InputMode: false },
onData(fn: typeof receive) { receive = fn; },
attachCustomKeyEventHandler(fn: typeof keyboard) { keyboard = fn; },
},
ctx: { terminalSettingsRef: { current: {} }, isBroadcastEnabledRef: { current: true }, onBroadcastInputRef: { current: () => undefined } },
imeTextInputDeferredKey: null, imeTextInputDeferredKittyEvent: null,
shouldBlockKeyPressForImeTextInput, shouldCommitDeferredImeTextInput, shouldMarkKittyTextInputEvent, shouldEncodeKittyCompositionText, encodeKittyCompositionText,
kittyKeyboardMode: createKittyKeyboardModeState(), shouldSplitImeTextInputForWire: () => false,
kittyKeyIdentity: (event: Partial<KeyboardEvent>) => event.code || event.key,
broadcastForwardedKeys: forwarded,
broadcastKittyInput: normalized,
sanitizeTerminalInput,
shouldSplitRawPasteInputForWire: () => false,
raw: (data: string) => writes.push(data),
};
runInNewContext(callbackCode, context);
return {
writes, receive: (data: string) => receive(data), controls: context.controls,
keypress: (key: string, code: string) => keyboard({ type: "keypress", key, code }),
press(key: string, code: string) {
normalized({ kind: "key", event: { type: "keydown", key, code }, fallbackToLegacy: true });
forwarded.set(code, { targetSessionIds: ["target"] });
context.controls.mark(code);
},
flushTimers() { for (const [id, fn] of timers) { timers.delete(id); fn(); } },
release(key: string, code: string) {
normalized({ kind: "key", event: { type: "keyup", key, code }, fallbackToLegacy: true });
forwarded.delete(code);
context.controls.clear();
},
};
}
for (const flags of [0, 8]) {
for (const [key, code] of [["A", "KeyA"], [" ", "Space"]]) {
test(`broadcast pairs delayed ${code} keypress once with target flags ${flags}`, () => {
const runtime = setup(flags);
runtime.press(key, code);
const firstWrite = runtime.writes.join("");
assert.ok(firstWrite);
runtime.flushTimers();
assert.equal(runtime.keypress(key, code), true);
runtime.receive(key);
assert.equal(runtime.writes.join(""), firstWrite, "the source's later text must not duplicate its physical key broadcast");
});
}
}
test("repeated physical presses each reach the broadcast target once", () => {
const runtime = setup();
for (let i = 0; i < 3; i++) {
runtime.press("A", "KeyA");
runtime.flushTimers();
runtime.keypress("A", "KeyA");
runtime.receive("A");
}
assert.equal(runtime.writes.join(""), "AAA");
});
test("keypress without an earlier broadcast and text after release are not swallowed", () => {
const runtime = setup();
runtime.keypress("B", "KeyB");
runtime.receive("B");
runtime.press("A", "KeyA");
runtime.flushTimers();
runtime.keypress("A", "KeyA");
runtime.receive("A");
runtime.release("A", "KeyA");
runtime.receive("paste");
assert.equal(runtime.writes.join(""), "BApaste");
});
test("unmatched keypress cleanup still permits a later paste", () => {
const runtime = setup();
runtime.press("A", "KeyA");
runtime.flushTimers();
runtime.keypress("A", "KeyA");
runtime.flushTimers();
runtime.receive("paste");
assert.equal(runtime.writes.join(""), "Apaste");
});
test("trailing insertText for Space does not turn the next physical key into duplicate composition text", () => {
const runtime = setup();
for (const [key, code] of [[" ", "Space"], ["A", "KeyA"], ["B", "KeyB"], ["C", "KeyC"]]) {
runtime.press(key, code);
runtime.keypress(key, code);
runtime.receive(key);
runtime.controls.input({ data: key, inputType: "insertText" });
runtime.release(key, code);
// Real trace: the next key can arrive before insertText's zero-delay timer.
}
assert.equal(runtime.writes.join(""), " ABC");
});
test("keyless insertText is still broadcast as actual text", () => {
const runtime = setup();
runtime.controls.input({ data: "中文", inputType: "insertText" });
runtime.receive("中文");
assert.equal(runtime.writes.join(""), "中文");
});

View File

@@ -0,0 +1,116 @@
import assert from "node:assert/strict";
import test from "node:test";
import { readFileSync } from "node:fs";
import {
isTerminalCloseGenerationCurrent,
resolveConnectionLogCapturePayload,
resolveHibernateSnapshotCapturePayload,
scheduleTerminalCloseTeardown,
} from "./terminalCloseCapture.ts";
test("resolveConnectionLogCapturePayload returns null when finalize produces empty data", () => {
assert.equal(
resolveConnectionLogCapturePayload(() => ""),
null,
);
});
test("resolveConnectionLogCapturePayload returns buffered connection log data", () => {
assert.deepEqual(
resolveConnectionLogCapturePayload(() => "line one\r\nline two"),
{ data: "line one\r\nline two", source: "connection-log" },
);
});
test("resolveHibernateSnapshotCapturePayload prefers combined snapshot fields", () => {
assert.deepEqual(
resolveHibernateSnapshotCapturePayload({
snapshot: "full snapshot",
viewportSnapshot: "viewport",
scrollbackSnapshot: "scrollback",
alternateScreen: false,
}),
{ data: "full snapshot", source: "hibernate-serialize" },
);
assert.deepEqual(
resolveHibernateSnapshotCapturePayload({
snapshot: "",
viewportSnapshot: "viewport",
scrollbackSnapshot: "scrollback",
alternateScreen: false,
}),
{ data: "scrollbackviewport", source: "hibernate-serialize" },
);
});
test("scheduleTerminalCloseTeardown runs teardown asynchronously", async () => {
let ran = false;
scheduleTerminalCloseTeardown(() => {
ran = true;
});
assert.equal(ran, false);
await new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
assert.equal(ran, true);
});
test("isTerminalCloseGenerationCurrent rejects stale close generations", () => {
assert.equal(isTerminalCloseGenerationCurrent(1, 1), true);
assert.equal(isTerminalCloseGenerationCurrent(1, 2), false);
});
test("terminal close fully drains pending output before finalizing capture", () => {
const source = readFileSync(
new URL("../useTerminalEffects.ts", import.meta.url),
"utf8",
);
const cleanupStart = source.indexOf("return () => {", source.indexOf("void boot();"));
const flushIndex = source.indexOf("await flushPendingTerminalWritesBeforeHibernate(term)", cleanupStart);
const incompleteIndex = source.indexOf("if (!flushed)", flushIndex);
const finalizeIndex = source.indexOf("resolveConnectionLogCapturePayload(finalizeTerminalLogData)", cleanupStart);
assert.ok(cleanupStart >= 0);
assert.ok(flushIndex > cleanupStart);
assert.ok(incompleteIndex > flushIndex);
assert.ok(finalizeIndex > flushIndex);
});
test("never-connected StrictMode cleanup sync-disposes its owned xterm runtime", () => {
const source = readFileSync(
new URL("../useTerminalEffects.ts", import.meta.url),
"utf8",
);
const cleanupStart = source.indexOf("return () => {", source.indexOf("void boot();"));
assert.ok(cleanupStart >= 0);
const neverConnectedClose = source.indexOf(
"if (!hasConnectedRef.current)",
cleanupStart,
);
assert.ok(neverConnectedClose > cleanupStart);
const branch = source.slice(
neverConnectedClose,
source.indexOf("const persistCloseCapture", neverConnectedClose),
);
assert.match(branch, /if \(!attachExistingSession\)/);
assert.match(branch, /disposeOwnedRuntime\(\);/);
assert.match(branch, /return;/);
assert.ok(
branch.indexOf("disposeOwnedRuntime();") < branch.indexOf("return;"),
"orphaned StrictMode xterm must be disposed before leaving cleanup",
);
});
test("createXTermRuntime clears orphaned .xterm children before open", () => {
const source = readFileSync(
new URL("./createXTermRuntime.ts", import.meta.url),
"utf8",
);
const openAt = source.indexOf("term.open(ctx.container);");
assert.ok(openAt > 0);
const beforeOpen = source.slice(Math.max(0, openAt - 350), openAt);
assert.match(beforeOpen, /querySelectorAll\(":scope > \.xterm"\)/);
assert.match(beforeOpen, /orphan\.remove\(\)/);
});

View File

@@ -0,0 +1,56 @@
import type { Terminal as XTerm } from "@xterm/xterm";
import type { SerializeAddon } from "@xterm/addon-serialize";
import {
serializeTerminalForHibernate,
type TerminalHibernateSnapshot,
} from "../terminalHibernateRuntime.ts";
export type TerminalCloseCaptureSource = "connection-log" | "hibernate-serialize" | "none";
export type TerminalCloseCapturePayload = {
data: string;
source: TerminalCloseCaptureSource;
};
export function scheduleTerminalCloseTeardown(teardown: () => void): void {
if (typeof queueMicrotask === "function") {
queueMicrotask(teardown);
return;
}
setTimeout(teardown, 0);
}
export function isTerminalCloseGenerationCurrent(
closeGeneration: number,
currentGeneration: number,
): boolean {
return closeGeneration === currentGeneration;
}
export function resolveConnectionLogCapturePayload(
finalizeTerminalLogData: () => string,
): TerminalCloseCapturePayload | null {
const data = finalizeTerminalLogData();
if (!data) return null;
return { data, source: "connection-log" };
}
export function resolveHibernateSnapshotCapturePayload(
snapshot: TerminalHibernateSnapshot,
): TerminalCloseCapturePayload | null {
const data = snapshot.snapshot
|| snapshot.contextSnapshot
|| [snapshot.scrollbackSnapshot, snapshot.viewportSnapshot].filter(Boolean).join("");
if (!data) return null;
return { data, source: "hibernate-serialize" };
}
export async function serializeTerminalCloseFallback(
term: XTerm,
serializeAddon: SerializeAddon,
options: { preferWasm?: boolean; prepare?: () => Promise<void> } = {},
): Promise<TerminalCloseCapturePayload | null> {
const snapshot = await serializeTerminalForHibernate(term, serializeAddon, options);
return resolveHibernateSnapshotCapturePayload(snapshot);
}

View File

@@ -0,0 +1,100 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { recordTerminalCommandExecution } from './terminalCommandExecution.ts';
function createFakeTerm(lineText: string) {
return {
buffer: {
active: {
cursorX: lineText.length,
cursorY: 0,
baseY: 0,
getLine(line: number) {
if (line !== 0) return undefined;
return {
isWrapped: false,
translateToString() { return lineText; },
};
},
},
},
};
}
test('sensitive challenge input never reaches command history or semantic callbacks', () => {
const submitted: string[] = [];
const executed: string[] = [];
const commandBufferRef = { current: '123456' };
const result = recordTerminalCommandExecution('123456', {
host: { id: 'host-1', label: 'Host' },
sessionId: 'session-1',
onCommandSubmitted: (command) => submitted.push(command),
onCommandExecuted: (command) => executed.push(command),
commandBufferRef,
}, createFakeTerm('OTP> 123456') as never, { sensitive: false });
assert.equal(result, null);
assert.equal(commandBufferRef.current, '');
assert.deepEqual(submitted, []);
assert.deepEqual(executed, []);
});
test('unknown authentication and REPL prompts fail closed before plugin semantic callbacks', () => {
for (const lineText of ['Custom authentication> hunter2', 'python> print(secret)']) {
const submitted: string[] = [];
const executed: string[] = [];
const command = lineText.split(' ').at(-1) ?? '';
const commandBufferRef = { current: command };
const result = recordTerminalCommandExecution(command, {
host: { id: 'host-1', label: 'Host' },
sessionId: 'session-1',
onTrustedCommandSubmitted: (command) => submitted.push(command),
onCommandExecuted: (command) => executed.push(command),
commandBufferRef,
}, createFakeTerm(lineText) as never);
assert.equal(result, command, lineText);
assert.deepEqual(submitted, [], lineText);
assert.deepEqual(executed, [command], lineText);
}
});
test('semantic callbacks run only after a shell or explicitly identified device prompt is trusted', () => {
for (const [lineText, command, allowDevice] of [
['alice@host:~$ echo ok', 'echo ok', false],
['router> show version', 'show version', true],
] as const) {
const submitted: string[] = [];
const executed: string[] = [];
const commandBufferRef = { current: command };
const result = recordTerminalCommandExecution(command, {
host: { id: 'host-1', label: 'Host' },
sessionId: 'session-1',
onTrustedCommandSubmitted: (value) => submitted.push(value),
onCommandExecuted: (value) => executed.push(value),
commandBufferRef,
}, createFakeTerm(lineText) as never, {
allowHostStyleGreaterThanPrompt: allowDevice,
});
assert.equal(result, command, lineText);
assert.deepEqual(submitted, [command], lineText);
assert.deepEqual(executed, [command], lineText);
}
});
test('plugin semantic callbacks fail closed when terminal prompt state is unavailable', () => {
const submitted: string[] = [];
const executed: string[] = [];
const commandBufferRef = { current: 'echo ok' };
const result = recordTerminalCommandExecution('echo ok', {
host: { id: 'host-1', label: 'Host' },
sessionId: 'session-1',
onTrustedCommandSubmitted: (command) => submitted.push(command),
onCommandExecuted: (command) => executed.push(command),
commandBufferRef,
});
assert.equal(result, 'echo ok');
assert.deepEqual(submitted, []);
assert.deepEqual(executed, ['echo ok']);
});

View File

@@ -0,0 +1,893 @@
import type { RefObject } from "react";
import type { Terminal as XTerm } from "@xterm/xterm";
import type { Host } from "../../../types";
import {
isConfirmedTerminalShellPrompt,
isSensitiveTerminalChallenge,
} from "../../../domain/terminalPromptSecurity";
import {
markPromptLineBreakCommandPending,
type PromptLineBreakState,
} from "./promptLineBreak";
import {
getAlignedPrompt,
isNonPromptLine,
reconcilePromptWithExternalCommand,
reconcilePromptWithTypedInput,
type PromptDetectionResult,
} from "../autocomplete/promptDetector";
import { getCommandToRecordOnEnter } from "../autocomplete/terminalAutocompletePrompt";
import { shouldArmSudoPasswordAutofill } from "./terminalSudoAutofill";
type TerminalCommandExecutionContext = {
host: Pick<Host, "id" | "label">;
sessionId: string;
onCommandExecuted?: (
command: string,
hostId: string,
hostLabel: string,
sessionId: string,
) => void;
onCommandSubmitted?: (
command: string,
hostId: string,
hostLabel: string,
sessionId: string,
) => void;
onTrustedCommandSubmitted?: (
command: string,
hostId: string,
hostLabel: string,
sessionId: string,
) => void;
commandBufferRef: RefObject<string>;
promptLineBreakStateRef?: RefObject<PromptLineBreakState>;
};
/** Bare omz/p10k glyph alone — detector often leaves cwd/git chrome in userInput. */
const isBareThemedTerminator = (promptText: string): boolean => {
const trimmed = promptText.trim();
if (trimmed.length !== 1) return false;
const code = trimmed.charCodeAt(0);
return /[❯❮→➜➤⟩»›]/.test(trimmed) || (code >= 0xE000 && code <= 0xF8FF);
};
type TerminalCellStyle = {
dim: number;
fgMode: number;
fg: number;
};
const readTerminalCellStyle = (
cell: {
isDim?: () => number;
getFgColorMode?: () => number;
getFgColor?: () => number;
} | null | undefined,
): TerminalCellStyle | null => {
if (
!cell
|| typeof cell.isDim !== "function"
|| typeof cell.getFgColorMode !== "function"
|| typeof cell.getFgColor !== "function"
) {
return null;
}
return {
dim: cell.isDim(),
fgMode: cell.getFgColorMode(),
fg: cell.getFgColor(),
};
};
const terminalCellStylesEqual = (
left: TerminalCellStyle,
right: TerminalCellStyle,
): boolean => (
left.dim === right.dim
&& left.fgMode === right.fgMode
&& left.fg === right.fg
);
const terminalCellStyleKey = (style: TerminalCellStyle): string => (
`${style.dim}:${style.fgMode}:${style.fg}`
);
type StyledCellSpan = {
offset: number;
style: TerminalCellStyle;
};
/**
* Walk logical line cells from startOffset to endOffset (buffer coordinates
* that include the prompt prefix). Skips cells with unreadable styles.
*/
const collectStyledCellsInRange = (
term: XTerm,
promptRow: number,
startOffset: number,
endOffset: number,
): StyledCellSpan[] | null => {
if (endOffset <= startOffset) return [];
const buffer = term.buffer.active;
const cells: StyledCellSpan[] = [];
let offset = 0;
for (let row = promptRow; ; row += 1) {
const rowLine = buffer.getLine(row);
if (!rowLine || typeof rowLine.getCell !== "function") return null;
const text = rowLine.translateToString(false);
for (let x = 0; x < text.length; x += 1) {
if (offset >= endOffset) return cells;
if (offset >= startOffset) {
const style = readTerminalCellStyle(rowLine.getCell(x));
if (!style) return null;
cells.push({ offset, style });
}
offset += 1;
}
const next = buffer.getLine(row + 1);
if (!next?.isWrapped) break;
}
return cells;
};
/**
* zsh-autosuggest ghosts are a uniform dim/foreign run past the cursor.
* Per-token syntax highlighting also changes fg past the cursor for accepted
* suffixes — only strip tails that look like suggestion paint, not ordinary
* highlight boundaries (`systemctl start`|`firewalld`).
*/
const truncateDivergentStyleTail = (
term: XTerm,
promptRow: number,
promptText: string,
input: string,
): string => {
try {
const buffer = term.buffer.active;
const cursorY = buffer.cursorY + buffer.baseY;
const cursorX = buffer.cursorX;
let combinedOffset = 0;
for (let row = promptRow; row < cursorY; row += 1) {
const rowLine = buffer.getLine(row);
if (!rowLine) return input;
combinedOffset += rowLine.translateToString(false).length;
}
combinedOffset += cursorX;
const inputCursor = combinedOffset - promptText.length;
if (inputCursor <= 0 || inputCursor >= input.length) return input;
const refLine = buffer.getLine(
inputCursor > 0 && cursorX === 0 && cursorY > promptRow
? cursorY - 1
: cursorY,
);
if (!refLine || typeof refLine.getCell !== "function") return input;
const refX = cursorX > 0
? cursorX - 1
: Math.max(0, refLine.translateToString(false).length - 1);
const refStyle = readTerminalCellStyle(refLine.getCell(refX));
if (!refStyle) return input;
const promptEnd = promptText.length;
const lineEnd = promptEnd + input.length;
const acceptedCells = collectStyledCellsInRange(
term,
promptRow,
promptEnd,
combinedOffset,
);
const postCells = collectStyledCellsInRange(
term,
promptRow,
combinedOffset,
lineEnd,
);
if (!acceptedCells || !postCells || postCells.length === 0) return input;
const acceptedStyleKeys = new Set(
acceptedCells.map((cell) => terminalCellStyleKey(cell.style)),
);
let cutOffset: number | null = null;
for (let i = 0; i < postCells.length; i += 1) {
const cell = postCells[i];
if (terminalCellStylesEqual(refStyle, cell.style)) continue;
const tail = postCells.slice(i);
const ghostStyle = tail[0]?.style;
if (!ghostStyle) return input;
// Mixed styles after the first break are syntax-highlighted tokens.
if (tail.some((entry) => !terminalCellStylesEqual(ghostStyle, entry.style))) {
return input;
}
const ghostKey = terminalCellStyleKey(ghostStyle);
const dimGhost = ghostStyle.dim !== 0 && refStyle.dim === 0;
// fg-only ghosts (common zsh-autosuggest `fg=8`) on mono-styled input.
const foreignMonoGhost = !acceptedStyleKeys.has(ghostKey)
&& acceptedStyleKeys.size <= 1;
if (!dimGhost && !foreignMonoGhost) return input;
cutOffset = cell.offset;
break;
}
if (cutOffset == null) return input;
const cut = cutOffset - promptText.length;
return input.slice(0, Math.max(0, cut)).replace(/\s+$/g, "");
} catch {
return input;
}
};
/**
* Read the full logical input after the prompt, including wrapped continuation
* rows and text past the cursor (Enter submits the whole line, not the prefix).
* Style-divergent tails past the cursor (zsh autosuggest ghosts) are dropped.
*/
const readFullLineAfterPrompt = (
term: XTerm,
promptText: string,
): string | null => {
if (!promptText) return null;
try {
const buffer = term.buffer.active;
const cursorY = buffer.cursorY + buffer.baseY;
let promptRow = cursorY;
let line = buffer.getLine(promptRow);
if (!line) return null;
// Walk up through wrapped continuation rows to the prompt row.
while (line.isWrapped && promptRow > 0) {
promptRow -= 1;
const prev = buffer.getLine(promptRow);
if (!prev) return null;
line = prev;
}
let combined = "";
for (let row = promptRow; ; row += 1) {
const rowLine = buffer.getLine(row);
if (!rowLine) break;
combined += rowLine.translateToString(false);
const next = buffer.getLine(row + 1);
if (!next?.isWrapped) break;
}
if (!combined.startsWith(promptText)) return null;
const rawInput = combined.slice(promptText.length).replace(/\s+$/g, "");
return truncateDivergentStyleTail(term, promptRow, promptText, rawInput);
} catch {
return null;
}
};
const readCurrentLogicalTerminalLine = (term?: XTerm | null): string => {
if (!term) return "";
try {
const buffer = term.buffer.active;
const cursorY = buffer.cursorY + buffer.baseY;
let firstRow = cursorY;
while (firstRow > 0 && buffer.getLine(firstRow)?.isWrapped) firstRow -= 1;
let line = "";
for (let row = firstRow; row <= cursorY; row += 1) {
const bufferLine = buffer.getLine(row);
if (!bufferLine) break;
line += bufferLine.translateToString(false);
}
return line.slice(-8_192);
} catch {
return "";
}
};
/**
* detectPrompt truncates userInput at the cursor.
*
* Never absorb painted tails into the command when the keystroke buffer is
* non-empty: zsh same-token autosuggest (`g` + paint `git status`) must stay
* as `g`. Incomplete remote echo (keystrokes ahead of the line) may promote
* the buffer into userInput. History that rewrote the line is handled later
* via live-line comparison (#2191 review).
*/
const expandPromptUserInputToFullLine = (
term: XTerm,
prompt: PromptDetectionResult,
typedBuffer: string,
): PromptDetectionResult => {
if (!prompt.isAtPrompt || !prompt.promptText) return prompt;
const buffered = typedBuffer.trim();
if (!buffered) return prompt;
// Incomplete echo: keystrokes ahead of what the line shows.
// - visible "su", buffer "sudo" (same single word still typing)
// - visible "su", buffer "su -" (more argv)
// Not: visible "su", buffer "sudo whoami" (history may have shortened).
if (
prompt.userInput.length > 0
&& buffered.startsWith(prompt.userInput)
&& buffered.length > prompt.userInput.length
) {
const next = buffered[prompt.userInput.length] ?? "";
const singleWordEchoLag =
!buffered.includes(" ")
&& /[\w@./:-]/.test(next);
const moreArgsEchoLag = next === " " || next === "\t";
if (singleWordEchoLag || moreArgsEchoLag) {
return {
...prompt,
userInput: buffered,
cursorOffset: buffered.length,
};
}
}
return prompt;
};
/** Status / cwd chrome that must not be recorded as a submitted command. */
const isDecorationOnlyCommand = (command: string): boolean => {
const t = command.trim();
if (!t) return true;
if (t === "~" || t.startsWith("~/")) return true;
if (/^[✗✔+*!]$/.test(t)) return true;
if (/^git:\([^)]*\)/.test(t)) return true;
// "git:(main) ✗" leftovers after a partial cache strip
if (/git:\([^)]*\)/.test(t) || /[✗✔]/.test(t)) {
const stripped = t
.replace(/git:\([^)]*\)/g, " ")
.replace(/[✗✔+*!]/g, " ")
.replace(/\s+/g, " ")
.trim();
if (!stripped) return true;
if (/^(?:su|sudo|doas)(?:\s|$)/i.test(stripped)) return false;
if (!/\s/.test(stripped) && !/^(?:su|sudo|doas)$/i.test(stripped)) return true;
}
return false;
};
const hasThemedPromptMarker = (promptText: string): boolean => {
if (isBareThemedTerminator(promptText)) return true;
if (/[❯❮→➜➤⟩»›]/.test(promptText)) return true;
for (const ch of promptText) {
const code = ch.charCodeAt(0);
if (code >= 0xE000 && code <= 0xF8FF) return true;
}
return false;
};
/**
* When the prompt has no trailing space (`user@host:~$su -`), the detector
* may not find a boundary. Fall back to the last known prompt prefix.
*/
const resolveFromCachedPromptPrefix = (
term: XTerm,
lastPromptText: string | undefined,
): string => {
const cached = lastPromptText ?? "";
if (!cached) return "";
const fullInput = readFullLineAfterPrompt(term, cached)?.trim() ?? "";
// Reject partial-cache leftovers like "git:(main) ✗" (#2191 review).
if (!fullInput || isDecorationOnlyCommand(fullInput)) return "";
return fullInput;
};
export const shouldRecordShellHistory = (
command: string,
term?: XTerm | null,
): boolean => {
if (!term) return true;
const trimmed = command.trim();
const alignedResult = getAlignedPrompt(term, command, true);
const prompt = expandPromptUserInputToFullLine(term, alignedResult.prompt, command);
if (!prompt.isAtPrompt) return false;
if (alignedResult.alignedTyped?.trim() === trimmed) return true;
if (reconcilePromptWithExternalCommand(prompt, command)) return true;
// History recall on themed prompts: live userInput still includes cwd/git
// chrome, but reconcile can attribute it back to the prompt (#2191).
if (trimmed) {
const reconciled = reconcilePromptWithTypedInput(prompt, trimmed);
if (reconciled !== prompt && reconciled.userInput.trim() === trimmed) {
return true;
}
}
const liveCommand = prompt.userInput.trim();
if (liveCommand.length === 0) {
return !isNonPromptLine(`${prompt.promptText}${trimmed}`);
}
if (liveCommand === trimmed) return true;
// Themed multi-word / unicode dirs: resolver peels to "su -" but the raw
// userInput is still " My Project su -". Accept trailing resolved commands
// so password assist still arms (#2191 review).
if (
liveCommand === trimmed
|| liveCommand.endsWith(` ${trimmed}`)
|| liveCommand.endsWith(trimmed)
) {
return true;
}
return false;
};
/** Common shell verbs that are commands, not themed directory names. */
const LOOKS_LIKE_SHELL_COMMAND_PREFIX =
/^(?:echo|printf|ls|cd|pwd|cat|grep|find|sed|awk|vim|nvim|nano|git|npm|yarn|pnpm|node|python|pip|docker|make|curl|wget|ssh|scp|rsync|tar|zip|unzip|chmod|chown|cp|mv|rm|mkdir|touch|tail|head|less|more|man|which|type|alias|export|source|bash|zsh|fish|sh|env|ps|top|htop|kill|df|du|free|uname|whoami|id|date|clear|history|exit|logout|true|false|test|expr|seq|sleep|yes|nohup|time|env|sudo|su|doas)\b/i;
const CWD_NAME_COMMAND_COLLISION =
/^(?:git|node|go|npm|yarn|pnpm|docker|src|app|bin|lib|test|tmp|home|user|root|www|html|dist|build|target|main|dev|prod|staging)$/i;
/** Path / git-status chrome that may sit between a glyph prompt and the command. */
const isPlausiblePathDecoration = (text: string): boolean => {
const s = text.trim();
if (!s) return true;
if (s === "~" || s.startsWith("~/") || s.startsWith("/")) return true;
// Privilege verbs in the prefix are never directory chrome.
if (/\b(?:su|sudo|doas)\b/i.test(s)) return false;
const words = s.split(/\s+/).filter(Boolean);
// Any ordinary shell verb in the prefix means this is command text, not cwd
// chrome — including after git-status markers (`git:(main) ✗ echo …`).
for (const word of words) {
const token = word.replace(/^git:\([^)]*\)$/i, "").replace(/[✗✔+*!]/g, "");
if (!token) continue;
if (
LOOKS_LIKE_SHELL_COMMAND_PREFIX.test(token)
&& !CWD_NAME_COMMAND_COLLISION.test(token)
&& !/^[./~]/.test(token)
) {
return false;
}
}
// Pure git-status / status glyph chrome.
if (/^git:\([^)]*\)/.test(s) || /^[✗✔+*!]+$/.test(s)) return true;
if (words.every((w) => /^git:\([^)]*\)$/i.test(w) || /^[✗✔+*!]+$/.test(w))) {
return true;
}
// Allow unicode letters and common path punctuation in directory names.
return /^(?:[^\s\\]|[./~_()-])+(?:\s+(?:[^\s\\]|[./~_()-])+)*$/u.test(s);
};
/**
* Recover a privilege command from a line with no space after the prompt marker
* (`user@host:~$su -`) when prompt detection and lastPromptText both fail.
*/
const resolveNoSpacePromptPrivilegeCommand = (term: XTerm): string => {
try {
const buffer = term.buffer.active;
const cursorY = buffer.cursorY + buffer.baseY;
const line = buffer.getLine(cursorY);
if (!line) return "";
const raw = line.translateToString(false).replace(/\s+$/g, "");
const match = raw.match(/^(.*?[$#%>])((?:sudo|su|doas)(?:\s.*)?)$/i);
if (!match) return "";
const command = match[2].trim();
return shouldArmSudoPasswordAutofill(command) ? command : "";
} catch {
return "";
}
};
/**
* Peel themed cwd/git chrome from userInput.
*
* Prefer a trailing privilege command (su/sudo/doas) when the prefix looks like
* path decoration — longest-prompt peel alone turns ` su -` into `-` and
* `➜ My Project su -` into `Project su -` (#2191 review).
*/
const peelThemedCommandFromPrompt = (
prompt: PromptDetectionResult,
): string => {
const live = prompt.userInput;
const trimmedStart = live.trimStart();
if (!trimmedStart) return "";
const privilegeMatch = trimmedStart.match(
/(?:^|\s)((?:sudo|su|doas)(?:\s+.*)?)$/i,
);
if (privilegeMatch) {
const command = privilegeMatch[1].trim();
const before = trimmedStart
.slice(0, trimmedStart.length - privilegeMatch[1].length)
.trim();
if (isPlausiblePathDecoration(before)) {
return command;
}
}
// Leading whitespace only: try path-prefix + trailing command before taking
// the whole line (avoids ` My Project ls` → recording the directory too).
const trimmed = live.trim();
if (
trimmed
&& live.endsWith(trimmed)
&& /^\s+$/.test(live.slice(0, live.length - trimmed.length))
) {
const parts = trimmed.split(/\s+/).filter(Boolean);
if (parts.length === 1 || shouldArmSudoPasswordAutofill(trimmed)) {
return trimmed;
}
for (let i = 1; i < parts.length; i += 1) {
const before = parts.slice(0, i).join(" ");
const command = parts.slice(i).join(" ");
if (!command || !isPlausiblePathDecoration(before)) continue;
// Privilege after any path chrome, or ordinary commands only after a
// multi-word / path-sigil directory (not `git status` → `status`).
if (
shouldArmSudoPasswordAutofill(command)
|| before.includes(" ")
|| before === "~"
|| before.startsWith("~/")
|| before.startsWith("/")
) {
return command;
}
}
return trimmed;
}
// Reconcile peel: prefer the longest command (avoid over-peeling to "-").
let best: { command: string; length: number } | null = null;
for (let start = 0; start < live.length; start += 1) {
if (start > 0 && live[start - 1] !== " ") continue;
const candidate = live.slice(start);
if (!candidate.trim()) continue;
const extra = live.slice(0, start);
// Never treat privilege words as path chrome in the stripped prefix.
if (/\b(?:su|sudo|doas)\b/i.test(extra)) continue;
const reconciled = reconcilePromptWithTypedInput(prompt, candidate);
if (reconciled === prompt || reconciled.userInput !== candidate) continue;
const command = candidate.trim();
if (!command) continue;
if (!best || command.length > best.length) {
best = { command, length: command.length };
}
}
return best?.command ?? "";
};
/**
* Read the command currently shown on the prompt line, stripping themed
* prompt chrome (➜ ~ / git status decorations) when needed.
*
* lastPromptText is only trusted when the remainder reconciles against the
* original detector split (avoids partial-cache pollution and over-peeling
* a clean remainder down to "-"). Complete Powerline prompts keep the
* detector's multiword userInput (#2191).
*/
export const resolveLiveSubmittedCommand = (
prompt: PromptDetectionResult,
lastPromptText?: string,
): string => {
if (!prompt.isAtPrompt) return "";
// Clean standard prompts (user@host:~$ su -).
const direct = getCommandToRecordOnEnter(prompt, null, "", true);
if (direct) return direct;
// Cached full prompt first: handles space-containing dirs ("➜ My Project ")
// before peel can mis-split on the path (#2191 review).
const cachedPrompt = lastPromptText ?? "";
if (cachedPrompt) {
const fullLine = `${prompt.promptText}${prompt.userInput}`;
if (fullLine.startsWith(cachedPrompt)) {
const remainder = fullLine.slice(cachedPrompt.length).trim();
if (remainder && !isDecorationOnlyCommand(remainder)) {
if (prompt.userInput.endsWith(remainder)) {
const reconciled = reconcilePromptWithTypedInput(prompt, remainder);
if (reconciled !== prompt && reconciled.userInput.trim() === remainder) {
return remainder;
}
}
// Exact cache prefix on the rendered line (no-space / multi-word dirs).
return remainder;
}
}
}
// Incomplete bare-glyph split (➜ + cwd/git in userInput): peel chrome.
if (isBareThemedTerminator(prompt.promptText)) {
const peeled = peelThemedCommandFromPrompt(prompt);
if (peeled) return peeled;
}
// Themed prompts (including prefixed terminators like "⚡ ➜ "): peel cwd/path
// chrome before accepting userInput (⚡ ➜ ~ su - → su -).
if (hasThemedPromptMarker(prompt.promptText)) {
const peeled = peelThemedCommandFromPrompt(prompt);
if (peeled) return peeled;
}
// Complete Powerline / multi-glyph prompts may already isolate multiword
// commands (sudo whoami) when peel has nothing left to strip.
if (!isBareThemedTerminator(prompt.promptText)) {
const liveTrimmed = prompt.userInput.trim();
if (
liveTrimmed
&& prompt.promptText.trim().length > 0
&& !isDecorationOnlyCommand(liveTrimmed)
) {
const rawTokens = liveTrimmed.split(/\s+/).filter(Boolean);
if (
rawTokens.length <= 1
&& hasThemedPromptMarker(prompt.promptText)
&& !/^(?:su|sudo|doas)$/i.test(liveTrimmed)
) {
return "";
}
return liveTrimmed;
}
}
return peelThemedCommandFromPrompt(prompt);
};
/**
* True when a live "command" is really empty-prompt chrome (cwd / git status)
* left in userInput by the detector — not a history-recalled command.
*/
const isEmptyPromptDecoration = (
live: string,
prompt: PromptDetectionResult,
): boolean => {
const command = live.trim();
if (!command) return true;
if (isDecorationOnlyCommand(command)) return true;
// Bare glyph or multi-glyph themed prompts can leave a single cwd token.
if (!hasThemedPromptMarker(prompt.promptText)) return false;
const rawTokens = prompt.userInput.trim().split(/\s+/).filter(Boolean);
if (rawTokens.length <= 1) {
// Cwd chrome often keeps a trailing space after the directory token
// (" git "). A real one-word history command usually has no trailing pad.
if (/\s$/.test(prompt.userInput)) return true;
// One-word history of su/sudo/doas ( su) with no trailing pad.
if (/^(?:su|sudo|doas)$/i.test(command)) return false;
return true;
}
return false;
};
/**
* Resolve the command that Enter is submitting.
*
* The keystroke buffer alone is incomplete for shell history recall (↑/↓ /
* Ctrl+R): those keys redraw the line remotely and never rewrite
* commandBuffer. Prefer an aligned buffer when reliable; otherwise prefer
* the live line when it disagrees with a stale prefix (#2191).
*/
export const resolveSubmittedShellCommand = (
commandBuffer: string,
term?: XTerm | null,
lastPromptText?: string,
): string => {
const buffered = commandBuffer.trim();
if (!term) return buffered;
const alignedResult = getAlignedPrompt(term, commandBuffer, true);
// Expand only for incomplete echo (never same-token autosuggest paint).
const prompt = expandPromptUserInputToFullLine(
term,
alignedResult.prompt,
commandBuffer,
);
const liveFromCursor = prompt.isAtPrompt
? resolveLiveSubmittedCommand(prompt, lastPromptText)
: "";
// Full painted line (for history that rewrote past a stale typed prefix).
// Only adopt it over the buffer when it is a privilege command the buffer
// is not — autosuggest `g`→`git status` stays on the buffer.
let liveFromFull = liveFromCursor;
if (prompt.isAtPrompt && prompt.promptText) {
const fullInput = readFullLineAfterPrompt(term, prompt.promptText);
if (fullInput && fullInput !== prompt.userInput) {
liveFromFull = resolveLiveSubmittedCommand(
{
...prompt,
userInput: fullInput,
cursorOffset: fullInput.length,
},
lastPromptText,
);
}
}
const preferFullOverBuffer = (
buffer: string,
fullLive: string,
): boolean => {
if (!fullLive || fullLive === buffer) return false;
if (!fullLive.startsWith(buffer) || fullLive.length <= buffer.length) {
return false;
}
// History to privilege command from a non-privilege typed prefix ("s"→"su -").
return (
shouldArmSudoPasswordAutofill(fullLive)
&& !shouldArmSudoPasswordAutofill(buffer)
);
};
const aligned = alignedResult.alignedTyped?.trim() ?? "";
// Enter submits the whole zle line. detectPrompt truncates at the cursor, so
// after ↑ recall + mid-line edit the keystroke buffer may only hold the
// replacement token ("start") while the painted line is still
// "systemctl start firewalld". Prefer that full paint only when:
// - the buffer is already a whole token there (not a prefix of "status"), and
// - cell styles are available so zsh autosuggest ghosts were stripped from
// liveFromFull (cross-token " upgrade" must not be recorded).
const paintedLineContinuesPastCursor =
Boolean(liveFromFull)
&& Boolean(liveFromCursor)
&& liveFromFull !== liveFromCursor
&& liveFromFull.startsWith(liveFromCursor);
const bufferIsWholeTokenInPaintedLine = Boolean(
buffered
&& liveFromFull
&& liveFromFull.split(/\s+/).includes(buffered),
);
const cursorLine = term.buffer.active.getLine(
term.buffer.active.cursorY + term.buffer.active.baseY,
);
const canTrustPostCursorPaint = typeof cursorLine?.getCell === "function";
const preferFullPaintedLine =
paintedLineContinuesPastCursor
&& canTrustPostCursorPaint
&& bufferIsWholeTokenInPaintedLine
&& buffered !== liveFromCursor
&& buffered !== liveFromFull
&& aligned !== liveFromCursor;
// Aligned buffer can match a stale mid-line prefix after history recall
// (typed "s", recalled "su -", cursor after "s"), or only a suffix when
// history prepended text (typed "whoami", recalled "sudo whoami").
if (aligned) {
if (preferFullOverBuffer(aligned, liveFromFull) || preferFullPaintedLine) {
return liveFromFull;
}
if (
liveFromCursor
&& liveFromCursor.length > aligned.length
&& (
liveFromCursor.startsWith(aligned)
|| liveFromCursor.endsWith(aligned)
|| liveFromCursor.endsWith(` ${aligned}`)
)
) {
return liveFromCursor;
}
return aligned;
}
if (!prompt.isAtPrompt) {
// No-space prompts (`user@host:~$su -`) often fail boundary detection;
// recover via the last fully-detected prompt prefix, then a direct
// privilege-command scan for the first history recall before any cache.
if (!buffered) {
return (
resolveFromCachedPromptPrefix(term, lastPromptText)
|| resolveNoSpacePromptPrivilegeCommand(term)
);
}
return buffered;
}
const live = liveFromCursor;
if (!buffered) {
// Empty buffer: submitted text is the painted command (history at EOL or
// mid-line). Keystroke autosuggest always leaves a non-empty buffer.
const emptyLive = liveFromFull || live;
if (!emptyLive || isEmptyPromptDecoration(emptyLive, prompt)) {
return resolveFromCachedPromptPrefix(term, lastPromptText);
}
return emptyLive;
}
if (preferFullOverBuffer(buffered, liveFromFull) || preferFullPaintedLine) {
return liveFromFull;
}
if (!live || live === buffered) return buffered || live;
// Direct send / incomplete echo: keystroke buffer is the real command even
// when the themed line still only shows decoration (➜ netcatty + "ls").
if (reconcilePromptWithExternalCommand(prompt, buffered)) {
return buffered;
}
// History / reverse-search replaced a typed prefix (buffer "s", live "su -").
if (live.startsWith(buffered) && live.length > buffered.length) {
return live;
}
if (preferFullOverBuffer(buffered, liveFromFull)) {
return liveFromFull;
}
// Echo lag: live is a visible prefix of what the user typed.
// - "su" + buffer "su -" → same command, more argv → buffer
// - "su" + buffer "sudo" → incomplete echo of the same word → buffer
// - "su" + buffer "sudo whoami" → history shortened the line → live
if (buffered.startsWith(live) && buffered.length > live.length) {
const next = buffered[live.length] ?? "";
if (next === " " || next === "" || live.length === 0) {
return buffered;
}
const liveFirst = live.split(/\s+/)[0] ?? "";
const bufFirst = buffered.split(/\s+/)[0] ?? "";
// Single-word buffer still extending the echoed prefix: trust keystrokes.
if (
!buffered.includes(" ")
&& bufFirst.startsWith(liveFirst)
&& bufFirst !== liveFirst
) {
return buffered;
}
// Multi-word typed buffer vs shorter live command: history replaced it.
return live;
}
// Live ends with the typed buffer: history grew leftward ("sudo whoami" after
// typing "whoami", or "git" + typed "st"), or path chrome + typed command.
// Prefer live when the buffer is a trailing whole token (space-delimited) or
// a privilege wrapper; otherwise keep the keystroke buffer.
if (live.endsWith(buffered) || live.endsWith(` ${buffered}`)) {
if (live === buffered) return live;
if (
/^(?:sudo|su|doas|command|builtin)\s/i.test(live)
|| live.endsWith(` ${buffered}`)
) {
return live;
}
return buffered;
}
// Completely different commands: trust the live line (history replaced it).
return live;
};
export const recordTerminalCommandExecution = (
command: string,
ctx: TerminalCommandExecutionContext,
term?: XTerm | null,
options?: { sensitive?: boolean; allowHostStyleGreaterThanPrompt?: boolean },
): string | null => {
if (options?.sensitive || isSensitiveTerminalChallenge(readCurrentLogicalTerminalLine(term))) {
ctx.commandBufferRef.current = "";
return null;
}
const lastPromptText = ctx.promptLineBreakStateRef?.current?.lastPromptText;
const cmd = resolveSubmittedShellCommand(command, term, lastPromptText);
if (cmd) {
ctx.onCommandSubmitted?.(cmd, ctx.host.id, ctx.host.label, ctx.sessionId);
}
const alignedPrompt = term ? getAlignedPrompt(term, command, true).prompt : null;
const trustedPrompt = Boolean(
term && alignedPrompt?.isAtPrompt
&& isConfirmedTerminalShellPrompt(alignedPrompt.promptText, {
allowHostStyleGreaterThan: options?.allowHostStyleGreaterThanPrompt,
}),
);
if (cmd && shouldRecordShellHistory(cmd, term)) {
if (trustedPrompt) {
ctx.onTrustedCommandSubmitted?.(cmd, ctx.host.id, ctx.host.label, ctx.sessionId);
}
ctx.onCommandExecuted?.(cmd, ctx.host.id, ctx.host.label, ctx.sessionId);
ctx.commandBufferRef.current = "";
markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef, term, cmd);
return cmd;
}
ctx.commandBufferRef.current = "";
markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef, term, cmd || command);
return null;
};

View File

@@ -0,0 +1,18 @@
const commandInjectionReadyReaders = new Map<string, () => boolean>();
export function registerTerminalCommandInjectionReadyReader(
sessionId: string,
reader: () => boolean,
): () => void {
commandInjectionReadyReaders.set(sessionId, reader);
return () => {
if (commandInjectionReadyReaders.get(sessionId) === reader) {
commandInjectionReadyReaders.delete(sessionId);
}
};
}
/** True when the live terminal reports an idle shell prompt ready for injection. */
export function isTerminalReadyForCommandInjection(sessionId: string): boolean {
return commandInjectionReadyReaders.get(sessionId)?.() === true;
}

View File

@@ -0,0 +1,9 @@
/**
* Incomplete terminal control strings are normally tiny. A missing terminator
* must not make every later chunk retain and rescan an ever-growing prefix.
*/
export const MAX_INCOMPLETE_TERMINAL_CONTROL_SEQUENCE_CHARS = 64 * 1024;
export const canRetainIncompleteTerminalControlSequence = (value: string): boolean => (
value.length <= MAX_INCOMPLETE_TERMINAL_CONTROL_SEQUENCE_CHARS
);

View File

@@ -0,0 +1,86 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
isPlainCtrlCInterruptChord,
isPlainMetaCCopyChord,
shouldPassThroughCopyShortcut,
} from "./terminalCopyShortcut.ts";
const keyboardEvent = (
key: string,
code: string,
modifiers: Partial<KeyboardEvent> = {},
): KeyboardEvent => ({
key,
code,
ctrlKey: false,
shiftKey: false,
altKey: false,
metaKey: false,
...modifiers,
}) as KeyboardEvent;
test("plain Ctrl+C copy with no selection passes through for SIGINT", () => {
const event = keyboardEvent("c", "KeyC", { ctrlKey: true });
assert.equal(isPlainCtrlCInterruptChord(event), true);
assert.equal(shouldPassThroughCopyShortcut("copy", false, event), true);
});
test("plain Cmd+C copy with no selection passes through for Kitty Super+C", () => {
const event = keyboardEvent("c", "KeyC", { metaKey: true });
assert.equal(isPlainMetaCCopyChord(event), true);
assert.equal(isPlainCtrlCInterruptChord(event), false);
assert.equal(shouldPassThroughCopyShortcut("copy", false, event), true);
});
test("copy shortcut does not pass through when text is selected", () => {
const ctrlC = keyboardEvent("c", "KeyC", { ctrlKey: true });
const cmdC = keyboardEvent("c", "KeyC", { metaKey: true });
assert.equal(shouldPassThroughCopyShortcut("copy", true, ctrlC), false);
assert.equal(shouldPassThroughCopyShortcut("copy", true, cmdC), false);
});
test("copy shortcut does not pass through for shifted or alternate chords", () => {
assert.equal(
shouldPassThroughCopyShortcut("copy", false, keyboardEvent("C", "KeyC", { ctrlKey: true, shiftKey: true })),
false,
);
assert.equal(
shouldPassThroughCopyShortcut("copy", false, keyboardEvent("C", "KeyC", { metaKey: true, shiftKey: true })),
false,
);
assert.equal(
shouldPassThroughCopyShortcut("copy", false, keyboardEvent("l", "KeyL", { ctrlKey: true })),
false,
);
assert.equal(
shouldPassThroughCopyShortcut("copy", false, keyboardEvent("c", "KeyC", { metaKey: true, ctrlKey: true })),
false,
);
assert.equal(
shouldPassThroughCopyShortcut("paste", false, keyboardEvent("c", "KeyC", { ctrlKey: true })),
false,
);
assert.equal(
shouldPassThroughCopyShortcut("paste", false, keyboardEvent("c", "KeyC", { metaKey: true })),
false,
);
});
test("plain Ctrl+C copy passthrough follows the physical C key on non-Latin layouts", () => {
const event = keyboardEvent("\u0441", "KeyC", { ctrlKey: true });
assert.equal(isPlainCtrlCInterruptChord(event), true);
assert.equal(shouldPassThroughCopyShortcut("copy", false, event), true);
});
test("plain Cmd+C copy passthrough follows the physical C key on non-Latin layouts", () => {
const event = keyboardEvent("\u0441", "KeyC", { metaKey: true });
assert.equal(isPlainMetaCCopyChord(event), true);
assert.equal(shouldPassThroughCopyShortcut("copy", false, event), true);
});

View File

@@ -0,0 +1,45 @@
type CopyShortcutKeyEvent = Pick<
KeyboardEvent,
"key" | "code" | "ctrlKey" | "shiftKey" | "altKey" | "metaKey"
>;
function isPhysicalCKey(e: CopyShortcutKeyEvent): boolean {
return e.key.toLowerCase() === "c" || e.code === "KeyC";
}
export function isPlainCtrlCInterruptChord(e: CopyShortcutKeyEvent): boolean {
return e.ctrlKey
&& !e.shiftKey
&& !e.altKey
&& !e.metaKey
&& isPhysicalCKey(e);
}
/** macOS Cmd+C / Super+C — forward when there is no xterm selection. */
export function isPlainMetaCCopyChord(e: CopyShortcutKeyEvent): boolean {
return e.metaKey
&& !e.ctrlKey
&& !e.shiftKey
&& !e.altKey
&& isPhysicalCKey(e);
}
/**
* When copy matches with no xterm selection, pass the chord through instead of
* consuming an empty clipboard write.
*
* - Ctrl+C → SIGINT (or Kitty Ctrl+C)
* - Cmd+C → Kitty Super+C for nested TUIs (e.g. Herdr)
*
* Other no-selection copy bindings stay consumed as a safe no-op so keys like
* F5 / Ctrl+L are not forwarded to the remote (#1461).
*/
export function shouldPassThroughCopyShortcut(
action: string,
hasSelection: boolean,
e: CopyShortcutKeyEvent,
): boolean {
return action === "copy"
&& !hasSelection
&& (isPlainCtrlCInterruptChord(e) || isPlainMetaCCopyChord(e));
}

View File

@@ -0,0 +1,148 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
registerConnectionToken,
runDistroDetection,
} from "./terminalDistroDetection.ts";
test("runDistroDetection uses SSH banner but skips POSIX probes for manually marked network devices", async () => {
let remoteInfoCalls = 0;
let distroProbeCalls = 0;
const detected: string[] = [];
const token = registerConnectionToken("ssh-session");
await runDistroDetection({
host: {
id: "host-1",
label: "HPE iLO",
hostname: "192.168.2.2",
username: "root",
deviceType: "network",
},
terminalBackend: {
getSessionRemoteInfo: async () => {
remoteInfoCalls += 1;
return { success: true, remoteSshVersion: "SSH-2.0-mpSSH_0.2.1" };
},
getSessionDistroInfo: async () => {
distroProbeCalls += 1;
return { success: false, error: "network device closed the extra channel" };
},
},
onOsDetected: (_hostId: string, distro: string) => {
detected.push(distro);
},
} as never, "ssh-session", token);
assert.equal(remoteInfoCalls, 1);
assert.equal(distroProbeCalls, 0);
assert.deepEqual(detected, ["hpe"]);
});
test("runDistroDetection normalizes Darwin probe output to macos", async () => {
let remoteInfoCalls = 0;
let distroProbeCalls = 0;
const detected: string[] = [];
const token = registerConnectionToken("macos-session");
await runDistroDetection({
host: {
id: "macos-host",
label: "Mac mini",
hostname: "mac-mini.local",
username: "dev",
},
terminalBackend: {
getSessionRemoteInfo: async () => {
remoteInfoCalls += 1;
return { success: true, remoteSshVersion: "SSH-2.0-OpenSSH_9.9" };
},
getSessionDistroInfo: async () => {
distroProbeCalls += 1;
return {
success: true,
stdout: "Darwin mac-mini.local 24.5.0 Darwin Kernel Version 24.5.0\n",
stderr: "",
};
},
},
onOsDetected: (_hostId: string, distro: string) => {
detected.push(distro);
},
} as never, "macos-session", token);
assert.equal(remoteInfoCalls, 1);
assert.equal(distroProbeCalls, 1);
assert.deepEqual(detected, ["macos"]);
});
test("runDistroDetection normalizes FreeBSD uname output", async () => {
const detected: string[] = [];
const token = registerConnectionToken("freebsd-session");
await runDistroDetection({
host: {
id: "freebsd-host",
label: "FreeBSD server",
hostname: "freebsd.example.com",
username: "root",
},
terminalBackend: {
getSessionRemoteInfo: async () => ({
success: true,
remoteSshVersion: "SSH-2.0-OpenSSH_9.7 FreeBSD-20240806",
}),
getSessionDistroInfo: async () => ({
success: true,
stdout: "FreeBSD freebsd.example.com 14.3-RELEASE-p1 GENERIC amd64\n",
stderr: "",
}),
},
onOsDetected: (_hostId: string, distro: string) => {
detected.push(distro);
},
} as never, "freebsd-session", token);
assert.deepEqual(detected, ["freebsd"]);
});
test("Windows OpenSSH is identified without sending POSIX probes", async () => {
const detected: string[] = [];
await runDistroDetection({
host: { id: 'windows', os: 'linux' },
terminalBackend: {
getSessionRemoteInfo: async () => ({ success: true, remoteSshVersion: 'SSH-2.0-OpenSSH_for_Windows_9.5' }),
getSessionDistroInfo: async () => { throw new Error('must not probe Windows with POSIX commands'); },
},
onOsDetected: (_id: string, distro: string) => detected.push(distro),
} as never, 'windows', registerConnectionToken('windows'));
assert.deepEqual(detected, ['windows']);
});
test("failed or unrecognized probes do not invent an operating system", async () => {
const detected: string[] = [];
await runDistroDetection({
host: { id: 'unknown', os: 'linux' },
terminalBackend: {
getSessionDistroInfo: async () => ({ success: true, stdout: '', stderr: 'Linux command not found' }),
},
onOsDetected: (_id: string, distro: string) => detected.push(distro),
} as never, 'unknown', registerConnectionToken('unknown'));
assert.deepEqual(detected, []);
});
test("a superseded Windows detection cannot update the newer connection", async () => {
const detected: string[] = [];
await runDistroDetection({
host: { id: 'windows', os: 'linux' },
terminalBackend: {
getSessionRemoteInfo: async () => {
registerConnectionToken('reconnected');
return { success: true, remoteSshVersion: 'OpenSSH_for_Windows_9.5' };
},
},
onOsDetected: (_id: string, distro: string) => detected.push(distro),
} as never, 'reconnected', registerConnectionToken('reconnected'));
assert.deepEqual(detected, []);
});

View File

@@ -0,0 +1,108 @@
import {
classifyDistroId,
detectVendorFromSshVersion,
normalizeDistroId,
} from "../../../domain/host";
import { logger } from "../../../lib/logger";
import type { TerminalSessionStartersContext } from "./createTerminalSessionStarters.types";
/**
* Per-connection token for stale-timer detection. The renderer reuses the
* same sessionId across reconnects within a tab, so comparing sessionIds
* cannot distinguish "the current attempt" from "a previous attempt on
* the same slot". We assign each startSSH call a fresh token object and
* store it in this module-local map, keyed by sessionId. A timer that
* was scheduled under an older token will see a different value here and
* bail out. The map entry for a sessionId is overwritten on each new
* connect and stays around until the app exits — since there is only one
* entry per active session, the memory cost is negligible.
*/
const connectionTokensBySessionId = new Map<string, object>();
export const isConnectionTokenCurrent = (sessionId: string, token: object): boolean =>
connectionTokensBySessionId.get(sessionId) === token;
export const registerConnectionToken = (sessionId: string): object => {
const connectionToken = {};
connectionTokensBySessionId.set(sessionId, connectionToken);
return connectionToken;
};
export const clearConnectionToken = (sessionId: string): void => {
connectionTokensBySessionId.delete(sessionId);
};
export const runDistroDetection = async (
ctx: TerminalSessionStartersContext,
sessionId: string,
connectionToken: object,
) => {
// Stale-session guard: the renderer reuses ctx.sessionId across
// reconnects in the same tab, so comparing sessionIds is not enough.
// We compare against a per-connection token instead; if a newer
// connect attempt has run, it will have replaced the token in the
// module-level map and this check will fail. Repeated after every
// await because the session can change during an async call.
const isStillCurrent = () => isConnectionTokenCurrent(sessionId, connectionToken);
if (!isStillCurrent()) return;
const isKnownNetworkDevice =
ctx.host.deviceType === "network" ||
classifyDistroId(ctx.host.distro) === "network-device";
// Step 1: try to classify from the SSH server identification string
// captured at handshake time. This is free (no extra channel) and
// reliably identifies most network-device vendors (Cisco IOS, Huawei
// VRP, HPE Comware, MikroTik, Fortinet, etc.) so we can skip the
// POSIX-shell probe entirely for those hosts — which otherwise fails
// and, on devices like Cisco / Juniper with AAA logging, generates an
// extra session log entry per connect.
try {
if (ctx.terminalBackend.getSessionRemoteInfo && sessionId) {
const info = await ctx.terminalBackend.getSessionRemoteInfo(sessionId);
if (!isStillCurrent()) return;
if (!isKnownNetworkDevice && /^(?:SSH-(?:2\.0|1\.99)-)?OpenSSH_for_Windows(?:_|$)/i.test(info?.remoteSshVersion || '')) {
ctx.onOsDetected?.(ctx.host.id, 'windows');
return;
}
const vendor = detectVendorFromSshVersion(info?.remoteSshVersion);
if (vendor) {
ctx.onOsDetected?.(ctx.host.id, vendor);
return;
}
}
} catch (err) {
logger.warn("SSH banner vendor detection failed", err);
}
if (!isStillCurrent()) return;
if (isKnownNetworkDevice) return;
// Step 2: unknown or generic OpenSSH/Dropbear — fall back to the
// /etc/os-release probe to pick a distro-specific icon. We deliberately
// use `getSessionDistroInfo` which runs the probe on the *existing*
// SSH connection's exec channel instead of spinning up a brand new
// SSH client the way `execCommand` would. That saves a full handshake
// round-trip on every connect, and on OpenSSH-fronted network devices
// that we couldn't identify from the banner (JUNOS, NX-OS, EOS) it
// avoids one extra AAA session log entry per connect.
try {
if (ctx.terminalBackend.getSessionDistroInfo && sessionId) {
const res = await ctx.terminalBackend.getSessionDistroInfo(sessionId);
if (!isStillCurrent()) return;
if (!res?.success) return;
const data = (res.stdout || "").trim();
const idMatch = data.match(/^ID="?([\w-]+)"?$/im);
const rawDistro = idMatch
? idMatch[1]
: (data.match(/^(Linux|Darwin|FreeBSD)\b/i)?.[1] || "").toLowerCase();
// An os-release ID confirms Linux even for distributions without a dedicated icon.
const distro = normalizeDistroId(rawDistro) || (idMatch ? 'linux' : '');
if (distro) ctx.onOsDetected?.(ctx.host.id, distro);
}
} catch (err) {
logger.warn("OS probe failed", err);
}
};

View File

@@ -0,0 +1,64 @@
import assert from "node:assert/strict";
import test from "node:test";
import { FLOW_CHAR_COUNT_ACK_SIZE } from "./terminalFlowConstants.ts";
import {
ackTerminalSessionFlow,
clearTerminalSessionFlowAck,
createFlowAckBuffer,
flushTerminalSessionFlowAck,
} from "./terminalFlowAckBuffer.ts";
test("createFlowAckBuffer emits fixed-size batches like VS Code AckDataBufferer", () => {
const acked: number[] = [];
const buffer = createFlowAckBuffer((bytes) => acked.push(bytes));
buffer.ack(FLOW_CHAR_COUNT_ACK_SIZE);
assert.deepEqual(acked, []);
buffer.ack(1);
assert.deepEqual(acked, [FLOW_CHAR_COUNT_ACK_SIZE]);
buffer.ack(FLOW_CHAR_COUNT_ACK_SIZE);
assert.deepEqual(acked, [FLOW_CHAR_COUNT_ACK_SIZE, FLOW_CHAR_COUNT_ACK_SIZE]);
buffer.ack(FLOW_CHAR_COUNT_ACK_SIZE + 1);
assert.deepEqual(acked, [
FLOW_CHAR_COUNT_ACK_SIZE,
FLOW_CHAR_COUNT_ACK_SIZE,
FLOW_CHAR_COUNT_ACK_SIZE,
]);
});
test("flushTerminalSessionFlowAck drains the remainder", () => {
const acked: number[] = [];
const buffer = createFlowAckBuffer((bytes) => acked.push(bytes), 100);
buffer.ack(250);
assert.deepEqual(acked, [100, 100]);
buffer.flush();
assert.deepEqual(acked, [100, 100, 50]);
});
test("ackTerminalSessionFlow batches per session", () => {
const acked: Array<{ sessionId: string; bytes: number }> = [];
const backend = {
ackSessionFlow: (sessionId: string, bytes: number) => {
acked.push({ sessionId, bytes });
},
};
ackTerminalSessionFlow(backend, "sess-a", FLOW_CHAR_COUNT_ACK_SIZE + 1);
ackTerminalSessionFlow(backend, "sess-b", 10);
flushTerminalSessionFlowAck("sess-a");
flushTerminalSessionFlowAck("sess-b");
assert.deepEqual(acked, [
{ sessionId: "sess-a", bytes: FLOW_CHAR_COUNT_ACK_SIZE },
{ sessionId: "sess-a", bytes: 1 },
{ sessionId: "sess-b", bytes: 10 },
]);
clearTerminalSessionFlowAck("sess-a");
clearTerminalSessionFlowAck("sess-b");
});

View File

@@ -0,0 +1,74 @@
import { FLOW_CHAR_COUNT_ACK_SIZE } from "./terminalFlowConstants";
export type FlowAckBuffer = {
ack: (charCount: number) => void;
flush: () => void;
};
/** Mirrors VS Code `AckDataBufferer` in `terminalProcessManager.ts`. */
export function createFlowAckBuffer(
callback: (charCount: number) => void,
ackSize = FLOW_CHAR_COUNT_ACK_SIZE,
): FlowAckBuffer {
let unsentCharCount = 0;
const emitBatchedAcks = (): void => {
while (unsentCharCount > ackSize) {
unsentCharCount -= ackSize;
callback(ackSize);
}
};
return {
ack(charCount: number): void {
if (charCount <= 0) return;
unsentCharCount += charCount;
emitBatchedAcks();
},
flush(): void {
if (unsentCharCount <= 0) return;
const remainder = unsentCharCount;
unsentCharCount = 0;
callback(remainder);
},
};
}
type FlowAckBackend = {
ackSessionFlow?: (sessionId: string, bytes: number) => void;
};
const sessionAckBuffers = new Map<string, FlowAckBuffer>();
const getOrCreateSessionAckBuffer = (
backend: FlowAckBackend,
sessionId: string,
): FlowAckBuffer | undefined => {
if (!backend.ackSessionFlow) return undefined;
let buffer = sessionAckBuffers.get(sessionId);
if (!buffer) {
buffer = createFlowAckBuffer((bytes) => backend.ackSessionFlow!(sessionId, bytes));
sessionAckBuffers.set(sessionId, buffer);
}
return buffer;
};
/** Queue IPC flow ACK bytes; emits in VS Code-sized batches. */
export const ackTerminalSessionFlow = (
backend: FlowAckBackend,
sessionId: string | null | undefined,
bytes: number,
): void => {
if (!sessionId || bytes <= 0) return;
getOrCreateSessionAckBuffer(backend, sessionId)?.ack(bytes);
};
export const flushTerminalSessionFlowAck = (sessionId: string | null | undefined): void => {
if (!sessionId) return;
sessionAckBuffers.get(sessionId)?.flush();
};
export const clearTerminalSessionFlowAck = (sessionId: string | null | undefined): void => {
if (!sessionId) return;
sessionAckBuffers.delete(sessionId);
};

View File

@@ -0,0 +1,166 @@
import { createRequire } from "node:module";
import assert from "node:assert/strict";
import test from "node:test";
import terminalFlowConstantsJson from "../../../infrastructure/config/terminalFlowConstants.json";
import {
FLOW_CHAR_COUNT_ACK_SIZE,
FLOW_HIGH_WATER_MARK,
FLOW_LOW_WATER_MARK,
MAX_PENDING_WRITE_COALESCE_BYTES,
MAX_PENDING_WRITE_COALESCE_BYTES_FLOOD,
MAX_TERMINAL_PLAIN_WRITE_CHUNK_BYTES,
MAX_TERMINAL_UNBROKEN_WRITE_CHUNK_BYTES,
MAX_TERMINAL_WRITE_QUEUE_DRAIN_BYTES,
TERMINAL_AUX_LONG_LINE_SCAN_LIMIT_CHARS,
TERMINAL_LONG_LINE_PRESSURE_BYTES,
XTERM_WRITE_CALLBACK_BATCH_BYTES,
XTERM_WRITE_CALLBACK_FAST_PATH_MAX_BYTES,
} from "./terminalFlowConstants.ts";
import { createOutputFlowController } from "./outputFlowController.ts";
const require = createRequire(import.meta.url);
const sharedConstantsCjs = require("../../../infrastructure/config/terminalFlowConstants.cjs") as typeof terminalFlowConstantsJson;
test("renderer flow constants match shared terminalFlowConstants.json", () => {
assert.equal(FLOW_HIGH_WATER_MARK, terminalFlowConstantsJson.FLOW_HIGH_WATER_MARK);
assert.equal(FLOW_LOW_WATER_MARK, terminalFlowConstantsJson.FLOW_LOW_WATER_MARK);
assert.equal(FLOW_CHAR_COUNT_ACK_SIZE, terminalFlowConstantsJson.FLOW_CHAR_COUNT_ACK_SIZE);
assert.equal(
MAX_PENDING_WRITE_COALESCE_BYTES,
terminalFlowConstantsJson.MAX_PENDING_WRITE_COALESCE_BYTES,
);
assert.equal(
MAX_PENDING_WRITE_COALESCE_BYTES_FLOOD,
terminalFlowConstantsJson.MAX_PENDING_WRITE_COALESCE_BYTES_FLOOD,
);
assert.equal(
MAX_TERMINAL_PLAIN_WRITE_CHUNK_BYTES,
terminalFlowConstantsJson.MAX_TERMINAL_PLAIN_WRITE_CHUNK_BYTES,
);
assert.equal(
MAX_TERMINAL_UNBROKEN_WRITE_CHUNK_BYTES,
terminalFlowConstantsJson.MAX_TERMINAL_UNBROKEN_WRITE_CHUNK_BYTES,
);
assert.equal(
MAX_TERMINAL_WRITE_QUEUE_DRAIN_BYTES,
terminalFlowConstantsJson.MAX_TERMINAL_WRITE_QUEUE_DRAIN_BYTES,
);
assert.equal(
TERMINAL_LONG_LINE_PRESSURE_BYTES,
terminalFlowConstantsJson.TERMINAL_LONG_LINE_PRESSURE_BYTES,
);
assert.equal(
TERMINAL_AUX_LONG_LINE_SCAN_LIMIT_CHARS,
terminalFlowConstantsJson.TERMINAL_AUX_LONG_LINE_SCAN_LIMIT_CHARS,
);
assert.equal(
XTERM_WRITE_CALLBACK_FAST_PATH_MAX_BYTES,
terminalFlowConstantsJson.XTERM_WRITE_CALLBACK_FAST_PATH_MAX_BYTES,
);
assert.equal(
XTERM_WRITE_CALLBACK_BATCH_BYTES,
terminalFlowConstantsJson.XTERM_WRITE_CALLBACK_BATCH_BYTES,
);
assert.deepEqual(sharedConstantsCjs, terminalFlowConstantsJson);
assert.ok(FLOW_CHAR_COUNT_ACK_SIZE <= FLOW_LOW_WATER_MARK);
assert.ok(MAX_PENDING_WRITE_COALESCE_BYTES_FLOOD < MAX_PENDING_WRITE_COALESCE_BYTES);
});
test("terminal flood limits keep interactive acks responsive", () => {
assert.ok(FLOW_LOW_WATER_MARK <= 8 * 1024);
assert.ok(FLOW_CHAR_COUNT_ACK_SIZE <= 4 * 1024);
// Flood coalesce must stay below bulk so TUI frames can interleave, but stay
// large enough that plain-text dumps (#1961) do not collapse into 8KB shards.
assert.ok(MAX_PENDING_WRITE_COALESCE_BYTES_FLOOD <= 256 * 1024);
assert.ok(MAX_PENDING_WRITE_COALESCE_BYTES_FLOOD >= 64 * 1024);
assert.ok(MAX_TERMINAL_PLAIN_WRITE_CHUNK_BYTES <= FLOW_HIGH_WATER_MARK);
// Unbroken-line shards should stay near Tabby's ~100KB PTY chunk size so
// long dumps stream smoothly instead of 4KB + setTimeout(0) stuttering.
assert.ok(MAX_TERMINAL_UNBROKEN_WRITE_CHUNK_BYTES >= 64 * 1024);
assert.ok(MAX_TERMINAL_UNBROKEN_WRITE_CHUNK_BYTES <= 256 * 1024);
assert.ok(MAX_TERMINAL_WRITE_QUEUE_DRAIN_BYTES <= FLOW_HIGH_WATER_MARK);
// Drain enough per event-loop turn that a 1MB high-water backlog does not
// require dozens of setTimeout(0) yields before SSH can resume.
assert.ok(MAX_TERMINAL_WRITE_QUEUE_DRAIN_BYTES >= 256 * 1024);
assert.ok(MAX_TERMINAL_WRITE_QUEUE_DRAIN_BYTES >= MAX_TERMINAL_UNBROKEN_WRITE_CHUNK_BYTES);
// Long-line pressure can trip earlier than the write shard size so highlight
// / gutter work throttles before bulk parse cost peaks.
assert.ok(TERMINAL_LONG_LINE_PRESSURE_BYTES >= 32 * 1024);
assert.ok(TERMINAL_AUX_LONG_LINE_SCAN_LIMIT_CHARS >= TERMINAL_LONG_LINE_PRESSURE_BYTES);
assert.ok(XTERM_WRITE_CALLBACK_BATCH_BYTES <= FLOW_HIGH_WATER_MARK);
});
test("terminal bulk output keeps large IPC coalesce but Tabby-sized xterm shards", () => {
const bulkCoalesceFloorBytes = 1024 * 1024;
assert.ok(
MAX_PENDING_WRITE_COALESCE_BYTES >= bulkCoalesceFloorBytes,
`MAX_PENDING_WRITE_COALESCE_BYTES (${MAX_PENDING_WRITE_COALESCE_BYTES}) should still batch multi-MB IPC into large flushes`,
);
// xterm write shards stay near Tabby's ~128KB FlowControl threshold so
// multi-line floods (seq/logs) leave the event loop between slices.
assert.ok(MAX_TERMINAL_PLAIN_WRITE_CHUNK_BYTES >= 64 * 1024);
assert.ok(MAX_TERMINAL_PLAIN_WRITE_CHUNK_BYTES <= 256 * 1024);
assert.equal(
MAX_TERMINAL_PLAIN_WRITE_CHUNK_BYTES,
MAX_TERMINAL_UNBROKEN_WRITE_CHUNK_BYTES,
);
});
test("terminal flow allows a large TUI repaint before applying back-pressure", () => {
const events: string[] = [];
const controller = createOutputFlowController({
highWaterMark: FLOW_HIGH_WATER_MARK,
lowWaterMark: FLOW_LOW_WATER_MARK,
onPause: () => events.push("pause"),
onResume: () => events.push("resume"),
});
const tuiFrameBytes = 80 * 1024;
const chunkBytes = 4 * 1024;
for (let received = 0; received < tuiFrameBytes; received += chunkBytes) {
controller.received(chunkBytes);
}
assert.deepEqual(events, []);
});
test("flow high-water mark stays clear of the ssh2 channel window (issue #1961)", () => {
// Pausing the source stream for SSH means calling ssh2 channel pause(),
// which stops the remote from sending until resume() + a full round-trip.
// ssh2's own channel window is 2MB (WINDOW_THRESHOLD 1MB), so a small
// high-water mark makes Netcatty pause/resume dozens of times during a
// multi-MB dump (e.g. `tail -2000f big.log`). Each cycle costs ~1 RTT, so
// on a WAN link the dump crawls (reported ~20s vs ~2s in other clients).
// Keep the high-water mark near the ssh2 window so bulk output flows in a
// handful of pause cycles instead of dozens.
const SSH2_CHANNEL_WINDOW_THRESHOLD_BYTES = 1024 * 1024;
assert.ok(
FLOW_HIGH_WATER_MARK >= SSH2_CHANNEL_WINDOW_THRESHOLD_BYTES,
`FLOW_HIGH_WATER_MARK (${FLOW_HIGH_WATER_MARK}) should be at least the ssh2 window threshold (${SSH2_CHANNEL_WINDOW_THRESHOLD_BYTES})`,
);
// A 4MB bulk dump should trigger only a few pause cycles, not dozens.
const events: string[] = [];
const controller = createOutputFlowController({
highWaterMark: FLOW_HIGH_WATER_MARK,
lowWaterMark: FLOW_LOW_WATER_MARK,
onPause: () => events.push("pause"),
onResume: () => events.push("resume"),
});
const totalBytes = 4 * 1024 * 1024;
const chunkBytes = 32 * 1024; // ssh2 PACKET_SIZE
let delivered = 0;
let pauses = 0;
for (let sent = 0; sent < totalBytes; sent += chunkBytes) {
controller.received(chunkBytes);
if (controller.isPaused()) {
pauses += 1;
// Renderer catches up and drains the backlog before the source resumes.
controller.written(controller.pendingBytes());
delivered = sent + chunkBytes;
}
}
controller.written(totalBytes - delivered);
assert.ok(pauses <= 8, `expected few pause cycles for a 4MB dump, got ${pauses}`);
});

View File

@@ -0,0 +1,29 @@
import terminalFlowConstants from "../../../infrastructure/config/terminalFlowConstants.json";
/**
* Terminal output flow-control thresholds.
*
* Single source of truth: infrastructure/config/terminalFlowConstants.json
* (aligned with VS Code FlowControlConstants).
*/
export const FLOW_HIGH_WATER_MARK = terminalFlowConstants.FLOW_HIGH_WATER_MARK;
export const FLOW_LOW_WATER_MARK = terminalFlowConstants.FLOW_LOW_WATER_MARK;
export const FLOW_CHAR_COUNT_ACK_SIZE = terminalFlowConstants.FLOW_CHAR_COUNT_ACK_SIZE;
export const MAX_PENDING_WRITE_COALESCE_BYTES =
terminalFlowConstants.MAX_PENDING_WRITE_COALESCE_BYTES;
export const MAX_PENDING_WRITE_COALESCE_BYTES_FLOOD =
terminalFlowConstants.MAX_PENDING_WRITE_COALESCE_BYTES_FLOOD;
export const MAX_TERMINAL_PLAIN_WRITE_CHUNK_BYTES =
terminalFlowConstants.MAX_TERMINAL_PLAIN_WRITE_CHUNK_BYTES;
export const MAX_TERMINAL_UNBROKEN_WRITE_CHUNK_BYTES =
terminalFlowConstants.MAX_TERMINAL_UNBROKEN_WRITE_CHUNK_BYTES;
export const MAX_TERMINAL_WRITE_QUEUE_DRAIN_BYTES =
terminalFlowConstants.MAX_TERMINAL_WRITE_QUEUE_DRAIN_BYTES;
export const TERMINAL_LONG_LINE_PRESSURE_BYTES =
terminalFlowConstants.TERMINAL_LONG_LINE_PRESSURE_BYTES;
export const TERMINAL_AUX_LONG_LINE_SCAN_LIMIT_CHARS =
terminalFlowConstants.TERMINAL_AUX_LONG_LINE_SCAN_LIMIT_CHARS;
export const XTERM_WRITE_CALLBACK_FAST_PATH_MAX_BYTES =
terminalFlowConstants.XTERM_WRITE_CALLBACK_FAST_PATH_MAX_BYTES;
export const XTERM_WRITE_CALLBACK_BATCH_BYTES =
terminalFlowConstants.XTERM_WRITE_CALLBACK_BATCH_BYTES;

View File

@@ -0,0 +1,51 @@
import test from "node:test";
import assert from "node:assert/strict";
import { forceXTermFontRemeasure } from "./terminalFontRemeasure";
test("forceXTermFontRemeasure uses xterm char size service when available", () => {
let measured = 0;
const term = {
_core: {
_charSizeService: {
measure() {
measured += 1;
},
},
},
options: {
fontSize: 14,
},
};
assert.equal(forceXTermFontRemeasure(term), true);
assert.equal(measured, 1);
assert.equal(term.options.fontSize, 14);
});
test("forceXTermFontRemeasure falls back to a restored font size nudge", () => {
let fontSize = 14;
const writes: number[] = [];
const term = {
options: {},
} as { options: { fontSize: number } };
Object.defineProperty(term.options, "fontSize", {
get: () => fontSize,
set: (next: number) => {
writes.push(next);
fontSize = next;
},
});
assert.equal(forceXTermFontRemeasure(term), true);
assert.equal(writes.length, 2);
assert.ok(writes[0] > 14);
assert.equal(writes[1], 14);
assert.equal(term.options.fontSize, 14);
});
test("forceXTermFontRemeasure reports unavailable when no measurement path exists", () => {
assert.equal(forceXTermFontRemeasure({}), false);
assert.equal(forceXTermFontRemeasure({ options: { fontSize: Number.NaN } }), false);
});

View File

@@ -0,0 +1,28 @@
export type XTermFontRemeasureTarget = {
_core?: {
_charSizeService?: {
measure?: () => void;
};
};
options?: {
fontSize?: number;
};
};
export function forceXTermFontRemeasure(term: XTermFontRemeasureTarget): boolean {
const charSizeService = term._core?._charSizeService;
if (typeof charSizeService?.measure === "function") {
charSizeService.measure();
return true;
}
const options = term.options;
const fontSize = options?.fontSize;
if (typeof fontSize !== "number" || !Number.isFinite(fontSize)) return false;
// xterm remeasures fonts when fontSize changes. Nudge and restore the value
// so the measurement path runs without changing the user's effective size.
options.fontSize = fontSize + 0.001;
options.fontSize = fontSize;
return true;
}

View File

@@ -0,0 +1,53 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
nextTerminalFontSizeForAction,
nextTerminalFontSizeForWheel,
shouldHandleTerminalFontSizeAction,
terminalFontSizeWheelListenerOptions,
} from './terminalFontZoom.ts';
test('terminal font size actions step and reset within bounds', () => {
assert.equal(nextTerminalFontSizeForAction('increaseTerminalFontSize', 14), 15);
assert.equal(nextTerminalFontSizeForAction('decreaseTerminalFontSize', 14), 13);
assert.equal(nextTerminalFontSizeForAction('resetTerminalFontSize', 18), 14);
assert.equal(nextTerminalFontSizeForAction('increaseTerminalFontSize', 32), 32);
assert.equal(nextTerminalFontSizeForAction('decreaseTerminalFontSize', 10), 10);
assert.equal(nextTerminalFontSizeForAction('copy', 14), null);
});
test('terminal font size actions return null when terminal font zoom is disabled', () => {
assert.equal(nextTerminalFontSizeForAction('increaseTerminalFontSize', 14, true), null);
assert.equal(nextTerminalFontSizeForAction('decreaseTerminalFontSize', 14, true), null);
assert.equal(nextTerminalFontSizeForAction('resetTerminalFontSize', 18, true), null);
});
test('terminal font size actions are not handled when terminal font zoom is disabled', () => {
assert.equal(shouldHandleTerminalFontSizeAction('increaseTerminalFontSize', false), true);
assert.equal(shouldHandleTerminalFontSizeAction('decreaseTerminalFontSize', false), true);
assert.equal(shouldHandleTerminalFontSizeAction('resetTerminalFontSize', false), true);
assert.equal(shouldHandleTerminalFontSizeAction('increaseTerminalFontSize', true), false);
assert.equal(shouldHandleTerminalFontSizeAction('copy', true), false);
});
test('wheel adjusts terminal font size with the platform modifier only', () => {
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: true, metaKey: false, deltaY: -1 }, 14, false), 15);
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: true, metaKey: false, deltaY: 1 }, 14, false), 13);
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: false, metaKey: true, deltaY: -1 }, 14, true), 15);
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: false, metaKey: true, deltaY: 1 }, 14, true), 13);
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: false, metaKey: true, deltaY: -1 }, 14, false), null);
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: true, metaKey: false, deltaY: -1 }, 14, true), null);
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: false, metaKey: false, deltaY: -1 }, 14, false), null);
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: true, metaKey: false, deltaY: 0 }, 14, false), null);
});
test('wheel zoom returns null when terminal font zoom is disabled', () => {
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: true, metaKey: false, deltaY: -1 }, 14, false, true), null);
assert.equal(nextTerminalFontSizeForWheel({ ctrlKey: false, metaKey: true, deltaY: -1 }, 14, true, true), null);
});
test('wheel font-size listener runs before xterm consumes terminal scrolling', () => {
assert.equal(terminalFontSizeWheelListenerOptions.capture, true);
assert.equal(terminalFontSizeWheelListenerOptions.passive, false);
});

View File

@@ -0,0 +1,61 @@
import {
DEFAULT_FONT_SIZE,
MAX_FONT_SIZE,
MIN_FONT_SIZE,
} from "../../../infrastructure/config/fonts";
type WheelLike = Pick<WheelEvent, "ctrlKey" | "metaKey" | "deltaY">;
const TERMINAL_FONT_SIZE_ACTIONS = new Set([
"increaseTerminalFontSize",
"decreaseTerminalFontSize",
"resetTerminalFontSize",
]);
export const terminalFontSizeWheelListenerOptions = {
passive: false,
capture: true,
} as const satisfies AddEventListenerOptions;
export const clampTerminalFontSize = (fontSize: number): number =>
Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, fontSize));
export const isTerminalFontSizeAction = (action: string): boolean =>
TERMINAL_FONT_SIZE_ACTIONS.has(action);
export const shouldHandleTerminalFontSizeAction = (
action: string,
disabled = false,
): boolean => isTerminalFontSizeAction(action) && !disabled;
export const nextTerminalFontSizeForAction = (
action: string,
currentFontSize: number,
disabled = false,
): number | null => {
if (disabled) return null;
switch (action) {
case "increaseTerminalFontSize":
return clampTerminalFontSize(currentFontSize + 1);
case "decreaseTerminalFontSize":
return clampTerminalFontSize(currentFontSize - 1);
case "resetTerminalFontSize":
return DEFAULT_FONT_SIZE;
default:
return null;
}
};
export const nextTerminalFontSizeForWheel = (
event: WheelLike,
currentFontSize: number,
isMac: boolean,
disabled = false,
): number | null => {
if (disabled) return null;
const hasZoomModifier = isMac
? event.metaKey && !event.ctrlKey
: event.ctrlKey && !event.metaKey;
if (!hasZoomModifier || event.deltaY === 0) return null;
return clampTerminalFontSize(currentFontSize + (event.deltaY < 0 ? 1 : -1));
};

View File

@@ -0,0 +1,394 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
HISTORY_PREVIEW_OVERLAY_ATTR,
HISTORY_PREVIEW_WRAP_ATTR,
bufferHasPreviewScrollback,
encodeHistoryPreviewWrapFlags,
getHistoryPreviewLines,
getHistoryPreviewRows,
getHistoryPreviewSelectionText,
forcedHistoryScrollLinesForWheel,
forcedHistoryScrollPageToLines,
forcedHistoryScrollPagesForKey,
forcedHistoryScrollWheelListenerOptions,
isHistoryPreviewContextMenuTarget,
isHistoryPreviewDismissClick,
joinHistoryPreviewSelectionText,
nextHistoryPreviewTop,
selectHistoryPreviewAll,
shouldHideHistoryPreviewOnMouseDown,
shouldKeepHistoryPreviewOnKey,
} from "./terminalHistoryScrollOverride.ts";
test("select all includes every preview row and preserves soft-wrap copying", async () => {
const { JSDOM } = await import("jsdom");
const dom = new JSDOM("<pre><span>中文abc</span>\n<span>def</span>\n<span>last</span></pre>");
try {
const overlay = dom.window.document.querySelector("pre")!;
overlay.setAttribute(HISTORY_PREVIEW_WRAP_ATTR, "010");
assert.equal(selectHistoryPreviewAll(overlay), true);
assert.equal(dom.window.getSelection()!.toString(), "中文abc\ndef\nlast");
assert.equal(getHistoryPreviewSelectionText(overlay, dom.window.getSelection()), "中文abcdef\nlast");
overlay.textContent = "plain\ntext";
assert.equal(selectHistoryPreviewAll(overlay), true);
assert.equal(dom.window.getSelection()!.toString(), "plain\ntext");
overlay.textContent = "";
assert.equal(selectHistoryPreviewAll(overlay), false);
} finally {
dom.window.close();
}
});
const wheel = (
over: Partial<Parameters<typeof forcedHistoryScrollLinesForWheel>[0]> = {},
) => ({
altKey: false,
ctrlKey: false,
deltaMode: 0,
deltaY: -100,
metaKey: false,
shiftKey: true,
...over,
});
const key = (
over: Partial<Parameters<typeof forcedHistoryScrollPagesForKey>[0]> = {},
) => ({
altKey: false,
ctrlKey: false,
key: "PageUp",
metaKey: false,
shiftKey: true,
type: "keydown",
...over,
});
test("Shift+wheel maps to explicit history scrolling before mouse tracking can consume it", () => {
assert.equal(forcedHistoryScrollLinesForWheel(wheel({ deltaY: -100 })), -3);
assert.equal(forcedHistoryScrollLinesForWheel(wheel({ deltaY: 100 })), 3);
});
test("forced history wheel listener can run before xterm mouse tracking and cancel scrolling", () => {
assert.equal(forcedHistoryScrollWheelListenerOptions.capture, true);
assert.equal(forcedHistoryScrollWheelListenerOptions.passive, false);
});
test("Shift+PageUp and Shift+PageDown map to one-page history scrolling", () => {
assert.equal(forcedHistoryScrollPagesForKey(key({ key: "PageUp" })), -1);
assert.equal(forcedHistoryScrollPagesForKey(key({ key: "PageDown" })), 1);
});
test("history scroll override stays out of unmodified TUI mouse and paging input", () => {
assert.equal(forcedHistoryScrollLinesForWheel(wheel({ shiftKey: false })), null);
assert.equal(forcedHistoryScrollPagesForKey(key({ shiftKey: false })), null);
});
test("history scroll override does not steal existing modified shortcuts", () => {
assert.equal(forcedHistoryScrollLinesForWheel(wheel({ ctrlKey: true })), null);
assert.equal(forcedHistoryScrollLinesForWheel(wheel({ metaKey: true })), null);
assert.equal(forcedHistoryScrollLinesForWheel(wheel({ altKey: true })), null);
assert.equal(forcedHistoryScrollPagesForKey(key({ ctrlKey: true })), null);
assert.equal(forcedHistoryScrollPagesForKey(key({ metaKey: true })), null);
assert.equal(forcedHistoryScrollPagesForKey(key({ altKey: true })), null);
});
test("PageUp/PageDown history preview uses xterm's page size", () => {
assert.equal(forcedHistoryScrollPageToLines(-1, 24), -23);
assert.equal(forcedHistoryScrollPageToLines(1, 24), 23);
assert.equal(forcedHistoryScrollPageToLines(-1, 1), -1);
});
test("alternate-screen history preview reads normal-buffer history", () => {
const normalLines = ["old 1", "old 2", "prompt before codex", "bottom"];
const normalBuffer = {
baseY: 2,
length: normalLines.length,
type: "normal" as const,
viewportY: 2,
getLine(y: number) {
const text = normalLines[y];
if (text === undefined) return undefined;
return {
translateToString() {
return text;
},
};
},
};
const alternateBuffer = {
baseY: 0,
length: 2,
type: "alternate" as const,
viewportY: 0,
getLine(y: number) {
return {
translateToString() {
return `codex frame ${y}`;
},
};
},
};
const top = nextHistoryPreviewTop({
buffer: normalBuffer,
currentTop: null,
lines: -2,
});
assert.equal(top, 0);
assert.deepEqual(getHistoryPreviewLines({ buffer: normalBuffer, rows: 3, top }), [
"old 1",
"old 2",
"prompt before codex",
]);
assert.notDeepEqual(getHistoryPreviewLines({ buffer: normalBuffer, rows: 2, top }), [
alternateBuffer.getLine(0)?.translateToString(),
alternateBuffer.getLine(1)?.translateToString(),
]);
});
test("only a normal buffer with rows above the viewport offers preview scrollback", () => {
assert.equal(bufferHasPreviewScrollback({ baseY: 12 }), true);
// Alternate-screen hosts leave the normal buffer at a single viewport.
assert.equal(bufferHasPreviewScrollback({ baseY: 0 }), false);
});
test("history preview stays up for pointer selection and copy chords", () => {
const overlay = { contains: (node: unknown) => node === "inside" };
assert.equal(shouldHideHistoryPreviewOnMouseDown("inside", overlay), false);
assert.equal(shouldHideHistoryPreviewOnMouseDown("outside", overlay), true);
assert.equal(shouldHideHistoryPreviewOnMouseDown("inside", null), false);
assert.equal(shouldKeepHistoryPreviewOnKey(key({ key: "Shift" })), true);
assert.equal(shouldKeepHistoryPreviewOnKey(key({ key: "Meta" })), true);
assert.equal(
shouldKeepHistoryPreviewOnKey(key({ key: "c", metaKey: true, shiftKey: false }), {
hasPreviewSelection: true,
}),
true,
);
assert.equal(
shouldKeepHistoryPreviewOnKey(key({ key: "c", ctrlKey: true, shiftKey: false }), {
action: "copy",
hasPreviewSelection: true,
}),
true,
);
assert.equal(
shouldKeepHistoryPreviewOnKey(key({ key: "c", ctrlKey: true, shiftKey: false }), {
action: "copy",
}),
false,
);
assert.equal(
shouldKeepHistoryPreviewOnKey(key({ key: "a", metaKey: true, shiftKey: false }), {
action: "selectAll",
overlayVisible: true,
}),
true,
);
assert.equal(
shouldKeepHistoryPreviewOnKey(key({ key: "a", metaKey: true, shiftKey: false }), {
overlayVisible: true,
}),
false,
);
assert.equal(
shouldKeepHistoryPreviewOnKey(key({ key: "j", shiftKey: false })),
false,
);
});
test("history preview copy uses only overlay-owned DOM selection", () => {
const overlay = {
contains(node: unknown) {
return node === "preview";
},
};
assert.equal(
getHistoryPreviewSelectionText(overlay, {
rangeCount: 1,
anchorNode: "preview",
focusNode: "preview",
toString: () => "old prompt output",
}),
"old prompt output",
);
assert.equal(
getHistoryPreviewSelectionText(overlay, {
rangeCount: 1,
anchorNode: "xterm",
focusNode: "xterm",
toString: () => "vim buffer",
}),
"",
);
assert.equal(
getHistoryPreviewSelectionText(overlay, {
rangeCount: 1,
isCollapsed: true,
anchorNode: "preview",
focusNode: "preview",
toString: () => "old prompt output",
}),
"",
);
});
test("history preview click dismisses and a drag keeps the overlay", () => {
assert.equal(
isHistoryPreviewDismissClick(
{ clientX: 10, clientY: 10 },
{ button: 0, clientX: 11, clientY: 12 },
),
true,
);
assert.equal(
isHistoryPreviewDismissClick(
{ clientX: 10, clientY: 10 },
{ button: 0, clientX: 40, clientY: 30 },
),
false,
);
assert.equal(
isHistoryPreviewDismissClick(
{ clientX: 10, clientY: 10 },
{ button: 2, clientX: 10, clientY: 10 },
),
false,
);
});
test("select-all overlay ranges still join soft-wrapped preview rows", () => {
const text = "ssh host long-command-name\n --flag";
const overlay = {
firstChild: "text",
textContent: text,
contains() {
return true;
},
getAttribute(name: string) {
return name === HISTORY_PREVIEW_WRAP_ATTR ? "01" : null;
},
};
const copied = getHistoryPreviewSelectionText(overlay, {
rangeCount: 1,
anchorNode: overlay,
focusNode: overlay,
anchorOffset: 0,
focusOffset: 1,
toString: () => text,
});
assert.equal(copied.includes("\n"), false);
assert.match(copied, /long-command-name\s*--flag/);
});
test("history preview copy keeps a selected hard line break", () => {
assert.equal(
joinHistoryPreviewSelectionText({
text: "abc\ndef",
startOffset: 0,
endOffset: 4,
wrapFlags: [false, false],
}),
"abc\n",
);
assert.equal(
joinHistoryPreviewSelectionText({
text: "abc\ndef",
startOffset: 3,
endOffset: 4,
wrapFlags: [false, false],
}),
"\n",
);
assert.equal(
joinHistoryPreviewSelectionText({
text: "abc\ndef",
startOffset: 0,
endOffset: 4,
wrapFlags: [false, true],
}),
"abc",
);
});
test("history preview copy joins soft-wrapped buffer rows", () => {
const text = "ssh user@host tail -f /var/log/very-long-name.log\n | grep error";
const joined = joinHistoryPreviewSelectionText({
text,
startOffset: 0,
endOffset: text.length,
wrapFlags: [false, true],
});
assert.equal(joined.includes("\n"), false);
assert.match(joined, /very-long-name\.log\s*\| grep error/);
const rows = getHistoryPreviewRows({
buffer: {
baseY: 1,
length: 2,
type: "normal",
viewportY: 1,
getLine(y: number) {
if (y === 0) {
return {
isWrapped: false,
translateToString() {
return "ssh user@host tail -f /var/log/very-long-name.log";
},
};
}
return {
isWrapped: true,
translateToString() {
return " | grep error";
},
};
},
},
rows: 2,
top: 0,
});
assert.equal(encodeHistoryPreviewWrapFlags(rows), "01");
});
test("history preview right-click is recognized as an app-menu target", () => {
assert.equal(
isHistoryPreviewContextMenuTarget({
closest: (selector: string) => selector === `[${HISTORY_PREVIEW_OVERLAY_ATTR}]` ? {} as Element : null,
}),
true,
);
assert.equal(
isHistoryPreviewContextMenuTarget({
closest: () => null,
}),
false,
);
});
test("nested preview rows retain soft-wrap copy and partial selections", async () => {
const { JSDOM } = await import("jsdom");
const dom = new JSDOM("<pre><span>中文abc</span>\n<span>def</span></pre>");
const overlay = dom.window.document.querySelector("pre")!;
overlay.setAttribute(HISTORY_PREVIEW_WRAP_ATTR, "01");
const first = overlay.firstChild!.firstChild!;
const last = overlay.lastChild!.firstChild!;
const selection = {
rangeCount: 1, anchorNode: first, focusNode: last,
anchorOffset: 1, focusOffset: 2, toString: () => "文abc\ndef",
};
assert.equal(getHistoryPreviewSelectionText(overlay, selection), "文abcde");
assert.equal(getHistoryPreviewSelectionText(overlay, {
...selection, anchorNode: overlay, focusNode: overlay,
anchorOffset: 0, focusOffset: overlay.childNodes.length,
}), "中文abcdef");
dom.window.close();
});

View File

@@ -0,0 +1,365 @@
import { joinSoftWrappedRows } from "../normalizeTerminalSelection";
type WheelLike = Pick<
WheelEvent,
"altKey" | "ctrlKey" | "deltaMode" | "deltaY" | "metaKey" | "shiftKey"
>;
type KeyLike = Pick<
KeyboardEvent,
"altKey" | "ctrlKey" | "key" | "metaKey" | "shiftKey" | "type"
>;
type BufferLineLike = {
isWrapped?: boolean;
translateToString(trimRight?: boolean): string;
};
type BufferLike = {
baseY: number;
length: number;
type: "normal" | "alternate";
viewportY: number;
getLine(y: number): BufferLineLike | undefined;
};
const DOM_DELTA_LINE = 1;
const DOM_DELTA_PAGE = 2;
const DEFAULT_WHEEL_SCROLL_LINES = 3;
const PAGE_WHEEL_SCROLL_LINES = 24;
export const forcedHistoryScrollWheelListenerOptions = {
passive: false,
capture: true,
} as const satisfies AddEventListenerOptions;
const hasOnlyShiftModifier = (event: {
altKey: boolean;
ctrlKey: boolean;
metaKey: boolean;
shiftKey: boolean;
}): boolean => event.shiftKey && !event.altKey && !event.ctrlKey && !event.metaKey;
export const forcedHistoryScrollLinesForWheel = (event: WheelLike): number | null => {
if (!hasOnlyShiftModifier(event) || event.deltaY === 0) return null;
const direction = event.deltaY < 0 ? -1 : 1;
if (event.deltaMode === DOM_DELTA_LINE) {
return direction * Math.max(1, Math.round(Math.abs(event.deltaY)));
}
if (event.deltaMode === DOM_DELTA_PAGE) {
return direction * PAGE_WHEEL_SCROLL_LINES;
}
return direction * DEFAULT_WHEEL_SCROLL_LINES;
};
export const forcedHistoryScrollPagesForKey = (event: KeyLike): number | null => {
if (event.type !== "keydown" || !hasOnlyShiftModifier(event)) return null;
if (event.key === "PageUp") return -1;
if (event.key === "PageDown") return 1;
return null;
};
export const forcedHistoryScrollPageToLines = (pageCount: number, rows: number): number =>
pageCount * Math.max(1, rows - 1);
export const clampHistoryPreviewTop = (top: number, buffer: Pick<BufferLike, "baseY">): number => {
const maxTop = Math.max(0, buffer.baseY);
return Math.max(0, Math.min(maxTop, top));
};
/**
* True when the buffer still has rows above the viewport, i.e. something to
* preview. In the alternate screen there is none (screen/vim/codex own that
* buffer), so the preview falls back to the captured session output.
*/
export const bufferHasPreviewScrollback = (buffer: Pick<BufferLike, "baseY">): boolean =>
buffer.baseY > 0;
export const nextHistoryPreviewTop = ({
buffer,
currentTop,
lines,
}: {
buffer: Pick<BufferLike, "baseY" | "viewportY">;
currentTop: number | null;
lines: number;
}): number => clampHistoryPreviewTop(
clampHistoryPreviewTop(currentTop ?? buffer.viewportY ?? buffer.baseY, buffer) + lines,
buffer,
);
export type HistoryPreviewRow = {
isWrapped: boolean;
text: string;
};
export const getHistoryPreviewRows = ({
buffer,
rows,
top,
}: {
buffer: BufferLike;
rows: number;
top: number;
}): HistoryPreviewRow[] => {
const clampedTop = clampHistoryPreviewTop(top, buffer);
const visibleRows = Math.max(1, rows);
const lines: HistoryPreviewRow[] = [];
for (let row = 0; row < visibleRows; row += 1) {
const line = buffer.getLine(clampedTop + row);
lines.push({
isWrapped: Boolean(line?.isWrapped),
text: line?.translateToString(true) ?? "",
});
}
return lines;
};
export const getHistoryPreviewLines = ({
buffer,
rows,
top,
}: {
buffer: BufferLike;
rows: number;
top: number;
}): string[] => getHistoryPreviewRows({ buffer, rows, top }).map((row) => row.text);
export const encodeHistoryPreviewWrapFlags = (rows: Array<Pick<HistoryPreviewRow, "isWrapped">>): string =>
rows.map((row) => (row.isWrapped ? "1" : "0")).join("");
export const HISTORY_PREVIEW_OVERLAY_ATTR = "data-terminal-history-preview";
export const HISTORY_PREVIEW_WRAP_ATTR = "data-terminal-history-preview-wraps";
export const HISTORY_PREVIEW_CLICK_SLOP_PX = 4;
const MODIFIER_ONLY_KEYS = new Set(["Shift", "Control", "Meta", "Alt"]);
export type HistoryPreviewSelectionLike = {
rangeCount: number;
isCollapsed?: boolean;
anchorNode: { nodeType?: number } | null;
focusNode: { nodeType?: number } | null;
anchorOffset?: number;
focusOffset?: number;
toString(): string;
};
export type HistoryPreviewNodeLike = {
ownerDocument?: Pick<Document, "createRange">;
contains(node: { nodeType?: number } | null): boolean;
firstChild?: { nodeType?: number } | null;
getAttribute?(name: string): string | null;
textContent?: string | null;
};
export const isHistoryPreviewPointerTarget = (
target: EventTarget | null | undefined,
overlay: EventTarget | null | undefined,
): boolean => {
if (!target || !overlay) return false;
if (target === overlay) return true;
if (typeof (overlay as HistoryPreviewNodeLike).contains === "function") {
return (overlay as HistoryPreviewNodeLike).contains(target as HistoryPreviewNodeLike);
}
return false;
};
export const shouldHideHistoryPreviewOnMouseDown = (
target: EventTarget | null | undefined,
overlay: EventTarget | null | undefined,
): boolean => Boolean(overlay) && !isHistoryPreviewPointerTarget(target, overlay);
export const isHistoryPreviewContextMenuTarget = (
target: EventTarget | null | undefined,
): boolean => {
if (!target || typeof target !== "object") return false;
const element = target as { closest?: (selector: string) => Element | null };
return Boolean(element.closest?.(`[${HISTORY_PREVIEW_OVERLAY_ATTR}]`));
};
export const shouldKeepHistoryPreviewOnKey = (
event: KeyLike,
options: {
action?: string | null;
hasPreviewSelection?: boolean;
overlayVisible?: boolean;
} = {},
): boolean => {
if (forcedHistoryScrollPagesForKey(event) !== null) return true;
if (MODIFIER_ONLY_KEYS.has(event.key)) return true;
if (options.action === "selectAll" && options.overlayVisible) return true;
if (options.action === "copy" && options.hasPreviewSelection) return true;
return Boolean(
options.hasPreviewSelection
&& options.action == null
&& (event.metaKey || event.ctrlKey)
&& !event.altKey
&& event.key.toLowerCase() === "c",
);
};
export const isHistoryPreviewDismissClick = (
down: Pick<MouseEvent, "clientX" | "clientY">,
up: Pick<MouseEvent, "button" | "clientX" | "clientY">,
slop = HISTORY_PREVIEW_CLICK_SLOP_PX,
): boolean => {
if (up.button !== 0) return false;
return Math.hypot(up.clientX - down.clientX, up.clientY - down.clientY) <= slop;
};
const lineOffsetsForPreviewText = (text: string): number[] => {
const offsets = [0];
for (let index = 0; index < text.length; index += 1) {
if (text.charCodeAt(index) === 10) offsets.push(index + 1);
}
return offsets;
};
export const joinHistoryPreviewSelectionText = ({
text,
startOffset,
endOffset,
wrapFlags,
}: {
text: string;
startOffset: number;
endOffset: number;
wrapFlags: boolean[];
}): string => {
const start = Math.max(0, Math.min(startOffset, endOffset));
const end = Math.max(0, Math.max(startOffset, endOffset));
if (end <= start) return "";
const lineStarts = lineOffsetsForPreviewText(text);
const lineIndexAt = (offset: number): number => {
let index = 0;
while (index + 1 < lineStarts.length && lineStarts[index + 1]! <= offset) {
index += 1;
}
return index;
};
const startLine = lineIndexAt(start);
const endLine = lineIndexAt(Math.max(start, end - 1));
const boundaryLine = lineStarts.indexOf(end);
const includesTrailingHardBreak = boundaryLine > 0 && !wrapFlags[boundaryLine];
const sliceLine = (line: number, from: number, to: number): string => {
const lineStart = lineStarts[line] ?? 0;
const lineEnd = line + 1 < lineStarts.length ? lineStarts[line + 1]! - 1 : text.length;
return text.slice(Math.max(lineStart, from), Math.min(lineEnd, to));
};
let current = sliceLine(startLine, start, startLine === endLine ? end : Number.POSITIVE_INFINITY);
const logical: string[] = [];
for (let line = startLine + 1; line <= endLine; line += 1) {
const row = sliceLine(line, 0, line === endLine ? end : Number.POSITIVE_INFINITY);
if (wrapFlags[line]) {
current = joinSoftWrappedRows(current, row);
continue;
}
logical.push(current);
current = row;
}
logical.push(current);
const joined = logical.join("\n");
return includesTrailingHardBreak ? `${joined}\n` : joined;
};
const resolveOverlayTextOffset = (
overlay: HistoryPreviewNodeLike,
textNode: { nodeType?: number },
node: { nodeType?: number } | null,
offset: number,
): number | null => {
if (overlay.ownerDocument && node) {
try {
const prefix = overlay.ownerDocument.createRange();
prefix.selectNodeContents(overlay as unknown as Node);
prefix.setEnd(node as Node, offset);
return prefix.toString().length;
} catch {
return null;
}
}
if (node === textNode) return offset;
if (node === overlay) return offset <= 0 ? 0 : overlay.textContent?.length ?? 0;
return null;
};
const selectionOffsetsInOverlay = (
overlay: HistoryPreviewNodeLike,
selection: HistoryPreviewSelectionLike,
): { start: number; end: number } | null => {
const textNode = overlay.firstChild;
if (!textNode) return null;
const { anchorNode, focusNode, anchorOffset, focusOffset } = selection;
if (anchorOffset == null || focusOffset == null) return null;
const start = resolveOverlayTextOffset(overlay, textNode, anchorNode, anchorOffset);
const end = resolveOverlayTextOffset(overlay, textNode, focusNode, focusOffset);
if (start == null || end == null) return null;
return {
start: Math.min(start, end),
end: Math.max(start, end),
};
};
export const getHistoryPreviewSelectionText = (
overlay: HistoryPreviewNodeLike | null | undefined,
selection: HistoryPreviewSelectionLike | null | undefined,
): string => {
if (!overlay || !selection || selection.rangeCount === 0 || selection.isCollapsed) {
return "";
}
const { anchorNode, focusNode } = selection;
if (!anchorNode || !focusNode) return "";
if (!overlay.contains(anchorNode) || !overlay.contains(focusNode)) return "";
const raw = selection.toString();
const wrapAttr = overlay.getAttribute?.(HISTORY_PREVIEW_WRAP_ATTR);
const offsets = selectionOffsetsInOverlay(overlay, selection);
if (!wrapAttr || !offsets) return raw;
return joinHistoryPreviewSelectionText({
text: overlay.textContent ?? "",
startOffset: offsets.start,
endOffset: offsets.end,
wrapFlags: [...wrapAttr].map((flag) => flag === "1"),
}) || raw;
};
export const findHistoryPreviewOverlay = (
root: ParentNode | Element | null | undefined,
): HTMLElement | null => {
if (!root || !("querySelector" in root)) return null;
return root.querySelector<HTMLElement>(`[${HISTORY_PREVIEW_OVERLAY_ATTR}]`);
};
export const getHistoryPreviewSelectionFromRoot = (
root: ParentNode | Element | null | undefined,
selection?: HistoryPreviewSelectionLike | null,
): string => {
const overlay = findHistoryPreviewOverlay(root);
const activeSelection = selection ?? overlay?.ownerDocument.getSelection() ?? null;
return getHistoryPreviewSelectionText(overlay, activeSelection);
};
export const HISTORY_PREVIEW_HIDE_EVENT = "netcatty-history-preview-hide";
export const requestHistoryPreviewHide = (
root: ParentNode | Element | null | undefined,
): boolean => {
const overlay = findHistoryPreviewOverlay(root);
if (!overlay) return false;
overlay.dispatchEvent(new Event(HISTORY_PREVIEW_HIDE_EVENT, { bubbles: true }));
return true;
};
export const selectHistoryPreviewAll = (overlay: HTMLElement | null | undefined): boolean => {
if (!overlay) return false;
const selection = overlay.ownerDocument.getSelection();
if (!selection) return false;
const range = overlay.ownerDocument.createRange();
range.selectNodeContents(overlay);
selection.removeAllRanges();
selection.addRange(range);
return !selection.isCollapsed;
};

View File

@@ -0,0 +1,526 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import {
isAsciiPunctuationKey,
isUnchangedDeferredImeTextInput,
shouldBlockKeyPressForImeTextInput,
shouldCommitDeferredImeTextInput,
shouldDeferKeyDownForImeTextInput,
resolveDeferredKeyupRelease,
shouldDiscardStaleDeferredImeTextInput,
shouldFlushDeferredImeTextInputOnKeyUp,
shouldFlushStaleDeferredImeTextInput,
} from "./terminalImeTextInput";
const runtimeSource = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), "createXTermRuntime.ts"),
"utf8",
);
test("isAsciiPunctuationKey accepts common remappable punctuation", () => {
for (const key of [",", ".", "/", ";", "'", "[", "]", "\\", "-", "=", "`", "?", "!", ":", '"', "<", ">", "{", "}", "|", "_", "+", "~", "@", "#", "$", "%", "^", "&", "*", "(", ")"]) {
assert.equal(isAsciiPunctuationKey(key), true, key);
}
});
test("isAsciiPunctuationKey rejects letters, digits, space, and CJK", () => {
for (const key of ["a", "Z", "0", " ", "", "、", "", "Enter", "ArrowLeft"]) {
assert.equal(isAsciiPunctuationKey(key), false, key);
}
});
test("shouldDeferKeyDownForImeTextInput defers bare ASCII punctuation keydowns", () => {
assert.equal(
shouldDeferKeyDownForImeTextInput({ type: "keydown", key: ",", keyCode: 188 }),
true,
);
assert.equal(
shouldDeferKeyDownForImeTextInput({ type: "keydown", key: "?", keyCode: 191 }),
true,
);
});
test("shouldDeferKeyDownForImeTextInput leaves composition and modified keys alone", () => {
assert.equal(
shouldDeferKeyDownForImeTextInput({ type: "keydown", key: ",", keyCode: 229 }),
false,
);
assert.equal(
shouldDeferKeyDownForImeTextInput({ type: "keydown", key: ",", isComposing: true }),
false,
);
assert.equal(
shouldDeferKeyDownForImeTextInput({ type: "keydown", key: ",", ctrlKey: true }),
false,
);
assert.equal(
shouldDeferKeyDownForImeTextInput({ type: "keydown", key: "a", keyCode: 65 }),
false,
);
assert.equal(
shouldDeferKeyDownForImeTextInput({ type: "keypress", key: "," }),
false,
);
});
test("shouldBlockKeyPressForImeTextInput only while a deferral is armed", () => {
assert.equal(
shouldBlockKeyPressForImeTextInput(",", { type: "keypress", key: "," }),
true,
);
assert.equal(
shouldBlockKeyPressForImeTextInput(null, { type: "keypress", key: "," }),
false,
);
assert.equal(
shouldBlockKeyPressForImeTextInput(",", { type: "keydown", key: "," }),
false,
);
});
test("shouldBlockKeyPressForImeTextInput still blocks the deferred keystroke itself", () => {
assert.equal(
shouldBlockKeyPressForImeTextInput(
"/",
{ type: "keypress", key: "/", keyCode: 191 },
191,
),
true,
);
// Shift+/ reports "?" on keydown, so the deferral arms with "?".
assert.equal(
shouldBlockKeyPressForImeTextInput(
"?",
{ type: "keypress", key: "?", keyCode: 191 },
191,
),
true,
);
assert.equal(
shouldBlockKeyPressForImeTextInput(
"/",
{ type: "keypress", key: "/", isComposing: true },
191,
),
true,
);
});
test("a stale deferral must not swallow unrelated keypresses (#3103)", () => {
// Uppercase letters are routed through keypress by xterm, so a blanket
// keypress block turned one stale deferral into a terminal that ignored
// everything typed afterwards.
assert.equal(
shouldBlockKeyPressForImeTextInput(
"/",
{ type: "keypress", key: "X", keyCode: 88 },
191,
),
false,
);
assert.equal(
shouldBlockKeyPressForImeTextInput(
"/",
{ type: "keypress", key: "a", keyCode: 65 },
191,
),
false,
);
// A matching keyCode keeps the block when an IME rewrites the key label.
assert.equal(
shouldBlockKeyPressForImeTextInput(
"/",
{ type: "keypress", key: "1", keyCode: 191 },
191,
),
true,
);
});
test("a Windows IME release reporting Process must flush the deferred slash (#3103)", () => {
// Windows + CJK IME in vi: keydown still reports the physical key while the
// IME consumes it, then reports Process/229 on the release and never sends
// an insertText the runtime would treat as a remap.
let deferredKey: string | null = null;
const keydown = { type: "keydown", key: "/", keyCode: 191 };
if (shouldDeferKeyDownForImeTextInput(keydown)) deferredKey = keydown.key;
assert.equal(deferredKey, "/");
const release = { type: "keyup", key: "Process", keyCode: 229 };
if (shouldFlushDeferredImeTextInputOnKeyUp(deferredKey, release)) deferredKey = null;
assert.equal(deferredKey, null, "the deferral must not outlive its keystroke");
// Input keeps flowing afterwards.
assert.equal(
shouldBlockKeyPressForImeTextInput(deferredKey, { type: "keypress", key: "x", keyCode: 88 }),
false,
);
});
test("a held key released while the deferral is armed keeps its own keyup identity", () => {
// "/" is deferred (its own release was swallowed) and the user still holds
// "a" with the other hand, so the "a" keyup lands first. The stale deferral
// still ends, but that release is a real keyup, not an IME sentinel: it
// keeps its own identity, and the deferred press is released separately.
const deferredKey = "/";
const heldKeyUp = { type: "keyup", key: "a", keyCode: 65, code: "KeyA" };
assert.equal(
shouldFlushDeferredImeTextInputOnKeyUp(deferredKey, heldKeyUp),
true,
"the stale deferral still ends on a real release",
);
assert.equal(
resolveDeferredKeyupRelease(deferredKey, "Slash", heldKeyUp),
"unrelated",
"an unrelated release must not be rewritten to the deferred key",
);
});
test("only IME sentinel releases take over the deferred key's release identity", () => {
// Windows IMEs report Process/229 (occasionally Unidentified) as the release
// of a key they consumed — those are the only releases that stand in for the
// deferred punctuation key.
assert.equal(
resolveDeferredKeyupRelease("/", "Slash", { type: "keyup", key: "Process", keyCode: 229 }),
"deferred",
);
assert.equal(
resolveDeferredKeyupRelease("/", "Slash", { type: "keyup", key: "Unidentified" }),
"deferred",
);
assert.equal(
resolveDeferredKeyupRelease("/", "Slash", { type: "keyup", key: "/", keyCode: 191 }),
"own",
"a matched release already encodes from the real event",
);
assert.equal(
resolveDeferredKeyupRelease("/", "Slash", {
type: "keyup",
key: "1",
keyCode: 191,
code: "Slash",
}),
"own",
"a release of the same physical key already pairs the flushed press",
);
assert.equal(
resolveDeferredKeyupRelease("/", "Slash", { type: "keyup", key: "Shift", keyCode: 16 }),
"own",
);
assert.equal(
resolveDeferredKeyupRelease("/", "Slash", {
type: "keyup",
key: "a",
keyCode: 65,
code: "KeyA",
ctrlKey: true,
}),
"own",
);
assert.equal(
resolveDeferredKeyupRelease(null, null, { type: "keyup", key: "Process", keyCode: 229 }),
"own",
);
});
test("a deferral left without release or insertText recovers on the next keystroke (#3103)", () => {
let deferredKey: string | null = null;
const keydown = { type: "keydown", key: "/", keyCode: 191 };
if (shouldDeferKeyDownForImeTextInput(keydown)) deferredKey = keydown.key;
assert.equal(deferredKey, "/");
// No insertText and no keyup at all; the user then types a plain letter.
const nextKeyDown = { type: "keydown", key: "a", keyCode: 65 };
if (shouldFlushStaleDeferredImeTextInput(deferredKey, nextKeyDown)) deferredKey = null;
assert.equal(deferredKey, null, "the stale deferral must flush before the new keystroke");
assert.equal(
shouldBlockKeyPressForImeTextInput(deferredKey, { type: "keypress", key: "a", keyCode: 65 }),
false,
);
});
test("a modified keydown discards the stale deferral instead of flushing it", () => {
// The IME swallowed the "/" release and the user then interrupts with
// Ctrl+C. Flushing there would inject "/" in front of the interrupt, and
// keeping the deferral armed would inject it before the next character, so
// the lost keystroke is dropped instead.
assert.equal(
shouldFlushStaleDeferredImeTextInput("/", {
type: "keydown",
key: "c",
keyCode: 67,
ctrlKey: true,
}),
false,
);
assert.equal(
shouldDiscardStaleDeferredImeTextInput("/", {
type: "keydown",
key: "c",
keyCode: 67,
ctrlKey: true,
}),
true,
);
assert.equal(
shouldDiscardStaleDeferredImeTextInput("/", {
type: "keydown",
key: "Tab",
keyCode: 9,
altKey: true,
}),
true,
);
assert.equal(
shouldDiscardStaleDeferredImeTextInput("/", {
type: "keydown",
key: "d",
keyCode: 229,
ctrlKey: true,
isComposing: true,
}),
false,
"a composition still owns the keystroke and resolves via insertText",
);
assert.equal(
shouldDiscardStaleDeferredImeTextInput("/", { type: "keydown", key: "a", keyCode: 65 }),
false,
);
assert.equal(
shouldDiscardStaleDeferredImeTextInput(null, {
type: "keydown",
key: "c",
keyCode: 67,
ctrlKey: true,
}),
false,
);
});
test("auto-repeat and modifier keystrokes keep the deferral armed", () => {
// Same-key keydown is auto-repeat: re-arm instead of flushing, so a held key
// does not emit an extra character per repeat.
assert.equal(
shouldFlushStaleDeferredImeTextInput("/", { type: "keydown", key: "/", keyCode: 191 }),
false,
);
assert.equal(
shouldFlushStaleDeferredImeTextInput("/", { type: "keydown", key: "Shift", keyCode: 16 }),
false,
);
assert.equal(
shouldFlushStaleDeferredImeTextInput("/", {
type: "keydown",
key: "d",
keyCode: 229,
isComposing: true,
}),
false,
);
assert.equal(
shouldFlushDeferredImeTextInputOnKeyUp("/", { type: "keyup", key: "Shift", keyCode: 16 }),
false,
);
assert.equal(
shouldFlushDeferredImeTextInputOnKeyUp("/", {
type: "keyup",
key: "c",
keyCode: 67,
ctrlKey: true,
}),
false,
);
});
test("an active composition still resolves the deferred keystroke via insertText", () => {
// The IME absorbed the punctuation into a composition: the composing release
// must not flush the ASCII key ahead of the committed glyph.
assert.equal(
shouldFlushDeferredImeTextInputOnKeyUp("/", {
type: "keyup",
key: "/",
keyCode: 191,
isComposing: true,
}),
false,
);
assert.equal(
shouldCommitDeferredImeTextInput("/", { inputType: "insertText", data: "、" }),
true,
);
});
test("a matched keyup still flushes the English punctuation fallback (#2833)", () => {
assert.equal(
shouldFlushDeferredImeTextInputOnKeyUp("/", { type: "keyup", key: "/", keyCode: 191 }),
true,
);
assert.equal(
shouldFlushDeferredImeTextInputOnKeyUp(null, { type: "keyup", key: "/", keyCode: 191 }),
false,
);
assert.equal(
shouldFlushDeferredImeTextInputOnKeyUp("/", { type: "keydown", key: "/", keyCode: 191 }),
false,
);
});
test("shouldCommitDeferredImeTextInput accepts insertText payloads while deferred", () => {
assert.equal(
shouldCommitDeferredImeTextInput(",", { inputType: "insertText", data: "" }),
true,
);
assert.equal(
shouldCommitDeferredImeTextInput(",", { inputType: "insertText", data: "," }),
true,
);
assert.equal(
shouldCommitDeferredImeTextInput(null, { inputType: "insertText", data: "" }),
false,
);
assert.equal(
shouldCommitDeferredImeTextInput(",", { inputType: "insertFromPaste", data: "" }),
false,
);
assert.equal(
shouldCommitDeferredImeTextInput(",", { inputType: "insertText", data: null }),
false,
);
});
test("isUnchangedDeferredImeTextInput detects English punctuation flush", () => {
assert.equal(isUnchangedDeferredImeTextInput(",", ","), true);
assert.equal(isUnchangedDeferredImeTextInput(",", ""), false);
assert.equal(isUnchangedDeferredImeTextInput(null, ","), false);
});
test("createXTermRuntime defers ASCII punctuation keydowns to insertText", () => {
assert.match(runtimeSource, /shouldDeferKeyDownForImeTextInput\(e\)/);
assert.match(runtimeSource, /armImeTextInputDeferral\(e\)/);
assert.match(
runtimeSource,
/shouldBlockKeyPressForImeTextInput\(\s*imeTextInputDeferredKey,\s*e,\s*imeTextInputDeferredKittyEvent\?\.keyCode \?\? null,\s*\)/,
);
assert.match(
runtimeSource,
/shouldCommitDeferredImeTextInput\(imeTextInputDeferredKey, event\)/,
);
assert.match(runtimeSource, /commitImeTextInput\(event\.data\)/);
assert.match(runtimeSource, /isUnchangedDeferredImeTextInput\(deferredKey, text\)/);
assert.match(runtimeSource, /imeTextInputDeferredKittyEvent/);
// Manual commit bypasses xterm onUserInput; clear selection unless preserved.
assert.match(
runtimeSource,
/!ctx\.terminalSettingsRef\.current\?\.preserveSelectionOnInput/,
);
assert.match(runtimeSource, /term\.clearSelection\(\)/);
const clearSelIdx = runtimeSource.indexOf("term.clearSelection()");
const commitIdx = runtimeSource.indexOf("const commitImeTextInput = (text: string)");
const firstHandleIdx = runtimeSource.indexOf(
"handleTerminalInputData",
commitIdx,
);
assert.ok(
commitIdx >= 0 &&
clearSelIdx > commitIdx &&
firstHandleIdx > clearSelIdx,
);
// Must run before Kitty/xterm send the half-width key from keydown.
const deferIdx = runtimeSource.indexOf("shouldDeferKeyDownForImeTextInput(e)");
const kittySendIdx = runtimeSource.indexOf("if (kittySequenceForKeyDown)");
assert.ok(deferIdx >= 0 && kittySendIdx > deferIdx);
// Unchanged ASCII must encode via Kitty key events, not composition text.
const unchangedIdx = runtimeSource.indexOf("isUnchangedDeferredImeTextInput(deferredKey, text)");
const compositionIdx = runtimeSource.indexOf(
"encodeKittyCompositionText(kittyKeyboardMode, sanitizedText)",
);
assert.ok(unchangedIdx >= 0 && compositionIdx > unchangedIdx);
// Even when the source writes the literal glyph, broadcast peers still get
// the deferred physical key (with legacy fallback), not composition text.
const armIdx = runtimeSource.indexOf("const armImeTextInputDeferral");
assert.ok(armIdx >= 0);
const armSlice = runtimeSource.slice(armIdx, armIdx + 700);
assert.match(
armSlice,
/imeTextInputDeferredKittyEvent = toKittyKeyboardEvent\(event\)/,
);
assert.doesNotMatch(
armSlice,
/imeTextInputDeferredKittyEvent = kittyKeyboardProtocolEnabled/,
);
const unchangedFallbackIdx = runtimeSource.indexOf(
"isUnchangedDeferredImeTextInput(deferredKey, text))",
unchangedIdx + 1,
);
assert.ok(unchangedFallbackIdx > unchangedIdx);
assert.match(
runtimeSource.slice(unchangedFallbackIdx, unchangedFallbackIdx + 1800),
/handleTerminalInputData\(text, \{ perCharacterWrites: shouldSplitImeTextInputForWire\(text\) \}\);[\s\S]*shouldTrackKittyKeyRelease\(kittyKeyboardMode, pressEvent\)[\s\S]*upsertKittyKeyboardForwardedPress\(\s*kittyForwardedKeys,[\s\S]*broadcastKittyInput\(\{[\s\S]*kind: "key",[\s\S]*fallbackToLegacy: true,/,
);
// Remap path must fall back to literal text when composition encoding is null
// (report-all without associated text).
assert.match(
runtimeSource.slice(compositionIdx, compositionIdx + 560),
/if \(encoded\) \{[\s\S]*handleTerminalInputData\(encoded, \{ source: "kitty" \}\);[\s\S]*\} else \{[\s\S]*handleTerminalInputData\(sanitizedText, \{\s*perCharacterWrites: shouldSplitImeTextInputForWire\(sanitizedText\),?\s*\}\);/,
);
});
test("createXTermRuntime recovers a stuck IME punctuation deferral (#3103)", () => {
// Any real key release ends the deferral, not just an exact key match.
const keyupIdx = runtimeSource.indexOf('if (e.type === "keyup")');
assert.ok(keyupIdx >= 0);
const keyupSlice = runtimeSource.slice(keyupIdx, keyupIdx + 2800);
assert.match(
keyupSlice,
/shouldFlushDeferredImeTextInputOnKeyUp\(imeTextInputDeferredKey, e\)/,
);
assert.match(keyupSlice, /flushImeTextInputDeferral\(\);/);
// Only an IME sentinel release (Process/229/Unidentified) takes over the
// deferred key's release identity; an unrelated keyup keeps its own identity
// and the flushed press is released separately.
assert.match(
keyupSlice,
/resolveDeferredKeyupRelease\(\s*imeTextInputDeferredKey,\s*deferredKittyEvent\?\.code \?\? null,\s*e,?\s*\)/,
);
assert.match(keyupSlice, /releaseMode === "deferred"/);
assert.match(keyupSlice, /releaseMode === "unrelated"/);
assert.match(
keyupSlice,
/\.\.\.deferredKittyEvent,\s*type: "keyup",/,
);
const kittyReleaseIdx = keyupSlice.indexOf("toKittyKeyboardEvent(releaseEvent)");
assert.ok(kittyReleaseIdx > keyupSlice.indexOf("flushImeTextInputDeferral();"));
// The stale flush runs on keydown, after the broadcast guard and before the
// composition handling, so a wedged deferral cannot survive a new keystroke.
const staleIdx = runtimeSource.indexOf(
"shouldFlushStaleDeferredImeTextInput(imeTextInputDeferredKey, e)",
);
assert.ok(staleIdx > keyupIdx);
const keydownGuardIdx = runtimeSource.indexOf("if (handlingKittyBroadcast) return true;", keyupIdx);
const keyCode229Idx = runtimeSource.indexOf("if (e.keyCode === 229) {", keyupIdx);
assert.ok(
keydownGuardIdx > keyupIdx &&
staleIdx > keydownGuardIdx &&
keyCode229Idx > staleIdx,
);
assert.match(
runtimeSource.slice(staleIdx - 400, staleIdx + 200),
/flushImeTextInputDeferral\(\);/,
);
// The recovery runs because the deferred key's release will never arrive, so
// the press emitted by the flush must be paired with a synthesized release
// instead of staying forwarded until focus loss.
const recoverySlice = runtimeSource.slice(staleIdx, staleIdx + 500);
assert.match(recoverySlice, /deferredKittyEvent = imeTextInputDeferredKittyEvent/);
assert.match(recoverySlice, /releaseForwardedKittyPress\(\s*\{\s*\.\.\.deferredKittyEvent,\s*type: "keyup",?\s*\}\s*\)/);
// A modified keydown (Ctrl+C, Alt+…) drops the lost keystroke instead of
// injecting it in front of the interrupt or shortcut.
assert.match(
runtimeSource.slice(staleIdx, staleIdx + 700),
/shouldDiscardStaleDeferredImeTextInput\(imeTextInputDeferredKey, e\)/,
);
});

View File

@@ -0,0 +1,190 @@
/**
* CJK IMEs (notably Sogou on macOS) often emit a keydown whose `event.key` is
* still the half-width ASCII punctuation, then commit the full-width glyph via
* an `input`/`insertText` event. xterm.js sends the keydown character and then
* drops the input event because `_keyDownSeen` is set — so the PTY receives
* "," instead of "".
*
* Defer those keydowns to the following insertText. If no remap arrives before
* keyup/blur, the original ASCII key is flushed. Composition (keyCode 229 /
* isComposing) stays on xterm's CompositionHelper path.
*
* The deferral must never outlive the keystroke it was armed for: Windows IMEs
* report `Process` / keyCode 229 as the keyup of a key they consumed (or drop
* the keyup), so a deferral flushed only on an exact key match stayed armed and
* blocked typed input from then on (#3103). Any real key release, and any later
* unrelated keydown, now ends the deferral.
*/
export type ImeTextInputKeyEvent = {
type?: string;
key: string;
code?: string;
keyCode?: number;
altKey?: boolean;
ctrlKey?: boolean;
metaKey?: boolean;
isComposing?: boolean;
};
export type ImeTextInputEvent = Pick<InputEvent, "data" | "inputType">;
/** Printable ASCII punctuation IMEs commonly remap to full-width forms. */
const ASCII_PUNCTUATION_RE = /^[\x21-\x2f\x3a-\x40\x5b-\x60\x7b-\x7e]$/;
export function isAsciiPunctuationKey(key: string): boolean {
return ASCII_PUNCTUATION_RE.test(key);
}
export function shouldDeferKeyDownForImeTextInput(
event: ImeTextInputKeyEvent,
): boolean {
if (event.type !== undefined && event.type !== "keydown") return false;
if (event.isComposing === true || event.keyCode === 229) return false;
if (event.altKey || event.ctrlKey || event.metaKey) return false;
return isAsciiPunctuationKey(event.key);
}
/** Key releases that carry no keystroke of their own. */
const MODIFIER_ONLY_KEY_RE =
/^(Shift|Control|Alt|Meta|CapsLock|NumLock|ScrollLock|Hyper|Super|Fn|FnLock|Symbol|SymbolLock)$/;
export function isModifierOnlyKey(key: string): boolean {
return MODIFIER_ONLY_KEY_RE.test(key);
}
/**
* DOM keys that stand in for a key the IME consumed, mirroring the non-text
* DOM keys the Kitty encoder already refuses to send as text.
*/
const IME_SENTINEL_KEYS = new Set(["Dead", "Process", "Unidentified", "Compose"]);
export function isImeSentinelKeyUp(event: ImeTextInputKeyEvent): boolean {
if (event.type !== undefined && event.type !== "keyup") return false;
return event.keyCode === 229 || IME_SENTINEL_KEYS.has(event.key);
}
/**
* A deferred punctuation keystroke is over once any real key release arrives.
* The IME remap (insertText) is dispatched before keyup, so a release means the
* IME did not remap the key and the ASCII character must be flushed.
*
* The release key cannot be matched exactly: Windows IMEs report `Process` /
* keyCode 229 as the release of a key they consumed, and some drop the release
* entirely. Requiring an exact key match left the deferral armed, and the
* armed deferral then blocked input (#3103). Composing releases are excluded —
* an active composition still owns the keystroke and resolves it via
* insertText.
*/
export function shouldFlushDeferredImeTextInputOnKeyUp(
deferredKey: string | null | undefined,
event: ImeTextInputKeyEvent,
): boolean {
if (!deferredKey) return false;
if (event.type !== undefined && event.type !== "keyup") return false;
if (event.isComposing === true) return false;
if (event.altKey || event.ctrlKey || event.metaKey) return false;
return !isModifierOnlyKey(event.key);
}
/**
* A deferral that outlived its own keystroke is stale — the IME swallowed the
* release. Flush it when a new, unmodified, non-composing keydown for a
* different key arrives so the pending ASCII character still reaches the PTY.
* A same-key keydown is auto-repeat (or a second IME attempt) and keeps
* re-arming instead.
*/
export function shouldFlushStaleDeferredImeTextInput(
deferredKey: string | null | undefined,
event: ImeTextInputKeyEvent,
): boolean {
if (!deferredKey) return false;
if (event.type !== undefined && event.type !== "keydown") return false;
if (event.isComposing === true || event.keyCode === 229) return false;
if (event.altKey || event.ctrlKey || event.metaKey) return false;
if (isModifierOnlyKey(event.key)) return false;
return event.key !== deferredKey;
}
/**
* A modified keydown is a command (Ctrl+C, Alt+…), not the continuation of the
* lost punctuation keystroke: flushing there would inject the ASCII character
* in front of the interrupt or shortcut. Drop the stale deferral instead — the
* keystroke was already lost to the IME.
*/
export function shouldDiscardStaleDeferredImeTextInput(
deferredKey: string | null | undefined,
event: ImeTextInputKeyEvent,
): boolean {
if (!deferredKey) return false;
if (event.type !== undefined && event.type !== "keydown") return false;
if (event.isComposing === true || event.keyCode === 229) return false;
return Boolean(event.altKey || event.ctrlKey || event.metaKey);
}
/**
* How the keyup that ends a deferral relates to the deferred key.
* - `deferred`: an IME sentinel stood in for the deferred key, so the paired
* release must be encoded from the deferred physical key.
* - `own`: the release belongs to the deferred key itself (matched key or
* physical code), so it already pairs the press the flush emitted.
* - `unrelated`: another held key was released; it keeps its own identity and
* the deferred press needs a separate synthesized release.
*/
export type DeferredKeyupReleaseMode = "deferred" | "own" | "unrelated";
export function resolveDeferredKeyupRelease(
deferredKey: string | null | undefined,
deferredCode: string | null | undefined,
event: ImeTextInputKeyEvent,
): DeferredKeyupReleaseMode {
if (!deferredKey) return "own";
if (event.type !== undefined && event.type !== "keyup") return "own";
if (event.altKey || event.ctrlKey || event.metaKey) return "own";
if (isModifierOnlyKey(event.key)) return "own";
if (event.key === deferredKey) return "own";
if (event.code && deferredCode && event.code === deferredCode) return "own";
if (isImeSentinelKeyUp(event)) return "deferred";
return "unrelated";
}
export function shouldBlockKeyPressForImeTextInput(
deferredKey: string | null | undefined,
event: ImeTextInputKeyEvent,
deferredKeyCode?: number | null,
): boolean {
if (!deferredKey || event.type !== "keypress") return false;
// Composition keypresses carry no committable character on this path.
if (event.isComposing === true || event.keyCode === 229) return true;
// Only the deferred keystroke itself is suppressed. Blocking every keypress
// while armed turned one stale deferral into a terminal that ignored all
// typed input (#3103).
return (
event.key === deferredKey ||
(deferredKeyCode != null && event.keyCode === deferredKeyCode)
);
}
export function shouldCommitDeferredImeTextInput(
deferredKey: string | null | undefined,
event: ImeTextInputEvent,
): event is ImeTextInputEvent & { data: string } {
return (
Boolean(deferredKey) &&
event.inputType === "insertText" &&
typeof event.data === "string" &&
event.data.length > 0
);
}
/**
* True when insertText/flush kept the deferred ASCII key (no CJK remap).
* Those commits must not use Kitty composition encoding — under report-all
* that emits unidentified CSI 0 u and drops press/release.
*/
export function isUnchangedDeferredImeTextInput(
deferredKey: string | null | undefined,
text: string,
): boolean {
return deferredKey != null && text === deferredKey;
}

View File

@@ -0,0 +1,88 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { sanitizeTerminalInput } from "./terminalInputSanitize";
test("sanitizeTerminalInput strips zero-width space (U+200B)", () => {
assert.equal(sanitizeTerminalInput("ls\u200b"), "ls");
assert.equal(sanitizeTerminalInput("\u200bls"), "ls");
assert.equal(sanitizeTerminalInput("l\u200bs"), "ls");
});
test("sanitizeTerminalInput strips BOM / ZWNBSP (U+FEFF)", () => {
assert.equal(sanitizeTerminalInput("\ufeffls"), "ls");
assert.equal(sanitizeTerminalInput("ls\ufeff"), "ls");
});
test("sanitizeTerminalInput strips soft hyphen (U+00AD)", () => {
assert.equal(sanitizeTerminalInput("ls\u00ad"), "ls");
});
test("sanitizeTerminalInput preserves ZWNJ (U+200C) and ZWJ (U+200D)", () => {
// ZWNJ is meaningful in Persian orthography; ZWJ joins emoji sequences.
// PTYs / remote programs can process these raw bytes, so they must not be
// stripped — otherwise filenames, args, or passwords containing them break.
assert.equal(sanitizeTerminalInput("a\u200cb"), "a\u200cb");
assert.equal(sanitizeTerminalInput("a\u200db"), "a\u200db");
// Emoji with ZWJ (👨‍💻 = man + ZWJ + laptop) is preserved
assert.equal(sanitizeTerminalInput("👨\u200d💻"), "👨\u200d💻");
});
test("sanitizeTerminalInput strips directional marks (U+200E, U+200F)", () => {
assert.equal(sanitizeTerminalInput("\u200els\u200f"), "ls");
});
test("sanitizeTerminalInput strips word joiner and invisible operators (U+2060-2064)", () => {
assert.equal(sanitizeTerminalInput("ls\u2060"), "ls");
assert.equal(sanitizeTerminalInput("ls\u2061"), "ls");
assert.equal(sanitizeTerminalInput("ls\u2062"), "ls");
assert.equal(sanitizeTerminalInput("ls\u2063"), "ls");
assert.equal(sanitizeTerminalInput("ls\u2064"), "ls");
});
test("sanitizeTerminalInput returns empty string for zero-width-only input", () => {
assert.equal(sanitizeTerminalInput("\u200b"), "");
assert.equal(sanitizeTerminalInput("\u200b\ufeff\u2060"), "");
});
test("sanitizeTerminalInput does not strip ZWNJ/ZWJ-only input", () => {
assert.equal(sanitizeTerminalInput("\u200c"), "\u200c");
assert.equal(sanitizeTerminalInput("\u200d"), "\u200d");
assert.equal(sanitizeTerminalInput("\u200c\u200d"), "\u200c\u200d");
});
test("sanitizeTerminalInput preserves regular ASCII and control characters", () => {
assert.equal(sanitizeTerminalInput("ls\r"), "ls\r");
assert.equal(sanitizeTerminalInput("\r"), "\r");
assert.equal(sanitizeTerminalInput("\n"), "\n");
assert.equal(sanitizeTerminalInput("\u0003"), "\u0003"); // Ctrl+C
assert.equal(sanitizeTerminalInput("\u007f"), "\u007f"); // DEL
});
test("sanitizeTerminalInput preserves CJK and emoji characters", () => {
assert.equal(sanitizeTerminalInput("你好"), "你好");
assert.equal(sanitizeTerminalInput("😀"), "😀");
// Full-width punctuation (common CJK IME output) is preserved
assert.equal(sanitizeTerminalInput(",。!?"), ",。!?");
});
test("sanitizeTerminalInput preserves Kitty escape sequences", () => {
const kittySeq = "\u001b[0;;97:97u";
assert.equal(sanitizeTerminalInput(kittySeq), kittySeq);
});
test("sanitizeTerminalInput handles empty and falsy input", () => {
assert.equal(sanitizeTerminalInput(""), "");
});
test("sanitizeTerminalInput strips multiple interspersed zero-width characters", () => {
assert.equal(sanitizeTerminalInput("\u200bl\u200bs\u200b \u200b-l\u200ba\u200b"), "ls -la");
});
test("sanitizeTerminalInput is stable across repeated calls", () => {
const input = "ls\u200b\r";
const first = sanitizeTerminalInput(input);
const second = sanitizeTerminalInput(first);
assert.equal(first, second);
assert.equal(first, "ls\r");
});

View File

@@ -0,0 +1,39 @@
/**
* Strip zero-width / invisible Unicode formatting characters from terminal
* input before it reaches the PTY.
*
* CJK IMEs (notably Microsoft Pinyin / Sogou on Windows) occasionally emit
* zero-width characters when switching composition modes. xterm.js sends
* these to the PTY via `onData`, and with the `15-graphemes` Unicode
* version they render at width 0 — so the command line looks normal but
* contains hidden characters that cause the executed command to fail (#3138).
*
* ZWNJ (U+200C) and ZWJ (U+200D) are intentionally preserved: they carry
* meaning in Persian orthography and emoji sequences, and PTYs / remote
* programs can process those raw bytes even though they occupy no display
* width. Stripping them would corrupt command arguments, filenames, and
* passwords that legitimately contain them.
*/
// U+00AD SOFT HYPHEN
// U+200B ZERO WIDTH SPACE
// U+200E LEFT-TO-RIGHT MARK
// U+200F RIGHT-TO-LEFT MARK
// U+2060 WORD JOINER
// U+2061 FUNCTION APPLICATION
// U+2062 INVISIBLE TIMES
// U+2063 INVISIBLE SEPARATOR
// U+2064 INVISIBLE PLUS
// U+FEFF ZERO WIDTH NO-BREAK SPACE / BOM
//
// U+200C (ZWNJ) and U+200D (ZWJ) are intentionally excluded.
const ZERO_WIDTH_INPUT_RE = /[\u00ad\u200b\u200e-\u200f\u2060-\u2064\ufeff]/g;
/**
* Remove zero-width / invisible formatting characters from terminal input.
* Returns an empty string when the input consisted solely of such characters.
*/
export function sanitizeTerminalInput(data: string): string {
if (!data) return data;
return data.replace(ZERO_WIDTH_INPUT_RE, "");
}

View File

@@ -0,0 +1,133 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
const runtimeSource = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
const attachmentSource = readFileSync(new URL("./terminalSessionAttachment.ts", import.meta.url), "utf8");
const terminalSource = readFileSync(new URL("../../Terminal.tsx", import.meta.url), "utf8");
const terminalLayerSource = readFileSync(new URL("../../TerminalLayer.tsx", import.meta.url), "utf8");
const preloadSource = readFileSync(
new URL("../../../electron/preload/api.cjs", import.meta.url),
"utf8",
);
const scriptBridgeSource = readFileSync(
new URL("../../../electron/bridges/scriptBridge.cjs", import.meta.url),
"utf8",
);
const scriptCodegenSource = readFileSync(
new URL("../../../electron/scripts/scriptCodegen.cjs", import.meta.url),
"utf8",
);
const scriptDialogSource = readFileSync(
new URL("../../scripts/ScriptDialogHost.tsx", import.meta.url),
"utf8",
);
test("password-prompt input is classified before prompt state reset and cannot broadcast", () => {
assert.match(
attachmentSource,
/typeof meta\?\.pluginPipelineSensitiveInput === "boolean"[\s\S]*?passwordPromptActiveRef\.current = meta\.pluginPipelineSensitiveInput/u,
);
assert.match(
terminalSource,
/typeof meta\?\.pluginPipelineSensitiveInput === "boolean"[\s\S]*?passwordPromptActiveRef\.current = meta\.pluginPipelineSensitiveInput[\s\S]*?sensitivePromptOutputTailRef\.current = "";[\s\S]*?return;[\s\S]*?else if \(isUntrustedTerminalInputPrompt/u,
);
assert.match(
runtimeSource,
/const sensitive = ctx\.passwordPromptActiveRef\?\.current === true;[\s\S]*?const canBroadcastInput = !sensitive &&[\s\S]*?const willBroadcastInput = canBroadcastInput && options\?\.skipBroadcast !== true;/u,
);
assert.match(
runtimeSource,
/for \(const chunk of getTextInputWireChunks\(outData, options\?\.perCharacterWrites === true\)\) \{\s*ctx\.terminalBackend\.writeToSession\(id, chunk, \{ sensitive \}\);\s*\}/u,
);
assert.match(
runtimeSource,
/writeToSession\(id, nextData, \{ sensitive \}\)/u,
);
assert.match(
runtimeSource,
/const broadcastUserPasteData = \(data: string\) => \{[\s\S]*?passwordPromptActiveRef\?\.current !== true[\s\S]*?onBroadcastInputRef\.current/u,
);
assert.match(
terminalSource,
/const sensitive = passwordPromptActiveRef\.current;[\s\S]*?!sensitive && isBroadcastEnabledRef\.current[\s\S]*?writeToSession\(id, data, \{[\s\S]*?sensitive,/u,
);
});
test("Ctrl+C clears renderer password-prompt classification before the next input", () => {
assert.match(
runtimeSource,
/clearTerminalInputStateForInterrupt\(\{[\s\S]*?passwordPromptActiveRef\.current = false;[\s\S]*?interruptSession/u,
);
});
test("confirmed sudo credentials and preload transport preserve the sensitive marker", () => {
assert.match(
attachmentSource,
/writeToSession\(id, data, \{ automated: true, sensitive: true \}\)/u,
);
assert.match(preloadSource, /sensitive: options\?\.sensitive === true/u);
});
test("OSC 52 clipboard replies bypass plugin input interception as sensitive host data", () => {
assert.match(
runtimeSource,
/writeToSession\(\s*sessionId,\s*`\\x1b\]52;\$\{target\};\$\{b64\}\\x07`,\s*\{ sensitive: true \},\s*\)/u,
);
});
test("generated script credentials stay masked and preserve the sensitive marker", () => {
assert.match(scriptCodegenSource, /dialog\.prompt\([\s\S]*?\{ sensitive: true \}\)/u);
assert.match(scriptCodegenSource, /screen\.sendLine\([\s\S]*?\{ sensitive: true \}\)/u);
assert.match(scriptBridgeSource, /options\.sensitive === true \? \{ sensitive: true \} : \{\}/u);
assert.match(scriptDialogSource, /type=\{request\.sensitive \? 'password' : 'text'\}/u);
});
test("renderer flow control acknowledges host ingress rather than transformed display length", () => {
assert.match(
attachmentSource,
/const pluginPipelineIngressBytes = Number\.isFinite\(meta\?\.pluginPipelineIngressBytes\)[\s\S]*?const ingressBytes = pluginPipelineIngressBytes[\s\S]*?\?\? filtered\.acceptedBytes/u,
);
assert.match(
attachmentSource,
/filtered\.accepted && !filtered\.data && pluginPipelineIngressBytes != null[\s\S]*?acknowledgeDroppedTerminalDisplayBytes\(ctx, pluginPipelineIngressBytes\)/u,
);
assert.match(
attachmentSource,
/!filtered\.accepted && pluginPipelineIngressBytes != null[\s\S]*?\? pluginPipelineIngressBytes[\s\S]*?: pluginPipelineIngressBytes != null[\s\S]*?\? 0[\s\S]*?: filtered\.droppedBytes/u,
);
assert.match(
attachmentSource,
/const displayBytes = data\.length;[\s\S]*?enqueueTerminalWrite\(term, displayBytes,[\s\S]*?dropBytes: ingressBytes/u,
);
assert.match(
readFileSync(new URL("./createTerminalSessionStarters.ts", import.meta.url), "utf8"),
/const pluginPipelineIngressBytes = Number\.isFinite\(meta\?\.pluginPipelineIngressBytes\)[\s\S]*?!chunk && pluginPipelineIngressBytes > 0[\s\S]*?acknowledgeDroppedTerminalDisplayBytes\(ctx, pluginPipelineIngressBytes\)[\s\S]*?writeSessionData\(ctx, term, chunk, pluginPipelineIngressBytes, meta\)/u,
);
assert.match(
terminalSource,
/beginHibernatedSessionListeners[\s\S]*?\(chunk, meta\) =>[\s\S]*?observeTerminalInputPrompt\(chunk, meta\)[\s\S]*?Number\.isFinite\(meta\?\.pluginPipelineIngressBytes\)[\s\S]*?ackTerminalSessionFlow\(terminalBackend, backendId, pluginPipelineIngressBytes\)/u,
);
});
test("active and hibernated output share host-owned sensitive prompt classification", () => {
assert.match(
terminalSource,
/const observeTerminalInputPrompt = useCallback[\s\S]*?typeof meta\?\.pluginPipelineSensitiveInput === "boolean"[\s\S]*?passwordPromptActiveRef\.current = meta\.pluginPipelineSensitiveInput[\s\S]*?sensitivePromptOutputTailRef\.current = "";[\s\S]*?return;[\s\S]*?isConfirmedTerminalShellPrompt[\s\S]*?passwordPromptActiveRef\.current = false/u,
);
assert.match(
terminalSource,
/beginHibernatedSessionListeners[\s\S]*?observeTerminalInputPrompt\(chunk, meta\)/u,
);
assert.match(
terminalSource,
/onTerminalOutput: \(chunk: string, meta\?: TerminalSessionDataMeta\) => \{\s*observeTerminalInputPrompt\(chunk, meta\)/u,
);
});
test("ordinary broadcast skips targets that are waiting for sensitive input", () => {
assert.match(
terminalLayerSource,
/if \(isTerminalSensitiveInputActive\(session\.id\)\) continue;[\s\S]*?writeToSession\(session\.id, data/u,
);
});

View File

@@ -0,0 +1,63 @@
import type { TerminalInputPrioritySnapshot } from "./terminalOutputPipeline";
const DEBUG_KEYS = [
"NETCATTY_CTRL_C_DEBUG",
"NETCATTY_TERMINAL_DEBUG",
];
function isDebugEnabled(): boolean {
try {
return DEBUG_KEYS.some((key) => window.localStorage?.getItem(key) === "1");
} catch {
return false;
}
}
function randomTraceSuffix(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID().slice(0, 8);
}
return Math.random().toString(36).slice(2, 10);
}
export function createTerminalInterruptTrace(options: {
sessionId: string;
rendererKeyAt: number;
status: string;
hasSelection: boolean;
priority?: TerminalInputPrioritySnapshot;
}): NetcattyTerminalInterruptTrace {
const debug = isDebugEnabled();
return {
debug,
traceId: `ctrlc-${Date.now().toString(36)}-${randomTraceSuffix()}`,
source: "renderer-xterm-keydown",
sessionId: options.sessionId,
rendererKeyAt: options.rendererKeyAt,
rendererSendAt: Date.now(),
rendererStatus: options.status,
rendererHasSelection: options.hasSelection,
rendererPriority: options.priority,
};
}
export function logTerminalInterruptTrace(
event: string,
trace: NetcattyTerminalInterruptTrace | undefined,
details: Record<string, unknown> = {},
): void {
if (!trace?.debug) return;
const now = Date.now();
try {
console.info("[Netcatty Ctrl+C]", {
event,
traceId: trace.traceId,
sessionId: trace.sessionId,
at: now,
deltaFromKeyMs: Number.isFinite(trace.rendererKeyAt) ? now - trace.rendererKeyAt : undefined,
...details,
});
} catch {
// Diagnostic logging must never affect terminal input.
}
}

View File

@@ -0,0 +1,29 @@
import assert from "node:assert/strict";
import test from "node:test";
import { clearTerminalInputStateForInterrupt } from "./terminalInterruptInputState";
test("interrupt input state clearing matches the normal Ctrl+C input bookkeeping", () => {
const commandBufferRef = { current: "sudo apt" };
const serialLineBufferRef = { current: "pending serial input" };
const autocompleteInputs: string[] = [];
clearTerminalInputStateForInterrupt({
commandBufferRef,
serialLineBufferRef,
onAutocompleteInput: (data) => autocompleteInputs.push(data),
});
assert.equal(commandBufferRef.current, "");
assert.equal(serialLineBufferRef.current, "");
assert.deepEqual(autocompleteInputs, ["\x03"]);
});
test("interrupt input state clearing tolerates terminals without serial line mode", () => {
const commandBufferRef = { current: "echo pending" };
assert.doesNotThrow(() => {
clearTerminalInputStateForInterrupt({ commandBufferRef });
});
assert.equal(commandBufferRef.current, "");
});

View File

@@ -0,0 +1,21 @@
type StringRef = {
current: string;
};
type InterruptInputStateOptions = {
commandBufferRef: StringRef;
serialLineBufferRef?: StringRef;
onAutocompleteInput?: (data: string) => void;
};
export function clearTerminalInputStateForInterrupt({
commandBufferRef,
serialLineBufferRef,
onAutocompleteInput,
}: InterruptInputStateOptions): void {
commandBufferRef.current = "";
if (serialLineBufferRef) {
serialLineBufferRef.current = "";
}
onAutocompleteInput?.("\x03");
}

View File

@@ -0,0 +1,42 @@
import assert from "node:assert/strict";
import test from "node:test";
import { shouldUseUrgentTerminalInterrupt } from "./terminalInterruptShortcut";
function key(overrides: Partial<KeyboardEvent> = {}): KeyboardEvent {
return {
key: "c",
code: "KeyC",
ctrlKey: true,
metaKey: false,
altKey: false,
shiftKey: false,
...overrides,
} as KeyboardEvent;
}
test("urgent interrupt handles plain Ctrl+C with no selection", () => {
assert.equal(shouldUseUrgentTerminalInterrupt(key(), { hasSelection: false }), true);
});
test("urgent interrupt follows the physical C key on non-Latin layouts", () => {
assert.equal(shouldUseUrgentTerminalInterrupt(key({ key: "с" }), { hasSelection: false }), true);
});
test("urgent interrupt prefers an active ASCII layout character over its physical key", () => {
assert.equal(
shouldUseUrgentTerminalInterrupt(key({ key: "c", code: "KeyJ" }), { hasSelection: false }),
true,
);
assert.equal(
shouldUseUrgentTerminalInterrupt(key({ key: "j", code: "KeyC" }), { hasSelection: false }),
false,
);
});
test("urgent interrupt leaves copy shortcuts and modified chords alone", () => {
assert.equal(shouldUseUrgentTerminalInterrupt(key(), { hasSelection: true }), false);
assert.equal(shouldUseUrgentTerminalInterrupt(key({ shiftKey: true }), { hasSelection: false }), false);
assert.equal(shouldUseUrgentTerminalInterrupt(key({ metaKey: true }), { hasSelection: false }), false);
assert.equal(shouldUseUrgentTerminalInterrupt(key({ altKey: true }), { hasSelection: false }), false);
});

View File

@@ -0,0 +1,11 @@
type InterruptShortcutEvent = Pick<KeyboardEvent, "altKey" | "code" | "ctrlKey" | "key" | "metaKey" | "shiftKey">;
export function shouldUseUrgentTerminalInterrupt(
event: InterruptShortcutEvent,
options: { hasSelection: boolean },
): boolean {
if (options.hasSelection) return false;
if (!event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) return false;
if (/^[\x20-\x7e]$/.test(event.key)) return event.key.toLowerCase() === "c";
return event.code === "KeyC" || event.key.toLowerCase() === "c";
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,152 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createTerminalLinkHandler } from "./terminalLinkHandler";
const click = {
ctrlKey: false,
altKey: false,
metaKey: false,
shiftKey: false,
} as MouseEvent;
test("OSC 8 links use the Netcatty external browser bridge", async () => {
const opened: string[] = [];
const confirmed: string[] = [];
const handler = createTerminalLinkHandler({
canActivate: () => true,
openExternalAvailable: () => true,
openExternal: async (uri) => opened.push(uri),
confirmOscLink: (uri) => {
confirmed.push(uri);
return true;
},
openWindow: () => {
throw new Error("window.open should not be used when the bridge is available");
},
});
handler.activateOsc(click, "https://github.com/flyspray/flyspray");
await Promise.resolve();
assert.deepEqual(confirmed, ["https://github.com/flyspray/flyspray"]);
assert.deepEqual(opened, ["https://github.com/flyspray/flyspray"]);
});
test("OSC 8 links do not open when the user rejects the safety confirmation", async () => {
const opened: string[] = [];
const handler = createTerminalLinkHandler({
canActivate: () => true,
openExternalAvailable: () => true,
openExternal: async (uri) => opened.push(uri),
confirmOscLink: () => false,
});
handler.activateOsc(click, "https://example.com");
await Promise.resolve();
assert.deepEqual(opened, []);
});
test("terminal links still honor the configured activation modifier", async () => {
const opened: string[] = [];
const handler = createTerminalLinkHandler({
canActivate: () => false,
openExternalAvailable: () => true,
openExternal: async (uri) => opened.push(uri),
confirmOscLink: () => true,
});
handler.activate(click, "https://example.com");
await Promise.resolve();
assert.deepEqual(opened, []);
});
test("terminal links reject non-http protocols", async () => {
const opened: string[] = [];
const warnings: unknown[][] = [];
const handler = createTerminalLinkHandler({
canActivate: () => true,
openExternalAvailable: () => true,
openExternal: async (uri) => opened.push(uri),
confirmOscLink: () => true,
warn: (...args) => warnings.push(args),
});
handler.activate(click, "file:///etc/passwd");
await Promise.resolve();
assert.deepEqual(opened, []);
assert.equal(warnings.length, 1);
});
test("terminal links fall back to window.open when the bridge is unavailable", async () => {
const opened: string[] = [];
const handler = createTerminalLinkHandler({
canActivate: () => true,
openExternalAvailable: () => false,
confirmOscLink: () => true,
openExternal: async () => {
throw new Error("bridge should not be used when unavailable");
},
openWindow: (uri) => opened.push(uri),
});
handler.activate(click, "https://example.com");
await Promise.resolve();
assert.deepEqual(opened, ["https://example.com"]);
});
test("terminal links do not report a noopener fallback as blocked", async () => {
const failures: unknown[] = [];
const handler = createTerminalLinkHandler({
canActivate: () => true,
openExternalAvailable: () => false,
confirmOscLink: () => true,
openExternal: async () => {
throw new Error("bridge should not be used when unavailable");
},
openWindow: () => null,
onError: (error) => failures.push(error),
});
await handler.open("https://example.com");
assert.deepEqual(failures, []);
});
test("terminal link failures are reported to the UI", async () => {
const failures: unknown[] = [];
const handler = createTerminalLinkHandler({
canActivate: () => true,
openExternalAvailable: () => true,
confirmOscLink: () => true,
openExternal: async () => {
throw new Error("no browser available");
},
onError: (error) => failures.push(error),
warn: () => {},
});
handler.activate(click, "https://example.com");
await Promise.resolve();
await Promise.resolve();
assert.equal(failures.length, 1);
assert.match(String(failures[0]), /no browser available/);
});
test("the xterm OSC 8 provider is wired to the confirmed terminal link path", () => {
const runtimeSource = readFileSync(
new URL("./createXTermRuntime.ts", import.meta.url),
"utf8",
);
assert.match(
runtimeSource,
/linkHandler:\s*\{\s*activate: terminalLinkHandler\.activateOsc,\s*\}/,
);
});

View File

@@ -0,0 +1,66 @@
export type TerminalLinkClickEvent = Pick<
MouseEvent,
"altKey" | "ctrlKey" | "metaKey" | "shiftKey"
>;
export type TerminalLinkHandlerOptions = {
canActivate: (event: TerminalLinkClickEvent) => boolean;
openExternalAvailable: () => boolean;
openExternal: (uri: string) => Promise<void>;
confirmOscLink: (uri: string) => boolean;
openWindow?: (uri: string) => unknown;
onError?: (error: unknown) => void;
warn?: (...args: unknown[]) => void;
};
export type TerminalLinkHandler = {
activate: (event: TerminalLinkClickEvent, uri: string) => void;
activateOsc: (event: TerminalLinkClickEvent, uri: string) => void;
open: (uri: string) => Promise<void>;
};
export function createTerminalLinkHandler(
options: TerminalLinkHandlerOptions,
): TerminalLinkHandler {
const warn = options.warn ?? console.warn;
const open = async (uri: string): Promise<void> => {
if (!/^https?:\/\//iu.test(String(uri || ""))) {
warn("[XTerm] Refusing to open non-http(s) link:", uri);
return;
}
try {
if (options.openExternalAvailable()) {
await options.openExternal(uri);
return;
}
(options.openWindow ?? ((url) => window.open(
url,
"_blank",
"noopener,noreferrer",
)))(uri);
} catch (error) {
warn("[XTerm] Failed to open terminal link:", error);
options.onError?.(error);
}
};
return {
activate(event, uri) {
if (!options.canActivate(event)) return;
void open(uri);
},
activateOsc(event, uri) {
if (!options.canActivate(event)) return;
if (!/^https?:\/\//iu.test(String(uri || ""))) {
warn("[XTerm] Refusing to open non-http(s) link:", uri);
return;
}
if (!options.confirmOscLink(uri)) return;
void open(uri);
},
open,
};
}

View File

@@ -0,0 +1,882 @@
import assert from "node:assert/strict";
import test from "node:test";
import { spawnSync } from "node:child_process";
import {
DEFAULT_OUTPUT_HISTORY_MAX_LINES,
createTerminalOutputHistoryPreview,
nextOutputHistoryPreviewTop,
stripTerminalDisplayToPlainText,
wrapOutputHistoryLineToRows,
} from "./terminalOutputHistory.ts";
test("display chunks reduce to plain transcript text", () => {
assert.deepEqual(
stripTerminalDisplayToPlainText("\x1b[32mhello\x1b[0m world\r\n"),
{ text: "hello world\r\n", pending: "" },
);
assert.equal(
stripTerminalDisplayToPlainText("\x1b]0;title\x07tail").text,
"tail",
);
assert.equal(
stripTerminalDisplayToPlainText("\x1b]0;title\x1b\\tail").text,
"tail",
);
assert.equal(stripTerminalDisplayToPlainText("a\x07b\x00c").text, "abc");
});
test("escape sequences split across chunks are not leaked into the transcript", () => {
const first = stripTerminalDisplayToPlainText("ok\x1b[3");
assert.equal(first.text, "ok");
assert.equal(first.pending, "\x1b[3");
const second = stripTerminalDisplayToPlainText("1mred", first.pending);
assert.equal(second.text, "red");
assert.equal(second.pending, "");
});
test("escape sequences with intermediate bytes are consumed through their final byte", () => {
// ESC ( B designates G0 (ncurses / terminal reset emit it constantly).
assert.equal(stripTerminalDisplayToPlainText("\x1b(Bplain").text, "plain");
// ESC # 8 is DECALN.
assert.equal(stripTerminalDisplayToPlainText("\x1b#8plain").text, "plain");
// Two-byte escapes without intermediates keep working.
assert.equal(stripTerminalDisplayToPlainText("\x1bMplain").text, "plain");
const first = stripTerminalDisplayToPlainText("ok\x1b(");
assert.equal(first.text, "ok");
assert.equal(first.pending, "\x1b(");
assert.equal(stripTerminalDisplayToPlainText("Btail", first.pending).text, "tail");
});
test("a cursor-addressed frame without newlines stays inside the character budget", () => {
const history = createTerminalOutputHistoryPreview({ maxChars: 64 });
for (let frame = 0; frame < 200; frame += 1) {
history.append(`\x1b[Hframe ${frame} ${"x".repeat(80)}`);
}
const transcript = [...history.getLines()].join("");
assert.ok(transcript.length <= 2 * 64, `unbounded open line: ${transcript.length}`);
// The newest frame still lands in the retained tail.
assert.ok(transcript.includes("frame 199"), transcript.slice(-200));
});
test("8-bit control strings are consumed through their terminator", () => {
// C1 OSC (0x9d) with C1 ST (0x9c) must not leak its payload.
assert.equal(stripTerminalDisplayToPlainText("\x9d0;SECRET\x9ctail").text, "tail");
// DCS (0x90), SOS (0x98), PM (0x9e) and APC (0x9f) use the same terminator.
assert.equal(stripTerminalDisplayToPlainText("\x90payload\x9ctail").text, "tail");
assert.equal(stripTerminalDisplayToPlainText("\x98payload\x9ctail").text, "tail");
assert.equal(stripTerminalDisplayToPlainText("\x9epayload\x9ctail").text, "tail");
assert.equal(stripTerminalDisplayToPlainText("\x9fpayload\x9ctail").text, "tail");
const first = stripTerminalDisplayToPlainText("ok\x9d0;title");
assert.equal(first.text, "ok");
assert.equal(first.pending, "\x9d0;title");
assert.equal(stripTerminalDisplayToPlainText("\x9ctail", first.pending).text, "tail");
});
test("tabs expand to terminal tab stops so preview rows wrap as they render", () => {
const history = createTerminalOutputHistoryPreview();
history.append("a\tb\n");
history.append("abc\td\n");
assert.deepEqual([...history.getLines()], ["a b", "abc d"]);
});
test("tab stops advance by cell columns, not characters", () => {
const history = createTerminalOutputHistoryPreview();
history.append("中\tb\n");
assert.deepEqual([...history.getLines()], ["中 b"]);
});
test("an oversized control string split across chunks leaks no payload", () => {
const payload = "y".repeat(5000);
const first = stripTerminalDisplayToPlainText(`ok\x1b]52;c;${payload}`);
assert.equal(first.text, "ok");
assert.equal(first.pending.length, 4096);
assert.equal(first.pending.startsWith("\x1b]"), true);
const second = stripTerminalDisplayToPlainText("\x07tail", first.pending);
assert.equal(second.text, "tail");
});
test("a span longer than the remaining budget continues into the next line", () => {
const history = createTerminalOutputHistoryPreview({ maxChars: 16 });
history.append("aaaaaaaaaaaaaaaaTTTTTTTT\n");
assert.ok([...history.getLines()].join("").includes("TTTTTTTT"));
});
test("erase-in-line after a carriage return drops the stale suffix", () => {
const history = createTerminalOutputHistoryPreview();
history.append("downloading 100%\rdownloading 5%\x1b[K\n");
assert.deepEqual([...history.getLines()], ["downloading 5%"]);
history.clear();
history.append("keep\r\x1b[2Knew\n");
assert.deepEqual([...history.getLines()], ["new"]);
});
test("clear resets the tab stop column with the rest of the line state", () => {
const history = createTerminalOutputHistoryPreview();
history.append("abc");
history.clear();
history.append("\tb\n");
assert.deepEqual([...history.getLines()], [" b"]);
});
test("bare carriage returns overwrite the line they restart", () => {
const history = createTerminalOutputHistoryPreview();
history.append("downloading 10%\r");
history.append("downloading 55%\r");
history.append("downloading 100%\r\n");
history.append("done\n");
assert.deepEqual([...history.getLines()], ["downloading 100%", "done"]);
});
test("history keeps a bounded tail of lines", () => {
const history = createTerminalOutputHistoryPreview({ maxLines: 3 });
for (let index = 0; index < 6; index += 1) history.append(`line ${index}\n`);
assert.deepEqual(
[...history.getLines()],
["line 3", "line 4", "line 5"],
);
});
test("preview rows wrap long lines and flag the continuation rows", () => {
const history = createTerminalOutputHistoryPreview();
history.append("ok\n");
history.append("abcdefgh\n");
const window = history.getPreviewRows({ cols: 4, rows: 3, top: 0 });
assert.equal(window.totalRows, 3);
assert.deepEqual(
window.rows.map((row) => row.text),
["ok", "abcd", "efgh"],
);
assert.deepEqual(
window.rows.map((row) => row.isWrapped),
[false, false, true],
);
});
test("preview rows flag lines committed by automatic wraps as soft-wrapped", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
// "abcde" fills the viewport and "f" wraps; the transcript commits both
// lines, but the preview must keep the soft-wrapped join between them.
history.append("abcdef\n");
assert.deepEqual([...history.getLines()], ["abcde", "f"]);
assert.deepEqual(
history.getPreviewRows({ cols: 5, rows: 2, top: 0 }).rows.map((row) => row.isWrapped),
[false, true],
);
});
test("preview reflows adjacent soft-wrapped lines together when widened", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
// Captured at five columns, the transcript holds two wrap segments; a
// ten-column preview must rejoin them into the single row xterm shows
// after its resize reflow.
history.append("abcdef\n");
assert.deepEqual(
history.getPreviewRows({ cols: 10, rows: 1, top: 0 }).rows.map((row) => row.text),
["abcdef"],
);
// A narrower preview keeps splitting the rejoined run, flagging the
// continuation rows.
assert.deepEqual(
history.getPreviewRows({ cols: 3, rows: 2, top: 0 }).rows.map((row) => row.text),
["abc", "def"],
);
assert.deepEqual(
history.getPreviewRows({ cols: 3, rows: 2, top: 0 }).rows.map((row) => row.isWrapped),
[false, true],
);
});
test("CUB moves from the displayed last column while a wrap is deferred", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
// "abcde" fills the row and defers the wrap; xterm displays the cursor on
// the last column, so CUB 1 targets it minus one and X overwrites "e".
history.append("abcde\x1b[DX");
assert.deepEqual([...history.getLines()], ["abcXe"]);
history.clear();
// CUF from the same deferred state still clamps to the last column.
history.append("abcde\x1b[CX");
assert.deepEqual([...history.getLines()], ["abcdX"]);
});
test("preview windows clamp to the retained rows and pad short output", () => {
const history = createTerminalOutputHistoryPreview();
history.append("one\ntwo\n");
assert.deepEqual(
history.getPreviewRows({ cols: 10, rows: 2, top: 99 }).rows.map((row) => row.text),
["one", "two"],
);
assert.deepEqual(
history.getPreviewRows({ cols: 10, rows: 4, top: 0 }).rows.map((row) => row.text),
["one", "two", "", ""],
);
});
test("preview rows keep wide characters intact at a column boundary", () => {
// Four columns hold two CJK cells per row; the glyphs are never split.
assert.deepEqual(wrapOutputHistoryLineToRows("中文中文中文", 4), ["中文", "中文", "中文"]);
const history = createTerminalOutputHistoryPreview();
history.append("中文中文中文\n");
assert.deepEqual(
history.getPreviewRows({ cols: 4, rows: 3, top: 0 }).rows.map((row) => row.text),
["中文", "中文", "中文"],
);
});
test("wrap decisions measure with the live terminal's Unicode width provider", () => {
// The configured `15-graphemes` runtime counts `🖥` as one xterm cell while
// the local fallback counts two, so without the injected provider the
// tracker commits `abcd` and puts `🖥X` on the continuation row while xterm
// renders `abcd🖥` with `X` wrapped. The preview must match the terminal.
const widthTerm = {
_core: {
unicodeService: { getStringCellWidth: (s: string) => [...s].length },
},
} as never;
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
history.setWidthTerminal(widthTerm);
history.append("abcd🖥X");
assert.deepEqual(history.getLines(), ["abcd🖥", "X"]);
const preview = createTerminalOutputHistoryPreview();
preview.setWidthTerminal(widthTerm);
preview.append("abcd🖥X\n");
assert.deepEqual(
preview.getPreviewRows({ cols: 5, rows: 2, top: 0 }).rows.map((row) => row.text),
["abcd🖥", "X"],
);
});
test("wheel steps walk the preview from the newest row upwards", () => {
const history = createTerminalOutputHistoryPreview();
for (let index = 0; index < 6; index += 1) history.append(`row ${index}\n`);
const totalRows = history.getPreviewRows({ cols: 20, rows: 2, top: 0 }).totalRows;
assert.equal(totalRows, 6);
const bottom = nextOutputHistoryPreviewTop({
currentTop: null,
lines: 0,
rows: 2,
totalRows,
});
assert.equal(bottom, 4);
assert.deepEqual(
history.getPreviewRows({ cols: 20, rows: 2, top: bottom }).rows.map((row) => row.text),
["row 4", "row 5"],
);
const up = nextOutputHistoryPreviewTop({ currentTop: bottom, lines: -3, rows: 2, totalRows });
assert.equal(up, 1);
const top = nextOutputHistoryPreviewTop({ currentTop: up, lines: -30, rows: 2, totalRows });
assert.equal(top, 0);
});
test("clear drops retained transcript and pending escapes", () => {
const history = createTerminalOutputHistoryPreview();
history.append(`keep\n`);
history.clear();
history.append("\x1b[3");
assert.deepEqual([...history.getLines()], []);
assert.equal(
history.getPreviewRows({ cols: 10, rows: 2, top: 0 }).totalRows,
0,
);
});
test("default retention bounds the preview history", () => {
assert.equal(DEFAULT_OUTPUT_HISTORY_MAX_LINES > 0, true);
const history = createTerminalOutputHistoryPreview({ maxLines: 2, maxChars: 6 });
history.append("aaaaaaaa\n");
history.append("bbbbbbbb\n");
history.append("cccccccc\n");
// Retained lines plus the open line stay within twice the character budget,
// and the newest text survives.
const transcript = [...history.getLines()].join("");
assert.ok(transcript.length <= 2 * 6, transcript);
assert.ok(transcript.includes("cc"), transcript);
});
test("Vim absolute row moves separate printed rows across arbitrary chunks", () => {
const input = '\x1b[2;1HSMOKE-VIM-002\x1b[2;14H\x1b[K\x1b[3;1HSMOKE-VIM-003\x1b[3;14H\x1b[K\x1b[4;1HSMOKE-VIM-004';
for (let split = 0; split <= input.length; split++) {
const history = createTerminalOutputHistoryPreview();
history.append(input.slice(0, split));
history.append(input.slice(split));
assert.deepEqual(history.getLines(), ['SMOKE-VIM-002', 'SMOKE-VIM-003', 'SMOKE-VIM-004']);
}
});
test("same-row cursor-home redraw stays one progress line", () => {
const history = createTerminalOutputHistoryPreview();
for (let i = 0; i < 100; i++) history.append(`\x1b[1;1Hprogress ${i}\x1b[K`);
assert.deepEqual(history.getLines(), ['progress 99']);
});
test("vertical moves do not insert blank transcript rows or expose control strings", () => {
const history = createTerminalOutputHistoryPreview();
history.append('one\r\n\x1b[2;1Htwo\x1b[1Bthree\x9b4;1Hfour');
history.append('\x1b]titleH\x07\x1b[999999999Bfive');
assert.deepEqual(history.getLines(), ['one', 'two', ' three', 'four', ' five']);
history.clear();
history.append('\x1b[Hnew\x1b[HNEW\x1b[K');
assert.deepEqual(history.getLines(), ['NEW']);
});
test("row controls retain requested and inherited terminal columns", () => {
const history = createTerminalOutputHistoryPreview();
history.append('\x1b[2;10Htext\x1b[1Bnext\x1b[6dlast\x1b[1Eleft');
assert.deepEqual(history.getLines(), [' text', ' next', ' last', 'left']);
history.clear();
history.append('\x9b2;3fAB\x1b[2;3HXY');
assert.deepEqual(history.getLines(), [' XY']);
});
test("positioned output respects wide-cell boundaries and retained size limits", () => {
const history = createTerminalOutputHistoryPreview({ maxChars: 64 });
history.append('\x1b[2;3H中文\x1b[2;5HX\x1b[K');
assert.deepEqual(history.getLines(), [' 中X']);
history.clear();
history.append('\x1b[2;999999999Hend');
assert.ok(history.getLines().join('').length <= 128);
assert.ok(history.getLines().join('').endsWith('end'));
});
test("CHA and relative horizontal moves stay on the tracked row", () => {
const history = createTerminalOutputHistoryPreview();
history.append('abcde\x1b[1GX');
assert.deepEqual(history.getLines(), ['Xbcde']);
history.clear();
history.append('ab\x1b[3CX');
assert.deepEqual(history.getLines(), ['ab X']);
history.clear();
history.append('abcde\x1b[2DX');
assert.deepEqual(history.getLines(), ['abcXe']);
});
test("NEL and IND advance the row; RI moves back up", () => {
const history = createTerminalOutputHistoryPreview();
history.append('foo\x1bEbar');
assert.deepEqual(history.getLines(), ['foo', 'bar']);
history.clear();
history.append('foo\x1bDbar');
assert.deepEqual(history.getLines(), ['foo', ' bar']);
history.clear();
history.append('\x1b[r\x1b[3;1Hfoo\x1bMbar');
assert.deepEqual(history.getLines(), ['foo', ' bar']);
history.clear();
history.append('\x1b[2;3r\x1b[2;1Hfoo\x1bMbar');
assert.deepEqual(history.getLines(), ['foo', ' bar']);
});
test("cursor placement alone does not append blank history rows", () => {
const history = createTerminalOutputHistoryPreview();
history.append('abc\x1b[2;4H\x1b[1B\x1b[1B');
assert.deepEqual(history.getLines(), ['abc']);
history.append('X');
assert.deepEqual(history.getLines(), ['abc', ' X']);
});
test("positioned overwrites replace whole graphemes and preserve cell spacing", () => {
for (const glyph of ['😀', '👩‍💻', '中']) {
const history = createTerminalOutputHistoryPreview();
history.append(`A${glyph}B\x1b[1;2HX`);
assert.deepEqual(history.getLines(), ['AX B']);
history.clear();
history.append(`A${glyph}B\x1b[1;3HX`);
assert.deepEqual(history.getLines(), ['A XB']);
}
const history = createTerminalOutputHistoryPreview();
history.append('AéB\x1b[1;2HX');
assert.deepEqual(history.getLines(), ['AXB']);
});
test("EL 1 erases a wide glyph intersected by the erase boundary", () => {
// The cursor lands on the wide glyph's first cell: xterm blanks both cells
// and keeps the suffix at its columns.
const history = createTerminalOutputHistoryPreview();
history.append('中A\x1b[1G\x1b[1K');
assert.deepEqual(history.getLines(), [' A']);
history.clear();
// The cursor lands on the wide glyph's second cell: the glyph is erased too.
history.append('A中B\x1b[1;3H\x1b[1K');
assert.deepEqual(history.getLines(), [' B']);
});
test("cursor-row moves clamp to the reported terminal viewport", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
// Bottom-row address + a down move past the viewport: the terminal keeps
// the cursor on row 24, so the status redraw must not split history lines.
history.append("\x1b[24;1Hstatus\x1b[1BNEW");
assert.deepEqual(history.getLines(), ["statusNEW"]);
history.clear();
// Absolute rows beyond the viewport clamp to the bottom row too: the redraw
// overwrites the bottom row in place instead of adding a history line.
history.append("\x1b[24;1Hrow24\x1b[999;1Hredraw");
assert.deepEqual(history.getLines(), ["redraw"]);
history.clear();
// Without a reported viewport the legacy behavior applies (columns are
// retained across row moves).
const unclamped = createTerminalOutputHistoryPreview();
unclamped.append("\x1b[24;1Hstatus\x1b[1BNEW");
assert.deepEqual(unclamped.getLines(), ["status", " NEW"]);
});
test("absolute cursor columns clamp to the reported viewport width", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(80);
// CUP past the last column clamps to it on screen; without the clamp the
// preview would pad 998 spaces and fabricate wrapped rows.
history.append("\x1b[1;999HX");
assert.deepEqual(history.getLines(), [" ".repeat(79) + "X"]);
});
test("line feeds on the bottom row stay clamped to it", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.append("\x1b[24;1Hstatus\r\nnew\x1b[24;1HNEW");
assert.deepEqual(history.getLines(), ["status", "NEW"]);
});
test("shrinking the viewport clamps the tracked cursor row", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.append("\x1b[24;1Hbottom");
history.setViewportRows(10);
// The terminal moved the cursor to the new bottom row, so a relative move
// from there must stay a same-row redraw instead of committing a line.
history.append("\x1b[1BX");
assert.deepEqual(history.getLines(), ["bottomX"]);
});
test("relative row moves stop at the scroll region's bottom margin", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.append("\x1b[1;20r\x1b[20;1Hstatus\x1b[1BNEW");
assert.deepEqual(history.getLines(), ["statusNEW"]);
});
test("invalid DECSTBM ranges are ignored", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
// Bottom not greater than top: xterm ignores the sequence, so the existing
// text must stay on its row and later text must not home the cursor.
history.append("\x1b[5;1Hbefore\x1b[20;10rafter");
assert.deepEqual(history.getLines(), ["beforeafter"]);
history.clear();
// A top past the viewport clamps to it, leaving no valid region either.
history.append("\x1b[5;1Hbefore\x1b[30;40rafter");
assert.deepEqual(history.getLines(), ["beforeafter"]);
history.clear();
// A bottom past the viewport clamps to it; the valid region still applies.
history.append("\x1b[1;999r\x1b[24;1Hstatus\x1b[999BNEW");
assert.deepEqual(history.getLines(), ["statusNEW"]);
});
test("growing the viewport resets the scroll margins", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.append("\x1b[1;24r");
history.setViewportRows(40);
// xterm resets the scroll region on resize, so a relative move past row 24
// reaches row 25 instead of stopping at the stale bottom margin.
history.append("\x1b[24;1Hrow24\x1b[1Brow25");
assert.deepEqual(history.getLines(), ["row24", " row25"]);
});
test("clear resets retained scroll margins between terminal boots", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.append("\x1b[1;20robsolete");
history.clear();
// The fresh xterm instance has a full-viewport region; the new session must
// not inherit the previous boot's bottom margin.
history.setViewportRows(24);
history.append("\x1b[20;1Hrow20\x1b[1Brow21");
assert.deepEqual(history.getLines(), ["row20", " row21"]);
});
test("narrowing the viewport clamps the tracked cursor column", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(80);
history.append("\x1b[1;80H");
history.setViewportCols(10);
// xterm clamps the cursor to the new last column; the next printable span
// must be written there, not padded out to the old column 80.
history.append("X");
assert.deepEqual(history.getLines(), [" ".repeat(9) + "X"]);
});
test("narrowing the viewport trims the tracked cursor row like xterm", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(10);
history.append("abcdefgh");
history.setViewportCols(5);
// xterm trims the cursor row to the new width on a narrowing resize
// (`reflowCursorLine` defaults to false, so `fgh` is discarded instead of
// re-wrapped), so X overwrites the last retained cell rather than leaving
// a phantom `fgh` tail plus an extra wrapped preview row.
history.append("X");
assert.deepEqual(history.getLines(), ["abcdX"]);
assert.deepEqual(
history.getPreviewRows({ cols: 5, rows: 1, top: 0 }).rows.map((row) => row.text),
["abcdX"],
);
});
test("narrowing the viewport drops the trimmed wide-glyph tail", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(6);
history.append("中文");
history.setViewportCols(3);
// The second glyph spans cells 2-3 past the new width; xterm discards it
// whole, so the open row keeps only the glyph that still fits and the next
// printable span lands on the new last column.
assert.deepEqual(history.getLines(), ["中"]);
history.append("X");
assert.deepEqual(history.getLines(), ["中X"]);
});
test("printable output past the viewport width wraps the tracked cursor", () => {
// xterm wraps `f` onto the second row, so `CSI 2;1H` overwrites it there
// instead of adding a separate line below a stale wrapped row.
const input = "abcdef\x1b[2;1HXY";
for (let split = 0; split <= input.length; split++) {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
history.append(input.slice(0, split));
history.append(input.slice(split));
assert.deepEqual(history.getLines(), ["abcde", "XY"]);
}
});
test("a carriage return cancels the deferred wrap instead of splitting the row", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
history.append("abcde\rXYZ");
assert.deepEqual(history.getLines(), ["XYZde"]);
});
test("wide glyphs wrap whole at the viewport width", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(4);
history.append("中文中文中文\n");
assert.deepEqual(history.getLines(), ["中文", "中文", "中文"]);
});
test("origin mode resolves absolute rows against the scroll region", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
// In origin mode both addresses target row 20 (top margin + 15, then the
// bottom-margin clamp), so the redraw overwrites `status` in place instead
// of retaining it as a stale separate line.
history.append("\x1b[5;20r\x1b[?6h\x1b[16;1Hstatus\x1b[99;1HNEW");
assert.deepEqual(history.getLines(), ["NEWtus"]);
history.clear();
// clear() forgets origin mode: rows are absolute again.
history.setViewportRows(24);
history.append("\x1b[5;20r\x1b[?6h\x1b[?6l\x1b[5;1Habsolute");
assert.deepEqual(history.getLines(), ["absolute"]);
});
test("non-wrapping wide glyph at the right edge cannot block terminal capture", () => {
const moduleUrl = new URL("./terminalOutputHistory.ts", import.meta.url).href;
const script = `
import { createTerminalOutputHistoryPreview } from ${JSON.stringify(moduleUrl)};
const results = [];
for (const glyph of ["中", "👩‍💻", "界界"]) {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
history.append(String.fromCharCode(27) + "[?7labcd" + glyph + "Z");
results.push(history.getLines());
}
console.log(JSON.stringify(results));
`;
const result = spawnSync(process.execPath, ["--import", "tsx", "--input-type=module", "-e", script], {
encoding: "utf8",
timeout: 5000,
});
assert.equal(result.error, undefined, String(result.error));
assert.equal(result.status, 0, result.stderr);
assert.deepEqual(JSON.parse(result.stdout), [["abcdZ"], ["abcdZ"], ["abcdZ"]]);
});
test("unchanged viewport reports preserve pending wrap across display chunks", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
history.append("abcde");
history.setViewportRows(24);
history.setViewportCols(5);
history.append("f");
assert.deepEqual(history.getLines(), ["abcde", "f"]);
});
test("a zero-width mark arriving after the wrap column joins the final cell", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
// The base character fills the last column; its combining mark arrives in
// the next chunk. xterm attaches the mark to that cell without wrapping.
history.append("abcde");
history.append("́Z\n");
assert.deepEqual([...history.getLines()], ["abcdé", "Z"]);
});
test("a standalone zero-width mark after a control starts the wrapped row", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
// The SGR control xterm parses resets its preceding-join state, so the
// combining mark that follows is a standalone grapheme: it must not attach
// to the previous row's final cell; the pending wrap happens and the mark
// begins the next row (like xterm's `15-graphemes` runtime).
history.append("abcde\x1b[31m");
history.append("́Z\n");
assert.deepEqual([...history.getLines()], ["abcde", "́Z"]);
// The same holds when the control rides inside one display chunk.
history.clear();
history.setViewportRows(24);
history.setViewportCols(5);
history.append("abcde\x1b[31ḿZ\n");
assert.deepEqual([...history.getLines()], ["abcde", "́Z"]);
});
test("combined private-mode controls apply every parameter", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
// `CSI ?6;7l` resets both origin mode and DECAWM in one control; dropping
// the control whole would leave autowrap on and wrap the overflow into a
// second row (xterm keeps `abcdef` on one row as `abcdf`).
history.append("\x1b[5;20r\x1b[?6;7l\x1b[5;1Habcdef");
assert.deepEqual(history.getLines(), ["abcdf"]);
// `CSI ?6;7h` re-enables both: absolute rows clamp to the margins again and
// printable output wraps once more.
history.append("\x1b[?6;7h\x1b[99;1HNEW");
assert.deepEqual(history.getLines(), ["abcdf", "NEW"]);
// Unrelated parameters ride along: xterm applies both `CSI ?6;25h` params
// (25 is DECTCEM, untracked here), so dropping the control whole would
// resolve the later CUP row absolutely instead of against the top margin.
const combined = createTerminalOutputHistoryPreview();
combined.setViewportRows(24);
combined.append("\x1b[5;20r\x1b[?6;25h\x1b[16;1Hstatus\x1b[99;1HOLD");
assert.deepEqual(combined.getLines(), ["OLDtus"]);
});
test("a grapheme longer than the per-row piece cap is not dropped", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
// One base character trailed by more combining marks than the piece cap
// holds: cutting at a grapheme boundary yields an empty slice for it, and
// returning early would silently drop this grapheme and everything after.
const mark = "́";
history.append("abcd" + "e" + mark.repeat(80) + "Z");
assert.deepEqual(history.getLines(), ["abcde" + mark.repeat(80), "Z"]);
});
test("discarding a non-fitting wide glyph does not re-pad the deferred gap", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
// Cursor on the last column, DECAWM off: the wide glyph is discarded and the
// next narrow character overwrites the last cell without padding it again.
history.append("\x1b[?7l\x1b[1;5H中Z");
assert.deepEqual(history.getLines(), [" Z"]);
});
test("restoring a cursor saved above a shrunken viewport clamps to the bottom row", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.append("\x1b[24;1Hsaved\x1b7");
history.setViewportRows(10);
history.append("\x1b8X");
// The saved row 24 no longer exists; the restore lands on the bottom row so
// the later bottom-row address writes `Y` on the same line as `X` instead of
// keeping an obsolete extra line (the saved column is preserved).
history.append("\x1b[10;1HY");
assert.deepEqual(history.getLines(), ["saved", "Y X"]);
});
test("RIS resets the tracked modes, margins, and cursor", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
// DECAWM off, then RIS: xterm re-enables autowrap and homes the cursor, so
// `abcdef` wraps to `abcde`/`f` exactly like a fresh terminal.
history.append("\x1b[?7l\x1bcabcdef");
assert.deepEqual(history.getLines(), ["abcde", "f"]);
// RIS also clears the scroll region, DECOM, and the saved cursor.
const reset = createTerminalOutputHistoryPreview();
reset.setViewportRows(24);
reset.append("\x1b[5;20r\x1b[?6h\x1b7\x1bc\x1b[99;1HOLD");
// Without the reset, the CUP row would be relative to the top margin and
// clamp to its bottom (row 20), rewriting the same tracked line as before.
assert.deepEqual(reset.getLines(), ["OLD"]);
});
test("RIS commits the open row even when the cursor is already homed", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(20);
// xterm clears the display before homing the cursor, so output after RIS
// must not overwrite a stale suffix of the cleared screen row.
history.append("LONG\x1bcX");
assert.deepEqual(history.getLines(), ["LONG", "X"]);
});
test("a grapheme split across display chunks is rejoined", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(5);
history.setViewportCols(4);
// The backend delivered the ZWJ sequence's base in one chunk and its
// continuation in the next; xterm's grapheme provider joins them across
// writes, so the preview must measure the joined cluster, not the chunks.
history.append("👩");
history.append("💻Z");
assert.deepEqual(history.getLines(), ["👩💻Z"]);
// The unsplit input is the reference behavior.
const joined = createTerminalOutputHistoryPreview();
joined.setViewportRows(5);
joined.setViewportCols(4);
joined.append("👩💻Z");
assert.deepEqual(joined.getLines(), ["👩💻Z"]);
});
test("a control between chunks resets the split-grapheme join", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(5);
history.setViewportCols(5);
// xterm resets its preceding-join state at every control it parses, so an
// SGR between the base chunk and the continuation leaves two graphemes:
// 👩 (2 cells) + ‍💻 (2 cells) + Z fill the row before Q wraps.
history.append("👩");
history.append("\x1b[31m");
history.append("💻ZQ");
assert.deepEqual(history.getLines(), ["👩💻Z", "Q"]);
// The unsplit input with the same control is the reference behavior.
const joined = createTerminalOutputHistoryPreview();
joined.setViewportRows(5);
joined.setViewportCols(5);
joined.append("👩\x1b[31m💻ZQ");
assert.deepEqual(joined.getLines(), ["👩💻Z", "Q"]);
});
test("a width-only resize resets the tracked scroll margins", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(80);
history.append("\x1b[1;20r");
history.append("row20content");
// xterm resets its scroll region on any buffer resize; a width-only resize
// must clear the old DECSTBM margins so relative moves clamp to the live
// bottom row instead of the region's stale one.
history.setViewportRows(24);
history.setViewportCols(40);
history.append("\x1b[20;1HX\x1b[BY");
assert.deepEqual(history.getLines(), ["row20content", "X", " Y"]);
});
test("preview reflow retains leading, consecutive and trailing blank hard lines", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportCols(5);
history.append("\nabcdef\n\n\nend\n\n");
assert.deepEqual([...history.getLines()], ["", "abcde", "f", "", "", "end", ""]);
const preview = history.getPreviewRows({ cols: 10, rows: 6, top: 0 });
assert.equal(preview.totalRows, 6);
assert.deepEqual(preview.rows.map((row) => row.text), ["", "abcdef", "", "", "end", ""]);
});
test("blank-only history remains distinct from empty history", () => {
const history = createTerminalOutputHistoryPreview();
assert.equal(history.getPreviewRowCount(10), 0);
history.append("\n\n");
assert.equal(history.getPreviewRowCount(10), 2);
});
test("reflow keeps an erase-blanked wrapped predecessor as its own row", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(5);
// At five columns `abcde` fills row one and defers the wrap; `CSI 2K`
// blanks it and `X` wraps to row two, so xterm shows a blank first row and
// a wrapped `X` on the second. The preview must keep both rows instead of
// joining the erased predecessor into its continuation.
history.append("abcde\x1b[2KX");
assert.deepEqual([...history.getLines()], ["", "X"]);
const preview = history.getPreviewRows({ cols: 5, rows: 2, top: 0 });
assert.equal(preview.totalRows, 2);
assert.deepEqual(preview.rows.map((row) => row.text), ["", "X"]);
assert.deepEqual(preview.rows.map((row) => row.isWrapped), [false, true]);
});
test("a grapheme wider than the viewport wraps the following characters", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(1);
// xterm renders the wide glyph on the first row and wraps `X` to the
// second; retaining the whole tail as one row would shift every later
// cursor-row transition one row behind the terminal.
history.append("中X");
assert.deepEqual([...history.getLines()], ["中", "X"]);
});
test("resizing invalidates cached preview text even when returning to its old width", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(10);
history.append("abcdefgh");
assert.deepEqual(history.getPreviewRows({ cols: 10, rows: 1, top: 0 }).rows,
[{ text: "abcdefgh", isWrapped: false }]);
// A hidden preview is not read between these resizes. Returning to the
// cached width must not resurrect characters trimmed from the live row.
history.setViewportCols(5);
history.setViewportCols(10);
assert.deepEqual(history.getLines(), ["abcde"]);
assert.deepEqual(history.getPreviewRows({ cols: 10, rows: 1, top: 0 }).rows,
[{ text: "abcde", isWrapped: false }]);
});
test("resizing invalidates cached preview row counts after trimming a wide line", () => {
const history = createTerminalOutputHistoryPreview();
history.setViewportRows(24);
history.setViewportCols(10);
history.append("abcdefgh");
assert.equal(history.getPreviewRowCount(4), 2);
history.setViewportCols(3);
assert.equal(history.getPreviewRowCount(4), 1);
assert.deepEqual(history.getPreviewRows({ cols: 4, rows: 1, top: 0 }).rows,
[{ text: "abc", isWrapped: false }]);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,84 @@
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import { readFileSync } from "node:fs";
import test from "node:test";
import type { Terminal } from "@xterm/xterm";
import { createTerminalOutputHistoryPreview } from "./terminalOutputHistory.ts";
const { Terminal: XTerm } = createRequire(import.meta.url)("@xterm/xterm") as { Terminal: typeof Terminal };
// Exercise the production resize listener without mounting the WebGL/UI runtime.
// The terminal buffer and history collector are real; only unrelated effects
// (atlas clearing and backend resize scheduling) are inert in this harness.
const runtime = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
const start = runtime.indexOf(" term.onResize(({ cols, rows }) => {");
assert.notEqual(start, -1);
const listenerSource = runtime.slice(start, runtime.indexOf("\n });", start) + "\n });".length);
function createHarness(cols: number) {
const term = new XTerm({ cols, rows: 24, allowProposedApi: true, convertEol: true });
const history = createTerminalOutputHistoryPreview();
new Function("term", "ctx", "clearWebglTextureAtlas", "resizeScheduler", listenerSource)(
term, { terminalOutputHistory: history, sessionRef: { current: null } }, () => {}, { schedule() {} },
);
const write = async (data: string) => {
history.setViewportRows(term.rows);
history.setViewportCols(term.cols);
history.append(data);
await new Promise<void>((resolve) => term.write(data, resolve));
};
return { term, history, write };
}
for (const alternate of [false, true]) test(`idle ${alternate ? "alternate" : "normal"}-screen resize updates history before any new output`, async () => {
const { term, history, write } = createHarness(10);
try {
await write(`${alternate ? "\x1b[?1049h" : ""}abcdefgh`);
history.getPreviewRows({ cols: 10, rows: 1, top: 0 });
term.resize(5, 24);
assert.equal(history.getPreviewRowCount(5), 1);
assert.equal(history.getPreviewRows({ cols: 5, rows: 1, top: 0 }).rows[0].text,
term.buffer.active.getLine(0)?.translateToString(true, 0, term.cols));
term.resize(10, 24);
assert.equal(history.getPreviewRows({ cols: 10, rows: 1, top: 0 }).rows[0].text,
term.buffer.active.getLine(0)?.translateToString(true, 0, term.cols));
} finally { term.dispose(); }
});
test("resize uses xterm's reflowed cursor for later same-row redraws", async () => {
const { term, history, write } = createHarness(5);
try {
await write("abcdef\n");
term.resize(10, 24);
assert.equal(term.buffer.active.cursorY, 1);
await write("X\x1b[2;2HY");
assert.deepEqual(history.getPreviewRows({ cols: 10, rows: 2, top: 0 }).rows.map(row => row.text),
[term.buffer.active.getLine(0)?.translateToString(true), term.buffer.active.getLine(1)?.translateToString(true)]);
} finally { term.dispose(); }
});
for (const chunks of [["abc中X"], ["abc中XYZ"], ["abc中文X"], ["abc中文", "X"], ["abc中\x1b[5GZ"]]) {
test(`no-autowrap final wide cell matches xterm: ${JSON.stringify(chunks)}`, async () => {
const { term, history, write } = createHarness(5);
try {
await write("\x1b[?7l");
for (const chunk of chunks) await write(chunk);
assert.equal(history.getLines().at(-1), term.buffer.active.getLine(0)?.translateToString(true));
} finally { term.dispose(); }
});
}
for (const [save, restore] of [["\x1b7", "\x1b8"], ["\x1b[s", "\x1b[u"]]) {
test(`cursor restore also restores wrapping mode: ${JSON.stringify(save)}`, async () => {
const { term, history, write } = createHarness(5);
try {
await write(`\x1b[?7l${save}\x1b[?7h${restore}abcdef`);
assert.deepEqual(history.getLines(), [term.buffer.active.getLine(0)?.translateToString(true)]);
} finally { term.dispose(); }
});
test(`cursor restore also restores origin mode: ${JSON.stringify(save)}`, async () => {
const { term, history, write } = createHarness(5);
try {
await write(`\x1b[2;5r\x1b[?6h${save}\x1b[?6lA${restore}X\x1b[1;2HY`);
assert.deepEqual(history.getLines(), [term.buffer.active.getLine(0)?.translateToString(true), term.buffer.active.getLine(1)?.translateToString(true)]);
} finally { term.dispose(); }
});
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,276 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { Terminal as XTerm } from "@xterm/xterm";
import {
getTerminalOutputPressure,
isTerminalScrollbackSaturated,
noteTerminalOutputPressureData,
resetTerminalOutputPressure,
setTerminalOutputPressureVisibility,
shouldDegradeTerminalSideWork,
shouldDegradeTerminalKeywordHighlight,
shouldSkipTerminalLineTimestamps,
} from "./terminalOutputPressure.ts";
import { TERMINAL_LONG_LINE_PRESSURE_BYTES } from "./terminalFlowConstants.ts";
import { XTERM_PERFORMANCE_CONFIG } from "../../../infrastructure/config/xtermPerformance.ts";
const createFakeTerm = (overrides: Record<string, unknown> = {}) => ({
rows: 24,
options: { scrollback: 1000 },
buffer: { active: { length: 10, baseY: 0 } },
...overrides,
}) as unknown as XTerm;
test("tracks long unbroken terminal output pressure until a line break arrives", () => {
const term = createFakeTerm();
noteTerminalOutputPressureData(term, "x".repeat(TERMINAL_LONG_LINE_PRESSURE_BYTES - 1));
assert.equal(getTerminalOutputPressure(term).longLine, false);
noteTerminalOutputPressureData(term, "x");
assert.equal(getTerminalOutputPressure(term).longLine, true);
assert.equal(getTerminalOutputPressure(term).mode, "long-line");
noteTerminalOutputPressureData(term, "\nshort");
assert.equal(getTerminalOutputPressure(term).longLine, false);
// Crossing the long-line threshold also arms the high-rate large-output window.
assert.equal(getTerminalOutputPressure(term).largeOutput, true);
assert.equal(getTerminalOutputPressure(term).mode, "large-output");
resetTerminalOutputPressure(term);
});
test("reports newline-terminated long terminal lines as long-line pressure", () => {
const term = createFakeTerm();
noteTerminalOutputPressureData(term, `${"x".repeat(TERMINAL_LONG_LINE_PRESSURE_BYTES)}\n`);
assert.equal(getTerminalOutputPressure(term).longLine, true);
assert.equal(getTerminalOutputPressure(term).mode, "long-line");
assert.equal(getTerminalOutputPressure(term).consecutiveUnbrokenBytes, 0);
noteTerminalOutputPressureData(term, "short\n");
assert.equal(getTerminalOutputPressure(term).longLine, false);
assert.equal(getTerminalOutputPressure(term).largeOutput, true);
assert.equal(getTerminalOutputPressure(term).mode, "large-output");
resetTerminalOutputPressure(term);
});
test("reports background pressure separately from output volume", () => {
const term = createFakeTerm();
setTerminalOutputPressureVisibility(term, false);
assert.equal(getTerminalOutputPressure(term).background, true);
assert.equal(getTerminalOutputPressure(term).mode, "background");
assert.equal(shouldDegradeTerminalSideWork(term), false);
setTerminalOutputPressureVisibility(term, true);
assert.equal(getTerminalOutputPressure(term).background, false);
assert.equal(getTerminalOutputPressure(term).mode, "normal");
resetTerminalOutputPressure(term);
});
test("keeps large-output pressure through small input echoes until output is quiet", () => {
const term = createFakeTerm();
const originalNow = performance.now.bind(performance);
let now = 1_000;
Object.defineProperty(performance, "now", {
configurable: true,
value: () => now,
});
try {
noteTerminalOutputPressureData(term, "x\n".repeat(Math.ceil(TERMINAL_LONG_LINE_PRESSURE_BYTES / 2)));
assert.equal(getTerminalOutputPressure(term).largeOutput, true);
assert.equal(getTerminalOutputPressure(term).mode, "large-output");
now += 16;
noteTerminalOutputPressureData(term, "a");
assert.equal(getTerminalOutputPressure(term).largeOutput, true);
assert.equal(getTerminalOutputPressure(term).mode, "large-output");
now += XTERM_PERFORMANCE_CONFIG.highlighting.largeOutputQuietMs + 1;
assert.equal(getTerminalOutputPressure(term).largeOutput, false);
assert.equal(getTerminalOutputPressure(term).mode, "normal");
} finally {
Object.defineProperty(performance, "now", {
configurable: true,
value: originalNow,
});
resetTerminalOutputPressure(term);
}
});
test("detects large-output pressure from high-rate small chunks", () => {
const term = createFakeTerm();
const originalNow = performance.now.bind(performance);
let now = 5_000;
Object.defineProperty(performance, "now", {
configurable: true,
value: () => now,
});
try {
// ~16KB of short lines inside the 100ms rate window (not one unbroken run).
// Threshold is intentionally below one Tabby-sized xterm shard.
for (let index = 0; index < 16; index += 1) {
noteTerminalOutputPressureData(term, `${"y".repeat(1023)}\n`);
}
assert.equal(getTerminalOutputPressure(term).largeOutput, true);
assert.equal(getTerminalOutputPressure(term).longLine, false);
assert.equal(getTerminalOutputPressure(term).mode, "large-output");
} finally {
Object.defineProperty(performance, "now", {
configurable: true,
value: originalNow,
});
resetTerminalOutputPressure(term);
}
});
test("arms large-output early on multi-line writes when scrollback is saturated", () => {
// rows(24) + scrollback(1000) = 1024 max; length near cap → second-seq path.
const term = createFakeTerm({
rows: 24,
options: { scrollback: 1000 },
buffer: { active: { length: 1020, baseY: 996 } },
});
const originalNow = performance.now.bind(performance);
let now = 9_000;
Object.defineProperty(performance, "now", {
configurable: true,
value: () => now,
});
try {
assert.equal(isTerminalScrollbackSaturated(term), true);
// Far below the long-line / 16KB rate thresholds — only saturated multi-line
// arming should trip bulk mode (second `seq` cold start).
noteTerminalOutputPressureData(term, "1\n2\n3\n4\n5\n");
assert.equal(getTerminalOutputPressure(term).scrollbackSaturated, true);
assert.equal(getTerminalOutputPressure(term).largeOutput, true);
assert.equal(shouldDegradeTerminalSideWork(term), true);
assert.equal(getTerminalOutputPressure(term).mode, "large-output");
// Quiet window is extended while saturated so prompt-echo gaps do not
// reopen the expensive timestamp/highlight path before a second dump.
now += XTERM_PERFORMANCE_CONFIG.highlighting.largeOutputQuietMs + 1;
assert.equal(getTerminalOutputPressure(term).largeOutput, true);
now += XTERM_PERFORMANCE_CONFIG.highlighting.largeOutputQuietMs + 1;
assert.equal(getTerminalOutputPressure(term).largeOutput, false);
} finally {
Object.defineProperty(performance, "now", {
configurable: true,
value: originalNow,
});
resetTerminalOutputPressure(term);
}
});
test("a one-line prompt does not degrade keyword coloring on saturated scrollback", () => {
const term = createFakeTerm({
rows: 24,
options: { scrollback: 1000 },
buffer: { active: { length: 1020, baseY: 996 } },
});
const prompt = "\r\nplain prompt # ";
noteTerminalOutputPressureData(term, prompt);
assert.equal(shouldDegradeTerminalSideWork(term), true);
assert.equal(shouldDegradeTerminalKeywordHighlight(term, prompt), false);
resetTerminalOutputPressure(term);
});
test("does not treat an empty buffer as scrollback-saturated", () => {
const term = createFakeTerm({
rows: 24,
options: { scrollback: 1000 },
buffer: { active: { length: 12, baseY: 0 } },
});
assert.equal(isTerminalScrollbackSaturated(term), false);
noteTerminalOutputPressureData(term, "1\n2\n3\n");
assert.equal(getTerminalOutputPressure(term).largeOutput, false);
assert.equal(getTerminalOutputPressure(term).scrollbackSaturated, false);
resetTerminalOutputPressure(term);
});
test("saturated multi-line degrades side work but keeps line timestamps", () => {
const term = createFakeTerm({
rows: 24,
options: { scrollback: 1000 },
buffer: { active: { length: 1020, baseY: 996 } },
});
// docker-ps-sized multi-line on a full scrollback: highlight/prep may degrade,
// but per-line gutter timestamps must still stamp.
noteTerminalOutputPressureData(term, "CONTAINER ID IMAGE\n".repeat(20));
assert.equal(shouldDegradeTerminalSideWork(term), true);
assert.equal(
shouldDegradeTerminalKeywordHighlight(term, "CONTAINER ID IMAGE\n".repeat(20)),
false,
);
assert.equal(shouldSkipTerminalLineTimestamps(term), false);
resetTerminalOutputPressure(term);
});
test("true flood rate skips line timestamps", () => {
const term = createFakeTerm();
const originalNow = performance.now.bind(performance);
let now = 20_000;
Object.defineProperty(performance, "now", {
configurable: true,
value: () => now,
});
try {
// 64KB+ inside the rate window → timestamp flood gate.
for (let index = 0; index < 64; index += 1) {
noteTerminalOutputPressureData(term, `${"z".repeat(1023)}\n`);
}
assert.equal(shouldSkipTerminalLineTimestamps(term), true);
assert.equal(shouldDegradeTerminalSideWork(term), true);
} finally {
Object.defineProperty(performance, "now", {
configurable: true,
value: originalNow,
});
resetTerminalOutputPressure(term);
}
});
test("rate detector uses a true rolling window across early tiny samples", () => {
const term = createFakeTerm();
const originalNow = performance.now.bind(performance);
let now = 1;
Object.defineProperty(performance, "now", {
configurable: true,
value: () => now,
});
try {
noteTerminalOutputPressureData(term, "x\n");
now = 60;
noteTerminalOutputPressureData(term, `${"x".repeat(40 * 1024)}\n`);
now = 102;
noteTerminalOutputPressureData(term, `${"x".repeat(40 * 1024)}\n`);
// 80KB arrived within 42ms; the tiny sample at t=1 should age out, not reset the window.
assert.equal(getTerminalOutputPressure(term).largeOutput, true);
assert.equal(getTerminalOutputPressure(term).mode, "large-output");
} finally {
Object.defineProperty(performance, "now", {
configurable: true,
value: originalNow,
});
resetTerminalOutputPressure(term);
}
});

View File

@@ -0,0 +1,335 @@
import type { Terminal as XTerm } from "@xterm/xterm";
import { XTERM_PERFORMANCE_CONFIG } from "../../../infrastructure/config/xtermPerformance";
import { TERMINAL_LONG_LINE_PRESSURE_BYTES } from "./terminalFlowConstants";
export type TerminalOutputPressureMode =
| "normal"
| "large-output"
| "long-line"
| "background";
export type TerminalOutputPressureSnapshot = {
mode: TerminalOutputPressureMode;
background: boolean;
largeOutput: boolean;
longLine: boolean;
/** True when the active buffer is near its scrollback capacity (trim-on-write). */
scrollbackSaturated: boolean;
consecutiveUnbrokenBytes: number;
};
type OutputRateSample = {
at: number;
bytes: number;
};
type TerminalOutputPressureState = {
background: boolean;
largeOutput: boolean;
largeOutputUntil: number;
/**
* Separate, stricter flood gate for line-timestamp markers. Full scrollback +
* multi-line (e.g. `docker ps`) must NOT drop per-line timestamps — only true
* high-rate dumps (seq/yes) skip registerMarker storms.
*/
timestampFloodUntil: number;
longLine: boolean;
consecutiveUnbrokenBytes: number;
/** True rolling window samples for high-rate small-chunk detection. */
recentSamples: OutputRateSample[];
recentSampleBytes: number;
};
/**
* Detect bulk streams that arrive as many small IPC chunks (e.g. `yes`, `seq`).
*
* Tabby has almost no write-path side work, so it never needs an explicit
* "bulk mode". We do (timestamps / keyword highlights), so arm large-output
* early enough that the *second* dump on a full scrollback does not spend its
* first ~64KB in the expensive normal path.
*/
const LARGE_OUTPUT_RATE_WINDOW_MS = 100;
/** Lower than a full 128KB xterm shard so pressure leads the first write batch. */
const LARGE_OUTPUT_RATE_BYTES = 16 * 1024;
/**
* Only skip line-timestamp markers at true flood rates. Early large-output
* (16KB) and saturated multi-line still degrade highlight/prep, but keep the
* product rule: each output line can get a gutter timestamp.
*/
const TIMESTAMP_SKIP_RATE_BYTES = 64 * 1024;
/**
* When scrollback is already full, any multi-line or modest chunk should arm
* bulk mode: every new line trims, and marker/highlight work multiplies cost.
*/
const SATURATED_SCROLLBACK_BULK_MIN_BYTES = 64;
const pressureStates = new WeakMap<XTerm, TerminalOutputPressureState>();
const getOrCreateState = (term: XTerm): TerminalOutputPressureState => {
let state = pressureStates.get(term);
if (!state) {
state = {
background: false,
largeOutput: false,
largeOutputUntil: 0,
timestampFloodUntil: 0,
longLine: false,
consecutiveUnbrokenBytes: 0,
recentSamples: [],
recentSampleBytes: 0,
};
pressureStates.set(term, state);
}
return state;
};
const noteRecentOutputRate = (
state: TerminalOutputPressureState,
now: number,
bytes: number,
): number => {
state.recentSamples.push({ at: now, bytes });
state.recentSampleBytes += bytes;
const cutoff = now - LARGE_OUTPUT_RATE_WINDOW_MS;
while (state.recentSamples.length > 0 && state.recentSamples[0]!.at < cutoff) {
const dropped = state.recentSamples.shift()!;
state.recentSampleBytes -= dropped.bytes;
}
if (state.recentSampleBytes < 0) state.recentSampleBytes = 0;
return state.recentSampleBytes;
};
const LINE_BREAK_SCAN = /[\n\r]/g;
const measureUnbrokenRuns = (
data: string,
initialRunBytes: number,
): { maxRunBytes: number; trailingRunBytes: number } => {
// Hot path for every output batch: hop between line breaks with a native
// regex scan instead of visiting each character in JS. A run only counts
// toward the max when this chunk actually appended characters to it,
// matching the original per-char accounting.
let maxRunBytes = 0;
let runStart = 0;
let carriedRunBytes = initialRunBytes;
LINE_BREAK_SCAN.lastIndex = 0;
for (
let match = LINE_BREAK_SCAN.exec(data);
match !== null;
match = LINE_BREAK_SCAN.exec(data)
) {
const appendedBytes = match.index - runStart;
if (appendedBytes > 0) {
const runBytes = carriedRunBytes + appendedBytes;
if (runBytes > maxRunBytes) {
maxRunBytes = runBytes;
}
}
carriedRunBytes = 0;
runStart = match.index + 1;
}
const trailingAppendedBytes = data.length - runStart;
const trailingRunBytes = carriedRunBytes + trailingAppendedBytes;
if (trailingAppendedBytes > 0 && trailingRunBytes > maxRunBytes) {
maxRunBytes = trailingRunBytes;
}
return { maxRunBytes, trailingRunBytes };
};
const resolveConfiguredScrollback = (term: XTerm): number => {
const options = (term as XTerm & { options?: { scrollback?: number } }).options;
const scrollback = options?.scrollback;
if (typeof scrollback === "number" && Number.isFinite(scrollback) && scrollback > 0) {
return Math.floor(scrollback);
}
return 0;
};
/**
* True when the active buffer is near capacity so new lines force scrollback
* trim. Second `seq` dumps hit this path for the entire run; first dumps only
* after the buffer fills.
*/
export const isTerminalScrollbackSaturated = (term: XTerm): boolean => {
try {
const active = term.buffer?.active as
| { length?: number; baseY?: number }
| undefined;
if (!active) return false;
const rows = Math.max(1, term.rows || 0);
const scrollback = resolveConfiguredScrollback(term);
if (scrollback <= 0) return false;
const maxLines = rows + scrollback;
const length = typeof active.length === "number" ? active.length : 0;
if (length <= 0) return false;
// Treat "within one viewport of full" as saturated — cheap, stable, and
// matches when xterm starts trimming aggressively on multi-line floods.
const slack = Math.max(rows, 8);
return length >= maxLines - slack;
} catch {
return false;
}
};
const markLargeOutput = (
state: TerminalOutputPressureState,
now: number,
quietMs: number,
): void => {
state.largeOutputUntil = now + quietMs;
state.largeOutput = true;
};
const resolveLargeOutputQuietMs = (scrollbackSaturated: boolean): number => {
const base = XTERM_PERFORMANCE_CONFIG.highlighting.largeOutputQuietMs;
// Full buffers stay expensive after the dump ends (trim/marker churn). Keep
// bulk side-work off a bit longer so a second dump does not reopen the
// expensive path between prompt echoes.
return scrollbackSaturated ? Math.max(base, base * 2) : base;
};
export const noteTerminalOutputPressureData = (
term: XTerm,
data: string,
): void => {
if (!data) return;
const state = getOrCreateState(term);
const now = performance.now();
const scrollbackSaturated = isTerminalScrollbackSaturated(term);
const quietMs = resolveLargeOutputQuietMs(scrollbackSaturated);
const recentBytes = noteRecentOutputRate(state, now, data.length);
const hasLineBreak = data.includes("\n") || data.includes("\r");
// Full scrollback + multi-line (seq/logs) or a modest plain chunk → bulk.
// Tiny single-key echoes without newlines stay on the normal path.
const saturatedBulkChunk = scrollbackSaturated
&& (
hasLineBreak
|| data.length >= SATURATED_SCROLLBACK_BULK_MIN_BYTES
);
const trueFlood = data.length >= TERMINAL_LONG_LINE_PRESSURE_BYTES
|| recentBytes >= TIMESTAMP_SKIP_RATE_BYTES;
if (
data.length >= TERMINAL_LONG_LINE_PRESSURE_BYTES
|| recentBytes >= LARGE_OUTPUT_RATE_BYTES
|| saturatedBulkChunk
) {
markLargeOutput(state, now, quietMs);
} else if (now >= state.largeOutputUntil) {
state.largeOutput = false;
}
// Timestamp markers: only suppress under true flood / long lines — never for
// "scrollback full + docker ps" style multi-line output.
if (trueFlood) {
state.timestampFloodUntil = now + quietMs;
}
const { maxRunBytes, trailingRunBytes } = measureUnbrokenRuns(
data,
state.consecutiveUnbrokenBytes,
);
state.consecutiveUnbrokenBytes = trailingRunBytes;
state.longLine = maxRunBytes >= TERMINAL_LONG_LINE_PRESSURE_BYTES;
if (state.longLine) {
state.timestampFloodUntil = Math.max(state.timestampFloodUntil, now + quietMs);
}
};
export const setTerminalOutputPressureVisibility = (
term: XTerm,
visible: boolean,
): void => {
getOrCreateState(term).background = !visible;
};
/** True while the pane is hidden (recorded via {@link setTerminalOutputPressureVisibility}). */
export const isTerminalOutputInBackground = (term: XTerm): boolean => (
pressureStates.get(term)?.background ?? false
);
export const setTerminalOutputPressureLargeOutput = (
term: XTerm,
largeOutput: boolean,
): void => {
const state = getOrCreateState(term);
state.largeOutput = largeOutput;
const quietMs = resolveLargeOutputQuietMs(isTerminalScrollbackSaturated(term));
state.largeOutputUntil = largeOutput
? performance.now() + quietMs
: 0;
// Explicit large-output flag is used by tests/flood paths that also suppress
// timestamp storms; clear both gates when turning off.
if (largeOutput) {
state.timestampFloodUntil = state.largeOutputUntil;
} else {
state.timestampFloodUntil = 0;
}
};
export const getTerminalOutputPressure = (
term: XTerm,
): TerminalOutputPressureSnapshot => {
const state = getOrCreateState(term);
const scrollbackSaturated = isTerminalScrollbackSaturated(term);
const largeOutput = state.largeOutput && performance.now() < state.largeOutputUntil;
const mode: TerminalOutputPressureMode = state.background
? "background"
: state.longLine
? "long-line"
: largeOutput
? "large-output"
: "normal";
return {
mode,
background: state.background,
largeOutput,
longLine: state.longLine,
scrollbackSaturated,
consecutiveUnbrokenBytes: state.consecutiveUnbrokenBytes,
};
};
/**
* True when hot-path side work (highlight scans, prep, coalesce) should degrade
* so xterm can keep painting bulk output smoothly — closer to Tabby's
* near-empty write path (FlowControl + xterm.write only).
*/
export const shouldDegradeTerminalSideWork = (term: XTerm): boolean => {
const pressure = getTerminalOutputPressure(term);
return pressure.largeOutput || pressure.longLine;
};
/**
* Keyword coloring only examines the current write, so a stale quiet-window
* bulk flag must not make a later one-line prompt schedule a history rebuild.
*/
export const shouldDegradeTerminalKeywordHighlight = (
term: XTerm,
data: string,
): boolean => {
const state = getOrCreateState(term);
if (state.longLine || data.length >= TERMINAL_LONG_LINE_PRESSURE_BYTES) return true;
return state.recentSampleBytes >= LARGE_OUTPUT_RATE_BYTES;
};
/**
* Whether line-timestamp registerMarker work should be skipped.
*
* Stricter than {@link shouldDegradeTerminalSideWork}: full-scrollback multi-line
* output (docker ps, short command output) must still stamp each line. Only
* true flood rates / long lines suppress markers.
*/
export const shouldSkipTerminalLineTimestamps = (term: XTerm): boolean => {
const state = getOrCreateState(term);
if (state.longLine) return true;
return performance.now() < state.timestampFloodUntil;
};
export const resetTerminalOutputPressure = (term: XTerm): void => {
pressureStates.delete(term);
};

View File

@@ -0,0 +1,69 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
MAX_RAW_PASTE_PER_CHARACTER_LENGTH,
getTextInputWireChunks,
shouldSplitImeTextInputForWire,
shouldSplitRawPasteInputForWire,
splitTextIntoCodePointWrites,
} from "./terminalPerCharacterInput";
test("splitTextIntoCodePointWrites keeps surrogate pairs on one write", () => {
assert.deepEqual(splitTextIntoCodePointWrites("a中👍b"), ["a", "中", "👍", "b"]);
assert.deepEqual(splitTextIntoCodePointWrites("👍"), ["👍"]);
assert.deepEqual(splitTextIntoCodePointWrites(""), []);
});
test("IME commits with more than one character split per character", () => {
assert.equal(shouldSplitImeTextInputForWire("中国"), true);
assert.equal(shouldSplitImeTextInputForWire("abc"), true);
assert.equal(shouldSplitImeTextInputForWire("中a👍"), true);
});
test("single-character IME commits keep the single write", () => {
assert.equal(shouldSplitImeTextInputForWire("中"), false);
assert.equal(shouldSplitImeTextInputForWire("👍"), false);
assert.equal(shouldSplitImeTextInputForWire(","), false);
assert.equal(shouldSplitImeTextInputForWire(""), false);
});
test("IME commits carrying escape sequences never split", () => {
assert.equal(shouldSplitImeTextInputForWire("\x1b[200~中\x1b[201~"), false);
assert.equal(shouldSplitImeTextInputForWire("\x1b[0;;200u"), false);
assert.equal(shouldSplitImeTextInputForWire("\x1ba"), false);
});
test("short raw pastes split per character", () => {
assert.equal(shouldSplitRawPasteInputForWire("10.1.2.3"), true);
assert.equal(shouldSplitRawPasteInputForWire("中文"), true);
assert.equal(shouldSplitRawPasteInputForWire("ab"), true);
assert.equal(
shouldSplitRawPasteInputForWire("a".repeat(MAX_RAW_PASTE_PER_CHARACTER_LENGTH)),
true,
);
});
test("single-character and long raw pastes keep the single write", () => {
assert.equal(shouldSplitRawPasteInputForWire("a"), false);
assert.equal(
shouldSplitRawPasteInputForWire("a".repeat(MAX_RAW_PASTE_PER_CHARACTER_LENGTH + 1)),
false,
);
assert.equal(shouldSplitRawPasteInputForWire(""), false);
});
test("raw pastes containing escape sequences never split", () => {
assert.equal(shouldSplitRawPasteInputForWire("\x1b[200~ab\x1b[201~"), false);
assert.equal(shouldSplitRawPasteInputForWire("\x1b[A"), false);
assert.equal(shouldSplitRawPasteInputForWire("a\x1b"), false);
});
test("wire chunking honors the per-character request only for plain text", () => {
assert.deepEqual(getTextInputWireChunks("ab", false), ["ab"]);
assert.deepEqual(getTextInputWireChunks("ab", true), ["a", "b"]);
assert.deepEqual(getTextInputWireChunks("a中👍", true), ["a", "中", "👍"]);
assert.deepEqual(getTextInputWireChunks("\x1b[200~ab\x1b[201~", true), [
"\x1b[200~ab\x1b[201~",
]);
});

View File

@@ -0,0 +1,45 @@
/**
* Strict bastion prompts (e.g. QAX/奇安信) treat one SSH channel write as a
* single keystroke and silently drop every multi-character chunk (#3077). IME
* commits and short raw pastes therefore have to leave the renderer as
* per-character writes, which is what typing produces and what Xshell does.
*/
/** Raw pastes longer than this keep the single-write behavior. */
export const MAX_RAW_PASTE_PER_CHARACTER_LENGTH = 32;
const ESC = "\x1b";
/** True while the payload only carries plain text and no escape sequence. */
export const isPlainTerminalInputText = (data: string): boolean => !data.includes(ESC);
/** One chunk per Unicode code point so surrogate pairs stay intact. */
export const splitTextIntoCodePointWrites = (data: string): string[] => Array.from(data);
/** IME commits: any plain text with more than one character splits per glyph. */
export const shouldSplitImeTextInputForWire = (text: string): boolean =>
Array.from(text).length > 1 && isPlainTerminalInputText(text);
/**
* Raw (non-bracketed) paste: short plain text goes out as keystrokes, longer
* pastes keep the single write so bulk pastes do not degrade.
*/
export const shouldSplitRawPasteInputForWire = (data: string): boolean => {
if (data.length <= 1 || !isPlainTerminalInputText(data)) return false;
let codePoints = 0;
for (let index = 0; index < data.length; ) {
index += (data.codePointAt(index) ?? 0) > 0xffff ? 2 : 1;
codePoints += 1;
if (codePoints > MAX_RAW_PASTE_PER_CHARACTER_LENGTH) return false;
}
return true;
};
/** Chunks to write for one input payload; escape sequences are never split. */
export const getTextInputWireChunks = (
data: string,
perCharacterWrites: boolean,
): string[] =>
perCharacterWrites && isPlainTerminalInputText(data)
? splitTextIntoCodePointWrites(data)
: [data];

View File

@@ -0,0 +1,143 @@
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
export type TerminalOutputPerfMeta = {
id: string;
emittedAt: number;
chars: number;
lineFeeds: number;
};
export type TerminalOutputPerfTrace = {
id: string;
sessionId?: string;
startedAt: number;
rendererReceivedAt: number;
ingressBytes: number;
inputChars: number;
inputLineFeeds: number;
backend?: TerminalOutputPerfMeta;
};
type TerminalOutputPerfMetaCarrier = {
terminalPerf?: TerminalOutputPerfMeta;
};
const DEBUG_KEYS = [
"NETCATTY_TERMINAL_PERF_DEBUG",
"NETCATTY_TERMINAL_DEBUG",
];
const PERF_LOG_PREFIX = "[Netcatty Terminal Perf]";
const LOCAL_STORAGE_DEBUG_CACHE_TTL_MS = 1000;
let localStorageDebugCache = false;
let localStorageDebugCacheAt = 0;
const countLineFeeds = (data: string): number => {
let count = 0;
for (let index = 0; index < data.length; index += 1) {
if (data[index] === "\n") count += 1;
}
return count;
};
const safeJson = (value: unknown): string => JSON.stringify(value, (_key, nested) => {
if (typeof nested === "bigint") return nested.toString();
if (typeof nested === "function") return "[function]";
return nested;
});
const sendRendererDiagnostic = (
message: string,
payload: Record<string, unknown>,
): void => {
try {
const logDiagnostic = netcattyBridge.get()?.logDiagnostic;
if (typeof logDiagnostic !== "function") return;
void logDiagnostic({
source: "terminal-perf",
message,
extra: payload,
}).catch(() => undefined);
} catch {
// Diagnostics must never affect terminal output.
}
};
const readLocalStorageDebugEnabled = (): boolean => {
try {
return DEBUG_KEYS.some((key) => window.localStorage?.getItem(key) === "1");
} catch {
return false;
}
};
const isLocalStorageDebugEnabled = (): boolean => {
const now = Date.now();
if (now - localStorageDebugCacheAt > LOCAL_STORAGE_DEBUG_CACHE_TTL_MS) {
localStorageDebugCache = readLocalStorageDebugEnabled();
localStorageDebugCacheAt = now;
}
return localStorageDebugCache;
};
export const isTerminalPerformanceDebugEnabled = (
meta?: TerminalOutputPerfMetaCarrier,
): boolean => Boolean(meta?.terminalPerf) || isLocalStorageDebugEnabled();
export const createTerminalOutputPerfTrace = ({
sessionId,
data,
ingressBytes,
meta,
}: {
sessionId?: string;
data: string;
ingressBytes: number;
meta?: TerminalOutputPerfMetaCarrier;
}): TerminalOutputPerfTrace | null => {
if (!isTerminalPerformanceDebugEnabled(meta)) return null;
const now = performance.now();
return {
id: meta?.terminalPerf?.id ?? `renderer-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`,
sessionId,
startedAt: now,
rendererReceivedAt: Date.now(),
ingressBytes,
inputChars: data.length,
inputLineFeeds: countLineFeeds(data),
backend: meta?.terminalPerf,
};
};
export const logTerminalOutputPerf = (
event: string,
trace: TerminalOutputPerfTrace | null | undefined,
details: Record<string, unknown> = {},
): void => {
if (!trace && !isLocalStorageDebugEnabled()) return;
const now = performance.now();
const backendToRendererMs = trace?.backend?.emittedAt
? trace.rendererReceivedAt - trace.backend.emittedAt
: undefined;
const payload = {
event,
id: trace?.id,
sessionId: trace?.sessionId,
at: Date.now(),
elapsedMs: trace ? Number((now - trace.startedAt).toFixed(1)) : undefined,
backendToRendererMs,
ingressBytes: trace?.ingressBytes,
inputChars: trace?.inputChars,
inputLineFeeds: trace?.inputLineFeeds,
backendChars: trace?.backend?.chars,
backendLineFeeds: trace?.backend?.lineFeeds,
...details,
};
try {
const message = `${PERF_LOG_PREFIX} ${safeJson(payload)}`;
console.info(message);
sendRendererDiagnostic(message, payload);
} catch {
// Diagnostics must never affect terminal output.
}
};

View File

@@ -0,0 +1,24 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const attachmentSource = readFileSync(new URL('./terminalSessionAttachment.ts', import.meta.url), 'utf8');
const startersSource = readFileSync(new URL('./createTerminalSessionStarters.ts', import.meta.url), 'utf8');
const hiddenPostConnectFitGuard =
/setTimeout\(\(\) => \{\s*if \(ctx\.isVisibleRef\?\.current === false\) \{\s*notePendingOutputScrollIfEnabled\(ctx\);\s*return;\s*\}\s*if \(!ctx\.fitAddonRef\.current\) return;[\s\S]*ctx\.fitAddonRef\.current\.fit\(\)/;
test('reattached sessions do not fit hidden terminal panes after first output', () => {
assert.match(attachmentSource, hiddenPostConnectFitGuard);
});
test('local sessions do not fit hidden terminal panes after first output', () => {
assert.match(startersSource, hiddenPostConnectFitGuard);
});
test('hidden post-connect scroll recovery respects the scroll-on-output setting', () => {
assert.match(
attachmentSource,
/export const notePendingOutputScrollIfEnabled[\s\S]*shouldScrollOnTerminalOutput\(settings\)[\s\S]*ctx\.pendingOutputScrollRef\.current = true/,
);
});

View File

@@ -0,0 +1,40 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createTerminalResizeScheduler } from './terminalResizeScheduler.ts';
const wait = (durationMs: number) => new Promise((resolve) => setTimeout(resolve, durationMs));
test('dispose cancels a pending terminal resize callback', async () => {
const applied: Array<{ sessionId: string; cols: number; rows: number }> = [];
const scheduler = createTerminalResizeScheduler(5, (request) => applied.push(request));
scheduler.schedule({ sessionId: 'session-1', cols: 120, rows: 40 });
scheduler.dispose();
await wait(20);
assert.deepEqual(applied, []);
});
test('a later terminal resize replaces the pending callback', async () => {
const applied: Array<{ sessionId: string; cols: number; rows: number }> = [];
const scheduler = createTerminalResizeScheduler(5, (request) => applied.push(request));
scheduler.schedule({ sessionId: 'session-1', cols: 100, rows: 30 });
scheduler.schedule({ sessionId: 'session-1', cols: 140, rows: 50 });
await wait(20);
assert.deepEqual(applied, [{ sessionId: 'session-1', cols: 140, rows: 50 }]);
scheduler.dispose();
});
test('terminal resize scheduling remains inert after disposal', async () => {
const applied: Array<{ sessionId: string; cols: number; rows: number }> = [];
const scheduler = createTerminalResizeScheduler(5, (request) => applied.push(request));
scheduler.dispose();
scheduler.schedule({ sessionId: 'session-1', cols: 160, rows: 60 });
await wait(20);
assert.deepEqual(applied, []);
});

View File

@@ -0,0 +1,39 @@
export type TerminalResizeRequest = Readonly<{
sessionId: string;
cols: number;
rows: number;
}>;
export type TerminalResizeScheduler = Readonly<{
schedule: (request: TerminalResizeRequest) => void;
dispose: () => void;
}>;
export function createTerminalResizeScheduler(
delayMs: number,
apply: (request: TerminalResizeRequest) => void,
): TerminalResizeScheduler {
let timeout: ReturnType<typeof setTimeout> | null = null;
let disposed = false;
return Object.freeze({
schedule(request: TerminalResizeRequest) {
if (disposed) return;
if (timeout) clearTimeout(timeout);
const pendingRequest = Object.freeze({ ...request });
timeout = setTimeout(() => {
timeout = null;
if (disposed) return;
apply(pendingRequest);
}, delayMs);
},
dispose() {
if (disposed) return;
disposed = true;
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
},
});
}

View File

@@ -0,0 +1,15 @@
const sensitiveInputReaders = new Map<string, () => boolean>();
export function registerTerminalSensitiveInputReader(
sessionId: string,
reader: () => boolean,
): () => void {
sensitiveInputReaders.set(sessionId, reader);
return () => {
if (sensitiveInputReaders.get(sessionId) === reader) sensitiveInputReaders.delete(sessionId);
};
}
export function isTerminalSensitiveInputActive(sessionId: string): boolean {
return sensitiveInputReaders.get(sessionId)?.() === true;
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,144 @@
import type { Terminal as XTerm } from "@xterm/xterm";
import { normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
import { markPromptLineBreakCommandPending } from "./promptLineBreak";
import type { TerminalSessionStartersContext } from "./createTerminalSessionStarters.types";
const STARTUP_COMMAND_DEFAULT_DELAY_MS = 600;
const STARTUP_COMMAND_MAX_DELAY_MS = 10000;
/**
* Split a (possibly multi-line) startup command into non-empty lines, dropping
* blank/whitespace-only lines but preserving each line's content verbatim — so
* a single-line command stays byte-identical to what the user typed (e.g. a
* leading space for `HISTCONTROL=ignorespace` is kept). Trailing `\r` from
* CRLF input is normalized away.
*/
export function splitStartupCommandLines(commandText: string): string[] {
return String(commandText || "")
.split("\n")
.map((line) => line.replace(/\r$/, ""))
.filter((line) => line.trim().length > 0);
}
/** Clamp a configured startup-command delay; fall back to the default when unset/invalid. */
export function normalizeStartupCommandDelay(raw: number | undefined): number {
const value = typeof raw === "number" && Number.isFinite(raw) ? raw : STARTUP_COMMAND_DEFAULT_DELAY_MS;
return Math.max(0, Math.min(STARTUP_COMMAND_MAX_DELAY_MS, value));
}
const buildStartupPasteInput = (term: XTerm, commandText: string): string => {
let data = normalizeLineEndings(commandText);
if (data.includes("\n") && term.modes?.bracketedPasteMode && !term.options?.ignoreBracketedPasteMode) {
data = wrapBracketedPaste(data);
}
return `${data}\r`;
};
export const resolveStartupCommand = (
ctx: TerminalSessionStartersContext,
options?: { consumeSuppressHostStartupCommand?: boolean },
): string | undefined => {
const command = ctx.startupCommand || (ctx.suppressHostStartupCommandRef?.current ? undefined : ctx.host.startupCommand);
if (options?.consumeSuppressHostStartupCommand && ctx.suppressHostStartupCommandRef) {
ctx.suppressHostStartupCommandRef.current = false;
}
return command;
};
export const scheduleStartupCommand = (
ctx: TerminalSessionStartersContext,
term: XTerm,
id: string,
onSettled?: () => void,
): (() => void) | undefined => {
const commandToRun = resolveStartupCommand(ctx, { consumeSuppressHostStartupCommand: true });
if (!commandToRun || ctx.hasRunStartupCommandRef.current) return undefined;
ctx.hasRunStartupCommandRef.current = true;
const scheduledSessionId = id;
const settings = ctx.terminalSettingsRef?.current ?? ctx.terminalSettings;
const delayMs = normalizeStartupCommandDelay(settings?.startupCommandDelayMs);
let cancelled = false;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const sessionIsCurrent = () =>
!!ctx.sessionRef.current && ctx.sessionRef.current === scheduledSessionId;
// noAutoRun (snippet "type but don't execute"): type the command as-is, no
// Enter and no line-splitting — unchanged behavior.
if (ctx.noAutoRun) {
timeoutId = setTimeout(() => {
if (cancelled) return;
if (!sessionIsCurrent()) {
onSettled?.();
return;
}
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, commandToRun, { automated: true });
onSettled?.();
}, delayMs);
return () => {
cancelled = true;
if (timeoutId) clearTimeout(timeoutId);
};
}
const lines = splitStartupCommandLines(commandToRun);
if (lines.length === 0) {
onSettled?.();
return undefined;
}
const runMode = ctx.startupCommand
? (ctx.multiLineRunMode ?? "paste")
: (ctx.host.startupCommandRunMode ?? "paste");
if (runMode === "paste") {
timeoutId = setTimeout(() => {
if (cancelled) return;
if (!sessionIsCurrent()) {
onSettled?.();
return;
}
ctx.terminalBackend.writeToSession(
ctx.sessionRef.current,
buildStartupPasteInput(term, commandToRun),
{ automated: true },
);
for (const line of lines) {
markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef, term, line);
ctx.onCommandExecuted?.(line, ctx.host.id, ctx.host.label, ctx.sessionId);
}
onSettled?.();
}, delayMs);
return () => {
cancelled = true;
if (timeoutId) clearTimeout(timeoutId);
};
}
// Line-by-line mode: wait before each line so prompt-driven sessions can
// react between steps.
let index = 0;
const runNext = () => {
if (cancelled) return;
if (!sessionIsCurrent()) {
onSettled?.();
return;
}
const line = lines[index];
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${line}\r`, { automated: true });
markPromptLineBreakCommandPending(ctx.promptLineBreakStateRef, term, line);
ctx.onCommandExecuted?.(line, ctx.host.id, ctx.host.label, ctx.sessionId);
index += 1;
if (index < lines.length) {
timeoutId = setTimeout(runNext, delayMs);
} else {
onSettled?.();
}
};
timeoutId = setTimeout(runNext, delayMs);
return () => {
cancelled = true;
if (timeoutId) clearTimeout(timeoutId);
};
};

View File

@@ -0,0 +1,727 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
createSudoPasswordAutofill,
getSingleBracketedPasteLine,
isExplicitSudoPrompt,
isSudoPasswordPrompt,
shouldArmSudoPasswordAutofill,
shouldDismissPasswordAssistOnInput,
} from "./terminalSudoAutofill";
// --- isSudoPasswordPrompt: relaxed — any password/密码/口令 line ending in a
// colon. Over-matching is safe now because filling requires explicit confirm. ---
test("isSudoPasswordPrompt detects sudo and PAM prompts", () => {
assert.equal(isSudoPasswordPrompt("[sudo] password for alice: "), true);
assert.equal(isSudoPasswordPrompt("Password: "), true);
assert.equal(isSudoPasswordPrompt("password for alice: "), true);
assert.equal(isSudoPasswordPrompt("[sudo: [sudo] password for alice: ] Password: "), true);
});
test("isSudoPasswordPrompt detects localized prompts", () => {
assert.equal(isSudoPasswordPrompt("[sudo] alice 的密码:"), true);
assert.equal(isSudoPasswordPrompt("密码:"), true);
assert.equal(isSudoPasswordPrompt("请输入密码: "), true);
});
test("isSudoPasswordPrompt matches Kylin-style prompts without trailing colon", () => {
// Kylin Professional: sudo prompt has no [sudo] tag and no trailing colon (#1293)
assert.equal(isSudoPasswordPrompt("密码"), true);
assert.equal(isSudoPasswordPrompt("用户 的密码"), true);
assert.equal(isSudoPasswordPrompt("密码 "), true);
// Exact prompts from issue #1293 screenshots (sudo -s on Kylin V10)
assert.equal(isSudoPasswordPrompt("输入密码"), true);
assert.equal(isSudoPasswordPrompt("Input Password"), true);
});
test("isExplicitSudoPrompt matches Kylin-style prompts", () => {
// Kylin-style [sudo] prompt without trailing colon
assert.equal(isExplicitSudoPrompt("[sudo] 密码"), true);
assert.equal(isExplicitSudoPrompt("[sudo] password for alice"), true);
});
test("handleOutput hints on Kylin screenshot sudo prompts when armed", () => {
const { autofill, hints, writes } = make();
autofill.armForCommand("sudo -s");
autofill.handleOutput("输入密码");
assert.deepEqual(hints, [true]);
assert.deepEqual(writes, []);
assert.equal(autofill.isPromptPending(), true);
const english = make();
english.autofill.armForCommand("sudo -s");
english.autofill.handleOutput("Input Password");
assert.deepEqual(english.hints, [true]);
assert.deepEqual(english.writes, []);
});
test("isSudoPasswordPrompt detects color-wrapped prompts", () => {
assert.equal(isSudoPasswordPrompt("\x1b[32m[sudo] password for alice: \x1b[0m"), true);
});
test("isSudoPasswordPrompt ignores ordinary output", () => {
assert.equal(isSudoPasswordPrompt("try sudo if the password is required\n"), false);
assert.equal(isSudoPasswordPrompt("the password was changed\n"), false);
assert.equal(isSudoPasswordPrompt("sudo: command not found\n"), false);
});
test("isSudoPasswordPrompt refuses concealed prompt text", () => {
assert.equal(isSudoPasswordPrompt("\x1b[8m[sudo] password for alice: \x1b[0m"), false);
});
// --- arm + hint (confirm-to-fill) ---
const make = (password = "secret") => {
const writes: string[] = [];
const hints: boolean[] = [];
const autofill = createSudoPasswordAutofill({
password,
write: (d) => writes.push(d),
onHint: (active) => {
hints.push(active);
return true; // hint overlay shown successfully
},
});
return { autofill, writes, hints };
};
test("shows a hint (not a fill) when a sudo prompt appears", () => {
const { autofill, writes, hints } = make();
autofill.armForCommand("sudo whoami");
assert.equal(
autofill.handleOutput("[sudo] password for alice: "),
"[sudo] password for alice: ",
);
assert.deepEqual(hints, [true]);
assert.deepEqual(writes, []);
assert.equal(autofill.isPromptPending(), true);
});
test("confirmFill writes the password and clears the hint", () => {
const { autofill, writes, hints } = make();
autofill.armForCommand("sudo whoami");
autofill.handleOutput("[sudo] password for alice: ");
autofill.confirmFill();
assert.deepEqual(writes, ["secret\n"]);
assert.deepEqual(hints, [true, false]);
assert.equal(autofill.isPromptPending(), false);
});
test("cancelHint clears the hint without filling", () => {
const { autofill, writes, hints } = make();
autofill.armForCommand("sudo whoami");
autofill.handleOutput("[sudo] password for alice: ");
autofill.cancelHint();
assert.deepEqual(writes, []);
assert.deepEqual(hints, [true, false]);
assert.equal(autofill.isPromptPending(), false);
});
// --- paste dismisses assist so Enter is not hijacked (#2198) ---
test("shouldDismissPasswordAssistOnInput detects paste and typed content", () => {
assert.equal(shouldDismissPasswordAssistOnInput("remote-secret"), true);
assert.equal(shouldDismissPasswordAssistOnInput("\x1b[200~remote-secret\x1b[201~"), true);
assert.equal(shouldDismissPasswordAssistOnInput("x"), true);
// Enter is confirmFill, not user content
assert.equal(shouldDismissPasswordAssistOnInput("\r"), false);
assert.equal(shouldDismissPasswordAssistOnInput("\n"), false);
// Control keys are handled separately
assert.equal(shouldDismissPasswordAssistOnInput("\x7f"), false);
assert.equal(shouldDismissPasswordAssistOnInput("\x1b"), false);
assert.equal(shouldDismissPasswordAssistOnInput(""), false);
});
test("clipboard paste dismisses pending hint so confirmFill no longer fires", () => {
// Nested SSH: assist offers jump-host password, user pastes the remote host
// password from clipboard, then presses Enter — Enter must submit the paste,
// not append the saved host password (#2198).
const { autofill, writes, hints } = make("jump-host-password");
autofill.armForCommand("sudo whoami");
autofill.handleOutput("[sudo] password for alice: ");
assert.equal(autofill.isPromptPending(), true);
assert.equal(
autofill.dismissOnUserContentInput("\x1b[200~remote-host-password\x1b[201~"),
true,
);
assert.equal(autofill.isPromptPending(), false);
assert.deepEqual(hints, [true, false]);
// Simulated Enter after paste must not inject the jump-host password
autofill.confirmFill();
assert.deepEqual(writes, []);
});
test("plain multi-char paste dismisses pending hint", () => {
const { autofill, writes, hints } = make("jump-host-password");
autofill.handleOutput("[sudo] password for alice: ");
assert.equal(autofill.dismissOnUserContentInput("remote-host-password"), true);
assert.equal(autofill.isPromptPending(), false);
assert.deepEqual(hints, [true, false]);
autofill.confirmFill();
assert.deepEqual(writes, []);
});
test("Enter alone does not dismiss via dismissOnUserContentInput", () => {
const { autofill, hints } = make();
autofill.handleOutput("[sudo] password for alice: ");
assert.equal(autofill.dismissOnUserContentInput("\r"), false);
assert.equal(autofill.isPromptPending(), true);
assert.deepEqual(hints, [true]);
});
test("Esc soft-dismiss keeps arm so assist can re-open on the same Password prompt", () => {
const writes: string[] = [];
const pickerActives: boolean[] = [];
const autofill = createSudoPasswordAutofill({
mode: "picker",
candidates: [
{ id: "host", label: "Host", password: "host-secret" },
{ id: "identity:root", label: "Root", password: "root-secret" },
],
write: (d) => writes.push(d),
onPicker: (active) => {
pickerActives.push(active);
return true;
},
});
autofill.armForCommand("su root");
autofill.handleOutput("Password: ");
assert.equal(autofill.isPickerPending(), true);
autofill.cancelHint();
assert.equal(autofill.isPickerPending(), false);
assert.equal(autofill.canReshowAssist(), true);
// Same static prompt: more output without a new line must not auto-reopen
autofill.handleOutput("");
assert.equal(autofill.isPickerPending(), false);
// Explicit re-open (Esc / arrows in the UI)
assert.equal(autofill.tryReshowAssist(), true);
assert.equal(autofill.isPickerPending(), true);
autofill.confirmFill("host");
assert.deepEqual(writes, ["host-secret\n"]);
});
test("abort hard-disarms so a later bare Password requires a fresh su arm (#2191)", () => {
// Ctrl+C aborts the remote su. Soft-dismiss would leave dismissedWhileArmed
// and block the next bare Password: without a leading newline.
const pickerActives: boolean[] = [];
const autofill = createSudoPasswordAutofill({
mode: "picker",
candidates: [
{ id: "host", label: "Host", password: "host-secret" },
{ id: "identity:root", label: "Root", password: "root-secret" },
],
write: () => {},
onPicker: (active) => {
pickerActives.push(active);
return true;
},
});
autofill.armForCommand("su -");
autofill.handleOutput("Password: ");
assert.equal(autofill.isPickerPending(), true);
autofill.abort();
assert.equal(autofill.isPickerPending(), false);
assert.equal(autofill.canReshowAssist(), false);
// Stale arm gone: bare Password without a new su must not open the picker
autofill.handleOutput("Password: ");
assert.equal(autofill.isPickerPending(), false);
// Fresh arm after interrupt works again
autofill.armForCommand("su -");
autofill.handleOutput("Password: ");
assert.equal(autofill.isPickerPending(), true);
});
test("confirmFill does nothing when no prompt is pending", () => {
const { autofill, writes } = make();
autofill.confirmFill();
assert.deepEqual(writes, []);
});
test("does not arm when the hint cannot be shown (overlay unavailable)", () => {
// If onHint reports the hint could not render (e.g. autocomplete disabled, no
// ghost overlay), we must NOT leave a pending arm — otherwise Enter would
// submit the sudo password with no visible confirmation.
const writes: string[] = [];
const autofill = createSudoPasswordAutofill({
password: "secret",
write: (d) => writes.push(d),
onHint: () => false,
});
autofill.armForCommand("sudo whoami");
autofill.handleOutput("[sudo] password for alice: ");
assert.equal(autofill.isPromptPending(), false);
autofill.confirmFill();
assert.deepEqual(writes, []);
});
test("a bare Password prompt does not hint until a su command is submitted", () => {
const { autofill, hints } = make();
autofill.handleOutput("Password: ");
assert.deepEqual(hints, []);
// sudo-armed bare Password: is too generic (mysql/ssh); su-armed is expected
autofill.armForCommand("sudo whoami");
autofill.handleOutput("Password: ");
assert.deepEqual(hints, []);
autofill.armForCommand("su -");
autofill.handleOutput("Password: ");
assert.deepEqual(hints, [true]);
});
test("an explicit [sudo] prompt hints without a recorded sudo command", () => {
// The [sudo] tag is sudo-specific, so we hint even when arming didn't fire —
// manual typing's recordedCommand is flaky (#1281/#1284), and the hint only
// pastes on explicit Enter, so showing it is safe.
const { autofill, hints } = make();
autofill.handleOutput("[sudo] password for alice: ");
assert.deepEqual(hints, [true]);
assert.equal(autofill.isPromptPending(), true);
});
test("no hint without a saved password", () => {
const { autofill, hints } = make("");
autofill.armForCommand("sudo whoami");
autofill.handleOutput("[sudo] password for alice: ");
assert.deepEqual(hints, []);
});
test("hint fires once across chunked prompt output", () => {
const { autofill, hints } = make();
autofill.armForCommand("sudo apt update");
autofill.handleOutput("[sudo] password ");
autofill.handleOutput("for alice: ");
assert.deepEqual(hints, [true]);
});
test("cached sudo then child Enter password does not assist", () => {
// sudo auth already cached: `sudo mysql -p` goes straight to mysql's
// "Enter password:" — must not offer the host SSH password.
const { autofill, hints, writes } = make();
autofill.armForCommand("sudo mysql -p");
autofill.handleOutput("Enter password: ");
assert.deepEqual(hints, []);
assert.deepEqual(writes, []);
assert.equal(autofill.isPromptPending(), false);
});
test("sudo-scoped bare prompts still assist when armed", () => {
// Kylin / PAM without [sudo] tag (#1293)
const kylink = make();
kylink.autofill.armForCommand("sudo -s");
kylink.autofill.handleOutput("输入密码");
assert.deepEqual(kylink.hints, [true]);
const scoped = make();
scoped.autofill.armForCommand("sudo whoami");
scoped.autofill.handleOutput("password for alice: ");
assert.deepEqual(scoped.hints, [true]);
});
test("a later non-sudo command disarms the pending hint", () => {
const { autofill, writes, hints } = make();
autofill.armForCommand("su -");
autofill.handleOutput("Password: ");
assert.deepEqual(hints, [true]);
autofill.armForCommand("mysql -p"); // non-sudo/su command clears the arm
assert.deepEqual(hints, [true, false]);
autofill.confirmFill();
assert.deepEqual(writes, []);
});
test("clears a pending hint when output moves past the prompt", () => {
const { autofill, writes, hints } = make();
autofill.armForCommand("sudo whoami");
autofill.handleOutput("[sudo] password for alice: ");
assert.equal(autofill.isPromptPending(), true);
// user never pressed Enter; sudo times out and returns to the shell
autofill.handleOutput("\r\nsudo: timed out reading password\r\nalice@host:~$ ");
assert.equal(autofill.isPromptPending(), false);
assert.deepEqual(hints, [true, false]); // hint was hidden
autofill.confirmFill();
assert.deepEqual(writes, []); // a later Enter no longer sends the password
});
test("keeps the hint pending when sudo re-prompts after a wrong password", () => {
const { autofill, hints } = make();
autofill.armForCommand("sudo whoami");
autofill.handleOutput("[sudo] password for alice: ");
autofill.handleOutput("\r\nSorry, try again.\r\n[sudo] password for alice: ");
assert.equal(autofill.isPromptPending(), true);
assert.deepEqual(hints, [true]);
});
test("an expired arm shows no hint for a bare prompt", () => {
const writes: string[] = [];
const hints: boolean[] = [];
let now = 1_000;
const autofill = createSudoPasswordAutofill({
password: "secret",
now: () => now,
write: (d) => writes.push(d),
onHint: (a) => {
hints.push(a);
return true;
},
});
autofill.armForCommand("su -");
now += 31_000;
autofill.handleOutput("Password: ");
assert.deepEqual(hints, []);
});
test("handleOutput passes data through unchanged", () => {
const { autofill } = make();
autofill.armForCommand("sudo whoami");
assert.equal(
autofill.handleOutput("Reading package lists...\r\n"),
"Reading package lists...\r\n",
);
});
test("getSingleBracketedPasteLine extracts single-line bracketed paste content", () => {
assert.equal(getSingleBracketedPasteLine("\x1b[200~sudo whoami\x1b[201~"), "sudo whoami");
assert.equal(getSingleBracketedPasteLine("\x1b[200~sudo whoami\rpwd\x1b[201~"), null);
});
test("shouldArmSudoPasswordAutofill arms direct sudo and su commands", () => {
assert.equal(shouldArmSudoPasswordAutofill("sudo whoami"), true);
assert.equal(shouldArmSudoPasswordAutofill("command sudo whoami"), true);
assert.equal(shouldArmSudoPasswordAutofill("builtin sudo whoami"), true);
assert.equal(shouldArmSudoPasswordAutofill("su"), true);
assert.equal(shouldArmSudoPasswordAutofill("su -"), true);
assert.equal(shouldArmSudoPasswordAutofill("su root"), true);
assert.equal(shouldArmSudoPasswordAutofill("su - root"), true);
assert.equal(shouldArmSudoPasswordAutofill("su -l alice"), true);
assert.equal(shouldArmSudoPasswordAutofill("command su -"), true);
assert.equal(shouldArmSudoPasswordAutofill("builtin su"), true);
assert.equal(shouldArmSudoPasswordAutofill("echo '[sudo] password for alice:'"), false);
assert.equal(shouldArmSudoPasswordAutofill("cat sudo.log"), false);
// Word-boundary: do not arm unrelated commands that only start with "su"
assert.equal(shouldArmSudoPasswordAutofill("sum file"), false);
assert.equal(shouldArmSudoPasswordAutofill("suspend"), false);
assert.equal(shouldArmSudoPasswordAutofill("suricata -T"), false);
assert.equal(shouldArmSudoPasswordAutofill("echo su"), false);
});
test("shows a hint for su Password prompt when armed", () => {
// su asks for the target account password with a bare "Password:" line
// (no [sudo] tag), so arming is required (#2156).
const { autofill, writes, hints } = make();
autofill.armForCommand("su -");
assert.equal(autofill.handleOutput("Password: "), "Password: ");
assert.deepEqual(hints, [true]);
assert.deepEqual(writes, []);
assert.equal(autofill.isPromptPending(), true);
autofill.confirmFill();
assert.deepEqual(writes, ["secret\n"]);
});
test("su to a named user arms the same confirm-to-fill path", () => {
const { autofill, writes, hints } = make();
autofill.armForCommand("su alice");
autofill.handleOutput("Password: ");
assert.deepEqual(hints, [true]);
autofill.confirmFill();
assert.deepEqual(writes, ["secret\n"]);
});
test("hint mode does not fall back to an unrelated keychain identity", () => {
// Without a session password, hint must stay silent even when password
// identities exist — Enter would otherwise paste the wrong secret.
const writes: string[] = [];
const hints: boolean[] = [];
const autofill = createSudoPasswordAutofill({
mode: "hint",
candidates: [
{ id: "identity:root", label: "Root", username: "root", password: "root-secret" },
],
write: (d) => writes.push(d),
onHint: (a) => {
hints.push(a);
return true;
},
});
autofill.armForCommand("sudo whoami");
autofill.handleOutput("[sudo] password for alice: ");
assert.deepEqual(hints, []);
assert.equal(autofill.isPromptPending(), false);
autofill.confirmFill();
assert.deepEqual(writes, []);
});
test("mode off never hints even for explicit sudo prompts", () => {
const writes: string[] = [];
const hints: boolean[] = [];
const autofill = createSudoPasswordAutofill({
mode: "off",
password: "secret",
write: (d) => writes.push(d),
onHint: (a) => {
hints.push(a);
return true;
},
});
autofill.handleOutput("[sudo] password for alice: ");
assert.deepEqual(hints, []);
assert.equal(autofill.isPromptPending(), false);
});
test("isPickerPending is false during hint mode", () => {
const { autofill } = make();
autofill.armForCommand("sudo whoami");
autofill.handleOutput("[sudo] password for alice: ");
assert.equal(autofill.isPromptPending(), true);
assert.equal(autofill.isPickerPending(), false);
assert.equal(autofill.moveSelection(1), false);
});
test("picker mode opens the credential list and fills the selected secret", () => {
const writes: string[] = [];
const pickerStates: Array<{ items: { id: string }[]; selectedIndex: number } | null> = [];
const hints: boolean[] = [];
const autofill = createSudoPasswordAutofill({
mode: "picker",
password: "host-secret",
candidates: [
{ id: "host", label: "Host", username: "alice", password: "host-secret" },
{ id: "identity:root", label: "Root", username: "root", password: "root-secret" },
],
write: (d) => writes.push(d),
onHint: (a) => {
hints.push(a);
return true;
},
onPicker: (active, state) => {
pickerStates.push(active ? { items: state!.items, selectedIndex: state!.selectedIndex } : null);
return true;
},
});
autofill.armForCommand("su -");
autofill.handleOutput("Password: ");
assert.equal(autofill.isPromptPending(), true);
assert.equal(pickerStates.length, 1);
assert.equal(pickerStates[0]?.items.length, 2);
assert.equal(pickerStates[0]?.selectedIndex, 0);
assert.equal(autofill.isPickerPending(), true);
assert.equal(autofill.moveSelection(1), true);
assert.equal(pickerStates.at(-1)?.selectedIndex, 1);
autofill.confirmFill();
assert.deepEqual(writes, ["root-secret\n"]);
assert.equal(pickerStates.at(-1), null);
// sudo never opens the multi-identity picker — host-password hint only
const sudoPicker = createSudoPasswordAutofill({
mode: "picker",
password: "host-secret",
candidates: [
{ id: "host", label: "Host", password: "host-secret" },
{ id: "identity:root", label: "Root", password: "root-secret" },
],
write: () => {},
onHint: (a) => {
hints.push(a);
return true;
},
onPicker: (active, state) => {
pickerStates.push(active ? { items: state!.items, selectedIndex: state!.selectedIndex } : null);
return true;
},
});
sudoPicker.armForCommand("sudo whoami");
sudoPicker.handleOutput("[sudo] password for alice: ");
assert.equal(sudoPicker.isPickerPending(), false);
assert.equal(sudoPicker.isPromptPending(), true);
});
test("picker confirmFill can target a specific candidate id", () => {
const writes: string[] = [];
const autofill = createSudoPasswordAutofill({
mode: "picker",
candidates: [
{ id: "host", label: "Host", password: "host-secret" },
{ id: "identity:root", label: "Root", password: "root-secret" },
],
write: (d) => writes.push(d),
onPicker: () => true,
});
autofill.armForCommand("su root");
autofill.handleOutput("Password: ");
autofill.confirmFill("identity:root");
assert.deepEqual(writes, ["root-secret\n"]);
});
test("picker reopens after a wrong password when still armed", () => {
const writes: string[] = [];
const pickerActives: boolean[] = [];
const autofill = createSudoPasswordAutofill({
mode: "picker",
candidates: [
{ id: "host", label: "Host", password: "wrong" },
{ id: "identity:root", label: "Root", password: "right" },
],
write: (d) => writes.push(d),
onPicker: (active) => {
pickerActives.push(active);
return true;
},
});
autofill.armForCommand("su -");
autofill.handleOutput("Password: ");
assert.equal(autofill.isPickerPending(), true);
autofill.confirmFill("host");
assert.deepEqual(writes, ["wrong\n"]);
assert.equal(autofill.isPickerPending(), false);
// Remote rejects and re-prompts — picker should open again for another pick
autofill.handleOutput("\r\nSorry, try again.\r\nPassword: ");
assert.equal(autofill.isPickerPending(), true);
autofill.confirmFill("identity:root");
assert.deepEqual(writes, ["wrong\n", "right\n"]);
});
test("does not re-assist a child password prompt after successful fill", () => {
// `sudo mysql -p`: after the sudo password is accepted, mysql's own
// "Enter password:" must not reopen assist with the host secret.
const { autofill, hints, writes } = make();
autofill.armForCommand("sudo mysql -p");
autofill.handleOutput("[sudo] password for alice: ");
assert.deepEqual(hints, [true]);
autofill.confirmFill();
assert.deepEqual(writes, ["secret\n"]);
autofill.handleOutput("\r\nEnter password: ");
assert.equal(autofill.isPromptPending(), false);
assert.deepEqual(hints, [true, false]); // only the original sudo hint
});
test("picker does not open for Enter password when sudo is already cached", () => {
const writes: string[] = [];
const pickerActives: boolean[] = [];
const autofill = createSudoPasswordAutofill({
mode: "picker",
candidates: [
{ id: "host", label: "Host", password: "host-secret" },
{ id: "identity:root", label: "Root", password: "root-secret" },
],
write: (d) => writes.push(d),
onPicker: (active) => {
pickerActives.push(active);
return true;
},
});
autofill.armForCommand("sudo mysql -p");
autofill.handleOutput("Enter password: ");
assert.equal(autofill.isPickerPending(), false);
assert.deepEqual(pickerActives, []);
assert.deepEqual(writes, []);
});
test("picker does not open for database-style Password for user prompts after sudo", () => {
const hints: boolean[] = [];
const pickerActives: boolean[] = [];
const autofill = createSudoPasswordAutofill({
mode: "picker",
password: "host-secret",
candidates: [
{ id: "host", label: "Host", password: "host-secret" },
{ id: "identity:root", label: "Root", password: "root-secret" },
],
write: () => {},
onHint: (a) => {
hints.push(a);
return true;
},
onPicker: (active) => {
pickerActives.push(active);
return true;
},
});
autofill.armForCommand("sudo -u postgres psql -h db");
autofill.handleOutput("Password for user postgres: ");
assert.equal(autofill.isPickerPending(), false);
assert.deepEqual(pickerActives, []);
assert.deepEqual(hints, []);
});
test("picker opens for su bare Password after arm", () => {
const autofill = createSudoPasswordAutofill({
mode: "picker",
candidates: [
{ id: "host", label: "Host", password: "host-secret" },
{ id: "identity:root", label: "Root", password: "root-secret" },
],
write: () => {},
onPicker: () => true,
});
autofill.armForCommand("su -");
autofill.handleOutput("Password: ");
assert.equal(autofill.isPickerPending(), true);
});
test("passwordless su -c ssh does not open picker for remote password", () => {
const autofill = createSudoPasswordAutofill({
mode: "picker",
candidates: [
{ id: "host", label: "Host", password: "host-secret" },
{ id: "identity:other", label: "Other", password: "other-secret" },
],
write: () => {},
onPicker: () => true,
});
autofill.armForCommand("su bob -c 'ssh other-host'");
autofill.handleOutput("bob@other-host's password: ");
assert.equal(autofill.isPickerPending(), false);
assert.equal(autofill.isPromptPending(), false);
});
test("picker mode requires arm before offering the full keychain list", () => {
// Unarmed explicit [sudo] must not surface every identity — a remote can
// forge that line. Host-password hint remains allowed (#2156 security).
const writes: string[] = [];
const pickerStates: Array<unknown> = [];
const hints: boolean[] = [];
const autofill = createSudoPasswordAutofill({
mode: "picker",
password: "host-secret",
candidates: [
{ id: "host", label: "Host", password: "host-secret" },
{ id: "identity:other", label: "Other", password: "other-secret" },
],
write: (d) => writes.push(d),
onHint: (a) => {
hints.push(a);
return true;
},
onPicker: (_active, state) => {
pickerStates.push(state);
return true;
},
});
// No armForCommand — forged remote prompt only
autofill.handleOutput("[sudo] password for alice: ");
assert.equal(autofill.isPickerPending(), false);
assert.deepEqual(pickerStates, []);
assert.deepEqual(hints, [true]);
autofill.confirmFill();
assert.deepEqual(writes, ["host-secret\n"]);
});
test("picker mode does not expose passwords in onPicker payload", () => {
let seen: unknown = null;
const autofill = createSudoPasswordAutofill({
mode: "picker",
candidates: [{ id: "host", label: "Host", password: "top-secret" }],
write: () => {},
onPicker: (_active, state) => {
seen = state;
return true;
},
});
autofill.armForCommand("su -");
autofill.handleOutput("Password: ");
assert.ok(seen && typeof seen === "object");
const json = JSON.stringify(seen);
assert.equal(json.includes("top-secret"), false);
});

View File

@@ -0,0 +1,589 @@
import type { PasswordPromptAssistMode } from "../../../domain/models";
const ESCAPE_SEQUENCE = "\\x" + "1b";
const BELL_SEQUENCE = "\\x" + "07";
const BRACKETED_PASTE_START = "\x1b[200~";
const BRACKETED_PASTE_END = "\x1b[201~";
const ANSI_PATTERN = new RegExp(`${ESCAPE_SEQUENCE}\\[[0-?]*[ -/]*[@-~]`, "g");
const OSC_PATTERN = new RegExp(
`${ESCAPE_SEQUENCE}\\][^${BELL_SEQUENCE}]*(?:${BELL_SEQUENCE}|${ESCAPE_SEQUENCE}\\\\)`,
"g",
);
// SGR conceal (parameter 8) hides the text it wraps. Refuse to treat concealed
// output as a real prompt so a remote can't disguise a fake prompt and trick the
// user into revealing the password.
const CONCEAL_PATTERN = new RegExp(`${ESCAPE_SEQUENCE}\\[(?:[0-9]+;)*8(?:;[0-9]+)*m`);
// A line that mentions password/密码/口令 and optionally ends in a colon.
// Intentionally broad: filling requires the user to confirm (press Enter), so
// over-matching only shows a dismissable hint and never leaks a password to a
// child program. The colon is optional because Kylin's sudo prompt doesn't
// use one (#1293).
const SUDO_PROMPT_PATTERN =
/(?:^|[\r\n])[^\r\n]*?(?:\bpassword\b|密\s*码|口\s*令)[^\r\n:]*(?:[:]\s*)?$/i;
// An explicit sudo prompt carries the sudo-specific "[sudo]" tag. No other tool
// prompts this way, so we hint on it WITHOUT requiring an arm — keeping the hint
// reliable even when command recording (arming) didn't fire for a manually
// typed command (#1284; manual typing's recordedCommand is flaky).
// Match [sudo] or [sudo: ...] variants (e.g. Chinese locale: [sudo: authenticate] 密码:, #1286).
// Colon is optional for Kylin (#1293).
const EXPLICIT_SUDO_PROMPT_PATTERN =
/(?:^|[\r\n])[^\r\n]*?\[sudo[^\]]*\][^\r\n]*?(?:\bpassword\b|密\s*码|口\s*令)[^\r\n:]*(?:[:]\s*)?$/i;
// Arm for direct sudo *and* su commands (#2156). Trailing space/end keeps
// `sum`/`suspend`/`suuser` out. `sudo` is checked before bare `su`.
const SUDO_COMMAND_PATTERN = /^\s*(?:builtin\s+|command\s+)?sudo(?:\s|$)/;
const SU_COMMAND_PATTERN = /^\s*(?:builtin\s+|command\s+)?su(?:\s|$)/;
const SUDO_OR_SU_COMMAND_PATTERN =
/^\s*(?:builtin\s+|command\s+)?su(?:do)?(?:\s|$)/;
// Used after confirm-to-fill: only re-open assist on a real auth retry, not on a
// subsequent child-program password prompt (e.g. `sudo mysql -p`).
const AUTH_RETRY_FAILURE_PATTERN =
/sorry,\s*try\s*again|incorrect\s+password|authentication\s+failure|auth(?:entication)?\s+fail|密码(?:错误|不正确)|认证失败|鉴权失败|口令错误/i;
// Sudo without the [sudo] tag (Kylin #1293) still scopes the prompt to the user
// ("password for alice", "输入密码"). Generic child-program prompts are excluded:
// "Enter password:", "Password for user postgres:", etc.
const SUDO_SCOPED_BARE_PROMPT_PATTERN =
/(?:password\s+for\b|的密码|输入密码|input\s+password)/i;
const CHILD_PROGRAM_PASSWORD_PROMPT_PATTERN =
/(?:enter\s+password\b|password\s+for\s+user\b)/i;
type ArmedCommandKind = "sudo" | "su";
export const stripTerminalControlSequences = (data: string): string =>
data.replace(OSC_PATTERN, "").replace(ANSI_PATTERN, "");
export const isSudoPasswordPrompt = (data: string): boolean => {
if (CONCEAL_PATTERN.test(data)) return false;
return SUDO_PROMPT_PATTERN.test(stripTerminalControlSequences(data));
};
export const isExplicitSudoPrompt = (data: string): boolean => {
if (CONCEAL_PATTERN.test(data)) return false;
return EXPLICIT_SUDO_PROMPT_PATTERN.test(stripTerminalControlSequences(data));
};
export const shouldArmSudoPasswordAutofill = (command: string): boolean =>
SUDO_OR_SU_COMMAND_PATTERN.test(command);
export const resolveArmedCommandKind = (command: string): ArmedCommandKind | null => {
if (SUDO_COMMAND_PATTERN.test(command)) return "sudo";
if (SU_COMMAND_PATTERN.test(command)) return "su";
return null;
};
/** Sudo prompts without [sudo] that still look like sudo/PAM, not mysql/psql. */
export const isSudoScopedBarePasswordPrompt = (data: string): boolean => {
if (CONCEAL_PATTERN.test(data)) return false;
const plain = stripTerminalControlSequences(data);
if (!isSudoPasswordPrompt(plain)) return false;
if (CHILD_PROGRAM_PASSWORD_PROMPT_PATTERN.test(plain)) return false;
return SUDO_SCOPED_BARE_PROMPT_PATTERN.test(plain);
};
/**
* su typically prints a short bare Password: / 密码: line.
* Reject SSH/scp style "user@host's password:" and long child prompts even
* while an su command arm is still active (e.g. passwordless su -c ssh).
*/
export const isSuBarePasswordPrompt = (data: string): boolean => {
if (CONCEAL_PATTERN.test(data)) return false;
const plain = stripTerminalControlSequences(data).replace(/\s+/g, " ").trim();
if (!plain) return false;
if (CHILD_PROGRAM_PASSWORD_PROMPT_PATTERN.test(plain)) return false;
// SSH/scp/rsync remote password prompts always include user@host.
if (plain.includes("@")) return false;
if (!isSudoPasswordPrompt(plain)) return false;
// Whole line should be essentially the password word (+ optional colon).
// Keep a small budget for locale variants like "Password: " / "密码:".
if (plain.length > 24) return false;
return /^(?:password|passwd|密\s*码|口\s*令)\s*[:]?\s*$/i.test(plain);
};
/** Public picker row — never includes the secret. */
export type PasswordPromptPickerItem = {
id: string;
label: string;
username?: string;
};
/** Internal candidate with password for confirm-to-fill. */
export type SudoPasswordAutofillCandidate = PasswordPromptPickerItem & {
password: string;
};
export type PasswordPromptPickerState = {
items: PasswordPromptPickerItem[];
selectedIndex: number;
};
export type SudoPasswordAutofill = {
armForCommand: (command: string) => void;
handleOutput: (data: string) => string;
/** Confirm with the selected (or host) password, or a specific candidate id. */
confirmFill: (candidateId?: string) => void;
/** Dismiss the open UI without clearing the su/sudo arm (Esc). */
cancelHint: () => void;
/**
* Hard-abort: hide UI and clear the arm (Ctrl+C / interrupt / disconnect).
* Unlike cancelHint, a later Password: line will not re-open assist until
* a fresh su/sudo command is armed (#2191).
*/
abort: () => void;
/**
* Soft-dismiss when the user pastes or types their own secret so Enter is
* not hijacked for confirmFill after clipboard paste (#2198).
* Returns true when the assist UI was dismissed.
*/
dismissOnUserContentInput: (data: string) => boolean;
isPromptPending: () => boolean;
/** True only while the multi-credential picker UI is open (not the hint). */
isPickerPending: () => boolean;
/**
* True when the user dismissed the UI with Esc but the command arm is still
* live and the last line still looks like a password prompt — Esc/↑/↓ can
* re-open the assist without re-running su/sudo.
*/
canReshowAssist: () => boolean;
/** Re-open assist after a soft dismiss. Returns whether the UI showed. */
tryReshowAssist: () => boolean;
/**
* Picker mode: move selection while the list is open.
* Returns true when the selection changed so callers can consume the key.
*/
moveSelection: (delta: number) => boolean;
updatePassword: (password?: string) => void;
updateCandidates: (candidates: SudoPasswordAutofillCandidate[]) => void;
updateMode: (mode: PasswordPromptAssistMode) => void;
};
const unwrapBracketedPaste = (data: string): string => {
if (data.startsWith(BRACKETED_PASTE_START) && data.endsWith(BRACKETED_PASTE_END)) {
return data.slice(BRACKETED_PASTE_START.length, -BRACKETED_PASTE_END.length);
}
return data;
};
export const getSinglePastedCommand = (
data: string,
): { command: string; lineEnding: string } | null => {
const match = unwrapBracketedPaste(data).match(/^([^\r\n]+)(\r\n|\r|\n)$/);
if (!match) return null;
return {
command: match[1],
lineEnding: match[2],
};
};
export const getSingleBracketedPasteLine = (data: string): string | null => {
if (!data.startsWith(BRACKETED_PASTE_START) || !data.endsWith(BRACKETED_PASTE_END)) {
return null;
}
const text = unwrapBracketedPaste(data);
if (!text || /[\r\n]/.test(text)) return null;
return text;
};
/**
* True when terminal input is the user supplying their own password text
* (typed char or clipboard paste), not Enter confirmation or Esc/Backspace.
*
* Used to dismiss password-prompt assist so a later Enter submits what the
* user pasted instead of hijacking Enter for the host session password
* (nested SSH / jump host, #2198).
*/
export const shouldDismissPasswordAssistOnInput = (data: string): boolean => {
if (!data) return false;
// Enter alone is handled by the key handler as confirmFill — do not dismiss.
if (data === "\r" || data === "\n" || data === "\r\n") return false;
// Bracketed paste always means the user is inserting their own text.
if (data.startsWith(BRACKETED_PASTE_START) || data.includes(BRACKETED_PASTE_START)) {
return true;
}
// Plain multi-char paste (no leading ESC / CSI).
if (data.length > 1 && !data.startsWith("\x1b")) {
return true;
}
// Single printable character — mirrors the key handler's cancelHint path.
// Exclude DEL (0x7f); Backspace/Esc are handled separately and must not
// count as "user password content" for onData-side dismissal.
const code = data.charCodeAt(0);
if (data.length === 1 && code >= 32 && code !== 0x7f) {
return true;
}
return false;
};
// Arm the autofill when a sudo/su command is submitted. The user's input is sent
// to the remote verbatim — we never rewrite it — so the terminal echo and cursor
// stay correct.
export const prepareSudoAutofillInput = (
data: string,
recordedCommand: string | null,
sudoAutofill: SudoPasswordAutofill | null | undefined,
): string => {
if (!sudoAutofill) return data;
if (data === "\r" || data === "\n") {
if (recordedCommand) sudoAutofill.armForCommand(recordedCommand);
return data;
}
if (data.startsWith(BRACKETED_PASTE_START) && data.endsWith(BRACKETED_PASTE_END)) {
return data;
}
const pastedCommand = getSinglePastedCommand(data);
if (pastedCommand) sudoAutofill.armForCommand(pastedCommand.command);
return data;
};
const toPickerItems = (
candidates: SudoPasswordAutofillCandidate[],
): PasswordPromptPickerItem[] =>
candidates.map(({ id, label, username }) => ({ id, label, username }));
// Confirm-to-fill model: when a sudo/su command is armed and a password prompt is
// seen, we DON'T send the password — we raise a hint or picker so the UI can
// offer confirmation. The password is only written when the user confirms via
// confirmFill(). This makes over-broad detection safe: a misfire just shows a
// dismissable UI instead of leaking the password.
export const createSudoPasswordAutofill = (_options: {
mode?: PasswordPromptAssistMode;
/** Hint-mode default password (host session password). */
password?: string;
/** Picker-mode candidates (host + keychain password identities). */
candidates?: SudoPasswordAutofillCandidate[];
write: (data: string) => void;
/** Show/hide the inline hint. Returns whether the hint actually rendered. */
onHint?: (active: boolean) => boolean;
/**
* Show/hide the credential picker. Returns whether the picker actually
* rendered. `state` is null when hiding.
*/
onPicker?: (active: boolean, state: PasswordPromptPickerState | null) => boolean;
now?: () => number;
}): SudoPasswordAutofill => {
const options = {
now: () => Date.now(),
onHint: (_active: boolean) => false,
onPicker: (_active: boolean, _state: PasswordPromptPickerState | null) => false,
..._options,
};
let mode: PasswordPromptAssistMode = options.mode ?? "hint";
let password = options.password ?? "";
let candidates: SudoPasswordAutofillCandidate[] = options.candidates ?? [];
const armWindowMs = 10_000;
let tail = "";
let armedUntil = Number.NEGATIVE_INFINITY;
let armedKind: ArmedCommandKind | null = null;
let pending = false;
let selectedIndex = 0;
let pendingUi: "hint" | "picker" | null = null;
/** True after confirmFill until we see success (non-prompt output) or expire. */
let postFillRetry = false;
/**
* User hit Esc while a prompt assist was open. Keep the arm so they can
* re-open (Esc/arrows) or so a real re-prompt can auto-show again — but do
* not immediately re-fire on the same static Password: line with no new
* output.
*/
let dismissedWhileArmed = false;
const hasFillMaterial = (): boolean => {
if (mode === "off") return false;
// Hint mode only uses the session host password — never an arbitrary
// keychain identity (that would silently send the wrong secret on Enter).
// Picker mode uses the full candidate list.
if (mode === "hint") return Boolean(password);
return candidates.length > 0 || Boolean(password);
};
/** Hint / single-password path: session password only (not candidates[0]). */
const defaultPassword = (): string => password || "";
const notifyPicker = (active: boolean): boolean => {
if (!active) {
return options.onPicker(false, null);
}
return options.onPicker(true, {
items: toPickerItems(candidates),
selectedIndex,
});
};
const hideUi = () => {
if (pendingUi === "hint") options.onHint(false);
if (pendingUi === "picker") options.onPicker(false, null);
pendingUi = null;
};
const isArmActiveNow = (): boolean =>
armedUntil !== Number.NEGATIVE_INFINITY && options.now() <= armedUntil;
const lastPromptLine = (): string => tail.split(/[\r\n]/).pop() ?? tail;
const disarm = () => {
armedUntil = Number.NEGATIVE_INFINITY;
armedKind = null;
postFillRetry = false;
dismissedWhileArmed = false;
tail = "";
selectedIndex = 0;
if (pending) {
pending = false;
hideUi();
}
};
const tryShowForCurrentTail = (): boolean => {
const armActive = isArmActiveNow();
const lastLine = lastPromptLine();
// Explicit [sudo] may show host-password hint without an arm; full picker
// still requires armed su (allowFullPickerForLine).
if (!isArmedPromptLine(lastLine, armActive)) return false;
const allowFullPicker = allowFullPickerForLine(lastLine, armActive);
if (!showAssist(allowFullPicker)) return false;
pending = true;
postFillRetry = false;
dismissedWhileArmed = false;
return true;
};
const isArmedPromptLine = (line: string, armActive: boolean): boolean => {
if (isExplicitSudoPrompt(line)) return true;
if (!armActive) return false;
// su always prompts with a bare Password: line (not Enter password / DB).
if (armedKind === "su") return isSuBarePasswordPrompt(line);
// sudo: only sudo-scoped prompts, never generic "Enter password:" from
// child programs when sudo credentials are already cached.
if (armedKind === "sudo") return isSudoScopedBarePasswordPrompt(line);
return false;
};
/**
* Full keychain picker is only for armed `su` prompts (#2156). Sudo keeps
* host-password quick-fill only — a multi-identity list after `sudo …` is
* too easy to confuse with a child-program password prompt (or a forged
* [sudo] line once auth is cached).
*/
const allowFullPickerForLine = (line: string, armActive: boolean): boolean => {
if (!armActive || armedKind !== "su") return false;
return isSuBarePasswordPrompt(line);
};
const showHostPasswordHint = (): boolean => {
if (!defaultPassword()) return false;
if (options.onHint(true)) {
pendingUi = "hint";
return true;
}
return false;
};
/**
* @param allowFullPicker When false (unarmed explicit [sudo] path), only
* the session host password may be offered — never the full keychain list.
* A forged remote `[sudo] password…` line must not surface other systems'
* secrets even though filling still requires a user click (#2156 review).
*/
const showAssist = (allowFullPicker: boolean): boolean => {
if (mode === "off" || !hasFillMaterial()) return false;
if (mode === "picker" && allowFullPicker && candidates.length > 0) {
selectedIndex = Math.min(selectedIndex, candidates.length - 1);
if (notifyPicker(true)) {
pendingUi = "picker";
return true;
}
return false;
}
// hint mode, or picker without arm / without multi candidates
return showHostPasswordHint();
};
/** Soft dismiss: hide UI but keep arm + tail so Esc/arrows can re-open. */
const softDismissPendingUi = () => {
if (!pending) return;
pending = false;
hideUi();
dismissedWhileArmed = isArmActiveNow();
if (!dismissedWhileArmed) {
armedKind = null;
armedUntil = Number.NEGATIVE_INFINITY;
}
};
return {
armForCommand: (command: string) => {
// Clear any prior arm/hint first: a non-sudo/su command must not leave a
// stale hint that a later prompt could satisfy.
disarm();
const kind = resolveArmedCommandKind(command);
if (!hasFillMaterial() || !kind) return;
armedKind = kind;
armedUntil = options.now() + armWindowMs;
tail = "";
},
handleOutput: (data: string) => {
if (!hasFillMaterial()) return data;
tail = `${tail}${data}`.slice(-1024);
// Fast path for bulk output: a prompt line ends in a colon, so a chunk
// with no colon can't be completing one. Skip the regex work unless a hint
// is pending (then we must keep watching for the prompt moving on).
// Also check for password keywords because Kylin's sudo prompt doesn't
// end with a colon (#1293).
if (
!pending &&
!data.includes(":") &&
!data.includes("") &&
!/(?:\bpassword\b|密码|口令)/i.test(data)
) {
return data;
}
const lastLine = lastPromptLine();
let armActive = isArmActiveNow();
if (!armActive) {
postFillRetry = false;
dismissedWhileArmed = false;
armedKind = null;
}
// Explicit "[sudo] …" always; su arm accepts bare Password:; sudo arm only
// accepts sudo-scoped bare prompts (not generic Enter password from mysql).
const isPrompt = isArmedPromptLine(lastLine, armActive);
if (pending) {
// The prompt moved on: a new line arrived and the latest line is no
// longer a password prompt (sudo timed out / failed / returned to the
// shell). Clear the pending UI — otherwise a later Enter would send
// the password to whatever is now reading input.
if (!isPrompt && /[\r\n]/.test(data)) disarm();
return data;
}
if (isPrompt) {
// Soft-dismissed (Esc): do not auto-reopen on the same static prompt
// with no new line. A real re-prompt (newline / auth-failure text)
// clears the dismiss flag and may show again.
if (dismissedWhileArmed) {
const looksLikeNewPromptCycle =
/[\r\n]/.test(data) || AUTH_RETRY_FAILURE_PATTERN.test(tail);
if (!looksLikeNewPromptCycle) return data;
dismissedWhileArmed = false;
}
// After a fill, only re-assist when this looks like a real auth retry
// (explicit [sudo] again, or failure text in the tail). A bare
// Password: from a child program after successful sudo must not reopen.
if (postFillRetry) {
const looksLikeAuthRetry =
isExplicitSudoPrompt(lastLine)
|| (armedKind === "su" && AUTH_RETRY_FAILURE_PATTERN.test(tail))
|| (armedKind === "sudo" && (
isExplicitSudoPrompt(lastLine) || AUTH_RETRY_FAILURE_PATTERN.test(tail)
));
if (!looksLikeAuthRetry) {
postFillRetry = false;
armedUntil = Number.NEGATIVE_INFINITY;
armedKind = null;
return data;
}
armActive = true;
}
// Full picker only with strong evidence the prompt is su/sudo itself.
// Unarmed / Kylin bare / ambiguous lines stay host-password hint only.
tryShowForCurrentTail();
}
return data;
},
confirmFill: (candidateId?: string) => {
if (!pending) return;
let secret = "";
if (candidateId) {
secret = candidates.find((c) => c.id === candidateId)?.password ?? "";
} else if (pendingUi === "picker" && candidates.length > 0) {
secret = candidates[selectedIndex]?.password ?? "";
} else {
// Hint path: only the explicit session password.
secret = defaultPassword();
}
if (!secret) {
disarm();
return;
}
options.write(`${secret}\n`);
// Clear pending UI. Keep a short arm + postFillRetry flag so a real
// sudo/su rejection re-prompt can reopen assist, but a later child
// Password: (e.g. after `sudo mysql -p`) will not (#2156 review).
pending = false;
hideUi();
dismissedWhileArmed = false;
postFillRetry = true;
if (hasFillMaterial()) {
armedUntil = options.now() + armWindowMs;
tail = "";
}
},
cancelHint: () => {
softDismissPendingUi();
},
dismissOnUserContentInput: (data: string) => {
if (!pending || !shouldDismissPasswordAssistOnInput(data)) return false;
softDismissPendingUi();
return true;
},
abort: () => {
disarm();
},
isPromptPending: () => pending,
isPickerPending: () => pending && pendingUi === "picker",
canReshowAssist: () => {
if (pending || !dismissedWhileArmed || !hasFillMaterial()) return false;
if (!isArmActiveNow()) return false;
return isArmedPromptLine(lastPromptLine(), true);
},
tryReshowAssist: () => {
if (pending || !hasFillMaterial()) return false;
if (!isArmActiveNow()) {
dismissedWhileArmed = false;
return false;
}
return tryShowForCurrentTail();
},
moveSelection: (delta: number) => {
if (!pending || pendingUi !== "picker" || candidates.length === 0) return false;
const next =
(selectedIndex + delta + candidates.length * 10) % candidates.length;
if (next === selectedIndex) return false;
selectedIndex = next;
notifyPicker(true);
return true;
},
updatePassword: (nextPassword?: string) => {
password = nextPassword ?? "";
if (!hasFillMaterial()) disarm();
},
updateCandidates: (next) => {
candidates = next ?? [];
if (selectedIndex >= candidates.length) {
selectedIndex = Math.max(0, candidates.length - 1);
}
if (!hasFillMaterial()) {
disarm();
return;
}
if (pending && pendingUi === "picker") {
notifyPicker(true);
}
},
updateMode: (nextMode) => {
mode = nextMode;
if (!hasFillMaterial()) {
disarm();
return;
}
// Mode change while pending: re-show the appropriate UI. Keep the full
// picker available only for armed su (same gate as first detection).
if (pending) {
const armStillActive =
armedUntil !== Number.NEGATIVE_INFINITY && options.now() <= armedUntil;
const allowFullPicker = armStillActive && armedKind === "su";
hideUi();
if (!showAssist(allowFullPicker)) {
pending = false;
}
}
},
};
};

View File

@@ -0,0 +1,120 @@
import assert from "node:assert/strict";
import { mock, test } from "node:test";
import type { Terminal as XTerm } from "@xterm/xterm";
import {
filterTerminalSessionData,
isTerminalSyncBlockOpen,
resetTerminalSyncBlockFilter,
SYNC_BLOCK_TIMEOUT_MS,
} from "./terminalSyncBlockFilter.ts";
const SYNC_START = "\x1b[?2026h";
const SYNC_END = "\x1b[?2026l";
const CLEAR = "\x1b[2J";
const CURSOR_HOME = "\x1b[H";
const createMockTerm = (): XTerm => ({
rows: 24,
buffer: {
active: {
type: "normal",
viewportY: 0,
baseY: 5,
},
},
} as XTerm);
test("abandoned sync blocks stop stripping full redraw clears after timeout", () => {
mock.timers.enable({ apis: ["setTimeout"] });
const term = createMockTerm();
try {
resetTerminalSyncBlockFilter(term);
assert.equal(filterTerminalSessionData(term, SYNC_START), SYNC_START);
assert.equal(filterTerminalSessionData(term, CURSOR_HOME), "");
// Home is re-emitted; only the clear is stripped while reading history.
assert.equal(filterTerminalSessionData(term, CLEAR), CURSOR_HOME);
mock.timers.tick(SYNC_BLOCK_TIMEOUT_MS);
assert.equal(filterTerminalSessionData(term, CLEAR), CLEAR);
} finally {
resetTerminalSyncBlockFilter(term);
mock.timers.reset();
}
});
test("completed sync blocks clear the timeout without waiting", () => {
mock.timers.enable({ apis: ["setTimeout"] });
const term = createMockTerm();
try {
resetTerminalSyncBlockFilter(term);
assert.equal(
filterTerminalSessionData(term, `${SYNC_START}${CURSOR_HOME}${CLEAR}frame${SYNC_END}`),
`${SYNC_START}${CURSOR_HOME}frame${SYNC_END}`,
);
mock.timers.tick(SYNC_BLOCK_TIMEOUT_MS);
assert.equal(filterTerminalSessionData(term, CLEAR), CLEAR);
} finally {
resetTerminalSyncBlockFilter(term);
mock.timers.reset();
}
});
test("passes incremental sync blocks through unchanged", () => {
const term = createMockTerm();
resetTerminalSyncBlockFilter(term);
assert.equal(
filterTerminalSessionData(term, `${SYNC_START}\x1b[5;1Hframe${SYNC_END}`),
`${SYNC_START}\x1b[5;1Hframe${SYNC_END}`,
);
});
test("sync block timeout preserves pending partial marker bytes", () => {
mock.timers.enable({ apis: ["setTimeout"] });
const term = createMockTerm();
try {
resetTerminalSyncBlockFilter(term);
assert.equal(filterTerminalSessionData(term, SYNC_START), SYNC_START);
assert.equal(filterTerminalSessionData(term, "color\x1b"), "color");
mock.timers.tick(SYNC_BLOCK_TIMEOUT_MS);
assert.equal(filterTerminalSessionData(term, "[31mtext"), "\x1b[31mtext");
} finally {
resetTerminalSyncBlockFilter(term);
mock.timers.reset();
}
});
test("does not strip full redraw through session filter for a one-row lag (#2291)", () => {
const term = {
rows: 24,
buffer: {
active: {
type: "normal",
viewportY: 9,
baseY: 10,
},
},
} as XTerm;
resetTerminalSyncBlockFilter(term);
const input = `${SYNC_START}${CURSOR_HOME}${CLEAR}frame${SYNC_END}`;
assert.equal(filterTerminalSessionData(term, input), input);
});
test("isTerminalSyncBlockOpen tracks open state across chunks for erase-scrollback", () => {
const term = createMockTerm();
resetTerminalSyncBlockFilter(term);
assert.equal(isTerminalSyncBlockOpen(term), false);
assert.equal(filterTerminalSessionData(term, SYNC_START), SYNC_START);
assert.equal(isTerminalSyncBlockOpen(term), true);
assert.equal(filterTerminalSessionData(term, `${CURSOR_HOME}${CLEAR}frame${SYNC_END}`), `${CURSOR_HOME}frame${SYNC_END}`);
assert.equal(isTerminalSyncBlockOpen(term), false);
});

View File

@@ -0,0 +1,75 @@
import type { Terminal as XTerm } from "@xterm/xterm";
import {
createSyncBlockFilterState,
filterSyncBlockClearsWithMeta,
type SyncBlockFilterState,
} from "./filterSyncBlockClears.ts";
/** Matches @xterm/xterm RenderService SYNCHRONIZED_OUTPUT_TIMEOUT_MS. */
export const SYNC_BLOCK_TIMEOUT_MS = 1000;
const syncBlockFilterStates = new WeakMap<XTerm, SyncBlockFilterState>();
const syncBlockTimers = new WeakMap<XTerm, ReturnType<typeof setTimeout>>();
const clearSyncBlockTimer = (term: XTerm): void => {
const timer = syncBlockTimers.get(term);
if (timer === undefined) {
return;
}
clearTimeout(timer);
syncBlockTimers.delete(term);
};
const expireSyncBlock = (term: XTerm, state: SyncBlockFilterState): void => {
state.inSyncBlock = false;
state.pendingCursorHome = null;
state.fullRedrawBlock = null;
clearSyncBlockTimer(term);
};
export const resetTerminalSyncBlockFilter = (term: XTerm): void => {
clearSyncBlockTimer(term);
syncBlockFilterStates.set(term, createSyncBlockFilterState());
};
const getSyncBlockFilterState = (term: XTerm): SyncBlockFilterState => {
let state = syncBlockFilterStates.get(term);
if (!state) {
state = createSyncBlockFilterState();
syncBlockFilterStates.set(term, state);
}
return state;
};
/** True when a prior chunk opened DEC 2026 and has not closed or expired it. */
export const isTerminalSyncBlockOpen = (term: XTerm): boolean =>
getSyncBlockFilterState(term).inSyncBlock;
const scheduleSyncBlockTimeout = (term: XTerm, state: SyncBlockFilterState): void => {
if (!state.inSyncBlock) {
return;
}
syncBlockTimers.set(
term,
setTimeout(() => {
syncBlockTimers.delete(term);
expireSyncBlock(term, state);
}, SYNC_BLOCK_TIMEOUT_MS),
);
};
export const filterTerminalSessionData = (term: XTerm, data: string): string => {
const state = getSyncBlockFilterState(term);
const { output, startedSyncBlock } = filterSyncBlockClearsWithMeta(data, state, term);
if (startedSyncBlock) {
clearSyncBlockTimer(term);
scheduleSyncBlockTimeout(term, state);
} else if (!state.inSyncBlock) {
clearSyncBlockTimer(term);
}
return output;
};

Some files were not shown because too many files have changed in this diff Show More