diff --git a/src/commands/root.ts b/src/commands/root.ts index d4cb5e3..bcdcddd 100644 --- a/src/commands/root.ts +++ b/src/commands/root.ts @@ -3,54 +3,16 @@ import { initRender } from "../ui/ui-init.js"; import { render } from "../ui/ui-root.js"; -import { executeShellCommandTTY, ExecuteShellCommandTTYResult } from "../runtime/utils.js"; -import { saveCommand, loadCommand } from "../utils/cache.js"; -import { supportedShells as shells } from "../utils/bindings.js"; +import { Shell, supportedShells as shells } from "../utils/bindings.js"; import { inferShell } from "../utils/shell.js"; import { Command } from "commander"; export const supportedShells = shells.join(", "); -type RootCommandOptions = { - shell: string | undefined; - command: string | undefined; - history: boolean | undefined; - duration: string | undefined; -}; - -export const action = (program: Command) => async (options: RootCommandOptions) => { - if (options.history) { - process.stdout.write(await loadCommand()); - process.exit(0); - } - - const shell = options.shell ?? (await inferShell()) ?? (await initRender()) ?? ""; +export const action = (program: Command) => async () => { + const shell = ((await inferShell()) ?? (await initRender()) ?? "") as unknown as Shell; if (!shells.map((s) => s.valueOf()).includes(shell)) { program.error(`Unsupported shell: '${shell}', supported shells: ${supportedShells}`, { exitCode: 1 }); } - - let executed = false; - const commands = []; - let result: ExecuteShellCommandTTYResult = { code: 0 }; - let startingCommand = options.command; - while (options.duration === "session" || !executed) { - const commandToExecute = await render(startingCommand); - - if (commandToExecute == null || commandToExecute.trim().toLowerCase() == "exit" || commandToExecute.trim().toLowerCase() == "logout") { - result = { code: 0 }; - break; - } - - commands.push(commandToExecute); - result = await executeShellCommandTTY(shell, commandToExecute); - executed = true; - startingCommand = undefined; - } - await saveCommand(commands); - - if (result.code) { - process.exit(result.code); - } else { - process.exit(0); - } + await render(shell); }; diff --git a/src/index.ts b/src/index.ts index d943c90..20f4fb7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,7 +9,7 @@ import { Command } from "commander"; import bind from "./commands/bind.js"; import uninstall from "./commands/uninstall.js"; -import { action, supportedShells } from "./commands/root.js"; +import { action } from "./commands/root.js"; import { getVersion } from "./utils/version.js"; const program = new Command(); @@ -18,10 +18,6 @@ program .name("inshellisense") .description("IDE style command line auto complete") .version(await getVersion(), "-v, --version", "output the current version") - .option("-s, --shell ", `shell to use for command execution, supported shells: ${supportedShells}`) - .option("-c, --command ", "command to use as initial input") - .option("--history", "get the last command execute") - .option("-d, --duration ", "duration of the autocomplete session, supported durations: single, session", "session") .action(action(program)) .showHelpAfterError("(add --help for additional information)"); diff --git a/src/isterm/commandManager.ts b/src/isterm/commandManager.ts index 7c0b0e1..4fdfb58 100644 --- a/src/isterm/commandManager.ts +++ b/src/isterm/commandManager.ts @@ -16,6 +16,7 @@ export type CommandState = { promptText?: string; commandText?: string; suggestionsText?: string; + persistentOutput?: boolean; hasOutput?: boolean; cursorTerminated?: boolean; }; @@ -120,9 +121,14 @@ export class CommandManager { } 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; + const dullColor = color == 8 || (color ?? 0) > 235; + if (this.#shell == Shell.Powershell) { + return false; + } else if (this.#shell == Shell.Pwsh) { + return (color ?? 0) > 235; + } + return dullColor; } getState(): CommandState { @@ -198,9 +204,11 @@ export class CommandManager { } } + const commandPostfix = this.#activeCommand.promptText.length + command.trim().length < this.#terminal.buffer.active.cursorX ? " " : ""; + this.#activeCommand.persistentOutput = this.#activeCommand.hasOutput && hasOutput; this.#activeCommand.hasOutput = hasOutput; this.#activeCommand.suggestionsText = suggestions.trim(); - this.#activeCommand.commandText = command.trim(); + this.#activeCommand.commandText = command.trim() + commandPostfix; this.#activeCommand.cursorTerminated = cursorAtEndOfInput; } diff --git a/src/isterm/pty.ts b/src/isterm/pty.ts index f6147dc..1147c2f 100644 --- a/src/isterm/pty.ts +++ b/src/isterm/pty.ts @@ -11,7 +11,6 @@ import { IsTermOscPs, IstermOscPt, IstermPromptStart, IstermPromptEnd } from ".. 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"; @@ -22,7 +21,7 @@ type ISTermOptions = { shell: Shell; }; -class ISTerm implements IPty { +export class ISTerm implements IPty { readonly pid: number; cols: number; rows: number; @@ -50,7 +49,7 @@ class ISTerm implements IPty { this.rows = this.#pty.rows; this.process = this.#pty.process; - this.#term = new xterm.Terminal({ allowProposedApi: true }); + this.#term = new xterm.Terminal({ allowProposedApi: true, rows, cols }); this.#term.parser.registerOscHandler(IsTermOscPs, (data) => this._handleIsSequence(data)); this.#commandManager = new CommandManager(this.#term, shell); @@ -118,8 +117,17 @@ class ISTerm implements IPty { } getCommandState(): CommandState { + log.debug({ x: this.#term.buffer.active.cursorX, y: this.#term.buffer.active.baseY, lines: this.#term.buffer.active.length }); return this.#commandManager.getState(); } + + getCursorState() { + return { + onLastLine: this.#term.buffer.active.cursorY >= this.#term.rows - 2, + cursorX: this.#term.buffer.active.cursorX, + cursorY: this.#term.buffer.active.cursorY, + }; + } } export const spawn = (options: ISTermOptions): ISTerm => { @@ -139,21 +147,3 @@ const convertToPtyEnv = (shell: Shell) => { } 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/isterm/input.ts b/src/ui/input.ts similarity index 100% rename from src/isterm/input.ts rename to src/ui/input.ts diff --git a/src/ui/suggestionManager.ts b/src/ui/suggestionManager.ts new file mode 100644 index 0000000..176529c --- /dev/null +++ b/src/ui/suggestionManager.ts @@ -0,0 +1,137 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Suggestion, SuggestionBlob } from "../runtime/model.js"; +import { getSuggestions } from "../runtime/runtime.js"; +import { ISTerm } from "../isterm/pty.js"; +import { renderBox, truncateText } from "./utils.js"; +import ansi from "ansi-escapes"; +import chalk from "chalk"; +import { parseKeystroke } from "../utils/ansi.js"; + +const maxSuggestions = 5; +const suggestionWidth = 40; +const descriptionWidth = 30; +const borderWidth = 2; +const activeSuggestionBackgroundColor = "#7D56F4"; +export const MAX_LINES = borderWidth + maxSuggestions; +type SuggestionsSequence = { + data: string; + columns: number; +}; + +export class SuggestionManager { + #term: ISTerm; + #command: string; + #activeSuggestionIdx: number; + #suggestBlob?: SuggestionBlob; + + constructor(terminal: ISTerm) { + this.#term = terminal; + this.#suggestBlob = { suggestions: [] }; + this.#command = ""; + this.#activeSuggestionIdx = 0; + } + + private async _loadSuggestions(): Promise { + const commandText = this.#term.getCommandState().commandText; + if (!commandText) { + this.#suggestBlob = undefined; + return; + } + if (commandText == this.#command) { + return; + } + this.#command = commandText; + const suggestionBlob = await getSuggestions(commandText); + this.#suggestBlob = suggestionBlob; + } + + // if I want a 30 box, this means that + + // normalBorder = Border{ + // Top: "─", + // Bottom: "─", + // Left: "│", + // Right: "│", + // TopLeft: "┌", + // TopRight: "┐", + // BottomLeft: "└", + // BottomRight: "┘", + // MiddleLeft: "├", + // MiddleRight: "┤", + // Middle: "┼", + // MiddleTop: "┬", + // MiddleBottom: "┴", + // } + + private _renderSuggestions(suggestions: Suggestion[], activeSuggestionIdx: number, x: number) { + return renderBox( + suggestions.map((suggestion, idx) => { + const suggestionText = `${suggestion.icon} ${suggestion.name}`.padEnd(suggestionWidth - borderWidth, " "); + const truncatedSuggestion = truncateText(suggestionText, suggestionWidth - 2); + return idx == activeSuggestionIdx ? chalk.bgHex(activeSuggestionBackgroundColor)(truncatedSuggestion) : truncatedSuggestion; + }), + suggestionWidth, + x, + ); + } + + async render(): Promise { + await this._loadSuggestions(); + if (!this.#suggestBlob) return { data: "", columns: 0 }; + const { suggestions } = this.#suggestBlob; + + const page = Math.min(Math.floor(this.#activeSuggestionIdx / maxSuggestions) + 1, Math.floor(suggestions.length / maxSuggestions) + 1); + const pagedSuggestions = suggestions.filter((_, idx) => idx < page * maxSuggestions && idx >= (page - 1) * maxSuggestions); + const activePagedSuggestionIndex = this.#activeSuggestionIdx % maxSuggestions; + // const activeDescription = pagedSuggestions.at(activePagedSuggestionIndex)?.description || ""; + const activeDescription = ""; + + const wrappedPadding = this.#term.getCursorState().cursorX % this.#term.cols; + const maxPadding = activeDescription.length !== 0 ? this.#term.cols - suggestionWidth - descriptionWidth : this.#term.cols - suggestionWidth; + const swapDescription = wrappedPadding > maxPadding; + const swappedPadding = swapDescription ? Math.max(wrappedPadding - descriptionWidth, 0) : wrappedPadding; + const clampedLeftPadding = Math.min(Math.min(wrappedPadding, swappedPadding), maxPadding); + + if (suggestions.length <= this.#activeSuggestionIdx) { + this.#activeSuggestionIdx = Math.max(suggestions.length - 1, 0); + } + + if (pagedSuggestions.length == 0) { + return { data: "", columns: 0 }; + } + + const columnsUsed = pagedSuggestions.length + borderWidth; + return { + data: + ansi.cursorHide + + ansi.cursorUp(columnsUsed - 1) + + ansi.cursorForward(clampedLeftPadding) + + this._renderSuggestions(pagedSuggestions, activePagedSuggestionIndex, clampedLeftPadding) + + ansi.cursorShow, + columns: columnsUsed, + }; + } + + update(input: Buffer): "handled" | "fully-handled" | false { + const keyStroke = parseKeystroke(input); + if (keyStroke == null) return false; + if (keyStroke == "up") { + this.#activeSuggestionIdx = Math.max(0, this.#activeSuggestionIdx - 1); + } else if (keyStroke == "down") { + this.#activeSuggestionIdx = Math.min(this.#activeSuggestionIdx + 1, (this.#suggestBlob?.suggestions.length ?? 1) - 1); + } else if (keyStroke == "tab") { + const removals = "\u007F".repeat(this.#suggestBlob?.charactersToDrop ?? 0); + const chars = this.#suggestBlob?.suggestions.at(this.#activeSuggestionIdx)?.name + " "; + if (this.#suggestBlob == null || !chars.trim()) { + return false; + } + this.#term.write(removals + chars); + } else if (keyStroke == "ctrl-space") { + this.#term.write("\t"); + return "fully-handled"; + } + return "handled"; + } +} diff --git a/src/ui/ui-root.ts b/src/ui/ui-root.ts new file mode 100644 index 0000000..9224778 --- /dev/null +++ b/src/ui/ui-root.ts @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { inputModifier } from "./input.js"; +import log from "../utils/log.js"; +import { Shell } from "../utils/bindings.js"; +import isterm from "../isterm/index.js"; +import { eraseLinesBelow, scrollDown } from "../utils/ansi.js"; +import ansi from "ansi-escapes"; +import { SuggestionManager, MAX_LINES } from "./suggestionManager.js"; + +export const render = async (shell: Shell) => { + const term = isterm.spawn({ shell, rows: process.stdout.rows, cols: process.stdout.columns }); + const suggestionManager = new SuggestionManager(term); + let hasActiveSuggestions = false; + let previousSuggestionsColumns = 0; + let addedLines = 0; + process.stdin.setRawMode(true); + + const writeOutput = (data: string) => { + log.debug({ msg: "writing data", data }); + process.stdout.write(data); + }; + + writeOutput(ansi.clearTerminal); + + term.onData((data) => { + if (term.getCursorState().onLastLine) { + // eslint-disable-next-line no-control-regex + for (const match of data.matchAll(/\x1b\[([0-9]+);([0-9]+)H/g)) { + const [cupSequence, , cursorX] = match; + data = data.replaceAll(cupSequence, ansi.cursorTo(parseInt(cursorX) - 1, term.rows - 1 - addedLines)); + } + } + + const commandState = term.getCommandState(); + if ((commandState.hasOutput || hasActiveSuggestions) && !commandState.persistentOutput) { + writeOutput(ansi.cursorHide + ansi.cursorSavePosition + eraseLinesBelow(MAX_LINES) + ansi.cursorRestorePosition + ansi.cursorShow + data); + } else { + writeOutput(data); + } + + setImmediate(async () => { + const suggestion = await suggestionManager.render(); + addedLines = suggestion.columns; + const commandState = term.getCommandState(); + + if (suggestion.data != "" && commandState.cursorTerminated && !commandState.hasOutput) { + if (hasActiveSuggestions) { + const offset = MAX_LINES - suggestion.columns; + writeOutput( + ansi.cursorHide + + ansi.cursorSavePosition + + eraseLinesBelow(MAX_LINES) + + (offset > 0 ? ansi.cursorUp(offset) : "") + + suggestion.data + + ansi.cursorRestorePosition + + ansi.cursorShow, + ); + } else { + if (term.getCursorState().onLastLine) { + writeOutput( + ansi.cursorHide + + ansi.cursorSavePosition + + "\n".repeat(suggestion.columns) + + suggestion.data + + ansi.cursorRestorePosition + + ansi.cursorUp(suggestion.columns) + + ansi.cursorShow, + ); + } else { + writeOutput( + ansi.cursorHide + ansi.cursorSavePosition + "\n".repeat(suggestion.columns) + suggestion.data + ansi.cursorRestorePosition + ansi.cursorShow, + ); + } + } + hasActiveSuggestions = true; + } else { + if (hasActiveSuggestions) { + if (term.getCursorState().onLastLine) { + writeOutput(scrollDown(previousSuggestionsColumns) + ansi.cursorDown(previousSuggestionsColumns)); + } else { + writeOutput(ansi.cursorHide + ansi.cursorSavePosition + eraseLinesBelow(MAX_LINES) + ansi.cursorRestorePosition + ansi.cursorShow); + } + } + hasActiveSuggestions = false; + } + previousSuggestionsColumns = suggestion.columns; + }); + }); + process.stdin.on("data", (d: Buffer) => { + const suggestionResult = suggestionManager.update(d); + if (previousSuggestionsColumns > 0 && suggestionResult == "handled") { + term.write("\u001B[m"); + } else if (!suggestionResult) { + term.write(inputModifier(d)); + } + }); + + term.onExit(({ exitCode }) => { + process.exit(exitCode); + }); + process.stdout.on("resize", () => { + term.resize(process.stdout.columns, process.stdout.rows); + }); +}; diff --git a/src/ui/ui-root.tsx b/src/ui/ui-root.tsx deleted file mode 100644 index ab9d4f7..0000000 --- a/src/ui/ui-root.tsx +++ /dev/null @@ -1,93 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import React, { useCallback, useEffect, useState } from "react"; -import { Text, Box, render as inkRender, measureElement, DOMElement, useInput, useApp } from "ink"; -import wrapAnsi from "wrap-ansi"; - -import { getSuggestions } from "../runtime/runtime.js"; -import { Suggestion } from "../runtime/model.js"; -import Suggestions from "./suggestions.js"; -import Input from "./input.js"; - -const Prompt = "> "; -let uiResult = undefined; - -function UI({ startingCommand }: { startingCommand: string }) { - const { exit } = useApp(); - const [isExiting, setIsExiting] = useState(false); - const [command, setCommand] = useState(startingCommand); - const [activeSuggestion, setActiveSuggestion] = useState(); - const [tabCompletionDropSize, setTabCompletionDropSize] = useState(0); - const [suggestions, setSuggestions] = useState([]); - const [windowWidth, setWindowWidth] = useState(500); - const leftPadding = getLeftPadding(windowWidth, command); - - const measureRef = useCallback((node: DOMElement) => { - if (node !== null) { - const { width } = measureElement(node); - setWindowWidth(width); - } - }, []); - - useInput((input, key) => { - if (key.ctrl && input.toLowerCase() == "d") { - uiResult = undefined; - exit(); - } - if (key.return) { - setIsExiting(true); - } - }); - - useEffect(() => { - if (isExiting) { - uiResult = command; - exit(); - } - }, [isExiting]); - - useEffect(() => { - getSuggestions(command).then((suggestions) => { - setSuggestions(suggestions?.suggestions ?? []); - setTabCompletionDropSize(suggestions?.charactersToDrop ?? 0); - }); - }, [command]); - - if (isExiting) { - return ( - - {Prompt} - {command} - - ); - } - - return ( - - - - - - - - - ); -} - -export const render = async (command: string | undefined): Promise => { - uiResult = undefined; - const { waitUntilExit } = inkRender(); - await waitUntilExit(); - - return uiResult; -}; - -function getLeftPadding(windowWidth: number, command: string) { - const wrappedText = wrapAnsi(command + "", windowWidth, { - trim: false, - hard: true, - }); - const lines = wrappedText.split("\n"); - return (lines.length - 1) * windowWidth + lines[lines.length - 1].length + Prompt.length; -} diff --git a/src/ui/utils.ts b/src/ui/utils.ts new file mode 100644 index 0000000..5daf1a2 --- /dev/null +++ b/src/ui/utils.ts @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import ansi from "ansi-escapes"; +import chalk from "chalk"; + +/** + * Renders a box around the given rows + * @param rows the text content to be included in the box, must be <= width - 2 + * @param width the max width of a row + * @param x the column to start the box at + */ +export const renderBox = (rows: string[], width: number, x: number, borderColor?: string) => { + const result = []; + const setColor = (text: string) => (borderColor ? chalk.hex(borderColor).apply(text) : text); + result.push(setColor("┌" + "─".repeat(width - 2) + "┐") + ansi.cursorTo(x)); + rows.forEach((row) => { + result.push(ansi.cursorDown() + setColor("│") + row + setColor("│") + ansi.cursorTo(x)); + }); + result.push(ansi.cursorDown() + setColor("└" + "─".repeat(width - 2) + "┘") + ansi.cursorTo(x)); + return result.join(""); +}; + +/** + * Truncates the text to the given width + */ +export const truncateText = (text: string, width: number) => { + const textPoints = [...text]; + const slicedText = textPoints.slice(0, width - 1); + return slicedText.length == textPoints.length ? text : slicedText.join("") + "…"; +}; diff --git a/src/utils/ansi.ts b/src/utils/ansi.ts index e07f4a5..b920e7f 100644 --- a/src/utils/ansi.ts +++ b/src/utils/ansi.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -const ESC = "\u001B["; +const CSI = "\u001B["; const OSC = "\u001B]"; const BEL = "\u0007"; @@ -15,6 +15,40 @@ export enum IstermOscPt { 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"; +export const cursorHide = CSI + "?25l"; +export const cursorShow = CSI + "?25h"; +export const cursorNextLine = CSI + "E"; +export const eraseLine = CSI + "2K"; +export const cursorBackward = (count = 1) => CSI + count + "D"; +export const cursorTo = ({ x, y }: { x?: number; y?: number }) => { + return CSI + (y ?? "") + ";" + (x ?? "") + "H"; +}; +export const deleteLinesBelow = (count = 1) => { + return [...Array(count).keys()].map(() => CSI + "B" + CSI + "M").join(""); +}; +export const deleteLine = (count = 1) => CSI + count + "M"; +export const scrollUp = (count = 1) => CSI + count + "S"; +export const scrollDown = (count = 1) => CSI + count + "T"; +export const eraseLinesBelow = (count = 1) => { + return [...Array(count).keys()].map(() => cursorNextLine + eraseLine).join(""); +}; + +export const parseKeystroke = (b: Buffer): "up" | "down" | "tab" | "ctrl-space" | undefined => { + let s: string; + if (b[0] > 127 && b[1] === undefined) { + b[0] -= 128; + s = "\u001B" + String(b); + } else { + s = String(b); + } + + if (s == CSI + "A") { + return "up"; + } else if (s == CSI + "B") { + return "down"; + } else if (s == "\t") { + return "tab"; + } else if (s == "\u0000") { + return "ctrl-space"; + } +};