diff --git a/src/ui/features/terminal/Terminal.tsx b/src/ui/features/terminal/Terminal.tsx index a3ee2175..c619d100 100644 --- a/src/ui/features/terminal/Terminal.tsx +++ b/src/ui/features/terminal/Terminal.tsx @@ -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( 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( ); 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( ) { ws.addEventListener("open", () => { alternateScreenModeRef.current = false; + controlStringModeRef.current = false; connectionTimeoutRef.current = setTimeout(() => { if ( !isConnected && diff --git a/src/ui/lib/terminal-syntax-highlighter.ts b/src/ui/lib/terminal-syntax-highlighter.ts index 3799e59f..96fcb85a 100644 --- a/src/ui/lib/terminal-syntax-highlighter.ts +++ b/src/ui/lib/terminal-syntax-highlighter.ts @@ -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; diff --git a/src/ui/tests/lib/terminal-syntax-highlighter.test.ts b/src/ui/tests/lib/terminal-syntax-highlighter.test.ts index c74a1a83..b7ecf61d 100644 --- a/src/ui/tests/lib/terminal-syntax-highlighter.test.ts +++ b/src/ui/tests/lib/terminal-syntax-highlighter.test.ts @@ -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 ; , 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); + }); +});