diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts index af5f2280de5..477f31aff06 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts @@ -145,6 +145,20 @@ export const createTerminalRouter = () => { terminalManager.detach(input); }), + /** + * Clear scrollback buffer for terminal (used by Cmd+K / clear command) + * This clears both in-memory scrollback and persistent history file + */ + clearScrollback: publicProcedure + .input( + z.object({ + tabId: z.string(), + }), + ) + .mutation(async ({ input }) => { + await terminalManager.clearScrollback(input); + }), + getSession: publicProcedure .input(z.string()) .query(async ({ input: tabId }) => { diff --git a/apps/desktop/src/main/lib/terminal-escape-filter.ts b/apps/desktop/src/main/lib/terminal-escape-filter.ts index 9ba7e5bef54..e7744a2ba52 100644 --- a/apps/desktop/src/main/lib/terminal-escape-filter.ts +++ b/apps/desktop/src/main/lib/terminal-escape-filter.ts @@ -10,6 +10,13 @@ const ESC = "\x1b"; const BEL = "\x07"; +/** + * Pattern to detect clear scrollback sequences: + * - ESC [ 3 J - Clear scrollback buffer (ED3) + * - ESC c - Full terminal reset (RIS) + */ +const CLEAR_SCROLLBACK_PATTERN = new RegExp(`${ESC}\\[3J|${ESC}c`); + /** * Pattern definitions for terminal query responses. * Each pattern matches a specific type of response that should be filtered. @@ -253,3 +260,15 @@ export function filterTerminalQueryResponses(data: string): string { // Export patterns for testing export const patterns = FILTER_PATTERNS; + +/** + * 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 Ctrl+L). + * + * Detected sequences: + * - ESC [ 3 J - Clear scrollback buffer (ED3) + * - ESC c - Full terminal reset (RIS) + */ +export function containsClearScrollbackSequence(data: string): boolean { + return CLEAR_SCROLLBACK_PATTERN.test(data); +} diff --git a/apps/desktop/src/main/lib/terminal-history.ts b/apps/desktop/src/main/lib/terminal-history.ts index 98b0e2c2842..ad06976b603 100644 --- a/apps/desktop/src/main/lib/terminal-history.ts +++ b/apps/desktop/src/main/lib/terminal-history.ts @@ -56,8 +56,9 @@ export class HistoryWriter { await fs.mkdir(dir, { recursive: true }); // Write initial scrollback (recovered from previous session) or truncate + // node-pty produces UTF-8 strings, so we store as UTF-8 if (initialScrollback) { - await fs.writeFile(this.filePath, Buffer.from(initialScrollback)); + await fs.writeFile(this.filePath, Buffer.from(initialScrollback, "utf8")); } else { await fs.writeFile(this.filePath, Buffer.alloc(0)); } @@ -73,7 +74,8 @@ export class HistoryWriter { write(data: string): void { if (this.stream && !this.streamErrored) { try { - this.stream.write(Buffer.from(data)); + // node-pty produces UTF-8 strings + this.stream.write(Buffer.from(data, "utf8")); } catch { this.streamErrored = true; } @@ -111,7 +113,9 @@ export class HistoryReader { async read(): Promise<{ scrollback: string; metadata?: SessionMetadata }> { try { const filePath = getHistoryFilePath(this.workspaceId, this.tabId); - const scrollback = await fs.readFile(filePath, "utf-8"); + // Read as UTF-8 to match how node-pty produces terminal output + // The file is stored as raw bytes from UTF-8 encoded strings + const scrollback = await fs.readFile(filePath, "utf8"); let metadata: SessionMetadata | undefined; try { diff --git a/apps/desktop/src/main/lib/terminal-manager.test.ts b/apps/desktop/src/main/lib/terminal-manager.test.ts index b46396a66ce..94e1d4e45c1 100644 --- a/apps/desktop/src/main/lib/terminal-manager.test.ts +++ b/apps/desktop/src/main/lib/terminal-manager.test.ts @@ -734,6 +734,113 @@ describe("TerminalManager", () => { }); }); + describe("clearScrollback", () => { + it("should clear in-memory scrollback", async () => { + await manager.createOrAttach({ + tabId: "tab-clear", + workspaceId: "workspace-1", + tabTitle: "Test Tab", + workspaceName: "Test Workspace", + }); + + const onDataCallback = + mockPty.onData.mock.calls[mockPty.onData.mock.calls.length - 1]?.[0]; + if (onDataCallback) { + onDataCallback("some output\n"); + } + + await manager.clearScrollback({ tabId: "tab-clear" }); + + const result = await manager.createOrAttach({ + tabId: "tab-clear", + workspaceId: "workspace-1", + tabTitle: "Test Tab", + workspaceName: "Test Workspace", + }); + + expect(result.scrollback).toBe(""); + }); + + it("should reinitialize history file", async () => { + await manager.createOrAttach({ + tabId: "tab-clear-history", + workspaceId: "workspace-clear", + tabTitle: "Test Tab", + workspaceName: "Test Workspace", + }); + + const onDataCallback = + mockPty.onData.mock.calls[mockPty.onData.mock.calls.length - 1]?.[0]; + if (onDataCallback) { + onDataCallback("output before clear\n"); + } + + await manager.clearScrollback({ tabId: "tab-clear-history" }); + + const onExitCallback = + mockPty.onExit.mock.calls[mockPty.onExit.mock.calls.length - 1]?.[0]; + if (onExitCallback) { + await onExitCallback({ exitCode: 0, signal: undefined }); + } + + await manager.cleanup(); + + const result = await manager.createOrAttach({ + tabId: "tab-clear-history", + workspaceId: "workspace-clear", + tabTitle: "Test Tab", + workspaceName: "Test Workspace", + }); + + expect(result.scrollback).toBe(""); + expect(result.wasRecovered).toBe(false); + }); + + it("should handle non-existent session gracefully", async () => { + const warnSpy = mock(() => {}); + const originalWarn = console.warn; + console.warn = warnSpy; + + await expect( + manager.clearScrollback({ tabId: "non-existent" }), + ).resolves.toBeUndefined(); + + expect(warnSpy).toHaveBeenCalledWith( + "Cannot clear scrollback for terminal non-existent: session not found", + ); + + console.warn = originalWarn; + }); + + it("should clear scrollback when shell sends clear sequence", async () => { + await manager.createOrAttach({ + tabId: "tab-shell-clear", + workspaceId: "workspace-1", + tabTitle: "Test Tab", + workspaceName: "Test Workspace", + }); + + const onDataCallback = + mockPty.onData.mock.calls[mockPty.onData.mock.calls.length - 1]?.[0]; + if (onDataCallback) { + onDataCallback("some output\n"); + // ED3 sequence clears scrollback, then output after the sequence is stored + onDataCallback("\x1b[3Jnew content after clear"); + } + + const result = await manager.createOrAttach({ + tabId: "tab-shell-clear", + workspaceId: "workspace-1", + tabTitle: "Test Tab", + workspaceName: "Test Workspace", + }); + + // Only content after the clear sequence should remain + expect(result.scrollback).not.toContain("some output"); + expect(result.scrollback).toContain("new content after clear"); + }); + }); + describe("multi-session history persistence", () => { it("should persist history across multiple sessions", async () => { // Session 1: Create and write data diff --git a/apps/desktop/src/main/lib/terminal-manager.ts b/apps/desktop/src/main/lib/terminal-manager.ts index c85345c18f3..6a64e7c0346 100644 --- a/apps/desktop/src/main/lib/terminal-manager.ts +++ b/apps/desktop/src/main/lib/terminal-manager.ts @@ -3,7 +3,10 @@ import os from "node:os"; import * as pty from "node-pty"; import { PORTS } from "shared/constants"; import { getShellArgs, getShellEnv } from "./agent-setup"; -import { TerminalEscapeFilter } from "./terminal-escape-filter"; +import { + containsClearScrollbackSequence, + TerminalEscapeFilter, +} from "./terminal-escape-filter"; import { HistoryReader, HistoryWriter } from "./terminal-history"; interface TerminalSession { @@ -223,18 +226,20 @@ export class TerminalManager extends EventEmitter { let commandsSent = false; ptyProcess.onData((data) => { - // Filter terminal query responses for storage only - // xterm needs raw data for proper terminal behavior (DA/DSR/OSC responses) + if (containsClearScrollbackSequence(data)) { + session.scrollback = ""; + session.escapeFilter = new TerminalEscapeFilter(); + this.reinitializeHistory(session).catch(() => {}); + } + + // Filter query responses for storage; xterm receives raw data for proper protocol handling const filteredData = session.escapeFilter.filter(data); session.scrollback += filteredData; session.historyWriter?.write(filteredData); - // Emit ORIGINAL data to xterm - it needs to process query responses this.emit(`data:${tabId}`, data); - // Send initial commands after shell outputs first data (prompt ready) if (shouldRunCommands && !commandsSent) { commandsSent = true; - // Small delay ensures shell is fully ready to accept input setTimeout(() => { if (session.isAlive) { const cmdString = `${initialCommands.join(" && ")}\n`; @@ -357,6 +362,37 @@ export class TerminalManager extends EventEmitter { session.lastActive = Date.now(); } + async clearScrollback(params: { tabId: string }): Promise { + const { tabId } = params; + const session = this.sessions.get(tabId); + + if (!session) { + console.warn( + `Cannot clear scrollback for terminal ${tabId}: session not found`, + ); + return; + } + + session.scrollback = ""; + session.escapeFilter = new TerminalEscapeFilter(); + await this.reinitializeHistory(session); + session.lastActive = Date.now(); + } + + private async reinitializeHistory(session: TerminalSession): Promise { + if (session.historyWriter) { + await session.historyWriter.close(); + session.historyWriter = new HistoryWriter( + session.workspaceId, + session.tabId, + session.cwd, + session.cols, + session.rows, + ); + await session.historyWriter.init(); + } + } + getSession(tabId: string): { isAlive: boolean; cwd: string; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index bf75cf7868f..a293f7633e6 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -68,15 +68,18 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const writeMutation = trpc.terminal.write.useMutation(); const resizeMutation = trpc.terminal.resize.useMutation(); const detachMutation = trpc.terminal.detach.useMutation(); + const clearScrollbackMutation = trpc.terminal.clearScrollback.useMutation(); const createOrAttachRef = useRef(createOrAttachMutation.mutate); const writeRef = useRef(writeMutation.mutate); const resizeRef = useRef(resizeMutation.mutate); const detachRef = useRef(detachMutation.mutate); + const clearScrollbackRef = useRef(clearScrollbackMutation.mutate); createOrAttachRef.current = createOrAttachMutation.mutate; writeRef.current = writeMutation.mutate; resizeRef.current = resizeMutation.mutate; detachRef.current = detachMutation.mutate; + clearScrollbackRef.current = clearScrollbackMutation.mutate; const parentTabIdRef = useRef(parentTabId); parentTabIdRef.current = parentTabId; @@ -302,6 +305,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { }, onClear: () => { xterm.clear(); + clearScrollbackRef.current({ tabId: paneId }); }, }); @@ -316,7 +320,6 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { resizeRef.current({ tabId: paneId, cols, rows }); }, ); - // Setup paste handler to ensure bracketed paste mode works for TUI apps like opencode const cleanupPaste = setupPasteHandler(xterm, { onPaste: (text) => { commandBufferRef.current += text; @@ -333,7 +336,6 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { cleanupPaste(); cleanupQuerySuppression(); debouncedSetTabAutoTitleRef.current?.cancel?.(); - // Detach instead of kill to keep PTY running for reattachment detachRef.current({ tabId: paneId }); setSubscriptionEnabled(false); xterm.dispose(); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index 76db0056d14..ef9098817d4 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts @@ -77,22 +77,17 @@ export function createTerminalInstance( const clipboardAddon = new ClipboardAddon(); - // Unicode 11 provides better emoji and unicode rendering than default const unicode11Addon = new Unicode11Addon(); xterm.open(container); - // Addons must be loaded after terminal is opened, otherwise they won't attach properly xterm.loadAddon(fitAddon); xterm.loadAddon(webLinksAddon); xterm.loadAddon(clipboardAddon); xterm.loadAddon(unicode11Addon); - // Suppress terminal query responses (DA1, DA2, CPR, OSC color responses, etc.) - // These are protocol-level responses that should be handled internally, not displayed const cleanupQuerySuppression = suppressQueryResponses(xterm); - // Register file path link provider (Cmd+Click to open in Cursor/VSCode) const filePathLinkProvider = new FilePathLinkProvider( xterm, (_event, path, line, column) => { @@ -114,10 +109,7 @@ export function createTerminalInstance( ); xterm.registerLinkProvider(filePathLinkProvider); - // Activate Unicode 11 xterm.unicode.activeVersion = "11"; - - // Fit after addons are loaded fitAddon.fit(); return { @@ -161,24 +153,16 @@ export function setupPasteHandler( if (!textarea) return () => {}; const handlePaste = (event: ClipboardEvent) => { - // Get text from clipboard event data const text = event.clipboardData?.getData("text/plain"); if (!text) return; - // Stop xterm's internal paste handler from also processing this event.preventDefault(); event.stopImmediatePropagation(); - // Notify caller of pasted text (for command buffer tracking) options.onPaste?.(text); - - // xterm.paste() handles: - // 1. Line ending normalization (CRLF/LF -> CR) - // 2. Bracketed paste mode wrapping (\x1b[200~ ... \x1b[201~) xterm.paste(text); }; - // Use capture phase to intercept before xterm's handler textarea.addEventListener("paste", handlePaste, { capture: true }); return () => { @@ -207,14 +191,12 @@ export function setupKeyboardHandler( !event.altKey; if (isShiftEnter) { - // Block both keydown and keyup to prevent Enter from leaking through if (event.type === "keydown" && options.onShiftEnter) { options.onShiftEnter(); } return false; } - // Handle Cmd+K to clear terminal (handle directly since it needs xterm access) const isClearShortcut = event.key.toLowerCase() === "k" && event.metaKey && @@ -233,8 +215,6 @@ export function setupKeyboardHandler( if (!event.metaKey && !event.ctrlKey) return true; if (isAppHotkey(event)) { - // Re-dispatch to document for react-hotkeys-hook to catch - // Must explicitly copy modifier properties since they're prototype getters, not own properties document.dispatchEvent( new KeyboardEvent(event.type, { key: event.key, @@ -258,7 +238,6 @@ export function setupKeyboardHandler( xterm.attachCustomKeyEventHandler(handler); - // Return cleanup function that removes the handler by setting a no-op return () => { xterm.attachCustomKeyEventHandler(() => true); };