[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
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:
23
components/terminal/runtime/altKeyOptions.test.ts
Normal file
23
components/terminal/runtime/altKeyOptions.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
20
components/terminal/runtime/altKeyOptions.ts
Normal file
20
components/terminal/runtime/altKeyOptions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
25
components/terminal/runtime/clearBufferPtySync.test.ts
Normal file
25
components/terminal/runtime/clearBufferPtySync.test.ts
Normal 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\)/);
|
||||
});
|
||||
@@ -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
4679
components/terminal/runtime/createTerminalSessionStarters.test.ts
Normal file
4679
components/terminal/runtime/createTerminalSessionStarters.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1996
components/terminal/runtime/createTerminalSessionStarters.ts
Normal file
1996
components/terminal/runtime/createTerminalSessionStarters.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
};
|
||||
1031
components/terminal/runtime/createXTermRuntime.test.ts
Normal file
1031
components/terminal/runtime/createXTermRuntime.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
2949
components/terminal/runtime/createXTermRuntime.ts
Normal file
2949
components/terminal/runtime/createXTermRuntime.ts
Normal file
File diff suppressed because it is too large
Load Diff
498
components/terminal/runtime/cursorLineHighlight.test.ts
Normal file
498
components/terminal/runtime/cursorLineHighlight.test.ts
Normal 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();
|
||||
});
|
||||
273
components/terminal/runtime/cursorLineHighlight.ts
Normal file
273
components/terminal/runtime/cursorLineHighlight.ts
Normal 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;
|
||||
});
|
||||
160
components/terminal/runtime/cursorPreference.test.ts
Normal file
160
components/terminal/runtime/cursorPreference.test.ts
Normal 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);
|
||||
});
|
||||
118
components/terminal/runtime/cursorPreference.ts
Normal file
118
components/terminal/runtime/cursorPreference.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
};
|
||||
244
components/terminal/runtime/filterSyncBlockClears.test.ts
Normal file
244
components/terminal/runtime/filterSyncBlockClears.test.ts
Normal 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);
|
||||
});
|
||||
351
components/terminal/runtime/filterSyncBlockClears.ts
Normal file
351
components/terminal/runtime/filterSyncBlockClears.ts
Normal 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,
|
||||
});
|
||||
1023
components/terminal/runtime/kittyKeyboardBroadcast.test.ts
Normal file
1023
components/terminal/runtime/kittyKeyboardBroadcast.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
448
components/terminal/runtime/kittyKeyboardBroadcast.ts
Normal file
448
components/terminal/runtime/kittyKeyboardBroadcast.ts
Normal 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;
|
||||
};
|
||||
907
components/terminal/runtime/kittyKeyboardProtocol.test.ts
Normal file
907
components/terminal/runtime/kittyKeyboardProtocol.test.ts
Normal 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);
|
||||
});
|
||||
1071
components/terminal/runtime/kittyKeyboardProtocol.ts
Normal file
1071
components/terminal/runtime/kittyKeyboardProtocol.ts
Normal file
File diff suppressed because it is too large
Load Diff
138
components/terminal/runtime/kittyKeyboardRuntime.ts
Normal file
138
components/terminal/runtime/kittyKeyboardRuntime.ts
Normal 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);
|
||||
};
|
||||
480
components/terminal/runtime/middleClickBehavior.test.ts
Normal file
480
components/terminal/runtime/middleClickBehavior.test.ts
Normal 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, []);
|
||||
});
|
||||
215
components/terminal/runtime/middleClickBehavior.ts
Normal file
215
components/terminal/runtime/middleClickBehavior.ts
Normal 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;
|
||||
};
|
||||
51
components/terminal/runtime/optionArrowWordJump.test.ts
Normal file
51
components/terminal/runtime/optionArrowWordJump.test.ts
Normal 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);
|
||||
});
|
||||
33
components/terminal/runtime/optionArrowWordJump.ts
Normal file
33
components/terminal/runtime/optionArrowWordJump.ts
Normal 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;
|
||||
}
|
||||
74
components/terminal/runtime/optionYankLastArg.test.ts
Normal file
74
components/terminal/runtime/optionYankLastArg.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
37
components/terminal/runtime/optionYankLastArg.ts
Normal file
37
components/terminal/runtime/optionYankLastArg.ts
Normal 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;
|
||||
}
|
||||
88
components/terminal/runtime/outputFlowController.test.ts
Normal file
88
components/terminal/runtime/outputFlowController.test.ts
Normal 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, []);
|
||||
});
|
||||
74
components/terminal/runtime/outputFlowController.ts
Normal file
74
components/terminal/runtime/outputFlowController.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
1234
components/terminal/runtime/promptLineBreak.test.ts
Normal file
1234
components/terminal/runtime/promptLineBreak.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
756
components/terminal/runtime/promptLineBreak.ts
Normal file
756
components/terminal/runtime/promptLineBreak.ts
Normal 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;
|
||||
}
|
||||
158
components/terminal/runtime/rendererDprWatch.test.ts
Normal file
158
components/terminal/runtime/rendererDprWatch.test.ts
Normal 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);
|
||||
});
|
||||
72
components/terminal/runtime/rendererDprWatch.ts
Normal file
72
components/terminal/runtime/rendererDprWatch.ts
Normal 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;
|
||||
}
|
||||
37
components/terminal/runtime/serialLineInput.test.ts
Normal file
37
components/terminal/runtime/serialLineInput.test.ts
Normal 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"]);
|
||||
});
|
||||
86
components/terminal/runtime/serialLineInput.ts
Normal file
86
components/terminal/runtime/serialLineInput.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
22
components/terminal/runtime/serialLocalEcho.test.ts
Normal file
22
components/terminal/runtime/serialLocalEcho.test.ts
Normal 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"), "");
|
||||
});
|
||||
25
components/terminal/runtime/serialLocalEcho.ts
Normal file
25
components/terminal/runtime/serialLocalEcho.ts
Normal 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 "";
|
||||
}
|
||||
195
components/terminal/runtime/shiftEnterText.test.ts
Normal file
195
components/terminal/runtime/shiftEnterText.test.ts
Normal 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\)/);
|
||||
});
|
||||
113
components/terminal/runtime/shiftEnterText.ts
Normal file
113
components/terminal/runtime/shiftEnterText.ts
Normal 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"],
|
||||
};
|
||||
}
|
||||
21
components/terminal/runtime/telnetLocalEcho.test.ts
Normal file
21
components/terminal/runtime/telnetLocalEcho.test.ts
Normal 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"), "");
|
||||
});
|
||||
30
components/terminal/runtime/telnetLocalEcho.ts
Normal file
30
components/terminal/runtime/telnetLocalEcho.ts
Normal 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;
|
||||
}
|
||||
25
components/terminal/runtime/terminalBackspaceInput.test.ts
Normal file
25
components/terminal/runtime/terminalBackspaceInput.test.ts
Normal 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");
|
||||
});
|
||||
8
components/terminal/runtime/terminalBackspaceInput.ts
Normal file
8
components/terminal/runtime/terminalBackspaceInput.ts
Normal 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;
|
||||
}
|
||||
169
components/terminal/runtime/terminalBroadcastKeypress.test.ts
Normal file
169
components/terminal/runtime/terminalBroadcastKeypress.test.ts
Normal 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(""), "中文");
|
||||
});
|
||||
116
components/terminal/runtime/terminalCloseCapture.test.ts
Normal file
116
components/terminal/runtime/terminalCloseCapture.test.ts
Normal 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\(\)/);
|
||||
});
|
||||
56
components/terminal/runtime/terminalCloseCapture.ts
Normal file
56
components/terminal/runtime/terminalCloseCapture.ts
Normal 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);
|
||||
}
|
||||
@@ -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']);
|
||||
});
|
||||
893
components/terminal/runtime/terminalCommandExecution.ts
Normal file
893
components/terminal/runtime/terminalCommandExecution.ts
Normal 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;
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
86
components/terminal/runtime/terminalCopyShortcut.test.ts
Normal file
86
components/terminal/runtime/terminalCopyShortcut.test.ts
Normal 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);
|
||||
});
|
||||
45
components/terminal/runtime/terminalCopyShortcut.ts
Normal file
45
components/terminal/runtime/terminalCopyShortcut.ts
Normal 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));
|
||||
}
|
||||
148
components/terminal/runtime/terminalDistroDetection.test.ts
Normal file
148
components/terminal/runtime/terminalDistroDetection.test.ts
Normal 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, []);
|
||||
});
|
||||
108
components/terminal/runtime/terminalDistroDetection.ts
Normal file
108
components/terminal/runtime/terminalDistroDetection.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
64
components/terminal/runtime/terminalFlowAckBuffer.test.ts
Normal file
64
components/terminal/runtime/terminalFlowAckBuffer.test.ts
Normal 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");
|
||||
});
|
||||
74
components/terminal/runtime/terminalFlowAckBuffer.ts
Normal file
74
components/terminal/runtime/terminalFlowAckBuffer.ts
Normal 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);
|
||||
};
|
||||
166
components/terminal/runtime/terminalFlowConstants.test.ts
Normal file
166
components/terminal/runtime/terminalFlowConstants.test.ts
Normal 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}`);
|
||||
});
|
||||
29
components/terminal/runtime/terminalFlowConstants.ts
Normal file
29
components/terminal/runtime/terminalFlowConstants.ts
Normal 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;
|
||||
51
components/terminal/runtime/terminalFontRemeasure.test.ts
Normal file
51
components/terminal/runtime/terminalFontRemeasure.test.ts
Normal 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);
|
||||
});
|
||||
28
components/terminal/runtime/terminalFontRemeasure.ts
Normal file
28
components/terminal/runtime/terminalFontRemeasure.ts
Normal 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;
|
||||
}
|
||||
53
components/terminal/runtime/terminalFontZoom.test.ts
Normal file
53
components/terminal/runtime/terminalFontZoom.test.ts
Normal 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);
|
||||
});
|
||||
61
components/terminal/runtime/terminalFontZoom.ts
Normal file
61
components/terminal/runtime/terminalFontZoom.ts
Normal 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));
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
365
components/terminal/runtime/terminalHistoryScrollOverride.ts
Normal file
365
components/terminal/runtime/terminalHistoryScrollOverride.ts
Normal 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;
|
||||
};
|
||||
526
components/terminal/runtime/terminalImeTextInput.test.ts
Normal file
526
components/terminal/runtime/terminalImeTextInput.test.ts
Normal 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\)/,
|
||||
);
|
||||
});
|
||||
190
components/terminal/runtime/terminalImeTextInput.ts
Normal file
190
components/terminal/runtime/terminalImeTextInput.ts
Normal 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;
|
||||
}
|
||||
88
components/terminal/runtime/terminalInputSanitize.test.ts
Normal file
88
components/terminal/runtime/terminalInputSanitize.test.ts
Normal 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");
|
||||
});
|
||||
39
components/terminal/runtime/terminalInputSanitize.ts
Normal file
39
components/terminal/runtime/terminalInputSanitize.ts
Normal 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, "");
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
63
components/terminal/runtime/terminalInterruptDiagnostics.ts
Normal file
63
components/terminal/runtime/terminalInterruptDiagnostics.ts
Normal 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.
|
||||
}
|
||||
}
|
||||
@@ -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, "");
|
||||
});
|
||||
21
components/terminal/runtime/terminalInterruptInputState.ts
Normal file
21
components/terminal/runtime/terminalInterruptInputState.ts
Normal 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");
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
11
components/terminal/runtime/terminalInterruptShortcut.ts
Normal file
11
components/terminal/runtime/terminalInterruptShortcut.ts
Normal 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";
|
||||
}
|
||||
1002
components/terminal/runtime/terminalLineTimestamps.test.ts
Normal file
1002
components/terminal/runtime/terminalLineTimestamps.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1826
components/terminal/runtime/terminalLineTimestamps.ts
Normal file
1826
components/terminal/runtime/terminalLineTimestamps.ts
Normal file
File diff suppressed because it is too large
Load Diff
152
components/terminal/runtime/terminalLinkHandler.test.ts
Normal file
152
components/terminal/runtime/terminalLinkHandler.test.ts
Normal 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*\}/,
|
||||
);
|
||||
});
|
||||
66
components/terminal/runtime/terminalLinkHandler.ts
Normal file
66
components/terminal/runtime/terminalLinkHandler.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
882
components/terminal/runtime/terminalOutputHistory.test.ts
Normal file
882
components/terminal/runtime/terminalOutputHistory.test.ts
Normal 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 }]);
|
||||
});
|
||||
1312
components/terminal/runtime/terminalOutputHistory.ts
Normal file
1312
components/terminal/runtime/terminalOutputHistory.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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(); }
|
||||
});
|
||||
}
|
||||
1393
components/terminal/runtime/terminalOutputPipeline.test.ts
Normal file
1393
components/terminal/runtime/terminalOutputPipeline.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1043
components/terminal/runtime/terminalOutputPipeline.ts
Normal file
1043
components/terminal/runtime/terminalOutputPipeline.ts
Normal file
File diff suppressed because it is too large
Load Diff
276
components/terminal/runtime/terminalOutputPressure.test.ts
Normal file
276
components/terminal/runtime/terminalOutputPressure.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
335
components/terminal/runtime/terminalOutputPressure.ts
Normal file
335
components/terminal/runtime/terminalOutputPressure.ts
Normal 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);
|
||||
};
|
||||
@@ -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~",
|
||||
]);
|
||||
});
|
||||
45
components/terminal/runtime/terminalPerCharacterInput.ts
Normal file
45
components/terminal/runtime/terminalPerCharacterInput.ts
Normal 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];
|
||||
143
components/terminal/runtime/terminalPerformanceDiagnostics.ts
Normal file
143
components/terminal/runtime/terminalPerformanceDiagnostics.ts
Normal 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.
|
||||
}
|
||||
};
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
40
components/terminal/runtime/terminalResizeScheduler.test.ts
Normal file
40
components/terminal/runtime/terminalResizeScheduler.test.ts
Normal 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, []);
|
||||
});
|
||||
39
components/terminal/runtime/terminalResizeScheduler.ts
Normal file
39
components/terminal/runtime/terminalResizeScheduler.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
2592
components/terminal/runtime/terminalSessionAttachment.test.ts
Normal file
2592
components/terminal/runtime/terminalSessionAttachment.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1017
components/terminal/runtime/terminalSessionAttachment.ts
Normal file
1017
components/terminal/runtime/terminalSessionAttachment.ts
Normal file
File diff suppressed because it is too large
Load Diff
144
components/terminal/runtime/terminalStartupCommands.ts
Normal file
144
components/terminal/runtime/terminalStartupCommands.ts
Normal 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);
|
||||
};
|
||||
};
|
||||
727
components/terminal/runtime/terminalSudoAutofill.test.ts
Normal file
727
components/terminal/runtime/terminalSudoAutofill.test.ts
Normal 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);
|
||||
});
|
||||
589
components/terminal/runtime/terminalSudoAutofill.ts
Normal file
589
components/terminal/runtime/terminalSudoAutofill.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
120
components/terminal/runtime/terminalSyncBlockFilter.test.ts
Normal file
120
components/terminal/runtime/terminalSyncBlockFilter.test.ts
Normal 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);
|
||||
});
|
||||
75
components/terminal/runtime/terminalSyncBlockFilter.ts
Normal file
75
components/terminal/runtime/terminalSyncBlockFilter.ts
Normal 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
Reference in New Issue
Block a user