diff --git a/src/isterm/pty.ts b/src/isterm/pty.ts index 5136855..952b180 100644 --- a/src/isterm/pty.ts +++ b/src/isterm/pty.ts @@ -88,6 +88,10 @@ export class ISTerm implements IPty { return true; } + noop() { + this.#ptyEmitter.emit(ISTermOnDataEvent, ""); + } + resize(columns: number, rows: number) { this.cols = columns; this.rows = rows; diff --git a/src/ui/suggestionManager.ts b/src/ui/suggestionManager.ts index 176529c..5505605 100644 --- a/src/ui/suggestionManager.ts +++ b/src/ui/suggestionManager.ts @@ -4,14 +4,14 @@ 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 { renderBox, truncateText, truncateMultilineText } 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 descriptionHeight = 6; const borderWidth = 2; const activeSuggestionBackgroundColor = "#7D56F4"; export const MAX_LINES = borderWidth + maxSuggestions; @@ -47,23 +47,15 @@ export class SuggestionManager { this.#suggestBlob = suggestionBlob; } - // if I want a 30 box, this means that + private _renderArgumentDescription(description: string | undefined, x: number) { + if (!description) return ""; + return renderBox([truncateText(description, descriptionWidth - borderWidth)], descriptionWidth, x); + } - // normalBorder = Border{ - // Top: "─", - // Bottom: "─", - // Left: "│", - // Right: "│", - // TopLeft: "┌", - // TopRight: "┐", - // BottomLeft: "└", - // BottomRight: "┘", - // MiddleLeft: "├", - // MiddleRight: "┤", - // Middle: "┼", - // MiddleTop: "┬", - // MiddleBottom: "┴", - // } + private _renderDescription(description: string | undefined, x: number) { + if (!description) return ""; + return renderBox(truncateMultilineText(description, descriptionWidth - borderWidth, descriptionHeight), descriptionWidth, x); + } private _renderSuggestions(suggestions: Suggestion[], activeSuggestionIdx: number, x: number) { return renderBox( @@ -80,13 +72,12 @@ export class SuggestionManager { async render(): Promise { await this._loadSuggestions(); if (!this.#suggestBlob) return { data: "", columns: 0 }; - const { suggestions } = this.#suggestBlob; + const { suggestions, argumentDescription } = 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 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; @@ -99,17 +90,27 @@ export class SuggestionManager { } if (pagedSuggestions.length == 0) { + if (argumentDescription != null) { + return { + data: + ansi.cursorHide + + ansi.cursorUp(2) + + ansi.cursorForward(clampedLeftPadding) + + this._renderArgumentDescription(argumentDescription, clampedLeftPadding), + columns: 3, + }; + } return { data: "", columns: 0 }; } const columnsUsed = pagedSuggestions.length + borderWidth; + const ui = swapDescription + ? this._renderDescription(activeDescription, clampedLeftPadding) + + this._renderSuggestions(pagedSuggestions, activePagedSuggestionIndex, clampedLeftPadding + descriptionWidth) + : this._renderSuggestions(pagedSuggestions, activePagedSuggestionIndex, clampedLeftPadding) + + this._renderDescription(activeDescription, clampedLeftPadding + suggestionWidth); return { - data: - ansi.cursorHide + - ansi.cursorUp(columnsUsed - 1) + - ansi.cursorForward(clampedLeftPadding) + - this._renderSuggestions(pagedSuggestions, activePagedSuggestionIndex, clampedLeftPadding) + - ansi.cursorShow, + data: ansi.cursorHide + ansi.cursorUp(columnsUsed - 1) + ansi.cursorForward(clampedLeftPadding) + ui + ansi.cursorShow, columns: columnsUsed, }; } @@ -124,11 +125,11 @@ export class SuggestionManager { } 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()) { + if (this.#suggestBlob == null || !chars.trim() || this.#suggestBlob?.suggestions.length == 0) { return false; } this.#term.write(removals + chars); - } else if (keyStroke == "ctrl-space") { + } else if (keyStroke == "right-arrow") { this.#term.write("\t"); return "fully-handled"; } diff --git a/src/ui/ui-root.ts b/src/ui/ui-root.ts index 836f820..f3188e0 100644 --- a/src/ui/ui-root.ts +++ b/src/ui/ui-root.ts @@ -91,7 +91,7 @@ export const render = async (shell: Shell) => { process.stdin.on("data", (d: Buffer) => { const suggestionResult = suggestionManager.update(d); if (previousSuggestionsColumns > 0 && suggestionResult == "handled") { - term.write("\u001B[m"); + term.noop(); } else if (!suggestionResult) { term.write(inputModifier(d)); } diff --git a/src/ui/utils.ts b/src/ui/utils.ts index 5daf1a2..06164d3 100644 --- a/src/ui/utils.ts +++ b/src/ui/utils.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import ansi from "ansi-escapes"; +import wrapAnsi from "wrap-ansi"; import chalk from "chalk"; /** @@ -13,12 +14,25 @@ import chalk from "chalk"; 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)); + result.push(ansi.cursorTo(x) + 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(""); + return result.join("") + ansi.cursorUp(rows.length + 1); +}; + +export const truncateMultilineText = (description: string, width: number, maxHeight: number) => { + const wrappedText = wrapAnsi(description, width, { + trim: false, + hard: true, + }); + const lines = wrappedText.split("\n"); + const truncatedLines = lines.slice(0, maxHeight); + if (lines.length > maxHeight) { + truncatedLines[maxHeight - 1] = [...truncatedLines[maxHeight - 1]].slice(0, -1).join("") + "…"; + } + return truncatedLines.map((line) => line.padEnd(width)); }; /** @@ -27,5 +41,5 @@ export const renderBox = (rows: string[], width: number, x: number, borderColor? 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("") + "…"; + return slicedText.length == textPoints.length ? text.padEnd(width) : (slicedText.join("") + "…").padEnd(width); }; diff --git a/src/utils/ansi.ts b/src/utils/ansi.ts index b920e7f..aa4261e 100644 --- a/src/utils/ansi.ts +++ b/src/utils/ansi.ts @@ -4,6 +4,7 @@ const CSI = "\u001B["; const OSC = "\u001B]"; const BEL = "\u0007"; +const SS3 = "\u001BO"; export const IsTermOscPs = 6973; const IS_OSC = OSC + IsTermOscPs + ";"; @@ -33,7 +34,7 @@ export const eraseLinesBelow = (count = 1) => { return [...Array(count).keys()].map(() => cursorNextLine + eraseLine).join(""); }; -export const parseKeystroke = (b: Buffer): "up" | "down" | "tab" | "ctrl-space" | undefined => { +export const parseKeystroke = (b: Buffer): "up" | "down" | "tab" | "right-arrow" | undefined => { let s: string; if (b[0] > 127 && b[1] === undefined) { b[0] -= 128; @@ -42,13 +43,13 @@ export const parseKeystroke = (b: Buffer): "up" | "down" | "tab" | "ctrl-space" s = String(b); } - if (s == CSI + "A") { + if (s == CSI + "A" || s == SS3 + "A") { return "up"; - } else if (s == CSI + "B") { + } else if (s == CSI + "B" || s == SS3 + "B") { return "down"; } else if (s == "\t") { return "tab"; - } else if (s == "\u0000") { - return "ctrl-space"; + } else if (s == CSI + "D" || s == SS3 + "D" || s == CSI + "d" || s == SS3 + "d") { + return "right-arrow"; } };