mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: move the terminal fully into the ssh-terminal plugin (B9)
This commit is contained in:
1 parent
535126fd1b
commit
79d77678c9
309 files changed
+41789
-14958
No files matched your search
@@ -8,10 +8,11 @@ import {
|
||||
/**
|
||||
* Which tab a click on a host opens. The ways to connect (SSH terminal, RDP,
|
||||
* VNC, Telnet) are host actions that plugins register, so the answer is the
|
||||
* highest-priority connect action the host allows.
|
||||
* highest-priority connect action the host allows, or null when no running
|
||||
* plugin can connect to it.
|
||||
*/
|
||||
export function getDefaultConnectionTab(host: Host): TabType {
|
||||
return defaultConnectAction(listHostActions(), host)?.tabType ?? "terminal";
|
||||
export function getDefaultConnectionTab(host: Host): TabType | null {
|
||||
return defaultConnectAction(listHostActions(), host)?.tabType ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -22,7 +23,7 @@ export function getDefaultConnectionTab(host: Host): TabType {
|
||||
export function resolveHostTabType(
|
||||
host: Host,
|
||||
preferredType?: TabType,
|
||||
): TabType {
|
||||
): TabType | null {
|
||||
if (!preferredType) return getDefaultConnectionTab(host);
|
||||
const all = listHostActions();
|
||||
const isConnectType = all.some(
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
import type { Terminal } from "@xterm/xterm";
|
||||
import type { KeybindingAction } from "@/types/keybindings";
|
||||
import { invokeAction } from "@/shell/action-registry";
|
||||
|
||||
export interface SnippetHostContext {
|
||||
ip?: string;
|
||||
username?: string;
|
||||
port?: number | string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface KeybindingDispatchContext {
|
||||
terminal: Terminal;
|
||||
webSocketRef: React.MutableRefObject<WebSocket | null>;
|
||||
writeTextToClipboard: (text: string) => Promise<boolean>;
|
||||
readTextFromClipboard: () => Promise<string>;
|
||||
/** Host context used to resolve $HOST/$USER/$PORT/$NAME in runSnippet actions. */
|
||||
hostContext?: SnippetHostContext | null;
|
||||
/**
|
||||
* Called instead of sending the snippet directly when its content still has
|
||||
* unresolved $INPUT_n placeholders after host-variable substitution -- the
|
||||
* caller is expected to collect values (e.g. via a dialog) and send itself.
|
||||
*/
|
||||
onSnippetNeedsInputs?: (snippet: { id: string; content: string }) => void;
|
||||
}
|
||||
|
||||
export function sendRawToSocket(
|
||||
webSocketRef: React.MutableRefObject<WebSocket | null>,
|
||||
data: string,
|
||||
): void {
|
||||
if (webSocketRef.current?.readyState === 1) {
|
||||
webSocketRef.current.send(JSON.stringify({ type: "input", data }));
|
||||
}
|
||||
}
|
||||
|
||||
export function dispatchKeybindingAction(
|
||||
action: KeybindingAction,
|
||||
ctx: KeybindingDispatchContext,
|
||||
): void {
|
||||
const sendRaw = (data: string) => sendRawToSocket(ctx.webSocketRef, data);
|
||||
|
||||
switch (action.type) {
|
||||
case "copy": {
|
||||
const selection = ctx.terminal.getSelection();
|
||||
if (selection) {
|
||||
ctx.writeTextToClipboard(selection);
|
||||
ctx.terminal.clearSelection();
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "paste": {
|
||||
ctx.readTextFromClipboard().then((text) => {
|
||||
if (text) ctx.terminal.paste(text);
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "sendControlCode": {
|
||||
if (!action.controlCode) return;
|
||||
const code = action.controlCode.toLowerCase().charCodeAt(0) - 96;
|
||||
if (code >= 1 && code <= 26) sendRaw(String.fromCharCode(code));
|
||||
return;
|
||||
}
|
||||
case "sendText": {
|
||||
sendRaw((action.text ?? "") + (action.appendEnter ? "\r" : ""));
|
||||
return;
|
||||
}
|
||||
case "runSnippet": {
|
||||
if (!action.snippetId) return;
|
||||
const snippetId = Number(action.snippetId);
|
||||
if (!Number.isFinite(snippetId)) return;
|
||||
// Resolving with no inputValues first tells us whether the snippet
|
||||
// still needs them; the plugin decides and reports back.
|
||||
void invokeAction(
|
||||
"snippets.resolveForTerminal",
|
||||
snippetId,
|
||||
ctx.hostContext ?? null,
|
||||
).then((result) => {
|
||||
const resolved = result as
|
||||
{ needsInputs: boolean; content: string } | null | undefined;
|
||||
if (!resolved) return;
|
||||
if (resolved.needsInputs) {
|
||||
ctx.onSnippetNeedsInputs?.({
|
||||
id: action.snippetId!,
|
||||
content: resolved.content,
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendRaw(resolved.content + (action.appendEnter !== false ? "\r" : ""));
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "nextTab":
|
||||
case "previousTab":
|
||||
case "openCommandPalette": {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("termix:global-keybinding", {
|
||||
detail: { type: action.type },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { isMacPlatform } from "@/lib/tab-jump-hotkey";
|
||||
|
||||
export function getMacLineNavigationSequence(e: KeyboardEvent): string | null {
|
||||
if (e.type !== "keydown" || !isMacPlatform()) return null;
|
||||
if (!e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return null;
|
||||
|
||||
switch (e.code) {
|
||||
case "ArrowLeft":
|
||||
return "\x01";
|
||||
case "ArrowRight":
|
||||
return "\x05";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,23 @@ export function createPluginApi(pluginId: string): AxiosInstance {
|
||||
}) as AxiosInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* pluginWsUrl for a path a backend handed out ("/plugin-ws/<id>/<path>?q"),
|
||||
* so a caller can dial a plugin's socket without naming the plugin.
|
||||
*/
|
||||
export async function pluginWsUrlForPath(
|
||||
wsPath: string,
|
||||
options: { origin?: ConnectionOrigin } = {},
|
||||
): Promise<WebSocketConnectionTarget | null> {
|
||||
const [pathname, query] = wsPath.split("?", 2);
|
||||
const match = /^\/plugin-ws\/([^/]+)(\/.*)$/.exec(pathname);
|
||||
if (!match) return null;
|
||||
const target = await pluginWsUrl(match[1], match[2], options);
|
||||
if (!target || !query) return target;
|
||||
const separator = target.url.includes("?") ? "&" : "?";
|
||||
return { ...target, url: `${target.url}${separator}${query}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* The WebSocket URL for /plugin-ws/<id>/<path>, plus the subprotocols that
|
||||
* carry the JWT.
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TerminalLocalEcho, resolveLocalEchoMode } from "./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");
|
||||
});
|
||||
});
|
||||
@@ -1,100 +0,0 @@
|
||||
export type LocalEchoMode = "off" | "auto" | "on";
|
||||
|
||||
const PASSWORD_PROMPT =
|
||||
/(?:password|passphrase|verification code|one[- ]time|otp|token)(?:[^\r\n::]{0,160})?[::]\s*$/i;
|
||||
const SAFE_INPUT = /^[\x20-\x7e]$/;
|
||||
|
||||
type PendingCharacter = { value: string; sentAt: number; predicted: boolean };
|
||||
|
||||
export class TerminalLocalEcho {
|
||||
private pending: PendingCharacter[] = [];
|
||||
private slowSamples = 0;
|
||||
private secretInput = false;
|
||||
private outputTail = "";
|
||||
|
||||
constructor(
|
||||
private readonly mode: LocalEchoMode,
|
||||
private readonly now: () => number = () => performance.now(),
|
||||
private readonly latencyThreshold = 120,
|
||||
) {}
|
||||
|
||||
handleInput(data: string): string {
|
||||
if (data === "\r" || data === "\n") {
|
||||
return "";
|
||||
}
|
||||
if (this.secretInput || !SAFE_INPUT.test(data)) return "";
|
||||
|
||||
const predicted =
|
||||
this.mode === "on" || (this.mode === "auto" && this.slowSamples >= 2);
|
||||
this.pending.push({ value: data, sentAt: this.now(), predicted });
|
||||
return predicted ? data : "";
|
||||
}
|
||||
|
||||
handleOutput(data: string): string {
|
||||
const plainOutput = this.stripAnsi(data);
|
||||
this.outputTail = (this.outputTail + plainOutput).slice(-128);
|
||||
if (PASSWORD_PROMPT.test(this.outputTail)) {
|
||||
const rollback = this.rollback();
|
||||
this.secretInput = true;
|
||||
return rollback + data;
|
||||
}
|
||||
|
||||
if (this.secretInput) {
|
||||
if (data.includes("\n") || data.includes("\r")) this.secretInput = false;
|
||||
return data;
|
||||
}
|
||||
|
||||
if (this.pending.length === 0) return data;
|
||||
|
||||
let consumed = 0;
|
||||
while (
|
||||
consumed < data.length &&
|
||||
consumed < this.pending.length &&
|
||||
data[consumed] === this.pending[consumed].value
|
||||
) {
|
||||
const item = this.pending[consumed];
|
||||
if (
|
||||
!item.predicted &&
|
||||
this.now() - item.sentAt >= this.latencyThreshold
|
||||
) {
|
||||
this.slowSamples += 1;
|
||||
}
|
||||
consumed += 1;
|
||||
}
|
||||
|
||||
if (consumed > 0) {
|
||||
const matched = this.pending.splice(0, consumed);
|
||||
const predictedCount = matched.filter((item) => item.predicted).length;
|
||||
return data.slice(predictedCount);
|
||||
}
|
||||
|
||||
return this.rollback() + data;
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.pending = [];
|
||||
this.slowSamples = 0;
|
||||
this.secretInput = false;
|
||||
this.outputTail = "";
|
||||
}
|
||||
|
||||
private rollback() {
|
||||
const count = this.pending.filter((item) => item.predicted).length;
|
||||
this.pending = [];
|
||||
return count > 0 ? `\x1b[${count}D\x1b[${count}X` : "";
|
||||
}
|
||||
|
||||
private stripAnsi(value: string) {
|
||||
return value.replace(/\x1b(?:[@-Z\\-_]|\[[0-9:;<=>?!]*[@-~])/g, "");
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveLocalEchoMode(
|
||||
hostMode: "default" | LocalEchoMode | undefined,
|
||||
storedMode: string | null,
|
||||
): LocalEchoMode {
|
||||
if (hostMode && hostMode !== "default") return hostMode;
|
||||
return storedMode === "on" || storedMode === "off" || storedMode === "auto"
|
||||
? storedMode
|
||||
: "auto";
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
export const TERMINAL_FONT_ZOOM_MIN = 8;
|
||||
export const TERMINAL_FONT_ZOOM_MAX = 36;
|
||||
|
||||
type ZoomKeyEvent = Pick<
|
||||
KeyboardEvent,
|
||||
"altKey" | "code" | "ctrlKey" | "key" | "metaKey"
|
||||
>;
|
||||
|
||||
export function getTerminalFontZoomDirection(event: ZoomKeyEvent): -1 | 0 | 1 {
|
||||
if ((!event.ctrlKey && !event.metaKey) || event.altKey) return 0;
|
||||
|
||||
if (
|
||||
event.key === "+" ||
|
||||
event.code === "Equal" ||
|
||||
event.code === "NumpadAdd"
|
||||
) {
|
||||
return 1;
|
||||
}
|
||||
if (
|
||||
event.key === "-" ||
|
||||
event.code === "Minus" ||
|
||||
event.code === "NumpadSubtract"
|
||||
) {
|
||||
return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function getNextTerminalFontSize(
|
||||
currentFontSize: number,
|
||||
direction: -1 | 1,
|
||||
): number {
|
||||
return Math.min(
|
||||
TERMINAL_FONT_ZOOM_MAX,
|
||||
Math.max(TERMINAL_FONT_ZOOM_MIN, currentFontSize + direction),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
const style = document.createElement("style");
|
||||
style.innerHTML = `
|
||||
@font-face {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono';
|
||||
src: url('./fonts/CaskaydiaCoveNerdFontMono-Regular.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono';
|
||||
src: url('./fonts/CaskaydiaCoveNerdFontMono-Bold.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono';
|
||||
src: url('./fonts/CaskaydiaCoveNerdFontMono-Italic.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Caskaydia Cove Nerd Font Mono';
|
||||
src: url('./fonts/CaskaydiaCoveNerdFontMono-BoldItalic.ttf') format('truetype');
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
background: transparent;
|
||||
}
|
||||
.xterm .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: rgba(0,0,0,0.3);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.xterm .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0,0,0,0.5);
|
||||
}
|
||||
.xterm .xterm-viewport {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0,0,0,0.3) transparent;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.dark .xterm .xterm-viewport::-webkit-scrollbar-thumb {
|
||||
background: rgba(255,255,255,0.3);
|
||||
}
|
||||
.dark .xterm .xterm-viewport::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
.dark .xterm .xterm-viewport {
|
||||
scrollbar-color: rgba(255,255,255,0.3) transparent;
|
||||
}
|
||||
|
||||
.xterm {
|
||||
font-feature-settings: "liga" 0, "calt" 0;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen .xterm-char {
|
||||
font-feature-settings: "liga" 0, "calt" 0;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Canvas fillText() does not reliably trigger @font-face fetches on every
|
||||
// browser engine (notably Android WebView) the way rendering real DOM text
|
||||
// does. xterm.js draws glyphs to a <canvas>, so without an explicit load the
|
||||
// terminal can keep painting the fallback font's tofu boxes even after
|
||||
// document.fonts.ready resolves. Forcing the load here ensures the glyph
|
||||
// data is actually fetched before the terminal renders with it.
|
||||
export function ensureTerminalFontsLoaded(fontFamily: string): void {
|
||||
if (typeof document === "undefined" || !document.fonts) return;
|
||||
const specs = [
|
||||
`400 16px "${fontFamily}"`,
|
||||
`700 16px "${fontFamily}"`,
|
||||
`italic 400 16px "${fontFamily}"`,
|
||||
`italic 700 16px "${fontFamily}"`,
|
||||
];
|
||||
for (const spec of specs) {
|
||||
document.fonts.load(spec).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { TERMINAL_THEMES, type TerminalTheme } from "@/lib/terminal-themes.ts";
|
||||
import type { TerminalConfig } from "@/types/index.ts";
|
||||
|
||||
// Background/foreground per UI theme for "Termix Default" - must match index.css
|
||||
const TERMIX_DEFAULT_COLORS: Record<
|
||||
string,
|
||||
{ background: string; foreground: string }
|
||||
> = {
|
||||
dark: { background: "#0c0d0b", foreground: "#fafafa" },
|
||||
light: { background: "#ffffff", foreground: "#111210" },
|
||||
dracula: { background: "#282a36", foreground: "#f8f8f2" },
|
||||
catppuccin: { background: "#1e1e2e", foreground: "#cdd6f4" },
|
||||
nord: { background: "#2e3440", foreground: "#eceff4" },
|
||||
solarized: { background: "#002b36", foreground: "#839496" },
|
||||
"tokyo-night": { background: "#1a1b26", foreground: "#a9b1d6" },
|
||||
"one-dark": { background: "#282c34", foreground: "#abb2bf" },
|
||||
gruvbox: { background: "#282828", foreground: "#ebdbb2" },
|
||||
};
|
||||
|
||||
export function resolveTermixThemeColors(
|
||||
activeTheme: string,
|
||||
appTheme: string,
|
||||
customThemeColors?: TerminalConfig["customThemeColors"],
|
||||
): TerminalTheme["colors"] {
|
||||
if (activeTheme === "custom") {
|
||||
if (customThemeColors) {
|
||||
return customThemeColors;
|
||||
}
|
||||
return TERMINAL_THEMES.termixDark.colors;
|
||||
}
|
||||
if (activeTheme !== "termix") {
|
||||
return (
|
||||
TERMINAL_THEMES[activeTheme]?.colors || TERMINAL_THEMES.termixDark.colors
|
||||
);
|
||||
}
|
||||
let resolvedUiTheme = appTheme;
|
||||
if (appTheme === "system") {
|
||||
resolvedUiTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
const uiColors =
|
||||
TERMIX_DEFAULT_COLORS[resolvedUiTheme] ?? TERMIX_DEFAULT_COLORS.dark;
|
||||
const base = TERMINAL_THEMES.termixDark.colors;
|
||||
return {
|
||||
...base,
|
||||
background: uiColors.background,
|
||||
foreground: uiColors.foreground,
|
||||
cursor: uiColors.foreground,
|
||||
cursorAccent: uiColors.background,
|
||||
};
|
||||
}
|
||||
@@ -1,507 +0,0 @@
|
||||
const ANSI = {
|
||||
reset: "\x1b[0m",
|
||||
red: "\x1b[31m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
magenta: "\x1b[35m",
|
||||
cyan: "\x1b[36m",
|
||||
brightBlack: "\x1b[90m",
|
||||
brightRed: "\x1b[91m",
|
||||
brightGreen: "\x1b[92m",
|
||||
brightYellow: "\x1b[93m",
|
||||
brightBlue: "\x1b[94m",
|
||||
brightCyan: "\x1b[96m",
|
||||
bold: "\x1b[1m",
|
||||
underline: "\x1b[4m",
|
||||
} as const;
|
||||
|
||||
export interface SyntaxHighlightOptions {
|
||||
logLevels?: boolean;
|
||||
paths?: boolean;
|
||||
timestamps?: boolean;
|
||||
ipAddresses?: boolean;
|
||||
urls?: boolean;
|
||||
numbers?: boolean;
|
||||
}
|
||||
|
||||
interface HighlightPattern {
|
||||
name: string;
|
||||
regex: RegExp;
|
||||
ansiCode: string;
|
||||
priority: number;
|
||||
category: keyof SyntaxHighlightOptions;
|
||||
}
|
||||
|
||||
interface MatchResult {
|
||||
start: number;
|
||||
end: number;
|
||||
ansiCode: string;
|
||||
priority: number;
|
||||
// capture group offset: some patterns capture a sub-group rather than whole match
|
||||
captureStart?: number;
|
||||
captureEnd?: number;
|
||||
}
|
||||
|
||||
interface TextSegment {
|
||||
isAnsi: boolean;
|
||||
content: string;
|
||||
// the last active SGR code before this segment (for state restoration)
|
||||
activeSgr?: string;
|
||||
}
|
||||
|
||||
interface ProtectedRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
const MAX_LINE_LENGTH = 2000;
|
||||
|
||||
// Cursor-positioning, erase, and private mode sequences used by TUI apps
|
||||
// (mc, nano, vim, htop). If a chunk contains these, highlighting can inject
|
||||
// extra SGR bytes into a full-screen redraw and corrupt xterm's cursor state.
|
||||
const TUI_SEQUENCE = /\x1b\[(?:[\d;]*[ABCDEFGHJKST]|\?[\d;]*[hl])/;
|
||||
const TUI_FRAME_SEQUENCE = /[\u2500-\u257f]|\x1b[()][0B]|\x1b\[[0-9;]*[~`]/;
|
||||
const CONTROL_STRING_SEQUENCE = /\x1b[\]P^_]/;
|
||||
|
||||
// A bare \r (not immediately followed by \n) means the terminal is overwriting
|
||||
// the current line (shell prompts, progress bars). Highlighting mid-rewrite
|
||||
// chunks corrupts the cursor state, so we skip the whole chunk.
|
||||
const MID_LINE_CR = /\r(?!\n)/;
|
||||
|
||||
// Detects shell prompt lines (user@host:/path$ or similar) after stripping ANSI.
|
||||
// These should not be highlighted — the server already colored them, and injecting
|
||||
// additional ANSI codes into the prompt fragments causes display corruption.
|
||||
//
|
||||
// The parameter-byte class (0-9;?>=!) intentionally also includes `<` and `:` —
|
||||
// SGR mouse-tracking reports (`ESC[<Cb;Cx;CyM`) use `<` as their private-mode
|
||||
// marker. Without it, mouse reports from TUI apps (especially through a
|
||||
// multiplexer like screen) aren't recognized as escape sequences and leak
|
||||
// through as literal "35;191;1M" text on screen.
|
||||
const STRIP_ANSI_RE = /\x1b(?:[@-Z\\-_]|\[[0-9:;<=>?!]*[@-~])/g;
|
||||
const SSH_BRACKET_HEADING_RE =
|
||||
/(?:(?<=^)|(?<=\s))\[[\w.-]+@[\w.-]+(?:[^\]\r\n]*)?\]/g;
|
||||
|
||||
function isShellPromptLine(bare: string): boolean {
|
||||
const plain = bare.replace(STRIP_ANSI_RE, "");
|
||||
// Matches a trailing prompt: "user@host:~$ ", "root@pi:/home/pi# ", "[user@host dir]$ "
|
||||
if (/(?:[\w.-]+@[\w.-]+|[\w.-]+).*?[$#%>]\s*$/.test(plain)) return true;
|
||||
// Matches a leading prompt followed by a command: "user@host:/path$ cmd arg"
|
||||
// This is the echoed command line — the shell colors the prompt prefix itself,
|
||||
// so injecting extra ANSI codes into it causes visual corruption / doubled paths.
|
||||
if (/^(?:\[?[\w.-]+@[\w.-]+[\w./ ~-]*\]?|[\w.-]+).*?[$#%>]\s+\S/.test(plain))
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Matches any complete ANSI escape sequence
|
||||
const ANSI_REGEX = /\x1b(?:[@-Z\\-_]|\[[0-9:;<=>?!]*[@-~])/g;
|
||||
|
||||
// Matches SGR sequences (color/style setters) specifically — used to track active color state
|
||||
const SGR_REGEX = /\x1b\[[0-9;]*m/;
|
||||
|
||||
// All patterns, ordered roughly by specificity. Priority determines which wins on overlap.
|
||||
const ALL_PATTERNS: HighlightPattern[] = [
|
||||
// IPv4 with optional :port — well-bounded, very specific
|
||||
{
|
||||
name: "ipv4",
|
||||
regex:
|
||||
/(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9]?[0-9])(?::\d{1,5})?/g,
|
||||
ansiCode: ANSI.magenta,
|
||||
priority: 10,
|
||||
category: "ipAddresses",
|
||||
},
|
||||
|
||||
// Bracket timestamps [HH:MM] or [HH:MM:SS] — tight range checks prevent false positives
|
||||
{
|
||||
name: "timestamp-bracket",
|
||||
regex: /\[(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d)?\]/g,
|
||||
ansiCode: ANSI.brightBlack,
|
||||
priority: 9,
|
||||
category: "timestamps",
|
||||
},
|
||||
|
||||
// ISO 8601 date with optional time. Require non-digit before to avoid matching
|
||||
// inside version strings like "1.2024.3". The \b on the right ensures we don't
|
||||
// match partial tokens.
|
||||
{
|
||||
name: "timestamp-iso",
|
||||
regex:
|
||||
/(?<![0-9])\b\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])(?:[T ]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?\b/g,
|
||||
ansiCode: ANSI.brightBlack,
|
||||
priority: 9,
|
||||
category: "timestamps",
|
||||
},
|
||||
|
||||
// Error-level keywords — case-insensitive, catches "Error", "error", "FAILED", etc.
|
||||
{
|
||||
name: "log-error",
|
||||
regex:
|
||||
/\b(?:error|fatal|critical|fail(?:ed)?|denied|exception)\b|\[(?:error|fatal|critical)\]/gi,
|
||||
ansiCode: ANSI.brightRed,
|
||||
priority: 9,
|
||||
category: "logLevels",
|
||||
},
|
||||
|
||||
// Warning-level keywords — case-insensitive
|
||||
{
|
||||
name: "log-warn",
|
||||
regex: /\b(?:warn(?:ing)?|alert|caution)\b|\[warn(?:ing)?\]/gi,
|
||||
ansiCode: ANSI.brightYellow,
|
||||
priority: 9,
|
||||
category: "logLevels",
|
||||
},
|
||||
|
||||
// Success keywords — kept conservative to avoid noise.
|
||||
// Negative lookahead prevents matching when directly followed by a path separator
|
||||
// (e.g. shell `cd` output that prints "success~/new/dir").
|
||||
{
|
||||
name: "log-success",
|
||||
regex:
|
||||
/\b(?:success(?:ful(?:ly)?)?|pass(?:ed)?|complete(?:d)?|ok\b)\b(?![\\/~])/gi,
|
||||
ansiCode: ANSI.brightGreen,
|
||||
priority: 8,
|
||||
category: "logLevels",
|
||||
},
|
||||
|
||||
// URLs — blue + underline
|
||||
{
|
||||
name: "url",
|
||||
regex: /https?:\/\/[^\s\])}>"']+/g,
|
||||
ansiCode: `${ANSI.blue}${ANSI.underline}`,
|
||||
priority: 8,
|
||||
category: "urls",
|
||||
},
|
||||
|
||||
// Absolute paths — permissive character class that allows *, ?, [], globs, etc.
|
||||
// Requires at least one slash after the root segment so we don't match bare /dev or /tmp.
|
||||
// Boundary: must start at beginning of text or after whitespace/colon/comma/paren.
|
||||
{
|
||||
name: "path-absolute",
|
||||
regex:
|
||||
/(?:(?<=^)|(?<=[\s:,;('"]))\/[^\s"'`|<>&;\\]+(?:\/[^\s"'`|<>&;\\]+)+/g,
|
||||
ansiCode: ANSI.cyan,
|
||||
priority: 7,
|
||||
category: "paths",
|
||||
},
|
||||
|
||||
// Home-relative paths
|
||||
{
|
||||
name: "path-home",
|
||||
regex: /~\/[^\s"'`|<>&;(){}[\]\\]+/g,
|
||||
ansiCode: ANSI.cyan,
|
||||
priority: 7,
|
||||
category: "paths",
|
||||
},
|
||||
|
||||
// Info keyword
|
||||
{
|
||||
name: "log-info",
|
||||
regex: /\binfo\b|\[info\]/gi,
|
||||
ansiCode: ANSI.blue,
|
||||
priority: 6,
|
||||
category: "logLevels",
|
||||
},
|
||||
|
||||
// Debug/trace
|
||||
{
|
||||
name: "log-debug",
|
||||
regex: /\b(?:debug|trace|verbose)\b|\[(?:debug|trace)\]/gi,
|
||||
ansiCode: ANSI.brightBlack,
|
||||
priority: 6,
|
||||
category: "logLevels",
|
||||
},
|
||||
|
||||
// Labeled numbers: port 8080, exit 1, code 127, status 404, signal 9, returned 0
|
||||
// Captures just the number portion so only the digit is highlighted.
|
||||
{
|
||||
name: "number-labeled",
|
||||
regex: /\b(?:port|exit|code|status|signal|returned?)\s+(\d+)\b/gi,
|
||||
ansiCode: ANSI.brightCyan,
|
||||
priority: 5,
|
||||
category: "numbers",
|
||||
},
|
||||
];
|
||||
|
||||
function hasIncompleteAnsiSequence(text: string): boolean {
|
||||
return /\x1b\[[0-9:;<=>?!]*$/.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks whether the stream is inside a control string (OSC/DCS/APC/PM) across
|
||||
* chunk boundaries.
|
||||
*
|
||||
* A control string carries text that must never reach the screen — an OSC 0
|
||||
* title, for instance, contains the user, host and path. Its opening `ESC ]`
|
||||
* and its terminator often land in different websocket frames, and the
|
||||
* continuation frame contains no escape byte at all, so every single-chunk
|
||||
* guard here misses it. Highlighting that continuation injects an SGR sequence
|
||||
* into the middle of the string, which aborts it early in xterm.js and dumps
|
||||
* the rest of the payload on screen as ordinary text.
|
||||
*
|
||||
* A trailing lone ESC counts as active for the same reason: its intent is only
|
||||
* knowable from the next chunk.
|
||||
*/
|
||||
export function updateControlStringMode(
|
||||
output: string,
|
||||
currentMode: boolean,
|
||||
): { isActive: boolean; wasActive: boolean } {
|
||||
const wasActive = currentMode;
|
||||
let isActive = currentMode;
|
||||
|
||||
for (let i = 0; i < output.length; i++) {
|
||||
const char = output[i];
|
||||
|
||||
if (isActive) {
|
||||
if (char === "\x07") {
|
||||
isActive = false;
|
||||
} else if (char === "\x1b") {
|
||||
// ST (ESC \) closes it; any other ESC aborts it.
|
||||
isActive = false;
|
||||
if (output[i + 1] === "\\") i++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char !== "\x1b") continue;
|
||||
|
||||
const next = output[i + 1];
|
||||
if (next === undefined) return { isActive: true, wasActive };
|
||||
if (next === "]" || next === "P" || next === "^" || next === "_") {
|
||||
isActive = true;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return { isActive, wasActive };
|
||||
}
|
||||
|
||||
function parseAnsiSegments(text: string): TextSegment[] {
|
||||
const segments: TextSegment[] = [];
|
||||
ANSI_REGEX.lastIndex = 0;
|
||||
let lastIndex = 0;
|
||||
let activeSgr = "";
|
||||
let match;
|
||||
|
||||
while ((match = ANSI_REGEX.exec(text)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({
|
||||
isAnsi: false,
|
||||
content: text.slice(lastIndex, match.index),
|
||||
activeSgr,
|
||||
});
|
||||
}
|
||||
const seq = match[0];
|
||||
segments.push({ isAnsi: true, content: seq });
|
||||
// Track the active SGR state so we can restore it after a highlight reset
|
||||
if (SGR_REGEX.test(seq)) {
|
||||
activeSgr = seq === ANSI.reset ? "" : seq;
|
||||
}
|
||||
lastIndex = ANSI_REGEX.lastIndex;
|
||||
}
|
||||
|
||||
if (lastIndex < text.length) {
|
||||
segments.push({ isAnsi: false, content: text.slice(lastIndex), activeSgr });
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
function highlightPlainText(
|
||||
text: string,
|
||||
activePatterns: HighlightPattern[],
|
||||
activeSgr: string,
|
||||
protectedRanges: ProtectedRange[],
|
||||
): string {
|
||||
if (text.length > MAX_LINE_LENGTH || !text.trim()) return text;
|
||||
|
||||
const matches: MatchResult[] = [];
|
||||
|
||||
for (const pattern of activePatterns) {
|
||||
pattern.regex.lastIndex = 0;
|
||||
let m;
|
||||
while ((m = pattern.regex.exec(text)) !== null) {
|
||||
// For patterns with a capture group (labeled numbers), highlight only the capture
|
||||
if (m[1] !== undefined) {
|
||||
const captureOffset = m[0].indexOf(m[1], m[0].search(/\d/));
|
||||
const captureStart = m.index + captureOffset;
|
||||
const captureEnd = captureStart + m[1].length;
|
||||
if (isProtectedRange(captureStart, captureEnd, protectedRanges)) {
|
||||
continue;
|
||||
}
|
||||
matches.push({
|
||||
start: m.index,
|
||||
end: m.index + m[0].length,
|
||||
ansiCode: pattern.ansiCode,
|
||||
priority: pattern.priority,
|
||||
captureStart,
|
||||
captureEnd,
|
||||
});
|
||||
} else {
|
||||
if (isProtectedRange(m.index, m.index + m[0].length, protectedRanges)) {
|
||||
continue;
|
||||
}
|
||||
matches.push({
|
||||
start: m.index,
|
||||
end: m.index + m[0].length,
|
||||
ansiCode: pattern.ansiCode,
|
||||
priority: pattern.priority,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 0) return text;
|
||||
|
||||
// Highest priority wins; ties broken by earlier start position
|
||||
matches.sort((a, b) =>
|
||||
a.priority !== b.priority ? b.priority - a.priority : a.start - b.start,
|
||||
);
|
||||
|
||||
const used: Array<{ start: number; end: number }> = [];
|
||||
const final = matches.filter((m) => {
|
||||
const overlaps = used.some(
|
||||
(r) =>
|
||||
(m.start >= r.start && m.start < r.end) ||
|
||||
(m.end > r.start && m.end <= r.end) ||
|
||||
(m.start <= r.start && m.end >= r.end),
|
||||
);
|
||||
if (!overlaps) {
|
||||
used.push({ start: m.start, end: m.end });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// Apply in reverse order so indices stay valid as we insert escape codes
|
||||
final.sort((a, b) => a.start - b.start).reverse();
|
||||
|
||||
let result = text;
|
||||
for (const m of final) {
|
||||
const hs = m.captureStart ?? m.start;
|
||||
const he = m.captureEnd ?? m.end;
|
||||
|
||||
// After reset, re-emit the SGR that was active before this plain-text segment
|
||||
// so we don't clobber colors set by the server earlier in the line.
|
||||
const restore = activeSgr || "";
|
||||
result =
|
||||
result.slice(0, hs) +
|
||||
m.ansiCode +
|
||||
result.slice(hs, he) +
|
||||
ANSI.reset +
|
||||
restore +
|
||||
result.slice(he);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getProtectedRanges(text: string): ProtectedRange[] {
|
||||
const ranges: ProtectedRange[] = [];
|
||||
SSH_BRACKET_HEADING_RE.lastIndex = 0;
|
||||
|
||||
let match;
|
||||
while ((match = SSH_BRACKET_HEADING_RE.exec(text)) !== null) {
|
||||
ranges.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
});
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function isProtectedRange(
|
||||
start: number,
|
||||
end: number,
|
||||
ranges: ProtectedRange[],
|
||||
): boolean {
|
||||
return ranges.some((range) => start < range.end && end > range.start);
|
||||
}
|
||||
|
||||
function buildActivePatterns(
|
||||
options: SyntaxHighlightOptions,
|
||||
): HighlightPattern[] {
|
||||
return ALL_PATTERNS.filter((p) => options[p.category] !== false);
|
||||
}
|
||||
|
||||
function highlightLine(
|
||||
line: string,
|
||||
activePatterns: HighlightPattern[],
|
||||
): string {
|
||||
const cr = line.endsWith("\r");
|
||||
const bare = cr ? line.slice(0, -1) : line;
|
||||
|
||||
if (!bare.trim()) return line;
|
||||
if (bare.length > MAX_LINE_LENGTH) return line;
|
||||
if (isShellPromptLine(bare)) return line;
|
||||
|
||||
// Compute protected ranges (e.g. SSH bracket headings) against the fully
|
||||
// stripped line rather than per-ANSI-segment text. A colored prompt theme
|
||||
// (e.g. "[<color>user<reset>@<color>host<reset>]") splits the heading across
|
||||
// multiple plain-text segments, so matching per-segment would miss it and
|
||||
// let a username like "warning" get wrongly highlighted as a log level.
|
||||
const plainLine = bare.replace(STRIP_ANSI_RE, "");
|
||||
const lineProtectedRanges = getProtectedRanges(plainLine);
|
||||
|
||||
const segments = parseAnsiSegments(bare);
|
||||
let plainOffset = 0;
|
||||
const result = segments
|
||||
.map((s) => {
|
||||
if (s.isAnsi) return s.content;
|
||||
const segmentStart = plainOffset;
|
||||
plainOffset += s.content.length;
|
||||
const localRanges = lineProtectedRanges
|
||||
.map((r) => ({
|
||||
start: r.start - segmentStart,
|
||||
end: r.end - segmentStart,
|
||||
}))
|
||||
.filter((r) => r.start < s.content.length && r.end > 0);
|
||||
return highlightPlainText(
|
||||
s.content,
|
||||
activePatterns,
|
||||
s.activeSgr ?? "",
|
||||
localRanges,
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
|
||||
return cr ? result + "\r" : result;
|
||||
}
|
||||
|
||||
export function highlightTerminalOutput(
|
||||
text: string,
|
||||
options: SyntaxHighlightOptions = {},
|
||||
): string {
|
||||
if (!text || !text.trim()) return text;
|
||||
if (hasIncompleteAnsiSequence(text)) return text;
|
||||
|
||||
if (TUI_SEQUENCE.test(text)) return text;
|
||||
if (TUI_FRAME_SEQUENCE.test(text)) return text;
|
||||
if (CONTROL_STRING_SEQUENCE.test(text)) return text;
|
||||
if (MID_LINE_CR.test(text)) return text;
|
||||
|
||||
const activePatterns = buildActivePatterns(options);
|
||||
if (activePatterns.length === 0) return text;
|
||||
|
||||
const lines = text.split("\n");
|
||||
const endsWithNewline = text.endsWith("\n");
|
||||
const hasMultipleLines = lines.length > 1;
|
||||
|
||||
// When a multi-line chunk has a trailing fragment without \n, that fragment
|
||||
// could be a live readline input line that bash will redraw with \r +
|
||||
// cursor-positioning sequences. Injecting ANSI bytes into it adds invisible
|
||||
// chars that bash doesn't count, so bash's cursor arithmetic diverges from
|
||||
// xterm's actual column position — causing the cursor to jump and text to
|
||||
// shift when the user types or navigates with arrow keys.
|
||||
// Only skip the last fragment when the chunk already has complete lines
|
||||
// before it (single-line chunks with no \n are plain output, not input).
|
||||
const highlightCount =
|
||||
hasMultipleLines && !endsWithNewline ? lines.length - 1 : lines.length;
|
||||
|
||||
return lines
|
||||
.map((line, i) =>
|
||||
i < highlightCount ? highlightLine(line, activePatterns) : line,
|
||||
)
|
||||
.join("\n");
|
||||
}
|
||||
Reference in new issue
Block a user