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

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