diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts index 2d70d1eec24..6f071016b74 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts @@ -94,8 +94,7 @@ export const createTerminalRouter = () => { return { paneId, isNew: result.isNew, - scrollback: result.scrollback, - wasRecovered: result.wasRecovered, + serializedState: result.serializedState, }; }), @@ -138,7 +137,6 @@ export const createTerminalRouter = () => { .input( z.object({ paneId: z.string(), - deleteHistory: z.boolean().optional(), }), ) .mutation(async ({ input }) => { @@ -147,11 +145,14 @@ export const createTerminalRouter = () => { /** * Detach from terminal (keep session alive) + * Accepts serialized terminal state from renderer's SerializeAddon + * to enable clean reattachment without escape sequence issues */ detach: publicProcedure .input( z.object({ paneId: z.string(), + serializedState: z.string().optional(), }), ) .mutation(async ({ input }) => { @@ -160,7 +161,7 @@ export const createTerminalRouter = () => { /** * Clear scrollback buffer for terminal (used by Cmd+K / clear command) - * This clears both in-memory scrollback and persistent history file + * This clears in-memory scrollback and serialized state */ clearScrollback: publicProcedure .input( @@ -168,8 +169,8 @@ export const createTerminalRouter = () => { paneId: z.string(), }), ) - .mutation(async ({ input }) => { - await terminalManager.clearScrollback(input); + .mutation(({ input }) => { + terminalManager.clearScrollback(input); }), getSession: publicProcedure diff --git a/apps/desktop/src/main/lib/data-batcher.ts b/apps/desktop/src/main/lib/data-batcher.ts deleted file mode 100644 index 2490445c514..00000000000 --- a/apps/desktop/src/main/lib/data-batcher.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { StringDecoder } from "node:string_decoder"; - -/** - * Batches terminal data to reduce IPC overhead between main and renderer processes. - * Based on Hyper terminal's DataBatcher implementation. - * - * This minimizes the number of IPC messages by: - * 1. Time-based batching: Flushes every BATCH_DURATION_MS (16ms = ~60fps) - * 2. Size-based batching: Flushes when batch exceeds BATCH_MAX_SIZE (200KB) - * 3. Proper UTF-8 handling: Uses StringDecoder to handle multi-byte characters - * that may be split across data chunks - */ - -// Batch timing - 16ms provides smooth 60fps updates -const BATCH_DURATION_MS = 16; - -// Maximum batch size before forcing a flush (200KB) -const BATCH_MAX_SIZE = 200 * 1024; - -export class DataBatcher { - private decoder: StringDecoder; - private buffer: string = ""; - private timeout: ReturnType | null = null; - private onFlush: (data: string) => void; - - constructor(onFlush: (data: string) => void) { - this.decoder = new StringDecoder("utf8"); - this.onFlush = onFlush; - } - - /** - * Add data to the batch. Data will be flushed either when: - * - BATCH_DURATION_MS has elapsed since the first write - * - Buffer size exceeds BATCH_MAX_SIZE - */ - write(data: Buffer | string): void { - // Decode buffer data to handle multi-byte UTF-8 characters correctly - const decoded = typeof data === "string" ? data : this.decoder.write(data); - this.buffer += decoded; - - // If buffer is getting large, flush immediately - if (this.buffer.length >= BATCH_MAX_SIZE) { - this.flush(); - return; - } - - // Schedule flush if not already scheduled - if (this.timeout === null) { - this.timeout = setTimeout(() => this.flush(), BATCH_DURATION_MS); - } - } - - /** - * Flush any buffered data immediately. - * Called automatically by timer or when buffer is full. - * Note: Does NOT call decoder.end() - the decoder retains state for - * incomplete multi-byte UTF-8 sequences that may span multiple flushes. - */ - flush(): void { - if (this.timeout !== null) { - clearTimeout(this.timeout); - this.timeout = null; - } - - if (this.buffer.length > 0) { - this.onFlush(this.buffer); - this.buffer = ""; - } - } - - /** - * Dispose of the batcher, flushing any remaining data. - * Only here do we call decoder.end() to handle any trailing incomplete sequences. - */ - dispose(): void { - this.flush(); - - // On stream termination: flush any incomplete multi-byte sequences - const remaining = this.decoder.end(); - if (remaining) { - this.onFlush(remaining); - } - } -} diff --git a/apps/desktop/src/main/lib/terminal-escape-filter.test.ts b/apps/desktop/src/main/lib/terminal-escape-filter.test.ts deleted file mode 100644 index 8c455c4144f..00000000000 --- a/apps/desktop/src/main/lib/terminal-escape-filter.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { - containsClearScrollbackSequence, - extractContentAfterClear, -} from "./terminal-escape-filter"; - -const ESC = "\x1b"; - -describe("containsClearScrollbackSequence", () => { - it("should detect ED3 sequence", () => { - expect(containsClearScrollbackSequence(`${ESC}[3J`)).toBe(true); - }); - - it("should NOT detect RIS sequence (used by TUI apps for repaints)", () => { - expect(containsClearScrollbackSequence(`${ESC}c`)).toBe(false); - }); - - it("should detect ED3 in mixed content", () => { - expect(containsClearScrollbackSequence(`before${ESC}[3Jafter`)).toBe(true); - }); - - it("should NOT detect RIS in mixed content", () => { - expect(containsClearScrollbackSequence(`before${ESC}cafter`)).toBe(false); - }); - - it("should return false for no clear sequence", () => { - expect(containsClearScrollbackSequence("normal text")).toBe(false); - }); - - it("should return false for other escape sequences", () => { - expect(containsClearScrollbackSequence(`${ESC}[2J`)).toBe(false); // Clear screen (not scrollback) - expect(containsClearScrollbackSequence(`${ESC}[H`)).toBe(false); // Cursor home - expect(containsClearScrollbackSequence(`${ESC}c`)).toBe(false); // RIS (used by TUI apps) - }); -}); - -describe("extractContentAfterClear", () => { - describe("ED3 sequence handling", () => { - it("should return empty string for ED3 only", () => { - expect(extractContentAfterClear(`${ESC}[3J`)).toBe(""); - }); - - it("should return content after ED3", () => { - expect(extractContentAfterClear(`${ESC}[3Jnew content`)).toBe( - "new content", - ); - }); - - it("should drop content before ED3", () => { - expect(extractContentAfterClear(`old stuff${ESC}[3Jnew content`)).toBe( - "new content", - ); - }); - - it("should handle ED3 at end of data", () => { - expect(extractContentAfterClear(`some content${ESC}[3J`)).toBe(""); - }); - - it("should handle multiple ED3 sequences - use last one", () => { - expect(extractContentAfterClear(`a${ESC}[3Jb${ESC}[3Jc`)).toBe("c"); - }); - }); - - describe("RIS sequence handling (should NOT clear)", () => { - it("should NOT treat RIS as clear sequence - return original data", () => { - expect(extractContentAfterClear(`${ESC}c`)).toBe(`${ESC}c`); - }); - - it("should preserve RIS and surrounding content", () => { - expect(extractContentAfterClear(`${ESC}cnew content`)).toBe( - `${ESC}cnew content`, - ); - }); - - it("should preserve content around RIS", () => { - expect(extractContentAfterClear(`old stuff${ESC}cnew content`)).toBe( - `old stuff${ESC}cnew content`, - ); - }); - }); - - describe("mixed ED3 and RIS sequences", () => { - it("should only use ED3 even when RIS comes after", () => { - // RIS is ignored, only ED3 matters - expect(extractContentAfterClear(`a${ESC}[3Jb${ESC}cc`)).toBe(`b${ESC}cc`); - }); - - it("should use ED3 and preserve RIS before it", () => { - expect(extractContentAfterClear(`a${ESC}cb${ESC}[3Jc`)).toBe("c"); - }); - - it("should handle multiple ED3 sequences - use last one", () => { - expect( - extractContentAfterClear( - `first${ESC}[3Jsecond${ESC}cthird${ESC}[3Jfinal`, - ), - ).toBe("final"); - }); - }); - - describe("no clear sequence", () => { - it("should return original data when no clear sequence", () => { - expect(extractContentAfterClear("normal text")).toBe("normal text"); - }); - - it("should return original data with other escape sequences", () => { - const data = `${ESC}[32mgreen${ESC}[0m`; - expect(extractContentAfterClear(data)).toBe(data); - }); - - it("should return empty string for empty input", () => { - expect(extractContentAfterClear("")).toBe(""); - }); - }); - - describe("edge cases", () => { - it("should handle unicode content after clear", () => { - expect(extractContentAfterClear(`old${ESC}[3J日本語🎉`)).toBe("日本語🎉"); - }); - - it("should handle newlines after clear", () => { - expect(extractContentAfterClear(`old${ESC}[3J\nnew\nlines`)).toBe( - "\nnew\nlines", - ); - }); - - it("should handle ANSI colors after clear", () => { - const result = extractContentAfterClear( - `old${ESC}[3J${ESC}[32mgreen${ESC}[0m`, - ); - expect(result).toBe(`${ESC}[32mgreen${ESC}[0m`); - }); - - 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 deleted file mode 100644 index da5f240163d..00000000000 --- a/apps/desktop/src/main/lib/terminal-escape-filter.ts +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Utilities for detecting terminal clear scrollback sequences. - */ - -const ESC = "\x1b"; - -/** - * 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). - * - * Detected sequences: - * - ESC [ 3 J - Clear scrollback buffer (ED3) - * - * Note: ESC c (RIS) is intentionally not detected as TUI apps use it for repaints. - */ -export function containsClearScrollbackSequence(data: string): boolean { - return CLEAR_SCROLLBACK_PATTERN.test(data); -} - -/** - * Extracts content after the last clear scrollback sequence. - * When a clear sequence is detected, only the content AFTER the last - * clear sequence should be persisted to scrollback/history. - */ -export function extractContentAfterClear(data: string): string { - const ed3Index = data.lastIndexOf(ED3_SEQUENCE); - - if (ed3Index === -1) { - return data; - } - - return data.slice(ed3Index + ED3_SEQUENCE.length); -} diff --git a/apps/desktop/src/main/lib/terminal-history.test.ts b/apps/desktop/src/main/lib/terminal-history.test.ts deleted file mode 100644 index 3c9c8233bed..00000000000 --- a/apps/desktop/src/main/lib/terminal-history.test.ts +++ /dev/null @@ -1,263 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { promises as fs } from "node:fs"; -import { - getHistoryDir, - HistoryReader, - HistoryWriter, -} from "./terminal-history"; - -describe("HistoryWriter", () => { - const testWorkspaceId = "test-workspace"; - const testTabId = "test-tab"; - let historyDir: string; - - beforeEach(async () => { - historyDir = getHistoryDir(testWorkspaceId, testTabId); - try { - await fs.rm(historyDir, { recursive: true, force: true }); - } catch { - // Ignore if doesn't exist - } - }); - - afterEach(async () => { - try { - await fs.rm(historyDir, { recursive: true, force: true }); - } catch { - // Ignore if doesn't exist - } - }); - - it("should write scrollback to file", async () => { - const writer = new HistoryWriter( - testWorkspaceId, - testTabId, - "/test/cwd", - 80, - 24, - ); - - await writer.init(); - writer.write("Hello, World!\n"); - writer.write("Line 2\n"); - writer.write("Line 3"); - await writer.close(0); - - const reader = new HistoryReader(testWorkspaceId, testTabId); - const result = await reader.read(); - - expect(result.scrollback).toBe("Hello, World!\nLine 2\nLine 3"); - }); - - it("should write metadata with exit code", async () => { - const writer = new HistoryWriter( - testWorkspaceId, - testTabId, - "/test/cwd", - 120, - 40, - ); - - await writer.init(); - writer.write("Some output"); - await writer.close(42); - - const reader = new HistoryReader(testWorkspaceId, testTabId); - const result = await reader.read(); - - expect(result.metadata?.cwd).toBe("/test/cwd"); - expect(result.metadata?.cols).toBe(120); - expect(result.metadata?.rows).toBe(40); - expect(result.metadata?.exitCode).toBe(42); - expect(result.metadata?.startedAt).toBeDefined(); - expect(result.metadata?.endedAt).toBeDefined(); - }); - - it("should preserve initial scrollback and append new data", async () => { - // First session - const writer1 = new HistoryWriter( - testWorkspaceId, - testTabId, - "/test/cwd", - 80, - 24, - ); - await writer1.init(); - writer1.write("First session"); - await writer1.close(0); - - // Second session - recover and append - const reader1 = new HistoryReader(testWorkspaceId, testTabId); - const recovered = await reader1.read(); - - const writer2 = new HistoryWriter( - testWorkspaceId, - testTabId, - "/test/cwd", - 80, - 24, - ); - await writer2.init(recovered.scrollback); - writer2.write(" + Second session"); - await writer2.close(0); - - const reader2 = new HistoryReader(testWorkspaceId, testTabId); - const result = await reader2.read(); - - expect(result.scrollback).toBe("First session + Second session"); - }); - - it("should preserve ANSI escape codes", async () => { - const writer = new HistoryWriter( - testWorkspaceId, - testTabId, - "/test/cwd", - 80, - 24, - ); - - // ANSI codes for colors, cursor movement, etc. - const ansiData = - "\x1b[32mGreen text\x1b[0m\r\n\x1b[1;34mBold blue\x1b[0m\x1b[2J\x1b[H"; - - await writer.init(); - writer.write(ansiData); - await writer.close(0); - - const reader = new HistoryReader(testWorkspaceId, testTabId); - const result = await reader.read(); - - expect(result.scrollback).toBe(ansiData); - }); - - it("should handle many small writes", async () => { - const writer = new HistoryWriter( - testWorkspaceId, - testTabId, - "/test/cwd", - 80, - 24, - ); - - await writer.init(); - - // Simulate terminal output - many small chunks - const chunks = []; - for (let i = 0; i < 100; i++) { - const chunk = `line ${i}\r\n`; - chunks.push(chunk); - writer.write(chunk); - } - await writer.close(0); - - const reader = new HistoryReader(testWorkspaceId, testTabId); - const result = await reader.read(); - - expect(result.scrollback).toBe(chunks.join("")); - }); - - it("should handle binary-like terminal data", async () => { - const writer = new HistoryWriter( - testWorkspaceId, - testTabId, - "/test/cwd", - 80, - 24, - ); - - // Mix of printable, control chars, and unicode - const binaryLikeData = "Hello\x00World\x1b[31m红色\x1b[0m\t\r\n\x07Bell🔔"; - - await writer.init(); - writer.write(binaryLikeData); - await writer.close(0); - - const reader = new HistoryReader(testWorkspaceId, testTabId); - const result = await reader.read(); - - expect(result.scrollback).toBe(binaryLikeData); - }); -}); - -describe("HistoryReader", () => { - const testWorkspaceId = "test-workspace-reader"; - const testTabId = "test-tab-reader"; - let historyDir: string; - - beforeEach(async () => { - historyDir = getHistoryDir(testWorkspaceId, testTabId); - try { - await fs.rm(historyDir, { recursive: true, force: true }); - } catch { - // Ignore if doesn't exist - } - }); - - afterEach(async () => { - try { - await fs.rm(historyDir, { recursive: true, force: true }); - } catch { - // Ignore if doesn't exist - } - }); - - it("should return empty scrollback for non-existent history", async () => { - const reader = new HistoryReader(testWorkspaceId, testTabId); - const result = await reader.read(); - - expect(result.scrollback).toBe(""); - expect(result.metadata).toBeUndefined(); - }); - - it("should read entire scrollback without truncation", async () => { - const writer = new HistoryWriter( - testWorkspaceId, - testTabId, - "/test/cwd", - 80, - 24, - ); - - // Write 200KB of data - const largeData = "X".repeat(200000); - await writer.init(); - writer.write(largeData); - await writer.close(0); - - const reader = new HistoryReader(testWorkspaceId, testTabId); - const result = await reader.read(); - - // Should return the entire scrollback - expect(result.scrollback.length).toBe(200000); - expect(result.scrollback).toBe(largeData); - }); - - it("should cleanup history directory completely", async () => { - const writer = new HistoryWriter( - testWorkspaceId, - testTabId, - "/test/cwd", - 80, - 24, - ); - await writer.init(); - writer.write("Test data"); - await writer.close(0); - - // Verify files exist - expect(await fs.stat(historyDir)).toBeDefined(); - - // Cleanup - const reader = new HistoryReader(testWorkspaceId, testTabId); - await reader.cleanup(); - - // Verify directory is gone - try { - await fs.stat(historyDir); - throw new Error("Directory should not exist"); - } catch (error) { - // @ts-expect-error - expect(error.code).toBe("ENOENT"); - } - }); -}); diff --git a/apps/desktop/src/main/lib/terminal-history.ts b/apps/desktop/src/main/lib/terminal-history.ts deleted file mode 100644 index 2d8e313b330..00000000000 --- a/apps/desktop/src/main/lib/terminal-history.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { createWriteStream, promises as fs, type WriteStream } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { env } from "main/env.main"; -import { SUPERSET_HOME_DIR } from "./app-environment"; - -export interface SessionMetadata { - cwd: string; - cols: number; - rows: number; - startedAt: string; - endedAt?: string; - exitCode?: number; -} - -export function getHistoryDir(workspaceId: string, paneId: string): string { - const baseDir = - env.NODE_ENV === "test" - ? join(tmpdir(), "superset-test", ".superset") - : SUPERSET_HOME_DIR; - return join(baseDir, "terminal-history", workspaceId, paneId); -} - -function getHistoryFilePath(workspaceId: string, paneId: string): string { - return join(getHistoryDir(workspaceId, paneId), "scrollback.bin"); -} - -function getMetadataPath(workspaceId: string, paneId: string): string { - return join(getHistoryDir(workspaceId, paneId), "meta.json"); -} - -export class HistoryWriter { - private stream: WriteStream | null = null; - private filePath: string; - private metaPath: string; - private metadata: SessionMetadata; - private streamErrored = false; - - constructor( - private workspaceId: string, - private paneId: string, - cwd: string, - cols: number, - rows: number, - ) { - this.filePath = getHistoryFilePath(workspaceId, paneId); - this.metaPath = getMetadataPath(workspaceId, paneId); - this.metadata = { - cwd, - cols, - rows, - startedAt: new Date().toISOString(), - }; - } - - async init(initialScrollback?: string): Promise { - const dir = getHistoryDir(this.workspaceId, this.paneId); - 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, "utf8")); - } else { - await fs.writeFile(this.filePath, Buffer.alloc(0)); - } - - // Open stream in append mode for subsequent writes - this.stream = createWriteStream(this.filePath, { flags: "a" }); - this.stream.on("error", () => { - this.streamErrored = true; - this.stream = null; - }); - } - - write(data: string): void { - if (this.stream && !this.streamErrored) { - try { - // node-pty produces UTF-8 strings - this.stream.write(Buffer.from(data, "utf8")); - } catch { - this.streamErrored = true; - } - } - } - - async close(exitCode?: number): Promise { - if (this.stream && !this.streamErrored) { - try { - await new Promise((resolve) => { - this.stream?.end(() => resolve()); - }); - } catch { - // Ignore - } - } - this.stream = null; - - this.metadata.endedAt = new Date().toISOString(); - this.metadata.exitCode = exitCode; - try { - await fs.writeFile(this.metaPath, JSON.stringify(this.metadata, null, 2)); - } catch { - // Ignore metadata write errors on shutdown - } - } -} - -export class HistoryReader { - constructor( - private workspaceId: string, - private paneId: string, - ) {} - - async read(): Promise<{ scrollback: string; metadata?: SessionMetadata }> { - try { - const filePath = getHistoryFilePath(this.workspaceId, this.paneId); - // 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 { - const metaPath = getMetadataPath(this.workspaceId, this.paneId); - const metaContent = await fs.readFile(metaPath, "utf-8"); - metadata = JSON.parse(metaContent); - } catch { - // Metadata not available - } - - return { scrollback, metadata }; - } catch { - return { scrollback: "" }; - } - } - - async cleanup(): Promise { - try { - const dir = getHistoryDir(this.workspaceId, this.paneId); - await fs.rm(dir, { recursive: true, force: true }); - } catch (error) { - console.error("Failed to cleanup history:", error); - } - } -} diff --git a/apps/desktop/src/main/lib/terminal/manager.test.ts b/apps/desktop/src/main/lib/terminal/manager.test.ts deleted file mode 100644 index e49cb8bfca5..00000000000 --- a/apps/desktop/src/main/lib/terminal/manager.test.ts +++ /dev/null @@ -1,905 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; -import { promises as fs } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import * as pty from "node-pty"; - -// Mock node-pty -mock.module("node-pty", () => ({ - spawn: mock(() => {}), -})); - -// Mock analytics to avoid electron imports (analytics → api-client → auth → electron.shell) -mock.module("main/lib/analytics", () => ({ - track: mock(() => {}), -})); - -// Import manager after mocks are set up -const { TerminalManager } = await import("./manager"); - -// Use real history implementation - it will write to tmpdir thanks to NODE_ENV=test -const testTmpDir = join(tmpdir(), "superset-test"); - -describe("TerminalManager", () => { - let manager: InstanceType; - let mockPty: { - write: ReturnType; - resize: ReturnType; - kill: ReturnType; - onData: ReturnType; - onExit: ReturnType; - }; - - beforeEach(async () => { - // Clean up test history directory before each test - try { - await fs.rm(join(testTmpDir, ".superset/terminal-history"), { - recursive: true, - force: true, - }); - } catch { - // Ignore if doesn't exist - } - - manager = new TerminalManager(); - - // Setup mock pty - mockPty = { - write: mock(() => {}), - resize: mock(() => {}), - // biome-ignore lint/suspicious/noExplicitAny: Mock requires this binding for proper context - kill: mock(function (this: any, _signal?: string) { - // Automatically trigger onExit when kill is called to avoid timeouts in cleanup - const onExitCallback = - mockPty.onExit.mock.calls[mockPty.onExit.mock.calls.length - 1]?.[0]; - if (onExitCallback) { - // Use setImmediate to avoid blocking - setImmediate(async () => { - await onExitCallback({ exitCode: 0, signal: undefined }); - }); - } - }), - onData: mock((callback: (data: string) => void) => { - // Store callback for testing - mockPty.onData.mockImplementation(() => callback); - return callback; - }), - onExit: mock( - (callback: (event: { exitCode: number; signal?: number }) => void) => { - mockPty.onExit.mockImplementation(() => callback); - return callback; - }, - ), - }; - - (pty.spawn as ReturnType).mockReturnValue( - mockPty as unknown as pty.IPty, - ); - }); - - afterEach(async () => { - await manager.cleanup(); - mock.restore(); - }); - - describe("createOrAttach", () => { - it("should create a new terminal session", async () => { - const result = await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-1", - cwd: "/test/path", - cols: 80, - rows: 24, - }); - - expect(result.isNew).toBe(true); - expect(result.scrollback).toBe(""); - expect(result.wasRecovered).toBe(false); - expect(pty.spawn).toHaveBeenCalledWith( - expect.any(String), - expect.any(Array), - expect.objectContaining({ - cwd: "/test/path", - cols: 80, - rows: 24, - }), - ); - }); - - it("should reuse existing terminal session", async () => { - await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-1", - cwd: "/test/path", - }); - - const spawnCallCount = (pty.spawn as ReturnType).mock.calls - .length; - - const result = await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-1", - }); - - expect(result.isNew).toBe(false); - // Should not have spawned again - expect((pty.spawn as ReturnType).mock.calls.length).toBe( - spawnCallCount, - ); - }); - - it("should update size when reattaching with new dimensions", async () => { - await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-1", - cols: 80, - rows: 24, - }); - - await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-1", - cols: 100, - rows: 30, - }); - - expect(mockPty.resize).toHaveBeenCalledWith(100, 30); - }); - }); - - describe("write", () => { - it("should write data to terminal", async () => { - await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-1", - }); - - manager.write({ - paneId: "pane-1", - data: "ls -la\n", - }); - - expect(mockPty.write).toHaveBeenCalledWith("ls -la\n"); - }); - - it("should throw error for non-existent session", () => { - expect(() => { - manager.write({ - paneId: "non-existent", - data: "test", - }); - }).toThrow("Terminal session non-existent not found or not alive"); - }); - }); - - describe("resize", () => { - it("should resize terminal", async () => { - await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-1", - }); - - manager.resize({ - paneId: "pane-1", - cols: 120, - rows: 40, - }); - - expect(mockPty.resize).toHaveBeenCalledWith(120, 40); - }); - - it("should handle resize of non-existent session gracefully", () => { - // Mock console.warn to suppress the warning in test output - const warnSpy = mock(() => {}); - const originalWarn = console.warn; - console.warn = warnSpy; - - // Should not throw - expect(() => { - manager.resize({ - paneId: "non-existent", - cols: 80, - rows: 24, - }); - }).not.toThrow(); - - // Verify warning was called - expect(warnSpy).toHaveBeenCalledWith( - "Cannot resize terminal non-existent: session not found or not alive", - ); - - console.warn = originalWarn; - }); - }); - - describe("signal", () => { - it("should send signal to terminal", async () => { - await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-1", - }); - - manager.signal({ - paneId: "pane-1", - signal: "SIGINT", - }); - - expect(mockPty.kill).toHaveBeenCalledWith("SIGINT"); - }); - - it("should use SIGTERM by default", async () => { - await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-1", - }); - - manager.signal({ - paneId: "pane-1", - }); - - expect(mockPty.kill).toHaveBeenCalledWith("SIGTERM"); - }); - }); - - describe("kill", () => { - it("should kill and preserve history by default", async () => { - await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-1", - }); - - // Trigger some data to create history - const onDataCallback = - mockPty.onData.mock.calls[mockPty.onData.mock.calls.length - 1]?.[0]; - if (onDataCallback) { - onDataCallback("test output\n"); - } - - const exitPromise = new Promise((resolve) => { - manager.once("exit:pane-1", () => resolve()); - }); - - await manager.kill({ paneId: "pane-1" }); - - expect(mockPty.kill).toHaveBeenCalled(); - - const onExitCallback = - mockPty.onExit.mock.calls[mockPty.onExit.mock.calls.length - 1]?.[0]; - if (onExitCallback) { - await onExitCallback({ exitCode: 0, signal: undefined }); - } - - await exitPromise; - - // Verify history directory still exists (preserved) - const historyDir = join( - testTmpDir, - ".superset/terminal-history/workspace-1/pane-1", - ); - const stats = await fs.stat(historyDir); - expect(stats.isDirectory()).toBe(true); - }); - - it("should delete history when deleteHistory flag is true", async () => { - await manager.createOrAttach({ - paneId: "pane-delete-history", - tabId: "tab-delete-history", - workspaceId: "workspace-1", - }); - - // Trigger some data to create history - const onDataCallback = - mockPty.onData.mock.calls[mockPty.onData.mock.calls.length - 1]?.[0]; - if (onDataCallback) { - onDataCallback("test output\n"); - } - - const exitPromise = new Promise((resolve) => { - manager.once("exit:pane-delete-history", () => resolve()); - }); - - await manager.kill({ - paneId: "pane-delete-history", - deleteHistory: true, - }); - - expect(mockPty.kill).toHaveBeenCalled(); - - const onExitCallback = - mockPty.onExit.mock.calls[mockPty.onExit.mock.calls.length - 1]?.[0]; - if (onExitCallback) { - await onExitCallback({ exitCode: 0, signal: undefined }); - } - - await exitPromise; - - // Verify history directory was deleted - const historyDir = join( - testTmpDir, - ".superset/terminal-history/workspace-1/pane-delete-history", - ); - const exists = await fs - .stat(historyDir) - .then(() => true) - .catch(() => false); - expect(exists).toBe(false); - }); - - it("should preserve history for recovery after kill without deleteHistory", async () => { - // Create and write some data - await manager.createOrAttach({ - paneId: "pane-preserve", - tabId: "tab-preserve", - workspaceId: "workspace-1", - }); - - const onDataCallback = - mockPty.onData.mock.calls[mockPty.onData.mock.calls.length - 1]?.[0]; - if (onDataCallback) { - onDataCallback("Preserved output\n"); - } - - const exitPromise = new Promise((resolve) => { - manager.once("exit:pane-preserve", () => resolve()); - }); - - await manager.kill({ paneId: "pane-preserve" }); - - const onExitCallback = - mockPty.onExit.mock.calls[mockPty.onExit.mock.calls.length - 1]?.[0]; - if (onExitCallback) { - await onExitCallback({ exitCode: 0, signal: undefined }); - } - - await exitPromise; - - // Recreate session - should recover history from filesystem - const result = await manager.createOrAttach({ - paneId: "pane-preserve", - tabId: "tab-preserve", - workspaceId: "workspace-1", - }); - - expect(result.wasRecovered).toBe(true); - expect(result.scrollback).toContain("Preserved output"); - }); - }); - - describe("detach", () => { - it("should keep session alive after detach", async () => { - await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-1", - }); - - manager.detach({ paneId: "pane-1" }); - - const session = manager.getSession("pane-1"); - expect(session).not.toBeNull(); - expect(session?.isAlive).toBe(true); - }); - }); - - describe("getSession", () => { - it("should return session metadata", async () => { - await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-1", - cwd: "/test/path", - }); - - const session = manager.getSession("pane-1"); - - expect(session).toMatchObject({ - isAlive: true, - cwd: "/test/path", - }); - expect(session?.lastActive).toBeGreaterThan(0); - }); - - it("should return null for non-existent session", () => { - const session = manager.getSession("non-existent"); - expect(session).toBeNull(); - }); - }); - - describe("cleanup", () => { - it("should kill all sessions and wait for exit handlers", async () => { - await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-1", - }); - - await manager.createOrAttach({ - paneId: "pane-2", - tabId: "tab-2", - workspaceId: "workspace-1", - }); - - const cleanupPromise = manager.cleanup(); - - const onExitCallback1 = mockPty.onExit.mock.calls[0]?.[0]; - const onExitCallback2 = mockPty.onExit.mock.calls[1]?.[0]; - - if (onExitCallback1) { - await onExitCallback1({ exitCode: 0, signal: undefined }); - } - if (onExitCallback2) { - await onExitCallback2({ exitCode: 0, signal: undefined }); - } - - await cleanupPromise; - - expect(mockPty.kill).toHaveBeenCalledTimes(2); - }); - - it("should preserve history during cleanup", async () => { - await manager.createOrAttach({ - paneId: "pane-cleanup", - tabId: "tab-cleanup", - workspaceId: "workspace-1", - }); - - const onDataCallback = - mockPty.onData.mock.calls[mockPty.onData.mock.calls.length - 1]?.[0]; - if (onDataCallback) { - onDataCallback("Test output during cleanup\n"); - } - - const cleanupPromise = manager.cleanup(); - - const onExitCallback = - mockPty.onExit.mock.calls[mockPty.onExit.mock.calls.length - 1]?.[0]; - if (onExitCallback) { - await onExitCallback({ exitCode: 0, signal: undefined }); - } - - await cleanupPromise; - - // Verify history was preserved (directory still exists) - const historyDir = join( - testTmpDir, - ".superset/terminal-history/workspace-1/pane-cleanup", - ); - const stats = await fs.stat(historyDir); - expect(stats.isDirectory()).toBe(true); - }); - }); - - describe("event handling", () => { - it("should emit data events", async () => { - const dataHandler = mock(() => {}); - - await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-1", - }); - - manager.on("data:pane-1", dataHandler); - - const onDataCallback = mockPty.onData.mock.results[0]?.value; - if (onDataCallback) { - onDataCallback("test output\n"); - } - - // Wait for DataBatcher to flush (16ms batching interval) - await new Promise((resolve) => setTimeout(resolve, 30)); - - expect(dataHandler).toHaveBeenCalledWith("test output\n"); - }); - - it("should pass through raw data including escape sequences", async () => { - const dataHandler = mock(() => {}); - - await manager.createOrAttach({ - paneId: "pane-raw", - tabId: "tab-raw", - workspaceId: "workspace-1", - }); - - manager.on("data:pane-raw", dataHandler); - - const onDataCallback = mockPty.onData.mock.results[0]?.value; - const dataWithEscapes = - "hello\x1b[2;1R\x1b[?1;0cworld\x1b]10;rgb:ffff/ffff/ffff\x07\n"; - if (onDataCallback) { - onDataCallback(dataWithEscapes); - } - - // Wait for DataBatcher to flush (16ms batching interval) - await new Promise((resolve) => setTimeout(resolve, 30)); - - // Raw data passed through unchanged - expect(dataHandler).toHaveBeenCalledWith(dataWithEscapes); - }); - - it("should emit exit events", async () => { - const exitHandler = mock(() => {}); - - await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-1", - }); - - // Listen for exit event - const exitPromise = new Promise((resolve) => { - manager.once("exit:pane-1", () => resolve()); - }); - - manager.on("exit:pane-1", exitHandler); - - const onExitCallback = mockPty.onExit.mock.results[0]?.value; - if (onExitCallback) { - await onExitCallback({ exitCode: 0, signal: undefined }); - } - - await exitPromise; - - expect(exitHandler).toHaveBeenCalledWith(0, undefined); - }); - }); - - describe("killByWorkspaceId", () => { - it("should kill session for a workspace and return count", async () => { - await manager.createOrAttach({ - paneId: "pane-kill-single", - tabId: "tab-kill-single", - workspaceId: "workspace-kill-single", - }); - - const result = await manager.killByWorkspaceId("workspace-kill-single"); - - // With the mock, the session exits cleanly via the kill mock's setImmediate - expect(result.killed + result.failed).toBe(1); - }); - - it("should not kill sessions from other workspaces", async () => { - await manager.createOrAttach({ - paneId: "pane-other", - tabId: "tab-other", - workspaceId: "workspace-other", - }); - - await manager.killByWorkspaceId("workspace-different"); - - // Session should still exist - expect(manager.getSession("pane-other")).not.toBeNull(); - expect(manager.getSession("pane-other")?.isAlive).toBe(true); - }); - - it("should return zero counts for non-existent workspace", async () => { - const result = await manager.killByWorkspaceId("non-existent"); - - expect(result.killed).toBe(0); - expect(result.failed).toBe(0); - }); - - it("should delete history for killed sessions", async () => { - await manager.createOrAttach({ - paneId: "pane-kill-history", - tabId: "tab-kill-history", - workspaceId: "workspace-kill", - }); - - // Trigger some data to create history - const onDataCallback = - mockPty.onData.mock.calls[mockPty.onData.mock.calls.length - 1]?.[0]; - if (onDataCallback) { - onDataCallback("test output\n"); - } - - await manager.killByWorkspaceId("workspace-kill"); - - // Wait a bit for cleanup to complete - await new Promise((resolve) => setTimeout(resolve, 100)); - - // Verify history directory was deleted - const historyDir = join( - testTmpDir, - ".superset/terminal-history/workspace-kill/pane-kill-history", - ); - const exists = await fs - .stat(historyDir) - .then(() => true) - .catch(() => false); - expect(exists).toBe(false); - }); - - it("should clean up already-dead sessions", async () => { - await manager.createOrAttach({ - paneId: "pane-dead", - tabId: "tab-dead", - workspaceId: "workspace-dead", - }); - - // Simulate the session dying naturally - const onExitCallback = - mockPty.onExit.mock.calls[mockPty.onExit.mock.calls.length - 1]?.[0]; - if (onExitCallback) { - await onExitCallback({ exitCode: 0, signal: undefined }); - } - - // Wait for the dead session to be kept in map (5s timeout in onExit) - await new Promise((resolve) => setTimeout(resolve, 100)); - - const result = await manager.killByWorkspaceId("workspace-dead"); - - expect(result.killed).toBe(1); - expect(result.failed).toBe(0); - }); - }); - - describe("getSessionCountByWorkspaceId", () => { - it("should return count of active sessions for workspace", async () => { - await manager.createOrAttach({ - paneId: "pane-1", - tabId: "tab-1", - workspaceId: "workspace-count", - }); - - await manager.createOrAttach({ - paneId: "pane-2", - tabId: "tab-2", - workspaceId: "workspace-count", - }); - - await manager.createOrAttach({ - paneId: "pane-3", - tabId: "tab-3", - workspaceId: "other-workspace", - }); - - expect(manager.getSessionCountByWorkspaceId("workspace-count")).toBe(2); - expect(manager.getSessionCountByWorkspaceId("other-workspace")).toBe(1); - }); - - it("should return zero for non-existent workspace", () => { - expect(manager.getSessionCountByWorkspaceId("non-existent")).toBe(0); - }); - - it("should not count dead sessions", async () => { - await manager.createOrAttach({ - paneId: "pane-alive", - tabId: "tab-alive", - workspaceId: "workspace-mixed", - }); - - await manager.createOrAttach({ - paneId: "pane-dead", - tabId: "tab-dead", - workspaceId: "workspace-mixed", - }); - - // Simulate the second session dying - const onExitCallback = - mockPty.onExit.mock.calls[mockPty.onExit.mock.calls.length - 1]?.[0]; - if (onExitCallback) { - await onExitCallback({ exitCode: 0, signal: undefined }); - } - - // Wait for state to update - await new Promise((resolve) => setTimeout(resolve, 100)); - - expect(manager.getSessionCountByWorkspaceId("workspace-mixed")).toBe(1); - }); - }); - - describe("clearScrollback", () => { - it("should clear in-memory scrollback", async () => { - await manager.createOrAttach({ - paneId: "pane-clear", - tabId: "tab-clear", - workspaceId: "workspace-1", - }); - - const onDataCallback = - mockPty.onData.mock.calls[mockPty.onData.mock.calls.length - 1]?.[0]; - if (onDataCallback) { - onDataCallback("some output\n"); - } - - await manager.clearScrollback({ paneId: "pane-clear" }); - - const result = await manager.createOrAttach({ - paneId: "pane-clear", - tabId: "tab-clear", - workspaceId: "workspace-1", - }); - - expect(result.scrollback).toBe(""); - }); - - it("should reinitialize history file", async () => { - await manager.createOrAttach({ - paneId: "pane-clear-history", - tabId: "tab-clear-history", - workspaceId: "workspace-clear", - }); - - const onDataCallback = - mockPty.onData.mock.calls[mockPty.onData.mock.calls.length - 1]?.[0]; - if (onDataCallback) { - onDataCallback("output before clear\n"); - } - - await manager.clearScrollback({ paneId: "pane-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({ - paneId: "pane-clear-history", - tabId: "tab-clear-history", - workspaceId: "workspace-clear", - }); - - 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({ paneId: "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({ - paneId: "pane-shell-clear", - tabId: "tab-shell-clear", - workspaceId: "workspace-1", - }); - - 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({ - paneId: "pane-shell-clear", - tabId: "tab-shell-clear", - workspaceId: "workspace-1", - }); - - // Only content after the clear sequence should remain - expect(result.scrollback).not.toContain("some output"); - expect(result.scrollback).toContain("new content after clear"); - // ED3 sequence itself should NOT be in scrollback - expect(result.scrollback).not.toContain("\x1b[3J"); - }); - - it("should not persist content before clear sequence", async () => { - await manager.createOrAttach({ - paneId: "pane-clear-before", - tabId: "tab-clear-before", - workspaceId: "workspace-1", - }); - - const onDataCallback = - mockPty.onData.mock.calls[mockPty.onData.mock.calls.length - 1]?.[0]; - if (onDataCallback) { - // Content before and after clear in same chunk - onDataCallback("old content\x1b[3Jnew content"); - } - - const result = await manager.createOrAttach({ - paneId: "pane-clear-before", - tabId: "tab-clear-before", - workspaceId: "workspace-1", - }); - - // Old content should be gone, only new content remains - expect(result.scrollback).not.toContain("old content"); - expect(result.scrollback).toContain("new content"); - expect(result.scrollback).not.toContain("\x1b[3J"); - }); - }); - - describe("multi-session history persistence", () => { - it("should persist history across multiple sessions", async () => { - // Session 1: Create and write data - const result1 = await manager.createOrAttach({ - paneId: "pane-multi", - tabId: "tab-multi", - workspaceId: "workspace-1", - }); - - expect(result1.isNew).toBe(true); - expect(result1.wasRecovered).toBe(false); - - const onDataCallback1 = - mockPty.onData.mock.calls[mockPty.onData.mock.calls.length - 1]?.[0]; - if (onDataCallback1) { - onDataCallback1("Session 1 output\n"); - } - - const exitPromise1 = new Promise((resolve) => { - manager.once("exit:pane-multi", () => resolve()); - }); - - const onExitCallback1 = - mockPty.onExit.mock.calls[mockPty.onExit.mock.calls.length - 1]?.[0]; - if (onExitCallback1) { - await onExitCallback1({ exitCode: 0, signal: undefined }); - } - - await exitPromise1; - await manager.cleanup(); - - // Session 2: Should recover Session 1 data - const result2 = await manager.createOrAttach({ - paneId: "pane-multi", - tabId: "tab-multi", - workspaceId: "workspace-1", - }); - - expect(result2.isNew).toBe(true); - expect(result2.wasRecovered).toBe(true); - expect(result2.scrollback).toContain("Session 1 output"); - - const onDataCallback2 = - mockPty.onData.mock.calls[mockPty.onData.mock.calls.length - 1]?.[0]; - if (onDataCallback2) { - onDataCallback2("Session 2 output\n"); - } - - const exitPromise2 = new Promise((resolve) => { - manager.once("exit:pane-multi", () => resolve()); - }); - - const onExitCallback2 = - mockPty.onExit.mock.calls[mockPty.onExit.mock.calls.length - 1]?.[0]; - if (onExitCallback2) { - await onExitCallback2({ exitCode: 0, signal: undefined }); - } - - await exitPromise2; - await manager.cleanup(); - - // Session 3: Should recover both Session 1 and Session 2 data - const result3 = await manager.createOrAttach({ - paneId: "pane-multi", - tabId: "tab-multi", - workspaceId: "workspace-1", - }); - - expect(result3.isNew).toBe(true); - expect(result3.wasRecovered).toBe(true); - expect(result3.scrollback).toContain("Session 1 output"); - expect(result3.scrollback).toContain("Session 2 output"); - }); - }); -}); diff --git a/apps/desktop/src/main/lib/terminal/manager.ts b/apps/desktop/src/main/lib/terminal/manager.ts index b42996254c6..59adfe6054c 100644 --- a/apps/desktop/src/main/lib/terminal/manager.ts +++ b/apps/desktop/src/main/lib/terminal/manager.ts @@ -3,16 +3,9 @@ import { track } from "main/lib/analytics"; import { ensureAgentHooks } from "../agent-setup/ensure-agent-hooks"; import { FALLBACK_SHELL, SHELL_CRASH_THRESHOLD_MS } from "./env"; import { portManager } from "./port-manager"; -import { - closeSessionHistory, - createSession, - flushSession, - reinitializeHistory, - setupDataHandler, -} from "./session"; +import { createSession, setupInitialCommands } from "./session"; import type { CreateSessionParams, - InternalCreateSessionParams, SessionResult, TerminalSession, } from "./types"; @@ -39,16 +32,12 @@ export class TerminalManager extends EventEmitter { } return { isNew: false, - scrollback: existing.scrollback, - wasRecovered: existing.wasRecovered, + serializedState: existing.serializedState || "", }; } // Create new session - const creationPromise = this.doCreateSession({ - ...params, - existingScrollback: existing?.scrollback || null, - }); + const creationPromise = this.doCreateSession(params); this.pendingSessions.set(paneId, creationPromise); try { @@ -59,7 +48,7 @@ export class TerminalManager extends EventEmitter { } private async doCreateSession( - params: InternalCreateSessionParams, + params: CreateSessionParams & { useFallbackShell?: boolean }, ): Promise { const { paneId, workspaceId, initialCommands } = params; @@ -78,12 +67,10 @@ export class TerminalManager extends EventEmitter { initialCommands?.some((command) => agentCommandPattern.test(command)) ?? false; - // Set up data handler - setupDataHandler( + // Set up initial commands (only for new sessions) + setupInitialCommands( session, initialCommands, - session.wasRecovered, - () => reinitializeHistory(session), shouldAwaitAgentHooks ? agentHooksReady : undefined, ); @@ -99,20 +86,18 @@ export class TerminalManager extends EventEmitter { return { isNew: true, - scrollback: session.scrollback, - wasRecovered: session.wasRecovered, + serializedState: "", }; } private setupExitHandler( session: TerminalSession, - params: InternalCreateSessionParams, + params: CreateSessionParams & { useFallbackShell?: boolean }, ): void { const { paneId } = params; session.pty.onExit(async ({ exitCode, signal }) => { session.isAlive = false; - flushSession(session); // Check if shell crashed quickly - try fallback const sessionDuration = Date.now() - session.startTime; @@ -124,13 +109,11 @@ export class TerminalManager extends EventEmitter { `[TerminalManager] Shell "${session.shell}" exited with code ${exitCode} after ${sessionDuration}ms, retrying with fallback shell "${FALLBACK_SHELL}"`, ); - await closeSessionHistory(session, exitCode); this.sessions.delete(paneId); try { await this.doCreateSession({ ...params, - existingScrollback: session.scrollback || null, useFallbackShell: true, }); return; // Recovered - don't emit exit @@ -142,8 +125,6 @@ export class TerminalManager extends EventEmitter { } } - await closeSessionHistory(session, exitCode); - // Unregister from port manager (also removes detected ports) portManager.unregisterSession(paneId); @@ -222,11 +203,8 @@ export class TerminalManager extends EventEmitter { session.lastActive = Date.now(); } - async kill(params: { - paneId: string; - deleteHistory?: boolean; - }): Promise { - const { paneId, deleteHistory = false } = params; + async kill(params: { paneId: string }): Promise { + const { paneId } = params; const session = this.sessions.get(paneId); if (!session) { @@ -234,20 +212,15 @@ export class TerminalManager extends EventEmitter { return; } - if (deleteHistory) { - session.deleteHistoryOnExit = true; - } - if (session.isAlive) { session.pty.kill(); } else { - await closeSessionHistory(session); this.sessions.delete(paneId); } } - detach(params: { paneId: string }): void { - const { paneId } = params; + detach(params: { paneId: string; serializedState?: string }): void { + const { paneId, serializedState } = params; const session = this.sessions.get(paneId); if (!session) { @@ -255,10 +228,13 @@ export class TerminalManager extends EventEmitter { return; } + if (serializedState) { + session.serializedState = serializedState; + } session.lastActive = Date.now(); } - async clearScrollback(params: { paneId: string }): Promise { + clearScrollback(params: { paneId: string }): void { const { paneId } = params; const session = this.sessions.get(paneId); @@ -269,8 +245,7 @@ export class TerminalManager extends EventEmitter { return; } - session.scrollback = ""; - await reinitializeHistory(session); + session.serializedState = ""; session.lastActive = Date.now(); } @@ -315,14 +290,10 @@ export class TerminalManager extends EventEmitter { session: TerminalSession, ): Promise { if (!session.isAlive) { - session.deleteHistoryOnExit = true; - await closeSessionHistory(session); this.sessions.delete(paneId); return true; } - session.deleteHistoryOnExit = true; - return new Promise((resolve) => { let resolved = false; let sigtermTimeout: ReturnType | undefined; @@ -359,7 +330,6 @@ export class TerminalManager extends EventEmitter { ); session.isAlive = false; this.sessions.delete(paneId); - closeSessionHistory(session).catch(() => {}); } cleanup(false); }, 500); @@ -374,7 +344,6 @@ export class TerminalManager extends EventEmitter { console.error(`Failed to send SIGTERM to terminal ${paneId}:`, error); session.isAlive = false; this.sessions.delete(paneId); - closeSessionHistory(session).catch(() => {}); cleanup(false); } }); @@ -439,8 +408,6 @@ export class TerminalManager extends EventEmitter { exitPromises.push(exitPromise); session.pty.kill(); - } else { - await closeSessionHistory(session); } } diff --git a/apps/desktop/src/main/lib/terminal/port-hints.ts b/apps/desktop/src/main/lib/terminal/port-hints.ts deleted file mode 100644 index c34f351c446..00000000000 --- a/apps/desktop/src/main/lib/terminal/port-hints.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Lightweight patterns to detect when terminal output suggests a port may have been opened. - * These are used as hints to trigger an immediate process-based scan, not as the source of truth. - */ - -const HINT_PATTERNS = [ - /localhost:\d{2,5}/i, - /127\.0\.0\.1:\d{2,5}/, - /0\.0\.0\.0:\d{2,5}/, - /https?:\/\/[^:/]+:\d{2,5}/i, - /listening (?:on|at)/i, - /server (?:running|started|is running)/i, - /ready (?:on|at|in)/i, - /started (?:on|at)/i, - /bound to (?:port)?/i, - /development server/i, - /serving (?:on|at)/i, - /next\.?js/i, - /vite/i, - /webpack.*compiled/i, - /express/i, - /fastify/i, -]; - -/** - * Check if terminal output contains hints that a port may have been opened. - * This is a lightweight check - false positives are acceptable since we verify - * with actual process scanning. - */ -export function containsPortHint(data: string): boolean { - if (data.length < 10) return false; - - return HINT_PATTERNS.some((pattern) => pattern.test(data)); -} diff --git a/apps/desktop/src/main/lib/terminal/port-manager.ts b/apps/desktop/src/main/lib/terminal/port-manager.ts index 3e0d720c526..dba44645266 100644 --- a/apps/desktop/src/main/lib/terminal/port-manager.ts +++ b/apps/desktop/src/main/lib/terminal/port-manager.ts @@ -1,16 +1,11 @@ import { EventEmitter } from "node:events"; import type { DetectedPort } from "shared/types"; -import { containsPortHint } from "./port-hints"; import { getListeningPortsForPids, getProcessTree } from "./port-scanner"; import type { TerminalSession } from "./types"; // How often to poll for port changes (in ms) const SCAN_INTERVAL_MS = 2500; -// Delay before running an immediate scan triggered by output hint (in ms) -// This gives the server time to fully bind the port -const HINT_SCAN_DELAY_MS = 500; - // Ports to ignore (common system ports that are usually not dev servers) const IGNORED_PORTS = new Set([22, 80, 443, 5432, 3306, 6379, 27017]); @@ -53,26 +48,6 @@ class PortManager extends EventEmitter { } } - /** - * Check terminal output for hints that a port may have been opened. - * If a hint is detected, schedule an immediate scan for that pane. - */ - checkOutputForHint(data: string, paneId: string): void { - if (!containsPortHint(data)) return; - - const existing = this.pendingHintScans.get(paneId); - if (existing) { - clearTimeout(existing); - } - - const timeout = setTimeout(() => { - this.pendingHintScans.delete(paneId); - this.scanPane(paneId).catch(() => {}); - }, HINT_SCAN_DELAY_MS); - - this.pendingHintScans.set(paneId, timeout); - } - /** * Start periodic scanning of all registered sessions */ @@ -104,28 +79,6 @@ class PortManager extends EventEmitter { this.pendingHintScans.clear(); } - /** - * Scan a specific pane for ports - */ - private async scanPane(paneId: string): Promise { - const registered = this.sessions.get(paneId); - if (!registered) return; - - const { session, workspaceId } = registered; - if (!session.isAlive) return; - - try { - const pid = session.pty.pid; - const pids = await getProcessTree(pid); - if (pids.length === 0) return; - - const portInfos = getListeningPortsForPids(pids); - this.updatePortsForPane(paneId, workspaceId, portInfos); - } catch (error) { - console.error(`[PortManager] Error scanning pane ${paneId}:`, error); - } - } - /** * Scan all registered sessions for ports */ diff --git a/apps/desktop/src/main/lib/terminal/session.test.ts b/apps/desktop/src/main/lib/terminal/session.test.ts deleted file mode 100644 index c0b4028aa0d..00000000000 --- a/apps/desktop/src/main/lib/terminal/session.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { describe, expect, it } from "bun:test"; -import { promises as fs } from "node:fs"; -import { join } from "node:path"; -import { getHistoryDir } from "../terminal-history"; -import { flushSession, recoverScrollback } from "./session"; -import type { TerminalSession } from "./types"; - -describe("session", () => { - describe("recoverScrollback", () => { - it("should return existing scrollback if provided", async () => { - const result = await recoverScrollback( - "existing content", - "workspace-1", - "pane-1", - ); - - expect(result.scrollback).toBe("existing content"); - expect(result.wasRecovered).toBe(true); - }); - - it("should return empty scrollback when no history exists", async () => { - const result = await recoverScrollback( - null, - "non-existent-workspace", - "non-existent-pane", - ); - - expect(result.scrollback).toBe(""); - expect(result.wasRecovered).toBe(false); - }); - - it("should recover scrollback from disk", async () => { - const workspaceId = "workspace-recover-test"; - const paneId = "pane-recover-test"; - const historyDir = getHistoryDir(workspaceId, paneId); - - // Create test history file - await fs.mkdir(historyDir, { recursive: true }); - const scrollbackContent = "hello world"; - await fs.writeFile(join(historyDir, "scrollback.bin"), scrollbackContent); - - try { - const result = await recoverScrollback(null, workspaceId, paneId); - - expect(result.wasRecovered).toBe(true); - expect(result.scrollback).toBe(scrollbackContent); - } finally { - // Cleanup - await fs.rm(historyDir, { recursive: true, force: true }); - } - }); - - it("should prefer existing scrollback over disk history", async () => { - const workspaceId = "workspace-prefer-existing"; - const paneId = "pane-prefer-existing"; - const historyDir = getHistoryDir(workspaceId, paneId); - - // Create disk history - await fs.mkdir(historyDir, { recursive: true }); - await fs.writeFile(join(historyDir, "scrollback.bin"), "disk content"); - - try { - const result = await recoverScrollback( - "memory content", - workspaceId, - paneId, - ); - - // Should use the provided existing scrollback, not disk - expect(result.scrollback).toBe("memory content"); - expect(result.wasRecovered).toBe(true); - } finally { - await fs.rm(historyDir, { recursive: true, force: true }); - } - }); - }); - - describe("flushSession", () => { - it("should dispose data batcher", () => { - let disposed = false; - const mockDataBatcher = { - dispose: () => { - disposed = true; - }, - }; - - const mockSession = { - dataBatcher: mockDataBatcher, - scrollback: "initial", - } as unknown as TerminalSession; - - flushSession(mockSession); - - expect(disposed).toBe(true); - }); - }); -}); diff --git a/apps/desktop/src/main/lib/terminal/session.ts b/apps/desktop/src/main/lib/terminal/session.ts index 50f6671ff9a..76135b2b4a3 100644 --- a/apps/desktop/src/main/lib/terminal/session.ts +++ b/apps/desktop/src/main/lib/terminal/session.ts @@ -1,14 +1,7 @@ import os from "node:os"; import * as pty from "node-pty"; import { getShellArgs } from "../agent-setup"; -import { DataBatcher } from "../data-batcher"; -import { - containsClearScrollbackSequence, - extractContentAfterClear, -} from "../terminal-escape-filter"; -import { HistoryReader, HistoryWriter } from "../terminal-history"; import { buildTerminalEnv, FALLBACK_SHELL, getDefaultShell } from "./env"; -import { portManager } from "./port-manager"; import type { InternalCreateSessionParams, TerminalSession } from "./types"; const DEFAULT_COLS = 80; @@ -16,31 +9,6 @@ const DEFAULT_ROWS = 24; /** Max time to wait for agent hooks before running initial commands */ const AGENT_HOOKS_TIMEOUT_MS = 2000; -export async function recoverScrollback( - existingScrollback: string | null, - workspaceId: string, - paneId: string, -): Promise<{ scrollback: string; wasRecovered: boolean }> { - if (existingScrollback) { - return { scrollback: existingScrollback, wasRecovered: true }; - } - - const historyReader = new HistoryReader(workspaceId, paneId); - const history = await historyReader.read(); - - if (history.scrollback) { - // Keep only a reasonable amount of scrollback history - const MAX_SCROLLBACK_CHARS = 500_000; - const scrollback = - history.scrollback.length > MAX_SCROLLBACK_CHARS - ? history.scrollback.slice(-MAX_SCROLLBACK_CHARS) - : history.scrollback; - return { scrollback, wasRecovered: true }; - } - - return { scrollback: "", wasRecovered: false }; -} - function spawnPty(params: { shell: string; cols: number; @@ -74,7 +42,6 @@ export async function createSession( cwd, cols, rows, - existingScrollback, useFallbackShell = false, } = params; @@ -93,12 +60,6 @@ export async function createSession( rootPath, }); - const { scrollback: recoveredScrollback, wasRecovered } = - await recoverScrollback(existingScrollback, workspaceId, paneId); - - // Note: Port detection is now process-based (via PortManager periodic scanning), - // so we don't need to scan recovered scrollback for port patterns. - const ptyProcess = spawnPty({ shell, cols: terminalCols, @@ -107,20 +68,7 @@ export async function createSession( env, }); - const historyWriter = new HistoryWriter( - workspaceId, - paneId, - workingDir, - terminalCols, - terminalRows, - ); - await historyWriter.init(recoveredScrollback || undefined); - - const dataBatcher = new DataBatcher((batchedData) => { - onData(paneId, batchedData); - }); - - return { + const session: TerminalSession = { pty: ptyProcess, paneId, workspaceId, @@ -128,110 +76,54 @@ export async function createSession( cols: terminalCols, rows: terminalRows, lastActive: Date.now(), - scrollback: recoveredScrollback, isAlive: true, - wasRecovered, - historyWriter, - dataBatcher, shell, startTime: Date.now(), usedFallback: useFallbackShell, }; + + ptyProcess.onData((data) => { + onData(paneId, data); + }); + + return session; } -export function setupDataHandler( +/** + * Set up initial commands to run after shell prompt is ready. + * Commands are only sent for new sessions (not reattachments). + */ +export function setupInitialCommands( session: TerminalSession, initialCommands: string[] | undefined, - wasRecovered: boolean, - onHistoryReinit: () => Promise, beforeInitialCommands?: Promise, ): void { - const initialCommandString = - !wasRecovered && initialCommands && initialCommands.length > 0 - ? `${initialCommands.join(" && ")}\n` - : null; - let commandsSent = false; - - session.pty.onData((data) => { - let dataToStore = data; - - if (containsClearScrollbackSequence(data)) { - session.scrollback = ""; - onHistoryReinit().catch(() => {}); - dataToStore = extractContentAfterClear(data); - } - - session.scrollback += dataToStore; - session.historyWriter?.write(dataToStore); - - // Check for hints that a port may have been opened (triggers immediate scan) - portManager.checkOutputForHint(dataToStore, session.paneId); - - session.dataBatcher.write(data); - - if (initialCommandString && !commandsSent) { - commandsSent = true; - setTimeout(() => { - if (session.isAlive) { - void (async () => { - if (beforeInitialCommands) { - const timeout = new Promise((resolve) => - setTimeout(resolve, AGENT_HOOKS_TIMEOUT_MS), - ); - await Promise.race([beforeInitialCommands, timeout]).catch( - () => {}, - ); - } - - if (session.isAlive) { - session.pty.write(initialCommandString); - } - })(); - } - }, 100); - } - }); -} - -export async function closeSessionHistory( - session: TerminalSession, - exitCode?: number, -): Promise { - if (session.deleteHistoryOnExit) { - if (session.historyWriter) { - await session.historyWriter.close(); - session.historyWriter = undefined; - } - const historyReader = new HistoryReader( - session.workspaceId, - session.paneId, - ); - await historyReader.cleanup(); + if (!initialCommands || initialCommands.length === 0) { return; } - if (session.historyWriter) { - await session.historyWriter.close(exitCode); - session.historyWriter = undefined; - } -} - -export async function reinitializeHistory( - session: TerminalSession, -): Promise { - if (session.historyWriter) { - await session.historyWriter.close(); - session.historyWriter = new HistoryWriter( - session.workspaceId, - session.paneId, - session.cwd, - session.cols, - session.rows, - ); - await session.historyWriter.init(); - } -} - -export function flushSession(session: TerminalSession): void { - session.dataBatcher.dispose(); + const initialCommandString = `${initialCommands.join(" && ")}\n`; + + const dataHandler = session.pty.onData(() => { + dataHandler.dispose(); + + setTimeout(() => { + if (session.isAlive) { + void (async () => { + if (beforeInitialCommands) { + const timeout = new Promise((resolve) => + setTimeout(resolve, AGENT_HOOKS_TIMEOUT_MS), + ); + await Promise.race([beforeInitialCommands, timeout]).catch( + () => {}, + ); + } + + if (session.isAlive) { + session.pty.write(initialCommandString); + } + })(); + } + }, 100); + }); } diff --git a/apps/desktop/src/main/lib/terminal/types.ts b/apps/desktop/src/main/lib/terminal/types.ts index 0a53eb35a78..c8893a65134 100644 --- a/apps/desktop/src/main/lib/terminal/types.ts +++ b/apps/desktop/src/main/lib/terminal/types.ts @@ -1,6 +1,4 @@ import type * as pty from "node-pty"; -import type { DataBatcher } from "../data-batcher"; -import type { HistoryWriter } from "../terminal-history"; export interface TerminalSession { pty: pty.IPty; @@ -10,12 +8,8 @@ export interface TerminalSession { cols: number; rows: number; lastActive: number; - scrollback: string; + serializedState?: string; isAlive: boolean; - deleteHistoryOnExit?: boolean; - wasRecovered: boolean; - historyWriter?: HistoryWriter; - dataBatcher: DataBatcher; shell: string; startTime: number; usedFallback: boolean; @@ -36,8 +30,8 @@ export type TerminalEvent = TerminalDataEvent | TerminalExitEvent; export interface SessionResult { isNew: boolean; - scrollback: string; - wasRecovered: boolean; + /** Serialized terminal state from xterm's SerializeAddon */ + serializedState: string; } export interface CreateSessionParams { @@ -54,6 +48,5 @@ export interface CreateSessionParams { } export interface InternalCreateSessionParams extends CreateSessionParams { - existingScrollback: string | null; useFallbackShell?: boolean; } 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 2f764dfaadd..fae84d9684e 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 @@ -1,6 +1,7 @@ import { toast } from "@superset/ui/sonner"; import type { FitAddon } from "@xterm/addon-fit"; import type { SearchAddon } from "@xterm/addon-search"; +import type { SerializeAddon } from "@xterm/addon-serialize"; import type { Terminal as XTerm } from "@xterm/xterm"; import "@xterm/xterm/css/xterm.css"; import debounce from "lodash/debounce"; @@ -38,10 +39,9 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const xtermRef = useRef(null); const fitAddonRef = useRef(null); const searchAddonRef = useRef(null); + const serializeAddonRef = useRef(null); const isExitedRef = useRef(false); - const pendingEventsRef = useRef([]); const commandBufferRef = useRef(""); - const [subscriptionEnabled, setSubscriptionEnabled] = useState(false); const [isSearchOpen, setIsSearchOpen] = useState(false); const [terminalCwd, setTerminalCwd] = useState(null); const [cwdConfirmed, setCwdConfirmed] = useState(false); @@ -210,9 +210,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { ); const handleStreamData = (event: TerminalStreamEvent) => { - // Queue events until terminal is ready to prevent data loss - if (!xtermRef.current || !subscriptionEnabled) { - pendingEventsRef.current.push(event); + if (!xtermRef.current) { return; } @@ -221,7 +219,6 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { updateCwdFromData(event.data); } else if (event.type === "exit") { isExitedRef.current = true; - setSubscriptionEnabled(false); xtermRef.current.writeln( `\r\n\r\n[Process exited with code ${event.exitCode}]`, ); @@ -284,6 +281,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const { xterm, fitAddon, + serializeAddon, cleanup: cleanupQuerySuppression, } = createTerminalInstance(container, { cwd: workspaceCwd, @@ -293,6 +291,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { }); xtermRef.current = xterm; fitAddonRef.current = fitAddon; + serializeAddonRef.current = serializeAddon; isExitedRef.current = false; if (isFocusedRef.current) { @@ -306,46 +305,14 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { searchAddonRef.current = searchAddon; }); - const flushPendingEvents = () => { - if (pendingEventsRef.current.length === 0) return; - const events = pendingEventsRef.current.splice( - 0, - pendingEventsRef.current.length, - ); - for (const event of events) { - if (event.type === "data") { - xterm.write(event.data); - updateCwdRef.current(event.data); - } else { - isExitedRef.current = true; - setSubscriptionEnabled(false); - xterm.writeln(`\r\n\r\n[Process exited with code ${event.exitCode}]`); - xterm.writeln("[Press any key to restart]"); - - // Clear transient pane status (direct store access since we're in effect) - const currentPane = useTabsStore.getState().panes[paneId]; - if ( - currentPane?.status === "working" || - currentPane?.status === "permission" - ) { - useTabsStore.getState().setPaneStatus(paneId, "idle"); - } - } + const applySerializedState = (serializedState: string) => { + if (serializedState) { + xterm.write(serializedState); } }; - const applyInitialState = (result: { - wasRecovered: boolean; - isNew: boolean; - scrollback: string; - }) => { - xterm.write(result.scrollback); - updateCwdRef.current(result.scrollback); - }; - const restartTerminal = () => { isExitedRef.current = false; - setSubscriptionEnabled(false); xterm.clear(); createOrAttachRef.current( { @@ -357,12 +324,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { }, { onSuccess: (result) => { - applyInitialState(result); - setSubscriptionEnabled(true); - flushPendingEvents(); - }, - onError: () => { - setSubscriptionEnabled(true); + applySerializedState(result.serializedState); }, }, ); @@ -436,14 +398,7 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { if (initialCommands || initialCwd) { clearPaneInitialDataRef.current(paneId); } - // Always apply initial state (scrollback) first, then flush pending events - // This ensures we don't lose terminal history when reattaching - applyInitialState(result); - setSubscriptionEnabled(true); - flushPendingEvents(); - }, - onError: () => { - setSubscriptionEnabled(true); + applySerializedState(result.serializedState); }, }, ); @@ -511,12 +466,19 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { cleanupQuerySuppression(); unregisterClearCallbackRef.current(paneId); debouncedSetTabAutoTitleRef.current?.cancel?.(); + + const serializedState = serializeAddon.serialize({ + excludeAltBuffer: true, + excludeModes: true, + scrollback: 1000, + }); + // Detach instead of kill to keep PTY running for reattachment - detachRef.current({ paneId }); - setSubscriptionEnabled(false); + detachRef.current({ paneId, serializedState }); xterm.dispose(); xtermRef.current = null; searchAddonRef.current = null; + serializeAddonRef.current = null; }; }, [paneId, workspaceId, workspaceCwd]); 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 fbd1b980b19..41c135de5a4 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 @@ -3,6 +3,7 @@ import { CanvasAddon } from "@xterm/addon-canvas"; import { ClipboardAddon } from "@xterm/addon-clipboard"; import { FitAddon } from "@xterm/addon-fit"; import { ImageAddon } from "@xterm/addon-image"; +import { SerializeAddon } from "@xterm/addon-serialize"; import { Unicode11Addon } from "@xterm/addon-unicode11"; import { WebglAddon } from "@xterm/addon-webgl"; import type { ITheme } from "@xterm/xterm"; @@ -105,6 +106,7 @@ export function createTerminalInstance( ): { xterm: XTerm; fitAddon: FitAddon; + serializeAddon: SerializeAddon; cleanup: () => void; } { const { cwd, initialTheme, onFileLinkClick } = options; @@ -118,6 +120,7 @@ export function createTerminalInstance( const clipboardAddon = new ClipboardAddon(); const unicode11Addon = new Unicode11Addon(); const imageAddon = new ImageAddon(); + const serializeAddon = new SerializeAddon(); xterm.open(container); @@ -127,6 +130,7 @@ export function createTerminalInstance( xterm.loadAddon(clipboardAddon); xterm.loadAddon(unicode11Addon); xterm.loadAddon(imageAddon); + xterm.loadAddon(serializeAddon); import("@xterm/addon-ligatures") .then(({ LigaturesAddon }) => { @@ -185,6 +189,7 @@ export function createTerminalInstance( return { xterm, fitAddon, + serializeAddon, cleanup: () => { cleanupQuerySuppression(); renderer.dispose(); diff --git a/apps/desktop/src/renderer/stores/tabs/utils/terminal-cleanup.ts b/apps/desktop/src/renderer/stores/tabs/utils/terminal-cleanup.ts index f867cd0e7d0..98f52b06e57 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils/terminal-cleanup.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils/terminal-cleanup.ts @@ -1,13 +1,10 @@ import { trpcClient } from "../../../lib/trpc-client"; /** - * Uses standalone tRPC client to avoid React hook dependencies - * Permanently deletes terminal history when killing the terminal + * Kills the terminal session */ export const killTerminalForPane = (paneId: string): void => { - trpcClient.terminal.kill - .mutate({ paneId, deleteHistory: true }) - .catch((error) => { - console.warn(`Failed to kill terminal for pane ${paneId}:`, error); - }); + trpcClient.terminal.kill.mutate({ paneId }).catch((error) => { + console.warn(`Failed to kill terminal for pane ${paneId}:`, error); + }); };