mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-24 06:58:15 +00:00
stop highlighting inside a split control string (#1124)
A control string (OSC/DCS/APC/PM) carries text that must never be displayed — an OSC 0 title holds the user, host and path, and PROMPT_COMMAND emits one on every prompt. Its opener and its terminator routinely land in different websocket frames, and the continuation frame contains no escape byte at all, so every guard in the highlighter misses it: TUI_SEQUENCE, CONTROL_STRING_SEQUENCE and hasIncompleteAnsiSequence all only look at one chunk. Highlighting that continuation injects an SGR sequence into the middle of the open string, which aborts it early in xterm.js and prints the remainder as ordinary text — the stray ~/path glued to the prompt, and the cursor arithmetic drift behind the duplicate prompts and Ctrl+R corruption. Track the state across chunks the way alternate-screen mode already is, and skip any chunk that starts or ends inside a control string. A trailing lone ESC counts as inside, since its meaning only arrives with the next chunk. Closes Termix-SSH/Support#1025
This commit is contained in:
@@ -45,7 +45,10 @@ import { ensureTerminalFontsLoaded } from "./terminal-global-styles.ts";
|
||||
import { useTheme } from "@/components/theme-provider.tsx";
|
||||
import { globalShortcutHandler } from "@/lib/global-shortcut-handler";
|
||||
import { useCommandTracker } from "@/features/terminal/command-history/useCommandTracker.ts";
|
||||
import { highlightTerminalOutput } from "@/lib/terminal-syntax-highlighter.ts";
|
||||
import {
|
||||
highlightTerminalOutput,
|
||||
updateControlStringMode,
|
||||
} from "@/lib/terminal-syntax-highlighter.ts";
|
||||
import { useCommandHistory } from "@/features/terminal/command-history/CommandHistoryContext.tsx";
|
||||
import { getAndroidHardwareKeySequence } from "@/features/terminal/android-hardware-keyboard.ts";
|
||||
import { CommandAutocomplete } from "./command-history/CommandAutocomplete.tsx";
|
||||
@@ -421,6 +424,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
const activityLoggingRef = useRef(false);
|
||||
const passwordPromptShownRef = useRef(false);
|
||||
const alternateScreenModeRef = useRef(false);
|
||||
const controlStringModeRef = useRef(false);
|
||||
|
||||
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||
const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
|
||||
@@ -691,12 +695,22 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
);
|
||||
alternateScreenModeRef.current = alternateScreen.isActive;
|
||||
|
||||
// Must run for every chunk, including ones we go on to skip, or the
|
||||
// control-string state stops tracking the stream.
|
||||
const controlString = updateControlStringMode(
|
||||
output,
|
||||
controlStringModeRef.current,
|
||||
);
|
||||
controlStringModeRef.current = controlString.isActive;
|
||||
|
||||
const syntaxHighlightingEnabled =
|
||||
hostConfig.terminalConfig?.syntaxHighlighting !== false;
|
||||
if (
|
||||
!syntaxHighlightingEnabled ||
|
||||
alternateScreen.sawSequence ||
|
||||
alternateScreen.isActive
|
||||
alternateScreen.isActive ||
|
||||
controlString.wasActive ||
|
||||
controlString.isActive
|
||||
) {
|
||||
return output;
|
||||
}
|
||||
@@ -1063,6 +1077,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
|
||||
) {
|
||||
ws.addEventListener("open", () => {
|
||||
alternateScreenModeRef.current = false;
|
||||
controlStringModeRef.current = false;
|
||||
connectionTimeoutRef.current = setTimeout(() => {
|
||||
if (
|
||||
!isConnected &&
|
||||
|
||||
@@ -221,6 +221,55 @@ 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;
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { highlightTerminalOutput } from "../../lib/terminal-syntax-highlighter.js";
|
||||
import {
|
||||
highlightTerminalOutput,
|
||||
updateControlStringMode,
|
||||
} from "../../lib/terminal-syntax-highlighter.js";
|
||||
|
||||
const ESC = "\x1b";
|
||||
|
||||
@@ -335,3 +338,85 @@ describe("highlightTerminalOutput", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user