feat: implement single-line passthrough ui (#90)

* feat: implement mock ui with ability to inject subtext

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

* fix: cursor movement issues

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

* fix: support dynamic output

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

* feat: initial support for displaying actual suggestions

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

* feat: enable suggestion navigation with arrow keys

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

* fix: make suggestion loading async

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

* fix: wrapping & coloring on single line completion

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

* fix: support tab passthrough

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

* fix: backspace on pwsh

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

* fix: suggestion detection for powershell

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

* fix: cli for ui

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

* ci: fix linting issues

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-24 14:06:47 -08:00
committed by GitHub
parent 930381cbf9
commit 6a39d0c718
10 changed files with 339 additions and 168 deletions
+4 -42
View File
@@ -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);
};
+1 -5
View File
@@ -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>", `shell to use for command execution, supported shells: ${supportedShells}`)
.option("-c, --command <commmand>", "command to use as initial input")
.option("--history", "get the last command execute")
.option("-d, --duration <duration>", "duration of the autocomplete session, supported durations: single, session", "session")
.action(action(program))
.showHelpAfterError("(add --help for additional information)");
+11 -3
View File
@@ -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;
}
+11 -21
View File
@@ -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);
// });
+137
View File
@@ -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<void> {
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<SuggestionsSequence> {
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";
}
}
+106
View File
@@ -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);
});
};
-93
View File
@@ -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<Suggestion>();
const [tabCompletionDropSize, setTabCompletionDropSize] = useState(0);
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
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 (
<Text>
{Prompt}
{command}
</Text>
);
}
return (
<Box flexDirection="column" ref={measureRef}>
<Box>
<Text>
<Input value={command} setValue={setCommand} prompt={Prompt} activeSuggestion={activeSuggestion} tabCompletionDropSize={tabCompletionDropSize} />
</Text>
</Box>
<Suggestions leftPadding={leftPadding} setActiveSuggestion={setActiveSuggestion} suggestions={suggestions} />
</Box>
);
}
export const render = async (command: string | undefined): Promise<string | undefined> => {
uiResult = undefined;
const { waitUntilExit } = inkRender(<UI startingCommand={command ?? ""} />);
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;
}
+31
View File
@@ -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("") + "…";
};
+38 -4
View File
@@ -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";
}
};