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:
211
third_party/pi/packages/tui/src/tui.ts
vendored
211
third_party/pi/packages/tui/src/tui.ts
vendored
@@ -6,11 +6,11 @@ import { performance } from "node:perf_hooks";
|
||||
import { isKeyRelease, matchesKey } from "./keys.ts";
|
||||
import type { Terminal } from "./terminal.ts";
|
||||
import {
|
||||
isOsc11BackgroundColorResponse,
|
||||
parseOsc11BackgroundColor,
|
||||
parseOscColorResponse,
|
||||
parseTerminalColorSchemeReport,
|
||||
type RgbColor,
|
||||
type TerminalColorScheme,
|
||||
type TerminalColors,
|
||||
} from "./terminal-colors.ts";
|
||||
import { getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.ts";
|
||||
import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.ts";
|
||||
@@ -81,7 +81,13 @@ export interface TuiMouseDispatchResult extends TuiMouseEventResult {
|
||||
export function dispatchMouseEvent(component: Component, event: TuiMouseEvent): TuiMouseDispatchResult | undefined {
|
||||
const result = component.handleMouse?.(event);
|
||||
if (!result) return undefined;
|
||||
if ("target" in result) return result as TuiMouseDispatchResult;
|
||||
if ("target" in result) {
|
||||
// The component forwarded the event to a child it hosts. Like a delegating container, it routes
|
||||
// keys to that child itself, so it keeps keyboard focus. Focusing the child directly would leave
|
||||
// focus on a detached component once the host removes it, e.g. a closed settings submenu.
|
||||
const forwarded = result as TuiMouseDispatchResult;
|
||||
return forwarded.focus && component.handleInput ? { ...forwarded, focusTarget: component } : forwarded;
|
||||
}
|
||||
if (!result.handled && !result.capture && !result.focus) return undefined;
|
||||
return {
|
||||
...result,
|
||||
@@ -137,12 +143,34 @@ export interface Component {
|
||||
|
||||
export type TuiInputListenerResult = { consume?: boolean; data?: string } | undefined;
|
||||
export type TuiInputListener = (data: string) => TuiInputListenerResult;
|
||||
type PendingOsc11BackgroundQuery = {
|
||||
settled: boolean;
|
||||
resolve: ((rgb: RgbColor | undefined) => void) | undefined;
|
||||
type PendingTerminalColorQuery = {
|
||||
foreground?: RgbColor;
|
||||
background?: RgbColor;
|
||||
palette: Array<RgbColor | undefined>;
|
||||
/** Targets that already replied, so duplicates do not count twice. */
|
||||
replied: Set<string>;
|
||||
/**
|
||||
* Receives the result: the promise's resolve until the timeout, then `onLateReply`. Unset once the
|
||||
* query completed (on the DA1 reply or once every color replied); later replies are ignored.
|
||||
*/
|
||||
deliver: ((colors: TerminalColors) => void) | undefined;
|
||||
timer: NodeJS.Timeout | undefined;
|
||||
};
|
||||
|
||||
const TERMINAL_PALETTE_SIZE = 16;
|
||||
/** OSC 10 and 11 plus OSC 4 for every palette color. */
|
||||
const TERMINAL_COLOR_REPLY_COUNT = 2 + TERMINAL_PALETTE_SIZE;
|
||||
/**
|
||||
* Default colors, palette colors 0-15, and a trailing primary device attributes (DA1) request.
|
||||
* Every terminal answers DA1 and terminals answer in order, so the DA1 reply marks the end of
|
||||
* the color replies, including for terminals that ignore the color queries.
|
||||
*/
|
||||
const TERMINAL_COLOR_QUERY = `\x1b]10;?\x07\x1b]11;?\x07${Array.from(
|
||||
{ length: TERMINAL_PALETTE_SIZE },
|
||||
(_, index) => `\x1b]4;${index};?\x07`,
|
||||
).join("")}\x1b[c`;
|
||||
const DEVICE_ATTRIBUTES_RESPONSE_PATTERN = /^\x1b\[\?[\d;]*c$/;
|
||||
|
||||
/**
|
||||
* Interface for components that can receive focus and display a hardware cursor.
|
||||
* When focused, the component should emit CURSOR_MARKER at the cursor position
|
||||
@@ -167,6 +195,17 @@ export function isFocusable(component: Component | null): component is Component
|
||||
*/
|
||||
export const CURSOR_MARKER = "\x1b_pi:c\x07";
|
||||
|
||||
/** Fake cursor markers - zero-width APC sequences around the text drawn as the cursor. See resolveFakeCursors(). */
|
||||
export const FAKE_CURSOR_START = "\x1b_pi:fc\x07";
|
||||
export const FAKE_CURSOR_END = "\x1b_pi:/fc\x07";
|
||||
// CURSOR_MARKER + FAKE_CURSOR_START + text + FAKE_CURSOR_END. Truncating the line right after the cursor cell drops the end.
|
||||
const FOCUSED_FAKE_CURSOR = /(\x1b_pi:c\x07)\x1b_pi:fc\x07(.*?)(?:\x1b_pi:\/fc\x07|$)/s;
|
||||
|
||||
/** Wrap text in fake cursor markers. */
|
||||
export function renderFakeCursor(text: string): string {
|
||||
return `${FAKE_CURSOR_START}${text}${FAKE_CURSOR_END}`;
|
||||
}
|
||||
|
||||
export { visibleWidth };
|
||||
|
||||
/**
|
||||
@@ -447,8 +486,10 @@ export interface TUI extends Component {
|
||||
removeInputListener(listener: TuiInputListener): void;
|
||||
onTerminalColorSchemeChange(listener: (scheme: TerminalColorScheme) => void): () => void;
|
||||
setTerminalColorSchemeNotifications(enabled: boolean): void;
|
||||
queryTerminalBackgroundColor(options: { timeoutMs: number }): Promise<RgbColor | undefined>;
|
||||
queryTerminalColorScheme(options: { timeoutMs: number }): Promise<TerminalColorScheme | undefined>;
|
||||
queryTerminalColors(options: {
|
||||
timeoutMs: number;
|
||||
onLateReply?: (colors: TerminalColors) => void;
|
||||
}): Promise<TerminalColors>;
|
||||
}
|
||||
|
||||
export const VIEWPORT_TUI = Symbol.for("@earendil-works/pi-tui/viewport");
|
||||
@@ -479,8 +520,11 @@ export abstract class TuiBase extends Container implements TUI {
|
||||
private clearOnShrink = false;
|
||||
protected fullRedrawCount = 0;
|
||||
protected stopped = false;
|
||||
private pendingOsc11BackgroundReplies = 0;
|
||||
private pendingOsc11BackgroundQueries: PendingOsc11BackgroundQuery[] = [];
|
||||
/**
|
||||
* Color queries waiting for their DA1 reply, oldest first. Terminals answer in order, so color
|
||||
* replies belong to the oldest one. Queries stay here after a timeout to collect late replies.
|
||||
*/
|
||||
private pendingTerminalColorQueries: PendingTerminalColorQuery[] = [];
|
||||
private terminalColorSchemeListeners = new Set<(scheme: TerminalColorScheme) => void>();
|
||||
private terminalColorSchemeNotificationsEnabled = false;
|
||||
/** Directory for debug/crash logs. When undefined, debug logging is disabled and crash dumps fall back to the OS temp directory. */
|
||||
@@ -529,7 +573,7 @@ export abstract class TuiBase extends Container implements TUI {
|
||||
if (this.showHardwareCursor === enabled) return;
|
||||
this.showHardwareCursor = enabled;
|
||||
if (!enabled) {
|
||||
this.terminal.hideCursor();
|
||||
this.hideTerminalCursor();
|
||||
}
|
||||
this.requestRender();
|
||||
}
|
||||
@@ -695,7 +739,7 @@ export abstract class TuiBase extends Container implements TUI {
|
||||
if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
|
||||
this.setFocus(component);
|
||||
}
|
||||
this.terminal.hideCursor();
|
||||
this.hideTerminalCursor();
|
||||
this.requestRender();
|
||||
|
||||
// Return handle for controlling this overlay
|
||||
@@ -711,7 +755,7 @@ export abstract class TuiBase extends Container implements TUI {
|
||||
const topVisible = this.getTopmostVisibleOverlay();
|
||||
this.setFocus(topVisible?.component ?? entry.preFocus);
|
||||
}
|
||||
if (this.overlayStack.length === 0) this.terminal.hideCursor();
|
||||
if (this.overlayStack.length === 0) this.hideTerminalCursor();
|
||||
this.requestRender();
|
||||
}
|
||||
},
|
||||
@@ -793,10 +837,15 @@ export abstract class TuiBase extends Container implements TUI {
|
||||
const topVisible = this.getTopmostVisibleOverlay();
|
||||
this.setFocus(topVisible?.component ?? overlay.preFocus);
|
||||
}
|
||||
if (this.overlayStack.length === 0) this.terminal.hideCursor();
|
||||
if (this.overlayStack.length === 0) this.hideTerminalCursor();
|
||||
this.requestRender();
|
||||
}
|
||||
|
||||
/** Hide the cursor while running. After stop(), the shell owns the cursor and it must stay visible. */
|
||||
private hideTerminalCursor(): void {
|
||||
if (!this.stopped) this.terminal.hideCursor();
|
||||
}
|
||||
|
||||
/** Check if there are any visible overlays */
|
||||
hasOverlay(): boolean {
|
||||
return this.overlayStack.some((o) => this.isOverlayVisible(o));
|
||||
@@ -1004,7 +1053,7 @@ export abstract class TuiBase extends Container implements TUI {
|
||||
}
|
||||
|
||||
private handleTerminalInput(data: string): void {
|
||||
if (this.consumeOsc11BackgroundResponse(data)) {
|
||||
if (this.consumeTerminalColorResponse(data)) {
|
||||
return;
|
||||
}
|
||||
if (this.consumeTerminalColorSchemeReport(data)) {
|
||||
@@ -1081,30 +1130,52 @@ export abstract class TuiBase extends Container implements TUI {
|
||||
}
|
||||
}
|
||||
|
||||
private consumeOsc11BackgroundResponse(data: string): boolean {
|
||||
if (this.pendingOsc11BackgroundReplies <= 0) {
|
||||
private consumeTerminalColorResponse(data: string): boolean {
|
||||
const query = this.pendingTerminalColorQueries[0];
|
||||
if (!query) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isOsc11BackgroundColorResponse(data)) {
|
||||
return false;
|
||||
if (DEVICE_ATTRIBUTES_RESPONSE_PATTERN.test(data)) {
|
||||
this.pendingTerminalColorQueries.shift();
|
||||
this.completeTerminalColorQuery(query);
|
||||
return true;
|
||||
}
|
||||
|
||||
const rgb = parseOsc11BackgroundColor(data);
|
||||
this.pendingOsc11BackgroundReplies -= 1;
|
||||
const query = this.pendingOsc11BackgroundQueries.shift();
|
||||
if (query && !query.settled) {
|
||||
query.settled = true;
|
||||
if (query.timer) {
|
||||
clearTimeout(query.timer);
|
||||
query.timer = undefined;
|
||||
}
|
||||
query.resolve?.(rgb);
|
||||
query.resolve = undefined;
|
||||
const response = parseOscColorResponse(data);
|
||||
if (!response) {
|
||||
return false;
|
||||
}
|
||||
const { target, rgb } = response;
|
||||
const key = String(target);
|
||||
if (!query.deliver || query.replied.has(key)) {
|
||||
return true;
|
||||
}
|
||||
query.replied.add(key);
|
||||
if (target === "foreground") {
|
||||
query.foreground = rgb;
|
||||
} else if (target === "background") {
|
||||
query.background = rgb;
|
||||
} else if (target < TERMINAL_PALETTE_SIZE) {
|
||||
query.palette[target] = rgb;
|
||||
}
|
||||
if (query.replied.size === TERMINAL_COLOR_REPLY_COUNT) {
|
||||
this.completeTerminalColorQuery(query);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private terminalColorQueryResult(query: PendingTerminalColorQuery): TerminalColors {
|
||||
const palette = query.palette.every((color) => color !== undefined) ? (query.palette as RgbColor[]) : undefined;
|
||||
return { foreground: query.foreground, background: query.background, palette };
|
||||
}
|
||||
|
||||
private completeTerminalColorQuery(query: PendingTerminalColorQuery): void {
|
||||
const deliver = query.deliver;
|
||||
query.deliver = undefined;
|
||||
clearTimeout(query.timer);
|
||||
deliver?.(this.terminalColorQueryResult(query));
|
||||
}
|
||||
|
||||
private consumeTerminalColorSchemeReport(data: string): boolean {
|
||||
const scheme = parseTerminalColorSchemeReport(data);
|
||||
if (!scheme) {
|
||||
@@ -1299,7 +1370,7 @@ export abstract class TuiBase extends Container implements TUI {
|
||||
const { width, maxHeight } = this.resolveOverlayLayout(options, 0, termWidth, termHeight);
|
||||
|
||||
// Render component at calculated width
|
||||
let overlayLines = component.render(width);
|
||||
let overlayLines = this.resolveFakeCursors(component.render(width));
|
||||
|
||||
// Apply maxHeight if specified
|
||||
if (maxHeight !== undefined && overlayLines.length > maxHeight) {
|
||||
@@ -1400,57 +1471,47 @@ export abstract class TuiBase extends Container implements TUI {
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the terminal's default background color with OSC 11 (`ESC ] 11 ; ? BEL`).
|
||||
* @param timeoutMs Query timeout in milliseconds.
|
||||
* @returns Promise containing the parsed RGB color, or undefined if it times out or fails to parse.
|
||||
* Resolve fake cursor markers to reverse video. When the hardware cursor is shown, a fake cursor
|
||||
* directly after CURSOR_MARKER is dropped because the terminal cursor is drawn there. Fake cursors
|
||||
* of components without focus have no CURSOR_MARKER and stay visible.
|
||||
*/
|
||||
queryTerminalBackgroundColor({ timeoutMs }: { timeoutMs: number }): Promise<RgbColor | undefined> {
|
||||
return new Promise((resolve) => {
|
||||
const query: PendingOsc11BackgroundQuery = {
|
||||
settled: false,
|
||||
resolve,
|
||||
timer: undefined,
|
||||
};
|
||||
|
||||
query.timer = setTimeout(() => {
|
||||
if (query.settled) {
|
||||
return;
|
||||
}
|
||||
query.settled = true;
|
||||
query.timer = undefined;
|
||||
query.resolve?.(undefined);
|
||||
query.resolve = undefined;
|
||||
}, timeoutMs);
|
||||
this.pendingOsc11BackgroundQueries.push(query);
|
||||
this.pendingOsc11BackgroundReplies += 1;
|
||||
this.terminal.write("\x1b]11;?\x07");
|
||||
protected resolveFakeCursors(lines: string[]): string[] {
|
||||
return lines.map((line) => {
|
||||
if (!line.includes(FAKE_CURSOR_START)) return line;
|
||||
const resolved = this.showHardwareCursor ? line.replace(FOCUSED_FAKE_CURSOR, "$1$2") : line;
|
||||
return resolved.replaceAll(FAKE_CURSOR_START, "\x1b[7m").replaceAll(FAKE_CURSOR_END, "\x1b[27m");
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Query the terminal's color-scheme preference with DSR (`CSI ? 996 n`).
|
||||
* Terminals that support the color palette notification protocol reply with
|
||||
* `CSI ? 997 ; 1 n` for dark or `CSI ? 997 ; 2 n` for light.
|
||||
* Query the terminal's theme colors: the default foreground (OSC 10), the default background
|
||||
* (OSC 11), and ANSI colors 0-15 (OSC 4), followed by a DA1 request that marks the end of the
|
||||
* replies. Resolves when the DA1 reply or all color replies arrive, or when the timeout expires.
|
||||
* Colors the terminal did not report are undefined; the palette is only set when all 16 arrived.
|
||||
* @param timeoutMs Query timeout in milliseconds, for terminals that do not answer DA1 either.
|
||||
* @param onLateReply Receives the replies if the query completes after the timeout, e.g. over slow links.
|
||||
*/
|
||||
queryTerminalColorScheme({ timeoutMs }: { timeoutMs: number }): Promise<TerminalColorScheme | undefined> {
|
||||
queryTerminalColors({
|
||||
timeoutMs,
|
||||
onLateReply,
|
||||
}: {
|
||||
timeoutMs: number;
|
||||
onLateReply?: (colors: TerminalColors) => void;
|
||||
}): Promise<TerminalColors> {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
let unsubscribe: () => void = () => {};
|
||||
const settle = (scheme: TerminalColorScheme | undefined) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
unsubscribe();
|
||||
resolve(scheme);
|
||||
const query: PendingTerminalColorQuery = {
|
||||
palette: Array.from({ length: TERMINAL_PALETTE_SIZE }, () => undefined),
|
||||
replied: new Set(),
|
||||
deliver: resolve,
|
||||
timer: undefined,
|
||||
};
|
||||
|
||||
unsubscribe = this.onTerminalColorSchemeChange(settle);
|
||||
timer = setTimeout(() => settle(undefined), timeoutMs);
|
||||
this.terminal.write("\x1b[?996n");
|
||||
// Resolve with the replies so far, and keep collecting late replies for `onLateReply`.
|
||||
query.timer = setTimeout(() => {
|
||||
query.deliver = onLateReply;
|
||||
resolve(this.terminalColorQueryResult(query));
|
||||
}, timeoutMs);
|
||||
this.pendingTerminalColorQueries.push(query);
|
||||
this.terminal.write(TERMINAL_COLOR_QUERY);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user