From d81f9f28ac25bb042320dc388a48fcaecacf15d9 Mon Sep 17 00:00:00 2001 From: cpendery Date: Sun, 2 Aug 2026 20:20:38 -0700 Subject: [PATCH] fix: bare newline handling repaints (antigravity) Signed-off-by: cpendery --- src/tests/utils/stdioProxy.test.ts | 60 +++++++++++++++++++++++++++++- src/ui/stdioProxy.ts | 41 +++++++++++++++----- src/utils/ansi.ts | 1 + 3 files changed, 91 insertions(+), 11 deletions(-) diff --git a/src/tests/utils/stdioProxy.test.ts b/src/tests/utils/stdioProxy.test.ts index 788f3f9..3cd734b 100644 --- a/src/tests/utils/stdioProxy.test.ts +++ b/src/tests/utils/stdioProxy.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { disableWin32InputMode, enableWin32InputMode } from "../../utils/ansi.js"; +import { disableWin32InputMode, enableWin32InputMode, index } from "../../utils/ansi.js"; import { StdioProxy } from "../../ui/stdioProxy.js"; const createRouter = () => { @@ -74,3 +74,61 @@ test("keeps unrelated outbound CSI sequences", () => { expect(proxy.handleOutput("\u001B[?25loutput")).toBe("\u001B[?25loutput"); expect(toggles).toEqual([]); }); + +test.each([ + ["kitty keyboard query", "\u001B[?u"], + ["kitty keyboard set", "\u001B[=1;1u"], + ["kitty keyboard reset", "\u001B[=0;1u"], + ["kitty keyboard push", "\u001B[>1u"], + ["kitty keyboard pop", "\u001B[<1u"], + ["modify other keys", "\u001B[>4;2m"], + ["modify other keys reset", "\u001B[>4m"], +])("strips outbound %s", (_name, sequence) => { + const { proxy } = createRouter(); + + expect(proxy.handleOutput(`before${sequence}after`)).toBe("beforeafter"); +}); + +test("handles outbound keyboard protocol upgrades split across chunks", () => { + const { proxy } = createRouter(); + + expect(proxy.handleOutput("before\u001B[>4;")).toBe("before"); + expect(proxy.handleOutput("2mafter")).toBe("after"); +}); + +test.each([ + ["restore cursor", "\u001B[u"], + ["secondary device attributes", "\u001B[>c"], + ["bracketed paste", "\u001B[?2004h"], + ["alternate buffer", "\u001B[?1049h"], + ["truecolor", "\u001B[38;2;219;177;49m"], +])("keeps outbound %s", (_name, sequence) => { + const { proxy } = createRouter(); + + expect(proxy.handleOutput(sequence)).toBe(sequence); +}); + +test("rewrites bare line feeds so the column survives newline auto-return", () => { + const { proxy } = createRouter(); + + expect(proxy.handleOutput("\u001B[H\u001B[2J\n\u001B[5C▄▀▀▄\n\u001B[5D▀▀▀▀▀▀")).toBe(`\u001B[H\u001B[2J${index}\u001B[5C▄▀▀▄${index}\u001B[5D▀▀▀▀▀▀`); +}); + +test("keeps carriage return line feed pairs intact", () => { + const { proxy } = createRouter(); + + expect(proxy.handleOutput("first\r\nsecond\r\n")).toBe("first\r\nsecond\r\n"); +}); + +test("rewrites consecutive bare line feeds", () => { + const { proxy } = createRouter(); + + expect(proxy.handleOutput("\r\n\n\n line")).toBe(`\r\n${index}${index} line`); +}); + +test("rewrites a line feed split from its carriage return, which lands in the same column", () => { + const { proxy } = createRouter(); + + expect(proxy.handleOutput("first\r")).toBe("first\r"); + expect(proxy.handleOutput("\nsecond")).toBe(`${index}second`); +}); diff --git a/src/ui/stdioProxy.ts b/src/ui/stdioProxy.ts index 61fbaf3..25dba6c 100644 --- a/src/ui/stdioProxy.ts +++ b/src/ui/stdioProxy.ts @@ -5,22 +5,41 @@ import readline from "node:readline"; import { PassThrough } from "node:stream"; import { StringDecoder } from "node:string_decoder"; -import { disableWin32InputMode, enableWin32InputMode } from "../utils/ansi.js"; +import * as ansi from "../utils/ansi.js"; import type { KeyPressEvent } from "./suggestionManager.js"; // eslint-disable-next-line no-control-regex const cursorPositionReport = new RegExp("\\u001B\\[\\??\\d+;\\d+R", "g"); // eslint-disable-next-line no-control-regex const partialCursorPositionReport = new RegExp("\\u001B\\[\\??\\d*(?:;\\d*)?$"); +// blocks win32 input mode, the kitty keyboard protocol and xterm modifyOtherKeys from upgrading input & breaking node's readline // eslint-disable-next-line no-control-regex -const win32InputMode = new RegExp("\\u001B\\[\\?9001([hl])", "g"); -const win32InputModeSequences = [enableWin32InputMode, disableWin32InputMode]; +const keyEncodingUpgrade = new RegExp("\\u001B\\[(?:\\?9001([hl])|\\?u|[=><][\\d;]*u|>[\\d;]*m)", "g"); +// a trailing portion of a key encoding upgrade +// eslint-disable-next-line no-control-regex +const partialKeyEncodingUpgrade = new RegExp("^\\u001B(?:\\[(?:\\?(?:9(?:0(?:0(?:1)?)?)?)?|[=><][\\d;]*)?)?$"); +const carriageReturn = "\r".charCodeAt(0); -const getPartialWin32InputMode = (input: string): string => { +const getPartialKeyEncodingUpgrade = (input: string): string => { const sequenceStart = input.lastIndexOf("\u001B"); if (sequenceStart === -1) return ""; const suffix = input.slice(sequenceStart); - return win32InputModeSequences.some((sequence) => sequence !== suffix && sequence.startsWith(suffix)) ? suffix : ""; + return partialKeyEncodingUpgrade.test(suffix) ? suffix : ""; +}; + +const replaceBareLineFeeds = (output: string): string => { + let feed = output.indexOf("\n"); + if (feed === -1) return output; + + let replaced = ""; + let copiedTo = 0; + for (; feed !== -1; feed = output.indexOf("\n", feed + 1)) { + if (output.charCodeAt(feed - 1) === carriageReturn) continue; + replaced += output.slice(copiedTo, feed); + replaced += ansi.index; + copiedTo = feed + 1; + } + return replaced + output.slice(copiedTo); }; type StdioProxyOptions = { @@ -54,13 +73,15 @@ export class StdioProxy { handleOutput(data: string): string { const input = this.#pendingOutput + data; - this.#pendingOutput = getPartialWin32InputMode(input); + this.#pendingOutput = getPartialKeyEncodingUpgrade(input); const completeInput = this.#pendingOutput.length === 0 ? input : input.slice(0, -this.#pendingOutput.length); - return completeInput.replace(win32InputMode, (_sequence, mode: string) => { - this.#onWin32InputMode(mode === "h"); - return ""; - }); + return replaceBareLineFeeds( + completeInput.replace(keyEncodingUpgrade, (_sequence, win32Mode?: string) => { + if (win32Mode != null) this.#onWin32InputMode(win32Mode === "h"); + return ""; + }), + ); } dispose(): string { diff --git a/src/utils/ansi.ts b/src/utils/ansi.ts index 3303b4f..d2bdb45 100644 --- a/src/utils/ansi.ts +++ b/src/utils/ansi.ts @@ -29,6 +29,7 @@ export const resetLine = CSI + "2K"; export const enableWin32InputMode = CSI + "?9001h"; export const disableWin32InputMode = CSI + "?9001l"; export const resetToInitialState = ESC + "c"; // RIS - Reset to Initial State +export const index = ESC + "D"; // IND - move down a row keeping the column (avoids newline auto-return, windows terminal default) export const cursorBackward = (count = 1) => CSI + count + "D"; export const cursorForward = (count = 1) => CSI + count + "C"; export const cursorTo = ({ x, y }: { x?: number; y?: number }) => {