diff --git a/apps/desktop/src/main/lib/terminal-escape-filter.test.ts b/apps/desktop/src/main/lib/terminal-escape-filter.test.ts index 691b0c52c8b..8c455c4144f 100644 --- a/apps/desktop/src/main/lib/terminal-escape-filter.test.ts +++ b/apps/desktop/src/main/lib/terminal-escape-filter.test.ts @@ -132,6 +132,7 @@ describe("extractContentAfterClear", () => { }); it("should not confuse similar sequences", () => { + // ESC[3 (without J) is not a clear sequence expect(extractContentAfterClear(`${ESC}[3mtext`)).toBe(`${ESC}[3mtext`); }); }); diff --git a/apps/desktop/src/main/lib/terminal-escape-filter.ts b/apps/desktop/src/main/lib/terminal-escape-filter.ts index 41cb168eb79..da5f240163d 100644 --- a/apps/desktop/src/main/lib/terminal-escape-filter.ts +++ b/apps/desktop/src/main/lib/terminal-escape-filter.ts @@ -3,14 +3,20 @@ */ const ESC = "\x1b"; -const ED3_SEQUENCE = `${ESC}[3J`; /** - * Pattern to detect clear scrollback sequences (ED3 only). - * ESC c (RIS) is intentionally excluded - TUI apps use it for repaints. + * Pattern to detect clear scrollback sequences: + * - ESC [ 3 J - Clear scrollback buffer (ED3) + * + * Note: We intentionally do NOT include ESC c (RIS - Reset to Initial State) + * because TUI applications (vim, htop, etc.) commonly use RIS for screen + * repaints/refreshes. Only ED3 is a deliberate "clear scrollback" action + * triggered by commands like `clear` or Cmd+K. */ const CLEAR_SCROLLBACK_PATTERN = new RegExp(`${ESC}\\[3J`); +const ED3_SEQUENCE = `${ESC}[3J`; + /** * Checks if data contains sequences that clear the scrollback buffer. * Used to detect when the shell sends clear commands (e.g., from `clear` command or Cmd+K). diff --git a/apps/desktop/src/main/lib/terminal/session.ts b/apps/desktop/src/main/lib/terminal/session.ts index a32fc347d66..54010ff3e16 100644 --- a/apps/desktop/src/main/lib/terminal/session.ts +++ b/apps/desktop/src/main/lib/terminal/session.ts @@ -178,7 +178,7 @@ export function setupDataHandler( let commandsSent = false; session.pty.onData((data) => { - // Recreate headless on clear because xterm.clear() is async and unreliable + // Recreate headless on clear (xterm writes are async, so clear() alone is unreliable) if (containsClearScrollbackSequence(data)) { session.headless.dispose(); const { headless, serializer } = createHeadlessTerminal({ diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalRestore.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalRestore.ts index d6858008570..b793124382a 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalRestore.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/hooks/useTerminalRestore.ts @@ -3,7 +3,7 @@ import type { Terminal as XTerm } from "@xterm/xterm"; import { useCallback, useRef } from "react"; import { DEBUG_TERMINAL } from "../config"; import type { CreateOrAttachResult, TerminalStreamEvent } from "../types"; -import { scrollToBottom, stripClearScrollbackSequence } from "../utils"; +import { scrollToBottom } from "../utils"; export interface UseTerminalRestoreOptions { paneId: string; @@ -92,7 +92,7 @@ export function useTerminalRestore({ for (const event of events) { if (event.type === "data") { updateModesRef.current(event.data); - xterm.write(stripClearScrollbackSequence(event.data)); + xterm.write(event.data); updateCwdRef.current(event.data); } else if (event.type === "exit") { onExitEventRef.current(event.exitCode, xterm); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/utils.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/utils.ts index 2b28f2813fd..95a10d2ea48 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/utils.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/utils.ts @@ -1,8 +1,6 @@ import type { Terminal } from "@xterm/xterm"; import { quote } from "shell-quote"; -export { stripClearScrollbackSequence } from "shared/terminal-escape"; - export function shellEscapePaths(paths: string[]): string { return quote(paths); } diff --git a/apps/desktop/src/shared/terminal-escape.test.ts b/apps/desktop/src/shared/terminal-escape.test.ts deleted file mode 100644 index 9ba80fdd626..00000000000 --- a/apps/desktop/src/shared/terminal-escape.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { stripClearScrollbackSequence } from "./terminal-escape"; - -const ESC = "\x1b"; - -describe("stripClearScrollbackSequence", () => { - it("should strip ED3 sequence", () => { - expect(stripClearScrollbackSequence(`${ESC}[3J`)).toBe(""); - }); - - it("should strip ED3 from middle of content", () => { - expect(stripClearScrollbackSequence(`before${ESC}[3Jafter`)).toBe( - "beforeafter", - ); - }); - - it("should strip multiple ED3 sequences", () => { - expect(stripClearScrollbackSequence(`a${ESC}[3Jb${ESC}[3Jc`)).toBe("abc"); - }); - - it("should preserve ESC[2J (clear screen)", () => { - expect(stripClearScrollbackSequence(`${ESC}[2J`)).toBe(`${ESC}[2J`); - }); - - it("should preserve ESC[2J while stripping ESC[3J", () => { - expect(stripClearScrollbackSequence(`${ESC}[2J${ESC}[3J`)).toBe( - `${ESC}[2J`, - ); - }); - - it("should preserve RIS (ESC c)", () => { - expect(stripClearScrollbackSequence(`${ESC}c`)).toBe(`${ESC}c`); - }); - - it("should return original data when no ED3 sequence", () => { - expect(stripClearScrollbackSequence("normal text")).toBe("normal text"); - }); - - it("should return empty string for empty input", () => { - expect(stripClearScrollbackSequence("")).toBe(""); - }); - - it("should preserve ANSI colors", () => { - const data = `${ESC}[32mgreen${ESC}[0m`; - expect(stripClearScrollbackSequence(data)).toBe(data); - }); - - it("should not confuse similar sequences", () => { - expect(stripClearScrollbackSequence(`${ESC}[3mtext`)).toBe(`${ESC}[3mtext`); - }); - - it("should handle unicode content", () => { - expect(stripClearScrollbackSequence(`ę—„ęœ¬čŖž${ESC}[3JšŸŽ‰`)).toBe("ę—„ęœ¬čŖžšŸŽ‰"); - }); - - it("should handle newlines", () => { - expect(stripClearScrollbackSequence(`line1\n${ESC}[3Jline2`)).toBe( - "line1\nline2", - ); - }); -}); diff --git a/apps/desktop/src/shared/terminal-escape.ts b/apps/desktop/src/shared/terminal-escape.ts deleted file mode 100644 index c133c6ed6ff..00000000000 --- a/apps/desktop/src/shared/terminal-escape.ts +++ /dev/null @@ -1,7 +0,0 @@ -const ESC = "\x1b"; -const ED3_SEQUENCE = `${ESC}[3J`; - -/** Strips ESC[3J (clear scrollback) to prevent viewport jumps on reattach. */ -export function stripClearScrollbackSequence(data: string): string { - return data.replaceAll(ED3_SEQUENCE, ""); -}