diff --git a/src/isterm/pty.ts b/src/isterm/pty.ts index c57d36c..b62058d 100644 --- a/src/isterm/pty.ts +++ b/src/isterm/pty.ts @@ -7,7 +7,6 @@ import os from "node:os"; import path from "node:path"; import url from "node:url"; import fs from "node:fs"; -import stripAnsi from "strip-ansi"; import { Unicode11Addon } from "@xterm/addon-unicode11"; import pty, { IPty, IEvent } from "@homebridge/node-pty-prebuilt-multiarch"; @@ -35,6 +34,12 @@ type ISTermOptions = { login: boolean; }; +export type ISTermPatch = { + startX: number; + length: number; + data: string; +}; + export class ISTerm implements IPty { readonly pid: number; cols: number; @@ -45,6 +50,8 @@ export class ISTerm implements IPty { readonly onExit: IEvent<{ exitCode: number; signal?: number }>; shellBuffer?: string; cwd: string = ""; + cursorHidden: boolean = false; + cursorShift: number = 0; readonly #pty: IPty; readonly #ptyEmitter: EventEmitter; @@ -78,7 +85,20 @@ export class ISTerm implements IPty { this.#ptyEmitter = new EventEmitter(); this.#pty.onData((data) => { + const cursorY = this.#term.buffer.active.cursorY; this.#term.write(data, () => { + if (data.includes(ansi.cursorHide)) { + this.cursorHidden = true; + } + if (data.includes(ansi.cursorShow)) { + this.cursorHidden = false; + } + const newCursorY = this.#term.buffer.active.cursorY; + if (newCursorY > cursorY) { + this.cursorShift = newCursorY - cursorY; + } else { + this.cursorShift = 0; + } log.debug({ msg: "parsing data", data, bytes: Uint8Array.from([...data].map((c) => c.charCodeAt(0))) }); this.#commandManager.termSync(); this.#ptyEmitter.emit(ISTermOnDataEvent, data); @@ -119,12 +139,6 @@ export class ISTerm implements IPty { return cwd; } - private _sanitizedPrompt(prompt: string): string { - // eslint-disable-next-line no-control-regex -- strip OSC control sequences - const oscStrippedPrompt = prompt.replace(/\x1b\][0-9]+;.*\x07/g, ""); - return stripAnsi(oscStrippedPrompt); - } - private _handleIsSequence(data: string): boolean { const argsIndex = data.indexOf(";"); const sequence = argsIndex === -1 ? data : data.substring(0, argsIndex); @@ -191,6 +205,8 @@ export class ISTerm implements IPty { remainingLines: Math.max(this.#term.rows - 2 - this.#term.buffer.active.cursorY, 0), cursorX: this.#term.buffer.active.cursorX, cursorY: this.#term.buffer.active.cursorY, + hidden: this.cursorHidden, + shift: this.cursorShift, }; } @@ -267,14 +283,27 @@ export class ISTerm implements IPty { this.#commandManager.clearActiveCommand(); } - getCells(height: number, direction: "below" | "above") { + getPatch(height: number, patches: ISTermPatch[], direction: "below" | "above"): string { const currentCursorPosition = this.#term.buffer.active.cursorY + this.#term.buffer.active.baseY; - const writeLine = (y: number) => { + const writeLine = (y: number, patch?: ISTermPatch): string => { const line = this.#term.buffer.active.getLine(y); - const ansiLine = [ansi.resetColor, ansi.resetLine]; + const hasPatch = patch != null; + const ansiPrePatch = [ansi.resetColor, ansi.resetLine]; + const ansiPostPatch = hasPatch ? [ansi.resetColor] : []; if (line == null) return ""; + let prevCell: ICellData | undefined; + let ansiLine = ansiPrePatch; + + const patchStartX = patch?.startX ?? 0; + const patchEndX = (patch?.startX ?? 0) + (patch?.length ?? 0); for (let x = 0; x < line.length; x++) { + if (hasPatch && x >= patchStartX && x < patchEndX) { + prevCell = undefined; + ansiLine = ansiPostPatch; + continue; + } + const cell = line.getCell(x) as unknown as ICellData | undefined; const chars = cell?.getChars() ?? ""; @@ -294,24 +323,31 @@ export class ISTerm implements IPty { ansiLine.push(chars == "" ? cursorForward : chars); prevCell = cell; } - return ansiLine.join(""); + return [ansiPrePatch.join(""), patch?.data ?? "", ansiPostPatch.join("")].join(""); }; - const lines = []; + const lines: string[] = []; if (direction == "above") { const startCursorPosition = currentCursorPosition - 1; const endCursorPosition = currentCursorPosition - 1 - height; + let patchIdx = patches.length - 1; for (let y = startCursorPosition; y > endCursorPosition; y--) { - lines.push(writeLine(y)); + const patch = patches[patchIdx]; + lines.push(writeLine(y, patch)); + patchIdx--; } } else { const startCursorPosition = currentCursorPosition + 1; const endCursorPosition = currentCursorPosition + 1 + height; + let patchIdx = 0; for (let y = startCursorPosition; y < endCursorPosition; y++) { - lines.push(writeLine(y)); + const patch = patches[patchIdx]; + lines.push(writeLine(y, patch)); + patchIdx++; } } - return lines.reverse().join(ansi.cursorNextLine); + + return (direction == "above" ? lines.reverse() : lines).join(ansi.cursorNextLine); } } diff --git a/src/ui/suggestionManager.ts b/src/ui/suggestionManager.ts index 4ac7ec2..aba7a17 100644 --- a/src/ui/suggestionManager.ts +++ b/src/ui/suggestionManager.ts @@ -3,9 +3,8 @@ import { Suggestion, SuggestionBlob } from "../runtime/model.js"; import { getSuggestions } from "../runtime/runtime.js"; -import { ISTerm } from "../isterm/pty.js"; +import { ISTerm, ISTermPatch } from "../isterm/pty.js"; import { renderBox, truncateText, truncateMultilineText } from "./utils.js"; -import ansi from "ansi-escapes"; import chalk from "chalk"; import { Shell } from "../utils/shell.js"; import log from "../utils/log.js"; @@ -17,11 +16,8 @@ const descriptionWidth = 30; const descriptionHeight = 5; const borderWidth = 2; const activeSuggestionBackgroundColor = "#7D56F4"; -export const MAX_LINES = borderWidth + Math.max(maxSuggestions, descriptionHeight); -type SuggestionsSequence = { - data: string; - rows: number; -}; +export const MAX_LINES = borderWidth + Math.max(maxSuggestions, descriptionHeight) + 1; // accounts when there is a unhandled newline at the end of the command +export const MIN_WIDTH = borderWidth + descriptionWidth; export type KeyPressEvent = [string | null | undefined, KeyPress]; @@ -67,22 +63,17 @@ export class SuggestionManager { this.#activeSuggestionIdx = 0; } - private _renderArgumentDescription(description: string | undefined, x: number) { - if (!description) return ""; - return renderBox([truncateText(description, descriptionWidth - borderWidth)], descriptionWidth, x); + private _renderArgumentDescription(description: string | undefined) { + if (!description) return []; + return renderBox([truncateText(description, descriptionWidth - borderWidth)], descriptionWidth); } - private _renderDescription(description: string | undefined, x: number) { + private _renderDescription(description: string | undefined) { if (!description) return ""; - return renderBox(truncateMultilineText(description, descriptionWidth - borderWidth, descriptionHeight), descriptionWidth, x); + return renderBox(truncateMultilineText(description, descriptionWidth - borderWidth, descriptionHeight), descriptionWidth); } - private _descriptionRows(description: string | undefined) { - if (!description) return 0; - return truncateMultilineText(description, descriptionWidth - borderWidth, descriptionHeight).length; - } - - private _renderSuggestions(suggestions: Suggestion[], activeSuggestionIdx: number, x: number) { + private _renderSuggestions(suggestions: Suggestion[], activeSuggestionIdx: number) { return renderBox( suggestions.map((suggestion, idx) => { const suggestionText = `${suggestion.icon} ${suggestion.name}`; @@ -90,19 +81,32 @@ export class SuggestionManager { return idx == activeSuggestionIdx ? chalk.bgHex(activeSuggestionBackgroundColor)(truncatedSuggestion) : truncatedSuggestion; }), suggestionWidth, - x, ); } - validate(suggestion: SuggestionsSequence): SuggestionsSequence { - const commandText = this.#term.getCommandState().commandText; - return !commandText ? { data: "", rows: 0 } : suggestion; + private _calculatePadding(description: string): { padding: number; swapDescription: boolean } { + const wrappedPadding = this.#term.getCursorState().cursorX % this.#term.cols; + const maxPadding = description.length !== 0 ? this.#term.cols - suggestionWidth - descriptionWidth : this.#term.cols - suggestionWidth; + const swapDescription = wrappedPadding > maxPadding && description.length !== 0; + const swappedPadding = swapDescription ? Math.max(wrappedPadding - descriptionWidth, 0) : wrappedPadding; + const padding = Math.min(Math.min(wrappedPadding, swappedPadding), maxPadding); + return { padding, swapDescription }; } - async render(remainingLines: number): Promise { - await this._loadSuggestions(); + private _calculateRowPadding(padding: number, swapDescription: boolean, suggestionContent?: string, descriptionContent?: string): number { + if (swapDescription) { + return descriptionContent == null ? padding + descriptionWidth : padding; + } + return suggestionContent == null ? padding + suggestionWidth : padding; + } + + async exec(): Promise { + return await this._loadSuggestions(); + } + + render(direction: "above" | "below"): ISTermPatch[] { if (!this.#suggestBlob) { - return { data: "", rows: 0 }; + return []; } const { suggestions, argumentDescription } = this.#suggestBlob; @@ -110,12 +114,7 @@ export class SuggestionManager { const pagedSuggestions = suggestions.filter((_, idx) => idx < page * maxSuggestions && idx >= (page - 1) * maxSuggestions); const activePagedSuggestionIndex = this.#activeSuggestionIdx % maxSuggestions; const activeDescription = pagedSuggestions.at(activePagedSuggestionIndex)?.description || argumentDescription || ""; - - 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 && activeDescription.length !== 0; - const swappedPadding = swapDescription ? Math.max(wrappedPadding - descriptionWidth, 0) : wrappedPadding; - const clampedLeftPadding = Math.min(Math.min(wrappedPadding, swappedPadding), maxPadding); + const { swapDescription, padding } = this._calculatePadding(activeDescription); if (suggestions.length <= this.#activeSuggestionIdx) { this.#activeSuggestionIdx = Math.max(suggestions.length - 1, 0); @@ -123,44 +122,30 @@ export class SuggestionManager { if (pagedSuggestions.length == 0) { if (argumentDescription != null) { - return { - data: - ansi.cursorHide + - ansi.cursorUp(2) + - ansi.cursorForward(clampedLeftPadding) + - this._renderArgumentDescription(argumentDescription, clampedLeftPadding), - rows: 3, - }; + return this._renderArgumentDescription(argumentDescription).map((row) => ({ startX: padding, length: descriptionWidth, data: row })); } - return { data: "", rows: 0 }; + return []; } - - const suggestionRowsUsed = pagedSuggestions.length + borderWidth; - let descriptionRowsUsed = this._descriptionRows(activeDescription) + borderWidth; - let rows = Math.max(descriptionRowsUsed, suggestionRowsUsed); - if (rows <= remainingLines) { - descriptionRowsUsed = suggestionRowsUsed; - rows = suggestionRowsUsed; + const descriptionUI = this._renderDescription(activeDescription); + const suggestionUI = this._renderSuggestions(pagedSuggestions, activePagedSuggestionIndex); + const ui = []; + const maxRows = Math.max(descriptionUI.length, suggestionUI.length); + for (let i = 0; i < maxRows; i++) { + const [suggestionUIRow, descriptionUIRow] = + direction == "above" + ? [suggestionUI[i - maxRows + suggestionUI.length], descriptionUI[i - maxRows + descriptionUI.length]] + : [suggestionUI[i], descriptionUI[i]]; + + const data = swapDescription ? (descriptionUIRow ?? "") + (suggestionUIRow ?? "") : (suggestionUIRow ?? "") + (descriptionUIRow ?? ""); + const rowPadding = this._calculateRowPadding(padding, swapDescription, suggestionUIRow, descriptionUIRow); + + ui.push({ + startX: rowPadding, + length: (suggestionUIRow == null ? 0 : suggestionWidth) + (descriptionUIRow == null ? 0 : descriptionWidth), + data: data, + }); } - - const descriptionUI = - ansi.cursorUp(descriptionRowsUsed - 1) + - (swapDescription - ? this._renderDescription(activeDescription, clampedLeftPadding) - : this._renderDescription(activeDescription, clampedLeftPadding + suggestionWidth)) + - ansi.cursorDown(descriptionRowsUsed - 1); - const suggestionUI = - ansi.cursorUp(suggestionRowsUsed - 1) + - (swapDescription - ? this._renderSuggestions(pagedSuggestions, activePagedSuggestionIndex, clampedLeftPadding + descriptionWidth) - : this._renderSuggestions(pagedSuggestions, activePagedSuggestionIndex, clampedLeftPadding)) + - ansi.cursorDown(suggestionRowsUsed - 1); - - const ui = swapDescription ? descriptionUI + suggestionUI : suggestionUI + descriptionUI; - return { - data: ansi.cursorHide + ansi.cursorForward(clampedLeftPadding) + ui + ansi.cursorShow, - rows, - }; + return ui; } update(keyPress: KeyPress): boolean { diff --git a/src/ui/ui-root.ts b/src/ui/ui-root.ts index e7f7d97..9796279 100644 --- a/src/ui/ui-root.ts +++ b/src/ui/ui-root.ts @@ -9,19 +9,48 @@ import { Command } from "commander"; import log from "../utils/log.js"; import { getBackspaceSequence, Shell } from "../utils/shell.js"; import isterm from "../isterm/index.js"; -import { eraseLinesBelow, resetToInitialState } from "../utils/ansi.js"; +import { resetToInitialState } from "../utils/ansi.js"; import { SuggestionManager, MAX_LINES, KeyPressEvent } from "./suggestionManager.js"; +import { ISTerm } from "../isterm/pty.js"; export const renderConfirmation = (live: boolean): string => { const statusMessage = live ? chalk.green("live") : chalk.red("not found"); return `inshellisense session [${statusMessage}]\n`; }; +const writeOutput = (data: string) => { + log.debug({ msg: "writing data", data }); + process.stdout.write(data); +}; + +const _render = (term: ISTerm, suggestionManager: SuggestionManager, data: string, handlingBackspace: boolean): boolean => { + const direction = term.getCursorState().remainingLines > MAX_LINES ? "below" : "above"; + const { hidden: cursorHidden, shift: cursorShift } = term.getCursorState(); + const linesOfInterest = MAX_LINES; + + const suggestion = suggestionManager.render(direction); + const hasSuggestion = suggestion.length != 0; + const commandState = term.getCommandState(); + const cursorTerminated = handlingBackspace ? true : commandState.cursorTerminated ?? false; + const showSuggestions = hasSuggestion && cursorTerminated && !commandState.hasOutput && !cursorShift; + const patch = term.getPatch(linesOfInterest, showSuggestions ? suggestion : [], direction); + + const ansiCursorShow = cursorHidden ? "" : ansi.cursorShow; + if (direction == "above") { + writeOutput( + data + ansi.cursorHide + ansi.cursorSavePosition + ansi.cursorPrevLine.repeat(linesOfInterest) + patch + ansi.cursorRestorePosition + ansiCursorShow, + ); + } else { + writeOutput(ansi.cursorHide + ansi.cursorSavePosition + ansi.cursorNextLine + patch + ansi.cursorRestorePosition + ansiCursorShow + data); + } + return showSuggestions; +}; + export const render = async (program: Command, shell: Shell, underTest: boolean, login: boolean) => { const term = await isterm.spawn(program, { shell, rows: process.stdout.rows, cols: process.stdout.columns, underTest, login }); const suggestionManager = new SuggestionManager(term, shell); - let hasActiveSuggestions = false; - let previousSuggestionsRows = 0; + let hasSuggestion = false; + let handlingBackspace = false; // backspace normally consistent of two data points (move back & delete), so on the first data point, we won't enforce the cursor terminated rule. this will help reduce flicker const stdinStartedInRawMode = process.stdin.isRaw; if (process.stdin.isTTY) process.stdin.setRawMode(true); readline.emitKeypressEvents(process.stdin); @@ -33,107 +62,22 @@ export const render = async (program: Command, shell: Shell, underTest: boolean, writeOutput(ansi.clearTerminal); - term.onData((data) => { - if (hasActiveSuggestions) { - // Considers when data includes newlines which have shifted the cursor position downwards - const newlines = Math.max((data.match(/\r/g) || []).length, (data.match(/\n/g) || []).length); - const linesOfInterest = MAX_LINES + newlines; - if (term.getCursorState().remainingLines <= MAX_LINES) { - // handles when suggestions get loaded before shell output so you need to always clear below output as a precaution - if (term.getCursorState().remainingLines != 0) { - writeOutput(ansi.cursorHide + ansi.cursorSavePosition + eraseLinesBelow(linesOfInterest + 1) + ansi.cursorRestorePosition); - } - writeOutput( - data + - ansi.cursorHide + - ansi.cursorSavePosition + - ansi.cursorPrevLine.repeat(linesOfInterest) + - term.getCells(linesOfInterest, "above") + - ansi.cursorRestorePosition + - ansi.cursorShow, - ); - } else { - writeOutput(ansi.cursorHide + ansi.cursorSavePosition + eraseLinesBelow(linesOfInterest + 1) + ansi.cursorRestorePosition + ansi.cursorShow + data); - } - } else { - writeOutput(data); - } + term.onData(async (data) => { + hasSuggestion = _render(term, suggestionManager, data, handlingBackspace); + await suggestionManager.exec(); + hasSuggestion = _render(term, suggestionManager, "", handlingBackspace); - process.nextTick(async () => { - // validate result to prevent stale suggestion being provided - const suggestion = suggestionManager.validate(await suggestionManager.render(term.getCursorState().remainingLines)); - const commandState = term.getCommandState(); - - if (suggestion.data != "" && commandState.cursorTerminated && !commandState.hasOutput) { - if (hasActiveSuggestions) { - if (term.getCursorState().remainingLines < MAX_LINES) { - writeOutput( - ansi.cursorHide + - ansi.cursorSavePosition + - ansi.cursorPrevLine.repeat(MAX_LINES) + - term.getCells(MAX_LINES, "above") + - ansi.cursorRestorePosition + - ansi.cursorSavePosition + - ansi.cursorUp() + - suggestion.data + - ansi.cursorRestorePosition + - ansi.cursorShow, - ); - } else { - const offset = MAX_LINES - suggestion.rows; - writeOutput( - ansi.cursorHide + - ansi.cursorSavePosition + - eraseLinesBelow(MAX_LINES) + - (offset > 0 ? ansi.cursorUp(offset) : "") + - suggestion.data + - ansi.cursorRestorePosition + - ansi.cursorShow, - ); - } - } else { - if (term.getCursorState().remainingLines < MAX_LINES) { - writeOutput(ansi.cursorHide + ansi.cursorSavePosition + ansi.cursorUp() + suggestion.data + ansi.cursorRestorePosition + ansi.cursorShow); - } else { - writeOutput( - ansi.cursorHide + - ansi.cursorSavePosition + - ansi.cursorNextLine.repeat(suggestion.rows) + - suggestion.data + - ansi.cursorRestorePosition + - ansi.cursorShow, - ); - } - } - hasActiveSuggestions = true; - } else { - if (hasActiveSuggestions) { - if (term.getCursorState().remainingLines <= MAX_LINES) { - writeOutput( - ansi.cursorHide + - ansi.cursorSavePosition + - ansi.cursorPrevLine.repeat(MAX_LINES) + - term.getCells(MAX_LINES, "above") + - ansi.cursorRestorePosition + - ansi.cursorShow, - ); - } else { - writeOutput(ansi.cursorHide + ansi.cursorSavePosition + eraseLinesBelow(MAX_LINES) + ansi.cursorRestorePosition + ansi.cursorShow); - } - } - hasActiveSuggestions = false; - } - previousSuggestionsRows = suggestion.rows; - }); + handlingBackspace = false; }); process.stdin.on("keypress", (...keyPress: KeyPressEvent) => { const press = keyPress[1]; const inputHandled = suggestionManager.update(press); - if (previousSuggestionsRows > 0 && inputHandled) { + if (hasSuggestion && inputHandled) { term.noop(); } else if (!inputHandled) { if (press.name == "backspace") { + handlingBackspace = true; term.write(getBackspaceSequence(keyPress, shell)); } else { term.write(press.sequence); diff --git a/src/ui/utils.ts b/src/ui/utils.ts index 39eb922..1f6bbc0 100644 --- a/src/ui/utils.ts +++ b/src/ui/utils.ts @@ -1,27 +1,20 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import ansi from "ansi-escapes"; import { resetColor } from "../utils/ansi.js"; import wrapAnsi from "wrap-ansi"; import chalk from "chalk"; import wcwidth from "wcwidth"; -/** - * 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) => { +export const renderBox = (rows: string[], width: number, borderColor?: string): string[] => { const result = []; const setColor = (text: string) => resetColor + (borderColor ? chalk.hex(borderColor).apply(text) : text); - result.push(ansi.cursorTo(x) + setColor("┌" + "─".repeat(width - 2) + "┐") + ansi.cursorTo(x)); + result.push(setColor("┌" + "─".repeat(width - 2) + "┐")); rows.forEach((row) => { - result.push(ansi.cursorDown() + setColor("│") + row + setColor("│") + ansi.cursorTo(x)); + result.push(setColor("│") + row + setColor("│")); }); - result.push(ansi.cursorDown() + setColor("└" + "─".repeat(width - 2) + "┘") + ansi.cursorTo(x)); - return result.join("") + ansi.cursorUp(rows.length + 1); + result.push(setColor("└" + "─".repeat(width - 2) + "┘")); + return result; }; export const truncateMultilineText = (description: string, width: number, maxHeight: number) => { diff --git a/src/utils/config.ts b/src/utils/config.ts index 3a1b014..c066552 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -117,7 +117,7 @@ export const loadConfig = async (program: Command) => { }; } }); - globalConfig.specs = { path: [`${os.homedir()}/.fig/autocomplete/build`, ...(globalConfig.specs?.path ?? [])] }; + globalConfig.specs = { path: [path.join(os.homedir(), ".fig", "autocomplete", "build"), ...(globalConfig.specs?.path ?? [])].map((p) => `file:\\${p}`) }; }; export const deleteCacheFolder = (): void => {