Skip to content
Closed
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
101 changes: 94 additions & 7 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,68 @@ describe("TerminalEscapeFilter (stateful)", () => {
expect(result2).toBe(`${ESC}[32mgreen`);
});

it("should NOT buffer ESC alone at end", () => {
it("should buffer ESC alone at end (could be start of query response)", () => {
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 should be buffered - could be start of query response
expect(result1).toBe("text");
});

it("should NOT buffer ESC [ alone at end", () => {
it("should buffer ESC [ alone at end (could be start of query response)", () => {
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 should be buffered - could be start of CPR/DA/etc
expect(result1).toBe("text");
});

it("should reassemble CPR split with ESC at chunk boundary", () => {
const filter = new TerminalEscapeFilter();
// CPR response ESC[1;1R split with just ESC at boundary
const result1 = filter.filter(`text${ESC}`);
const result2 = filter.filter("[1;1R");
expect(result1 + result2).toBe("text");
});

it("should reassemble CPR split with ESC[ at chunk boundary", () => {
const filter = new TerminalEscapeFilter();
// CPR response ESC[1;1R split with ESC[ at boundary
const result1 = filter.filter(`text${ESC}[`);
const result2 = filter.filter("1;1R");
expect(result1 + result2).toBe("text");
});

it("should buffer ESC ] alone at end (could be start of OSC response)", () => {
const filter = new TerminalEscapeFilter();
const chunk1 = `text${ESC}]`;
const result1 = filter.filter(chunk1);
// ESC ] alone should be buffered - could be start of OSC color response
expect(result1).toBe("text");
});

it("should reassemble OSC split with ESC] at chunk boundary", () => {
const filter = new TerminalEscapeFilter();
// OSC 10 response split with ESC] at boundary
const result1 = filter.filter(`text${ESC}]`);
const result2 = filter.filter(`10;rgb:ffff/ffff/ffff${BEL}more`);
expect(result1 + result2).toBe("textmore");
});

it("should buffer ESC P alone at end (could be start of DCS response)", () => {
const filter = new TerminalEscapeFilter();
const chunk1 = `text${ESC}P`;
const result1 = filter.filter(chunk1);
// ESC P alone should be buffered - could be start of XTVERSION/DA3
expect(result1).toBe("text");
});

it("should reassemble XTVERSION split with ESC P at chunk boundary", () => {
const filter = new TerminalEscapeFilter();
// XTVERSION response split with ESC P at boundary
const result1 = filter.filter(`text${ESC}P`);
const result2 = filter.filter(`>|XTerm(354)${ESC}\\more`);
expect(result1 + result2).toBe("textmore");
});

it("should buffer ESC [ digit (could be CPR/mode report/DA)", () => {
Expand Down Expand Up @@ -490,6 +538,42 @@ describe("TerminalEscapeFilter (stateful)", () => {
filter.filter("complete data");
expect(filter.flush()).toBe("");
});

it("should preserve standalone ESC on flush (not a query response)", () => {
const filter = new TerminalEscapeFilter();
const result = filter.filter(`text${ESC}`);
expect(result).toBe("text");
// Flush should return the standalone ESC - it never formed a query response
const flushed = filter.flush();
expect(flushed).toBe(ESC);
});

it("should preserve standalone ESC[ on flush (not a query response)", () => {
const filter = new TerminalEscapeFilter();
const result = filter.filter(`text${ESC}[`);
expect(result).toBe("text");
// Flush should return ESC[ - it never formed a query response
const flushed = filter.flush();
expect(flushed).toBe(`${ESC}[`);
});

it("should preserve standalone ESC] on flush (not a query response)", () => {
const filter = new TerminalEscapeFilter();
const result = filter.filter(`text${ESC}]`);
expect(result).toBe("text");
// Flush should return ESC] - it never formed a query response
const flushed = filter.flush();
expect(flushed).toBe(`${ESC}]`);
});

it("should preserve standalone ESC P on flush (not a query response)", () => {
const filter = new TerminalEscapeFilter();
const result = filter.filter(`text${ESC}P`);
expect(result).toBe("text");
// Flush should return ESC P - it never formed a query response
const flushed = filter.flush();
expect(flushed).toBe(`${ESC}P`);
});
});

describe("reset behavior", () => {
Expand All @@ -514,7 +598,10 @@ describe("TerminalEscapeFilter (stateful)", () => {
const chunk2 = `[0mnormal`;
const result1 = filter.filter(chunk1);
const result2 = filter.filter(chunk2);
// Colors should pass through
// Trailing ESC is buffered, then reassembled with next chunk
// Colors should pass through (not matching query response patterns)
expect(result1).toBe(`${ESC}[31mred`);
expect(result2).toBe(`${ESC}[0mnormal`);
expect(result1 + result2).toBe(`${ESC}[31mred${ESC}[0mnormal`);
});
});
Expand Down
31 changes: 31 additions & 0 deletions apps/desktop/src/main/lib/terminal-escape-filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,30 @@ export class TerminalEscapeFilter {
const combined = this.buffer + data;
this.buffer = "";

// Pre-check: Buffer trailing prefix fragments at chunk boundaries
// These could be the start of query responses split across chunks
// Covers: ESC (any), ESC[ (CSI), ESC] (OSC), ESC P (DCS)
if (combined.endsWith(ESC)) {
this.buffer = ESC;
const toFilter = combined.slice(0, -1);
return toFilter.replace(COMBINED_PATTERN, "");
}
if (combined.endsWith(`${ESC}[`)) {
this.buffer = `${ESC}[`;
const toFilter = combined.slice(0, -2);
return toFilter.replace(COMBINED_PATTERN, "");
}
if (combined.endsWith(`${ESC}]`)) {
this.buffer = `${ESC}]`;
const toFilter = combined.slice(0, -2);
return toFilter.replace(COMBINED_PATTERN, "");
}
if (combined.endsWith(`${ESC}P`)) {
this.buffer = `${ESC}P`;
const toFilter = combined.slice(0, -2);
return toFilter.replace(COMBINED_PATTERN, "");
}

// Check if the data ends with a potential incomplete query response
const lastEscIndex = combined.lastIndexOf(ESC);

Expand Down Expand Up @@ -224,10 +248,17 @@ export class TerminalEscapeFilter {
/**
* Flush any remaining buffered data.
* Call this when the terminal session ends.
* Preserves standalone prefix fragments that never formed a query response.
*/
flush(): string {
const remaining = this.buffer;
this.buffer = "";
// Preserve prefix fragments that never completed into a query response
// These are genuine ESC bytes, not query responses to filter
const prefixFragments = [ESC, `${ESC}[`, `${ESC}]`, `${ESC}P`];
if (prefixFragments.includes(remaining)) {
return remaining;
}
return remaining.replace(COMBINED_PATTERN, "");
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,17 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => {
}

if (event.type === "data") {
xtermRef.current.write(event.data);
const xterm = xtermRef.current;
// Check if viewport is at the bottom before writing (xterm.js recommended pattern)
const buffer = xterm.buffer.active;
const isAtBottom = buffer.viewportY === buffer.baseY;

xterm.write(event.data);

// Scroll to bottom after write if user was at bottom (prevents scroll-to-top on cursor moves)
if (isAtBottom) {
xterm.scrollToBottom();
}
} else if (event.type === "exit") {
isExitedRef.current = true;
setSubscriptionEnabled(false);
Expand Down Expand Up @@ -159,6 +169,8 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => {
xterm.writeln("[Press any key to restart]");
}
}
// Scroll to bottom after flushing pending events
xterm.scrollToBottom();
};

const applyInitialScrollback = (result: {
Expand All @@ -167,6 +179,8 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => {
scrollback: string;
}) => {
xterm.write(result.scrollback);
// Scroll to bottom after applying scrollback to show latest content
xterm.scrollToBottom();
};

const restartTerminal = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,10 @@ export function setupResizeHandlers(

const handleResize = () => {
fitAddon.fit();
// Defer scroll to next frame to ensure xterm has finished reflowing the buffer
requestAnimationFrame(() => {
xterm.scrollToBottom();
});
debouncedResize(xterm.cols, xterm.rows);
};

Expand Down