Skip to content
Draft
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 { getBackspaceSequence, getShellSourceCommand, hasLegacyShellConfig, 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(getBackspaceSequence("\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(getBackspaceSequence("\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
59 changes: 59 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 { jest } from "@jest/globals";

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

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

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 () => {
jest.useFakeTimers();
const { keypresses, proxy } = createRouter();
const forwarded: string[] = [];
try {
proxy.handleInput("a\u001B");
proxy.handleInput("[A", (data) => forwarded.push(data));
await jest.advanceTimersByTimeAsync(1_000);
proxy.handleInput("\u001B[D");

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

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
38 changes: 31 additions & 7 deletions src/ui/stdioProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,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 +60,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 +95,36 @@ export class StdioProxy {
return pendingOutput;
}

#routeInput(input: string): void {
#createKeypressInput(): PassThrough {
const input = new PassThrough();
readline.emitKeypressEvents(input as unknown as NodeJS.ReadStream);
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);
}
}
}
7 changes: 4 additions & 3 deletions src/ui/ui-root.ts
Original file line number Diff line number Diff line change
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(getBackspaceSequence(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);
term.write(press.name === "backspace" ? getBackspaceSequence(press.sequence, shell) : 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(getBackspaceSequence(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 getBackspaceSequence = (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