diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..94f480d --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 31cccd6..9864fb7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/package-lock.json b/package-lock.json index 9d6cad7..97cc46a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 03c33c8..a6626ff 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/isterm/commandManager.ts b/src/isterm/commandManager.ts new file mode 100644 index 0000000..7c0b0e1 --- /dev/null +++ b/src/isterm/commandManager.ts @@ -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; + #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(/^(?.*\$\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(/(?(\(.+\)\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(/^(?(\(.+\)\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, + }); + } +} diff --git a/src/isterm/index.ts b/src/isterm/index.ts new file mode 100644 index 0000000..c5323ca --- /dev/null +++ b/src/isterm/index.ts @@ -0,0 +1,6 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { spawn } from "./pty.js"; + +export default { spawn }; diff --git a/src/isterm/input.ts b/src/isterm/input.ts new file mode 100644 index 0000000..f6b8ada --- /dev/null +++ b/src/isterm/input.ts @@ -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(); +}; diff --git a/src/isterm/pty.ts b/src/isterm/pty.ts new file mode 100644 index 0000000..f6147dc --- /dev/null +++ b/src/isterm/pty.ts @@ -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; + 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); +// }); diff --git a/src/tests/isterm/__snapshots__/pty.test.ts.snap b/src/tests/isterm/__snapshots__/pty.test.ts.snap new file mode 100644 index 0000000..94b448f --- /dev/null +++ b/src/tests/isterm/__snapshots__/pty.test.ts.snap @@ -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", +} +`; diff --git a/src/tests/isterm/pty.test.ts b/src/tests/isterm/pty.test.ts new file mode 100644 index 0000000..b2d07dd --- /dev/null +++ b/src/tests/isterm/pty.test.ts @@ -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, +); diff --git a/src/utils/ansi.ts b/src/utils/ansi.ts new file mode 100644 index 0000000..e07f4a5 --- /dev/null +++ b/src/utils/ansi.ts @@ -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"; diff --git a/src/utils/bindings.ts b/src/utils/bindings.ts index 8aa8c25..6ad7085 100644 --- a/src/utils/bindings.ts +++ b/src/utils/bindings.ts @@ -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( diff --git a/src/utils/log.ts b/src/utils/log.ts new file mode 100644 index 0000000..5f7d5f5 --- /dev/null +++ b/src/utils/log.ts @@ -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 }; diff --git a/tsconfig.json b/tsconfig.json index 0ab261f..b634d63 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,6 @@ "compilerOptions": { "jsx": "react", "outDir": "./build", - "types": ["@withfig/autocomplete-types", "jest"] + "types": ["@withfig/autocomplete-types", "jest", "node"] } }