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:
alexion committed 2026-10-15 17:58:48 -04:00
1 parent 719370c8f7
commit 6081e3e325
1743 files changed
+205459 -131704

No files matched your search

+14 -9
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;
}
}
+19 -7
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)
+42 -2
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,
+11 -4
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
+13 -3
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;
+7 -1
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;