mirror of
https://github.com/wavetermdev/inshellisense.git
synced 2026-08-05 13:43:30 -07:00
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:
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
+65
-28
@@ -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
|
||||
|
||||
Generated
+40
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
import { spawn } from "./pty.js";
|
||||
|
||||
export default { spawn };
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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",
|
||||
}
|
||||
`;
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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";
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -5,6 +5,6 @@
|
||||
"compilerOptions": {
|
||||
"jsx": "react",
|
||||
"outDir": "./build",
|
||||
"types": ["@withfig/autocomplete-types", "jest"]
|
||||
"types": ["@withfig/autocomplete-types", "jest", "node"]
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user