diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 0b297ec0319..1480edd4739 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -64,6 +64,7 @@ "@xterm/addon-unicode11": "^0.8.0", "@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.18.0", + "@xterm/headless": "^5.5.0", "@xterm/xterm": "^5.5.0", "better-sqlite3": "12.5.0", "bindings": "^1.5.0", diff --git a/apps/desktop/src/main/lib/terminal/manager.test.ts b/apps/desktop/src/main/lib/terminal/manager.test.ts index 0b27510e442..29e928e4602 100644 --- a/apps/desktop/src/main/lib/terminal/manager.test.ts +++ b/apps/desktop/src/main/lib/terminal/manager.test.ts @@ -577,6 +577,9 @@ describe("TerminalManager", () => { onDataCallback("\x1b[3Jnew content after clear"); } + // Wait for headless terminal to process async writes + await new Promise((resolve) => setTimeout(resolve, 50)); + const result = await manager.createOrAttach({ paneId: "pane-shell-clear", tabId: "tab-shell-clear", @@ -604,6 +607,9 @@ describe("TerminalManager", () => { onDataCallback("old content\x1b[3Jnew content"); } + // Wait for headless terminal to process async writes + await new Promise((resolve) => setTimeout(resolve, 50)); + const result = await manager.createOrAttach({ paneId: "pane-clear-before", tabId: "tab-clear-before", diff --git a/apps/desktop/src/main/lib/terminal/manager.ts b/apps/desktop/src/main/lib/terminal/manager.ts index d52a2166f9b..5603ec2fcbc 100644 --- a/apps/desktop/src/main/lib/terminal/manager.ts +++ b/apps/desktop/src/main/lib/terminal/manager.ts @@ -3,7 +3,13 @@ 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, flushSession, setupDataHandler } from "./session"; +import { + createHeadlessTerminal, + createSession, + flushSession, + getSerializedScrollback, + setupDataHandler, +} from "./session"; import type { CreateSessionParams, InternalCreateSessionParams, @@ -33,7 +39,7 @@ export class TerminalManager extends EventEmitter { } return { isNew: false, - scrollback: existing.scrollback, + scrollback: getSerializedScrollback(existing), wasRecovered: existing.wasRecovered, }; } @@ -41,7 +47,7 @@ export class TerminalManager extends EventEmitter { // Create new session const creationPromise = this.doCreateSession({ ...params, - existingScrollback: existing?.scrollback || null, + existingScrollback: existing ? getSerializedScrollback(existing) : null, }); this.pendingSessions.set(paneId, creationPromise); @@ -92,7 +98,7 @@ export class TerminalManager extends EventEmitter { return { isNew: true, - scrollback: session.scrollback, + scrollback: getSerializedScrollback(session), wasRecovered: session.wasRecovered, }; } @@ -105,6 +111,9 @@ export class TerminalManager extends EventEmitter { session.pty.onExit(async ({ exitCode, signal }) => { session.isAlive = false; + + // Must capture before flush (flush disposes headless terminal) + const existingScrollback = getSerializedScrollback(session); flushSession(session); // Check if shell crashed quickly - try fallback @@ -122,7 +131,7 @@ export class TerminalManager extends EventEmitter { try { await this.doCreateSession({ ...params, - existingScrollback: session.scrollback || null, + existingScrollback, useFallbackShell: true, }); return; // Recovered - don't emit exit @@ -186,6 +195,7 @@ export class TerminalManager extends EventEmitter { try { session.pty.resize(cols, rows); + session.headless.resize(cols, rows); session.cols = cols; session.rows = rows; session.lastActive = Date.now(); @@ -251,7 +261,14 @@ export class TerminalManager extends EventEmitter { return; } - session.scrollback = ""; + // Recreate headless (xterm writes are async, so clear() alone is unreliable) + session.headless.dispose(); + const { headless, serializer } = createHeadlessTerminal({ + cols: session.cols, + rows: session.rows, + }); + session.headless = headless; + session.serializer = serializer; session.lastActive = Date.now(); } diff --git a/apps/desktop/src/main/lib/terminal/session.test.ts b/apps/desktop/src/main/lib/terminal/session.test.ts index fb7161a5aa8..d367ce6f07a 100644 --- a/apps/desktop/src/main/lib/terminal/session.test.ts +++ b/apps/desktop/src/main/lib/terminal/session.test.ts @@ -1,41 +1,115 @@ import { describe, expect, it } from "bun:test"; -import { flushSession, recoverScrollback } from "./session"; +import { SerializeAddon } from "@xterm/addon-serialize"; +import { Terminal as HeadlessTerminal } from "@xterm/headless"; +import { + flushSession, + getSerializedScrollback, + recoverScrollback, +} from "./session"; import type { TerminalSession } from "./types"; +function createTestHeadless(): { + headless: HeadlessTerminal; + serializer: SerializeAddon; +} { + const headless = new HeadlessTerminal({ + cols: 80, + rows: 24, + scrollback: 1000, + allowProposedApi: true, + }); + const serializer = new SerializeAddon(); + headless.loadAddon( + serializer as unknown as Parameters[0], + ); + return { headless, serializer }; +} + describe("session", () => { describe("recoverScrollback", () => { - it("should return existing scrollback if provided", () => { - const result = recoverScrollback("existing content"); + it("should write existing scrollback to headless and return true", async () => { + const { headless, serializer } = createTestHeadless(); + + const wasRecovered = recoverScrollback({ + existingScrollback: "existing content", + headless, + }); + + expect(wasRecovered).toBe(true); + + // Wait for write to complete (xterm write is async) + await new Promise((resolve) => { + headless.write("", resolve); + }); + + // The headless terminal should have the content + const serialized = serializer.serialize(); + expect(serialized).toContain("existing content"); + + headless.dispose(); + }); + + it("should return false when no existing scrollback", () => { + const { headless } = createTestHeadless(); + + const wasRecovered = recoverScrollback({ + existingScrollback: null, + headless, + }); + + expect(wasRecovered).toBe(false); - expect(result.scrollback).toBe("existing content"); - expect(result.wasRecovered).toBe(true); + headless.dispose(); }); + }); + + describe("getSerializedScrollback", () => { + it("should return serialized content from headless terminal", async () => { + const { headless, serializer } = createTestHeadless(); + + // Wait for write to complete (xterm write is async) + await new Promise((resolve) => { + headless.write("test output", resolve); + }); + + const mockSession = { + headless, + serializer, + } as unknown as TerminalSession; - it("should return empty scrollback when no existing scrollback", () => { - const result = recoverScrollback(null); + const result = getSerializedScrollback(mockSession); + expect(result).toContain("test output"); - expect(result.scrollback).toBe(""); - expect(result.wasRecovered).toBe(false); + headless.dispose(); }); }); describe("flushSession", () => { - it("should dispose data batcher", () => { - let disposed = false; + it("should dispose data batcher and headless terminal", () => { + let batcherDisposed = false; + let headlessDisposed = false; + const mockDataBatcher = { dispose: () => { - disposed = true; + batcherDisposed = true; + }, + }; + + const mockHeadless = { + dispose: () => { + headlessDisposed = true; }, }; const mockSession = { dataBatcher: mockDataBatcher, - scrollback: "initial", + headless: mockHeadless, } as unknown as TerminalSession; flushSession(mockSession); - expect(disposed).toBe(true); + expect(batcherDisposed).toBe(true); + expect(headlessDisposed).toBe(true); }); }); }); diff --git a/apps/desktop/src/main/lib/terminal/session.ts b/apps/desktop/src/main/lib/terminal/session.ts index 89a65bf5573..00929a81e56 100644 --- a/apps/desktop/src/main/lib/terminal/session.ts +++ b/apps/desktop/src/main/lib/terminal/session.ts @@ -1,4 +1,6 @@ import os from "node:os"; +import { SerializeAddon } from "@xterm/addon-serialize"; +import { Terminal as HeadlessTerminal } from "@xterm/headless"; import * as pty from "node-pty"; import { getShellArgs } from "../agent-setup"; import { DataBatcher } from "../data-batcher"; @@ -11,17 +13,47 @@ import type { InternalCreateSessionParams, TerminalSession } from "./types"; const DEFAULT_COLS = 80; const DEFAULT_ROWS = 24; +const DEFAULT_SCROLLBACK = 10000; /** Max time to wait for agent hooks before running initial commands */ const AGENT_HOOKS_TIMEOUT_MS = 2000; -export function recoverScrollback(existingScrollback: string | null): { - scrollback: string; - wasRecovered: boolean; -} { +export function createHeadlessTerminal(params: { + cols: number; + rows: number; + scrollback?: number; +}): { headless: HeadlessTerminal; serializer: SerializeAddon } { + const { cols, rows, scrollback = DEFAULT_SCROLLBACK } = params; + + const headless = new HeadlessTerminal({ + cols, + rows, + scrollback, + allowProposedApi: true, + }); + + const serializer = new SerializeAddon(); + // SerializeAddon types expect browser Terminal, but works with headless at runtime + headless.loadAddon( + serializer as unknown as Parameters[0], + ); + + return { headless, serializer }; +} + +export function getSerializedScrollback(session: TerminalSession): string { + return session.serializer.serialize(); +} + +export function recoverScrollback(params: { + existingScrollback: string | null; + headless: HeadlessTerminal; +}): boolean { + const { existingScrollback, headless } = params; if (existingScrollback) { - return { scrollback: existingScrollback, wasRecovered: true }; + headless.write(existingScrollback); + return true; } - return { scrollback: "", wasRecovered: false }; + return false; } function spawnPty(params: { @@ -76,8 +108,15 @@ export async function createSession( rootPath, }); - const { scrollback: recoveredScrollback, wasRecovered } = - recoverScrollback(existingScrollback); + const { headless, serializer } = createHeadlessTerminal({ + cols: terminalCols, + rows: terminalRows, + }); + + const wasRecovered = recoverScrollback({ + existingScrollback, + headless, + }); const ptyProcess = spawnPty({ shell, @@ -99,7 +138,8 @@ export async function createSession( cols: terminalCols, rows: terminalRows, lastActive: Date.now(), - scrollback: recoveredScrollback, + headless, + serializer, isAlive: true, wasRecovered, dataBatcher, @@ -122,15 +162,23 @@ export function setupDataHandler( let commandsSent = false; session.pty.onData((data) => { - let dataToStore = data; - + // Recreate headless on clear (xterm writes are async, so clear() alone is unreliable) if (containsClearScrollbackSequence(data)) { - session.scrollback = ""; - dataToStore = extractContentAfterClear(data); + session.headless.dispose(); + const { headless, serializer } = createHeadlessTerminal({ + cols: session.cols, + rows: session.rows, + }); + session.headless = headless; + session.serializer = serializer; + const contentAfterClear = extractContentAfterClear(data); + if (contentAfterClear) { + session.headless.write(contentAfterClear); + } + } else { + session.headless.write(data); } - session.scrollback += dataToStore; - session.dataBatcher.write(data); if (initialCommandString && !commandsSent) { @@ -159,4 +207,5 @@ export function setupDataHandler( export function flushSession(session: TerminalSession): void { session.dataBatcher.dispose(); + session.headless.dispose(); } diff --git a/apps/desktop/src/main/lib/terminal/types.ts b/apps/desktop/src/main/lib/terminal/types.ts index 212ef1b0e26..1fdcf4169ca 100644 --- a/apps/desktop/src/main/lib/terminal/types.ts +++ b/apps/desktop/src/main/lib/terminal/types.ts @@ -1,3 +1,5 @@ +import type { SerializeAddon } from "@xterm/addon-serialize"; +import type { Terminal as HeadlessTerminal } from "@xterm/headless"; import type * as pty from "node-pty"; import type { DataBatcher } from "../data-batcher"; @@ -9,7 +11,8 @@ export interface TerminalSession { cols: number; rows: number; lastActive: number; - scrollback: string; + headless: HeadlessTerminal; + serializer: SerializeAddon; isAlive: boolean; wasRecovered: boolean; dataBatcher: DataBatcher; diff --git a/bun.lock b/bun.lock index 37cdedcf2b1..c259823837a 100644 --- a/bun.lock +++ b/bun.lock @@ -121,7 +121,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "0.0.47", + "version": "0.0.50", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -156,6 +156,7 @@ "@xterm/addon-unicode11": "^0.8.0", "@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.18.0", + "@xterm/headless": "5.5.0", "@xterm/xterm": "^5.5.0", "better-sqlite3": "12.5.0", "bindings": "^1.5.0", @@ -1638,6 +1639,8 @@ "@xterm/addon-webgl": ["@xterm/addon-webgl@0.18.0", "", { "peerDependencies": { "@xterm/xterm": "^5.0.0" } }, "sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w=="], + "@xterm/headless": ["@xterm/headless@5.5.0", "", {}, "sha512-5xXB7kdQlFBP82ViMJTwwEc3gKCLGKR/eoxQm4zge7GPBl86tCdI0IdPJjoKd8mUSFXz5V7i/25sfsEkP4j46g=="], + "@xterm/xterm": ["@xterm/xterm@5.5.0", "", {}, "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A=="], "@xtuc/ieee754": ["@xtuc/ieee754@1.2.0", "", {}, "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA=="],