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
10 changes: 9 additions & 1 deletion src/tests/utils/shell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,20 @@
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { getShellSourceCommand, hasLegacyShellConfig, Shell, shouldFlagLegacyResourcePlugin, zdotdir } from "../../utils/shell.js";
import { getShellSourceCommand, hasLegacyShellConfig, normalizeBackspace, Shell, shouldFlagLegacyResourcePlugin, zdotdir } from "../../utils/shell.js";

test("uses a process-specific ZDOTDIR", () => {
expect(zdotdir).toBe(path.join(os.tmpdir(), `is-zsh-${process.pid}`));
});

test.each([Shell.Pwsh, Shell.Powershell, Shell.Cmd, Shell.Nushell])("normalizes backspace without dropping other raw %s input", (shell) => {
expect(normalizeBackspace("\u001Ba\u0008b\u007F\r", shell)).toBe("\u001Ba\u007Fb\u007F\r");
});

test.each([Shell.Bash, Shell.Zsh, Shell.Fish, Shell.Xonsh])("preserves raw %s backspace input", (shell) => {
expect(normalizeBackspace("\u001Ba\u0008b\u007F\r", shell)).toBe("\u001Ba\u0008b\u007F\r");
});

describe("getShellSourceCommand", () => {
test.each([
[Shell.Bash, "~/.inshellisense/init/bash/init.sh", "[ -f ~/.inshellisense/init/bash/init.sh ] && source ~/.inshellisense/init/bash/init.sh"],
Expand Down
94 changes: 94 additions & 0 deletions src/tests/utils/stdioProxy.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

import { setTimeout as delay } from "node:timers/promises";

import { disableWin32InputMode, enableWin32InputMode, index } from "../../utils/ansi.js";
import { StdioProxy } from "../../ui/stdioProxy.js";

Expand All @@ -16,6 +18,98 @@ const createRouter = () => {
return { keypresses, proxy, responses, toggles };
};

// Readline captures native timers, which Jest's fake timers cannot advance.
test("emits a standalone Escape after the shortened timeout", async () => {
const { keypresses, proxy } = createRouter();
try {
proxy.handleInput("\u001B");
expect(keypresses).toEqual([]);

await delay(100);
expect(keypresses).toEqual(["\u001B"]);

proxy.handleInput("a");
expect(keypresses).toEqual(["\u001B", "a"]);
} finally {
proxy.dispose();
}
});

test.each([
["an arrow key", "[A"],
["an Alt combination", "a"],
])("preserves %s completed within the Escape timeout", async (_name, suffix) => {
const { keypresses, proxy } = createRouter();
try {
proxy.handleInput("\u001B");
await delay(25);
expect(keypresses).toEqual([]);

proxy.handleInput(suffix);
expect(keypresses).toEqual([`\u001B${suffix}`]);

await delay(100);
expect(keypresses).toEqual([`\u001B${suffix}`]);
} finally {
proxy.dispose();
}
});

test("forwards raw Escape immediately without creating a keypress", () => {
const { keypresses, proxy } = createRouter();
const forwarded: string[] = [];

proxy.handleInput("\u001B", (data) => forwarded.push(data));

expect(forwarded).toEqual(["\u001B"]);
expect(keypresses).toEqual([]);
proxy.dispose();
});

test("transfers an incomplete key to the raw route without a delayed duplicate", async () => {
const { keypresses, proxy } = createRouter();
const forwarded: string[] = [];
try {
proxy.handleInput("a\u001B");
proxy.handleInput("[A", (data) => forwarded.push(data));
await delay(100);
proxy.handleInput("\u001B[D");

expect(forwarded).toEqual(["\u001B[A"]);
expect(keypresses).toEqual(["a", "\u001B[D"]);
} finally {
proxy.dispose();
}
});

test("keeps cursor reports separate from raw input across chunks", () => {
const { keypresses, proxy, responses } = createRouter();
const forwarded: string[] = [];
const forward = (data: string) => forwarded.push(data);

proxy.handleInput("a\u001B[2;", forward);
proxy.handleInput("7Rb", forward);

expect(forwarded).toEqual(["a", "b"]);
expect(responses).toEqual(["\u001B[2;7R"]);
expect(keypresses).toEqual([]);
proxy.dispose();
});

test("preserves split UTF-8 input on the raw route", () => {
const { keypresses, proxy } = createRouter();
const forwarded: string[] = [];
const input = Buffer.from("\u{1F680}");
const forward = (data: string) => forwarded.push(data);

proxy.handleInput(input.subarray(0, 2), forward);
proxy.handleInput(input.subarray(2), forward);

expect(forwarded).toEqual(["\u{1F680}"]);
expect(keypresses).toEqual([]);
proxy.dispose();
});

test("consumes cursor-position reports without creating keypresses", () => {
const { keypresses, responses, proxy } = createRouter();

Expand Down
40 changes: 33 additions & 7 deletions src/ui/stdioProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import { StringDecoder } from "node:string_decoder";
import * as ansi from "../utils/ansi.js";
import type { KeyPressEvent } from "./suggestionManager.js";

const ESCAPE_CODE_TIMEOUT = 50;

// eslint-disable-next-line no-control-regex
const cursorPositionReport = new RegExp("\\u001B\\[\\??\\d+;\\d+R", "g");
// eslint-disable-next-line no-control-regex
Expand Down Expand Up @@ -48,7 +50,9 @@ type StdioProxyOptions = {
};

export class StdioProxy {
readonly #keypressInput = new PassThrough();
readonly #keypressListeners: ((...event: KeyPressEvent) => void)[] = [];
#pendingKeypressInput = "";
#keypressInput = this.#createKeypressInput();
#decoder = new StringDecoder("utf8");
readonly #onCursorPositionReport: (data: string) => void;
readonly #onWin32InputMode: (enabled: boolean) => void;
Expand All @@ -58,17 +62,16 @@ export class StdioProxy {
constructor({ onCursorPositionReport = () => {}, onWin32InputMode = () => {} }: StdioProxyOptions = {}) {
this.#onCursorPositionReport = onCursorPositionReport;
this.#onWin32InputMode = onWin32InputMode;
readline.emitKeypressEvents(this.#keypressInput as unknown as NodeJS.ReadStream);
}

onKeypress(listener: (...event: KeyPressEvent) => void): void {
this.#keypressInput.on("keypress", listener);
this.#keypressListeners.push(listener);
}

handleInput(data: Buffer | string): void {
handleInput(data: Buffer | string, forwardInput?: (data: string) => void): void {
const decoded = Buffer.isBuffer(data) ? this.#decoder.write(data) : this.#decoder.end() + data;
if (!Buffer.isBuffer(data)) this.#decoder = new StringDecoder("utf8");
this.#routeInput(this.#pendingInput + decoded);
this.#routeInput(this.#pendingInput + decoded, forwardInput);
}

handleOutput(data: string): string {
Expand All @@ -94,13 +97,36 @@ export class StdioProxy {
return pendingOutput;
}

#routeInput(input: string): void {
#createKeypressInput(): PassThrough {
const input = new PassThrough();
readline.emitKeypressEvents(input, { escapeCodeTimeout: ESCAPE_CODE_TIMEOUT } as unknown as readline.Interface);
input.on("keypress", (...event: KeyPressEvent) => {
this.#pendingKeypressInput = this.#pendingKeypressInput.slice(event[1].sequence.length);
this.#keypressListeners.forEach((listener) => listener(...event));
});
return input;
}

#routeInput(input: string, forwardInput?: (data: string) => void): void {
this.#pendingInput = input.match(partialCursorPositionReport)?.[0] ?? "";
const completeInput = this.#pendingInput.length === 0 ? input : input.slice(0, -this.#pendingInput.length);
const keypressInput = completeInput.replace(cursorPositionReport, (response) => {
this.#onCursorPositionReport(response);
return "";
});
if (keypressInput.length !== 0) this.#keypressInput.write(keypressInput);
if (forwardInput != null) {
const pending = this.#pendingKeypressInput;
if (pending.length !== 0) {
// Forward incomplete keys in order, without leaving readline's Escape timer active on this route.
this.#keypressInput.removeAllListeners("keypress");
this.#keypressInput.destroy();
this.#pendingKeypressInput = "";
this.#keypressInput = this.#createKeypressInput();
}
if (pending.length + keypressInput.length !== 0) forwardInput(pending + keypressInput);
} else if (keypressInput.length !== 0) {
this.#pendingKeypressInput += keypressInput;
this.#keypressInput.write(keypressInput);
}
}
}
9 changes: 5 additions & 4 deletions src/ui/ui-root.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import ansi from "ansi-escapes";
import { Command } from "commander";

import log from "../utils/log.js";
import { getBackspaceSequence, Shell } from "../utils/shell.js";
import { normalizeBackspace, Shell } from "../utils/shell.js";
import { resetToInitialState } from "../utils/ansi.js";
import { endTiming, incrementMetric, startTiming } from "../utils/performance.js";
import type { KeyPressEvent } from "./suggestionManager.js";
Expand Down Expand Up @@ -37,7 +37,8 @@ export const render = async (program: Command, shell: Shell, underTest: boolean,
}
},
});
const handleInput = (data: Buffer | string) => stdio.handleInput(data);
const forwardInput = (data: string) => term.write(normalizeBackspace(data, shell));
const handleInput = (data: Buffer | string) => stdio.handleInput(data, term.isAlternateBuffer() ? forwardInput : undefined);
process.stdin.on("data", handleInput);
writeOutput(ansi.clearTerminal);

Expand Down Expand Up @@ -81,7 +82,7 @@ export const render = async (program: Command, shell: Shell, underTest: boolean,
stdio.onKeypress((...keyPress: KeyPressEvent) => {
const press = keyPress[1];
if (term.isAlternateBuffer()) {
term.write(press.name === "backspace" ? getBackspaceSequence(keyPress, shell) : press.sequence);
forwardInput(press.sequence);
return;
}

Expand All @@ -91,7 +92,7 @@ export const render = async (program: Command, shell: Shell, underTest: boolean,
} else if (!inputHandled) {
if (press.name === "backspace") {
backspaceEchoPending = true;
term.write(getBackspaceSequence(keyPress, shell));
term.write(normalizeBackspace(press.sequence, shell));
} else {
term.write(press.sequence);
}
Expand Down
5 changes: 2 additions & 3 deletions src/utils/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import fsAsync from "node:fs/promises";
import util from "node:util";
import { shellResourcesPath, initResourcesPath, usesLegacyResources, xdgConfigPath } from "./constants.js";
import childProcess from "node:child_process";
import { KeyPressEvent } from "../ui/suggestionManager.js";
import log from "./log.js";

const exec = util.promisify(childProcess.exec);
Expand Down Expand Up @@ -260,8 +259,8 @@ const getGitBashPaths = async (): Promise<string[]> => {
return gitBashPaths;
};

export const getBackspaceSequence = (press: KeyPressEvent, shell: Shell) =>
shell === Shell.Pwsh || shell === Shell.Powershell || shell === Shell.Cmd || shell === Shell.Nushell ? "\u007F" : press[1].sequence;
export const normalizeBackspace = (input: string, shell: Shell) =>
shell === Shell.Pwsh || shell === Shell.Powershell || shell === Shell.Cmd || shell === Shell.Nushell ? input.replaceAll("\u0008", "\u007F") : input;

export const getPathSeparator = (shell: Shell) => (shell == Shell.Bash || shell == Shell.Xonsh || shell == Shell.Nushell ? "/" : path.sep);

Expand Down
Loading