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:
2026-10-15 17:58:48 -04:00
parent 719370c8f7
commit 6081e3e325
1743 changed files with 205460 additions and 131705 deletions

View File

@@ -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);

View 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}`;
}

View File

@@ -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;
}
}

View File

@@ -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)

View File

@@ -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,

View File

@@ -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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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";

View File

@@ -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 {

View File

@@ -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

View File

@@ -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()))

View File

@@ -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 {

View 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
View 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 };
}

View 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\\`;
}

View File

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

View File

@@ -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)) {

View File

@@ -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) {

View File

@@ -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

View File

@@ -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) {

View File

@@ -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) {

View File

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

View File

@@ -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;

View 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;
}
}