diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts index 6f071016b74..2d70d1eec24 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts @@ -94,7 +94,8 @@ export const createTerminalRouter = () => { return { paneId, isNew: result.isNew, - serializedState: result.serializedState, + scrollback: result.scrollback, + wasRecovered: result.wasRecovered, }; }), @@ -137,6 +138,7 @@ export const createTerminalRouter = () => { .input( z.object({ paneId: z.string(), + deleteHistory: z.boolean().optional(), }), ) .mutation(async ({ input }) => { @@ -145,14 +147,11 @@ 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 }) => { @@ -161,7 +160,7 @@ export const createTerminalRouter = () => { /** * Clear scrollback buffer for terminal (used by Cmd+K / clear command) - * This clears in-memory scrollback and serialized state + * This clears both in-memory scrollback and persistent history file */ clearScrollback: publicProcedure .input( @@ -169,8 +168,8 @@ export const createTerminalRouter = () => { paneId: z.string(), }), ) - .mutation(({ input }) => { - terminalManager.clearScrollback(input); + .mutation(async ({ input }) => { + await 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 new file mode 100644 index 00000000000..2490445c514 --- /dev/null +++ b/apps/desktop/src/main/lib/data-batcher.ts @@ -0,0 +1,84 @@ +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 new file mode 100644 index 00000000000..8c455c4144f --- /dev/null +++ b/apps/desktop/src/main/lib/terminal-escape-filter.test.ts @@ -0,0 +1,139 @@ +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 new file mode 100644 index 00000000000..da5f240163d --- /dev/null +++ b/apps/desktop/src/main/lib/terminal-escape-filter.ts @@ -0,0 +1,46 @@ +/** + * 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 new file mode 100644 index 00000000000..3c9c8233bed --- /dev/null +++ b/apps/desktop/src/main/lib/terminal-history.test.ts @@ -0,0 +1,263 @@ +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 new file mode 100644 index 00000000000..2d8e313b330 --- /dev/null +++ b/apps/desktop/src/main/lib/terminal-history.ts @@ -0,0 +1,145 @@ +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 new file mode 100644 index 00000000000..e49cb8bfca5 --- /dev/null +++ b/apps/desktop/src/main/lib/terminal/manager.test.ts @@ -0,0 +1,905 @@ +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 59adfe6054c..b42996254c6 100644 --- a/apps/desktop/src/main/lib/terminal/manager.ts +++ b/apps/desktop/src/main/lib/terminal/manager.ts @@ -3,9 +3,16 @@ 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 { createSession, setupInitialCommands } from "./session"; +import { + closeSessionHistory, + createSession, + flushSession, + reinitializeHistory, + setupDataHandler, +} from "./session"; import type { CreateSessionParams, + InternalCreateSessionParams, SessionResult, TerminalSession, } from "./types"; @@ -32,12 +39,16 @@ export class TerminalManager extends EventEmitter { } return { isNew: false, - serializedState: existing.serializedState || "", + scrollback: existing.scrollback, + wasRecovered: existing.wasRecovered, }; } // Create new session - const creationPromise = this.doCreateSession(params); + const creationPromise = this.doCreateSession({ + ...params, + existingScrollback: existing?.scrollback || null, + }); this.pendingSessions.set(paneId, creationPromise); try { @@ -48,7 +59,7 @@ export class TerminalManager extends EventEmitter { } private async doCreateSession( - params: CreateSessionParams & { useFallbackShell?: boolean }, + params: InternalCreateSessionParams, ): Promise { const { paneId, workspaceId, initialCommands } = params; @@ -67,10 +78,12 @@ export class TerminalManager extends EventEmitter { initialCommands?.some((command) => agentCommandPattern.test(command)) ?? false; - // Set up initial commands (only for new sessions) - setupInitialCommands( + // Set up data handler + setupDataHandler( session, initialCommands, + session.wasRecovered, + () => reinitializeHistory(session), shouldAwaitAgentHooks ? agentHooksReady : undefined, ); @@ -86,18 +99,20 @@ export class TerminalManager extends EventEmitter { return { isNew: true, - serializedState: "", + scrollback: session.scrollback, + wasRecovered: session.wasRecovered, }; } private setupExitHandler( session: TerminalSession, - params: CreateSessionParams & { useFallbackShell?: boolean }, + params: InternalCreateSessionParams, ): 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; @@ -109,11 +124,13 @@ 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 @@ -125,6 +142,8 @@ export class TerminalManager extends EventEmitter { } } + await closeSessionHistory(session, exitCode); + // Unregister from port manager (also removes detected ports) portManager.unregisterSession(paneId); @@ -203,8 +222,11 @@ export class TerminalManager extends EventEmitter { session.lastActive = Date.now(); } - async kill(params: { paneId: string }): Promise { - const { paneId } = params; + async kill(params: { + paneId: string; + deleteHistory?: boolean; + }): Promise { + const { paneId, deleteHistory = false } = params; const session = this.sessions.get(paneId); if (!session) { @@ -212,15 +234,20 @@ 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; serializedState?: string }): void { - const { paneId, serializedState } = params; + detach(params: { paneId: string }): void { + const { paneId } = params; const session = this.sessions.get(paneId); if (!session) { @@ -228,13 +255,10 @@ export class TerminalManager extends EventEmitter { return; } - if (serializedState) { - session.serializedState = serializedState; - } session.lastActive = Date.now(); } - clearScrollback(params: { paneId: string }): void { + async clearScrollback(params: { paneId: string }): Promise { const { paneId } = params; const session = this.sessions.get(paneId); @@ -245,7 +269,8 @@ export class TerminalManager extends EventEmitter { return; } - session.serializedState = ""; + session.scrollback = ""; + await reinitializeHistory(session); session.lastActive = Date.now(); } @@ -290,10 +315,14 @@ 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; @@ -330,6 +359,7 @@ export class TerminalManager extends EventEmitter { ); session.isAlive = false; this.sessions.delete(paneId); + closeSessionHistory(session).catch(() => {}); } cleanup(false); }, 500); @@ -344,6 +374,7 @@ 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); } }); @@ -408,6 +439,8 @@ export class TerminalManager extends EventEmitter { exitPromises.push(exitPromise); session.pty.kill(); + } else { + await closeSessionHistory(session); } } diff --git a/apps/desktop/src/main/lib/terminal/session.test.ts b/apps/desktop/src/main/lib/terminal/session.test.ts new file mode 100644 index 00000000000..c0b4028aa0d --- /dev/null +++ b/apps/desktop/src/main/lib/terminal/session.test.ts @@ -0,0 +1,97 @@ +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 76135b2b4a3..48afde8f0ec 100644 --- a/apps/desktop/src/main/lib/terminal/session.ts +++ b/apps/desktop/src/main/lib/terminal/session.ts @@ -1,6 +1,12 @@ 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 type { InternalCreateSessionParams, TerminalSession } from "./types"; @@ -9,6 +15,31 @@ 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; @@ -42,6 +73,7 @@ export async function createSession( cwd, cols, rows, + existingScrollback, useFallbackShell = false, } = params; @@ -60,6 +92,9 @@ export async function createSession( rootPath, }); + const { scrollback: recoveredScrollback, wasRecovered } = + await recoverScrollback(existingScrollback, workspaceId, paneId); + const ptyProcess = spawnPty({ shell, cols: terminalCols, @@ -68,7 +103,20 @@ export async function createSession( env, }); - const session: TerminalSession = { + const historyWriter = new HistoryWriter( + workspaceId, + paneId, + workingDir, + terminalCols, + terminalRows, + ); + await historyWriter.init(recoveredScrollback || undefined); + + const dataBatcher = new DataBatcher((batchedData) => { + onData(paneId, batchedData); + }); + + return { pty: ptyProcess, paneId, workspaceId, @@ -76,54 +124,107 @@ 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; } -/** - * Set up initial commands to run after shell prompt is ready. - * Commands are only sent for new sessions (not reattachments). - */ -export function setupInitialCommands( +export function setupDataHandler( session: TerminalSession, initialCommands: string[] | undefined, + wasRecovered: boolean, + onHistoryReinit: () => Promise, beforeInitialCommands?: Promise, ): void { - if (!initialCommands || initialCommands.length === 0) { + 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); + + 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(); return; } - 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); - }); + 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(); } diff --git a/apps/desktop/src/main/lib/terminal/types.ts b/apps/desktop/src/main/lib/terminal/types.ts index c8893a65134..0a53eb35a78 100644 --- a/apps/desktop/src/main/lib/terminal/types.ts +++ b/apps/desktop/src/main/lib/terminal/types.ts @@ -1,4 +1,6 @@ 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; @@ -8,8 +10,12 @@ export interface TerminalSession { cols: number; rows: number; lastActive: number; - serializedState?: string; + scrollback: string; isAlive: boolean; + deleteHistoryOnExit?: boolean; + wasRecovered: boolean; + historyWriter?: HistoryWriter; + dataBatcher: DataBatcher; shell: string; startTime: number; usedFallback: boolean; @@ -30,8 +36,8 @@ export type TerminalEvent = TerminalDataEvent | TerminalExitEvent; export interface SessionResult { isNew: boolean; - /** Serialized terminal state from xterm's SerializeAddon */ - serializedState: string; + scrollback: string; + wasRecovered: boolean; } export interface CreateSessionParams { @@ -48,5 +54,6 @@ 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 08525586f98..3fd5825c2f6 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,7 +1,6 @@ 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"; @@ -39,9 +38,10 @@ 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); @@ -238,7 +238,9 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { ); const handleStreamData = (event: TerminalStreamEvent) => { - if (!xtermRef.current) { + // Queue events until terminal is ready to prevent data loss + if (!xtermRef.current || !subscriptionEnabled) { + pendingEventsRef.current.push(event); return; } @@ -247,6 +249,7 @@ 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}]`, ); @@ -318,7 +321,6 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { const { xterm, fitAddon, - serializeAddon, cleanup: cleanupQuerySuppression, } = createTerminalInstance(container, { cwd: workspaceCwd, @@ -328,7 +330,6 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { }); xtermRef.current = xterm; fitAddonRef.current = fitAddon; - serializeAddonRef.current = serializeAddon; isExitedRef.current = false; if (isFocusedRef.current) { @@ -342,14 +343,46 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { searchAddonRef.current = searchAddon; }); - const applySerializedState = (serializedState: string) => { - if (serializedState) { - xterm.write(serializedState); + 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 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( { @@ -361,7 +394,12 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { }, { onSuccess: (result) => { - applySerializedState(result.serializedState); + applyInitialState(result); + setSubscriptionEnabled(true); + flushPendingEvents(); + }, + onError: () => { + setSubscriptionEnabled(true); }, }, ); @@ -435,7 +473,14 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { if (initialCommands || initialCwd) { clearPaneInitialDataRef.current(paneId); } - applySerializedState(result.serializedState); + // 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); }, }, ); @@ -511,15 +556,12 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { unregisterClearCallbackRef.current(paneId); unregisterScrollToBottomCallbackRef.current(paneId); debouncedSetTabAutoTitleRef.current?.cancel?.(); - - const serializedState = serializeAddon.serialize(); - // Detach instead of kill to keep PTY running for reattachment - detachRef.current({ paneId, serializedState }); + detachRef.current({ paneId }); + setSubscriptionEnabled(false); 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 41c135de5a4..fbd1b980b19 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,7 +3,6 @@ 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"; @@ -106,7 +105,6 @@ export function createTerminalInstance( ): { xterm: XTerm; fitAddon: FitAddon; - serializeAddon: SerializeAddon; cleanup: () => void; } { const { cwd, initialTheme, onFileLinkClick } = options; @@ -120,7 +118,6 @@ export function createTerminalInstance( const clipboardAddon = new ClipboardAddon(); const unicode11Addon = new Unicode11Addon(); const imageAddon = new ImageAddon(); - const serializeAddon = new SerializeAddon(); xterm.open(container); @@ -130,7 +127,6 @@ export function createTerminalInstance( xterm.loadAddon(clipboardAddon); xterm.loadAddon(unicode11Addon); xterm.loadAddon(imageAddon); - xterm.loadAddon(serializeAddon); import("@xterm/addon-ligatures") .then(({ LigaturesAddon }) => { @@ -189,7 +185,6 @@ 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 98f52b06e57..f867cd0e7d0 100644 --- a/apps/desktop/src/renderer/stores/tabs/utils/terminal-cleanup.ts +++ b/apps/desktop/src/renderer/stores/tabs/utils/terminal-cleanup.ts @@ -1,10 +1,13 @@ import { trpcClient } from "../../../lib/trpc-client"; /** - * Kills the terminal session + * Uses standalone tRPC client to avoid React hook dependencies + * Permanently deletes terminal history when killing the terminal */ export const killTerminalForPane = (paneId: string): void => { - trpcClient.terminal.kill.mutate({ paneId }).catch((error) => { - console.warn(`Failed to kill terminal for pane ${paneId}:`, error); - }); + trpcClient.terminal.kill + .mutate({ paneId, deleteHistory: true }) + .catch((error) => { + console.warn(`Failed to kill terminal for pane ${paneId}:`, error); + }); };