Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions apps/desktop/src/main/lib/terminal/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
29 changes: 23 additions & 6 deletions apps/desktop/src/main/lib/terminal/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -33,15 +39,15 @@ export class TerminalManager extends EventEmitter {
}
return {
isNew: false,
scrollback: existing.scrollback,
scrollback: getSerializedScrollback(existing),
wasRecovered: existing.wasRecovered,
};
}

// Create new session
const creationPromise = this.doCreateSession({
...params,
existingScrollback: existing?.scrollback || null,
existingScrollback: existing ? getSerializedScrollback(existing) : null,
});
this.pendingSessions.set(paneId, creationPromise);

Expand Down Expand Up @@ -92,7 +98,7 @@ export class TerminalManager extends EventEmitter {

return {
isNew: true,
scrollback: session.scrollback,
scrollback: getSerializedScrollback(session),
wasRecovered: session.wasRecovered,
};
}
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
}

Expand Down
102 changes: 88 additions & 14 deletions apps/desktop/src/main/lib/terminal/session.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof headless.loadAddon>[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<void>((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<void>((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);
});
});
});
79 changes: 64 additions & 15 deletions apps/desktop/src/main/lib/terminal/session.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<typeof headless.loadAddon>[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: {
Expand Down Expand Up @@ -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,
Expand All @@ -99,7 +138,8 @@ export async function createSession(
cols: terminalCols,
rows: terminalRows,
lastActive: Date.now(),
scrollback: recoveredScrollback,
headless,
serializer,
isAlive: true,
wasRecovered,
dataBatcher,
Expand All @@ -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) {
Expand Down Expand Up @@ -159,4 +207,5 @@ export function setupDataHandler(

export function flushSession(session: TerminalSession): void {
session.dataBatcher.dispose();
session.headless.dispose();
}
5 changes: 4 additions & 1 deletion apps/desktop/src/main/lib/terminal/types.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -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;
Expand Down
Loading