diff --git a/apps/desktop/src/main/lib/terminal-escape-filter.test.ts b/apps/desktop/src/main/lib/terminal-escape-filter.test.ts index ff185ee800c..229e1154dce 100644 --- a/apps/desktop/src/main/lib/terminal-escape-filter.test.ts +++ b/apps/desktop/src/main/lib/terminal-escape-filter.test.ts @@ -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)", () => { @@ -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", () => { @@ -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`); }); }); diff --git a/apps/desktop/src/main/lib/terminal-escape-filter.ts b/apps/desktop/src/main/lib/terminal-escape-filter.ts index 9ba7e5bef54..394f82e036c 100644 --- a/apps/desktop/src/main/lib/terminal-escape-filter.ts +++ b/apps/desktop/src/main/lib/terminal-escape-filter.ts @@ -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); @@ -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, ""); } 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 1457232ca71..c5c2a508b93 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 @@ -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); @@ -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: { @@ -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 = () => { 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 1b1914a69f7..5df7fe2c717 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 @@ -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); };