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
60 changes: 59 additions & 1 deletion src/tests/utils/stdioProxy.test.ts
Original file line number Diff line number Diff line change
@@ -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 = () => {
Expand Down Expand Up @@ -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`);
});
41 changes: 31 additions & 10 deletions src/ui/stdioProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions src/utils/ansi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
Loading