feat: move the terminal fully into the ssh-terminal plugin (B9)

This commit is contained in:
LukeGus committed 2026-09-23 21:41:22 -05:00
1 parent 535126fd1b
commit 79d77678c9
309 files changed
+41789 -14958

No files matched your search

@@ -58,10 +58,36 @@ describe(`${manifest.id} activate`, () => {
"terminal.listSessions",
"terminal.sendToActive",
"terminal.sendToSession",
"terminal.open",
]),
);
});
it("registers the command history panel and its rail item", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.panels()).toContain("history");
expect(rendered.registered.railItems().map((item) => item.id)).toContain(
"history",
);
});
it("offers the local terminal only in the desktop app", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.tabs()).not.toContain("local-terminal");
await rendered.deactivate();
(window as { IS_ELECTRON?: boolean }).IS_ELECTRON = true;
try {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.tabs()).toContain("local-terminal");
expect(rendered.registered.railItems().map((item) => item.id)).toContain(
"local-terminal",
);
} finally {
delete (window as { IS_ELECTRON?: boolean }).IS_ELECTRON;
}
});
it("terminal.sendToActive and terminal.listSessions report no session when none is registered", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
await expect(
@@ -0,0 +1,154 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { dispatchKeybindingAction } from "../../../src/frontend/lib/keybinding-dispatch";
import type { Terminal } from "@xterm/xterm";
import type { KeybindingDispatchContext } from "../../../src/frontend/lib/keybinding-dispatch";
import * as sdkFrontend from "@termix/plugin-sdk/frontend";
vi.mock("@termix/plugin-sdk/frontend", () => ({ invokeAction: vi.fn() }));
function makeContext(
overrides: Partial<KeybindingDispatchContext> = {},
): KeybindingDispatchContext & {
sentData: string[];
} {
const sentData: string[] = [];
const ws = {
readyState: 1,
send: vi.fn((raw: string) => {
sentData.push(JSON.parse(raw).data);
}),
};
const ctx: KeybindingDispatchContext & { sentData: string[] } = {
terminal: {
getSelection: vi.fn(() => ""),
clearSelection: vi.fn(),
paste: vi.fn(),
} as unknown as Terminal,
webSocketRef: { current: ws as unknown as WebSocket },
writeTextToClipboard: vi.fn().mockResolvedValue(true),
readTextFromClipboard: vi.fn().mockResolvedValue(""),
sentData,
...overrides,
};
return ctx;
}
describe("dispatchKeybindingAction", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("copy writes the selection to clipboard and clears it", () => {
const ctx = makeContext();
(ctx.terminal.getSelection as ReturnType<typeof vi.fn>).mockReturnValue(
"hello",
);
dispatchKeybindingAction({ type: "copy" }, ctx);
expect(ctx.writeTextToClipboard).toHaveBeenCalledWith("hello");
expect(ctx.terminal.clearSelection).toHaveBeenCalled();
});
it("copy does nothing when there is no selection", () => {
const ctx = makeContext();
dispatchKeybindingAction({ type: "copy" }, ctx);
expect(ctx.writeTextToClipboard).not.toHaveBeenCalled();
expect(ctx.terminal.clearSelection).not.toHaveBeenCalled();
});
it("paste reads the clipboard and pastes into the terminal", async () => {
const ctx = makeContext({
readTextFromClipboard: vi.fn().mockResolvedValue("pasted text"),
});
dispatchKeybindingAction({ type: "paste" }, ctx);
await Promise.resolve();
await Promise.resolve();
expect(ctx.terminal.paste).toHaveBeenCalledWith("pasted text");
});
it("sendControlCode sends the corresponding control byte", () => {
const ctx = makeContext();
dispatchKeybindingAction(
{ type: "sendControlCode", controlCode: "c" },
ctx,
);
expect(ctx.sentData).toEqual(["\x03"]);
});
it("sendText sends literal text without a trailing return by default", () => {
const ctx = makeContext();
dispatchKeybindingAction({ type: "sendText", text: "ls -la" }, ctx);
expect(ctx.sentData).toEqual(["ls -la"]);
});
it("sendText appends \\r when appendEnter is true", () => {
const ctx = makeContext();
dispatchKeybindingAction(
{ type: "sendText", text: "ls -la", appendEnter: true },
ctx,
);
expect(ctx.sentData).toEqual(["ls -la\r"]);
});
it("runSnippet resolves the snippet through the snippets.resolveForTerminal action and sends its content with a trailing return", async () => {
vi.mocked(sdkFrontend.invokeAction).mockResolvedValue({
needsInputs: false,
content: "uptime",
});
const ctx = makeContext();
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx);
await Promise.resolve();
await Promise.resolve();
expect(sdkFrontend.invokeAction).toHaveBeenCalledWith(
"snippets.resolveForTerminal",
1,
null,
);
expect(ctx.sentData).toEqual(["uptime\r"]);
});
it("runSnippet does nothing when the snippet no longer exists", async () => {
vi.mocked(sdkFrontend.invokeAction).mockResolvedValue(undefined);
const ctx = makeContext();
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx);
await Promise.resolve();
await Promise.resolve();
expect(ctx.sentData).toEqual([]);
});
it("runSnippet passes hostContext through to the resolve action", async () => {
vi.mocked(sdkFrontend.invokeAction).mockResolvedValue({
needsInputs: false,
content: "ping 10.0.0.5 -p 22",
});
const ctx = makeContext({
hostContext: { ip: "10.0.0.5", username: "root", port: 22 },
});
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx);
await Promise.resolve();
await Promise.resolve();
expect(sdkFrontend.invokeAction).toHaveBeenCalledWith(
"snippets.resolveForTerminal",
1,
{ ip: "10.0.0.5", username: "root", port: 22 },
);
expect(ctx.sentData).toEqual(["ping 10.0.0.5 -p 22\r"]);
});
it("runSnippet defers to onSnippetNeedsInputs instead of sending when the action reports needsInputs", async () => {
vi.mocked(sdkFrontend.invokeAction).mockResolvedValue({
needsInputs: true,
content: "echo $INPUT_1",
});
const onSnippetNeedsInputs = vi.fn();
const ctx = makeContext({ onSnippetNeedsInputs });
dispatchKeybindingAction({ type: "runSnippet", snippetId: "1" }, ctx);
await Promise.resolve();
await Promise.resolve();
expect(ctx.sentData).toEqual([]);
expect(onSnippetNeedsInputs).toHaveBeenCalledWith({
id: "1",
content: "echo $INPUT_1",
});
});
});
@@ -0,0 +1,50 @@
import { describe, expect, it, vi } from "vitest";
import { getMacLineNavigationSequence } from "../../../src/frontend/lib/mac-line-navigation";
function keyEvent(
init: Partial<KeyboardEvent> & Pick<KeyboardEvent, "code">,
): KeyboardEvent {
return {
type: "keydown",
altKey: false,
ctrlKey: false,
metaKey: false,
shiftKey: false,
...init,
} as KeyboardEvent;
}
describe("getMacLineNavigationSequence", () => {
it("maps Cmd+arrow keys on macOS", () => {
vi.stubGlobal("navigator", { platform: "MacIntel" });
expect(
getMacLineNavigationSequence(
keyEvent({ code: "ArrowLeft", metaKey: true }),
),
).toBe("\x01");
expect(
getMacLineNavigationSequence(
keyEvent({ code: "ArrowRight", metaKey: true }),
),
).toBe("\x05");
vi.unstubAllGlobals();
});
it("ignores modified and non-mac shortcuts", () => {
vi.stubGlobal("navigator", { platform: "MacIntel" });
expect(
getMacLineNavigationSequence(
keyEvent({ code: "ArrowLeft", metaKey: true, shiftKey: true }),
),
).toBeNull();
vi.unstubAllGlobals();
vi.stubGlobal("navigator", { platform: "Win32" });
expect(
getMacLineNavigationSequence(
keyEvent({ code: "ArrowLeft", metaKey: true }),
),
).toBeNull();
vi.unstubAllGlobals();
});
});
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import {
TerminalLocalEcho,
resolveLocalEchoMode,
} from "../../../src/frontend/lib/terminal-local-echo";
describe("TerminalLocalEcho", () => {
it("renders immediately and suppresses the matching remote echo", () => {
const echo = new TerminalLocalEcho("on");
expect(echo.handleInput("a")).toBe("a");
expect(echo.handleOutput("a")).toBe("");
});
it("rolls back a prediction when remote output differs", () => {
const echo = new TerminalLocalEcho("on");
echo.handleInput("a");
expect(echo.handleOutput("z")).toBe("\x1b[1D\x1b[1Xz");
});
it("only erases the predicted cells during rollback", () => {
const echo = new TerminalLocalEcho("on");
echo.handleInput("a");
echo.handleInput("b");
expect(echo.handleOutput("\x1b[C")).toBe("\x1b[2D\x1b[2X\x1b[C");
});
it.each([
["split prompt", ["Pass", "word: "]],
["sudo prompt", ["[sudo] password for alice: "]],
["ssh-keygen prompt", ["Enter passphrase (empty for no passphrase): "]],
["passwd prompt", ["New password: "]],
])("does not expose input after a %s", (_name, chunks) => {
const echo = new TerminalLocalEcho("on");
for (const chunk of chunks) expect(echo.handleOutput(chunk)).toBe(chunk);
expect(echo.handleInput("s")).toBe("");
});
it("does not treat ordinary password text as a hidden-input prompt", () => {
const echo = new TerminalLocalEcho("on");
expect(echo.handleOutput("Your password has expired\r\n$ ")).toBe(
"Your password has expired\r\n$ ",
);
expect(echo.handleInput("s")).toBe("s");
});
it("does not predict control input, paste, or wide characters", () => {
const echo = new TerminalLocalEcho("on");
expect(echo.handleInput("\t")).toBe("");
expect(echo.handleInput("paste")).toBe("");
expect(echo.handleInput("界")).toBe("");
});
it("enables automatic prediction after repeated slow echoes", () => {
let now = 0;
const echo = new TerminalLocalEcho("auto", () => now, 100);
expect(echo.handleInput("a")).toBe("");
now = 150;
expect(echo.handleOutput("a")).toBe("a");
expect(echo.handleInput("b")).toBe("");
now = 300;
expect(echo.handleOutput("b")).toBe("b");
expect(echo.handleInput("c")).toBe("c");
});
it("uses an explicit host mode before the global mode", () => {
expect(resolveLocalEchoMode("off", "on")).toBe("off");
expect(resolveLocalEchoMode("default", "on")).toBe("on");
expect(resolveLocalEchoMode(undefined, null)).toBe("auto");
});
});
@@ -0,0 +1,441 @@
import { describe, it, expect } from "vitest";
import {
highlightTerminalOutput,
updateControlStringMode,
} from "../../../src/frontend/lib/terminal-syntax-highlighter";
const ESC = "\x1b";
describe("highlightTerminalOutput", () => {
it("returns empty/whitespace text unchanged", () => {
expect(highlightTerminalOutput("")).toBe("");
expect(highlightTerminalOutput(" ")).toBe(" ");
});
it("wraps ERROR in bright red", () => {
const out = highlightTerminalOutput("Something ERROR happened");
expect(out).toContain(ESC + "[91m");
expect(out).toContain("ERROR");
});
it("wraps lowercase error in bright red (case-insensitive)", () => {
const out = highlightTerminalOutput("something error happened");
expect(out).toContain(ESC + "[91m");
});
it("wraps Error (mixed case) in bright red", () => {
const out = highlightTerminalOutput("Error: connection refused");
expect(out).toContain(ESC + "[91m");
});
it("wraps warn (lowercase) in bright yellow", () => {
const out = highlightTerminalOutput("warn: disk almost full");
expect(out).toContain(ESC + "[93m");
});
it("wraps warning (lowercase) in bright yellow", () => {
const out = highlightTerminalOutput("warning: something is off");
expect(out).toContain(ESC + "[93m");
});
it("highlights IPv4 addresses in magenta", () => {
const out = highlightTerminalOutput("connect to 192.168.1.10 now");
expect(out).toContain("192.168.1.10");
expect(out).toContain(ESC + "[35m");
});
it("leaves text with no matchable tokens unchanged", () => {
const plain = "just a normal sentence here";
expect(highlightTerminalOutput(plain)).toBe(plain);
});
it("leaves text ending in an incomplete ANSI escape untouched", () => {
const partial = `loading${ESC}[`;
expect(highlightTerminalOutput(partial)).toBe(partial);
});
it("skips TUI cursor-positioning frames (nano/vim protection)", () => {
const tuiFrame = `${ESC}[H${ESC}[2J some nano content`;
expect(highlightTerminalOutput(tuiFrame)).toBe(tuiFrame);
});
it("skips private TUI mode sequences", () => {
const tuiFrame = `${ESC}[?1049h${ESC}[?25lERROR at /var/log/app.log`;
expect(highlightTerminalOutput(tuiFrame)).toBe(tuiFrame);
});
it("skips bracketed paste mode sequences", () => {
const chunk = `${ESC}[?2004hERROR: enabled paste mode`;
expect(highlightTerminalOutput(chunk)).toBe(chunk);
});
it("skips TUI frames with box-drawing characters", () => {
const chunk = `┌─ /var/log ─┐\n│ ERROR file │`;
expect(highlightTerminalOutput(chunk)).toBe(chunk);
});
it("skips DEC line-drawing charset frames", () => {
const chunk = `${ESC}(0lqq /var/log qk${ESC}(B`;
expect(highlightTerminalOutput(chunk)).toBe(chunk);
});
it("skips OSC current-directory sequences used by fish prompts", () => {
const chunk = `${ESC}]7;file://server/home/user/docker/sonarr\x07user@server ~/docker/sonarr> `;
expect(highlightTerminalOutput(chunk)).toBe(chunk);
});
it("skips OSC title sequences", () => {
const chunk = `${ESC}]0;user@server:/var/log\x07ERROR after title`;
expect(highlightTerminalOutput(chunk)).toBe(chunk);
});
it("highlights text that already has ANSI codes", () => {
let heavy = "";
for (let i = 0; i < 12; i++) heavy += `${ESC}[32mgreen${ESC}[0m `;
heavy += "ERROR at line 5";
const out = highlightTerminalOutput(heavy);
expect(out).toContain(ESC + "[91m");
});
it("does not process lines exceeding MAX_LINE_LENGTH", () => {
const huge = "ERROR " + "x".repeat(3000);
expect(highlightTerminalOutput(huge)).toBe(huge);
});
it("highlights simple absolute paths in cyan", () => {
const out = highlightTerminalOutput("log at /var/log/nginx/access.log");
expect(out).toContain(ESC + "[36m");
expect(out).toContain("/var/log/nginx/access.log");
});
it("highlights absolute paths with glob wildcards in cyan", () => {
const out = highlightTerminalOutput("files: /usr/share/doc/*/copyright");
expect(out).toContain(ESC + "[36m");
expect(out).toContain("/usr/share/doc/*/copyright");
});
it("highlights home paths in cyan", () => {
const out = highlightTerminalOutput("file: ~/documents/notes.txt");
expect(out).toContain(ESC + "[36m");
expect(out).toContain("~/documents/notes.txt");
});
it("highlights bracket timestamps in bright black", () => {
const out = highlightTerminalOutput("[12:34:56] server started");
expect(out).toContain(ESC + "[90m");
expect(out).toContain("[12:34:56]");
});
it("does not highlight out-of-range bracket timestamps", () => {
// [99:99] is not a valid time
const out = highlightTerminalOutput("[99:99] something");
expect(out).not.toContain(ESC + "[90m");
});
it("highlights ISO date timestamps in bright black", () => {
const out = highlightTerminalOutput("2024-01-15 event occurred");
expect(out).toContain(ESC + "[90m");
expect(out).toContain("2024-01-15");
});
it("does not highlight version strings as ISO dates", () => {
// 2.4.0 or 1.2024.3 should not trigger ISO date
const out = highlightTerminalOutput("version 2.4.0 released");
expect(out).toBe("version 2.4.0 released");
});
it("highlights labeled numbers (port)", () => {
const out = highlightTerminalOutput("listening on port 8080");
expect(out).toContain(ESC + "[96m");
expect(out).toContain("8080");
});
it("highlights labeled numbers (exit)", () => {
const out = highlightTerminalOutput("process exited with exit 1");
expect(out).toContain(ESC + "[96m");
});
it("highlights labeled numbers (status)", () => {
const out = highlightTerminalOutput("returned status 404");
expect(out).toContain(ESC + "[96m");
});
it("does not highlight standalone numbers outside labeled context", () => {
const out = highlightTerminalOutput("there are 42 files here");
expect(out).toBe("there are 42 files here");
});
it("does not highlight 'up' or 'active' as success", () => {
const out = highlightTerminalOutput("service is up and running");
expect(out).not.toContain(ESC + "[92m");
});
it("highlights unambiguous success keywords (case-insensitive)", () => {
const out = highlightTerminalOutput("Test passed successfully");
expect(out).toContain(ESC + "[92m");
});
it("highlights URLs with blue+underline", () => {
const out = highlightTerminalOutput("visit https://example.com now");
expect(out).toContain(ESC + "[34m");
expect(out).toContain(ESC + "[4m");
});
it("preserves \\r in CRLF terminal output", () => {
const out = highlightTerminalOutput("ERROR occurred\r\n");
expect(out).toContain("\r\n");
});
it("processes multi-line text line by line", () => {
const text = "some output\nERROR: failed\n";
const out = highlightTerminalOutput(text);
expect(out).toContain(ESC + "[91m");
});
it("respects category options - disabling logLevels skips error highlight", () => {
const out = highlightTerminalOutput("ERROR occurred", { logLevels: false });
expect(out).not.toContain(ESC + "[91m");
});
it("respects category options - disabling paths skips path highlight", () => {
const out = highlightTerminalOutput("at /usr/share/doc/something/file", {
paths: false,
});
expect(out).not.toContain(ESC + "[36m");
});
it("respects category options - disabling urls skips url highlight", () => {
const out = highlightTerminalOutput("see https://example.com", {
urls: false,
});
expect(out).not.toContain(ESC + "[34m");
});
it("respects category options - disabling ipAddresses skips IP highlight", () => {
const out = highlightTerminalOutput("connect to 192.168.1.1", {
ipAddresses: false,
});
expect(out).not.toContain(ESC + "[35m");
});
it("respects category options - disabling timestamps skips ISO date highlight", () => {
const out = highlightTerminalOutput("2024-01-15 something", {
timestamps: false,
});
expect(out).not.toContain(ESC + "[90m");
});
it("respects category options - disabling numbers skips port highlight", () => {
const out = highlightTerminalOutput("listening on port 8080", {
numbers: false,
});
expect(out).not.toContain(ESC + "[96m");
});
it("skips highlighting when chunk contains a mid-line carriage return", () => {
// Progress bars and shell prompt redraws use \r to overwrite the current line
const chunk = "downloading...\rdownloading [====] 100%";
expect(highlightTerminalOutput(chunk)).toBe(chunk);
});
it("still processes CRLF line endings (\\r\\n is fine)", () => {
const out = highlightTerminalOutput("ERROR occurred\r\n");
expect(out).toContain(ESC + "[91m");
});
it("does not highlight shell prompt lines (user@host:path$)", () => {
const prompt = `${ESC}[01;32mpi@raspberrypi${ESC}[00m:${ESC}[01;34m/home/pi${ESC}[00m$ `;
const out = highlightTerminalOutput(prompt);
expect(out).toBe(prompt);
});
it("does not highlight plain-text shell prompt line", () => {
const prompt = "pi@raspberrypi:/home/pi$ ";
const out = highlightTerminalOutput(prompt);
expect(out).toBe(prompt);
});
it("does not highlight log keywords inside bracketed SSH headings", () => {
const out = highlightTerminalOutput("[info@archlinux] command output");
expect(out).toBe("[info@archlinux] command output");
});
it("still highlights log keywords after a bracketed SSH heading", () => {
const out = highlightTerminalOutput("[warning@host] command ERROR");
expect(out).not.toContain(`${ESC}[93mwarning`);
expect(out).toContain(`${ESC}[91mERROR`);
});
it("does not highlight a log-level-like username split across ANSI segments in a colored SSH heading", () => {
// Prompt themes often color the user and host portions of "[user@host]"
// separately, so the heading is not one contiguous plain-text segment.
const chunk = `[${ESC}[1;33mwarning${ESC}[0m@host] some command output`;
const out = highlightTerminalOutput(chunk);
expect(out).toBe(chunk);
});
it("does not highlight 'success' when immediately followed by a path (cd output)", () => {
// Some shells print "success~/new/dir" or "success/path" after a cd command
const out = highlightTerminalOutput("success~/home/user/projects");
expect(out).not.toContain(ESC + "[92m");
});
it("still highlights 'success' when not followed by a path separator", () => {
const out = highlightTerminalOutput("Build success: all tests passed");
expect(out).toContain(ESC + "[92m");
});
it("highlights output lines but not the prompt in a mixed chunk", () => {
const chunk = `ERROR: disk full\npi@raspberrypi:~$ `;
const out = highlightTerminalOutput(chunk);
// The error line should be highlighted
expect(out).toContain(ESC + "[91m");
// The prompt line should be unchanged
expect(out).toContain("pi@raspberrypi:~$ ");
const promptLine = out.split("\n")[1];
expect(promptLine).toBe("pi@raspberrypi:~$ ");
});
it("does not highlight paths inside a command-echo line (prompt + command)", () => {
// When the shell echoes the user's command, it prefixes the prompt.
// The path in the prompt portion (/home/user) must not be highlighted —
// the shell already colored it, and re-coloring it causes the doubled-path bug.
const echo = "user@host:/home/user$ cd /opt/app/bin/files";
const out = highlightTerminalOutput(echo);
expect(out).toBe(echo);
});
it("does not highlight paths in colored command-echo lines (root prompt)", () => {
const echo = `${ESC}[01;32mroot@host${ESC}[00m:${ESC}[01;34m/home/user${ESC}[00m# cd /opt/app/bin/files`;
const out = highlightTerminalOutput(echo);
expect(out).toBe(echo);
});
it("does not highlight the last line of a multi-line chunk with no trailing newline", () => {
// In a multi-line chunk, the trailing fragment without \n could be a live
// readline input line. Injecting ANSI bytes there breaks bash's cursor
// arithmetic (causes cursor jump / text shift when using arrow keys).
const chunk = "ERROR: disk full\nuser@host:/path$ cd /var/log";
const out = highlightTerminalOutput(chunk);
// First line (terminated by \n) gets highlighted
expect(out.split("\n")[0]).toContain(ESC + "[91m");
// Last unterminated fragment is left verbatim
expect(out.split("\n")[1]).toBe("user@host:/path$ cd /var/log");
});
it("still highlights a single-line chunk with no trailing newline", () => {
// A single-line chunk with no \n is plain command output, not a readline
// input fragment — highlight it normally.
const out = highlightTerminalOutput("ERROR: something failed");
expect(out).toContain(ESC + "[91m");
});
it("highlights all lines when the chunk ends with a newline", () => {
// When a chunk ends with \n every line is complete output — highlight them all.
const chunk = "ERROR: disk full\nconnect to /var/run/app.sock\n";
const out = highlightTerminalOutput(chunk);
expect(out.split("\n")[0]).toContain(ESC + "[91m");
expect(out.split("\n")[1]).toContain(ESC + "[36m");
});
});
describe("updateControlStringMode", () => {
const BEL = "\x07";
it("stays inactive for output with no control string", () => {
expect(updateControlStringMode("total 4\nfile.txt\n", false)).toEqual({
isActive: false,
wasActive: false,
});
});
it("goes active when a control string is left open at the chunk end", () => {
// What PROMPT_COMMAND emits: ESC ] 0 ; <title>, terminator not yet sent.
const chunk = `${ESC}]0;user@host:~/path/current-dir`;
expect(updateControlStringMode(chunk, false)).toEqual({
isActive: true,
wasActive: false,
});
});
it("clears on the continuation chunk that carries the terminator", () => {
// The continuation has no escape byte at all, which is why every
// single-chunk guard misses it.
const continuation = `${BEL}[user@host current-dir]$ `;
expect(updateControlStringMode(continuation, true)).toEqual({
isActive: false,
wasActive: true,
});
});
it("reports a chunk that opens and closes a control string as inactive", () => {
const chunk = `${ESC}]0;title${BEL}ready\n`;
expect(updateControlStringMode(chunk, false)).toEqual({
isActive: false,
wasActive: false,
});
});
it("accepts ST as a terminator", () => {
expect(
updateControlStringMode(`${ESC}]7;file:///tmp${ESC}\\`, false),
).toEqual({
isActive: false,
wasActive: false,
});
});
it("treats a trailing lone ESC as active, since its meaning is in the next chunk", () => {
expect(updateControlStringMode(`done${ESC}`, false)).toEqual({
isActive: true,
wasActive: false,
});
});
it("recognises DCS, APC and PM openers too", () => {
for (const opener of ["P", "^", "_"]) {
expect(
updateControlStringMode(`${ESC}${opener}payload`, false).isActive,
).toBe(true);
}
});
it("does not treat CSI as a control string", () => {
expect(updateControlStringMode(`${ESC}[31mred${ESC}[0m`, false)).toEqual({
isActive: false,
wasActive: false,
});
});
it("leaves a continuation chunk unhighlighted", () => {
// The bug: this chunk highlights cleanly on its own, and injecting SGR
// bytes into an open OSC string dumps the payload onto the screen.
const continuation = `${BEL}connect to 10.0.0.11 failed`;
expect(highlightTerminalOutput(continuation)).not.toBe(continuation);
const state = updateControlStringMode(continuation, true);
expect(state.wasActive).toBe(true);
});
it("passes through SGR mouse-tracking reports untouched (issue #1023)", () => {
// ESC[<Cb;Cx;CyM is the SGR (1006) mouse-report format. The `<` marker
// byte was missing from the parameter-byte character class, so these
// sequences weren't recognised as ANSI escapes and their raw digits
// leaked onto the screen as visible text inside `screen`/tmux sessions.
const report = `${ESC}[<35;191;1M`;
expect(highlightTerminalOutput(report)).toBe(report);
});
it("passes through a burst of SGR mouse reports untouched", () => {
const burst = `${ESC}[<35;191;1M${ESC}[<35;186;2M${ESC}[<35;46;21M`;
expect(highlightTerminalOutput(burst)).toBe(burst);
});
it("treats a chunk ending mid SGR-mouse-report as incomplete", () => {
const partial = `foo${ESC}[<35;191`;
expect(highlightTerminalOutput(partial)).toBe(partial);
});
});
@@ -0,0 +1,52 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TouchInputSettings } from "../../../src/frontend/settings/TouchInputSettings";
import { TOUCH_INPUT_DEFAULTS } from "../../../src/shared/touch-input-settings";
function renderSettings(values: Record<string, unknown> = {}) {
const setValue = vi.fn();
render(
<TouchInputSettings
pluginId="ssh-terminal"
values={values}
setValue={setValue}
running
/>,
);
return setValue;
}
describe("TouchInputSettings", () => {
it("shows simple defaults, keeps tunables collapsed, and resets", async () => {
const user = userEvent.setup();
const setValue = renderSettings({
touchInput: { ...TOUCH_INPUT_DEFAULTS, dragThresholdPx: 20 },
});
expect(screen.getByText("touchInput.enabled")).toBeTruthy();
expect(screen.queryByLabelText("touchInput.dragThreshold")).toBeNull();
await user.click(screen.getByText("touchInput.advanced"));
expect(
(screen.getByLabelText("touchInput.dragThreshold") as HTMLInputElement)
.value,
).toBe("20");
expect(
(
screen.getByLabelText(
"touchInput.maximumTicksPerFrame",
) as HTMLInputElement
).value,
).toBe("4");
await user.click(screen.getByText("touchInput.resetDefaults"));
expect(setValue).toHaveBeenLastCalledWith("touchInput", {
...TOUCH_INPUT_DEFAULTS,
});
});
it("falls back to the defaults when nothing is stored", () => {
renderSettings();
expect(screen.getByText("touchInput.momentumEnabled")).toBeTruthy();
});
});
@@ -0,0 +1,920 @@
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StrictMode } from "react";
import type { Host } from "../../../src/frontend/types";
const mobileApi = vi.hoisted(() => ({
isMobile: false as boolean | undefined,
}));
const slots = vi.hoisted(() => ({
toolbar: [] as Array<{ actionId: string; titleKey: string }>,
invoked: [] as unknown[][],
}));
const labels: Record<string, string> = {
"terminalToolbar.detachTmux": "Detach tmux",
"terminalToolbar.detachTmuxDescription": "Detach tmux session",
"terminalToolbar.image": "Image actions (images only)",
"terminalToolbar.upload": "Upload image",
"terminalToolbar.uploadImage": "Upload an image to the host",
"terminalToolbar.uploadingImage": "Uploading image…",
"terminalToolbar.paste": "Paste image",
"terminalToolbar.pasteImage": "Paste image from clipboard (images only)",
"terminalToolbar.showActions": "Show toolbar",
"terminalToolbar.showToolbar": "Expand toolbar",
"terminalToolbar.hideToolbar": "Hide toolbar",
"terminalToolbar.layout": "Toolbar display mode",
"terminalToolbar.layoutIcon": "Icon only",
"terminalToolbar.layoutLabeled": "Icons and labels",
"terminalToolbar.layoutExpanded": "Expanded with metrics",
"terminalToolbar.moveToolbar": "Move toolbar",
"terminalToolbar.moveToolbarHint": "Drag to reposition the toolbar",
"terminalToolbar.imageActionFailed": "Image action failed",
"terminalToolbar.retry": "Retry",
"terminalToolbar.dismissError": "Dismiss image error",
"ai.assistant": "AI Assistant",
};
vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useTranslation: () => ({
t: (key: string) => labels[key] ?? key,
language: "en",
}),
useSlotContributions: (slotId: string) =>
slotId === "terminal.toolbar" ? slots.toolbar : [],
}));
vi.mock("@termix/plugin-sdk/ui", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useIsMobile: () => mobileApi.isMobile,
// The real slot reads the action registry; the toolbar only decides where
// it goes and what it is handed.
ActionSlot: ({
context,
enabled,
}: {
context: () => unknown[];
enabled?: boolean;
}) =>
enabled === false
? null
: slots.toolbar.map((contribution) => (
<button
key={contribution.actionId}
type="button"
onClick={() => slots.invoked.push(context())}
>
{labels[contribution.titleKey] ?? contribution.titleKey}
</button>
)),
ComponentSlot: () => null,
}));
import { TerminalToolbar } from "../../../src/frontend/terminal/TerminalToolbar";
import {
clampToolbarPosition,
getResponsiveToolbarDensity,
} from "../../../src/frontend/terminal/toolbar-geometry";
const host = { id: 7, connectionType: "ssh" } as unknown as Host;
function renderToolbar(
overrides: Partial<React.ComponentProps<typeof TerminalToolbar>> = {},
) {
const props: React.ComponentProps<typeof TerminalToolbar> = {
host,
isConnected: true,
isTmuxAttached: true,
onTmuxDetach: vi.fn(),
isImageUploading: false,
onUploadImage: vi.fn(),
onPasteImage: vi.fn(),
isFocused: true,
...overrides,
};
return { ...render(<TerminalToolbar {...props} />), props };
}
// jsdom lacks the pointer APIs Radix Select needs to open its popup.
beforeEach(() => {
Element.prototype.hasPointerCapture ??= () => false;
Element.prototype.setPointerCapture ??= () => {};
Element.prototype.releasePointerCapture ??= () => {};
Element.prototype.scrollIntoView ??= () => {};
vi.clearAllMocks();
mobileApi.isMobile = false;
Object.defineProperty(window, "innerWidth", {
configurable: true,
value: 1024,
});
localStorage.clear();
slots.toolbar = [];
slots.invoked = [];
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("responsive toolbar density", () => {
it("enters below 110 percent and restores at 115 percent", () => {
expect(getResponsiveToolbarDensity("labeled", "labeled", 1099, 1000)).toBe(
"icon",
);
expect(getResponsiveToolbarDensity("labeled", "labeled", 1100, 1000)).toBe(
"labeled",
);
expect(getResponsiveToolbarDensity("labeled", "icon", 1149, 1000)).toBe(
"icon",
);
expect(getResponsiveToolbarDensity("labeled", "icon", 1150, 1000)).toBe(
"labeled",
);
});
it("never falls back when Icon is explicitly selected", () => {
expect(getResponsiveToolbarDensity("icon", "icon", 10, 1000)).toBe("icon");
expect(getResponsiveToolbarDensity("icon", "icon", 2000, 1000)).toBe(
"icon",
);
});
it("retains the prior effective state while measurement is unavailable", () => {
expect(getResponsiveToolbarDensity("expanded", "expanded", 0, 1000)).toBe(
"expanded",
);
expect(getResponsiveToolbarDensity("expanded", "icon", 0, 1000)).toBe(
"icon",
);
});
});
describe("TerminalToolbar Phase 1", () => {
it("clamps vertical movement and preserves over-tall recovery", () => {
const hostRect = {
left: 0,
right: 400,
top: 0,
bottom: 200,
width: 400,
height: 200,
};
const toolbarRect = {
left: 100,
right: 300,
top: 140,
bottom: 190,
width: 200,
height: 50,
};
expect(
clampToolbarPosition({ x: 0, y: -999 }, toolbarRect, hostRect, {
x: 0,
y: 0,
}).y,
).toBe(-132);
expect(
clampToolbarPosition({ x: 0, y: 999 }, toolbarRect, hostRect, {
x: 0,
y: 0,
}).y,
).toBe(2);
expect(
clampToolbarPosition(
{ x: 0, y: -999 },
{ ...toolbarRect, top: 150, bottom: 350, height: 200 },
hostRect,
{ x: 0, y: 0 },
).y,
).toBe(-298);
});
it("keeps horizontal outputs unchanged during simultaneous movement", () => {
const hostRect = {
left: 100,
right: 500,
top: 50,
bottom: 350,
width: 400,
height: 300,
};
const toolbarRect = {
left: 250,
right: 450,
top: 280,
bottom: 330,
width: 200,
height: 50,
};
expect(
clampToolbarPosition({ x: -999, y: -100 }, toolbarRect, hostRect, {
x: 0,
y: 0,
}),
).toEqual({ x: -142, y: -100 });
expect(
clampToolbarPosition({ x: 999, y: 12 }, toolbarRect, hostRect, {
x: 0,
y: 0,
}),
).toEqual({ x: 42, y: 12 });
});
it("fills the full terminal surface while remaining bottom-centered and pointer transparent", () => {
const { container } = renderToolbar();
const toolbarHost = container.querySelector("[data-terminal-toolbar-host]");
const toolbar = container.querySelector("[data-terminal-toolbar-wide]");
expect(toolbarHost).toHaveClass(
"absolute",
"inset-0",
"flex",
"items-end",
"justify-center",
"pointer-events-none",
"pb-2",
);
expect(toolbarHost).not.toHaveClass("inset-x-2", "bottom-2");
expect(toolbar).toHaveClass("pointer-events-auto");
expect(toolbar).toHaveStyle({ transform: "translate(0px, 0px)" });
});
it("clamps every edge and preserves recovery for an over-wide toolbar", () => {
const hostRect = {
left: 100,
right: 500,
top: 50,
bottom: 350,
width: 400,
height: 300,
};
const toolbar = {
left: 250,
right: 450,
top: 280,
bottom: 330,
width: 200,
height: 50,
};
expect(
clampToolbarPosition({ x: -500, y: 0 }, toolbar, hostRect, {
x: 0,
y: 0,
}),
).toEqual({
x: -142,
y: 0,
});
expect(
clampToolbarPosition({ x: 500, y: 0 }, toolbar, hostRect, { x: 0, y: 0 }),
).toEqual({
x: 42,
y: 0,
});
expect(
clampToolbarPosition({ x: 0, y: -500 }, toolbar, hostRect, {
x: 0,
y: 0,
}),
).toEqual({
x: 0,
y: -222,
});
expect(
clampToolbarPosition({ x: 0, y: 500 }, toolbar, hostRect, { x: 0, y: 0 }),
).toEqual({
x: 0,
y: 12,
});
expect(
clampToolbarPosition(
{ x: -999, y: 0 },
{ ...toolbar, left: -100, right: 600, width: 700 },
hostRect,
{ x: 0, y: 0 },
).x,
).toBe(-448);
});
it("clamps an off-screen restored position to actual host bounds", async () => {
localStorage.setItem(
"termix-terminal-toolbar-position-v2",
JSON.stringify({ x: 9000, y: -9000 }),
);
const original = HTMLElement.prototype.getBoundingClientRect;
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
function () {
if (this.hasAttribute("data-terminal-toolbar-host")) {
return DOMRect.fromRect({ x: 100, y: 50, width: 500, height: 300 });
}
if (this.hasAttribute("data-terminal-toolbar-wide")) {
const match = this.style.transform.match(
/translate\(([-\d.]+)px, ([-\d.]+)px\)/,
);
const x = Number(match?.[1] ?? 0);
const y = Number(match?.[2] ?? 0);
return DOMRect.fromRect({
x: 250 + x,
y: 250 + y,
width: 250,
height: 50,
});
}
return original.call(this);
},
);
const { container } = renderToolbar();
await waitFor(() =>
expect(
(container.querySelector("[data-terminal-toolbar-wide]") as HTMLElement)
.style.transform,
).toBe("translate(0px, 0px)"),
);
});
it("renders the PC toolbar and Image group with adjacent actions", () => {
const { container } = renderToolbar();
expect(container.querySelector("[data-terminal-toolbar-host]")).toHaveClass(
"@container",
);
expect(container.querySelector("[data-terminal-toolbar-wide]")).toHaveClass(
"pointer-events-auto",
);
expect(
container.querySelector("[data-terminal-toolbar-narrow]"),
).toBeNull();
const imageGroup = screen.getAllByRole("group", {
name: "Image actions (images only)",
})[0];
expect(imageGroup.querySelectorAll("button, label")).toHaveLength(2);
expect(
screen.getAllByRole("button", { name: "Upload an image to the host" })[0],
).toBeVisible();
expect(
screen.getAllByRole("button", {
name: "Paste image from clipboard (images only)",
})[0],
).toBeVisible();
});
it("uses explicit labels, conditional tmux, and compact interactive targets", () => {
const { rerender, props } = renderToolbar();
expect(
screen.getAllByRole("button", { name: "Detach tmux session" })[0],
).toHaveTextContent("Detach tmux");
expect(
screen.getAllByRole("button", { name: "Detach tmux session" })[0],
).toHaveClass("min-h-8", "min-w-8");
rerender(<TerminalToolbar {...props} isTmuxAttached={false} />);
expect(
screen.queryByRole("button", { name: "Detach tmux session" }),
).not.toBeInTheDocument();
});
it("exposes density as a select and applies the chosen mode", async () => {
const user = userEvent.setup();
renderToolbar();
const trigger = screen.getByRole("combobox", {
name: "Toolbar display mode",
});
expect(trigger).toHaveTextContent("");
await user.click(trigger);
const options = await screen.findAllByRole("option");
expect(options.map((option) => option.textContent)).toEqual([
"Icon only",
"Icons and labels",
"Expanded with metrics",
]);
await user.click(
screen.getByRole("option", { name: "Expanded with metrics" }),
);
await waitFor(() =>
expect(localStorage.getItem("termix-terminal-toolbar-density")).toBe(
"expanded",
),
);
});
it("keeps one file input mounted, resets repeated selections, and exposes loading", () => {
const onUploadImage = vi.fn();
const { container, rerender, props } = renderToolbar({ onUploadImage });
const input = container.querySelector(
'input[type="file"]',
) as HTMLInputElement;
expect(container.querySelectorAll('input[type="file"]')).toHaveLength(1);
const file = new File(["image"], "same.png", { type: "image/png" });
fireEvent.change(input, { target: { files: [file] } });
fireEvent.change(input, { target: { files: [file] } });
expect(onUploadImage).toHaveBeenCalledTimes(2);
rerender(<TerminalToolbar {...props} isImageUploading />);
expect(container.querySelector('input[type="file"]')).toBe(input);
expect(screen.getByRole("status")).toHaveTextContent("Uploading image…");
expect(
screen.getAllByRole("button", { name: "Upload an image to the host" })[0],
).toBeDisabled();
expect(
screen.getAllByRole("button", {
name: "Paste image from clipboard (images only)",
})[0],
).toBeDisabled();
});
it("hides clearly, leaves recovery, and closes open UI on disconnect", async () => {
const user = userEvent.setup();
const { rerender, props } = renderToolbar();
await user.click(screen.getByRole("button", { name: "Hide toolbar" }));
const collapsedMover = screen.getByRole("button", {
name: "Move toolbar",
});
expect(collapsedMover).toHaveAttribute("aria-label", "Move toolbar");
await user.click(screen.getByRole("button", { name: "Expand toolbar" }));
expect(
screen.getByRole("combobox", { name: "Toolbar display mode" }),
).toBeInTheDocument();
rerender(<TerminalToolbar {...props} isConnected={false} />);
expect(
screen.queryByRole("combobox", { name: "Toolbar display mode" }),
).not.toBeInTheDocument();
});
it("calls paste synchronously in the activation handler and does not cancel outside pointerdown", () => {
const onPasteImage = vi.fn();
renderToolbar({ onPasteImage });
const paste = screen.getAllByRole("button", {
name: "Paste image from clipboard (images only)",
})[0];
fireEvent.click(paste);
expect(onPasteImage).toHaveBeenCalledOnce();
const outside = new PointerEvent("pointerdown", {
bubbles: true,
cancelable: true,
});
document.body.dispatchEvent(outside);
expect(outside.defaultPrevented).toBe(false);
});
it("shows an actionable image error and retries the failed activation", async () => {
const user = userEvent.setup();
const onPasteImage = vi
.fn<() => Promise<void>>()
.mockRejectedValueOnce(new Error("Clipboard permission denied"))
.mockResolvedValueOnce();
renderToolbar({ onPasteImage });
await user.click(
screen.getAllByRole("button", {
name: "Paste image from clipboard (images only)",
})[0],
);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Clipboard permission denied",
);
await user.click(screen.getByRole("button", { name: "Retry" }));
expect(onPasteImage).toHaveBeenCalledTimes(2);
await waitFor(() =>
expect(screen.queryByRole("alert")).not.toBeInTheDocument(),
);
});
it("routes synchronous image failures through the alert flow", async () => {
renderToolbar({
onPasteImage: vi.fn(() => {
throw new Error("Synchronous clipboard failure");
}),
});
fireEvent.click(
screen.getByRole("button", {
name: "Paste image from clipboard (images only)",
}),
);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Synchronous clipboard failure",
);
});
it("orders desktop actions and keeps the grab handle persistent", () => {
const { container } = renderToolbar();
const labels = Array.from(
container.querySelectorAll("[data-terminal-toolbar-wide] button"),
).map((button) => button.getAttribute("aria-label"));
expect(labels).toEqual([
"Detach tmux session",
"Upload an image to the host",
"Paste image from clipboard (images only)",
"Toolbar display mode",
"Hide toolbar",
"Move toolbar",
]);
});
it("shows three modes and fades the expanded toolbar", async () => {
const user = userEvent.setup();
const { container } = renderToolbar();
const toolbar = container.querySelector(
"[data-terminal-toolbar-wide]",
) as HTMLElement;
expect(toolbar).toHaveClass(
"opacity-30",
"transition-opacity",
"hover:opacity-100",
"focus-within:opacity-100",
);
await user.click(
screen.getByRole("combobox", { name: "Toolbar display mode" }),
);
expect(await screen.findAllByRole("option")).toHaveLength(3);
});
it("gives the collapsed toolbar idle opacity and keyboard recovery", async () => {
const user = userEvent.setup();
const { container } = renderToolbar();
await user.click(screen.getByRole("button", { name: "Hide toolbar" }));
const toolbar = container.querySelector(
"[data-terminal-toolbar-wide]",
) as HTMLElement;
expect(toolbar).toHaveClass(
"opacity-100",
"hover:opacity-100",
"focus-within:opacity-100",
);
expect(
screen.getByRole("button", { name: "Expand toolbar" }),
).toBeVisible();
const collapsedMover = screen.getByRole("button", {
name: "Move toolbar",
});
expect(collapsedMover).toBeVisible();
expect(
container.querySelector(".terminal-toolbar-collapsed-shell"),
).toBeInTheDocument();
collapsedMover.focus();
expect(toolbar).toHaveClass("focus-within:opacity-100");
const expandButton = screen.getByRole("button", {
name: "Expand toolbar",
});
expandButton.focus();
await user.keyboard("{Enter}");
await waitFor(() =>
expect(
screen.getByRole("button", { name: "Hide toolbar" }),
).toHaveFocus(),
);
}, 15_000);
it("keeps the mover on the right edge across collapse and expansion", async () => {
const original = HTMLElement.prototype.getBoundingClientRect;
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
function () {
if (this.hasAttribute("data-terminal-toolbar-host")) {
return DOMRect.fromRect({ x: 100, y: 50, width: 500, height: 300 });
}
if (this.hasAttribute("data-terminal-toolbar-wide")) {
const match = this.style.transform.match(
/translate\(([-\d.]+)px, ([-\d.]+)px\)/,
);
const collapsed = !this.querySelector(
'button[aria-label="Hide toolbar"]',
);
return DOMRect.fromRect({
x: 225 + Number(match?.[1] ?? 0),
y: 290 + Number(match?.[2] ?? 0),
width: collapsed ? 44 : 250,
height: collapsed ? 44 : 50,
});
}
return original.call(this);
},
);
localStorage.setItem(
"termix-terminal-toolbar-position-v2",
JSON.stringify({ x: 117, y: 2 }),
);
const { container } = renderToolbar();
const toolbar = container.querySelector(
"[data-terminal-toolbar-wide]",
) as HTMLElement;
await userEvent
.setup()
.click(screen.getByRole("button", { name: "Hide toolbar" }));
expect(toolbar.style.transform).toBe("translate(323px, 2px)");
await userEvent
.setup()
.click(screen.getByRole("button", { name: "Expand toolbar" }));
expect(toolbar.style.transform).toBe("translate(117px, 2px)");
});
it("moves and persists the toolbar position, then stops after pointerup", () => {
const { container, unmount } = renderToolbar();
const grab = screen.getByRole("button", { name: "Move toolbar" });
const toolbar = container.querySelector(
"[data-terminal-toolbar-wide]",
) as HTMLElement;
fireEvent.pointerDown(grab, {
pointerId: 1,
button: 0,
clientX: 100,
clientY: 100,
});
fireEvent.pointerMove(grab, {
pointerId: 1,
clientX: 140,
clientY: 90,
});
expect(toolbar.style.transform).toBe("translate(40px, -10px)");
fireEvent.pointerUp(grab, { pointerId: 1 });
expect(
JSON.parse(localStorage.getItem("termix-terminal-toolbar-position-v2")!),
).toEqual({ x: 40, y: -10 });
fireEvent.pointerMove(grab, {
pointerId: 1,
clientX: 500,
clientY: 500,
});
expect(toolbar.style.transform).toBe("translate(40px, -10px)");
unmount();
const second = renderToolbar();
expect(
(
second.container.querySelector(
"[data-terminal-toolbar-wide]",
) as HTMLElement
).style.transform,
).toBe("translate(40px, -10px)");
});
it("moves on both axes, clamps safely, ignores a different pointer ID, and persists", () => {
const original = HTMLElement.prototype.getBoundingClientRect;
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
function () {
if (this.hasAttribute("data-terminal-toolbar-host")) {
return DOMRect.fromRect({ x: 100, y: 50, width: 500, height: 300 });
}
if (this.hasAttribute("data-terminal-toolbar-wide")) {
const match = this.style.transform.match(
/translate\(([-\d.]+)px, ([-\d.]+)px\)/,
);
return DOMRect.fromRect({
x: 225 + Number(match?.[1] ?? 0),
y: 290 + Number(match?.[2] ?? 0),
width: 250,
height: 50,
});
}
return original.call(this);
},
);
const { container } = renderToolbar();
const grab = screen.getByRole("button", { name: "Move toolbar" });
const toolbar = container.querySelector(
"[data-terminal-toolbar-wide]",
) as HTMLElement;
fireEvent.pointerDown(grab, {
pointerId: 11,
button: 0,
clientX: 10,
clientY: 100,
});
fireEvent.pointerMove(grab, {
pointerId: 12,
clientX: 30,
clientY: -500,
});
expect(toolbar).toHaveStyle({ transform: "translate(0px, 0px)" });
fireEvent.pointerMove(grab, {
pointerId: 11,
clientX: 30,
clientY: 120,
});
expect(toolbar.style.transform).toBe("translate(20px, 2px)");
fireEvent.pointerMove(grab, {
pointerId: 11,
clientX: 500,
clientY: 500,
});
expect(toolbar.style.transform).toBe("translate(117px, 2px)");
fireEvent.pointerMove(grab, {
pointerId: 11,
clientX: 500,
clientY: 500,
});
expect(toolbar.style.transform).toBe("translate(117px, 2px)");
fireEvent.pointerUp(grab, { pointerId: 11 });
expect(
JSON.parse(localStorage.getItem("termix-terminal-toolbar-position-v2")!),
).toEqual({ x: 117, y: 2 });
});
it("avoids ResizeObserver write churn and writes one real reclamp", async () => {
let resizeCallback: ResizeObserverCallback | undefined;
class ControlledResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeCallback = callback;
}
observe() {}
disconnect() {}
unobserve() {}
}
vi.stubGlobal("ResizeObserver", ControlledResizeObserver);
let hostWidth = 500;
const original = HTMLElement.prototype.getBoundingClientRect;
vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation(
function () {
if (this.hasAttribute("data-terminal-toolbar-host")) {
return DOMRect.fromRect({
x: 0,
y: 0,
width: hostWidth,
height: 300,
});
}
if (this.hasAttribute("data-terminal-toolbar-wide")) {
const match = this.style.transform.match(
/translate\(([-\d.]+)px, ([-\d.]+)px\)/,
);
return DOMRect.fromRect({
x: 125 + Number(match?.[1] ?? 0),
y: 242 + Number(match?.[2] ?? 0),
width: 250,
height: 50,
});
}
return original.call(this);
},
);
const writes = vi.spyOn(Storage.prototype, "setItem");
renderToolbar();
writes.mockClear();
act(() => {
resizeCallback?.([], {} as ResizeObserver);
resizeCallback?.([], {} as ResizeObserver);
});
expect(writes).not.toHaveBeenCalledWith(
"termix-terminal-toolbar-position-v2",
expect.any(String),
);
hostWidth = 200;
act(() => resizeCallback?.([], {} as ResizeObserver));
expect(writes).not.toHaveBeenCalledWith(
"termix-terminal-toolbar-position-v2",
expect.any(String),
);
expect(
writes.mock.calls.filter(
([key]) => key === "termix-terminal-toolbar-position-v2",
),
).toHaveLength(0);
});
it.each([undefined, true])(
"does not mount or run desktop side effects when mobile is %s",
(isMobile) => {
mobileApi.isMobile = isMobile;
localStorage.setItem("termix-terminal-toolbar-density", "expanded");
const writes = vi.spyOn(Storage.prototype, "setItem");
const addWindowListener = vi.spyOn(window, "addEventListener");
const resizeObserver = vi.fn();
vi.stubGlobal("ResizeObserver", resizeObserver);
const { container } = renderToolbar();
expect(
container.querySelector("[data-terminal-toolbar-host]"),
).not.toBeInTheDocument();
expect(writes).not.toHaveBeenCalled();
expect(resizeObserver).not.toHaveBeenCalled();
expect(addWindowListener).not.toHaveBeenCalledWith(
"resize",
expect.any(Function),
);
},
);
it("ends dragging on pointer cancellation and lost capture, and rejects touch", () => {
const { container } = renderToolbar();
const grab = screen.getByRole("button", { name: "Move toolbar" });
const toolbar = container.querySelector(
"[data-terminal-toolbar-wide]",
) as HTMLElement;
fireEvent.pointerDown(grab, {
pointerId: 2,
pointerType: "touch",
button: 0,
clientX: 10,
clientY: 10,
});
fireEvent.pointerMove(grab, { pointerId: 2, clientX: 50, clientY: 50 });
expect(toolbar.style.transform).toBe("translate(0px, 0px)");
fireEvent.pointerDown(grab, {
pointerId: 3,
button: 0,
clientX: 10,
clientY: 10,
});
fireEvent.pointerCancel(grab, { pointerId: 3 });
fireEvent.pointerMove(grab, { pointerId: 3, clientX: 50, clientY: 50 });
expect(toolbar.style.transform).toBe("translate(0px, 0px)");
fireEvent.pointerDown(grab, {
pointerId: 4,
button: 0,
clientX: 10,
clientY: 10,
});
fireEvent.lostPointerCapture(grab, { pointerId: 4 });
fireEvent.pointerMove(grab, { pointerId: 4, clientX: 50, clientY: 50 });
expect(toolbar.style.transform).toBe("translate(0px, 0px)");
});
it("ignores stale image completions after an instance change in Strict Mode", async () => {
let reject!: (error: Error) => void;
const pending = new Promise<void>((_, rejectPromise) => {
reject = rejectPromise;
});
const props = {
host,
isConnected: true,
isTmuxAttached: false,
onTmuxDetach: vi.fn(),
isImageUploading: false,
onUploadImage: vi.fn(),
onPasteImage: vi.fn(() => pending),
isFocused: true,
};
const { rerender } = render(
<StrictMode>
<TerminalToolbar {...props} />
</StrictMode>,
);
fireEvent.click(
screen.getByRole("button", {
name: "Paste image from clipboard (images only)",
}),
);
rerender(
<StrictMode>
<TerminalToolbar
{...props}
host={{ ...host, id: 8 } as unknown as Host}
/>
</StrictMode>,
);
reject(new Error("old instance"));
await Promise.resolve();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
describe("contributed toolbar actions", () => {
it("shows no contributed button and no stray separator when nothing contributed", () => {
const { container } = renderToolbar();
expect(screen.queryByRole("button", { name: "AI Assistant" })).toBeNull();
const separatorsWithout = container.querySelectorAll(".w-px").length;
cleanup();
slots.toolbar = [
{ actionId: "ai.openWithContext", titleKey: "ai.assistant" },
];
const withButton = renderToolbar().container;
expect(withButton.querySelectorAll(".w-px").length).toBe(
separatorsWithout + 1,
);
});
it("hands the action the terminal slot api", async () => {
slots.toolbar = [
{ actionId: "ai.openWithContext", titleKey: "ai.assistant" },
];
const slotApi = {
host: undefined,
getBufferText: vi.fn(() => "$ whoami\nroot"),
sessionId: () => null,
openSidePanel: vi.fn(),
runCommand: vi.fn(),
};
renderToolbar({ slotApi });
await userEvent.click(
screen.getByRole("button", { name: "AI Assistant" }),
);
expect(slots.invoked).toEqual([[slotApi]]);
});
it("hides the slot when the host has contributed actions disabled", () => {
slots.toolbar = [
{ actionId: "ai.openWithContext", titleKey: "ai.assistant" },
];
renderToolbar({ actionsEnabled: false });
expect(screen.queryByRole("button", { name: "AI Assistant" })).toBeNull();
});
});
});
@@ -0,0 +1,50 @@
import { describe, expect, it } from "vitest";
import { getAndroidHardwareKeySequence } from "../../../src/frontend/terminal/android-hardware-keyboard";
const key = (
value: string,
modifiers: Partial<KeyboardEvent> = {},
): Pick<
KeyboardEvent,
"key" | "ctrlKey" | "altKey" | "metaKey" | "shiftKey"
> => ({
key: value,
ctrlKey: false,
altKey: false,
metaKey: false,
shiftKey: false,
...modifiers,
});
describe("getAndroidHardwareKeySequence", () => {
it("maps cursor keys in normal and application modes", () => {
expect(
getAndroidHardwareKeySequence(key("ArrowUp"), false, undefined),
).toBe("\x1b[A");
expect(getAndroidHardwareKeySequence(key("ArrowUp"), true, undefined)).toBe(
"\x1bOA",
);
});
it("maps Delete and the default Backspace mode", () => {
expect(getAndroidHardwareKeySequence(key("Delete"), false, undefined)).toBe(
"\x1b[3~",
);
expect(
getAndroidHardwareKeySequence(key("Backspace"), false, undefined),
).toBe("\x7f");
});
it("leaves control-h Backspace and modified keys to existing handlers", () => {
expect(
getAndroidHardwareKeySequence(key("Backspace"), false, "control-h"),
).toBeNull();
expect(
getAndroidHardwareKeySequence(
key("ArrowLeft", { ctrlKey: true }),
false,
undefined,
),
).toBeNull();
});
});
@@ -0,0 +1,76 @@
import { afterEach, describe, expect, it } from "vitest";
import { Terminal } from "@xterm/xterm";
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
describe("Android IME composition", () => {
let terminal: Terminal | undefined;
let container: HTMLDivElement | undefined;
afterEach(() => {
terminal?.dispose();
container?.remove();
terminal = undefined;
container = undefined;
});
it("forwards a shorter Vietnamese replacement to the shell", async () => {
container = document.createElement("div");
document.body.appendChild(container);
terminal = new Terminal();
terminal.open(container);
const input: string[] = [];
terminal.onData((data) => input.push(data));
const textarea = terminal.textarea!;
textarea.value = "Hoar";
textarea.selectionStart = textarea.value.length;
textarea.selectionEnd = textarea.value.length;
textarea.dispatchEvent(new CompositionEvent("compositionstart"));
textarea.dispatchEvent(
new CompositionEvent("compositionupdate", { data: "Hoar" }),
);
await tick();
textarea.value = "Hỏa";
textarea.selectionStart = textarea.value.length;
textarea.selectionEnd = textarea.value.length;
textarea.dispatchEvent(
new CompositionEvent("compositionupdate", { data: "Hỏa" }),
);
await tick();
textarea.dispatchEvent(new CompositionEvent("compositionend"));
await tick();
expect(input.join("")).toBe("\x7f\x7f\x7fỏa");
});
it("coalesces rapid textarea changes without dropping or duplicating input", async () => {
container = document.createElement("div");
document.body.appendChild(container);
terminal = new Terminal();
terminal.open(container);
const input: string[] = [];
terminal.onData((data) => input.push(data));
const textarea = terminal.textarea!;
const type = (value: string) => {
textarea.dispatchEvent(
new KeyboardEvent("keydown", { keyCode: 229 } as KeyboardEventInit),
);
textarea.value = value;
textarea.selectionStart = value.length;
textarea.selectionEnd = value.length;
};
type("t");
type("te");
type("ter");
type("termix");
await tick();
expect(input.join("")).toBe("termix");
});
});
@@ -0,0 +1,32 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
fontSizeStorageKey,
readFontSize,
saveFontSize,
} from "../../../src/frontend/terminal/font-size-storage";
beforeEach(() => localStorage.clear());
describe("persistent terminal zoom", () => {
it("survives repeated mounts and option refreshes", () => {
const key = fontSizeStorageKey("host-sync-id");
saveFontSize(localStorage, key, 14, 18);
for (let mount = 0; mount < 3; mount++)
expect(readFontSize(localStorage, key, 14)).toBe(18);
});
it("invalidates an override when defaults change while unmounted", () => {
const key = fontSizeStorageKey(1);
saveFontSize(localStorage, key, 14, 18);
expect(readFontSize(localStorage, key, 16)).toBeNull();
expect(readFontSize(localStorage, key, 14)).toBeNull();
});
it("does not share storage for missing host identities", () => {
const key = fontSizeStorageKey(undefined);
saveFontSize(localStorage, key, 14, 18);
expect(readFontSize(localStorage, key, 14)).toBeNull();
expect(localStorage.length).toBe(0);
});
it("ignores legacy values without a known configured size", () => {
localStorage.setItem("legacy", "18");
expect(readFontSize(localStorage, "legacy", 14)).toBeNull();
});
});
@@ -0,0 +1,60 @@
import { afterEach, describe, expect, it } from "vitest";
import { Terminal } from "@xterm/xterm";
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
textarea.value = value;
textarea.selectionStart = value.length;
textarea.selectionEnd = value.length;
}
// iOS Safari/WKWebView reports keyCode 229 for ordinary software-keyboard
// input, not just true IME composition, so xterm routes typing through
// CompositionHelper.keydown -> _handleAnyTextareaChanges instead of the
// normal keypress path. That handler snapshots the textarea value on
// keydown, then diffs it against the value a setTimeout(0) later.
function dispatchIOSKeydown(textarea: HTMLTextAreaElement) {
textarea.dispatchEvent(
new KeyboardEvent("keydown", { keyCode: 229 } as KeyboardEventInit),
);
}
describe("iOS rapid typing (keyCode 229 outside composition)", () => {
let terminal: Terminal | undefined;
let container: HTMLDivElement | undefined;
afterEach(() => {
terminal?.dispose();
container?.remove();
terminal = undefined;
container = undefined;
});
it("forwards a mid-word autocorrect rewrite instead of dropping it", async () => {
container = document.createElement("div");
document.body.appendChild(container);
terminal = new Terminal();
terminal.open(container);
const input: string[] = [];
terminal.onData((data) => input.push(data));
const textarea = terminal.textarea!;
// keydown fires while the textarea still holds the pre-keystroke value;
// the browser (or, on iOS, autocorrect) mutates the value afterward.
// Autocorrect can rewrite characters earlier in the word, not just
// append at the cursor, so the old value is no longer a literal
// substring of the new one.
dispatchIOSKeydown(textarea);
setTextareaValue(textarea, "wrold");
await tick();
dispatchIOSKeydown(textarea);
setTextareaValue(textarea, "world");
await tick();
expect(input.join("")).toBe("wrold" + "orld");
});
});
@@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import type { Terminal } from "@xterm/xterm";
import { getTerminalBufferText } from "../../../src/frontend/terminal/terminal-buffer-text";
interface FakeLine {
text: string;
wrapped?: boolean;
}
/** Minimal stand-in for the parts of xterm this reads. */
function fakeTerminal(lines: FakeLine[], rows = lines.length): Terminal {
return {
rows,
buffer: {
active: {
baseY: Math.max(0, lines.length - rows),
getLine: (i: number) => {
const line = lines[i];
if (!line) return undefined;
return {
isWrapped: !!line.wrapped,
translateToString: () => line.text,
};
},
},
},
} as unknown as Terminal;
}
describe("getTerminalBufferText", () => {
it("returns an empty string for a missing terminal", () => {
expect(getTerminalBufferText(null)).toBe("");
expect(getTerminalBufferText(undefined)).toBe("");
});
it("joins buffer rows with newlines", () => {
const terminal = fakeTerminal([{ text: "$ whoami" }, { text: "root" }]);
expect(getTerminalBufferText(terminal)).toBe("$ whoami\nroot");
});
it("joins a wrapped row onto the line it continues", () => {
const terminal = fakeTerminal([
{ text: "$ echo start" },
{ text: "a-very-long-command " },
{ text: "that-wrapped", wrapped: true },
]);
expect(getTerminalBufferText(terminal)).toBe(
"$ echo start\na-very-long-command that-wrapped",
);
});
it("drops trailing blank lines", () => {
const terminal = fakeTerminal([
{ text: "output" },
{ text: "" },
{ text: "" },
]);
expect(getTerminalBufferText(terminal)).toBe("output");
});
it("keeps blank lines in the middle", () => {
const terminal = fakeTerminal([
{ text: "one" },
{ text: "" },
{ text: "two" },
]);
expect(getTerminalBufferText(terminal)).toBe("one\n\ntwo");
});
it("bounds the result to maxLines, keeping the tail", () => {
const lines = Array.from({ length: 50 }, (_, i) => ({ text: `line ${i}` }));
const terminal = fakeTerminal(lines, 50);
const result = getTerminalBufferText(terminal, 3);
expect(result).toBe("line 47\nline 48\nline 49");
});
it("returns an empty string for a non-positive maxLines", () => {
const terminal = fakeTerminal([{ text: "anything" }]);
expect(getTerminalBufferText(terminal, 0)).toBe("");
});
it("returns an empty string when the buffer throws", () => {
const disposed = {
rows: 1,
get buffer(): never {
throw new Error("terminal disposed");
},
} as unknown as Terminal;
expect(getTerminalBufferText(disposed)).toBe("");
});
});
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import {
resolveTerminalContextMenuAction,
selectedTextToCopy,
} from "../../../src/frontend/terminal/terminal-clipboard-actions";
describe("terminal clipboard actions", () => {
it("copies a completed left-button selection when enabled", () => {
expect(
selectedTextToCopy({
copyOnSelect: true,
button: 0,
selection: "selected output",
}),
).toBe("selected output");
expect(
selectedTextToCopy({ copyOnSelect: false, button: 0, selection: "x" }),
).toBeNull();
});
it("pastes on right-click after copy-on-select", () => {
expect(
resolveTerminalContextMenuAction({
rightClickCopyPaste: true,
copyOnSelect: true,
hasSelection: true,
}),
).toBe("paste");
});
it("preserves existing right-click and native-menu behavior", () => {
expect(
resolveTerminalContextMenuAction({
rightClickCopyPaste: true,
copyOnSelect: false,
hasSelection: true,
}),
).toBe("copy");
expect(
resolveTerminalContextMenuAction({
rightClickCopyPaste: false,
copyOnSelect: true,
hasSelection: true,
}),
).toBe("native");
});
});
@@ -0,0 +1,332 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Terminal } from "@xterm/xterm";
const getCookieMock = vi.fn<(name: string) => string | undefined>();
vi.mock("@termix/plugin-sdk/ui", () => ({
getCookie: (name: string) => getCookieMock(name),
}));
import {
getUseRightClickCopyPaste,
handleTerminalClipboardKeyEvent,
createTerminalContextMenuHandler,
} from "../../../src/frontend/terminal/terminal-clipboard";
function createFakeTerminal({ selection = "" }: { selection?: string } = {}) {
return {
hasSelection: vi.fn(() => selection.length > 0),
getSelection: vi.fn(() => selection),
clearSelection: vi.fn(),
paste: vi.fn(),
};
}
function createActions() {
return {
writeTextToClipboard: vi.fn().mockResolvedValue(true),
readTextFromClipboard: vi.fn().mockResolvedValue("clipboard-text"),
};
}
function createKeyEvent(init: {
ctrlKey?: boolean;
shiftKey?: boolean;
altKey?: boolean;
metaKey?: boolean;
key?: string;
code?: string;
}) {
return {
type: "keydown",
ctrlKey: false,
shiftKey: false,
altKey: false,
metaKey: false,
key: "",
code: "",
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
...init,
} as unknown as KeyboardEvent;
}
function createMouseEvent(init: { ctrlKey?: boolean } = {}) {
return {
ctrlKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
...init,
} as unknown as MouseEvent;
}
beforeEach(() => {
getCookieMock.mockReset();
getCookieMock.mockReturnValue(undefined);
});
describe("getUseRightClickCopyPaste", () => {
it("defaults to enabled when the cookie is unset", () => {
getCookieMock.mockReturnValue(undefined);
expect(getUseRightClickCopyPaste()).toBe(true);
});
it('is disabled only when the cookie is explicitly "false"', () => {
getCookieMock.mockReturnValue("false");
expect(getUseRightClickCopyPaste()).toBe(false);
getCookieMock.mockReturnValue("true");
expect(getUseRightClickCopyPaste()).toBe(true);
});
});
describe("handleTerminalClipboardKeyEvent", () => {
it("copies the selection on Ctrl+C and clears it", () => {
const terminal = createFakeTerminal({ selection: "hello" });
const actions = createActions();
const e = createKeyEvent({ ctrlKey: true, code: "KeyC", key: "c" });
const handled = handleTerminalClipboardKeyEvent(
e,
terminal as unknown as Terminal,
actions,
);
expect(handled).toBe(false);
expect(e.preventDefault).toHaveBeenCalled();
expect(e.stopPropagation).toHaveBeenCalled();
expect(actions.writeTextToClipboard).toHaveBeenCalledWith("hello");
expect(terminal.clearSelection).toHaveBeenCalled();
});
it("leaves Ctrl+C alone (SIGINT) when there is no selection", () => {
const terminal = createFakeTerminal({ selection: "" });
const actions = createActions();
const e = createKeyEvent({ ctrlKey: true, code: "KeyC", key: "c" });
const handled = handleTerminalClipboardKeyEvent(
e,
terminal as unknown as Terminal,
actions,
);
expect(handled).toBe(true);
expect(e.preventDefault).not.toHaveBeenCalled();
expect(actions.writeTextToClipboard).not.toHaveBeenCalled();
});
it("copies via Ctrl+Insert without clearing the selection", () => {
const terminal = createFakeTerminal({ selection: "hello" });
const actions = createActions();
const e = createKeyEvent({ ctrlKey: true, key: "Insert" });
const handled = handleTerminalClipboardKeyEvent(
e,
terminal as unknown as Terminal,
actions,
);
expect(handled).toBe(false);
expect(actions.writeTextToClipboard).toHaveBeenCalledWith("hello");
expect(terminal.clearSelection).not.toHaveBeenCalled();
});
it("copies and clears the selection on Ctrl+Shift+C", () => {
const terminal = createFakeTerminal({ selection: "hello" });
const actions = createActions();
const e = createKeyEvent({
ctrlKey: true,
shiftKey: true,
code: "KeyC",
key: "C",
});
const handled = handleTerminalClipboardKeyEvent(
e,
terminal as unknown as Terminal,
actions,
);
expect(handled).toBe(false);
expect(actions.writeTextToClipboard).toHaveBeenCalledWith("hello");
expect(terminal.clearSelection).toHaveBeenCalled();
});
it("is a no-op for Ctrl+Shift+C without a selection", () => {
const terminal = createFakeTerminal({ selection: "" });
const actions = createActions();
const e = createKeyEvent({
ctrlKey: true,
shiftKey: true,
code: "KeyC",
key: "C",
});
const handled = handleTerminalClipboardKeyEvent(
e,
terminal as unknown as Terminal,
actions,
);
expect(handled).toBe(true);
expect(actions.writeTextToClipboard).not.toHaveBeenCalled();
});
it("always pastes on Ctrl+Shift+V regardless of selection", async () => {
const terminal = createFakeTerminal({ selection: "irrelevant" });
const actions = createActions();
const e = createKeyEvent({
ctrlKey: true,
shiftKey: true,
code: "KeyV",
key: "V",
});
const handled = handleTerminalClipboardKeyEvent(
e,
terminal as unknown as Terminal,
actions,
);
await Promise.resolve();
expect(handled).toBe(false);
expect(e.preventDefault).toHaveBeenCalled();
expect(actions.readTextFromClipboard).toHaveBeenCalled();
expect(terminal.paste).toHaveBeenCalledWith("clipboard-text");
});
it("leaves plain Ctrl+V to the browser's native paste event by default", () => {
const terminal = createFakeTerminal();
const actions = createActions();
const e = createKeyEvent({ ctrlKey: true, code: "KeyV", key: "v" });
const handled = handleTerminalClipboardKeyEvent(
e,
terminal as unknown as Terminal,
actions,
);
expect(handled).toBe(false);
expect(e.preventDefault).not.toHaveBeenCalled();
expect(e.stopPropagation).not.toHaveBeenCalled();
expect(actions.readTextFromClipboard).not.toHaveBeenCalled();
});
it("reads the clipboard explicitly for plain Ctrl+V when configured", async () => {
const terminal = createFakeTerminal();
const actions = createActions();
const e = createKeyEvent({ ctrlKey: true, code: "KeyV", key: "v" });
const handled = handleTerminalClipboardKeyEvent(
e,
terminal as unknown as Terminal,
actions,
{ plainPasteMode: "explicit" },
);
await Promise.resolve();
expect(handled).toBe(false);
expect(e.preventDefault).toHaveBeenCalled();
expect(actions.readTextFromClipboard).toHaveBeenCalled();
expect(terminal.paste).toHaveBeenCalledWith("clipboard-text");
});
it("ignores keys with no clipboard shortcut", () => {
const terminal = createFakeTerminal();
const actions = createActions();
const e = createKeyEvent({ key: "a", code: "KeyA" });
const handled = handleTerminalClipboardKeyEvent(
e,
terminal as unknown as Terminal,
actions,
);
expect(handled).toBe(true);
expect(actions.writeTextToClipboard).not.toHaveBeenCalled();
expect(actions.readTextFromClipboard).not.toHaveBeenCalled();
});
});
describe("createTerminalContextMenuHandler", () => {
it("routes Ctrl+right-click to the passthrough callback instead of the clipboard", () => {
const terminal = createFakeTerminal({ selection: "hello" });
const actions = createActions();
const onCtrlClick = vi.fn();
const handler = createTerminalContextMenuHandler(
terminal as unknown as Terminal,
actions,
{ onCtrlClick },
);
const e = createMouseEvent({ ctrlKey: true });
handler(e);
expect(onCtrlClick).toHaveBeenCalledTimes(1);
expect(e.preventDefault).toHaveBeenCalled();
expect(actions.writeTextToClipboard).not.toHaveBeenCalled();
});
it("falls back to normal right-click behavior when Ctrl is held with no passthrough configured", () => {
const terminal = createFakeTerminal({ selection: "hello" });
const actions = createActions();
const handler = createTerminalContextMenuHandler(
terminal as unknown as Terminal,
actions,
);
const e = createMouseEvent({ ctrlKey: true });
handler(e);
expect(actions.writeTextToClipboard).toHaveBeenCalledWith("hello");
});
it("does nothing when the right-click preference is disabled", () => {
getCookieMock.mockReturnValue("false");
const terminal = createFakeTerminal({ selection: "hello" });
const actions = createActions();
const handler = createTerminalContextMenuHandler(
terminal as unknown as Terminal,
actions,
);
const e = createMouseEvent();
handler(e);
expect(e.preventDefault).not.toHaveBeenCalled();
expect(actions.writeTextToClipboard).not.toHaveBeenCalled();
});
it("copies the current selection when right-click copy/paste is enabled", async () => {
const terminal = createFakeTerminal({ selection: "hello" });
const actions = createActions();
const handler = createTerminalContextMenuHandler(
terminal as unknown as Terminal,
actions,
);
const e = createMouseEvent();
handler(e);
await Promise.resolve();
expect(e.preventDefault).toHaveBeenCalled();
expect(actions.writeTextToClipboard).toHaveBeenCalledWith("hello");
expect(terminal.clearSelection).toHaveBeenCalled();
});
it("pastes when there is no selection", async () => {
const terminal = createFakeTerminal({ selection: "" });
const actions = createActions();
const handler = createTerminalContextMenuHandler(
terminal as unknown as Terminal,
actions,
);
const e = createMouseEvent();
handler(e);
await Promise.resolve();
expect(actions.readTextFromClipboard).toHaveBeenCalled();
expect(terminal.paste).toHaveBeenCalledWith("clipboard-text");
});
});
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { quoteTerminalImagePath } from "../../../src/frontend/terminal/terminal-image-path";
describe("quoteTerminalImagePath", () => {
it("preserves spaces as one shell argument", () => {
expect(quoteTerminalImagePath("/mnt/my images/clipboard.png")).toBe(
"'/mnt/my images/clipboard.png'",
);
});
it("does not expose shell metacharacters as syntax", () => {
expect(
quoteTerminalImagePath("/tmp/$(touch /tmp/pwned);`id`; 'quoted'.png"),
).toBe("'/tmp/$(touch /tmp/pwned);`id`; '\\''quoted'\\''.png'");
});
it("quotes an empty path instead of returning executable input", () => {
expect(quoteTerminalImagePath("")).toBe("''");
});
});
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { buildImageUploadFormData } from "../../../src/frontend/terminal/terminal-image-upload";
describe("buildImageUploadFormData", () => {
it("tags a picked-file upload with source=file and a client timestamp", () => {
const file = new File(["png-bytes"], "photo.png", { type: "image/png" });
const form = buildImageUploadFormData(file, "tab-1", "file");
expect(form.get("image")).toBe(file);
expect(form.get("instanceId")).toBe("tab-1");
expect(form.get("source")).toBe("file");
const timestamp = form.get("clientUploadTimestamp");
expect(typeof timestamp).toBe("string");
expect(Number.isNaN(Date.parse(timestamp as string))).toBe(false);
});
it("tags a clipboard upload with source=clipboard", () => {
const file = new File(["png-bytes"], "clipboard-image.png", {
type: "image/png",
});
const form = buildImageUploadFormData(file, "tab-1", "clipboard");
expect(form.get("source")).toBe("clipboard");
});
it("uses the caller-provided client timestamp unchanged", () => {
const file = new File(["png-bytes"], "photo.png", { type: "image/png" });
const form = buildImageUploadFormData(
file,
"tab-1",
"file",
"2026-08-15T12:00:00.000Z",
);
expect(form.get("clientUploadTimestamp")).toBe("2026-08-15T12:00:00.000Z");
});
});
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import {
isPhysicalShortcutKey,
isTabKeyEvent,
} from "../../../src/frontend/terminal/terminal-key-event";
describe("isTabKeyEvent", () => {
it.each([
["key", new KeyboardEvent("keydown", { key: "Tab" })],
["code", new KeyboardEvent("keydown", { code: "Tab" })],
[
"legacy keyCode",
new KeyboardEvent("keydown", { keyCode: 9 } as KeyboardEventInit),
],
])("recognizes Tab from %s", (_source, event) => {
expect(isTabKeyEvent(event)).toBe(true);
});
it("does not treat another key as Tab", () => {
expect(isTabKeyEvent(new KeyboardEvent("keydown", { key: "Enter" }))).toBe(
false,
);
});
});
describe("isPhysicalShortcutKey", () => {
it("matches copy and paste by physical code under a Cyrillic layout", () => {
expect(isPhysicalShortcutKey({ key: "с", code: "KeyC" }, "KeyC", "c")).toBe(
true,
);
expect(isPhysicalShortcutKey({ key: "м", code: "KeyV" }, "KeyV", "v")).toBe(
true,
);
});
it("does not confuse another physical key with a translated character", () => {
expect(isPhysicalShortcutKey({ key: "c", code: "KeyV" }, "KeyC", "c")).toBe(
false,
);
});
it("falls back to key when code is unavailable", () => {
expect(isPhysicalShortcutKey({ key: "C", code: "" }, "KeyC", "c")).toBe(
true,
);
});
});
@@ -0,0 +1,417 @@
import { describe, expect, it, vi } from "vitest";
import {
installTouchWheelCoordinator,
TouchWheelCoordinator,
} from "../../../src/frontend/terminal/touch-wheel-coordinator";
import { TOUCH_INPUT_DEFAULTS } from "../../../src/shared/touch-input-settings";
const point = (clientY: number, clientX = 10) => ({ clientX, clientY });
const touchEvent = (type: string, clientYValues: number[]) => {
const event = new Event(type, { bubbles: true, cancelable: true });
const touches = clientYValues.map((clientY) => ({ clientX: 10, clientY }));
Object.defineProperty(event, "touches", {
value: Object.assign(touches, {
item: (index: number) => touches[index] ?? null,
}),
});
return event;
};
const createTiming = (momentumAllowed = true) => {
let now = 0;
let nextHandle = 1;
const callbacks = new Map<number, FrameRequestCallback>();
return {
timing: {
now: () => now,
requestFrame: (callback: FrameRequestCallback) => {
const handle = nextHandle++;
callbacks.set(handle, callback);
return handle;
},
cancelFrame: (handle: number) => callbacks.delete(handle),
momentumAllowed: () => momentumAllowed,
},
advance(milliseconds: number) {
now += milliseconds;
const pending = [...callbacks.values()];
callbacks.clear();
pending.forEach((callback) => callback(now));
},
pendingFrames: () => callbacks.size,
};
};
describe("TouchWheelCoordinator", () => {
it("consumes configured drag and wheel limits", () => {
const emit = vi.fn();
const coordinator = new TouchWheelCoordinator(emit, 2, 3, undefined, {
...TOUCH_INPUT_DEFAULTS,
dragThresholdPx: 2,
maxWheelDeltaPx: 3,
});
coordinator.start(point(20));
coordinator.move(point(10));
expect(emit.mock.calls.map(([event]) => event.deltaY)).toEqual([
3, 3, 3, 1,
]);
});
it("accumulates the drag threshold and preserves delta direction", () => {
const emit = vi.fn();
const coordinator = new TouchWheelCoordinator(emit);
coordinator.start(point(100));
expect(coordinator.move(point(97.5))).toBe(true);
expect(coordinator.move(point(93))).toBe(true);
coordinator.move(point(101));
expect(emit.mock.calls.map(([event]) => event.deltaY)).toEqual([7, -8]);
});
it("keeps movement below the tap threshold from scrolling", () => {
const emit = vi.fn();
const coordinator = new TouchWheelCoordinator(emit);
coordinator.start(point(100));
expect(coordinator.move(point(95))).toBe(true);
coordinator.end();
expect(emit).not.toHaveBeenCalled();
});
it("cancels the gesture when more than one touch is represented", () => {
const emit = vi.fn();
const coordinator = new TouchWheelCoordinator(emit);
coordinator.start(point(100));
coordinator.move(undefined);
expect(coordinator.move(point(80))).toBe(false);
expect(emit).not.toHaveBeenCalled();
});
it("cleans up cancelled state before the next gesture", () => {
const emit = vi.fn();
const coordinator = new TouchWheelCoordinator(emit);
coordinator.start(point(100));
coordinator.cancel();
coordinator.start(point(50));
expect(coordinator.move(point(43.5))).toBe(true);
expect(emit).toHaveBeenCalledOnce();
expect(emit.mock.calls[0][0].deltaY).toBe(6.5);
});
it.each([
{ start: 100, end: 20, direction: 1 },
{ start: 20, end: 100, direction: -1 },
])(
"emits multiple $direction line ticks after release",
({ start, end, direction }) => {
const emit = vi.fn();
const clock = createTiming();
const coordinator = new TouchWheelCoordinator(
emit,
undefined,
undefined,
clock.timing,
);
coordinator.start(point(start));
clock.advance(20);
coordinator.move(point((start + end) / 2));
clock.advance(20);
coordinator.move(point(end));
coordinator.end();
const dragCallCount = emit.mock.calls.length;
for (let index = 0; index < 12; index++) clock.advance(16);
const momentum = emit.mock.calls
.slice(dragCallCount)
.map(([event]) => event);
expect(momentum.length).toBeGreaterThan(3);
expect(
momentum.every(
(event) =>
event.deltaMode === WheelEvent.DOM_DELTA_LINE &&
event.deltaY === direction,
),
).toBe(true);
},
);
it("decays the number of line ticks emitted over time", () => {
const emit = vi.fn();
const clock = createTiming();
const coordinator = new TouchWheelCoordinator(
emit,
undefined,
undefined,
clock.timing,
);
coordinator.start(point(200));
clock.advance(20);
coordinator.move(point(100));
coordinator.end();
const dragCalls = emit.mock.calls.length;
const frameCounts: number[] = [];
for (let index = 0; index < 24; index++) {
const before = emit.mock.calls.length;
clock.advance(16);
frameCounts.push(emit.mock.calls.length - before);
}
expect(emit.mock.calls.length - dragCalls).toBeGreaterThan(3);
expect(frameCounts.slice(0, 8).reduce((a, b) => a + b, 0)).toBeGreaterThan(
frameCounts.slice(-8).reduce((a, b) => a + b, 0),
);
});
it("uses the recent movement window despite a short touchend delay", () => {
const emit = vi.fn();
const clock = createTiming();
const coordinator = new TouchWheelCoordinator(
emit,
undefined,
undefined,
clock.timing,
);
coordinator.start(point(100));
clock.advance(20);
coordinator.move(point(50));
clock.advance(80);
coordinator.end();
expect(clock.pendingFrames()).toBe(1);
});
it("bounds momentum velocity, duration, and total travel", () => {
const emit = vi.fn();
const clock = createTiming();
const coordinator = new TouchWheelCoordinator(
emit,
undefined,
undefined,
clock.timing,
);
coordinator.start(point(1000));
clock.advance(1);
coordinator.move(point(0));
coordinator.end();
const dragCallCount = emit.mock.calls.length;
clock.advance(200);
const firstFrameTicks = emit.mock.calls.length - dragCallCount;
for (let index = 0; index < 30; index++) clock.advance(20);
expect(clock.pendingFrames()).toBe(1);
for (let index = 0; index < 10; index++) clock.advance(20);
const totalTicks = emit.mock.calls.length - dragCallCount;
expect(firstFrameTicks).toBeLessThanOrEqual(4);
expect(totalTicks).toBeLessThanOrEqual(Math.floor(720 / 12));
expect(clock.pendingFrames()).toBe(0);
});
it("cancels momentum on a new touch or cancellation", () => {
const emit = vi.fn();
const clock = createTiming();
const coordinator = new TouchWheelCoordinator(
emit,
undefined,
undefined,
clock.timing,
);
coordinator.start(point(100));
clock.advance(20);
coordinator.move(point(50));
coordinator.end();
expect(clock.pendingFrames()).toBe(1);
coordinator.start(point(60));
expect(clock.pendingFrames()).toBe(0);
clock.advance(20);
coordinator.move(point(20));
coordinator.end();
expect(clock.pendingFrames()).toBe(1);
coordinator.cancel();
expect(clock.pendingFrames()).toBe(0);
});
it("disables momentum when reduced motion is requested", () => {
const emit = vi.fn();
const clock = createTiming(false);
const coordinator = new TouchWheelCoordinator(
emit,
undefined,
undefined,
clock.timing,
);
coordinator.start(point(100));
clock.advance(20);
coordinator.move(point(50));
coordinator.end();
expect(clock.pendingFrames()).toBe(0);
expect(emit).toHaveBeenCalledOnce();
});
it("stops momentum if the active terminal mode changes", () => {
const emit = vi.fn();
let momentumAllowed = true;
const clock = createTiming();
const coordinator = new TouchWheelCoordinator(emit, undefined, undefined, {
...clock.timing,
momentumAllowed: () => momentumAllowed,
});
coordinator.start(point(100));
clock.advance(20);
coordinator.move(point(50));
coordinator.end();
momentumAllowed = false;
clock.advance(20);
expect(emit).toHaveBeenCalledOnce();
expect(clock.pendingFrames()).toBe(0);
});
it("retains fractional pixel distance until it forms a whole line tick", () => {
const emit = vi.fn();
const clock = createTiming();
const coordinator = new TouchWheelCoordinator(
emit,
undefined,
undefined,
clock.timing,
);
coordinator.start(point(100));
clock.advance(100);
coordinator.move(point(80));
coordinator.end();
const dragCallCount = emit.mock.calls.length;
for (let index = 0; index < 4; index++) clock.advance(16);
expect(emit.mock.calls.length).toBe(dragCallCount);
clock.advance(16);
expect(emit.mock.calls.length).toBeGreaterThan(dragCallCount);
expect(emit.mock.calls[dragCallCount][0]).toMatchObject({
deltaY: 1,
deltaMode: WheelEvent.DOM_DELTA_LINE,
});
});
});
describe("installTouchWheelCoordinator", () => {
it("claims the first single-touch move but preserves taps and multi-touch", () => {
const terminalElement = document.createElement("div");
const scrollableElement = document.createElement("div");
scrollableElement.className = "xterm-scrollable-element";
const screenElement = document.createElement("div");
screenElement.className = "xterm-screen";
scrollableElement.append(screenElement);
terminalElement.append(scrollableElement);
const dispose = installTouchWheelCoordinator(terminalElement);
terminalElement.dispatchEvent(touchEvent("touchstart", [100]));
const earlyMove = touchEvent("touchmove", [99]);
terminalElement.dispatchEvent(earlyMove);
expect(earlyMove.defaultPrevented).toBe(true);
terminalElement.dispatchEvent(touchEvent("touchend", []));
const tapEnd = touchEvent("touchend", []);
terminalElement.dispatchEvent(tapEnd);
expect(tapEnd.defaultPrevented).toBe(false);
terminalElement.dispatchEvent(touchEvent("touchstart", [100]));
const multiTouchMove = touchEvent("touchmove", [90, 80]);
terminalElement.dispatchEvent(multiTouchMove);
expect(multiTouchMove.defaultPrevented).toBe(false);
dispose();
});
it("dispatches wheel events at xterm's normal scrollback seam", () => {
const terminalElement = document.createElement("div");
const scrollableElement = document.createElement("div");
scrollableElement.className = "xterm-scrollable-element";
const screenElement = document.createElement("div");
const canvas = document.createElement("canvas");
screenElement.className = "xterm-screen";
screenElement.append(canvas);
scrollableElement.append(screenElement);
terminalElement.append(scrollableElement);
const scrollableWheel = vi.fn();
const canvasWheel = vi.fn();
scrollableElement.addEventListener("wheel", scrollableWheel);
canvas.addEventListener("wheel", canvasWheel);
const dispose = installTouchWheelCoordinator(terminalElement);
canvas.dispatchEvent(touchEvent("touchstart", [100]));
canvas.dispatchEvent(touchEvent("touchmove", [90]));
expect(scrollableWheel).toHaveBeenCalledOnce();
expect(canvasWheel).not.toHaveBeenCalled();
expect((scrollableWheel.mock.calls[0][0] as WheelEvent).deltaY).toBe(10);
expect((scrollableWheel.mock.calls[0][0] as WheelEvent).deltaMode).toBe(
WheelEvent.DOM_DELTA_PIXEL,
);
dispose();
});
it("dispatches momentum as line-mode wheel ticks from the scrollback seam", () => {
let now = 0;
let nextHandle = 1;
const callbacks = new Map<number, FrameRequestCallback>();
vi.spyOn(performance, "now").mockImplementation(() => now);
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
const handle = nextHandle++;
callbacks.set(handle, callback);
return handle;
});
vi.stubGlobal("cancelAnimationFrame", (handle: number) => {
callbacks.delete(handle);
});
const terminalElement = document.createElement("div");
const scrollableElement = document.createElement("div");
scrollableElement.className = "xterm-scrollable-element";
const screenElement = document.createElement("div");
const canvas = document.createElement("canvas");
screenElement.className = "xterm-screen";
screenElement.append(canvas);
scrollableElement.append(screenElement);
terminalElement.append(scrollableElement);
const wheels: WheelEvent[] = [];
scrollableElement.addEventListener("wheel", (event) =>
wheels.push(event as WheelEvent),
);
const dispose = installTouchWheelCoordinator(terminalElement);
canvas.dispatchEvent(touchEvent("touchstart", [100]));
now = 20;
canvas.dispatchEvent(touchEvent("touchmove", [40]));
canvas.dispatchEvent(touchEvent("touchend", []));
for (let index = 0; index < 8; index++) {
now += 16;
const pending = [...callbacks.values()];
callbacks.clear();
pending.forEach((callback) => callback(now));
}
const momentum = wheels.filter(
(event) => event.deltaMode === WheelEvent.DOM_DELTA_LINE,
);
expect(momentum.length).toBeGreaterThan(1);
expect(momentum.every((event) => event.deltaY === 1)).toBe(true);
expect(wheels.every((event) => event.currentTarget === null)).toBe(true);
dispose();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
});
@@ -0,0 +1,140 @@
import { Terminal } from "@xterm/xterm";
import { afterEach, describe, expect, it, vi } from "vitest";
import { installTouchWheelCoordinator } from "../../../src/frontend/terminal/touch-wheel-coordinator";
const touchEvent = (type: string, clientYValues: number[]) => {
const event = new Event(type, { bubbles: true, cancelable: true });
const touches = clientYValues.map((clientY) => ({ clientX: 10, clientY }));
Object.defineProperty(event, "touches", {
value: Object.assign(touches, {
item: (index: number) => touches[index] ?? null,
}),
});
return event;
};
const write = (terminal: Terminal, data: string) =>
new Promise<void>((resolve) => terminal.write(data, resolve));
describe("touch wheel coordinator with xterm", () => {
let terminal: Terminal | undefined;
let container: HTMLDivElement | undefined;
afterEach(() => {
terminal?.dispose();
container?.remove();
terminal = undefined;
container = undefined;
vi.unstubAllGlobals();
});
const installXtermCharacterMeasurements = () => {
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue({
clearRect: vi.fn(),
fillRect: vi.fn(),
fillStyle: "",
lineWidth: 1,
} as unknown as CanvasRenderingContext2D);
vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockImplementation(
function (this: HTMLElement) {
return this.classList.contains("xterm-char-measure-element")
? 32 * 9
: 0;
},
);
vi.spyOn(HTMLElement.prototype, "offsetHeight", "get").mockImplementation(
function (this: HTMLElement) {
return this.classList.contains("xterm-char-measure-element") ? 18 : 0;
},
);
};
it("moves real normal-buffer scrollback during a drag and momentum", async () => {
installXtermCharacterMeasurements();
let now = 0;
let nextHandle = 1;
const frames = new Map<number, FrameRequestCallback>();
vi.spyOn(performance, "now").mockImplementation(() => now);
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
const handle = nextHandle++;
frames.set(handle, callback);
return handle;
});
vi.stubGlobal("cancelAnimationFrame", (handle: number) => {
frames.delete(handle);
});
container = document.createElement("div");
document.body.appendChild(container);
terminal = new Terminal({ cols: 80, rows: 10, scrollback: 1_000 });
terminal.open(container);
await write(
terminal,
Array.from({ length: 500 }, (_, index) => `${index + 1}\r\n`).join(""),
);
const element = terminal.element!;
const dispose = installTouchWheelCoordinator(element);
terminal.scrollToBottom();
const bottom = terminal.buffer.active.viewportY;
element.dispatchEvent(touchEvent("touchstart", [20]));
now = 20;
element.dispatchEvent(touchEvent("touchmove", [100]));
const afterDrag = terminal.buffer.active.viewportY;
element.dispatchEvent(touchEvent("touchend", []));
for (let index = 0; index < 8; index++) {
now += 16;
const pending = [...frames.values()];
frames.clear();
pending.forEach((callback) => callback(now));
}
expect(bottom).toBeGreaterThan(400);
expect(afterDrag).toBeLessThan(bottom);
expect(terminal.buffer.active.viewportY).toBeLessThan(afterDrag);
dispose();
});
it("preserves alternate-buffer wheel line ticks", async () => {
installXtermCharacterMeasurements();
let now = 0;
let nextHandle = 1;
const frames = new Map<number, FrameRequestCallback>();
vi.spyOn(performance, "now").mockImplementation(() => now);
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
const handle = nextHandle++;
frames.set(handle, callback);
return handle;
});
vi.stubGlobal("cancelAnimationFrame", (handle: number) => {
frames.delete(handle);
});
container = document.createElement("div");
document.body.appendChild(container);
terminal = new Terminal({ cols: 80, rows: 10 });
terminal.open(container);
const data: string[] = [];
terminal.onData((value) => data.push(value));
await write(terminal, "\x1b[?1049h");
const element = terminal.element!;
const dispose = installTouchWheelCoordinator(element);
element.dispatchEvent(touchEvent("touchstart", [100]));
now = 20;
element.dispatchEvent(touchEvent("touchmove", [0]));
data.length = 0;
element.dispatchEvent(touchEvent("touchend", []));
for (let index = 0; index < 8; index++) {
now += 16;
const pending = [...frames.values()];
frames.clear();
pending.forEach((callback) => callback(now));
}
expect(terminal.buffer.active.type).toBe("alternate");
expect(data.length).toBeGreaterThan(1);
expect(data.every((value) => value === "\x1b[B")).toBe(true);
dispose();
});
});