Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 51 additions & 15 deletions src/isterm/pty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
};
}

Expand Down Expand Up @@ -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() ?? "";

Expand All @@ -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);
}
}

Expand Down
117 changes: 51 additions & 66 deletions src/ui/suggestionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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];

Expand Down Expand Up @@ -67,100 +63,89 @@ 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}`;
const truncatedSuggestion = truncateText(suggestionText, suggestionWidth - 2);
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<SuggestionsSequence> {
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<void> {
return await this._loadSuggestions();
}

render(direction: "above" | "below"): ISTermPatch[] {
if (!this.#suggestBlob) {
return { data: "", rows: 0 };
return [];
}
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 || 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);
}

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 {
Expand Down
Loading
Loading