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:
1 parent
719370c8f7
commit
6081e3e325
1743 files changed
+205459
-131704
No files matched your search
+14
-9
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user