feat: implement isterm (#87)

* docs: notes about initial implementation

Signed-off-by: Chapman Pendery <cpendery@vt.edu>

* chore: partial impl of isterm

Signed-off-by: Chapman Pendery <cpendery@vt.edu>

* fix: restore original data echo, clear still breaks

Signed-off-by: Chapman Pendery <cpendery@vt.edu>

* fix: adjust incoming backspace so full line delete doesn't happen

Signed-off-by: Chapman Pendery <cpendery@vt.edu>

* fix: term size giving confusing clear issues

Signed-off-by: Chapman Pendery <cpendery@vt.edu>

* fix: handle wrapped text & detecting when output is under prompt

Signed-off-by: Chapman Pendery <cpendery@vt.edu>

* feat: add detection for if the cursor is in a suggestable position

Signed-off-by: Chapman Pendery <cpendery@vt.edu>

* fix: add resize handler

Signed-off-by: Chapman Pendery <cpendery@vt.edu>

* fix: bash initial prompt detection & clear conflict

Signed-off-by: Chapman Pendery <cpendery@vt.edu>

* feat: add better debugging logging support to isterm

Signed-off-by: Chapman Pendery <cpendery@vt.edu>

* refactor: use es private field syntax

Signed-off-by: Chapman Pendery <cpendery@vt.edu>

* test: isterm

Signed-off-by: Chapman Pendery <cpendery@vt.edu>

* ci: update for node-pty

Signed-off-by: Chapman Pendery <cpendery@vt.edu>

* ci: add pwsh prompt setup for testing

Signed-off-by: Chapman Pendery <cpendery@vt.edu>

* style: fix lint issue

Signed-off-by: Chapman Pendery <cpendery@vt.edu>

* ci: try lf to fix issue

Signed-off-by: Chapman Pendery <cpendery@vt.edu>

* ci: enforce lf via gitattributes

Signed-off-by: Chapman Pendery <cpendery@vt.edu>

* ci: tweak config

Signed-off-by: Chapman Pendery <cpendery@vt.edu>

* ci: rollback types upgrade

Signed-off-by: Chapman Pendery <cpendery@vt.edu>

* ci: drop macos builds for now

Signed-off-by: Chapman Pendery <cpendery@vt.edu>

* ci: disable logging

Signed-off-by: Chapman Pendery <cpendery@vt.edu>

* test: unix shells

Signed-off-by: Chapman Pendery <cpendery@vt.edu>

* ci: use step conditions

Signed-off-by: Chapman Pendery <cpendery@vt.edu>

* ci: use condition for pwsh setup

Signed-off-by: Chapman Pendery <cpendery@vt.edu>

* ci: disable isterm tests

Signed-off-by: Chapman Pendery <cpendery@vt.edu>

---------

Signed-off-by: Chapman Pendery <cpendery@vt.edu>
This commit is contained in:
Chapman Pendery
2023-11-20 13:54:39 -08:00
committed by GitHub
parent 0736ad81d2
commit 930381cbf9
14 changed files with 764 additions and 31 deletions
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+65 -28
View File
@@ -1,28 +1,65 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: ["16.x", "18.x", "20.x"]
steps:
- uses: actions/checkout@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm run lint
- run: npm test
- run: npm run build
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
strategy:
matrix:
os: ["windows-latest", "ubuntu-latest"]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- name: Use Node.js 18.x
uses: actions/setup-node@v3
with:
node-version: 18.x
- run: npm ci
- run: npm run lint
- name: setup pwsh prompt
shell: pwsh
if: matrix.os == 'windows-latest'
run: |
New-Item -Path $profile -ItemType File -Force
Set-Content $profile 'function prompt {'
Add-Content $profile ' $prompt = "PS: $(get-date)> "'
Add-Content $profile ' return "`e]6973;PS`a$prompt`e]6973;PE`a"'
Add-Content $profile '}'
- name: setup powershell prompt
if: matrix.os == 'windows-latest'
shell: powershell
run: |
New-Item -Path $profile -ItemType File -Force
Set-Content $profile 'function prompt {'
Add-Content $profile ' $ESC = [char]27'
Add-Content $profile ' $BEL = [char]7'
Add-Content $profile ' $prompt = "PS: $(Get-Location)> "'
Add-Content $profile ' return "$ESC]6973;PS$BEL$prompt$ESC]6973;PE$BEL"'
Add-Content $profile '}'
- name: setup fish prompt
if: matrix.os != 'windows-latest'
shell: bash
run: |
mkdir -p ~/.config/fish/functions && touch ~/.config/fish/functions/fish_prompt.fish
echo "function fish_prompt -d \"Write out the prompt\"" >> ~/.config/fish/functions/fish_prompt.fish
echo " printf '\033]6973;PS\007%s@%s %s%s%s > \033]6973;PE\007' $USER $hostname (set_color $fish_color_cwd) (prompt_pwd) (set_color normal)" >> ~/.config/fish/functions/fish_prompt.fish
echo "end" >> ~/.config/fish/functions/fish_prompt.fish
- name: setup zsh-autosuggestions
if: matrix.os != 'windows-latest'
shell: bash
run: |
git clone https://github.com/zsh-users/zsh-autosuggestions ~/.zsh/zsh-autosuggestions
echo "source ~/.zsh/zsh-autosuggestions/zsh-autosuggestions.zsh" > ~/.zshrc
- run: npm test
- run: npm run build
+40 -1
View File
@@ -14,8 +14,10 @@
"commander": "^11.0.0",
"find-process": "^1.4.7",
"ink": "^4.4.1",
"node-pty": "^1.0.0",
"react": "^18.2.0",
"wrap-ansi": "^8.1.0"
"wrap-ansi": "^8.1.0",
"xterm-headless": "^5.3.0"
},
"bin": {
"inshellisense": "build/index.js",
@@ -6392,6 +6394,11 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/nan": {
"version": "2.18.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.18.0.tgz",
"integrity": "sha512-W7tfG7vMOGtD30sHoZSSc/JVYiyDPEyQVso/Zz+/uQd0B0L46gtC+pHha5FFMRpil6fm/AoEcRWyOVi4+E/f8w=="
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -6404,6 +6411,15 @@
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
"dev": true
},
"node_modules/node-pty": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz",
"integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==",
"hasInstallScript": true,
"dependencies": {
"nan": "^2.17.0"
}
},
"node_modules/node-releases": {
"version": "2.0.13",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz",
@@ -7904,6 +7920,11 @@
}
}
},
"node_modules/xterm-headless": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/xterm-headless/-/xterm-headless-5.3.0.tgz",
"integrity": "sha512-HjKkEgvjlyXfZvI0LdQChOqGL5nDiXge6X2IvoQbOn+oavAKUCX9hKHtDxmWVwxgNCCvXDnfQCYL+3wyHQ9PXA=="
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
@@ -12714,6 +12735,11 @@
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"nan": {
"version": "2.18.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.18.0.tgz",
"integrity": "sha512-W7tfG7vMOGtD30sHoZSSc/JVYiyDPEyQVso/Zz+/uQd0B0L46gtC+pHha5FFMRpil6fm/AoEcRWyOVi4+E/f8w=="
},
"natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -12726,6 +12752,14 @@
"integrity": "sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==",
"dev": true
},
"node-pty": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/node-pty/-/node-pty-1.0.0.tgz",
"integrity": "sha512-wtBMWWS7dFZm/VgqElrTvtfMq4GzJ6+edFI0Y0zyzygUSZMgZdraDUMUhCIvkjhJjme15qWmbyJbtAx4ot4uZA==",
"requires": {
"nan": "^2.17.0"
}
},
"node-releases": {
"version": "2.0.13",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.13.tgz",
@@ -13749,6 +13783,11 @@
"integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==",
"requires": {}
},
"xterm-headless": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/xterm-headless/-/xterm-headless-5.3.0.tgz",
"integrity": "sha512-HjKkEgvjlyXfZvI0LdQChOqGL5nDiXge6X2IvoQbOn+oavAKUCX9hKHtDxmWVwxgNCCvXDnfQCYL+3wyHQ9PXA=="
},
"y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
+3 -1
View File
@@ -38,8 +38,10 @@
"commander": "^11.0.0",
"find-process": "^1.4.7",
"ink": "^4.4.1",
"node-pty": "^1.0.0",
"react": "^18.2.0",
"wrap-ansi": "^8.1.0"
"wrap-ansi": "^8.1.0",
"xterm-headless": "^5.3.0"
},
"devDependencies": {
"@tsconfig/node18": "^18.2.2",
+214
View File
@@ -0,0 +1,214 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
import { IBufferCell, IMarker, Terminal } from "xterm-headless";
import os from "node:os";
import { Shell } from "../utils/bindings.js";
import log from "../utils/log.js";
type TerminalCommand = {
promptStartMarker?: IMarker;
promptEndMarker?: IMarker;
promptEndX?: number;
} & CommandState;
export type CommandState = {
promptText?: string;
commandText?: string;
suggestionsText?: string;
hasOutput?: boolean;
cursorTerminated?: boolean;
};
export class CommandManager {
#activeCommand: TerminalCommand;
#previousCommandLines: Set<number>;
#terminal: Terminal;
#shell: Shell;
readonly #supportsProperOscPlacements = os.platform() !== "win32";
constructor(terminal: Terminal, shell: Shell) {
this.#terminal = terminal;
this.#shell = shell;
this.#activeCommand = {};
this.#previousCommandLines = new Set();
if (this.#supportsProperOscPlacements) {
this.#terminal.parser.registerCsiHandler({ final: "J" }, (params) => {
if (params.at(0) == 3 || params.at(0) == 2) {
this.handleClear();
}
return false;
});
}
}
handlePromptStart() {
this.#activeCommand = { promptStartMarker: this.#terminal.registerMarker(0), hasOutput: false, cursorTerminated: false };
}
handlePromptEnd() {
this.#activeCommand.promptEndMarker = this.#terminal.registerMarker(0);
if (this.#activeCommand.promptEndMarker?.line === this.#terminal.buffer.active.cursorY) {
this.#activeCommand.promptEndX = this.#terminal.buffer.active.cursorX;
}
if (this.#supportsProperOscPlacements) {
this.#activeCommand.promptText = this.#terminal.buffer.active.getLine(this.#activeCommand.promptEndMarker?.line ?? 0)?.translateToString(true);
this.#previousCommandLines.add(this.#activeCommand.promptEndMarker?.line ?? -1);
}
}
handleClear() {
this.handlePromptStart();
this.#previousCommandLines = new Set();
}
private _getWindowsPrompt(y: number) {
const line = this.#terminal.buffer.active.getLine(y);
if (!line) {
return;
}
const lineText = line.translateToString(true);
if (!lineText) {
return;
}
// User defined prompt
// TOOD: allow users to define their own prompt patterns per shell
if (this.#shell == Shell.Bash) {
const bashPrompt = lineText.match(/^(?<prompt>.*\$\s?)/)?.groups?.prompt;
if (bashPrompt) {
const adjustedPrompt = this._adjustPrompt(bashPrompt, lineText, "$");
if (adjustedPrompt) {
return adjustedPrompt;
}
}
}
if (this.#shell == Shell.Powershell || this.#shell == Shell.Pwsh) {
const pwshPrompt = lineText.match(/(?<prompt>(\(.+\)\s)?(?:PS.+>\s?))/)?.groups?.prompt;
if (pwshPrompt) {
const adjustedPrompt = this._adjustPrompt(pwshPrompt, lineText, ">");
if (adjustedPrompt) {
return adjustedPrompt;
}
}
}
if (this.#shell == Shell.Cmd) {
return lineText.match(/^(?<prompt>(\(.+\)\s)?(?:[A-Z]:\\.*>))/)?.groups?.prompt;
}
// Custom prompts like starship end in the common \u276f character
const customPrompt = lineText.match(/.*\u276f(?=[^\u276f]*$)/g)?.[0];
if (customPrompt) {
const adjustedPrompt = this._adjustPrompt(customPrompt, lineText, "\u276f");
if (adjustedPrompt) {
return adjustedPrompt;
}
}
}
private _adjustPrompt(prompt: string | undefined, lineText: string, char: string): string | undefined {
if (!prompt) {
return;
}
// Conpty may not 'render' the space at the end of the prompt
if (lineText === prompt && prompt.endsWith(char)) {
prompt += " ";
}
return prompt;
}
private _isSuggestion(cell: IBufferCell | undefined): boolean {
log.debug({ msg: "suggestion detection", fgColor: cell?.getFgColor(), content: cell?.getChars() });
const color = cell?.getFgColor();
return color == 8 || (color ?? 0) > 235;
}
getState(): CommandState {
return {
promptText: this.#activeCommand.promptText,
commandText: this.#activeCommand.commandText,
suggestionsText: this.#activeCommand.suggestionsText,
hasOutput: this.#activeCommand.hasOutput,
cursorTerminated: this.#activeCommand.cursorTerminated,
};
}
termSync() {
if (this.#activeCommand.promptEndMarker == null || this.#activeCommand.promptStartMarker == null) {
return;
}
const promptEndMarker = this.#activeCommand.promptEndMarker;
const promptStartMarker = this.#activeCommand.promptStartMarker;
const globalCursorPosition = this.#terminal.buffer.active.baseY + this.#terminal.buffer.active.cursorY;
const withinPollDistance = globalCursorPosition < this.#activeCommand.promptEndMarker.line + 5;
if (globalCursorPosition < promptStartMarker.line) {
this.handleClear();
}
// if we haven't fond the prompt yet, poll over the next 5 lines searching for it
if (this.#activeCommand.promptText == null && withinPollDistance) {
for (let i = globalCursorPosition; i < promptEndMarker.line + 5; i++) {
if (this.#previousCommandLines.has(i)) continue;
const promptResult = this._getWindowsPrompt(i);
if (promptResult != null) {
this.#activeCommand.promptEndMarker = this.#terminal.registerMarker(i - globalCursorPosition);
this.#activeCommand.promptEndX = promptResult.length;
this.#activeCommand.promptText = promptResult;
this.#previousCommandLines.add(i);
}
}
}
// if the prompt is set, now parse out the values from the terminal
if (this.#activeCommand.promptText != null) {
let lineY = promptEndMarker.line;
let line = this.#terminal.buffer.active.getLine(promptEndMarker.line);
let command = "";
let suggestions = "";
for (;;) {
for (let i = lineY == promptEndMarker.line ? this.#activeCommand.promptText.length : 0; i < this.#terminal.cols; i++) {
const cell = line?.getCell(i);
if (cell == null) continue;
if (!this._isSuggestion(cell) && suggestions.length == 0) {
command += cell.getChars();
} else {
suggestions += cell.getChars();
}
}
lineY += 1;
line = this.#terminal.buffer.active.getLine(lineY);
if (!line?.isWrapped) {
break;
}
}
const cursorAtEndOfInput = (this.#activeCommand.promptText.length + command.trim().length) % this.#terminal.cols <= this.#terminal.buffer.active.cursorX;
let hasOutput = false;
for (let i = 0; i < this.#terminal.cols; i++) {
const cell = line?.getCell(i);
if (cell == null) continue;
hasOutput = cell.getChars() != "";
if (hasOutput) {
break;
}
}
this.#activeCommand.hasOutput = hasOutput;
this.#activeCommand.suggestionsText = suggestions.trim();
this.#activeCommand.commandText = command.trim();
this.#activeCommand.cursorTerminated = cursorAtEndOfInput;
}
log.debug({
msg: "cmd manager state",
...this.#activeCommand,
promptEndMarker: this.#activeCommand.promptEndMarker?.line,
promptStartMarker: this.#activeCommand.promptStartMarker?.line,
});
}
}
+6
View File
@@ -0,0 +1,6 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
import { spawn } from "./pty.js";
export default { spawn };
+10
View File
@@ -0,0 +1,10 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
export const inputModifier = (input: Buffer): string => {
switch (input.toString()) {
case "\b":
return "\u007F"; // DEL
}
return input.toString();
};
+159
View File
@@ -0,0 +1,159 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
import { EventEmitter } from "node:events";
import process from "node:process";
import os from "node:os";
import pty, { IPty, IEvent } from "node-pty";
import { Shell } from "../utils/bindings.js";
import { IsTermOscPs, IstermOscPt, IstermPromptStart, IstermPromptEnd } from "../utils/ansi.js";
import xterm from "xterm-headless";
import { CommandManager, CommandState } from "./commandManager.js";
import log from "../utils/log.js";
// import { inputModifier } from "./input.js";
const ISTermOnDataEvent = "data";
type ISTermOptions = {
env?: { [key: string]: string | undefined };
rows: number;
cols: number;
shell: Shell;
};
class ISTerm implements IPty {
readonly pid: number;
cols: number;
rows: number;
readonly process: string;
readonly handleFlowControl = false;
readonly onData: IEvent<string>;
readonly onExit: IEvent<{ exitCode: number; signal?: number }>;
shellBuffer?: string;
readonly #pty: IPty;
readonly #ptyEmitter: EventEmitter;
readonly #term: xterm.Terminal;
readonly #commandManager: CommandManager;
constructor({ shell, cols, rows, env }: ISTermOptions) {
this.#pty = pty.spawn(convertToPtyTarget(shell), [], {
name: "xterm-256color",
cols,
rows,
cwd: process.cwd(),
env: { ...convertToPtyEnv(shell), ...env },
});
this.pid = this.#pty.pid;
this.cols = this.#pty.cols;
this.rows = this.#pty.rows;
this.process = this.#pty.process;
this.#term = new xterm.Terminal({ allowProposedApi: true });
this.#term.parser.registerOscHandler(IsTermOscPs, (data) => this._handleIsSequence(data));
this.#commandManager = new CommandManager(this.#term, shell);
this.#ptyEmitter = new EventEmitter();
this.#pty.onData((data) => {
this.#term.write(data, () => {
log.debug({ msg: "parsing data", data, bytes: Uint8Array.from([...data].map((c) => c.charCodeAt(0))) });
this.#commandManager.termSync();
this.#ptyEmitter.emit(ISTermOnDataEvent, data);
});
});
this.onData = (listener) => {
this.#ptyEmitter.on(ISTermOnDataEvent, listener);
return {
dispose: () => this.#ptyEmitter.removeListener(ISTermOnDataEvent, listener),
};
};
this.onExit = this.#pty.onExit;
}
private _handleIsSequence(data: string): boolean {
const argsIndex = data.indexOf(";");
const sequence = argsIndex === -1 ? data : data.substring(0, argsIndex);
switch (sequence) {
case IstermOscPt.PromptStarted:
this.#commandManager.handlePromptStart();
break;
case IstermOscPt.PromptEnded:
this.#commandManager.handlePromptEnd();
break;
default:
return false;
}
return true;
}
resize(columns: number, rows: number) {
this.cols = columns;
this.rows = rows;
this.#pty.resize(columns, rows);
this.#term.resize(columns, rows);
}
clear() {
this.#term.reset();
this.#pty.clear();
}
kill(signal?: string) {
this.#pty.kill(signal);
}
pause(): void {
this.#pty.pause();
}
resume(): void {
this.#pty.resume();
}
write(data: string): void {
log.debug({ msg: "reading data", data, bytes: Uint8Array.from([...data].map((c) => c.charCodeAt(0))) });
this.#pty.write(data);
}
getCommandState(): CommandState {
return this.#commandManager.getState();
}
}
export const spawn = (options: ISTermOptions): ISTerm => {
return new ISTerm(options);
};
const convertToPtyTarget = (shell: Shell): string => {
return os.platform() == "win32" ? `${shell}.exe` : shell;
};
const convertToPtyEnv = (shell: Shell) => {
switch (shell) {
case Shell.Cmd: {
const prompt = process.env.PROMPT ? process.env.PROMPT : "$P$G";
return { ...process.env, PROMPT: `${IstermPromptStart}${prompt}${IstermPromptEnd}` };
}
}
return process.env;
};
// TODO bring up to higher level outside isterm
// await log.reset();
// const ptyProcess = spawn({ shell: Shell.Fish, rows: process.stdout.rows, cols: process.stdout.columns });
// process.stdin.setRawMode(true);
// ptyProcess.onData((data) => {
// process.stdout.write(data);
// });
// process.stdin.on("data", (d: Buffer) => {
// ptyProcess.write(inputModifier(d));
// });
// ptyProcess.onExit(({ exitCode }) => {
// process.exit(exitCode);
// });
// process.stdout.on("resize", () => {
// ptyProcess.resize(process.stdout.columns, process.stdout.rows);
// });
@@ -0,0 +1,101 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`test bash on initial simple command input 1`] = `
{
"commandText": "zsh",
"cursorTerminated": true,
"hasOutput": false,
"hasPromptText": true,
"suggestionsText": "",
}
`;
exports[`test cmd on initial simple command input 1`] = `
{
"commandText": "dir",
"cursorTerminated": true,
"hasOutput": false,
"hasPromptText": true,
"suggestionsText": "",
}
`;
exports[`test cmd on initial simple command input with cursor movement backward 1`] = `
{
"commandText": "dir",
"cursorTerminated": false,
"hasOutput": false,
"hasPromptText": true,
"suggestionsText": "",
}
`;
exports[`test cmd on multi line command input 1`] = `
{
"commandText": "cmd",
"cursorTerminated": true,
"hasOutput": false,
"hasPromptText": true,
"suggestionsText": "",
}
`;
exports[`test fish on suggestion detection 1`] = `
{
"commandText": "l",
"cursorTerminated": true,
"hasOutput": false,
"hasPromptText": true,
"suggestionsText": "s -la",
}
`;
exports[`test powershell on multi line command input 1`] = `
{
"commandText": "cmd",
"cursorTerminated": true,
"hasOutput": false,
"hasPromptText": true,
"suggestionsText": "",
}
`;
exports[`test pwsh on command detection after failure 1`] = `
{
"commandText": "zsh",
"cursorTerminated": true,
"hasOutput": false,
"hasPromptText": true,
"suggestionsText": "",
}
`;
exports[`test pwsh on suggestion detection 1`] = `
{
"commandText": "ls",
"cursorTerminated": true,
"hasOutput": false,
"hasPromptText": true,
"suggestionsText": "-Force",
}
`;
exports[`test zsh on initial simple command input 1`] = `
{
"commandText": "zsh",
"cursorTerminated": true,
"hasOutput": false,
"hasPromptText": true,
"suggestionsText": "",
}
`;
exports[`test zsh on suggestion detection 1`] = `
{
"commandText": "l",
"cursorTerminated": true,
"hasOutput": false,
"hasPromptText": true,
"suggestionsText": "s -la",
}
`;
+116
View File
@@ -0,0 +1,116 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
import os from "node:os";
import isterm from "../../isterm";
import { cursorBackward, IstermPromptEnd, IstermPromptStart } from "../../utils/ansi";
import { Shell } from "../../utils/bindings";
const windowsTest = os.platform() == "win32" ? test.skip : test.skip;
const unixTest = os.platform() == "darwin" || os.platform() == "linux" ? test.skip : test.skip;
const bashEnv = { PS1: `${IstermPromptStart}\\u$ ${IstermPromptEnd}` };
const zshEnv = { PROMPT: `%{${IstermPromptStart}%}%/ %# %{${IstermPromptEnd}%}` };
const runTerm = async (shell: Shell, input: string[], env?: { [key: string]: string | undefined }) => {
const ptyProcess = isterm.spawn({ shell, rows: process.stdout.rows, cols: process.stdout.columns, env });
await new Promise((r) => setTimeout(r, 1_000));
for (const data of input) {
ptyProcess.write(data);
await new Promise((r) => setTimeout(r, 1_000));
}
const commandState = ptyProcess.getCommandState();
const hasPromptText = commandState.promptText != null;
delete commandState.promptText;
return { ...commandState, hasPromptText };
};
windowsTest(
"test cmd on initial simple command input",
async () => {
const r = await runTerm(Shell.Cmd, ["dir"]);
expect(r).toMatchSnapshot();
},
10000,
);
windowsTest(
"test cmd on initial simple command input with cursor movement backward",
async () => {
const r = await runTerm(Shell.Cmd, ["dir" + cursorBackward(1)]);
expect(r).toMatchSnapshot();
},
10000,
);
windowsTest(
"test cmd on multi line command input",
async () => {
const r = await runTerm(Shell.Cmd, ["dir\r", "cmd"]);
expect(r).toMatchSnapshot();
},
10000,
);
windowsTest(
"test powershell on multi line command input",
async () => {
const r = await runTerm(Shell.Powershell, ["dir\r", "cmd"]);
expect(r).toMatchSnapshot();
},
10000,
);
windowsTest(
"test pwsh on suggestion detection",
async () => {
const r = await runTerm(Shell.Pwsh, ["ls -Force\r", "ls"]);
expect(r).toMatchSnapshot();
},
10000,
);
windowsTest(
"test pwsh on command detection after failure",
async () => {
const r = await runTerm(Shell.Pwsh, ["tomato\r", "zsh"]);
expect(r).toMatchSnapshot();
},
10000,
);
unixTest(
"test bash on initial simple command input",
async () => {
const r = await runTerm(Shell.Bash, ["ls\r", "zsh"], bashEnv);
expect(r).toMatchSnapshot();
},
10000,
);
unixTest(
"test zsh on initial simple command input",
async () => {
const r = await runTerm(Shell.Zsh, ["ls\r", "zsh"], zshEnv);
expect(r).toMatchSnapshot();
},
10000,
);
unixTest(
"test zsh on suggestion detection",
async () => {
const r = await runTerm(Shell.Zsh, ["ls -la\r", "l"], zshEnv);
expect(r).toMatchSnapshot();
},
10000,
);
unixTest(
"test fish on suggestion detection",
async () => {
const r = await runTerm(Shell.Fish, ["ls -la\r", "l"]);
expect(r).toMatchSnapshot();
},
10000,
);
+20
View File
@@ -0,0 +1,20 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
const ESC = "\u001B[";
const OSC = "\u001B]";
const BEL = "\u0007";
export const IsTermOscPs = 6973;
const IS_OSC = OSC + IsTermOscPs + ";";
export enum IstermOscPt {
PromptStarted = "PS",
PromptEnded = "PE",
}
export const IstermPromptStart = IS_OSC + IstermOscPt.PromptStarted + BEL;
export const IstermPromptEnd = IS_OSC + IstermOscPt.PromptEnded + BEL;
export const cursorHide = ESC + "?25l";
export const cursorShow = ESC + "?25h";
export const cursorBackward = (count = 1) => ESC + count + "D";
+1
View File
@@ -21,6 +21,7 @@ export enum Shell {
Pwsh = "pwsh",
Zsh = "zsh",
Fish = "fish",
Cmd = "cmd",
}
export const supportedShells = [Shell.Bash, process.platform == "win32" ? Shell.Powershell : null, Shell.Pwsh, Shell.Zsh, Shell.Fish].filter(
+27
View File
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
import os from "node:os";
import path from "node:path";
import fs from "node:fs";
import fsAsync from "node:fs/promises";
const logTarget = path.join(os.homedir(), ".inshellisense", "inshellisense.log");
const logEnabled = false;
const reset = async () => {
await fsAsync.writeFile(logTarget, "");
};
const debug = (content: object) => {
if (!logEnabled) {
return;
}
fs.appendFile(logTarget, JSON.stringify(content) + "\n", (err) => {
if (err != null) {
throw err;
}
});
};
export default { reset, debug };
+1 -1
View File
@@ -5,6 +5,6 @@
"compilerOptions": {
"jsx": "react",
"outDir": "./build",
"types": ["@withfig/autocomplete-types", "jest"]
"types": ["@withfig/autocomplete-types", "jest", "node"]
}
}