Skip to content
15 changes: 15 additions & 0 deletions apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,21 @@ export const createTerminalRouter = () => {
terminalManager.detach(input);
}),

/**
* Save serialized terminal state from renderer.
* Uses xterm.js serialize addon output for clean scrollback persistence.
*/
saveScrollback: publicProcedure
.input(
z.object({
tabId: z.string(),
serialized: z.string(),
}),
)
.mutation(async ({ input }) => {
await terminalManager.saveScrollback(input);
}),

getSession: publicProcedure
.input(z.string())
.query(async ({ input: tabId }) => {
Expand Down
71 changes: 62 additions & 9 deletions apps/desktop/src/main/lib/terminal-escape-filter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -417,20 +417,28 @@ describe("TerminalEscapeFilter (stateful)", () => {
expect(result2).toBe(`${ESC}[32mgreen`);
});

it("should NOT buffer ESC alone at end", () => {
it("should buffer ESC alone at end", () => {
const filter = new TerminalEscapeFilter();
const chunk1 = `text${ESC}`;
const result1 = filter.filter(chunk1);
// ESC alone should pass through (conservative - don't buffer)
expect(result1).toBe(`text${ESC}`);
// ESC alone must be buffered to check if next chunk forms a query response
// This prevents orphaned sequences like "2R" when ESC is at chunk boundary
expect(result1).toBe("text");
// If next chunk completes a query response, it gets filtered
const result2 = filter.filter("[2R");
expect(result2).toBe("");
});

it("should NOT buffer ESC [ alone at end", () => {
it("should buffer ESC [ alone at end", () => {
const filter = new TerminalEscapeFilter();
const chunk1 = `text${ESC}[`;
const result1 = filter.filter(chunk1);
// ESC [ alone should pass through (could be any CSI)
expect(result1).toBe(`text${ESC}[`);
// ESC [ alone must be buffered to see what follows
// This prevents ";1R" from leaking when next chunk is ";1R..."
expect(result1).toBe("text");
// Complete with a query response
const result2 = filter.filter(";1R");
expect(result2).toBe("");
});

it("should buffer ESC [ digit (could be CPR/mode report/DA)", () => {
Expand Down Expand Up @@ -475,21 +483,66 @@ describe("TerminalEscapeFilter (stateful)", () => {
});

describe("flush behavior", () => {
it("should flush buffered incomplete sequence", () => {
it("should discard incomplete query response on flush", () => {
const filter = new TerminalEscapeFilter();
const chunk = `hello${ESC}[?1;0`; // Incomplete DA1
const result = filter.filter(chunk);
expect(result).toBe("hello");
// Flush returns the buffered data (filtered)
// Flush discards incomplete query responses to prevent garbage on restore
const flushed = filter.flush();
expect(flushed).toBe(`${ESC}[?1;0`); // Not filtered because incomplete
expect(flushed).toBe("");
});

it("should return empty on flush when no buffer", () => {
const filter = new TerminalEscapeFilter();
filter.filter("complete data");
expect(filter.flush()).toBe("");
});

it("should discard lone ESC on flush", () => {
const filter = new TerminalEscapeFilter();
const chunk = `prompt ❯ ${ESC}`;
const result = filter.filter(chunk);
expect(result).toBe("prompt ❯ ");
// Lone ESC is discarded on flush
expect(filter.flush()).toBe("");
});
});

describe("TUI app scenarios", () => {
it("should filter rapid query responses split across chunks", () => {
// Simulates TUI apps like lazygit sending many query responses
const filter = new TerminalEscapeFilter();
const chunks = [
`prompt ❯ ${ESC}`,
`[2R${ESC}`,
`[1R${ESC}`,
`[12;2$y more text`,
];

let output = "";
for (const chunk of chunks) {
output += filter.filter(chunk);
}
output += filter.flush();

// All query responses should be filtered, only text remains
expect(output).toBe("prompt ❯ more text");
});

it("should not leak orphaned sequence parts", () => {
// This was the bug: ESC at chunk end caused "[2R" to leak through
const filter = new TerminalEscapeFilter();
const chunk1 = `text${ESC}`;
const chunk2 = `[2R${ESC}[1R`;

const out1 = filter.filter(chunk1);
const out2 = filter.filter(chunk2);
const flushed = filter.flush();

// Should not contain any orphaned "2R", "1R", etc.
expect(out1 + out2 + flushed).toBe("text");
});
});

describe("reset behavior", () => {
Expand Down
25 changes: 22 additions & 3 deletions apps/desktop/src/main/lib/terminal-escape-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,13 @@ const FILTER_PATTERNS = {
/**
* Cursor Position Report (CPR): ESC [ Pl ; Pc R or ESC [ Pl R
* Response to DSR (Device Status Report) query ESC [ 6 n
* Some terminals omit row/column, sending ESC[;1R or ESC[R
* Examples:
* - ESC[24;1R (cursor at row 24, column 1)
* - ESC[2R (cursor at row 2, column defaults to 1)
* - ESC[;1R (row omitted, column 1)
*/
cursorPositionReport: `${ESC}\\[\\d+(?:;\\d+)?R`,
cursorPositionReport: `${ESC}\\[\\d*(?:;\\d*)?R`,

/**
* Primary Device Attributes (DA1): ESC [ ? Ps c
Expand Down Expand Up @@ -146,19 +148,25 @@ export class TerminalEscapeFilter {
* at chunk boundaries. If the complete sequence doesn't match our filter, it passes through.
*/
private looksLikeQueryResponse(str: string): boolean {
if (str.length < 2) return false; // Just ESC alone - don't buffer, could be anything
// Buffer lone ESC - we need to see the next char to decide
if (str.length === 1 && str[0] === ESC) return true;
if (str.length < 2) return false;

const secondChar = str[1];

// CSI query responses we want to buffer:
// - ESC [ alone (need to see next char)
// - ESC [ ? (DA1, DECRPM private mode)
// - ESC [ > (DA2 secondary)
// - ESC [ digit (CPR, standard mode reports, device attributes)
// - ESC [ ; (CPR with omitted row, e.g., ESC[;1R)
if (secondChar === "[") {
if (str.length < 3) return false; // ESC [ alone - don't buffer
if (str.length < 3) return true; // ESC [ alone - buffer to see what follows
const thirdChar = str[2];
// Buffer ? (private mode) or > (secondary DA)
if (thirdChar === "?" || thirdChar === ">") return true;
// Buffer ; for CPR with omitted parameters (ESC[;1R)
if (thirdChar === ";") return true;
// Buffer digit-starting CSI sequences that could be query responses:
// - CPR: ESC[24;1R or ESC[1R
// - Standard mode report: ESC[12;2$y
Expand Down Expand Up @@ -192,6 +200,7 @@ export class TerminalEscapeFilter {
* Check if a potential query response sequence is incomplete.
*/
private isIncomplete(str: string): boolean {
// Lone ESC is incomplete - need to see what follows
if (str.length < 2) return true;

const secondChar = str[1];
Expand Down Expand Up @@ -224,10 +233,20 @@ export class TerminalEscapeFilter {
/**
* Flush any remaining buffered data.
* Call this when the terminal session ends.
*
* If the buffer contains what looks like an incomplete query response,
* discard it entirely to prevent garbage output on restore.
*/
flush(): string {
const remaining = this.buffer;
this.buffer = "";

// If the buffer looks like an incomplete query response, discard it
// This prevents orphaned sequences like "2R1R12;2$y" from appearing
if (remaining && this.looksLikeQueryResponse(remaining)) {
return "";
}

return remaining.replace(COMBINED_PATTERN, "");
}

Expand Down
31 changes: 31 additions & 0 deletions apps/desktop/src/main/lib/terminal-history.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export class HistoryWriter {
await fs.mkdir(dir, { recursive: true });

// Write initial scrollback (recovered from previous session) or truncate
// Serialized data from xterm.js is already clean, no filtering needed
if (initialScrollback) {
await fs.writeFile(this.filePath, Buffer.from(initialScrollback));
} else {
Expand All @@ -80,6 +81,36 @@ export class HistoryWriter {
}
}

/**
* Save a complete serialized terminal snapshot.
* This replaces the entire scrollback file with clean serialized data
* from xterm.js serialize addon, which doesn't contain query responses.
*/
async saveSnapshot(serialized: string): Promise<void> {
// Close existing stream before replacing file
if (this.stream && !this.streamErrored) {
try {
await new Promise<void>((resolve) => {
this.stream?.end(() => resolve());
});
} catch {
// Ignore close errors
}
}
this.stream = null;
this.streamErrored = false;

// Write serialized data (already clean, no filtering needed)
await fs.writeFile(this.filePath, Buffer.from(serialized));

// Reopen stream for any subsequent writes
this.stream = createWriteStream(this.filePath, { flags: "a" });
this.stream.on("error", () => {
this.streamErrored = true;
this.stream = null;
});
}

async close(exitCode?: number): Promise<void> {
if (this.stream && !this.streamErrored) {
try {
Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/src/main/lib/terminal-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,12 @@ describe("TerminalManager", () => {
onDataCallback("Preserved output\n");
}

// Simulate what the renderer does: save serialized scrollback before detach
await manager.saveScrollback({
tabId: "tab-preserve",
serialized: "Preserved output\n",
});

const exitPromise = new Promise<void>((resolve) => {
manager.once("exit:tab-preserve", () => resolve());
});
Expand Down Expand Up @@ -753,6 +759,12 @@ describe("TerminalManager", () => {
onDataCallback1("Session 1 output\n");
}

// Simulate renderer saving scrollback before exit
await manager.saveScrollback({
tabId: "tab-multi",
serialized: "Session 1 output\n",
});

const exitPromise1 = new Promise<void>((resolve) => {
manager.once("exit:tab-multi", () => resolve());
});
Expand Down Expand Up @@ -784,6 +796,12 @@ describe("TerminalManager", () => {
onDataCallback2("Session 2 output\n");
}

// Simulate renderer saving scrollback with both sessions' output
await manager.saveScrollback({
tabId: "tab-multi",
serialized: "Session 1 output\nSession 2 output\n",
});

const exitPromise2 = new Promise<void>((resolve) => {
manager.once("exit:tab-multi", () => resolve());
});
Expand Down
Loading