chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree. git-subtree-dir: third_party/pi git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
77
third_party/pi/packages/tui/src/autocomplete.ts
vendored
77
third_party/pi/packages/tui/src/autocomplete.ts
vendored
@@ -3,8 +3,12 @@ import { readdirSync, statSync } from "fs";
|
||||
import { homedir } from "os";
|
||||
import { basename, dirname, join } from "path";
|
||||
import { fuzzyFilter } from "./fuzzy.ts";
|
||||
import { autocompleteBoundaryRegex, autocompleteSeparatorRegex } from "./utils.ts";
|
||||
|
||||
const PATH_DELIMITERS = new Set([" ", "\t", '"', "'", "="]);
|
||||
const tokenStartRegex = new RegExp(`${autocompleteBoundaryRegex.source}$`, "u");
|
||||
// Opening wrappers that may precede a path in prose, mapped to their closing counterpart.
|
||||
const PATH_WRAPPERS: Record<string, string> = { "(": ")", "[": "]", "{": "}", "<": ">", "`": "`" };
|
||||
|
||||
function toDisplayPath(value: string): string {
|
||||
return value.replace(/\\/g, "/");
|
||||
@@ -43,12 +47,29 @@ function buildFdPathQuery(query: string): string {
|
||||
}
|
||||
|
||||
function findLastDelimiter(text: string): number {
|
||||
for (let i = text.length - 1; i >= 0; i -= 1) {
|
||||
if (PATH_DELIMITERS.has(text[i] ?? "")) {
|
||||
return i;
|
||||
let lastDelimiter = -1;
|
||||
let index = 0;
|
||||
for (const character of text) {
|
||||
index += character.length;
|
||||
if (PATH_DELIMITERS.has(character) || autocompleteSeparatorRegex.test(character)) {
|
||||
lastDelimiter = index - 1;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
return lastDelimiter;
|
||||
}
|
||||
|
||||
// Strip opening wrappers before a path, e.g. "(~/Dev" -> "~/Dev" or "`src/ma" -> "src/ma".
|
||||
// Keep a wrapper if the token also contains its closer, e.g. "app/[slug]/pa" or "(group)/pa".
|
||||
function stripLeadingWrappers(token: string): string {
|
||||
let result = token;
|
||||
while (result.length > 0) {
|
||||
const closer = PATH_WRAPPERS[result[0]!];
|
||||
if (!closer || result.includes(closer, 1)) {
|
||||
break;
|
||||
}
|
||||
result = result.slice(1);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function findUnclosedQuoteStart(text: string): number | null {
|
||||
@@ -68,7 +89,11 @@ function findUnclosedQuoteStart(text: string): number | null {
|
||||
}
|
||||
|
||||
function isTokenStart(text: string, index: number): boolean {
|
||||
return index === 0 || PATH_DELIMITERS.has(text[index - 1] ?? "");
|
||||
let start = index;
|
||||
while (start > 0 && PATH_WRAPPERS[text[start - 1]!]) {
|
||||
start -= 1;
|
||||
}
|
||||
return PATH_DELIMITERS.has(text[start - 1] ?? "") || tokenStartRegex.test(text.slice(0, start));
|
||||
}
|
||||
|
||||
function extractQuotedPrefix(text: string): string | null {
|
||||
@@ -108,7 +133,7 @@ function buildCompletionValue(
|
||||
path: string,
|
||||
options: { isDirectory: boolean; isAtPrefix: boolean; isQuotedPrefix: boolean },
|
||||
): string {
|
||||
const needsQuotes = options.isQuotedPrefix || path.includes(" ");
|
||||
const needsQuotes = options.isQuotedPrefix || autocompleteSeparatorRegex.test(path);
|
||||
const prefix = options.isAtPrefix ? "@" : "";
|
||||
|
||||
if (!needsQuotes) {
|
||||
@@ -310,11 +335,12 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||
};
|
||||
}
|
||||
|
||||
if (!options.force && textBeforeCursor.startsWith("/")) {
|
||||
const spaceIndex = textBeforeCursor.indexOf(" ");
|
||||
const commandText = textBeforeCursor.trimStart();
|
||||
if (!options.force && commandText.startsWith("/")) {
|
||||
const spaceIndex = commandText.indexOf(" ");
|
||||
|
||||
if (spaceIndex === -1) {
|
||||
const prefix = textBeforeCursor.slice(1);
|
||||
const prefix = commandText.slice(1);
|
||||
const commandItems = this.commands.map((cmd) => {
|
||||
const name = "name" in cmd ? cmd.name : cmd.value;
|
||||
const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
|
||||
@@ -327,7 +353,16 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||
};
|
||||
});
|
||||
|
||||
const filtered = fuzzyFilter(commandItems, prefix, (item) => item.name).map((item) => ({
|
||||
const bareNameMatches = fuzzyFilter(commandItems, prefix, (item) =>
|
||||
item.name.startsWith("skill:") ? item.name.slice("skill:".length) : item.name,
|
||||
);
|
||||
const bareNameMatchSet = new Set(bareNameMatches);
|
||||
const fullNameOnlyMatches = fuzzyFilter(
|
||||
commandItems.filter((item) => item.name.startsWith("skill:") && !bareNameMatchSet.has(item)),
|
||||
prefix,
|
||||
(item) => item.name,
|
||||
);
|
||||
const filtered = [...bareNameMatches, ...fullNameOnlyMatches].map((item) => ({
|
||||
value: item.name,
|
||||
label: item.label,
|
||||
...(item.description && { description: item.description }),
|
||||
@@ -337,12 +372,12 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||
|
||||
return {
|
||||
items: filtered,
|
||||
prefix: textBeforeCursor,
|
||||
prefix: commandText,
|
||||
};
|
||||
}
|
||||
|
||||
const commandName = textBeforeCursor.slice(1, spaceIndex);
|
||||
const argumentText = textBeforeCursor.slice(spaceIndex + 1);
|
||||
const commandName = commandText.slice(1, spaceIndex);
|
||||
const argumentText = commandText.slice(spaceIndex + 1);
|
||||
|
||||
const command = this.commands.find((cmd) => {
|
||||
const name = "name" in cmd ? cmd.name : cmd.value;
|
||||
@@ -472,10 +507,10 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||
}
|
||||
|
||||
const lastDelimiterIndex = findLastDelimiter(text);
|
||||
const tokenStart = lastDelimiterIndex === -1 ? 0 : lastDelimiterIndex + 1;
|
||||
const token = stripLeadingWrappers(lastDelimiterIndex === -1 ? text : text.slice(lastDelimiterIndex + 1));
|
||||
|
||||
if (text[tokenStart] === "@") {
|
||||
return text.slice(tokenStart);
|
||||
if (token.startsWith("@")) {
|
||||
return token;
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -489,7 +524,7 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||
}
|
||||
|
||||
const lastDelimiterIndex = findLastDelimiter(text);
|
||||
const pathPrefix = lastDelimiterIndex === -1 ? text : text.slice(lastDelimiterIndex + 1);
|
||||
const pathPrefix = stripLeadingWrappers(lastDelimiterIndex === -1 ? text : text.slice(lastDelimiterIndex + 1));
|
||||
|
||||
// For forced extraction (Tab key), always return something
|
||||
if (forceExtract) {
|
||||
@@ -502,9 +537,9 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||
return pathPrefix;
|
||||
}
|
||||
|
||||
// Return empty string only after a space (not for completely empty text)
|
||||
// Return an empty prefix after whitespace or CJK punctuation, but not for empty text.
|
||||
// Empty text should not trigger file suggestions - that's for forced Tab completion
|
||||
if (pathPrefix === "" && text.endsWith(" ")) {
|
||||
if (pathPrefix === "" && text !== "" && tokenStartRegex.test(text)) {
|
||||
return pathPrefix;
|
||||
}
|
||||
|
||||
@@ -683,8 +718,8 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
|
||||
|
||||
// Sort directories first, then alphabetically
|
||||
suggestions.sort((a, b) => {
|
||||
const aIsDir = a.value.endsWith("/");
|
||||
const bIsDir = b.value.endsWith("/");
|
||||
const aIsDir = a.label.endsWith("/");
|
||||
const bIsDir = b.label.endsWith("/");
|
||||
if (aIsDir && !bIsDir) return -1;
|
||||
if (!aIsDir && bIsDir) return 1;
|
||||
return a.label.localeCompare(b.label);
|
||||
|
||||
367
third_party/pi/packages/tui/src/colors.ts
vendored
Normal file
367
third_party/pi/packages/tui/src/colors.ts
vendored
Normal file
@@ -0,0 +1,367 @@
|
||||
import { linearSrgbToRgb, okhslToRgb, oklabToLinearSrgb, rgbToOkhsl, rgbToOklab } from "./oklab.ts";
|
||||
import type { RgbColor } from "./terminal-colors.ts";
|
||||
|
||||
export interface IndexedColor {
|
||||
readonly kind: "indexed";
|
||||
readonly index: number;
|
||||
}
|
||||
|
||||
export interface RgbColorValue {
|
||||
readonly kind: "rgb";
|
||||
readonly r: number;
|
||||
readonly g: number;
|
||||
readonly b: number;
|
||||
}
|
||||
|
||||
export interface OklchColorValue {
|
||||
readonly kind: "oklch";
|
||||
readonly l: number;
|
||||
readonly c: number;
|
||||
readonly h: number;
|
||||
}
|
||||
|
||||
/** A concrete color. Every color can be converted to sRGB, so color math never fails. */
|
||||
export type Color = IndexedColor | RgbColorValue | OklchColorValue;
|
||||
export type TerminalColorMode = "256color" | "truecolor";
|
||||
export type ColorMixSpace = "oklch" | "srgb";
|
||||
|
||||
export interface OklchChannels {
|
||||
l: number;
|
||||
c: number;
|
||||
h: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* OKHSL channels: hue in degrees, saturation and lightness 0-1. Saturation is relative to the most the
|
||||
* sRGB gamut allows at that hue and lightness, so every value is in gamut.
|
||||
*/
|
||||
export interface OkhslChannels {
|
||||
h: number;
|
||||
s: number;
|
||||
l: number;
|
||||
}
|
||||
|
||||
export interface TextAttributes {
|
||||
bold?: boolean;
|
||||
dim?: boolean;
|
||||
italic?: boolean;
|
||||
underline?: boolean;
|
||||
inverse?: boolean;
|
||||
strikethrough?: boolean;
|
||||
}
|
||||
|
||||
export interface TextStyle extends TextAttributes {
|
||||
fg?: Color;
|
||||
bg?: Color;
|
||||
}
|
||||
|
||||
function requireFinite(value: number, name: string): void {
|
||||
if (!Number.isFinite(value)) throw new Error(`${name} must be finite`);
|
||||
}
|
||||
|
||||
export function indexedColor(index: number): IndexedColor {
|
||||
if (!Number.isInteger(index) || index < 0 || index > 255) {
|
||||
throw new Error(`ANSI color index must be an integer from 0 to 255: ${index}`);
|
||||
}
|
||||
return Object.freeze({ kind: "indexed", index });
|
||||
}
|
||||
|
||||
export function rgbColor(r: number, g: number, b: number): RgbColorValue {
|
||||
for (const [name, value] of [
|
||||
["r", r],
|
||||
["g", g],
|
||||
["b", b],
|
||||
] as const) {
|
||||
requireFinite(value, name);
|
||||
if (value < 0 || value > 255) throw new Error(`${name} must be between 0 and 255: ${value}`);
|
||||
}
|
||||
return Object.freeze({ kind: "rgb", r, g, b });
|
||||
}
|
||||
|
||||
export function oklchColor(l: number, c: number, h: number): OklchColorValue {
|
||||
requireFinite(l, "l");
|
||||
requireFinite(c, "c");
|
||||
requireFinite(h, "h");
|
||||
if (l < 0 || l > 1) throw new Error(`l must be between 0 and 1: ${l}`);
|
||||
if (c < 0) throw new Error(`c must not be negative: ${c}`);
|
||||
return Object.freeze({ kind: "oklch", l, c, h: ((h % 360) + 360) % 360 });
|
||||
}
|
||||
|
||||
const NUMBER_PATTERN = String.raw`[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?`;
|
||||
const OKLCH_PATTERN = new RegExp(
|
||||
`^oklch\\(\\s*(${NUMBER_PATTERN})(%)?\\s+(${NUMBER_PATTERN})\\s+(${NUMBER_PATTERN})(?:deg)?\\s*\\)$`,
|
||||
"i",
|
||||
);
|
||||
const OKHSL_PATTERN = new RegExp(
|
||||
`^okhsl\\(\\s*(${NUMBER_PATTERN})(?:deg)?\\s+(${NUMBER_PATTERN})(%)?\\s+(${NUMBER_PATTERN})(%)?\\s*\\)$`,
|
||||
"i",
|
||||
);
|
||||
|
||||
/**
|
||||
* An OKHSL color, converted to sRGB. Saturation is relative to the sRGB gamut at the hue and lightness,
|
||||
* so equal saturation looks equally colorful across hues and lightness.
|
||||
* @param h Hue in degrees.
|
||||
* @param s Saturation, 0-1.
|
||||
* @param l Lightness, 0-1.
|
||||
*/
|
||||
export function okhslColor(h: number, s: number, l: number): RgbColorValue {
|
||||
requireFinite(h, "h");
|
||||
requireFinite(s, "s");
|
||||
requireFinite(l, "l");
|
||||
if (s < 0 || s > 1) throw new Error(`s must be between 0 and 1: ${s}`);
|
||||
if (l < 0 || l > 1) throw new Error(`l must be between 0 and 1: ${l}`);
|
||||
const { r, g, b } = okhslToRgb(h, s, l);
|
||||
return rgbColor(r, g, b);
|
||||
}
|
||||
|
||||
export function colorToOkhsl(color: Color): OkhslChannels {
|
||||
return rgbToOkhsl(colorToRgb(color));
|
||||
}
|
||||
|
||||
export function parseColor(value: string | number): Color {
|
||||
if (typeof value === "number") return indexedColor(value);
|
||||
|
||||
const hex = /^#([\da-f]{3}|[\da-f]{6})$/i.exec(value);
|
||||
if (hex) {
|
||||
const digits = hex[1].length === 3 ? [...hex[1]].map((digit) => digit + digit).join("") : hex[1];
|
||||
return rgbColor(
|
||||
Number.parseInt(digits.slice(0, 2), 16),
|
||||
Number.parseInt(digits.slice(2, 4), 16),
|
||||
Number.parseInt(digits.slice(4, 6), 16),
|
||||
);
|
||||
}
|
||||
|
||||
const oklch = OKLCH_PATTERN.exec(value);
|
||||
if (oklch) {
|
||||
const lightness = Number.parseFloat(oklch[1]) / (oklch[2] ? 100 : 1);
|
||||
return oklchColor(lightness, Number.parseFloat(oklch[3]), Number.parseFloat(oklch[4]));
|
||||
}
|
||||
|
||||
const okhsl = OKHSL_PATTERN.exec(value);
|
||||
if (okhsl) {
|
||||
const saturation = Number.parseFloat(okhsl[2]) / (okhsl[3] ? 100 : 1);
|
||||
const lightness = Number.parseFloat(okhsl[4]) / (okhsl[5] ? 100 : 1);
|
||||
return okhslColor(Number.parseFloat(okhsl[1]), saturation, lightness);
|
||||
}
|
||||
|
||||
throw new Error(`Invalid color value: ${value}`);
|
||||
}
|
||||
|
||||
const BASIC_COLORS: readonly RgbColor[] = [
|
||||
{ r: 0, g: 0, b: 0 },
|
||||
{ r: 128, g: 0, b: 0 },
|
||||
{ r: 0, g: 128, b: 0 },
|
||||
{ r: 128, g: 128, b: 0 },
|
||||
{ r: 0, g: 0, b: 128 },
|
||||
{ r: 128, g: 0, b: 128 },
|
||||
{ r: 0, g: 128, b: 128 },
|
||||
{ r: 192, g: 192, b: 192 },
|
||||
{ r: 128, g: 128, b: 128 },
|
||||
{ r: 255, g: 0, b: 0 },
|
||||
{ r: 0, g: 255, b: 0 },
|
||||
{ r: 255, g: 255, b: 0 },
|
||||
{ r: 0, g: 0, b: 255 },
|
||||
{ r: 255, g: 0, b: 255 },
|
||||
{ r: 0, g: 255, b: 255 },
|
||||
{ r: 255, g: 255, b: 255 },
|
||||
];
|
||||
const CUBE_VALUES = [0, 95, 135, 175, 215, 255] as const;
|
||||
const GRAY_VALUES = Array.from({ length: 24 }, (_, index) => 8 + index * 10);
|
||||
|
||||
function indexedToRgb(index: number): RgbColor {
|
||||
if (index < 16) return { ...BASIC_COLORS[index] };
|
||||
if (index < 232) {
|
||||
const cubeIndex = index - 16;
|
||||
return {
|
||||
r: CUBE_VALUES[Math.floor(cubeIndex / 36)],
|
||||
g: CUBE_VALUES[Math.floor((cubeIndex % 36) / 6)],
|
||||
b: CUBE_VALUES[cubeIndex % 6],
|
||||
};
|
||||
}
|
||||
const gray = 8 + (index - 232) * 10;
|
||||
return { r: gray, g: gray, b: gray };
|
||||
}
|
||||
|
||||
function isInSrgbGamut(linear: number[]): boolean {
|
||||
const epsilon = 1e-7;
|
||||
return linear.every((channel) => channel >= -epsilon && channel <= 1 + epsilon);
|
||||
}
|
||||
|
||||
function oklchToRgb({ l, c, h }: OklchChannels): RgbColor {
|
||||
// Gamut mapping keeps the hue fixed, so its direction is computed once and scaled by chroma.
|
||||
const radians = (h * Math.PI) / 180;
|
||||
const cos = Math.cos(radians);
|
||||
const sin = Math.sin(radians);
|
||||
const atChroma = (chroma: number) => oklabToLinearSrgb([l, chroma * cos, chroma * sin]);
|
||||
|
||||
const direct = atChroma(c);
|
||||
if (isInSrgbGamut(direct)) return linearSrgbToRgb(direct);
|
||||
|
||||
// Reduce chroma until the color fits. The achromatic color is always in gamut, so it is the
|
||||
// fallback when no bisection step fits, e.g. `oklch(100% 0.3 150)` must map to white.
|
||||
let linear = atChroma(0);
|
||||
let low = 0;
|
||||
let high = c;
|
||||
for (let index = 0; index < 20; index++) {
|
||||
const chroma = (low + high) / 2;
|
||||
const candidate = atChroma(chroma);
|
||||
if (isInSrgbGamut(candidate)) {
|
||||
low = chroma;
|
||||
linear = candidate;
|
||||
} else {
|
||||
high = chroma;
|
||||
}
|
||||
}
|
||||
return linearSrgbToRgb(linear);
|
||||
}
|
||||
|
||||
export function colorToRgb(color: Color): RgbColor {
|
||||
switch (color.kind) {
|
||||
case "indexed":
|
||||
return indexedToRgb(color.index);
|
||||
case "rgb":
|
||||
return { r: color.r, g: color.g, b: color.b };
|
||||
case "oklch":
|
||||
return oklchToRgb(color);
|
||||
}
|
||||
}
|
||||
|
||||
export function colorToOklch(color: Color): OklchChannels {
|
||||
if (color.kind === "oklch") return { l: color.l, c: color.c, h: color.h };
|
||||
const [l, a, b] = rgbToOklab(colorToRgb(color));
|
||||
return { l, c: Math.hypot(a, b), h: ((Math.atan2(b, a) * 180) / Math.PI + 360) % 360 };
|
||||
}
|
||||
|
||||
export function colorToHex(color: Color): string {
|
||||
const { r, g, b } = colorToRgb(color);
|
||||
const channel = (value: number) => Math.round(value).toString(16).padStart(2, "0");
|
||||
return `#${channel(r)}${channel(g)}${channel(b)}`;
|
||||
}
|
||||
|
||||
export function mixColors(first: Color, second: Color, amount: number, space: ColorMixSpace = "oklch"): Color {
|
||||
requireFinite(amount, "amount");
|
||||
if (amount < 0 || amount > 1) throw new Error(`amount must be between 0 and 1: ${amount}`);
|
||||
|
||||
if (space === "srgb") {
|
||||
const a = colorToRgb(first);
|
||||
const b = colorToRgb(second);
|
||||
return rgbColor(a.r + (b.r - a.r) * amount, a.g + (b.g - a.g) * amount, a.b + (b.b - a.b) * amount);
|
||||
}
|
||||
|
||||
const a = colorToOklch(first);
|
||||
const b = colorToOklch(second);
|
||||
const firstHue = a.c < 1e-7 ? b.h : a.h;
|
||||
const secondHue = b.c < 1e-7 ? firstHue : b.h;
|
||||
const hueDelta = ((secondHue - firstHue + 540) % 360) - 180;
|
||||
return oklchColor(a.l + (b.l - a.l) * amount, a.c + (b.c - a.c) * amount, firstHue + hueDelta * amount);
|
||||
}
|
||||
|
||||
function findClosest(values: readonly number[], target: number): number {
|
||||
let closestIndex = 0;
|
||||
let closestDistance = Infinity;
|
||||
for (let index = 0; index < values.length; index++) {
|
||||
const distance = Math.abs(target - values[index]);
|
||||
if (distance < closestDistance) {
|
||||
closestIndex = index;
|
||||
closestDistance = distance;
|
||||
}
|
||||
}
|
||||
return closestIndex;
|
||||
}
|
||||
|
||||
function colorDistance(first: RgbColor, second: RgbColor): number {
|
||||
const dr = first.r - second.r;
|
||||
const dg = first.g - second.g;
|
||||
const db = first.b - second.b;
|
||||
return dr * dr * 0.299 + dg * dg * 0.587 + db * db * 0.114;
|
||||
}
|
||||
|
||||
function rgbToAnsi256(color: RgbColor): number {
|
||||
const rIndex = findClosest(CUBE_VALUES, color.r);
|
||||
const gIndex = findClosest(CUBE_VALUES, color.g);
|
||||
const bIndex = findClosest(CUBE_VALUES, color.b);
|
||||
const cubeColor = { r: CUBE_VALUES[rIndex], g: CUBE_VALUES[gIndex], b: CUBE_VALUES[bIndex] };
|
||||
const cubeIndex = 16 + 36 * rIndex + 6 * gIndex + bIndex;
|
||||
|
||||
const gray = Math.round(0.299 * color.r + 0.587 * color.g + 0.114 * color.b);
|
||||
const grayOffset = findClosest(GRAY_VALUES, gray);
|
||||
const grayValue = GRAY_VALUES[grayOffset];
|
||||
const spread = Math.max(color.r, color.g, color.b) - Math.min(color.r, color.g, color.b);
|
||||
if (
|
||||
spread < 10 &&
|
||||
colorDistance(color, { r: grayValue, g: grayValue, b: grayValue }) < colorDistance(color, cubeColor)
|
||||
) {
|
||||
return 232 + grayOffset;
|
||||
}
|
||||
return cubeIndex;
|
||||
}
|
||||
|
||||
function colorAnsi(color: Color, mode: TerminalColorMode, background: boolean): string {
|
||||
if (color.kind === "indexed") return `\x1b[${background ? 48 : 38};5;${color.index}m`;
|
||||
|
||||
const rgb = colorToRgb(color);
|
||||
if (mode === "truecolor") {
|
||||
return `\x1b[${background ? 48 : 38};2;${Math.round(rgb.r)};${Math.round(rgb.g)};${Math.round(rgb.b)}m`;
|
||||
}
|
||||
return `\x1b[${background ? 48 : 38};5;${rgbToAnsi256(rgb)}m`;
|
||||
}
|
||||
|
||||
export function foregroundAnsi(color: Color, mode: TerminalColorMode): string {
|
||||
return colorAnsi(color, mode, false);
|
||||
}
|
||||
|
||||
export function backgroundAnsi(color: Color, mode: TerminalColorMode): string {
|
||||
return colorAnsi(color, mode, true);
|
||||
}
|
||||
|
||||
export function styleText(text: string, options: TextStyle, mode: TerminalColorMode): string {
|
||||
return styleTextWithAnsi(
|
||||
text,
|
||||
options.fg && foregroundAnsi(options.fg, mode),
|
||||
options.bg && backgroundAnsi(options.bg, mode),
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Like `styleText()`, but with precomputed color escape sequences, e.g. cached theme colors.
|
||||
* Colors in `options` are ignored.
|
||||
*/
|
||||
export function styleTextWithAnsi(
|
||||
text: string,
|
||||
fgAnsi: string | undefined,
|
||||
bgAnsi: string | undefined,
|
||||
options: TextAttributes,
|
||||
): string {
|
||||
// Resets are prepended so they close in reverse order of the opening sequences.
|
||||
let prefix = "";
|
||||
let suffix = "";
|
||||
if (fgAnsi) {
|
||||
prefix += fgAnsi;
|
||||
suffix = "\x1b[39m";
|
||||
}
|
||||
if (bgAnsi) {
|
||||
prefix += bgAnsi;
|
||||
suffix = `\x1b[49m${suffix}`;
|
||||
}
|
||||
if (options.bold) prefix += "\x1b[1m";
|
||||
if (options.dim) prefix += "\x1b[2m";
|
||||
if (options.bold || options.dim) suffix = `\x1b[22m${suffix}`;
|
||||
if (options.italic) {
|
||||
prefix += "\x1b[3m";
|
||||
suffix = `\x1b[23m${suffix}`;
|
||||
}
|
||||
if (options.underline) {
|
||||
prefix += "\x1b[4m";
|
||||
suffix = `\x1b[24m${suffix}`;
|
||||
}
|
||||
if (options.inverse) {
|
||||
prefix += "\x1b[7m";
|
||||
suffix = `\x1b[27m${suffix}`;
|
||||
}
|
||||
if (options.strikethrough) {
|
||||
prefix += "\x1b[9m";
|
||||
suffix = `\x1b[29m${suffix}`;
|
||||
}
|
||||
return `${prefix}${text}${suffix}`;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type Component, dispatchMouseEvent, type TuiMouseDispatchResult, type TuiMouseEvent } from "../tui.ts";
|
||||
import { applyBackgroundToLine, visibleWidth } from "../utils.ts";
|
||||
import { flattenLines, visibleWidth } from "../utils.ts";
|
||||
|
||||
type RenderCache = {
|
||||
childLines: string[];
|
||||
@@ -50,6 +50,11 @@ export class Box implements Component {
|
||||
// Don't invalidate here - we'll detect bgFn changes by sampling output
|
||||
}
|
||||
|
||||
setPaddingX(paddingX: number): void {
|
||||
this.paddingX = paddingX;
|
||||
this.invalidateCache();
|
||||
}
|
||||
|
||||
private invalidateCache(): void {
|
||||
this.cache = undefined;
|
||||
}
|
||||
@@ -106,14 +111,16 @@ export class Box implements Component {
|
||||
const contentWidth = Math.max(1, width - this.paddingX * 2);
|
||||
const leftPad = " ".repeat(this.paddingX);
|
||||
|
||||
// Render all children
|
||||
// Render all children. Keep the child lines unpadded: children usually return the same string
|
||||
// objects every frame, so the cache check below is a cheap identity comparison per line.
|
||||
// Padding here would create new strings that must be compared character by character.
|
||||
const childLines: string[] = [];
|
||||
const mouseChildren: Array<{ component: Component; height: number }> = [];
|
||||
for (const child of this.children) {
|
||||
const lines = child.render(contentWidth);
|
||||
mouseChildren.push({ component: child, height: lines.length });
|
||||
for (const line of lines) {
|
||||
childLines.push(leftPad + line);
|
||||
childLines.push(line);
|
||||
}
|
||||
}
|
||||
this.mouseLayout = { width: contentWidth, children: mouseChildren };
|
||||
@@ -140,7 +147,7 @@ export class Box implements Component {
|
||||
|
||||
// Content
|
||||
for (const line of childLines) {
|
||||
result.push(this.applyBg(line, width));
|
||||
result.push(this.applyBg(leftPad + line, width));
|
||||
}
|
||||
|
||||
// Bottom padding
|
||||
@@ -149,6 +156,7 @@ export class Box implements Component {
|
||||
}
|
||||
|
||||
// Update cache
|
||||
flattenLines(result);
|
||||
this.cache = { childLines, width, bgSample, lines: result };
|
||||
|
||||
return result;
|
||||
@@ -158,10 +166,7 @@ export class Box implements Component {
|
||||
const visLen = visibleWidth(line);
|
||||
const padNeeded = Math.max(0, width - visLen);
|
||||
const padded = line + " ".repeat(padNeeded);
|
||||
|
||||
if (this.bgFn) {
|
||||
return applyBackgroundToLine(padded, width, this.bgFn);
|
||||
}
|
||||
return padded;
|
||||
// Already padded to width, so apply the background directly instead of measuring the line again.
|
||||
return this.bgFn ? this.bgFn(padded) : padded;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,15 @@ import {
|
||||
type Component,
|
||||
CURSOR_MARKER,
|
||||
type Focusable,
|
||||
renderFakeCursor,
|
||||
type TUI,
|
||||
type TuiMouseEvent,
|
||||
type TuiMouseEventResult,
|
||||
} from "../tui.ts";
|
||||
import { UndoStack } from "../undo-stack.ts";
|
||||
import {
|
||||
autocompleteBoundaryRegex,
|
||||
autocompleteSeparatorRegex,
|
||||
cjkBreakRegex,
|
||||
getGraphemeSegmenter,
|
||||
getWordSegmenter,
|
||||
@@ -249,18 +252,28 @@ const SLASH_COMMAND_SELECT_LIST_LAYOUT: SelectListLayoutOptions = {
|
||||
|
||||
const ATTACHMENT_AUTOCOMPLETE_DEBOUNCE_MS = 20;
|
||||
const DEFAULT_AUTOCOMPLETE_TRIGGER_CHARACTERS = ["@", "#"];
|
||||
// Unquoted completions end at whitespace or CJK punctuation; quoted paths may contain either.
|
||||
const unquotedAutocompleteSuffixRegex = new RegExp(`(?:(?!${autocompleteSeparatorRegex.source}).)*`, "u");
|
||||
// Trigger tokens may be wrapped in prose, e.g. "(@src/foo" or "`@src/foo".
|
||||
const autocompleteTokenStartSource = `${autocompleteBoundaryRegex.source}[([{<\`]*`;
|
||||
|
||||
function escapeCharacterClass(value: string): string {
|
||||
return value.replace(/[\\^$.*+?()[\]{}|-]/g, "\\$&");
|
||||
}
|
||||
|
||||
function buildTriggerPattern(triggerCharacters: string[]): RegExp {
|
||||
return new RegExp(`(?:^|[\\s])[${triggerCharacters.map(escapeCharacterClass).join("")}][^\\s]*$`);
|
||||
return new RegExp(
|
||||
`${autocompleteTokenStartSource}(?:@"[^"]*|[${triggerCharacters.map(escapeCharacterClass).join("")}]${unquotedAutocompleteSuffixRegex.source})$`,
|
||||
"u",
|
||||
);
|
||||
}
|
||||
|
||||
function buildDebouncePattern(triggerCharacters: string[]): RegExp {
|
||||
const escapedWithoutAt = triggerCharacters.filter((character) => character !== "@").map(escapeCharacterClass);
|
||||
return new RegExp(`(?:^|[ \\t])(?:@(?:"[^"]*|[^\\s]*)|[${escapedWithoutAt.join("")}][^\\s]*)$`);
|
||||
return new RegExp(
|
||||
`${autocompleteTokenStartSource}(?:@(?:"[^"]*|${unquotedAutocompleteSuffixRegex.source})|[${escapedWithoutAt.join("")}]${unquotedAutocompleteSuffixRegex.source})$`,
|
||||
"u",
|
||||
);
|
||||
}
|
||||
|
||||
function createScrollBorder(direction: "↑" | "↓", hiddenLineCount: number, width: number): string {
|
||||
@@ -575,12 +588,12 @@ export class Editor implements Component, Focusable {
|
||||
const afterGraphemes = [...this.segment(after, "grapheme")];
|
||||
const firstGrapheme = afterGraphemes[0]?.segment || "";
|
||||
const restAfter = after.slice(firstGrapheme.length);
|
||||
const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`;
|
||||
const cursor = renderFakeCursor(firstGrapheme);
|
||||
displayText = before + marker + cursor + restAfter;
|
||||
// lineVisibleWidth stays the same - we're replacing, not adding
|
||||
} else {
|
||||
// Cursor is at the end - add highlighted space
|
||||
const cursor = "\x1b[7m \x1b[0m";
|
||||
const cursor = renderFakeCursor(" ");
|
||||
displayText = before + marker + cursor;
|
||||
lineVisibleWidth = lineVisibleWidth + 1;
|
||||
// If cursor overflows content width into the padding, flag it
|
||||
@@ -1222,13 +1235,12 @@ export class Editor implements Component, Focusable {
|
||||
else if (this.autocompleteTriggerCharacters.includes(char)) {
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
||||
const charBeforeSymbol = textBeforeCursor[textBeforeCursor.length - 2];
|
||||
if (textBeforeCursor.length === 1 || charBeforeSymbol === " " || charBeforeSymbol === "\t") {
|
||||
if (this.autocompleteTriggerPattern.test(textBeforeCursor)) {
|
||||
this.tryTriggerAutocomplete();
|
||||
}
|
||||
}
|
||||
// Also auto-trigger when typing letters in a slash command or symbol completion context
|
||||
else if (/[a-zA-Z0-9.\-_]/.test(char)) {
|
||||
else if (/[a-zA-Z0-9.\-_]/.test(char) || cjkBreakRegex.test(char)) {
|
||||
const currentLine = this.state.lines[this.state.cursorLine] || "";
|
||||
const textBeforeCursor = currentLine.slice(0, this.state.cursorCol);
|
||||
// Check if we're in a slash command (with or without space for arguments)
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
getCapabilities,
|
||||
getCellDimensions,
|
||||
getImageDimensions,
|
||||
getPngDimensions,
|
||||
type ImageDimensions,
|
||||
imageFallback,
|
||||
renderImage,
|
||||
@@ -10,6 +11,35 @@ import {
|
||||
import type { Component } from "../tui.ts";
|
||||
import { truncateToWidth } from "../utils.ts";
|
||||
|
||||
/**
|
||||
* Converts base64 image data to base64 PNG data, or returns null if it cannot.
|
||||
* Called synchronously during rendering.
|
||||
*/
|
||||
export type ImageTranscoder = (base64Data: string, mimeType: string) => string | null;
|
||||
|
||||
let imageTranscoder: ImageTranscoder | undefined;
|
||||
// Backstop for callers that recreate Image instances. Keyed by source data, least recently used first.
|
||||
const pngCache = new Map<string, string | null>();
|
||||
|
||||
/**
|
||||
* Register the converter used for non-PNG images on Kitty-protocol terminals, which only accept PNG.
|
||||
* Without one, such images render as text fallbacks.
|
||||
*/
|
||||
export function setImageTranscoder(transcoder: ImageTranscoder | undefined): void {
|
||||
imageTranscoder = transcoder;
|
||||
pngCache.clear();
|
||||
}
|
||||
|
||||
function toPng(base64Data: string, mimeType: string): string | null {
|
||||
if (!imageTranscoder) return null;
|
||||
const cached = pngCache.get(base64Data);
|
||||
const png = cached === undefined ? imageTranscoder(base64Data, mimeType) : cached;
|
||||
pngCache.delete(base64Data);
|
||||
pngCache.set(base64Data, png);
|
||||
if (pngCache.size > 32) pngCache.delete(pngCache.keys().next().value!);
|
||||
return png;
|
||||
}
|
||||
|
||||
export interface ImageTheme {
|
||||
fallbackColor: (str: string) => string;
|
||||
}
|
||||
@@ -29,6 +59,8 @@ export class Image implements Component {
|
||||
private theme: ImageTheme;
|
||||
private options: ImageOptions;
|
||||
private imageId?: number;
|
||||
/** Converted PNG data for Kitty. Failures are not stored so a later transcoder can retry. */
|
||||
private pngData?: string;
|
||||
|
||||
private cachedLines?: string[];
|
||||
private cachedWidth?: number;
|
||||
@@ -69,13 +101,21 @@ export class Image implements Component {
|
||||
const maxHeight = this.options.maxHeightCells ?? defaultMaxHeight;
|
||||
|
||||
const caps = getCapabilities();
|
||||
let data: string | null = this.base64Data;
|
||||
let dimensions = this.dimensions;
|
||||
if (caps.images === "kitty" && this.mimeType !== "image/png") {
|
||||
this.pngData ??= toPng(this.base64Data, this.mimeType) ?? undefined;
|
||||
data = this.pngData ?? null;
|
||||
// Conversion may apply EXIF rotation, so prefer the PNG's own dimensions.
|
||||
if (data) dimensions = getPngDimensions(data) ?? dimensions;
|
||||
}
|
||||
let lines: string[];
|
||||
|
||||
if (caps.images) {
|
||||
if (caps.images && data) {
|
||||
if (caps.images === "kitty" && this.imageId === undefined) {
|
||||
this.imageId = allocateImageId();
|
||||
}
|
||||
const result = renderImage(this.base64Data, this.dimensions, {
|
||||
const result = renderImage(data, dimensions, {
|
||||
maxWidthCells: maxWidth,
|
||||
maxHeightCells: maxHeight,
|
||||
imageId: this.imageId,
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { getKeybindings } from "../keybindings.ts";
|
||||
import { decodeKittyPrintable } from "../keys.ts";
|
||||
import { KillRing } from "../kill-ring.ts";
|
||||
import { type Component, CURSOR_MARKER, type Focusable, type TuiMouseEvent, type TuiMouseEventResult } from "../tui.ts";
|
||||
import {
|
||||
type Component,
|
||||
CURSOR_MARKER,
|
||||
type Focusable,
|
||||
renderFakeCursor,
|
||||
type TuiMouseEvent,
|
||||
type TuiMouseEventResult,
|
||||
} from "../tui.ts";
|
||||
import { UndoStack } from "../undo-stack.ts";
|
||||
import { getGraphemeSegmenter, isWhitespaceChar, sliceByColumn, truncateToWidth, visibleWidth } from "../utils.ts";
|
||||
import { findWordBackward, findWordForward } from "../word-navigation.ts";
|
||||
@@ -423,7 +430,7 @@ export class Input implements Component, Focusable {
|
||||
const atCursor = graphemes[0]?.segment ?? " ";
|
||||
const afterCursor = placeholder.slice(atCursor.length);
|
||||
const marker = this.focused ? CURSOR_MARKER : "";
|
||||
const cursorChar = `\x1b[7m${this.placeholderStyle(atCursor)}\x1b[27m`;
|
||||
const cursorChar = renderFakeCursor(this.placeholderStyle(atCursor));
|
||||
const textWithCursor = marker + cursorChar + this.placeholderStyle(afterCursor);
|
||||
const padding = " ".repeat(Math.max(0, availableWidth - visibleWidth(textWithCursor)));
|
||||
return [this.prompt + textWithCursor + padding];
|
||||
@@ -480,8 +487,8 @@ export class Input implements Component, Focusable {
|
||||
// Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning)
|
||||
const marker = this.focused ? CURSOR_MARKER : "";
|
||||
|
||||
// Use inverse video to show cursor
|
||||
const cursorChar = `\x1b[7m${atCursor}\x1b[27m`; // ESC[7m = reverse video, ESC[27m = normal
|
||||
// TUI draws the fake cursor in reverse video, or omits it when the hardware cursor is shown
|
||||
const cursorChar = renderFakeCursor(atCursor);
|
||||
const textWithCursor = beforeCursor + marker + cursorChar + afterCursor;
|
||||
|
||||
// Calculate visual width
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Marked, type Token, Tokenizer, type TokenizerExtension, type Tokens } f
|
||||
import { renderLatex } from "../latex.ts";
|
||||
import { getCapabilities, hyperlink, isImageLine } from "../terminal-image.ts";
|
||||
import type { Component } from "../tui.ts";
|
||||
import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.ts";
|
||||
import { applyBackgroundToLine, flattenLines, visibleWidth, wrapTextWithAnsi } from "../utils.ts";
|
||||
|
||||
const STRICT_STRIKETHROUGH_REGEX = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/;
|
||||
|
||||
@@ -246,6 +246,10 @@ export class Markdown implements Component {
|
||||
private cachedText?: string;
|
||||
private cachedWidth?: number;
|
||||
private cachedLines?: string[];
|
||||
// Parsed tokens depend only on the source, so they survive theme and width invalidation. Held weakly: a token tree is
|
||||
// about ten times the size of its source, and every message of a long transcript keeps a Markdown component. The
|
||||
// tokens survive a burst of re-renders, such as a theme preview, and are collected afterwards.
|
||||
private cachedTokens?: WeakRef<{ source: string; tokens: Token[] }>;
|
||||
|
||||
constructor(
|
||||
text: string,
|
||||
@@ -298,8 +302,13 @@ export class Markdown implements Component {
|
||||
const normalizedText = text.replace(/\t/g, " ");
|
||||
|
||||
// Parse markdown to HTML-like tokens
|
||||
const tokens = markdownParser.lexer(normalizedText);
|
||||
trimPartialClosingFences(tokens);
|
||||
const cached = this.cachedTokens?.deref();
|
||||
let tokens = cached?.source === normalizedText ? cached.tokens : undefined;
|
||||
if (!tokens) {
|
||||
tokens = markdownParser.lexer(normalizedText);
|
||||
trimPartialClosingFences(tokens);
|
||||
this.cachedTokens = new WeakRef({ source: normalizedText, tokens });
|
||||
}
|
||||
|
||||
// Convert tokens to styled terminal output
|
||||
const renderedLines: string[] = [];
|
||||
@@ -359,6 +368,7 @@ export class Markdown implements Component {
|
||||
|
||||
// Combine top padding, content, and bottom padding
|
||||
const result = emptyLines.concat(contentLines, emptyLines);
|
||||
flattenLines(result);
|
||||
|
||||
// Update cache
|
||||
this.cachedText = this.text;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Component } from "../tui.ts";
|
||||
import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.ts";
|
||||
import { applyBackgroundToLine, flattenLines, visibleWidth, wrapTextWithAnsi } from "../utils.ts";
|
||||
|
||||
/**
|
||||
* Text component - displays multi-line text with word wrapping
|
||||
@@ -36,6 +36,11 @@ export class Text implements Component {
|
||||
this.cachedLines = undefined;
|
||||
}
|
||||
|
||||
setPaddingX(paddingX: number): void {
|
||||
this.paddingX = paddingX;
|
||||
this.invalidate();
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.cachedText = undefined;
|
||||
this.cachedWidth = undefined;
|
||||
@@ -96,6 +101,7 @@ export class Text implements Component {
|
||||
}
|
||||
|
||||
const result = [...emptyLines, ...contentLines, ...emptyLines];
|
||||
flattenLines(result);
|
||||
|
||||
// Update cache
|
||||
this.cachedText = this.text;
|
||||
|
||||
49
third_party/pi/packages/tui/src/fuzzy.ts
vendored
49
third_party/pi/packages/tui/src/fuzzy.ts
vendored
@@ -27,33 +27,34 @@ export function fuzzyMatch(query: string, text: string): FuzzyMatch {
|
||||
let lastMatchIndex = -1;
|
||||
let consecutiveMatches = 0;
|
||||
|
||||
for (let i = 0; i < textLower.length && queryIndex < normalizedQuery.length; i++) {
|
||||
if (textLower[i] === normalizedQuery[queryIndex]) {
|
||||
const isWordBoundary = i === 0 || /[\s\-_./:]/.test(textLower[i - 1]!);
|
||||
while (queryIndex < normalizedQuery.length) {
|
||||
const i = textLower.indexOf(normalizedQuery[queryIndex]!, lastMatchIndex + 1);
|
||||
if (i === -1) break;
|
||||
|
||||
// Reward consecutive matches
|
||||
if (lastMatchIndex === i - 1) {
|
||||
consecutiveMatches++;
|
||||
score -= consecutiveMatches * 5;
|
||||
} else {
|
||||
consecutiveMatches = 0;
|
||||
// Penalize gaps
|
||||
if (lastMatchIndex >= 0) {
|
||||
score += (i - lastMatchIndex - 1) * 2;
|
||||
}
|
||||
const isWordBoundary = i === 0 || /[\s\-_./:]/.test(textLower[i - 1]!);
|
||||
|
||||
// Reward consecutive matches
|
||||
if (lastMatchIndex === i - 1) {
|
||||
consecutiveMatches++;
|
||||
score -= consecutiveMatches * 5;
|
||||
} else {
|
||||
consecutiveMatches = 0;
|
||||
// Penalize gaps
|
||||
if (lastMatchIndex >= 0) {
|
||||
score += (i - lastMatchIndex - 1) * 2;
|
||||
}
|
||||
|
||||
// Reward word boundary matches
|
||||
if (isWordBoundary) {
|
||||
score -= 10;
|
||||
}
|
||||
|
||||
// Slight penalty for later matches
|
||||
score += i * 0.1;
|
||||
|
||||
lastMatchIndex = i;
|
||||
queryIndex++;
|
||||
}
|
||||
|
||||
// Reward word boundary matches
|
||||
if (isWordBoundary) {
|
||||
score -= 10;
|
||||
}
|
||||
|
||||
// Slight penalty for later matches
|
||||
score += i * 0.1;
|
||||
|
||||
lastMatchIndex = i;
|
||||
queryIndex++;
|
||||
}
|
||||
|
||||
if (queryIndex < normalizedQuery.length) {
|
||||
|
||||
47
third_party/pi/packages/tui/src/index.ts
vendored
47
third_party/pi/packages/tui/src/index.ts
vendored
@@ -9,12 +9,45 @@ export {
|
||||
CombinedAutocompleteProvider,
|
||||
type SlashCommand,
|
||||
} from "./autocomplete.ts";
|
||||
// Colors and styling
|
||||
export {
|
||||
backgroundAnsi,
|
||||
type Color,
|
||||
type ColorMixSpace,
|
||||
colorToHex,
|
||||
colorToOkhsl,
|
||||
colorToOklch,
|
||||
colorToRgb,
|
||||
foregroundAnsi,
|
||||
type IndexedColor,
|
||||
indexedColor,
|
||||
mixColors,
|
||||
type OkhslChannels,
|
||||
type OklchChannels,
|
||||
type OklchColorValue,
|
||||
okhslColor,
|
||||
oklchColor,
|
||||
parseColor,
|
||||
type RgbColorValue,
|
||||
rgbColor,
|
||||
styleText,
|
||||
styleTextWithAnsi,
|
||||
type TerminalColorMode,
|
||||
type TextAttributes,
|
||||
type TextStyle,
|
||||
} from "./colors.ts";
|
||||
// Components
|
||||
export { Box } from "./components/box.ts";
|
||||
export { CancellableLoader } from "./components/cancellable-loader.ts";
|
||||
export { Editor, type EditorOptions, type EditorTheme } from "./components/editor.ts";
|
||||
export { HStack } from "./components/h-stack.ts";
|
||||
export { Image, type ImageOptions, type ImageTheme } from "./components/image.ts";
|
||||
export {
|
||||
Image,
|
||||
type ImageOptions,
|
||||
type ImageTheme,
|
||||
type ImageTranscoder,
|
||||
setImageTranscoder,
|
||||
} from "./components/image.ts";
|
||||
export { Input } from "./components/input.ts";
|
||||
export { Loader, type LoaderIndicatorOptions } from "./components/loader.ts";
|
||||
export { type DefaultTextStyle, Markdown, type MarkdownOptions, type MarkdownTheme } from "./components/markdown.ts";
|
||||
@@ -75,16 +108,21 @@ export {
|
||||
} from "./keys.ts";
|
||||
// LaTeX rendering
|
||||
export { type RenderLatexOptions, renderLatex } from "./latex.ts";
|
||||
// Native platform integration
|
||||
export { getNativeClipboard, type NativeClipboard } from "./native-platform.ts";
|
||||
export { oklabToOkhslLightness } from "./oklab.ts";
|
||||
// Program status reporting (OSC 7501)
|
||||
export { formatProgramStatus, type ProgramStatus } from "./program-status.ts";
|
||||
// Input buffering for batch splitting
|
||||
export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.ts";
|
||||
// Terminal interface and implementations
|
||||
export { ProcessTerminal, type Terminal } from "./terminal.ts";
|
||||
export { isAppleTerminalSession, ProcessTerminal, type Terminal } from "./terminal.ts";
|
||||
// Terminal colors
|
||||
export {
|
||||
parseOsc11BackgroundColor,
|
||||
parseTerminalColorSchemeReport,
|
||||
type RgbColor,
|
||||
type TerminalColorScheme,
|
||||
type TerminalColors,
|
||||
} from "./terminal-colors.ts";
|
||||
// Terminal image support
|
||||
export {
|
||||
@@ -102,6 +140,7 @@ export {
|
||||
getImageDimensions,
|
||||
getJpegDimensions,
|
||||
getPngDimensions,
|
||||
getTerminalColorMode,
|
||||
getWebpDimensions,
|
||||
hyperlink,
|
||||
type ImageDimensions,
|
||||
@@ -129,6 +168,7 @@ export {
|
||||
type OverlayMargin,
|
||||
type OverlayOptions,
|
||||
type OverlayUnfocusOptions,
|
||||
renderFakeCursor,
|
||||
type SizeValue,
|
||||
type TUI,
|
||||
type TuiInputListener,
|
||||
@@ -152,3 +192,4 @@ export {
|
||||
visibleWidth,
|
||||
wrapTextWithAnsi,
|
||||
} from "./utils.ts";
|
||||
export type { WheelScrollLines } from "./wheel-scroll.ts";
|
||||
|
||||
@@ -96,11 +96,11 @@ export const TUI_KEYBINDINGS = {
|
||||
description: "Move cursor word right",
|
||||
},
|
||||
"tui.editor.cursorLineStart": {
|
||||
defaultKeys: ["home", "ctrl+home", "ctrl+a"],
|
||||
defaultKeys: ["home", "ctrl+a"],
|
||||
description: "Move to line start",
|
||||
},
|
||||
"tui.editor.cursorLineEnd": {
|
||||
defaultKeys: ["end", "ctrl+end", "ctrl+e"],
|
||||
defaultKeys: ["end", "ctrl+e"],
|
||||
description: "Move to line end",
|
||||
},
|
||||
"tui.editor.jumpForward": {
|
||||
@@ -205,8 +205,8 @@ export const TUI_KEYBINDINGS = {
|
||||
defaultKeys: "escape",
|
||||
description: "Close transcript search",
|
||||
},
|
||||
"tui.altScreen.top": { defaultKeys: "home", description: "Scroll viewport to top" },
|
||||
"tui.altScreen.bottom": { defaultKeys: "end", description: "Scroll viewport to bottom" },
|
||||
"tui.altScreen.top": { defaultKeys: "ctrl+home", description: "Scroll viewport to top" },
|
||||
"tui.altScreen.bottom": { defaultKeys: "ctrl+end", description: "Scroll viewport to bottom" },
|
||||
} as const satisfies KeybindingDefinitions;
|
||||
|
||||
export interface KeybindingConflict {
|
||||
|
||||
92
third_party/pi/packages/tui/src/keys.ts
vendored
92
third_party/pi/packages/tui/src/keys.ts
vendored
@@ -289,6 +289,10 @@ const SYMBOL_KEYS = new Set([
|
||||
"?",
|
||||
]);
|
||||
|
||||
const SHIFTED_SYMBOL_KEYS = new Set("~!@#$%^&*()_+|{}:<>?");
|
||||
|
||||
const MODIFIER_NAMES = ["shift", "alt", "ctrl", "super"] as const;
|
||||
|
||||
const MODIFIERS = {
|
||||
shift: 1,
|
||||
alt: 2,
|
||||
@@ -365,6 +369,17 @@ function normalizeShiftedLetterIdentityCodepoint(codepoint: number, modifier: nu
|
||||
return codepoint;
|
||||
}
|
||||
|
||||
function getLogicalShiftedSymbolCandidate(
|
||||
codepoint: number | undefined,
|
||||
modifier: number,
|
||||
allowedSymbols: ReadonlySet<string>,
|
||||
): { codepoint: number; modifier: number } | undefined {
|
||||
if (codepoint === undefined || (modifier & MODIFIERS.shift) === 0) return undefined;
|
||||
const normalizedCodepoint = normalizeKittyFunctionalCodepoint(codepoint);
|
||||
if (!allowedSymbols.has(String.fromCharCode(normalizedCodepoint))) return undefined;
|
||||
return { codepoint: normalizedCodepoint, modifier: modifier & ~MODIFIERS.shift };
|
||||
}
|
||||
|
||||
const LEGACY_KEY_SEQUENCES = {
|
||||
up: ["\x1b[A", "\x1bOA"],
|
||||
down: ["\x1b[B", "\x1bOB"],
|
||||
@@ -656,9 +671,6 @@ function matchesKittySequence(data: string, expectedCodepoint: number, expectedM
|
||||
const actualMod = parsed.modifier & ~LOCK_MASK;
|
||||
const expectedMod = expectedModifier & ~LOCK_MASK;
|
||||
|
||||
// Check if modifiers match
|
||||
if (actualMod !== expectedMod) return false;
|
||||
|
||||
const normalizedCodepoint = normalizeShiftedLetterIdentityCodepoint(
|
||||
normalizeKittyFunctionalCodepoint(parsed.codepoint),
|
||||
parsed.modifier,
|
||||
@@ -668,8 +680,22 @@ function matchesKittySequence(data: string, expectedCodepoint: number, expectedM
|
||||
expectedModifier,
|
||||
);
|
||||
|
||||
// Primary match: codepoint matches directly after normalizing functional keys
|
||||
if (normalizedCodepoint === normalizedExpectedCodepoint) return true;
|
||||
// Physical match: primary codepoint and every reported modifier match.
|
||||
if (actualMod === expectedMod && normalizedCodepoint === normalizedExpectedCodepoint) return true;
|
||||
|
||||
// Logical match: Kitty reports symbols such as "+" as Shift plus the physical "=" key.
|
||||
// Bindings name the produced symbol, so consume that implicit Shift while preserving
|
||||
// Ctrl, Alt, and Super. The physical "shift+=" spelling remains valid through the match above.
|
||||
const logicalShiftedSymbol = getLogicalShiftedSymbolCandidate(parsed.shiftedKey, actualMod, SYMBOL_KEYS);
|
||||
if (
|
||||
logicalShiftedSymbol &&
|
||||
(logicalShiftedSymbol.modifier & ~LOCK_MASK) === expectedMod &&
|
||||
logicalShiftedSymbol.codepoint === normalizeKittyFunctionalCodepoint(expectedCodepoint)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (actualMod !== expectedMod) return false;
|
||||
|
||||
// Alternate match: use base layout key for non-Latin keyboard layouts.
|
||||
// This allows Ctrl+С (Cyrillic) to match Ctrl+c (Latin) when terminal reports
|
||||
@@ -764,13 +790,19 @@ function isDigitKey(key: string): boolean {
|
||||
}
|
||||
|
||||
function matchesPrintableModifyOtherKeys(data: string, expectedKeycode: number, expectedModifier: number): boolean {
|
||||
if (expectedModifier === 0) return false;
|
||||
const parsed = parseModifyOtherKeysSequence(data);
|
||||
if (!parsed || parsed.modifier !== expectedModifier) return false;
|
||||
return (
|
||||
if (!parsed) return false;
|
||||
const physicalMatch =
|
||||
parsed.modifier === expectedModifier &&
|
||||
normalizeShiftedLetterIdentityCodepoint(parsed.codepoint, parsed.modifier) ===
|
||||
normalizeShiftedLetterIdentityCodepoint(expectedKeycode, expectedModifier)
|
||||
normalizeShiftedLetterIdentityCodepoint(expectedKeycode, expectedModifier);
|
||||
if (physicalMatch) return true;
|
||||
const logicalShiftedSymbol = getLogicalShiftedSymbolCandidate(
|
||||
parsed.codepoint,
|
||||
parsed.modifier,
|
||||
SHIFTED_SYMBOL_KEYS,
|
||||
);
|
||||
return logicalShiftedSymbol?.modifier === expectedModifier && logicalShiftedSymbol.codepoint === expectedKeycode;
|
||||
}
|
||||
|
||||
function formatKeyNameWithModifiers(keyName: string, modifier: number): string | undefined {
|
||||
@@ -788,15 +820,22 @@ function formatKeyNameWithModifiers(keyName: string, modifier: number): string |
|
||||
function parseKeyId(
|
||||
keyId: string,
|
||||
): { key: string; ctrl: boolean; shift: boolean; alt: boolean; super: boolean } | null {
|
||||
const parts = keyId.toLowerCase().split("+");
|
||||
const key = parts[parts.length - 1];
|
||||
if (!key) return null;
|
||||
let key = keyId.toLowerCase();
|
||||
const modifiers = new Set<(typeof MODIFIER_NAMES)[number]>();
|
||||
while (true) {
|
||||
const modifier = MODIFIER_NAMES.find((name) => key.startsWith(`${name}+`));
|
||||
if (!modifier) break;
|
||||
if (modifiers.has(modifier)) return null;
|
||||
modifiers.add(modifier);
|
||||
key = key.slice(modifier.length + 1);
|
||||
}
|
||||
if (!key || (key.includes("+") && key !== "+")) return null;
|
||||
return {
|
||||
key,
|
||||
ctrl: parts.includes("ctrl"),
|
||||
shift: parts.includes("shift"),
|
||||
alt: parts.includes("alt"),
|
||||
super: parts.includes("super"),
|
||||
ctrl: modifiers.has("ctrl"),
|
||||
shift: modifiers.has("shift"),
|
||||
alt: modifiers.has("alt"),
|
||||
super: modifiers.has("super"),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1197,7 +1236,9 @@ export function matchesKey(data: string, keyId: KeyId): boolean {
|
||||
}
|
||||
|
||||
// Check both raw char and Kitty sequence (needed for release events)
|
||||
return data === key || matchesKittySequence(data, codepoint, 0);
|
||||
return (
|
||||
data === key || matchesKittySequence(data, codepoint, 0) || matchesPrintableModifyOtherKeys(data, codepoint, 0)
|
||||
);
|
||||
}
|
||||
|
||||
return false;
|
||||
@@ -1251,12 +1292,25 @@ function formatParsedKey(codepoint: number, modifier: number, baseLayoutKey?: nu
|
||||
export function parseKey(data: string): string | undefined {
|
||||
const kitty = parseKittySequence(data);
|
||||
if (kitty) {
|
||||
return formatParsedKey(kitty.codepoint, kitty.modifier, kitty.baseLayoutKey);
|
||||
const logicalShiftedSymbol = getLogicalShiftedSymbolCandidate(kitty.shiftedKey, kitty.modifier, SYMBOL_KEYS);
|
||||
return formatParsedKey(
|
||||
logicalShiftedSymbol?.codepoint ?? kitty.codepoint,
|
||||
logicalShiftedSymbol?.modifier ?? kitty.modifier,
|
||||
kitty.baseLayoutKey,
|
||||
);
|
||||
}
|
||||
|
||||
const modifyOtherKeys = parseModifyOtherKeysSequence(data);
|
||||
if (modifyOtherKeys) {
|
||||
return formatParsedKey(modifyOtherKeys.codepoint, modifyOtherKeys.modifier);
|
||||
const logicalShiftedSymbol = getLogicalShiftedSymbolCandidate(
|
||||
modifyOtherKeys.codepoint,
|
||||
modifyOtherKeys.modifier,
|
||||
SHIFTED_SYMBOL_KEYS,
|
||||
);
|
||||
return formatParsedKey(
|
||||
logicalShiftedSymbol?.codepoint ?? modifyOtherKeys.codepoint,
|
||||
logicalShiftedSymbol?.modifier ?? modifyOtherKeys.modifier,
|
||||
);
|
||||
}
|
||||
|
||||
// Mode-aware legacy sequences
|
||||
|
||||
146
third_party/pi/packages/tui/src/latex.ts
vendored
146
third_party/pi/packages/tui/src/latex.ts
vendored
@@ -523,6 +523,7 @@ const SPACING_COMMANDS = new Set([
|
||||
]);
|
||||
const NEGATIVE_SPACING_COMMANDS = new Set(["!", "negmedspace", "negthickspace", "negthinspace"]);
|
||||
const NEGATIVE_SPACE = "\u0000";
|
||||
const FONT_SWITCH_COMMANDS = new Set(["bf", "cal", "it", "rm", "sf", "sl", "tt"]);
|
||||
const IGNORED_COMMANDS = new Set([
|
||||
"displaystyle",
|
||||
"limits",
|
||||
@@ -610,10 +611,17 @@ function replaceCharacters(value: string, replacements: Readonly<Record<string,
|
||||
return result;
|
||||
}
|
||||
|
||||
function normalizeScriptValue(value: string): string {
|
||||
return value.trim().replace(/\s*([=+-])\s*/g, "$1");
|
||||
}
|
||||
|
||||
function formatUnicodeScript(value: string, kind: "sub" | "sup"): string | undefined {
|
||||
return replaceCharacters(normalizeScriptValue(value), kind === "sub" ? SUBSCRIPTS : SUPERSCRIPTS);
|
||||
}
|
||||
|
||||
function formatScript(value: string, kind: "sub" | "sup"): string {
|
||||
value = value.trim();
|
||||
const replacements = kind === "sub" ? SUBSCRIPTS : SUPERSCRIPTS;
|
||||
const unicode = replaceCharacters(value.replace(/\s*([=+-])\s*/g, "$1"), replacements);
|
||||
value = normalizeScriptValue(value);
|
||||
const unicode = formatUnicodeScript(value, kind);
|
||||
if (unicode !== undefined) {
|
||||
return unicode;
|
||||
}
|
||||
@@ -669,13 +677,19 @@ interface OperatorNode {
|
||||
upper?: string;
|
||||
}
|
||||
|
||||
interface ScriptNode {
|
||||
type: "script";
|
||||
lower?: string;
|
||||
upper?: string;
|
||||
}
|
||||
|
||||
interface MatrixNode {
|
||||
type: "matrix";
|
||||
lines: string[];
|
||||
baseline: number;
|
||||
}
|
||||
|
||||
type LayoutNode = FractionNode | OperatorNode | MatrixNode;
|
||||
type LayoutNode = FractionNode | OperatorNode | ScriptNode | MatrixNode;
|
||||
|
||||
interface Layout {
|
||||
lines: string[];
|
||||
@@ -778,6 +792,19 @@ function renderLayout(source: string, nodes: readonly LayoutNode[]): Layout {
|
||||
width: contentWidth + 1,
|
||||
baseline: node.upper === undefined ? 0 : 1,
|
||||
});
|
||||
} else if (node.type === "script") {
|
||||
const upper = node.upper === undefined ? undefined : renderLayout(node.upper, nodes);
|
||||
const lower = node.lower === undefined ? undefined : renderLayout(node.lower, nodes);
|
||||
const width = Math.max(upper?.width ?? 0, lower?.width ?? 0);
|
||||
layouts.push({
|
||||
lines: [
|
||||
...(upper?.lines.map((line) => padLayoutLine(line, width)) ?? []),
|
||||
" ".repeat(width),
|
||||
...(lower?.lines.map((line) => padLayoutLine(line, width)) ?? []),
|
||||
],
|
||||
width,
|
||||
baseline: upper?.lines.length ?? 0,
|
||||
});
|
||||
} else {
|
||||
const width = Math.max(0, ...node.lines.map((line) => visibleWidth(line)));
|
||||
layouts.push({
|
||||
@@ -815,6 +842,7 @@ class LatexParser {
|
||||
private position = 0;
|
||||
private supported = true;
|
||||
private stackFractions = true;
|
||||
private scriptDepth = 0;
|
||||
|
||||
constructor(source: string, layoutNodes: LayoutNode[], display: boolean) {
|
||||
this.source = source;
|
||||
@@ -866,7 +894,7 @@ class LatexParser {
|
||||
if (character === "^" || character === "_") {
|
||||
this.position++;
|
||||
result = result.trimEnd();
|
||||
const script = formatScript(this.parseRequiredArgument(false), character === "_" ? "sub" : "sup");
|
||||
const script = this.parseScripts(character);
|
||||
if (result.endsWith(NAMED_OPERATOR_END)) {
|
||||
result = `${result.slice(0, -NAMED_OPERATOR_END.length)}${script}${NAMED_OPERATOR_END}`;
|
||||
} else {
|
||||
@@ -918,6 +946,64 @@ class LatexParser {
|
||||
return result;
|
||||
}
|
||||
|
||||
private parseScripts(initialMarker: "^" | "_"): string {
|
||||
const scripts: { sub?: string; sup?: string } = {};
|
||||
const order: Array<"sub" | "sup"> = [];
|
||||
const parse = (marker: "^" | "_"): void => {
|
||||
const kind = marker === "_" ? "sub" : "sup";
|
||||
this.scriptDepth++;
|
||||
try {
|
||||
scripts[kind] = this.parseRequiredArgument(false);
|
||||
} finally {
|
||||
this.scriptDepth--;
|
||||
}
|
||||
order.push(kind);
|
||||
};
|
||||
|
||||
parse(initialMarker);
|
||||
let nextPosition = this.position;
|
||||
while (nextPosition < this.source.length && /\s/.test(this.source[nextPosition] ?? "")) {
|
||||
nextPosition++;
|
||||
}
|
||||
const nextMarker = this.source[nextPosition];
|
||||
if ((nextMarker === "^" || nextMarker === "_") && nextMarker !== initialMarker) {
|
||||
this.position = nextPosition + 1;
|
||||
parse(nextMarker);
|
||||
}
|
||||
|
||||
const subUnicode = scripts.sub === undefined ? undefined : formatUnicodeScript(scripts.sub, "sub");
|
||||
const supUnicode = scripts.sup === undefined ? undefined : formatUnicodeScript(scripts.sup, "sup");
|
||||
const canUseLayout = ![scripts.sub, scripts.sup].some(
|
||||
(value) =>
|
||||
value !== undefined &&
|
||||
(value.includes("/") ||
|
||||
(!value.includes(LAYOUT_MARKER_START) && Array.from(value).length > 1 && !/[A-Z*∗]/.test(value))),
|
||||
);
|
||||
const needsLayout =
|
||||
this.display &&
|
||||
canUseLayout &&
|
||||
(this.scriptDepth > 0 ||
|
||||
(scripts.sub !== undefined && subUnicode === undefined) ||
|
||||
(scripts.sup !== undefined && supUnicode === undefined));
|
||||
if (!needsLayout) {
|
||||
return order
|
||||
.map((kind) =>
|
||||
kind === "sub"
|
||||
? (subUnicode ?? formatScript(scripts.sub ?? "", kind))
|
||||
: (supUnicode ?? formatScript(scripts.sup ?? "", kind)),
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
const index =
|
||||
this.layoutNodes.push({
|
||||
type: "script",
|
||||
lower: scripts.sub === undefined ? undefined : normalizeOutput(scripts.sub),
|
||||
upper: scripts.sup === undefined ? undefined : normalizeOutput(scripts.sup),
|
||||
}) - 1;
|
||||
return `${LAYOUT_MARKER_START}${index}${LAYOUT_MARKER_END}`;
|
||||
}
|
||||
|
||||
private parseWhitespace(): string {
|
||||
while (this.position < this.source.length && /\s/.test(this.source[this.position] ?? "")) {
|
||||
this.position++;
|
||||
@@ -961,6 +1047,12 @@ class LatexParser {
|
||||
if (NEGATIVE_SPACING_COMMANDS.has(command)) {
|
||||
return NEGATIVE_SPACE;
|
||||
}
|
||||
if (FONT_SWITCH_COMMANDS.has(command)) {
|
||||
while (this.position < this.source.length && /\s/.test(this.source[this.position] ?? "")) {
|
||||
this.position++;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
if (IGNORED_COMMANDS.has(command)) {
|
||||
return "";
|
||||
}
|
||||
@@ -1284,18 +1376,7 @@ class LatexParser {
|
||||
}
|
||||
|
||||
if (environment === "cases" || environment === "cases*") {
|
||||
const rows = this.splitEnvironmentRows(body)
|
||||
.map((row) => row.split("&").map((cell) => this.renderNested(cell, false).trim()))
|
||||
.filter((row) => row.some(Boolean));
|
||||
return rows
|
||||
.map((row, index) => {
|
||||
const value = (row[0] ?? "").replace(/,\s*$/, "");
|
||||
const condition = row[1] ?? "";
|
||||
const delimiter = index === 0 ? "⎧" : index === rows.length - 1 ? "⎩" : "⎨";
|
||||
const conditionPrefix = /^(?:if|when|for|otherwise)\b/i.test(condition) ? " " : " if ";
|
||||
return `${delimiter} ${value}${condition ? `${conditionPrefix}${condition}` : ""}`;
|
||||
})
|
||||
.join("\n");
|
||||
return this.renderCases(body);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -1309,6 +1390,37 @@ class LatexParser {
|
||||
return body;
|
||||
}
|
||||
|
||||
private renderCases(body: string): string {
|
||||
const rows = this.splitEnvironmentRows(body)
|
||||
.map((row) => row.split("&").map((cell) => this.renderNested(cell, false).trim()))
|
||||
.filter((row) => row.some(Boolean));
|
||||
const valueWidth = Math.max(0, ...rows.map((row) => visibleWidth((row[0] ?? "").replace(/,\s*$/, ""))));
|
||||
const contents = rows.map((row) => {
|
||||
const value = (row[0] ?? "").replace(/,\s*$/, "");
|
||||
const condition = row[1] ?? "";
|
||||
if (!condition) {
|
||||
return value;
|
||||
}
|
||||
const conditionPrefix = /^(?:if|when|for|otherwise)\b/i.test(condition) ? " " : " if ";
|
||||
return `${value}${PROTECTED_SPACE.repeat(valueWidth - visibleWidth(value))}${conditionPrefix}${condition}`;
|
||||
});
|
||||
if (contents.length <= 1) {
|
||||
return contents.length === 0 ? "" : `⎧ ${contents[0]}`;
|
||||
}
|
||||
|
||||
const middle = Math.floor(contents.length / 2);
|
||||
const visualRows: Array<string | undefined> = [...contents];
|
||||
if (contents.length % 2 === 0) {
|
||||
visualRows.splice(middle, 0, undefined);
|
||||
}
|
||||
const lines = visualRows.map((content, index) => {
|
||||
const delimiter = index === 0 ? "⎧" : index === visualRows.length - 1 ? "⎩" : "⎨";
|
||||
return content === undefined ? delimiter : `${delimiter} ${content}`;
|
||||
});
|
||||
const index = this.layoutNodes.push({ type: "matrix", lines, baseline: middle }) - 1;
|
||||
return `${LAYOUT_MARKER_START}${index}${LAYOUT_MARKER_END}`;
|
||||
}
|
||||
|
||||
private renderMatrix(environment: string, body: string): string {
|
||||
const matrix = this.splitEnvironmentRows(body)
|
||||
.map((row) => row.split("&").map((cell) => this.renderNested(cell, false).trim()))
|
||||
|
||||
@@ -1,56 +1,10 @@
|
||||
import { createRequire } from "node:module";
|
||||
import * as path from "node:path";
|
||||
import { getNativeModuleCandidates } from "./native-module-path.ts";
|
||||
import { getNativePlatformHelper, type ModifierKey } from "./native-platform.ts";
|
||||
|
||||
const cjsRequire = createRequire(import.meta.url);
|
||||
|
||||
export type ModifierKey = "shift" | "command" | "control" | "option";
|
||||
|
||||
type NativeModifiersHelper = {
|
||||
isModifierPressed: (name: ModifierKey) => boolean;
|
||||
};
|
||||
|
||||
let nativeModifiersHelper: NativeModifiersHelper | null | undefined;
|
||||
|
||||
function isNativeModifiersHelper(value: unknown): value is NativeModifiersHelper {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const candidate = (value as { isModifierPressed?: unknown }).isModifierPressed;
|
||||
return typeof candidate === "function";
|
||||
}
|
||||
|
||||
function loadNativeModifiersHelper(): NativeModifiersHelper | undefined {
|
||||
if (nativeModifiersHelper !== undefined) return nativeModifiersHelper ?? undefined;
|
||||
nativeModifiersHelper = null;
|
||||
const arch = process.arch;
|
||||
if (arch !== "x64" && arch !== "arm64") return undefined;
|
||||
|
||||
let nativePath: string;
|
||||
if (process.platform === "darwin") {
|
||||
nativePath = path.join("native", "darwin", "prebuilds", `darwin-${arch}`, "darwin-modifiers.node");
|
||||
} else if (process.platform === "win32") {
|
||||
nativePath = path.join("native", "win32", "prebuilds", `win32-${arch}`, "win32-console-mode.node");
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const modulePath of getNativeModuleCandidates(nativePath)) {
|
||||
try {
|
||||
const helper = cjsRequire(modulePath) as unknown;
|
||||
if (isNativeModifiersHelper(helper)) {
|
||||
nativeModifiersHelper = helper;
|
||||
return helper;
|
||||
}
|
||||
} catch {
|
||||
// Try the next possible packaging location.
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
export type { ModifierKey } from "./native-platform.ts";
|
||||
|
||||
export function isNativeModifierPressed(key: ModifierKey): boolean {
|
||||
const helper = loadNativeModifiersHelper();
|
||||
if (!helper) return false;
|
||||
const helper = getNativePlatformHelper();
|
||||
if (!helper?.isModifierPressed) return false;
|
||||
try {
|
||||
return helper.isModifierPressed(key) === true;
|
||||
} catch {
|
||||
|
||||
65
third_party/pi/packages/tui/src/native-platform.ts
vendored
Normal file
65
third_party/pi/packages/tui/src/native-platform.ts
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
import { createRequire } from "node:module";
|
||||
import * as path from "node:path";
|
||||
import { getNativeModuleCandidates } from "./native-module-path.ts";
|
||||
|
||||
const cjsRequire = createRequire(import.meta.url);
|
||||
|
||||
export type ModifierKey = "shift" | "command" | "control" | "option";
|
||||
|
||||
export interface NativeClipboard {
|
||||
/** Undefined means unavailable, null means no text; transfer failures reject. */
|
||||
getText(): Promise<string | null | undefined>;
|
||||
/** Undefined means unavailable, null means no image; transfer failures reject. */
|
||||
getImage(): Promise<Uint8Array | null | undefined>;
|
||||
/** POSIX paths of file URLs on the clipboard. Undefined means unsupported, null means no files. */
|
||||
getFilePaths?(): Promise<string[] | null | undefined>;
|
||||
/** Linux uses command-line tools to retain clipboard ownership instead. */
|
||||
setText?(text: string): Promise<void>;
|
||||
}
|
||||
|
||||
type NativePlatformHelper = NativeClipboard & {
|
||||
enableVirtualTerminalInput?: () => boolean;
|
||||
isModifierPressed?: (name: ModifierKey) => boolean;
|
||||
};
|
||||
|
||||
// Cache module loading, not display availability: a disconnected display can recover.
|
||||
const helpers = new Map<string, NativePlatformHelper | undefined>();
|
||||
|
||||
function loadNativePlatformHelper(platform: string, suffix = ""): NativePlatformHelper | undefined {
|
||||
const arch = process.arch;
|
||||
if (arch !== "x64" && arch !== "arm64") return undefined;
|
||||
const nativePath = path.join(
|
||||
"native",
|
||||
platform,
|
||||
"prebuilds",
|
||||
`${platform}-${arch}`,
|
||||
`${platform}-platform${suffix}.node`,
|
||||
);
|
||||
if (helpers.has(nativePath)) return helpers.get(nativePath);
|
||||
|
||||
for (const modulePath of getNativeModuleCandidates(nativePath)) {
|
||||
try {
|
||||
const helper = cjsRequire(modulePath) as Partial<NativePlatformHelper> | null;
|
||||
if (typeof helper?.getText === "function" && typeof helper.getImage === "function") {
|
||||
helpers.set(nativePath, helper as NativePlatformHelper);
|
||||
return helper as NativePlatformHelper;
|
||||
}
|
||||
} catch {
|
||||
// Try the next possible packaging location.
|
||||
}
|
||||
}
|
||||
helpers.set(nativePath, undefined);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function getNativePlatformHelper(): NativePlatformHelper | undefined {
|
||||
if (process.platform !== "darwin" && process.platform !== "win32") return undefined;
|
||||
return loadNativePlatformHelper(process.platform);
|
||||
}
|
||||
|
||||
/** Load a clipboard helper without opening the display until a read is requested. */
|
||||
export function getNativeClipboard(): NativeClipboard | undefined {
|
||||
if (process.platform !== "linux") return getNativePlatformHelper();
|
||||
if (!process.env.DISPLAY) return undefined;
|
||||
return loadNativePlatformHelper("linux", "-x11");
|
||||
}
|
||||
233
third_party/pi/packages/tui/src/oklab.ts
vendored
Normal file
233
third_party/pi/packages/tui/src/oklab.ts
vendored
Normal file
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Oklab and OKHSL <-> sRGB conversion. `colors.ts` builds its OKLCH, OKHSL, and color mixing on it.
|
||||
*
|
||||
* Oklab and OKHSL are Björn Ottosson's color spaces; OKHSL's saturation is relative to the sRGB gamut at
|
||||
* each hue and lightness. This is a port of his reference implementation (https://bottosson.github.io/posts/colorpicker/),
|
||||
* Copyright (c) 2021 Björn Ottosson, used under the MIT license:
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
* associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||
* without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
* following conditions: The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
|
||||
* KIND, EXPRESS OR IMPLIED.
|
||||
*/
|
||||
|
||||
import type { RgbColor } from "./terminal-colors.ts";
|
||||
|
||||
type Vector = [number, number, number];
|
||||
type Matrix = [Vector, Vector, Vector];
|
||||
|
||||
const multiply = (m: Matrix, [x, y, z]: Vector): Vector =>
|
||||
m.map((row) => row[0] * x + row[1] * y + row[2] * z) as Vector;
|
||||
|
||||
// ============================================================================
|
||||
// OKHSL <-> sRGB
|
||||
// ============================================================================
|
||||
|
||||
const LINEAR_SRGB_TO_LMS: Matrix = [
|
||||
[0.4122214694707629, 0.5363325372617349, 0.0514459932675022],
|
||||
[0.2119034958178251, 0.6806995506452344, 0.1073969535369405],
|
||||
[0.0883024591900564, 0.2817188391361215, 0.6299787016738222],
|
||||
];
|
||||
const LMS_TO_LAB: Matrix = [
|
||||
[0.210454268309314, 0.793617774702305, -0.0040720430116193],
|
||||
[1.9779985324311684, -2.42859224204858, 0.450593709617411],
|
||||
[0.0259040424655478, 0.7827717124575296, -0.8086757549230774],
|
||||
];
|
||||
const LAB_TO_LMS: Matrix = [
|
||||
[1, 0.3963377773761749, 0.2158037573099136],
|
||||
[1, -0.1055613458156586, -0.0638541728258133],
|
||||
[1, -0.0894841775298119, -1.2914855480194092],
|
||||
];
|
||||
const LMS_TO_LINEAR_SRGB: Matrix = [
|
||||
[4.0767416360759583, -3.3077115392580629, 0.2309699031821043],
|
||||
[-1.2684379732850315, 2.6097573492876882, -0.341319376002657],
|
||||
[-0.0041960761386756, -0.7034186179359362, 1.7076146940746117],
|
||||
];
|
||||
/**
|
||||
* Per sRGB channel (red, green, blue): the (a, b) half-plane where that channel clips
|
||||
* first, and the polynomial approximating the maximum saturation there.
|
||||
*/
|
||||
const SATURATION_FIT: [[number, number], number[]][] = [
|
||||
[
|
||||
[-1.8817031, -0.80936501],
|
||||
[1.19086277, 1.76576728, 0.59662641, 0.75515197, 0.56771245],
|
||||
],
|
||||
[
|
||||
[1.8144408, -1.19445267],
|
||||
[0.73956515, -0.45954404, 0.08285427, 0.12541073, -0.14503204],
|
||||
],
|
||||
[
|
||||
[0.13110758, 1.81333971],
|
||||
[1.35733652, -0.00915799, -1.1513021, -0.50559606, 0.00692167],
|
||||
],
|
||||
];
|
||||
const K1 = 0.206;
|
||||
const K2 = 0.03;
|
||||
const K3 = (1 + K1) / (1 + K2);
|
||||
|
||||
/** Oklab lightness to OKHSL lightness. */
|
||||
export const oklabToOkhslLightness = (x: number): number =>
|
||||
0.5 * (K3 * x - K1 + Math.sqrt((K3 * x - K1) ** 2 + 4 * K2 * K3 * x));
|
||||
/** OKHSL lightness to Oklab lightness. */
|
||||
const okhslToOklabLightness = (x: number): number => (x * x + K1 * x) / (K3 * (x + K2));
|
||||
|
||||
/** sRGB transfer function: linear to encoded channel, both 0-1. */
|
||||
const linearToSrgb = (value: number): number =>
|
||||
value > 0.0031308 ? 1.055 * value ** (1 / 2.4) - 0.055 : 12.92 * value;
|
||||
/** Inverse sRGB transfer function: encoded to linear channel, both 0-1. */
|
||||
const srgbToLinear = (value: number): number => (value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4);
|
||||
|
||||
/** Oklab [L, a, b] to linear sRGB [r, g, b] (0-1, may leave the gamut). */
|
||||
export function oklabToLinearSrgb(lab: Vector): Vector {
|
||||
return multiply(LMS_TO_LINEAR_SRGB, multiply(LAB_TO_LMS, lab).map((value) => value ** 3) as Vector);
|
||||
}
|
||||
|
||||
/** Linear sRGB [r, g, b] (0-1) to Oklab [L, a, b]. */
|
||||
function linearSrgbToOklab(rgb: Vector): Vector {
|
||||
return multiply(LMS_TO_LAB, multiply(LINEAR_SRGB_TO_LMS, rgb).map(Math.cbrt) as Vector);
|
||||
}
|
||||
|
||||
/** sRGB channels (0-255) to Oklab [L, a, b]. */
|
||||
export function rgbToOklab({ r, g, b }: RgbColor): Vector {
|
||||
return linearSrgbToOklab([r / 255, g / 255, b / 255].map(srgbToLinear) as Vector);
|
||||
}
|
||||
|
||||
/** Linear sRGB [r, g, b] to sRGB channels (0-255, rounded), clipping out-of-gamut channels. */
|
||||
export function linearSrgbToRgb(linear: Vector): RgbColor {
|
||||
const [r, g, b] = linear.map((value) => Math.round(Math.min(1, Math.max(0, linearToSrgb(value))) * 255));
|
||||
return { r, g, b };
|
||||
}
|
||||
|
||||
/** Rate of change of each cube-root LMS component along a chroma direction (a, b). */
|
||||
function lmsSlopes(a: number, b: number): Vector {
|
||||
return [LAB_TO_LMS[0], LAB_TO_LMS[1], LAB_TO_LMS[2]].map((row) => row[1] * a + row[2] * b) as Vector;
|
||||
}
|
||||
|
||||
/** Largest saturation (C/L) inside sRGB for hue (a, b): polynomial fit plus one Halley step. */
|
||||
function maxSaturation(a: number, b: number): number {
|
||||
const channel = SATURATION_FIT.findIndex(([[x, y]], index) => index === 2 || x * a + y * b > 1);
|
||||
const [k0, k1, k2, k3, k4] = SATURATION_FIT[channel][1];
|
||||
const weights = LMS_TO_LINEAR_SRGB[channel];
|
||||
const saturation = k0 + k1 * a + k2 * b + k3 * a * a + k4 * a * b;
|
||||
|
||||
const slopes = lmsSlopes(a, b);
|
||||
const base = slopes.map((k) => 1 + saturation * k);
|
||||
const dot = (values: number[]) => values.reduce((sum, value, index) => sum + weights[index] * value, 0);
|
||||
const f = dot(base.map((value) => value ** 3));
|
||||
const f1 = dot(base.map((value, index) => 3 * slopes[index] * value ** 2));
|
||||
const f2 = dot(base.map((value, index) => 6 * slopes[index] ** 2 * value));
|
||||
return saturation - (f * f1) / (f1 * f1 - 0.5 * f * f2);
|
||||
}
|
||||
|
||||
/** Oklab lightness and chroma of the most saturated sRGB color of hue (a, b). */
|
||||
function cusp(a: number, b: number): [number, number] {
|
||||
const saturation = maxSaturation(a, b);
|
||||
const lightness = Math.cbrt(1 / Math.max(...oklabToLinearSrgb([1, saturation * a, saturation * b])));
|
||||
return [lightness, lightness * saturation];
|
||||
}
|
||||
|
||||
/** Chroma where the constant-lightness line at `lightness` leaves the sRGB gamut. */
|
||||
function maxChroma(a: number, b: number, lightness: number, [cuspL, cuspC]: [number, number]): number {
|
||||
if (lightness <= cuspL) return (cuspC * lightness) / cuspL;
|
||||
// Upper half: triangle edge, then one Halley step against each channel reaching 1.
|
||||
const t = (cuspC * (lightness - 1)) / (cuspL - 1);
|
||||
const slopes = lmsSlopes(a, b);
|
||||
const lms = slopes.map((k) => lightness + t * k);
|
||||
const cubes = lms.map((value) => value ** 3);
|
||||
const first = lms.map((value, index) => 3 * slopes[index] * value ** 2);
|
||||
const second = lms.map((value, index) => 6 * slopes[index] ** 2 * value);
|
||||
const dot = (row: Vector, values: number[]) => row[0] * values[0] + row[1] * values[1] + row[2] * values[2];
|
||||
const steps = LMS_TO_LINEAR_SRGB.map((row) => {
|
||||
const f = dot(row, cubes) - 1;
|
||||
const f1 = dot(row, first);
|
||||
const f2 = dot(row, second);
|
||||
const u = f1 / (f1 * f1 - 0.5 * f * f2);
|
||||
return u >= 0 ? -f * u : Number.MAX_VALUE;
|
||||
});
|
||||
return t + Math.min(...steps);
|
||||
}
|
||||
|
||||
/** OKHSL's chroma reference points at lightness L and hue (a, b): [c0, cMid, cMax]. */
|
||||
function chromaStops(L: number, a: number, b: number): [number, number, number] {
|
||||
const peak = cusp(a, b);
|
||||
const cMax = maxChroma(a, b, L, peak);
|
||||
const k = cMax / Math.min(L * (peak[1] / peak[0]), (1 - L) * (peak[1] / (1 - peak[0])));
|
||||
const midS =
|
||||
0.11516993 +
|
||||
1 /
|
||||
(7.4477897 +
|
||||
4.1590124 * b +
|
||||
a *
|
||||
(-2.19557347 +
|
||||
1.75198401 * b +
|
||||
a * (-2.13704948 - 10.02301043 * b + a * (-4.24894561 + 5.38770819 * b + 4.69891013 * a))));
|
||||
const midT =
|
||||
0.11239642 +
|
||||
1 /
|
||||
(1.6132032 -
|
||||
0.68124379 * b +
|
||||
a *
|
||||
(0.40370612 +
|
||||
0.90148123 * b +
|
||||
a * (-0.27087943 + 0.6122399 * b + a * (0.00299215 - 0.45399568 * b - 0.14661872 * a))));
|
||||
const cMid = 0.9 * k * Math.sqrt(Math.sqrt(1 / (1 / (L * midS) ** 4 + 1 / ((1 - L) * midT) ** 4)));
|
||||
const c0 = Math.sqrt(1 / (1 / (L * 0.4) ** 2 + 1 / ((1 - L) * 0.8) ** 2));
|
||||
return [c0, cMid, cMax];
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert OKHSL to sRGB channels (0-255, rounded), clipping out-of-gamut channels.
|
||||
* @param hue Hue in degrees.
|
||||
* @param saturation Saturation, 0-1.
|
||||
* @param lightness Lightness, 0-1.
|
||||
*/
|
||||
export function okhslToRgb(hue: number, saturation: number, lightness: number): RgbColor {
|
||||
const L = okhslToOklabLightness(lightness);
|
||||
let lab: Vector = [L, 0, 0];
|
||||
if (L > 0 && L < 1 && saturation > 0) {
|
||||
const angle = (2 * Math.PI * (((hue % 360) + 360) % 360)) / 360;
|
||||
const a = Math.cos(angle);
|
||||
const b = Math.sin(angle);
|
||||
const [c0, cMid, cMax] = chromaStops(L, a, b);
|
||||
// Chroma rises from 0 through cMid at s = 0.8 to cMax at s = 1.
|
||||
let chroma: number;
|
||||
if (saturation < 0.8) {
|
||||
const t = 1.25 * saturation;
|
||||
const k1 = 0.8 * c0;
|
||||
chroma = (t * k1) / (1 - (1 - k1 / cMid) * t);
|
||||
} else {
|
||||
const t = 5 * (saturation - 0.8);
|
||||
const k1 = (0.2 * cMid ** 2 * 1.25 ** 2) / c0;
|
||||
chroma = cMid + (t * k1) / (1 - (1 - k1 / (cMax - cMid)) * t);
|
||||
}
|
||||
lab = [L, chroma * a, chroma * b];
|
||||
}
|
||||
return linearSrgbToRgb(oklabToLinearSrgb(lab));
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert sRGB channels (0-255) to OKHSL.
|
||||
* @returns Hue `h` in degrees (0 for grays), saturation `s` and lightness `l` 0-1.
|
||||
*/
|
||||
export function rgbToOkhsl(rgb: RgbColor): { h: number; s: number; l: number } {
|
||||
const [L, labA, labB] = rgbToOklab(rgb);
|
||||
const chroma = Math.hypot(labA, labB);
|
||||
const lightness = oklabToOkhslLightness(L);
|
||||
if (chroma < 1e-9 || lightness <= 0 || lightness >= 1) return { h: 0, s: 0, l: lightness };
|
||||
|
||||
const hue = ((Math.atan2(labB, labA) * 180) / Math.PI + 360) % 360;
|
||||
const [c0, cMid, cMax] = chromaStops(L, labA / chroma, labB / chroma);
|
||||
let saturation: number;
|
||||
if (chroma < cMid) {
|
||||
const k1 = 0.8 * c0;
|
||||
saturation = 0.8 * (chroma / (k1 + (1 - k1 / cMid) * chroma));
|
||||
} else {
|
||||
const k1 = (0.2 * cMid ** 2 * 1.25 ** 2) / c0;
|
||||
const offset = chroma - cMid;
|
||||
saturation = 0.8 + 0.2 * (offset / (k1 + (1 - k1 / (cMax - cMid)) * offset));
|
||||
}
|
||||
return { h: hue, s: Math.min(1, Math.max(0, saturation)), l: lightness };
|
||||
}
|
||||
53
third_party/pi/packages/tui/src/program-status.ts
vendored
Normal file
53
third_party/pi/packages/tui/src/program-status.ts
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Program Status Protocol (OSC 7501): a program tells the terminal whether it is idle, working,
|
||||
* blocked on the user, done, or failed. Only the root record is supported.
|
||||
*
|
||||
* Spec: https://www.superlogical.com/rex/docs/build/program-status
|
||||
*/
|
||||
|
||||
export interface ProgramStatus {
|
||||
/** `clear` removes the status instead of reporting one. */
|
||||
state: "idle" | "working" | "blocked" | "done" | "error" | "clear";
|
||||
/** Stable program name, `[A-Za-z0-9_.+-]{1,32}`. Other values are omitted. */
|
||||
app?: string;
|
||||
/** What a blocked program waits for. Omitted for other states. */
|
||||
kind?: "permission" | "question" | "auth";
|
||||
/** One human-readable line. Control characters become spaces; longer text is cut to the spec limit. */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/** Feature detection query. A supporting terminal replies with the same body. */
|
||||
export const PROGRAM_STATUS_QUERY = "\x1b]7501;?\x1b\\";
|
||||
|
||||
/** The reply to {@link PROGRAM_STATUS_QUERY}. Later spec revisions may add pairs after the `?`. */
|
||||
export function isProgramStatusReply(sequence: string): boolean {
|
||||
return /^\x1b\]7501;\?[^\x07\x1b]*(?:\x07|\x1b\\)$/.test(sequence);
|
||||
}
|
||||
|
||||
const APP_PATTERN = /^[A-Za-z0-9_.+-]{1,32}$/;
|
||||
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]+/g;
|
||||
/** Decoded `msg` limit. Its base64 encoding stays under the 2732-byte encoded limit. */
|
||||
const MAX_MESSAGE_BYTES = 2048;
|
||||
|
||||
function truncateUtf8(text: string, maxBytes: number): string {
|
||||
if (Buffer.byteLength(text, "utf8") <= maxBytes) return text;
|
||||
let bytes = 0;
|
||||
let end = 0;
|
||||
for (const char of text) {
|
||||
const size = Buffer.byteLength(char, "utf8");
|
||||
if (bytes + size > maxBytes) break;
|
||||
bytes += size;
|
||||
end += char.length;
|
||||
}
|
||||
return text.slice(0, end);
|
||||
}
|
||||
|
||||
/** Encode a status report. Terminals discard reports whose text contains control characters, so they are replaced. */
|
||||
export function formatProgramStatus(status: ProgramStatus): string {
|
||||
const pairs = [`state=${status.state}`];
|
||||
if (status.app !== undefined && APP_PATTERN.test(status.app)) pairs.push(`app=${status.app}`);
|
||||
if (status.state === "blocked" && status.kind) pairs.push(`kind=${status.kind}`);
|
||||
const message = truncateUtf8((status.message ?? "").replace(CONTROL_CHARACTERS, " ").trim(), MAX_MESSAGE_BYTES);
|
||||
if (message) pairs.push(`msg=${Buffer.from(message, "utf8").toString("base64")}`);
|
||||
return `\x1b]7501;${pairs.join(":")}\x1b\\`;
|
||||
}
|
||||
42
third_party/pi/packages/tui/src/stdin-buffer.ts
vendored
42
third_party/pi/packages/tui/src/stdin-buffer.ts
vendored
@@ -24,6 +24,13 @@ const DEFAULT_SEQUENCE_TIMEOUT_MS = 50;
|
||||
const DEFAULT_ESCAPE_TIMEOUT_MS = 10;
|
||||
const BRACKETED_PASTE_START = "\x1b[200~";
|
||||
const BRACKETED_PASTE_END = "\x1b[201~";
|
||||
// Color and program-status OSC replies, XTVersion, and Kitty graphics replies.
|
||||
// Until their header is complete, the input may be batched legacy Alt keystrokes.
|
||||
const TERMINAL_REPLY_HEADER_PATTERN = /^\x1b(?:\](?:1[01];|4;\d{1,3};|7501;\?)|P>\||_G(?:[a-z]=\d+,)*[a-z]=\d+;)/;
|
||||
|
||||
function isControlString(data: string): boolean {
|
||||
return data.startsWith(`${ESC}]`) || data.startsWith(`${ESC}P`) || data.startsWith(`${ESC}_`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a string is a complete escape sequence or needs more data
|
||||
@@ -200,9 +207,24 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain
|
||||
|
||||
// Try to extract a sequence starting at this position
|
||||
if (remaining.startsWith(ESC)) {
|
||||
const terminalReply = isControlString(remaining) && TERMINAL_REPLY_HEADER_PATTERN.test(remaining);
|
||||
// Find the end of this escape sequence
|
||||
let seqEnd = 1;
|
||||
while (seqEnd <= remaining.length) {
|
||||
if (terminalReply && seqEnd > 2) {
|
||||
const char = remaining[seqEnd - 1];
|
||||
if (char === "\x18" || char === "\x1a") {
|
||||
// CAN and SUB cancel the string, not subsequent keyboard input.
|
||||
pos += seqEnd;
|
||||
break;
|
||||
}
|
||||
if (char === ESC && seqEnd < remaining.length && remaining[seqEnd] !== "\\") {
|
||||
// A new escape sequence aborts an unterminated reply. Keep a
|
||||
// trailing ESC buffered until we can distinguish it from ST.
|
||||
pos += seqEnd - 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const candidate = remaining.slice(0, seqEnd);
|
||||
const status = isCompleteSequence(candidate);
|
||||
|
||||
@@ -258,8 +280,10 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain
|
||||
|
||||
export type StdinBufferOptions = {
|
||||
/**
|
||||
* Maximum time to wait for an incomplete sequence such as CSI or mouse
|
||||
* (default: 50ms).
|
||||
* Maximum time to wait for an incomplete keyboard or mouse sequence
|
||||
* (default: 50ms). Strings with a recognized terminal reply header wait for
|
||||
* their terminator instead, so delayed replies cannot become keypresses.
|
||||
* Ambiguous legacy Alt-key input still uses this timeout.
|
||||
*/
|
||||
timeout?: number;
|
||||
/**
|
||||
@@ -385,12 +409,24 @@ export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
|
||||
}
|
||||
|
||||
if (this.buffer.length > 0) {
|
||||
// Only a recognizable reply header establishes a delimited string.
|
||||
// Flushing it would turn a delayed tail into text and an OSC's BEL
|
||||
// terminator into Ctrl+G (the external editor shortcut). Other input,
|
||||
// including Alt+] followed by text, must retain bounded recovery.
|
||||
if (TERMINAL_REPLY_HEADER_PATTERN.test(this.buffer)) return;
|
||||
const timeoutMs = this.buffer === ESC ? this.escapeTimeoutMs : this.timeoutMs;
|
||||
this.timeout = setTimeout(() => {
|
||||
const flushed = this.flush();
|
||||
|
||||
for (const sequence of flushed) {
|
||||
this.emitDataSequence(sequence);
|
||||
if (sequence.length > 2 && isControlString(sequence)) {
|
||||
// Recover the Alt key and reparse its tail so batched text,
|
||||
// Enter, and control keys are not swallowed as one event.
|
||||
this.emitDataSequence(sequence.slice(0, 2));
|
||||
this.process(sequence.slice(2));
|
||||
} else {
|
||||
this.emitDataSequence(sequence);
|
||||
}
|
||||
}
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,16 @@ export interface RgbColor {
|
||||
|
||||
export type TerminalColorScheme = "dark" | "light";
|
||||
|
||||
/** Colors the terminal reports for its current theme. */
|
||||
export interface TerminalColors {
|
||||
/** Default foreground (OSC 10). */
|
||||
foreground?: RgbColor;
|
||||
/** Default background (OSC 11). */
|
||||
background?: RgbColor;
|
||||
/** ANSI colors 0-15 (OSC 4). Only set when the terminal reported all 16. */
|
||||
palette?: RgbColor[];
|
||||
}
|
||||
|
||||
function hexToRgb(hex: string): RgbColor {
|
||||
const normalized = hex.startsWith("#") ? hex.slice(1) : hex;
|
||||
const r = parseInt(normalized.slice(0, 2), 16);
|
||||
@@ -25,20 +35,28 @@ function parseOscHexChannel(channel: string): number | undefined {
|
||||
return Math.round((parseInt(channel, 16) / max) * 255);
|
||||
}
|
||||
|
||||
const OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN = /^\x1b\]11;([^\x07\x1b]*)(?:\x07|\x1b\\)$/i;
|
||||
/** What an OSC color reply reports: the default foreground (OSC 10), background (OSC 11), or a palette index (OSC 4). */
|
||||
export type OscColorTarget = "foreground" | "background" | number;
|
||||
|
||||
const OSC_COLOR_RESPONSE_PATTERN = /^\x1b\](?:(1[01])|4;(\d{1,3}));([^\x07\x1b]*)(?:\x07|\x1b\\)$/i;
|
||||
const COLOR_SCHEME_REPORT_PATTERN = /^(?:\x1b\[\?997;(1|2)n)+$/;
|
||||
|
||||
export function isOsc11BackgroundColorResponse(data: string): boolean {
|
||||
return OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN.test(data);
|
||||
}
|
||||
|
||||
export function parseOsc11BackgroundColor(data: string): RgbColor | undefined {
|
||||
const match = data.match(OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN);
|
||||
/**
|
||||
* Parse an OSC 10, 11, or 4 color reply. Returns undefined when `data` is not such a reply;
|
||||
* `rgb` is undefined when it is a reply with an unparseable color.
|
||||
*/
|
||||
export function parseOscColorResponse(data: string): { target: OscColorTarget; rgb: RgbColor | undefined } | undefined {
|
||||
const match = data.match(OSC_COLOR_RESPONSE_PATTERN);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const target: OscColorTarget =
|
||||
match[1] === "10" ? "foreground" : match[1] === "11" ? "background" : Number.parseInt(match[2], 10);
|
||||
return { target, rgb: parseOscColorValue(match[3]) };
|
||||
}
|
||||
|
||||
const value = match[1].trim();
|
||||
function parseOscColorValue(rawValue: string): RgbColor | undefined {
|
||||
const value = rawValue.trim();
|
||||
if (value.startsWith("#")) {
|
||||
const hex = value.slice(1);
|
||||
if (/^[0-9a-f]{6}$/i.test(hex)) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { execSync } from "node:child_process";
|
||||
import { homedir } from "node:os";
|
||||
import { isAbsolute } from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { TerminalColorMode } from "./colors.ts";
|
||||
|
||||
export type ImageProtocol = "kitty" | "iterm2" | null;
|
||||
|
||||
@@ -71,7 +72,7 @@ function detectCapabilitiesFromEnvironment(tmuxForwardsHyperlink: () => boolean)
|
||||
const terminalEmulator = process.env.TERMINAL_EMULATOR?.toLowerCase() || "";
|
||||
const term = process.env.TERM?.toLowerCase() || "";
|
||||
const colorTerm = process.env.COLORTERM?.toLowerCase() || "";
|
||||
const hasTrueColorHint = colorTerm === "truecolor" || colorTerm === "24bit";
|
||||
const hasTrueColorHint = colorTerm === "truecolor" || colorTerm === "24bit" || term.endsWith("-direct");
|
||||
const isWindowsConsole = process.platform === "win32";
|
||||
|
||||
// Emit OSC 8 hyperlinks only when tmux confirms it forwards.
|
||||
@@ -85,6 +86,12 @@ function detectCapabilitiesFromEnvironment(tmuxForwardsHyperlink: () => boolean)
|
||||
return { images: null, trueColor: hasTrueColorHint, hyperlinks: false };
|
||||
}
|
||||
|
||||
// Herdr forwards OSC 8 hyperlinks and supports the Kitty graphics protocol. Check it before
|
||||
// outer-terminal detection because variables like KITTY_WINDOW_ID can leak into its panes.
|
||||
if (termProgram === "herdr") {
|
||||
return { images: "kitty", trueColor: hasTrueColorHint, hyperlinks: true };
|
||||
}
|
||||
|
||||
if (process.env.KITTY_WINDOW_ID || termProgram === "kitty") {
|
||||
return { images: "kitty", trueColor: true, hyperlinks: true };
|
||||
}
|
||||
@@ -168,6 +175,10 @@ export function getCapabilities(): TerminalCapabilities {
|
||||
return cachedCapabilities;
|
||||
}
|
||||
|
||||
export function getTerminalColorMode(capabilities: TerminalCapabilities = getCapabilities()): TerminalColorMode {
|
||||
return capabilities.trueColor ? "truecolor" : "256color";
|
||||
}
|
||||
|
||||
export function resetCapabilitiesCache(): void {
|
||||
cachedCapabilities = null;
|
||||
}
|
||||
@@ -327,6 +338,7 @@ export interface KittyImagePlacement {
|
||||
transmissionGeneration: number;
|
||||
transmissionBytes: number;
|
||||
estimatedDecodedBytes: number;
|
||||
rows: number;
|
||||
sequence: string;
|
||||
replacementLine: string;
|
||||
}
|
||||
@@ -344,13 +356,27 @@ export function registerKittyImageMetadata(metadata: KittyImageMetadata): void {
|
||||
}
|
||||
}
|
||||
|
||||
function getRegisteredKittyImageMetadata(line: string): RegisteredKittyImageMetadata | undefined {
|
||||
const controls = /\x1b_G([^;]*);/.exec(line)?.[1];
|
||||
if (!controls) return undefined;
|
||||
function getRegisteredKittyImageMetadataFromControls(controls: string): RegisteredKittyImageMetadata | undefined {
|
||||
const imageId = /(?:^|,)i=(\d+)(?:,|$)/.exec(controls)?.[1];
|
||||
return imageId === undefined ? undefined : kittyImageMetadata.get(Number.parseInt(imageId, 10));
|
||||
}
|
||||
|
||||
function getRegisteredKittyImageMetadata(line: string): RegisteredKittyImageMetadata | undefined {
|
||||
const controls = /\x1b_G([^;]*);/.exec(line)?.[1];
|
||||
return controls === undefined ? undefined : getRegisteredKittyImageMetadataFromControls(controls);
|
||||
}
|
||||
|
||||
function getExplicitKittyImageRows(controls: string): number | undefined {
|
||||
const value = /(?:^|,)r=(\d+)(?:,|$)/.exec(controls)?.[1];
|
||||
if (value === undefined) return undefined;
|
||||
const rows = Number.parseInt(value, 10);
|
||||
return rows > 0 ? rows : undefined;
|
||||
}
|
||||
|
||||
function getKittyImageRowsFromControls(controls: string, fallbackRows: number): number {
|
||||
return getExplicitKittyImageRows(controls) ?? fallbackRows;
|
||||
}
|
||||
|
||||
export function getKittyImageMetadata(line: string): KittyImageMetadata | undefined {
|
||||
const metadata = getRegisteredKittyImageMetadata(line);
|
||||
if (!metadata) return undefined;
|
||||
@@ -383,11 +409,21 @@ const KITTY_PLACEMENT_CONTROL_KEYS = new Set([
|
||||
"V",
|
||||
]);
|
||||
|
||||
/** Read the number of rows covered by an image placement without scanning its payload. */
|
||||
export function getKittyImagePlacementRows(line: string): number | undefined {
|
||||
const controls = /\x1b_G([^;]*);/.exec(line)?.[1];
|
||||
if (controls === undefined) return undefined;
|
||||
const explicitRows = getExplicitKittyImageRows(controls);
|
||||
if (explicitRows !== undefined) return explicitRows;
|
||||
return getRegisteredKittyImageMetadataFromControls(controls)?.rows;
|
||||
}
|
||||
|
||||
/** Build a placement-only command for an image line emitted by {@link renderImage}. */
|
||||
export function getKittyImagePlacement(line: string): KittyImagePlacement | undefined {
|
||||
const match = /\x1b_G([^;]*);/.exec(line);
|
||||
const metadata = getRegisteredKittyImageMetadata(line);
|
||||
if (!match || !metadata) return undefined;
|
||||
if (!match) return undefined;
|
||||
const metadata = getRegisteredKittyImageMetadataFromControls(match[1]);
|
||||
if (!metadata) return undefined;
|
||||
|
||||
let commandStart = match.index;
|
||||
let commandControls = match[1];
|
||||
@@ -413,6 +449,7 @@ export function getKittyImagePlacement(line: string): KittyImagePlacement | unde
|
||||
transmissionGeneration: metadata.transmissionGeneration,
|
||||
transmissionBytes: transmissionEnd - match.index,
|
||||
estimatedDecodedBytes: metadata.widthPx * metadata.heightPx * 4,
|
||||
rows: getKittyImageRowsFromControls(match[1], metadata.rows),
|
||||
sequence,
|
||||
replacementLine: `${line.slice(0, match.index)}${sequence}${line.slice(transmissionEnd)}`,
|
||||
};
|
||||
@@ -432,11 +469,21 @@ export function cropKittyImageLine(line: string, hiddenRows: number, visibleRows
|
||||
return `${line.slice(0, match.index)}\x1b_G${controls.join(",")};${line.slice(match.index + match[0].length)}`;
|
||||
}
|
||||
|
||||
function chooseLessDistortedCellCount(upperCount: number, idealCount: number): number {
|
||||
if (upperCount <= 1) return upperCount;
|
||||
|
||||
const lowerCount = upperCount - 1;
|
||||
const upperDistortion = Math.max(upperCount / idealCount, idealCount / upperCount);
|
||||
const lowerDistortion = Math.max(lowerCount / idealCount, idealCount / lowerCount);
|
||||
return lowerDistortion < upperDistortion ? lowerCount : upperCount;
|
||||
}
|
||||
|
||||
export function calculateImageCellSize(
|
||||
imageDimensions: ImageDimensions,
|
||||
maxWidthCells: number,
|
||||
maxHeightCells?: number,
|
||||
cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 },
|
||||
optimizeAspectRatio = false,
|
||||
): ImageCellSize {
|
||||
const maxWidth = Math.max(1, Math.floor(maxWidthCells));
|
||||
const maxHeight = maxHeightCells === undefined ? undefined : Math.max(1, Math.floor(maxHeightCells));
|
||||
@@ -449,13 +496,26 @@ export function calculateImageCellSize(
|
||||
|
||||
const scaledWidthPx = imageWidth * scale;
|
||||
const scaledHeightPx = imageHeight * scale;
|
||||
const columns = Math.ceil(scaledWidthPx / cellDimensions.widthPx);
|
||||
const rows = Math.ceil(scaledHeightPx / cellDimensions.heightPx);
|
||||
let columns = Math.max(1, Math.min(maxWidth, Math.ceil(scaledWidthPx / cellDimensions.widthPx)));
|
||||
const heightRows = scaledHeightPx / cellDimensions.heightPx;
|
||||
let rows = Math.max(1, Math.ceil(heightRows));
|
||||
if (maxHeight !== undefined) {
|
||||
rows = Math.min(maxHeight, rows);
|
||||
}
|
||||
|
||||
return {
|
||||
columns: Math.max(1, Math.min(maxWidth, columns)),
|
||||
rows: Math.max(1, maxHeight === undefined ? rows : Math.min(maxHeight, rows)),
|
||||
};
|
||||
if (!optimizeAspectRatio) {
|
||||
return { columns, rows };
|
||||
}
|
||||
|
||||
if (widthScale <= heightScale) {
|
||||
const idealRows = (columns * cellDimensions.widthPx * imageHeight) / (imageWidth * cellDimensions.heightPx);
|
||||
rows = chooseLessDistortedCellCount(rows, idealRows);
|
||||
} else {
|
||||
const idealColumns = (rows * cellDimensions.heightPx * imageWidth) / (imageHeight * cellDimensions.widthPx);
|
||||
columns = chooseLessDistortedCellCount(columns, idealColumns);
|
||||
}
|
||||
|
||||
return { columns, rows };
|
||||
}
|
||||
|
||||
export function calculateImageRows(
|
||||
@@ -619,7 +679,14 @@ export function renderImage(
|
||||
}
|
||||
|
||||
const maxWidth = options.maxWidthCells ?? 80;
|
||||
const size = calculateImageCellSize(imageDimensions, maxWidth, options.maxHeightCells, getCellDimensions());
|
||||
// Reduce Kitty's cell-aligned distortion without shrinking iTerm2 reservations.
|
||||
const size = calculateImageCellSize(
|
||||
imageDimensions,
|
||||
maxWidth,
|
||||
options.maxHeightCells,
|
||||
getCellDimensions(),
|
||||
caps.images === "kitty",
|
||||
);
|
||||
|
||||
if (caps.images === "kitty") {
|
||||
if (options.imageId !== undefined) {
|
||||
|
||||
106
third_party/pi/packages/tui/src/terminal.ts
vendored
106
third_party/pi/packages/tui/src/terminal.ts
vendored
@@ -1,20 +1,24 @@
|
||||
import * as fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import * as path from "node:path";
|
||||
import { setKittyProtocolActive } from "./keys.ts";
|
||||
import { isNativeModifierPressed } from "./native-modifiers.ts";
|
||||
import { getNativeModuleCandidates } from "./native-module-path.ts";
|
||||
import { getNativePlatformHelper } from "./native-platform.ts";
|
||||
import {
|
||||
formatProgramStatus,
|
||||
isProgramStatusReply,
|
||||
PROGRAM_STATUS_QUERY,
|
||||
type ProgramStatus,
|
||||
} from "./program-status.ts";
|
||||
import { StdinBuffer } from "./stdin-buffer.ts";
|
||||
|
||||
const cjsRequire = createRequire(import.meta.url);
|
||||
|
||||
const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000;
|
||||
const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
|
||||
const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0\x07";
|
||||
const NATIVE_SHIFT_ENTER_SEQUENCE = "\x1b[13;2u";
|
||||
const DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS = 7;
|
||||
const KEYBOARD_PROTOCOL_RESPONSE_FRAGMENT_TIMEOUT_MS = 150;
|
||||
const KITTY_KEYBOARD_PROTOCOL_QUERY = `\x1b[>${DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS}u\x1b[?u\x1b[c`;
|
||||
const KITTY_KEYBOARD_PROTOCOL_QUERY = `\x1b[>${DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS}u\x1b[?u`;
|
||||
const DEVICE_ATTRIBUTES_QUERY = "\x1b[c";
|
||||
|
||||
export type KeyboardProtocolNegotiationSequence =
|
||||
| { type: "kitty-flags"; flags: number }
|
||||
@@ -113,6 +117,12 @@ export interface Terminal {
|
||||
|
||||
// Progress indicator (OSC 9;4)
|
||||
setProgress(active: boolean): void;
|
||||
|
||||
/**
|
||||
* Report what the program is doing (OSC 7501). Sent only to terminals that support it; the latest
|
||||
* status is re-sent when support is confirmed or the terminal restarts.
|
||||
*/
|
||||
setProgramStatus(status: ProgramStatus): void;
|
||||
}
|
||||
|
||||
const DEFAULT_ESCAPE_TIMEOUT_MS = 10;
|
||||
@@ -144,11 +154,19 @@ export class ProcessTerminal implements Terminal {
|
||||
private _kittyProtocolActive = false;
|
||||
private _modifyOtherKeysActive = false;
|
||||
private keyboardProtocolPushed = false;
|
||||
/** DA1 replies owed to keyboard protocol queries. Later DA1 replies answer other queries and are forwarded. */
|
||||
private pendingKeyboardProtocolDeviceAttributes = 0;
|
||||
private keyboardProtocolNegotiationBuffer = "";
|
||||
private keyboardProtocolBufferFlushTimer?: ReturnType<typeof setTimeout>;
|
||||
private stdinBuffer?: StdinBuffer;
|
||||
private stdinDataHandler?: (data: string) => void;
|
||||
private progressInterval?: ReturnType<typeof setInterval>;
|
||||
/** Latest program status, kept across stop() so start() can report it again. */
|
||||
private programStatus?: ProgramStatus;
|
||||
/** Whether the terminal confirmed OSC 7501 support since the last start(), or `PI_PROGRAM_STATUS=1`. */
|
||||
private programStatusSupported = false;
|
||||
/** The support query was sent and its DA1 sentinel has not arrived yet. */
|
||||
private programStatusQueryPending = false;
|
||||
private writeLogPath = (() => {
|
||||
const env = process.env.PI_TUI_WRITE_LOG || "";
|
||||
if (!env) return "";
|
||||
@@ -218,16 +236,24 @@ export class ProcessTerminal implements Terminal {
|
||||
|
||||
// Forward individual sequences to the input handler
|
||||
this.stdinBuffer.on("data", (sequence) => {
|
||||
const negotiationSequence = this.readKeyboardProtocolNegotiationSequence(sequence);
|
||||
if (negotiationSequence === "pending") {
|
||||
if (isProgramStatusReply(sequence)) {
|
||||
if (this.programStatusQueryPending) {
|
||||
this.programStatusQueryPending = false;
|
||||
this.programStatusSupported = true;
|
||||
this.writeProgramStatus();
|
||||
}
|
||||
return;
|
||||
}
|
||||
const negotiation = this.readKeyboardProtocolNegotiationSequence(sequence);
|
||||
if (negotiation === "pending") {
|
||||
this.scheduleKeyboardProtocolNegotiationBufferFlush();
|
||||
return; // Wait briefly for the rest of a split Kitty response.
|
||||
}
|
||||
if (this.handleKeyboardProtocolNegotiationSequence(negotiationSequence)) {
|
||||
if (negotiation && this.handleKeyboardProtocolNegotiationSequence(negotiation.parsed)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.forwardInputSequence(sequence);
|
||||
this.forwardInputSequence(negotiation?.sequence ?? sequence);
|
||||
});
|
||||
|
||||
// Re-wrap paste content with bracketed paste markers for existing editor handling
|
||||
@@ -255,20 +281,35 @@ export class ProcessTerminal implements Terminal {
|
||||
* - 1 = disambiguate escape codes
|
||||
* - 2 = report event types (press/repeat/release)
|
||||
* - 4 = report alternate keys (shifted key, base layout key)
|
||||
*
|
||||
* The OSC 7501 program status query shares the DA sentinel: a terminal that supports it replies
|
||||
* before DA. `PI_PROGRAM_STATUS=1` or `0` skips the query.
|
||||
*/
|
||||
private queryAndEnableKittyProtocol(): void {
|
||||
this.setupStdinBuffer();
|
||||
process.stdin.on("data", this.stdinDataHandler!);
|
||||
this.keyboardProtocolPushed = true;
|
||||
this.pendingKeyboardProtocolDeviceAttributes += 1;
|
||||
this.clearKeyboardProtocolNegotiationBuffer();
|
||||
process.stdout.write(KITTY_KEYBOARD_PROTOCOL_QUERY);
|
||||
const programStatusOverride = process.env.PI_PROGRAM_STATUS;
|
||||
this.programStatusSupported = programStatusOverride === "1";
|
||||
this.programStatusQueryPending = programStatusOverride !== "1" && programStatusOverride !== "0";
|
||||
const programStatusQuery = this.programStatusQueryPending ? PROGRAM_STATUS_QUERY : "";
|
||||
process.stdout.write(`${KITTY_KEYBOARD_PROTOCOL_QUERY}${programStatusQuery}${DEVICE_ATTRIBUTES_QUERY}`);
|
||||
this.writeProgramStatus();
|
||||
}
|
||||
|
||||
private handleKeyboardProtocolNegotiationSequence(
|
||||
negotiationSequence: KeyboardProtocolNegotiationSequence | undefined,
|
||||
negotiationSequence: KeyboardProtocolNegotiationSequence,
|
||||
): boolean {
|
||||
if (!negotiationSequence) return false;
|
||||
this.clearKeyboardProtocolNegotiationBuffer();
|
||||
if (negotiationSequence.type === "device-attributes") {
|
||||
if (this.pendingKeyboardProtocolDeviceAttributes === 0) return false;
|
||||
this.pendingKeyboardProtocolDeviceAttributes -= 1;
|
||||
// The last owed DA answers the latest query, which got no program status reply first. Earlier DA
|
||||
// replies belong to queries from before a restart.
|
||||
if (this.pendingKeyboardProtocolDeviceAttributes === 0) this.programStatusQueryPending = false;
|
||||
}
|
||||
if (negotiationSequence.type === "kitty-flags") {
|
||||
if (negotiationSequence.flags !== 0) {
|
||||
this.disableModifyOtherKeys();
|
||||
@@ -288,15 +329,16 @@ export class ProcessTerminal implements Terminal {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Returns the parsed negotiation reply with its full (possibly reassembled) sequence. */
|
||||
private readKeyboardProtocolNegotiationSequence(
|
||||
sequence: string,
|
||||
): KeyboardProtocolNegotiationSequence | "pending" | undefined {
|
||||
): { parsed: KeyboardProtocolNegotiationSequence; sequence: string } | "pending" | undefined {
|
||||
if (this.keyboardProtocolNegotiationBuffer) {
|
||||
const bufferedSequence = this.keyboardProtocolNegotiationBuffer + sequence;
|
||||
const negotiationSequence = parseKeyboardProtocolNegotiationSequence(bufferedSequence);
|
||||
if (negotiationSequence) {
|
||||
this.clearKeyboardProtocolNegotiationBuffer();
|
||||
return negotiationSequence;
|
||||
return { parsed: negotiationSequence, sequence: bufferedSequence };
|
||||
}
|
||||
if (isKeyboardProtocolNegotiationSequencePrefix(bufferedSequence)) {
|
||||
this.setKeyboardProtocolNegotiationBuffer(bufferedSequence);
|
||||
@@ -306,7 +348,7 @@ export class ProcessTerminal implements Terminal {
|
||||
}
|
||||
|
||||
const negotiationSequence = parseKeyboardProtocolNegotiationSequence(sequence);
|
||||
if (negotiationSequence) return negotiationSequence;
|
||||
if (negotiationSequence) return { parsed: negotiationSequence, sequence };
|
||||
if (isKeyboardProtocolNegotiationSequencePrefix(sequence)) {
|
||||
this.setKeyboardProtocolNegotiationBuffer(sequence);
|
||||
return "pending";
|
||||
@@ -378,22 +420,7 @@ export class ProcessTerminal implements Terminal {
|
||||
private enableWindowsVTInput(): void {
|
||||
if (process.platform !== "win32") return;
|
||||
try {
|
||||
const arch = process.arch;
|
||||
if (arch !== "x64" && arch !== "arm64") return;
|
||||
|
||||
// Dynamic require so non-Windows and bundled/browser paths never load the
|
||||
// native helper. Installed packages resolve it from pi-tui; standalone
|
||||
// binaries resolve the copy next to the executable.
|
||||
const nativePath = path.join("native", "win32", "prebuilds", `win32-${arch}`, "win32-console-mode.node");
|
||||
for (const modulePath of getNativeModuleCandidates(nativePath)) {
|
||||
try {
|
||||
const helper = cjsRequire(modulePath) as { enableVirtualTerminalInput?: () => boolean };
|
||||
helper.enableVirtualTerminalInput?.();
|
||||
return;
|
||||
} catch {
|
||||
// Try the next possible packaging location.
|
||||
}
|
||||
}
|
||||
getNativePlatformHelper()?.enableVirtualTerminalInput?.();
|
||||
} catch {
|
||||
// Native helper not available — Shift+Tab won't be distinguishable from Tab.
|
||||
}
|
||||
@@ -441,6 +468,12 @@ export class ProcessTerminal implements Terminal {
|
||||
if (this.clearProgressInterval()) {
|
||||
process.stdout.write(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
|
||||
}
|
||||
// Remove the status while stopped, for example after exit or while suspended. start() reports it again.
|
||||
if (this.programStatusSupported && this.programStatus) {
|
||||
process.stdout.write(formatProgramStatus({ state: "clear" }));
|
||||
}
|
||||
this.programStatusSupported = false;
|
||||
this.programStatusQueryPending = false;
|
||||
|
||||
// Disable bracketed paste mode
|
||||
process.stdout.write("\x1b[?2004l");
|
||||
@@ -540,6 +573,17 @@ export class ProcessTerminal implements Terminal {
|
||||
process.stdout.write(`\x1b]0;${title}\x07`);
|
||||
}
|
||||
|
||||
setProgramStatus(status: ProgramStatus): void {
|
||||
this.programStatus = status.state === "clear" ? undefined : status;
|
||||
if (this.programStatusSupported) process.stdout.write(formatProgramStatus(status));
|
||||
}
|
||||
|
||||
private writeProgramStatus(): void {
|
||||
if (this.programStatusSupported && this.programStatus) {
|
||||
process.stdout.write(formatProgramStatus(this.programStatus));
|
||||
}
|
||||
}
|
||||
|
||||
setProgress(active: boolean): void {
|
||||
if (active) {
|
||||
// OSC 9;4;3 - indeterminate progress
|
||||
|
||||
131
third_party/pi/packages/tui/src/tui-alt-screen.ts
vendored
131
third_party/pi/packages/tui/src/tui-alt-screen.ts
vendored
@@ -25,6 +25,7 @@ import {
|
||||
deleteKittyImage,
|
||||
getCapabilities,
|
||||
getKittyImagePlacement,
|
||||
getKittyImagePlacementRows,
|
||||
type ImageProtocol,
|
||||
isImageLine,
|
||||
setCapabilities,
|
||||
@@ -57,6 +58,7 @@ import {
|
||||
truncateToWidth,
|
||||
visibleWidth,
|
||||
} from "./utils.ts";
|
||||
import { WheelScrollAccelerator, type WheelScrollLines } from "./wheel-scroll.ts";
|
||||
|
||||
const ENTER_ALT_SCREEN = "\x1b[?1049h";
|
||||
const EXIT_ALT_SCREEN = "\x1b[?1049l";
|
||||
@@ -77,6 +79,7 @@ const MAX_CACHED_OFFSCREEN_KITTY_IMAGES = 16;
|
||||
const MAX_CACHED_OFFSCREEN_KITTY_TRANSMISSION_BYTES = 32 * 1024 * 1024;
|
||||
const MAX_CACHED_OFFSCREEN_KITTY_DECODED_BYTES = 64 * 1024 * 1024;
|
||||
const DOUBLE_CLICK_INTERVAL_MS = 500;
|
||||
const COPY_ERROR_FLASH_DURATION_MS = 5000;
|
||||
// Regular mode delegates double-click selection to the terminal emulator. Fullscreen owns mouse selection,
|
||||
// so mirror common terminal word-selection behavior by keeping paths and kebab-case tokens whole.
|
||||
const TERMINAL_WORD_SELECTION_JOINERS = new Set(["/", "-"]);
|
||||
@@ -163,8 +166,11 @@ interface SearchHighlightRange {
|
||||
}
|
||||
|
||||
export interface TuiAltScreenOptions {
|
||||
/** Number of logical lines moved for each mouse-wheel event. */
|
||||
wheelScrollLines?: number;
|
||||
/**
|
||||
* Logical lines moved for each mouse-wheel event (default: 1). `"auto"` accelerates fast wheel
|
||||
* spins on terminals that send one event per notch. Alt+wheel moves five times as far.
|
||||
*/
|
||||
wheelScrollLines?: WheelScrollLines;
|
||||
/** Capture mouse events for viewport scrolling and application-owned text selection. */
|
||||
mouse?: boolean;
|
||||
/** Style a non-current transcript search match. */
|
||||
@@ -185,10 +191,11 @@ export interface TuiAltScreenOptions {
|
||||
/** Automatically copy selected text to the clipboard on mouse release (default: true). */
|
||||
copyOnSelect?: boolean;
|
||||
/**
|
||||
* Copy selected text to the system clipboard. Return `true` on success; the caller flashes
|
||||
* an error otherwise. When omitted, the selection is copied via an OSC 52 write.
|
||||
* Copy selected text to the system clipboard. Return `true` on success, an error message to
|
||||
* display on failure, or `false` for a generic error. When omitted, the selection is copied
|
||||
* via an OSC 52 write.
|
||||
*/
|
||||
copySelection?: (text: string) => Promise<boolean>;
|
||||
copySelection?: (text: string) => Promise<boolean | string>;
|
||||
}
|
||||
|
||||
/** Alternate-screen TUI with a scrollable, application-owned viewport. */
|
||||
@@ -234,7 +241,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
||||
x: number;
|
||||
y: number;
|
||||
};
|
||||
private readonly wheelScrollLines: number;
|
||||
private readonly wheelScroll: WheelScrollAccelerator;
|
||||
private readonly mouseEnabled: boolean;
|
||||
private readonly searchMatchStyle: (text: string) => string;
|
||||
private readonly searchCurrentMatchStyle: (text: string) => string;
|
||||
@@ -243,7 +250,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
||||
private readonly openUrl?: (url: string) => void;
|
||||
private readonly onRightClickPaste?: () => void;
|
||||
private copyOnSelect: boolean;
|
||||
private readonly copySelection?: (text: string) => Promise<boolean>;
|
||||
private readonly copySelection?: (text: string) => Promise<boolean | string>;
|
||||
|
||||
constructor(
|
||||
terminal: Terminal,
|
||||
@@ -261,7 +268,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
||||
};
|
||||
this.implicitScrollView = new ScrollView(this.implicitDocument, { follow: "end", primary: true });
|
||||
this.flashes = new AltScreenFlashContainer(() => this.requestRender());
|
||||
this.wheelScrollLines = Math.max(1, Math.floor(options.wheelScrollLines ?? 1));
|
||||
this.wheelScroll = new WheelScrollAccelerator(options.wheelScrollLines ?? 1);
|
||||
this.mouseEnabled = options.mouse ?? true;
|
||||
this.searchMatchStyle = options.searchMatchStyle ?? ((text) => `\x1b[4m${text}\x1b[24m`);
|
||||
this.searchCurrentMatchStyle = options.searchCurrentMatchStyle ?? ((text) => `\x1b[1;7m${text}\x1b[22;27m`);
|
||||
@@ -282,6 +289,10 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
||||
return this.getPrimaryScrollView().isFollowingEnd;
|
||||
}
|
||||
|
||||
setWheelScrollLines(lines: WheelScrollLines): void {
|
||||
this.wheelScroll.setLines(lines);
|
||||
}
|
||||
|
||||
getCopyOnSelect(): boolean {
|
||||
return this.copyOnSelect;
|
||||
}
|
||||
@@ -302,6 +313,17 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
||||
return this.copyTextToClipboard(text);
|
||||
}
|
||||
|
||||
/** Drop the text selection and multi-click history, e.g. before the host replaces the transcript. */
|
||||
resetTextSelection(): void {
|
||||
this.clearTextSelection();
|
||||
this.lastClick = undefined;
|
||||
}
|
||||
|
||||
/** The lines of the last rendered frame, one per terminal row, as written to the terminal. */
|
||||
getScreenLines(): string[] {
|
||||
return [...this.previousScreen];
|
||||
}
|
||||
|
||||
setLayoutRoot(component: Component | undefined): void {
|
||||
if (this.layoutRoot === component) return;
|
||||
this.layoutRoot = component;
|
||||
@@ -385,7 +407,9 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
||||
this.terminal.write(`${BEGIN_SYNCHRONIZED_OUTPUT}${EXIT_ALT_SCREEN}\x1b[?25h${END_SYNCHRONIZED_OUTPUT}`);
|
||||
} else {
|
||||
const width = Math.max(1, this.terminal.columns);
|
||||
const documentLines = this.render(width).map((line) => line.replace(OSC133_ZONE_PREFIX, ""));
|
||||
const documentLines = this.resolveFakeCursors(this.render(width)).map((line) =>
|
||||
line.replace(OSC133_ZONE_PREFIX, ""),
|
||||
);
|
||||
this.lastDocument = this.applyLineResets(documentLines.map((line) => line.replaceAll(CURSOR_MARKER, ""))).map(
|
||||
(line) => (isImageLine(line) || visibleWidth(line) <= width ? line : sliceByColumn(line, 0, width, true)),
|
||||
);
|
||||
@@ -680,17 +704,18 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
||||
|
||||
const wheelEvent = this.parseWheelEvent(data);
|
||||
if (wheelEvent) {
|
||||
const event = this.createMouseEvent("wheel", wheelEvent.button, wheelEvent.x, wheelEvent.y, {
|
||||
wheelDelta: wheelEvent.direction * this.getWheelScrollLines(wheelEvent.button),
|
||||
});
|
||||
const lines = this.wheelScroll.next(wheelEvent.direction, performance.now());
|
||||
// SGR mouse button codes use bit 3 (value 8) for the Alt modifier.
|
||||
const wheelDelta =
|
||||
wheelEvent.direction * ((wheelEvent.button & 8) !== 0 ? lines * ALT_WHEEL_SCROLL_MULTIPLIER : lines);
|
||||
const event = this.createMouseEvent("wheel", wheelEvent.button, wheelEvent.x, wheelEvent.y, { wheelDelta });
|
||||
const overlay = this.dispatchMouseToOverlay(event);
|
||||
const result = overlay.result ?? (overlay.hit ? undefined : this.dispatchMouseToLayout(event));
|
||||
if (result) {
|
||||
if (this.applyMouseDispatchResult(event, result)) this.requestRender();
|
||||
return { consume: true };
|
||||
}
|
||||
if (this.shouldDeferViewportInputToOverlay()) return undefined;
|
||||
this.routeWheel(wheelEvent);
|
||||
this.routeWheel(wheelEvent, wheelDelta);
|
||||
return { consume: true };
|
||||
}
|
||||
const mouseEvent = this.parseSgrMouseEvent(data);
|
||||
@@ -965,13 +990,8 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private getWheelScrollLines(button: number): number {
|
||||
// SGR mouse button codes use bit 3 (value 8) for the Alt modifier.
|
||||
return (button & 8) !== 0 ? this.wheelScrollLines * ALT_WHEEL_SCROLL_MULTIPLIER : this.wheelScrollLines;
|
||||
}
|
||||
|
||||
private routeWheel(event: WheelEvent): void {
|
||||
let remaining = event.direction * this.getWheelScrollLines(event.button);
|
||||
private routeWheel(event: WheelEvent, delta: number): void {
|
||||
let remaining = delta;
|
||||
const seen = new Set<ScrollView>();
|
||||
for (const scrollView of this.currentLayout ? getScrollViewsAt(this.currentLayout, event.x, event.y) : []) {
|
||||
seen.add(scrollView);
|
||||
@@ -1452,8 +1472,12 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
||||
// "Copied!" while leaving the system clipboard untouched (e.g. macOS Terminal.app, tmux
|
||||
// without OSC 52 clipboard passthrough), so only report success when it actually copies.
|
||||
if (this.copySelection) {
|
||||
const ok = await this.copySelection(text);
|
||||
this.flash(ok ? "Copied!" : "Copy failed");
|
||||
const result = await this.copySelection(text);
|
||||
const ok = result === true;
|
||||
this.flash(
|
||||
ok ? "Copied!" : typeof result === "string" ? result : "Copy failed",
|
||||
ok ? undefined : COPY_ERROR_FLASH_DURATION_MS,
|
||||
);
|
||||
return ok;
|
||||
}
|
||||
this.terminal.write(`\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
|
||||
@@ -1624,11 +1648,14 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
||||
const row = clip.y + clip.height - 1;
|
||||
if (row >= screen.length || isImageLine(screen[row] ?? "")) return screen;
|
||||
const scrollbarColumn = box ? getScrollbarGeometry(box)?.column : undefined;
|
||||
const availableWidth = Math.max(0, (scrollbarColumn ?? clip.x + clip.width) - clip.x);
|
||||
const text = truncateToWidth(this.scrollToEndIndicator(), availableWidth, "");
|
||||
const label = truncateToWidth(this.scrollToEndIndicator(), clip.width, "");
|
||||
const labelWidth = visibleWidth(label);
|
||||
const column = clip.x + Math.floor((clip.width - labelWidth) / 2);
|
||||
const rightEdge = scrollbarColumn ?? clip.x + clip.width;
|
||||
const availableWidth = Math.max(0, rightEdge - column);
|
||||
const text = truncateToWidth(label, availableWidth, "");
|
||||
const textWidth = visibleWidth(text);
|
||||
if (textWidth === 0) return screen;
|
||||
const column = clip.x + Math.floor((availableWidth - textWidth) / 2);
|
||||
const result = [...screen];
|
||||
result[row] = compositeTuiLine(result[row] ?? "", text, column, textWidth, width);
|
||||
this.scrollToEndIndicatorRect = { row, column, width: textWidth };
|
||||
@@ -1658,7 +1685,8 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
||||
if (this.refreshSearch(nextLayout)) {
|
||||
nextLayout = renderLayoutFrame(root, width, height, () => this.requestRender());
|
||||
}
|
||||
let screen = nextLayout.lines.map((line) => line.replace(OSC133_ZONE_PREFIX, ""));
|
||||
// Resolve fake cursors before highlighting and compositing, which only track SGR codes
|
||||
let screen = this.resolveFakeCursors(nextLayout.lines.map((line) => line.replace(OSC133_ZONE_PREFIX, "")));
|
||||
screen = this.applySearchHighlights(screen, nextLayout);
|
||||
screen = this.compositeScrollToEndIndicator(screen, nextLayout, width);
|
||||
screen = this.compositeOverlays(screen, width, height);
|
||||
@@ -1674,10 +1702,25 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
||||
|
||||
const fullRedraw =
|
||||
this.previousScreen.length === 0 || this.previousScreenWidth !== width || this.previousScreenHeight !== height;
|
||||
const imagesNeedRedraw = screen.some(
|
||||
(line, row) =>
|
||||
line !== this.previousScreen[row] && (isImageLine(line) || isImageLine(this.previousScreen[row] ?? "")),
|
||||
const changedRows = screen.map((line, row) => line !== this.previousScreen[row]);
|
||||
const imageAnchorsNeedRedraw = screen.some(
|
||||
(line, row) => changedRows[row] && (isImageLine(line) || isImageLine(this.previousScreen[row] ?? "")),
|
||||
);
|
||||
const isWezTerm = Boolean(process.env.WEZTERM_PANE) || process.env.TERM_PROGRAM?.toLowerCase() === "wezterm";
|
||||
const imageCellsNeedRedraw =
|
||||
!imageAnchorsNeedRedraw &&
|
||||
isWezTerm &&
|
||||
this.imageProtocol === "kitty" &&
|
||||
changedRows.some(Boolean) &&
|
||||
screen.some((line, row) => {
|
||||
const placementRows = getKittyImagePlacementRows(line);
|
||||
if (placementRows === undefined) return false;
|
||||
for (let coveredRow = row; coveredRow < row + placementRows; coveredRow++) {
|
||||
if (changedRows[coveredRow]) return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
const imagesNeedRedraw = imageAnchorsNeedRedraw || imageCellsNeedRedraw;
|
||||
const redrawImages = fullRedraw || imagesNeedRedraw;
|
||||
const hadUploadedKittyImages = this.uploadedKittyImages.size > 0;
|
||||
const preparedKittyScreen =
|
||||
@@ -1699,9 +1742,31 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
|
||||
}
|
||||
buffer += preparedKittyScreen.evictedImageDeletion;
|
||||
|
||||
for (let row = 0; row < height; row++) {
|
||||
if (!fullRedraw && !imagesNeedRedraw && screen[row] === this.previousScreen[row]) continue;
|
||||
buffer += `\x1b[${row + 1};1H\x1b[2K${preparedKittyScreen.lines[row] ?? ""}`;
|
||||
// WezTerm erases intersecting Kitty image cells when a later row write touches a covered row.
|
||||
// Draw image placements after every clear and text write so nothing later intersects them; preserve
|
||||
// the existing interleaved output for text-only frames and every other terminal.
|
||||
const drawKittyImagesLast =
|
||||
redrawImages && this.imageProtocol === "kitty" && screen.some(isImageLine) && isWezTerm;
|
||||
if (drawKittyImagesLast) {
|
||||
for (let row = 0; row < height; row++) {
|
||||
if (!fullRedraw && !imagesNeedRedraw && screen[row] === this.previousScreen[row]) continue;
|
||||
buffer += `\x1b[${row + 1};1H\x1b[2K`;
|
||||
}
|
||||
for (let row = 0; row < height; row++) {
|
||||
if (!fullRedraw && !imagesNeedRedraw && screen[row] === this.previousScreen[row]) continue;
|
||||
if (isImageLine(preparedKittyScreen.lines[row] ?? "")) continue;
|
||||
buffer += `\x1b[${row + 1};1H${preparedKittyScreen.lines[row] ?? ""}`;
|
||||
}
|
||||
for (let row = 0; row < height; row++) {
|
||||
if (!fullRedraw && !imagesNeedRedraw && screen[row] === this.previousScreen[row]) continue;
|
||||
if (!isImageLine(preparedKittyScreen.lines[row] ?? "")) continue;
|
||||
buffer += `\x1b[${row + 1};1H${preparedKittyScreen.lines[row] ?? ""}`;
|
||||
}
|
||||
} else {
|
||||
for (let row = 0; row < height; row++) {
|
||||
if (!fullRedraw && !imagesNeedRedraw && screen[row] === this.previousScreen[row]) continue;
|
||||
buffer += `\x1b[${row + 1};1H\x1b[2K${preparedKittyScreen.lines[row] ?? ""}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (cursorPos) {
|
||||
|
||||
@@ -261,7 +261,8 @@ export class TuiMainScreen extends TuiBase implements TUI {
|
||||
};
|
||||
|
||||
// Render all components to get new lines
|
||||
let newLines = this.render(width);
|
||||
// Resolve fake cursors before compositing so overlays and line slicing see plain SGR codes
|
||||
let newLines = this.resolveFakeCursors(this.render(width));
|
||||
|
||||
// Composite overlays into the rendered lines (before differential compare)
|
||||
if (this.hasOverlayEntries) {
|
||||
|
||||
211
third_party/pi/packages/tui/src/tui.ts
vendored
211
third_party/pi/packages/tui/src/tui.ts
vendored
@@ -6,11 +6,11 @@ import { performance } from "node:perf_hooks";
|
||||
import { isKeyRelease, matchesKey } from "./keys.ts";
|
||||
import type { Terminal } from "./terminal.ts";
|
||||
import {
|
||||
isOsc11BackgroundColorResponse,
|
||||
parseOsc11BackgroundColor,
|
||||
parseOscColorResponse,
|
||||
parseTerminalColorSchemeReport,
|
||||
type RgbColor,
|
||||
type TerminalColorScheme,
|
||||
type TerminalColors,
|
||||
} from "./terminal-colors.ts";
|
||||
import { getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.ts";
|
||||
import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.ts";
|
||||
@@ -81,7 +81,13 @@ export interface TuiMouseDispatchResult extends TuiMouseEventResult {
|
||||
export function dispatchMouseEvent(component: Component, event: TuiMouseEvent): TuiMouseDispatchResult | undefined {
|
||||
const result = component.handleMouse?.(event);
|
||||
if (!result) return undefined;
|
||||
if ("target" in result) return result as TuiMouseDispatchResult;
|
||||
if ("target" in result) {
|
||||
// The component forwarded the event to a child it hosts. Like a delegating container, it routes
|
||||
// keys to that child itself, so it keeps keyboard focus. Focusing the child directly would leave
|
||||
// focus on a detached component once the host removes it, e.g. a closed settings submenu.
|
||||
const forwarded = result as TuiMouseDispatchResult;
|
||||
return forwarded.focus && component.handleInput ? { ...forwarded, focusTarget: component } : forwarded;
|
||||
}
|
||||
if (!result.handled && !result.capture && !result.focus) return undefined;
|
||||
return {
|
||||
...result,
|
||||
@@ -137,12 +143,34 @@ export interface Component {
|
||||
|
||||
export type TuiInputListenerResult = { consume?: boolean; data?: string } | undefined;
|
||||
export type TuiInputListener = (data: string) => TuiInputListenerResult;
|
||||
type PendingOsc11BackgroundQuery = {
|
||||
settled: boolean;
|
||||
resolve: ((rgb: RgbColor | undefined) => void) | undefined;
|
||||
type PendingTerminalColorQuery = {
|
||||
foreground?: RgbColor;
|
||||
background?: RgbColor;
|
||||
palette: Array<RgbColor | undefined>;
|
||||
/** Targets that already replied, so duplicates do not count twice. */
|
||||
replied: Set<string>;
|
||||
/**
|
||||
* Receives the result: the promise's resolve until the timeout, then `onLateReply`. Unset once the
|
||||
* query completed (on the DA1 reply or once every color replied); later replies are ignored.
|
||||
*/
|
||||
deliver: ((colors: TerminalColors) => void) | undefined;
|
||||
timer: NodeJS.Timeout | undefined;
|
||||
};
|
||||
|
||||
const TERMINAL_PALETTE_SIZE = 16;
|
||||
/** OSC 10 and 11 plus OSC 4 for every palette color. */
|
||||
const TERMINAL_COLOR_REPLY_COUNT = 2 + TERMINAL_PALETTE_SIZE;
|
||||
/**
|
||||
* Default colors, palette colors 0-15, and a trailing primary device attributes (DA1) request.
|
||||
* Every terminal answers DA1 and terminals answer in order, so the DA1 reply marks the end of
|
||||
* the color replies, including for terminals that ignore the color queries.
|
||||
*/
|
||||
const TERMINAL_COLOR_QUERY = `\x1b]10;?\x07\x1b]11;?\x07${Array.from(
|
||||
{ length: TERMINAL_PALETTE_SIZE },
|
||||
(_, index) => `\x1b]4;${index};?\x07`,
|
||||
).join("")}\x1b[c`;
|
||||
const DEVICE_ATTRIBUTES_RESPONSE_PATTERN = /^\x1b\[\?[\d;]*c$/;
|
||||
|
||||
/**
|
||||
* Interface for components that can receive focus and display a hardware cursor.
|
||||
* When focused, the component should emit CURSOR_MARKER at the cursor position
|
||||
@@ -167,6 +195,17 @@ export function isFocusable(component: Component | null): component is Component
|
||||
*/
|
||||
export const CURSOR_MARKER = "\x1b_pi:c\x07";
|
||||
|
||||
/** Fake cursor markers - zero-width APC sequences around the text drawn as the cursor. See resolveFakeCursors(). */
|
||||
export const FAKE_CURSOR_START = "\x1b_pi:fc\x07";
|
||||
export const FAKE_CURSOR_END = "\x1b_pi:/fc\x07";
|
||||
// CURSOR_MARKER + FAKE_CURSOR_START + text + FAKE_CURSOR_END. Truncating the line right after the cursor cell drops the end.
|
||||
const FOCUSED_FAKE_CURSOR = /(\x1b_pi:c\x07)\x1b_pi:fc\x07(.*?)(?:\x1b_pi:\/fc\x07|$)/s;
|
||||
|
||||
/** Wrap text in fake cursor markers. */
|
||||
export function renderFakeCursor(text: string): string {
|
||||
return `${FAKE_CURSOR_START}${text}${FAKE_CURSOR_END}`;
|
||||
}
|
||||
|
||||
export { visibleWidth };
|
||||
|
||||
/**
|
||||
@@ -447,8 +486,10 @@ export interface TUI extends Component {
|
||||
removeInputListener(listener: TuiInputListener): void;
|
||||
onTerminalColorSchemeChange(listener: (scheme: TerminalColorScheme) => void): () => void;
|
||||
setTerminalColorSchemeNotifications(enabled: boolean): void;
|
||||
queryTerminalBackgroundColor(options: { timeoutMs: number }): Promise<RgbColor | undefined>;
|
||||
queryTerminalColorScheme(options: { timeoutMs: number }): Promise<TerminalColorScheme | undefined>;
|
||||
queryTerminalColors(options: {
|
||||
timeoutMs: number;
|
||||
onLateReply?: (colors: TerminalColors) => void;
|
||||
}): Promise<TerminalColors>;
|
||||
}
|
||||
|
||||
export const VIEWPORT_TUI = Symbol.for("@earendil-works/pi-tui/viewport");
|
||||
@@ -479,8 +520,11 @@ export abstract class TuiBase extends Container implements TUI {
|
||||
private clearOnShrink = false;
|
||||
protected fullRedrawCount = 0;
|
||||
protected stopped = false;
|
||||
private pendingOsc11BackgroundReplies = 0;
|
||||
private pendingOsc11BackgroundQueries: PendingOsc11BackgroundQuery[] = [];
|
||||
/**
|
||||
* Color queries waiting for their DA1 reply, oldest first. Terminals answer in order, so color
|
||||
* replies belong to the oldest one. Queries stay here after a timeout to collect late replies.
|
||||
*/
|
||||
private pendingTerminalColorQueries: PendingTerminalColorQuery[] = [];
|
||||
private terminalColorSchemeListeners = new Set<(scheme: TerminalColorScheme) => void>();
|
||||
private terminalColorSchemeNotificationsEnabled = false;
|
||||
/** Directory for debug/crash logs. When undefined, debug logging is disabled and crash dumps fall back to the OS temp directory. */
|
||||
@@ -529,7 +573,7 @@ export abstract class TuiBase extends Container implements TUI {
|
||||
if (this.showHardwareCursor === enabled) return;
|
||||
this.showHardwareCursor = enabled;
|
||||
if (!enabled) {
|
||||
this.terminal.hideCursor();
|
||||
this.hideTerminalCursor();
|
||||
}
|
||||
this.requestRender();
|
||||
}
|
||||
@@ -695,7 +739,7 @@ export abstract class TuiBase extends Container implements TUI {
|
||||
if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
|
||||
this.setFocus(component);
|
||||
}
|
||||
this.terminal.hideCursor();
|
||||
this.hideTerminalCursor();
|
||||
this.requestRender();
|
||||
|
||||
// Return handle for controlling this overlay
|
||||
@@ -711,7 +755,7 @@ export abstract class TuiBase extends Container implements TUI {
|
||||
const topVisible = this.getTopmostVisibleOverlay();
|
||||
this.setFocus(topVisible?.component ?? entry.preFocus);
|
||||
}
|
||||
if (this.overlayStack.length === 0) this.terminal.hideCursor();
|
||||
if (this.overlayStack.length === 0) this.hideTerminalCursor();
|
||||
this.requestRender();
|
||||
}
|
||||
},
|
||||
@@ -793,10 +837,15 @@ export abstract class TuiBase extends Container implements TUI {
|
||||
const topVisible = this.getTopmostVisibleOverlay();
|
||||
this.setFocus(topVisible?.component ?? overlay.preFocus);
|
||||
}
|
||||
if (this.overlayStack.length === 0) this.terminal.hideCursor();
|
||||
if (this.overlayStack.length === 0) this.hideTerminalCursor();
|
||||
this.requestRender();
|
||||
}
|
||||
|
||||
/** Hide the cursor while running. After stop(), the shell owns the cursor and it must stay visible. */
|
||||
private hideTerminalCursor(): void {
|
||||
if (!this.stopped) this.terminal.hideCursor();
|
||||
}
|
||||
|
||||
/** Check if there are any visible overlays */
|
||||
hasOverlay(): boolean {
|
||||
return this.overlayStack.some((o) => this.isOverlayVisible(o));
|
||||
@@ -1004,7 +1053,7 @@ export abstract class TuiBase extends Container implements TUI {
|
||||
}
|
||||
|
||||
private handleTerminalInput(data: string): void {
|
||||
if (this.consumeOsc11BackgroundResponse(data)) {
|
||||
if (this.consumeTerminalColorResponse(data)) {
|
||||
return;
|
||||
}
|
||||
if (this.consumeTerminalColorSchemeReport(data)) {
|
||||
@@ -1081,30 +1130,52 @@ export abstract class TuiBase extends Container implements TUI {
|
||||
}
|
||||
}
|
||||
|
||||
private consumeOsc11BackgroundResponse(data: string): boolean {
|
||||
if (this.pendingOsc11BackgroundReplies <= 0) {
|
||||
private consumeTerminalColorResponse(data: string): boolean {
|
||||
const query = this.pendingTerminalColorQueries[0];
|
||||
if (!query) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isOsc11BackgroundColorResponse(data)) {
|
||||
return false;
|
||||
if (DEVICE_ATTRIBUTES_RESPONSE_PATTERN.test(data)) {
|
||||
this.pendingTerminalColorQueries.shift();
|
||||
this.completeTerminalColorQuery(query);
|
||||
return true;
|
||||
}
|
||||
|
||||
const rgb = parseOsc11BackgroundColor(data);
|
||||
this.pendingOsc11BackgroundReplies -= 1;
|
||||
const query = this.pendingOsc11BackgroundQueries.shift();
|
||||
if (query && !query.settled) {
|
||||
query.settled = true;
|
||||
if (query.timer) {
|
||||
clearTimeout(query.timer);
|
||||
query.timer = undefined;
|
||||
}
|
||||
query.resolve?.(rgb);
|
||||
query.resolve = undefined;
|
||||
const response = parseOscColorResponse(data);
|
||||
if (!response) {
|
||||
return false;
|
||||
}
|
||||
const { target, rgb } = response;
|
||||
const key = String(target);
|
||||
if (!query.deliver || query.replied.has(key)) {
|
||||
return true;
|
||||
}
|
||||
query.replied.add(key);
|
||||
if (target === "foreground") {
|
||||
query.foreground = rgb;
|
||||
} else if (target === "background") {
|
||||
query.background = rgb;
|
||||
} else if (target < TERMINAL_PALETTE_SIZE) {
|
||||
query.palette[target] = rgb;
|
||||
}
|
||||
if (query.replied.size === TERMINAL_COLOR_REPLY_COUNT) {
|
||||
this.completeTerminalColorQuery(query);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private terminalColorQueryResult(query: PendingTerminalColorQuery): TerminalColors {
|
||||
const palette = query.palette.every((color) => color !== undefined) ? (query.palette as RgbColor[]) : undefined;
|
||||
return { foreground: query.foreground, background: query.background, palette };
|
||||
}
|
||||
|
||||
private completeTerminalColorQuery(query: PendingTerminalColorQuery): void {
|
||||
const deliver = query.deliver;
|
||||
query.deliver = undefined;
|
||||
clearTimeout(query.timer);
|
||||
deliver?.(this.terminalColorQueryResult(query));
|
||||
}
|
||||
|
||||
private consumeTerminalColorSchemeReport(data: string): boolean {
|
||||
const scheme = parseTerminalColorSchemeReport(data);
|
||||
if (!scheme) {
|
||||
@@ -1299,7 +1370,7 @@ export abstract class TuiBase extends Container implements TUI {
|
||||
const { width, maxHeight } = this.resolveOverlayLayout(options, 0, termWidth, termHeight);
|
||||
|
||||
// Render component at calculated width
|
||||
let overlayLines = component.render(width);
|
||||
let overlayLines = this.resolveFakeCursors(component.render(width));
|
||||
|
||||
// Apply maxHeight if specified
|
||||
if (maxHeight !== undefined && overlayLines.length > maxHeight) {
|
||||
@@ -1400,57 +1471,47 @@ export abstract class TuiBase extends Container implements TUI {
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the terminal's default background color with OSC 11 (`ESC ] 11 ; ? BEL`).
|
||||
* @param timeoutMs Query timeout in milliseconds.
|
||||
* @returns Promise containing the parsed RGB color, or undefined if it times out or fails to parse.
|
||||
* Resolve fake cursor markers to reverse video. When the hardware cursor is shown, a fake cursor
|
||||
* directly after CURSOR_MARKER is dropped because the terminal cursor is drawn there. Fake cursors
|
||||
* of components without focus have no CURSOR_MARKER and stay visible.
|
||||
*/
|
||||
queryTerminalBackgroundColor({ timeoutMs }: { timeoutMs: number }): Promise<RgbColor | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
const query: PendingOsc11BackgroundQuery = {
|
||||
settled: false,
|
||||
resolve,
|
||||
timer: undefined,
|
||||
};
|
||||
|
||||
query.timer = setTimeout(() => {
|
||||
if (query.settled) {
|
||||
return;
|
||||
}
|
||||
query.settled = true;
|
||||
query.timer = undefined;
|
||||
query.resolve?.(undefined);
|
||||
query.resolve = undefined;
|
||||
}, timeoutMs);
|
||||
this.pendingOsc11BackgroundQueries.push(query);
|
||||
this.pendingOsc11BackgroundReplies += 1;
|
||||
this.terminal.write("\x1b]11;?\x07");
|
||||
protected resolveFakeCursors(lines: string[]): string[] {
|
||||
return lines.map((line) => {
|
||||
if (!line.includes(FAKE_CURSOR_START)) return line;
|
||||
const resolved = this.showHardwareCursor ? line.replace(FOCUSED_FAKE_CURSOR, "$1$2") : line;
|
||||
return resolved.replaceAll(FAKE_CURSOR_START, "\x1b[7m").replaceAll(FAKE_CURSOR_END, "\x1b[27m");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the terminal's color-scheme preference with DSR (`CSI ? 996 n`).
|
||||
* Terminals that support the color palette notification protocol reply with
|
||||
* `CSI ? 997 ; 1 n` for dark or `CSI ? 997 ; 2 n` for light.
|
||||
* Query the terminal's theme colors: the default foreground (OSC 10), the default background
|
||||
* (OSC 11), and ANSI colors 0-15 (OSC 4), followed by a DA1 request that marks the end of the
|
||||
* replies. Resolves when the DA1 reply or all color replies arrive, or when the timeout expires.
|
||||
* Colors the terminal did not report are undefined; the palette is only set when all 16 arrived.
|
||||
* @param timeoutMs Query timeout in milliseconds, for terminals that do not answer DA1 either.
|
||||
* @param onLateReply Receives the replies if the query completes after the timeout, e.g. over slow links.
|
||||
*/
|
||||
queryTerminalColorScheme({ timeoutMs }: { timeoutMs: number }): Promise<TerminalColorScheme | undefined> {
|
||||
queryTerminalColors({
|
||||
timeoutMs,
|
||||
onLateReply,
|
||||
}: {
|
||||
timeoutMs: number;
|
||||
onLateReply?: (colors: TerminalColors) => void;
|
||||
}): Promise<TerminalColors> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
let unsubscribe: () => void = () => {};
|
||||
const settle = (scheme: TerminalColorScheme | undefined) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
unsubscribe();
|
||||
resolve(scheme);
|
||||
const query: PendingTerminalColorQuery = {
|
||||
palette: Array.from({ length: TERMINAL_PALETTE_SIZE }, () => undefined),
|
||||
replied: new Set(),
|
||||
deliver: resolve,
|
||||
timer: undefined,
|
||||
};
|
||||
|
||||
unsubscribe = this.onTerminalColorSchemeChange(settle);
|
||||
timer = setTimeout(() => settle(undefined), timeoutMs);
|
||||
this.terminal.write("\x1b[?996n");
|
||||
// Resolve with the replies so far, and keep collecting late replies for `onLateReply`.
|
||||
query.timer = setTimeout(() => {
|
||||
query.deliver = onLateReply;
|
||||
resolve(this.terminalColorQueryResult(query));
|
||||
}, timeoutMs);
|
||||
this.pendingTerminalColorQueries.push(query);
|
||||
this.terminal.write(TERMINAL_COLOR_QUERY);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
171
third_party/pi/packages/tui/src/utils.ts
vendored
171
third_party/pi/packages/tui/src/utils.ts
vendored
@@ -54,6 +54,14 @@ const widthCache = new Map<string, number>();
|
||||
export const cjkBreakRegex =
|
||||
/[\p{Script_Extensions=Han}\p{Script_Extensions=Hiragana}\p{Script_Extensions=Katakana}\p{Script_Extensions=Hangul}\p{Script_Extensions=Bopomofo}]/u;
|
||||
|
||||
// CJK letters remain part of words and paths; only punctuation can separate prose from completions.
|
||||
export const cjkPunctuationRegex = new RegExp(
|
||||
`(?:(?=\\p{Punctuation})${cjkBreakRegex.source}|[,.:;!?()[]{}“”‘’…—])`,
|
||||
"u",
|
||||
);
|
||||
export const autocompleteSeparatorRegex = new RegExp(`(?:\\s|${cjkPunctuationRegex.source})`, "u");
|
||||
export const autocompleteBoundaryRegex = new RegExp(`(?:^|${autocompleteSeparatorRegex.source})`, "u");
|
||||
|
||||
function isPrintableAscii(str: string): boolean {
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const code = str.charCodeAt(i);
|
||||
@@ -172,8 +180,10 @@ function finalizeTruncatedResult(
|
||||
* check to avoid running the RGI_Emoji regex unnecessarily.
|
||||
*/
|
||||
function graphemeWidth(segment: string): number {
|
||||
if (segment === "\t") {
|
||||
return 3;
|
||||
if (segment.length === 1) {
|
||||
const code = segment.charCodeAt(0);
|
||||
if (code >= 0x20 && code <= 0x7e) return 1;
|
||||
if (code === 0x09) return 3;
|
||||
}
|
||||
|
||||
// Some marks occupy cells even without a base character.
|
||||
@@ -242,9 +252,11 @@ export function visibleWidth(str: string): number {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Fast path: pure ASCII printable
|
||||
if (isPrintableAscii(str)) {
|
||||
return str.length;
|
||||
// Fast path: printable ASCII, tabs, and ANSI escape sequences. Styled lines take this path, so
|
||||
// re-rendering after a theme change does not run grapheme segmentation on every line.
|
||||
const asciiWidth = asciiVisibleWidth(str);
|
||||
if (asciiWidth !== -1) {
|
||||
return asciiWidth;
|
||||
}
|
||||
|
||||
// Check cache
|
||||
@@ -258,22 +270,25 @@ export function visibleWidth(str: string): number {
|
||||
if (str.includes("\t")) {
|
||||
clean = clean.replace(/\t/g, " ");
|
||||
}
|
||||
if (clean.includes("\x1b")) {
|
||||
let escapeIndex = clean.indexOf("\x1b");
|
||||
if (escapeIndex !== -1) {
|
||||
// Strip supported ANSI/OSC/APC escape sequences in one pass.
|
||||
// This covers CSI styling/cursor codes, OSC hyperlinks and prompt markers,
|
||||
// and APC sequences like CURSOR_MARKER.
|
||||
let stripped = "";
|
||||
let i = 0;
|
||||
while (i < clean.length) {
|
||||
const ansi = extractAnsiCode(clean, i);
|
||||
if (ansi) {
|
||||
i += ansi.length;
|
||||
continue;
|
||||
let copyFrom = 0;
|
||||
while (escapeIndex !== -1) {
|
||||
const length = ansiCodeLength(clean, escapeIndex);
|
||||
if (length > 0) {
|
||||
stripped += clean.slice(copyFrom, escapeIndex);
|
||||
escapeIndex += length;
|
||||
copyFrom = escapeIndex;
|
||||
} else {
|
||||
escapeIndex++;
|
||||
}
|
||||
stripped += clean[i];
|
||||
i++;
|
||||
escapeIndex = clean.indexOf("\x1b", escapeIndex);
|
||||
}
|
||||
clean = stripped;
|
||||
clean = stripped + clean.slice(copyFrom);
|
||||
}
|
||||
|
||||
// Calculate width
|
||||
@@ -404,43 +419,66 @@ export function normalizeTerminalOutput(str: string): string {
|
||||
* Extract ANSI escape sequences from a string at the given position.
|
||||
*/
|
||||
export function extractAnsiCode(str: string, pos: number): { code: string; length: number } | null {
|
||||
if (pos >= str.length || str[pos] !== "\x1b") return null;
|
||||
const length = ansiCodeLength(str, pos);
|
||||
return length > 0 ? { code: str.substring(pos, pos + length), length } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Width of a string made of printable ASCII, tabs, and ANSI escape sequences, or -1 if it contains
|
||||
* anything else. Matches `visibleWidth` for those strings without allocating.
|
||||
*/
|
||||
function asciiVisibleWidth(str: string): number {
|
||||
let width = 0;
|
||||
let i = 0;
|
||||
while (i < str.length) {
|
||||
const code = str.charCodeAt(i);
|
||||
if (code >= 0x20 && code <= 0x7e) {
|
||||
width++;
|
||||
i++;
|
||||
} else if (code === 0x09) {
|
||||
width += 3;
|
||||
i++;
|
||||
} else if (code === 0x1b) {
|
||||
const length = ansiCodeLength(str, i);
|
||||
if (length === 0) return -1;
|
||||
i += length;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
return width;
|
||||
}
|
||||
|
||||
/** Length of the ANSI/OSC/APC escape sequence starting at `pos`, or 0 if there is none. */
|
||||
function ansiCodeLength(str: string, pos: number): number {
|
||||
if (pos >= str.length || str.charCodeAt(pos) !== 0x1b) return 0;
|
||||
|
||||
const next = str[pos + 1];
|
||||
|
||||
// CSI sequence: ESC [ ... m/G/K/H/J
|
||||
if (next === "[") {
|
||||
let j = pos + 2;
|
||||
while (j < str.length && !/[mGKHJ]/.test(str[j]!)) j++;
|
||||
if (j < str.length) return { code: str.substring(pos, j + 1), length: j + 1 - pos };
|
||||
return null;
|
||||
for (let j = pos + 2; j < str.length; j++) {
|
||||
const c = str.charCodeAt(j);
|
||||
// m, G, K, H, J
|
||||
if (c === 0x6d || c === 0x47 || c === 0x4b || c === 0x48 || c === 0x4a) return j + 1 - pos;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// OSC sequence: ESC ] ... BEL or ESC ] ... ST (ESC \)
|
||||
// Used for hyperlinks (OSC 8), window titles, etc.
|
||||
if (next === "]") {
|
||||
let j = pos + 2;
|
||||
while (j < str.length) {
|
||||
if (str[j] === "\x07") return { code: str.substring(pos, j + 1), length: j + 1 - pos };
|
||||
if (str[j] === "\x1b" && str[j + 1] === "\\") return { code: str.substring(pos, j + 2), length: j + 2 - pos };
|
||||
j++;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// APC sequence: ESC _ ... BEL or ESC _ ... ST (ESC \)
|
||||
// Used for cursor marker and application-specific commands
|
||||
if (next === "_") {
|
||||
let j = pos + 2;
|
||||
while (j < str.length) {
|
||||
if (str[j] === "\x07") return { code: str.substring(pos, j + 1), length: j + 1 - pos };
|
||||
if (str[j] === "\x1b" && str[j + 1] === "\\") return { code: str.substring(pos, j + 2), length: j + 2 - pos };
|
||||
j++;
|
||||
if (next === "]" || next === "_") {
|
||||
for (let j = pos + 2; j < str.length; j++) {
|
||||
const c = str.charCodeAt(j);
|
||||
if (c === 0x07) return j + 1 - pos;
|
||||
if (c === 0x1b && str[j + 1] === "\\") return j + 2 - pos;
|
||||
}
|
||||
return null;
|
||||
return 0;
|
||||
}
|
||||
|
||||
return null;
|
||||
return 0;
|
||||
}
|
||||
|
||||
type Osc8Terminator = "\x07" | "\x1b\\";
|
||||
@@ -731,15 +769,16 @@ class AnsiCodeTracker {
|
||||
}
|
||||
|
||||
function updateTrackerFromText(text: string, tracker: AnsiCodeTracker): void {
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const ansiResult = extractAnsiCode(text, i);
|
||||
if (ansiResult) {
|
||||
tracker.process(ansiResult.code);
|
||||
i += ansiResult.length;
|
||||
let i = text.indexOf("\x1b");
|
||||
while (i !== -1) {
|
||||
const length = ansiCodeLength(text, i);
|
||||
if (length > 0) {
|
||||
tracker.process(text.substring(i, i + length));
|
||||
i += length;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
i = text.indexOf("\x1b", i);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -750,6 +789,10 @@ export function getActiveBackgroundAnsi(text: string): string {
|
||||
return tracker.getActiveBackgroundCode();
|
||||
}
|
||||
|
||||
function* graphemeSegments(text: string): Generator<string> {
|
||||
for (const { segment } of graphemeSegmenter.segment(text)) yield segment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Split text into words while keeping ANSI codes attached.
|
||||
*/
|
||||
@@ -778,14 +821,19 @@ function splitIntoTokensWithAnsi(text: string): string[] {
|
||||
continue;
|
||||
}
|
||||
|
||||
let end = i;
|
||||
while (end < text.length && !extractAnsiCode(text, end)) {
|
||||
end++;
|
||||
// Visible text runs up to the next escape sequence.
|
||||
let end = text.indexOf("\x1b", i + 1);
|
||||
while (end !== -1 && ansiCodeLength(text, end) === 0) {
|
||||
end = text.indexOf("\x1b", end + 1);
|
||||
}
|
||||
if (end === -1) end = text.length;
|
||||
|
||||
for (const { segment } of graphemeSegmenter.segment(text.slice(i, end))) {
|
||||
const chunk = text.slice(i, end);
|
||||
// Printable ASCII characters are single graphemes, so skip the segmenter for them.
|
||||
const ascii = isPrintableAscii(chunk);
|
||||
for (const segment of ascii ? chunk : graphemeSegments(chunk)) {
|
||||
const segmentIsSpace = segment === " ";
|
||||
if (!segmentIsSpace && cjkBreakRegex.test(segment)) {
|
||||
if (!ascii && !segmentIsSpace && cjkBreakRegex.test(segment)) {
|
||||
flushCurrent();
|
||||
const token = pendingAnsi + segment;
|
||||
pendingAnsi = "";
|
||||
@@ -840,6 +888,15 @@ function splitIntoTokensWithAnsi(text: string): string[] {
|
||||
* @param width - Maximum visible width per line
|
||||
* @returns Array of wrapped lines (NOT padded to width)
|
||||
*/
|
||||
/**
|
||||
* Flatten cached lines. V8 keeps a string built by concatenation as a tree of its parts until something reads it
|
||||
* whole, and a cached line kept as such a tree retains several times its own size. Converting a string to a number
|
||||
* reads it whole, so V8 flattens it in place; the strings' values do not change.
|
||||
*/
|
||||
export function flattenLines(lines: readonly string[]): void {
|
||||
for (const line of lines) Number(line);
|
||||
}
|
||||
|
||||
export function wrapTextWithAnsi(text: string, width: number): string[] {
|
||||
if (!text) {
|
||||
return [""];
|
||||
@@ -1225,8 +1282,11 @@ export function sliceWithWidth(
|
||||
while (i < line.length) {
|
||||
const ansi = extractAnsiCode(line, i);
|
||||
if (ansi) {
|
||||
if (currentCol >= startCol && currentCol < endCol) result += ansi.code;
|
||||
else if (currentCol < startCol) pendingAnsi += ansi.code;
|
||||
if (currentCol >= startCol && currentCol < endCol) {
|
||||
// Keep original order: codes from before the range must precede codes at the boundary
|
||||
result += pendingAnsi + ansi.code;
|
||||
pendingAnsi = "";
|
||||
} else if (currentCol < startCol) pendingAnsi += ansi.code;
|
||||
i += ansi.length;
|
||||
continue;
|
||||
}
|
||||
@@ -1291,8 +1351,13 @@ export function extractSegments(
|
||||
// Include ANSI codes in their respective segments
|
||||
if (currentCol < beforeEnd) {
|
||||
pendingAnsiBefore += ansi.code;
|
||||
} else if (currentCol >= afterStart && currentCol < afterEnd && afterStarted) {
|
||||
// Only include after we've started "after" (styling already prepended)
|
||||
} else if (
|
||||
currentCol >= afterStart &&
|
||||
currentCol < afterEnd &&
|
||||
(afterStarted || ansi.code.startsWith("\x1b_"))
|
||||
) {
|
||||
// SGR codes are only included after "after" started (styling already prepended). Zero-width APC
|
||||
// markers like CURSOR_MARKER are not tracked styling, so keep them at the boundary too.
|
||||
after += ansi.code;
|
||||
}
|
||||
i += ansi.length;
|
||||
|
||||
82
third_party/pi/packages/tui/src/wheel-scroll.ts
vendored
Normal file
82
third_party/pi/packages/tui/src/wheel-scroll.ts
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
/** Lines moved per mouse-wheel event, or `"auto"` to accelerate fast wheel spins. */
|
||||
export type WheelScrollLines = number | "auto";
|
||||
|
||||
// Several events closer than this belong to one physical notch (Ghostty emits them ~4 ms apart)
|
||||
// or come from a high-resolution source. They move one line each and do not accelerate.
|
||||
const BURST_GAP_MS = 5;
|
||||
// A pause longer than this ends a scroll gesture.
|
||||
const GESTURE_GAP_MS = 200;
|
||||
// Average event gap that maps to one line per event. Faster events scale up proportionally.
|
||||
const REFERENCE_GAP_MS = 100;
|
||||
const MAX_AUTO_LINES = 6;
|
||||
|
||||
/**
|
||||
* Local macOS terminals receive wheel and trackpad deltas that the OS has already accelerated,
|
||||
* and they emit one event per line. Other platforms, and SSH sessions where the client platform
|
||||
* is unknown, usually send one event per wheel notch.
|
||||
*/
|
||||
function terminalAcceleratesWheel(): boolean {
|
||||
const env = process.env;
|
||||
return (
|
||||
process.platform === "darwin" &&
|
||||
env.SSH_CONNECTION === undefined &&
|
||||
env.SSH_CLIENT === undefined &&
|
||||
env.SSH_TTY === undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts wheel events into line counts.
|
||||
*
|
||||
* In `"auto"` mode on terminals that do not accelerate wheel input, the count follows event
|
||||
* velocity: an isolated notch moves one line, while a fast spin moves up to six lines per event.
|
||||
* For example, notches 100 ms apart move 1 line each, 50 ms apart move 2, and 20 ms apart move 5.
|
||||
*/
|
||||
export class WheelScrollAccelerator {
|
||||
private lines: WheelScrollLines;
|
||||
private readonly accelerate: boolean;
|
||||
private lastTime = Number.NEGATIVE_INFINITY;
|
||||
private lastDirection = 0;
|
||||
private averageGap: number | undefined;
|
||||
private carry = 0;
|
||||
|
||||
constructor(lines: WheelScrollLines = "auto", accelerate = !terminalAcceleratesWheel()) {
|
||||
this.lines = lines;
|
||||
this.accelerate = accelerate;
|
||||
}
|
||||
|
||||
setLines(lines: WheelScrollLines): void {
|
||||
this.lines = lines;
|
||||
this.reset();
|
||||
}
|
||||
|
||||
/** Return the positive line count for a wheel event in `direction` at time `now` (milliseconds). */
|
||||
next(direction: -1 | 1, now: number): number {
|
||||
if (this.lines !== "auto") return Number.isFinite(this.lines) ? Math.max(1, Math.floor(this.lines)) : 1;
|
||||
if (!this.accelerate) return 1;
|
||||
|
||||
const gap = now - this.lastTime;
|
||||
const sameGesture = direction === this.lastDirection && gap <= GESTURE_GAP_MS;
|
||||
this.lastTime = now;
|
||||
this.lastDirection = direction;
|
||||
if (!sameGesture) {
|
||||
this.averageGap = undefined;
|
||||
this.carry = 0;
|
||||
return 1;
|
||||
}
|
||||
if (gap < BURST_GAP_MS) return 1;
|
||||
|
||||
this.averageGap = this.averageGap === undefined ? gap : (this.averageGap + gap) / 2;
|
||||
const lines = Math.min(MAX_AUTO_LINES, Math.max(1, REFERENCE_GAP_MS / this.averageGap)) + this.carry;
|
||||
const whole = Math.floor(lines);
|
||||
this.carry = lines - whole;
|
||||
return whole;
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
this.lastTime = Number.NEGATIVE_INFINITY;
|
||||
this.lastDirection = 0;
|
||||
this.averageGap = undefined;
|
||||
this.carry = 0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user