diff --git a/package-lock.json b/package-lock.json index 5213fc1..16734b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@withfig/autocomplete": "^2.633.0", "ajv": "^8.12.0", + "ansi-styles": "^6.2.1", "chalk": "^5.3.0", "commander": "^11.0.0", "find-process": "^1.4.7", diff --git a/package.json b/package.json index d3fb363..c65b5d2 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "dependencies": { "@withfig/autocomplete": "^2.633.0", "ajv": "^8.12.0", + "ansi-styles": "^6.2.1", "chalk": "^5.3.0", "commander": "^11.0.0", "find-process": "^1.4.7", diff --git a/src/isterm/commandManager.ts b/src/isterm/commandManager.ts index dbb08ab..69050c0 100644 --- a/src/isterm/commandManager.ts +++ b/src/isterm/commandManager.ts @@ -7,6 +7,8 @@ import { Shell } from "../utils/bindings.js"; import log from "../utils/log.js"; import { getConfig } from "../utils/config.js"; +const maxPromptPollDistance = 10; + type TerminalCommand = { promptStartMarker?: IMarker; promptEndMarker?: IMarker; @@ -75,12 +77,9 @@ export class CommandManager { // User defined prompt const inshellisenseConfig = getConfig(); - log.debug({ inshellisenseConfig, t: "tomato" }); - if (this.#shell == Shell.Bash) { if (inshellisenseConfig.promptRegex?.bash != null) { const customBashPrompt = lineText.match(new RegExp(inshellisenseConfig.promptRegex?.bash.regex))?.groups?.prompt; - log.debug({ customBashPrompt }); const adjustedPrompt = this._adjustPrompt(customBashPrompt, lineText, inshellisenseConfig.promptRegex?.bash.postfix); if (adjustedPrompt) { return adjustedPrompt; @@ -184,7 +183,7 @@ export class CommandManager { // 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++) { + for (let i = globalCursorPosition; i < promptEndMarker.line + maxPromptPollDistance; i++) { if (this.#previousCommandLines.has(i)) continue; const promptResult = this._getWindowsPrompt(i); if (promptResult != null) { @@ -222,8 +221,9 @@ export class CommandManager { const cursorAtEndOfInput = (this.#activeCommand.promptText.length + command.trim().length) % this.#terminal.cols <= this.#terminal.buffer.active.cursorX; let hasOutput = false; + let cell = undefined; for (let i = 0; i < this.#terminal.cols; i++) { - const cell = line?.getCell(i); + cell = line?.getCell(i, cell); if (cell == null) continue; hasOutput = cell.getChars() != ""; if (hasOutput) { diff --git a/src/isterm/pty.ts b/src/isterm/pty.ts index 952b180..2ff71ce 100644 --- a/src/isterm/pty.ts +++ b/src/isterm/pty.ts @@ -12,6 +12,8 @@ import xterm from "xterm-headless"; import { CommandManager, CommandState } from "./commandManager.js"; import log from "../utils/log.js"; import { gitBashPath } from "../utils/shell.js"; +import ansi from "ansi-escapes"; +import styles from "ansi-styles"; const ISTermOnDataEvent = "data"; @@ -122,17 +124,87 @@ export 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, + 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, }; } + + private _sameColor(baseCell: xterm.IBufferCell | undefined, targetCell: xterm.IBufferCell | undefined) { + return ( + baseCell?.getBgColorMode() == targetCell?.getBgColorMode() && + baseCell?.getBgColor() == targetCell?.getBgColor() && + baseCell?.getFgColorMode() == targetCell?.getFgColorMode() && + baseCell?.getFgColor() == targetCell?.getFgColor() + ); + } + + private _getAnsiColors(cell: xterm.IBufferCell | undefined): string { + if (cell == null) return ""; + let bgAnsi = ""; + cell.getBgColor; + cell.getFgColor; + if (cell.isBgDefault()) { + bgAnsi = "\x1b[49m"; + } else if (cell.isBgPalette()) { + bgAnsi = `\x1b[48;5;${cell.getBgColor()}m`; + } else { + bgAnsi = `\x1b[48;5;${styles.hexToAnsi256(cell.getBgColor().toString(16))}m`; + } + + let fgAnsi = ""; + if (cell.isFgDefault()) { + fgAnsi = "\x1b[39m"; + } else if (cell.isFgPalette()) { + fgAnsi = `\x1b[38;5;${cell.getFgColor()}m`; + } else { + fgAnsi = `\x1b[38;5;${styles.hexToAnsi256(cell.getFgColor().toString(16))}m`; + } + return bgAnsi + fgAnsi; + } + + getCells(height: number, direction: "below" | "above") { + const currentCursorPosition = this.#term.buffer.active.cursorY + this.#term.buffer.active.baseY; + const writeLine = (y: number) => { + const line = this.#term.buffer.active.getLine(y); + const ansiLine = ["\x1b[0m"]; + if (line == null) return ""; + let cell = line.getCell(0); + let prevCell: xterm.IBufferCell | undefined; + for (let x = 0; x < line.length; x++) { + cell = line.getCell(x, cell); + const chars = cell?.getChars() ?? ""; + if (!this._sameColor(prevCell, cell)) { + ansiLine.push(this._getAnsiColors(cell)); + } + ansiLine.push(chars == "" ? " " : chars); + prevCell = cell; + } + return ansiLine.join(""); + }; + + const lines = []; + if (direction == "above") { + const startCursorPosition = currentCursorPosition - 1; + const endCursorPosition = currentCursorPosition - 1 - height; + for (let y = startCursorPosition; y > endCursorPosition; y--) { + lines.push(writeLine(y)); + } + } else { + const startCursorPosition = currentCursorPosition + 1; + const endCursorPosition = currentCursorPosition + 1 + height; + for (let y = startCursorPosition; y < endCursorPosition; y++) { + lines.push(writeLine(y)); + } + } + return lines.reverse().join(ansi.cursorNextLine); + } } export const spawn = async (options: ISTermOptions): Promise => { diff --git a/src/ui/suggestionManager.ts b/src/ui/suggestionManager.ts index 5505605..6cab764 100644 --- a/src/ui/suggestionManager.ts +++ b/src/ui/suggestionManager.ts @@ -8,13 +8,14 @@ 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 descriptionHeight = 5; const borderWidth = 2; const activeSuggestionBackgroundColor = "#7D56F4"; -export const MAX_LINES = borderWidth + maxSuggestions; +export const MAX_LINES = borderWidth + Math.max(maxSuggestions, descriptionHeight); type SuggestionsSequence = { data: string; columns: number; @@ -81,7 +82,7 @@ export class SuggestionManager { 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 swapDescription = wrappedPadding > maxPadding && activeDescription.length !== 0; const swappedPadding = swapDescription ? Math.max(wrappedPadding - descriptionWidth, 0) : wrappedPadding; const clampedLeftPadding = Math.min(Math.min(wrappedPadding, swappedPadding), maxPadding); diff --git a/src/ui/ui-root.ts b/src/ui/ui-root.ts index f3188e0..0b7937f 100644 --- a/src/ui/ui-root.ts +++ b/src/ui/ui-root.ts @@ -5,7 +5,7 @@ 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 { eraseLinesBelow } from "../utils/ansi.js"; import ansi from "ansi-escapes"; import { SuggestionManager, MAX_LINES } from "./suggestionManager.js"; @@ -14,7 +14,6 @@ export const render = async (shell: Shell) => { const suggestionManager = new SuggestionManager(term); let hasActiveSuggestions = false; let previousSuggestionsColumns = 0; - let addedLines = 0; process.stdin.setRawMode(true); const writeOutput = (data: string) => { @@ -25,60 +24,82 @@ export const render = async (shell: Shell) => { 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); + if (term.getCursorState().remainingLines < previousSuggestionsColumns) { + writeOutput( + ansi.cursorHide + + ansi.cursorSavePosition + + ansi.cursorPrevLine.repeat(MAX_LINES) + + term.getCells(MAX_LINES, "above") + + ansi.cursorRestorePosition + + ansi.cursorShow + + data, + ); + } else { + writeOutput(ansi.cursorHide + ansi.cursorSavePosition + eraseLinesBelow(MAX_LINES + 1) + 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) { + if (term.getCursorState().remainingLines < suggestion.columns) { writeOutput( ansi.cursorHide + ansi.cursorSavePosition + - "\n".repeat(suggestion.columns) + + ansi.cursorPrevLine.repeat(MAX_LINES) + + term.getCells(MAX_LINES, "above") + + ansi.cursorRestorePosition + + ansi.cursorSavePosition + + ansi.cursorUp() + suggestion.data + ansi.cursorRestorePosition + - ansi.cursorUp(suggestion.columns) + ansi.cursorShow, ); } else { + const offset = MAX_LINES - suggestion.columns; writeOutput( - ansi.cursorHide + ansi.cursorSavePosition + "\n".repeat(suggestion.columns) + suggestion.data + ansi.cursorRestorePosition + ansi.cursorShow, + ansi.cursorHide + + ansi.cursorSavePosition + + eraseLinesBelow(MAX_LINES) + + (offset > 0 ? ansi.cursorUp(offset) : "") + + suggestion.data + + ansi.cursorRestorePosition + + ansi.cursorShow, + ); + } + } else { + if (term.getCursorState().remainingLines < suggestion.columns) { + writeOutput(ansi.cursorHide + ansi.cursorSavePosition + ansi.cursorUp() + suggestion.data + ansi.cursorRestorePosition + ansi.cursorShow); + } else { + writeOutput( + ansi.cursorHide + + ansi.cursorSavePosition + + ansi.cursorNextLine.repeat(suggestion.columns) + + suggestion.data + + ansi.cursorRestorePosition + + ansi.cursorShow, ); } } hasActiveSuggestions = true; } else { if (hasActiveSuggestions) { - if (term.getCursorState().onLastLine) { - writeOutput(scrollDown(previousSuggestionsColumns) + ansi.cursorDown(previousSuggestionsColumns)); + if (term.getCursorState().remainingLines < previousSuggestionsColumns) { + 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); }