[READ-ONLY] Mirror of https://github.com/bombshell-dev/tty. Platform independent 2D layout engine for terminal applications based on Clay bomb.sh/docs/tty
Something went wrong. Try again.
tty terminfo.ts
9.4 kB · 315 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316import { readFile } from "node:fs/promises";import { pathToFileURL } from "node:url";import process from "node:process";
import { compiled } from "./wasm.ts";import { offsets, struct, uint32 } from "./typedef.ts";
export const MAX_TERMINFO_ENTRY = 32768;
/* Flag bits — must match src/terminfo.h. */const FLAG_TRUECOLOR = 1 << 0;const FLAG_BACK_COLOR_ERASE = 1 << 1;const FLAG_AUTO_RIGHT_MARGIN = 1 << 2;const FLAG_EAT_NEWLINE_GLITCH = 1 << 3;const FLAG_ALTSCREEN = 1 << 4;const FLAG_STYLED_UNDERLINE = 1 << 5;
const TermInfoStruct = struct({ generation: uint32(), colors: uint32(), flags: uint32(), confirmed: uint32(), theme_fg: uint32(), theme_bg: uint32(), theme_cursor: uint32(),});
const TI = offsets(TermInfoStruct);
/** 8-bit RGB, each channel 0–255. */export interface Rgb { r: number; g: number; b: number;}
/** Static terminal capabilities, frozen at detection time. No WASM backing. */export interface Capabilities { readonly colors: number; readonly trueColor: boolean; readonly bce: boolean; readonly autoMargin: boolean; readonly xenl: boolean; readonly altScreen: boolean; readonly styledUnderline: boolean; readonly pointerShape: boolean;}
/** Opaque type alias for the raw terminfo key-sequence bytes. */export type KeyTable = Uint8Array;
/** Result of detectTerminal(). */export interface TerminalInfo { /** Static capabilities, frozen at detection time. */ readonly capabilities: Capabilities; /** * Write to stdout immediately after detection. Probe responses arrive * as CapabilityEvent values in input.scan(). */ readonly probe: Uint8Array; /** Raw terminfo key bytes. The input parser seeds its trie from this. */ readonly keys: KeyTable;}
export interface DetectOptions { /** Terminal name to resolve. Defaults to env.TERM. */ term?: string; /** * Environment for TERM/TERMINFO/TERMINFO_DIRS/HOME/COLORTERM lookups. * Defaults to process.env. Injectable for testing. */ env?: Record<string, string | undefined>; /** Raw compiled terminfo bytes; skips the filesystem lookup. */ entry?: Uint8Array; signal?: AbortSignal;}
const encoder = new TextEncoder();
const PROBE = encoder.encode( "\x1b]10;?\x07" + "\x1b]11;?\x07" + "\x1b]12;?\x07" + "\x1b]21;foreground=?;background=?;cursor=?\x1b\\" + "\x1b]22;?__current__\x1b\\" + "\x1bP+q524742;5463\x1b\\" + "\x1b[?2026$p" + "\x1b[?u" + "\x1b_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA\x1b\\" + "\x1b[c",);
const WASM_PAGE_BYTES = 65536;
/** * The known OSC 22 pointer-shape support table (terminfo-spec §6.1): terminals * that implement OSC 22 pointer shape setting but never answer its support * query, identified by environment evidence. Entries require primary evidence * from the terminal's own documentation. Each entry records the version that * introduced support; a stale entry degrades to the same silent swallowing the * capability gate already produces, never to garbage. */function knownPointerShape( env: Record<string, string | undefined>,): boolean { // ghostty: OSC 22 since 1.0.0 (ghostty.org/docs/vt/osc/22); set only — // the reference documents no query form. if (env.TERM === "xterm-ghostty" || env.TERM_PROGRAM === "ghostty") { return true; } // kitty: full kitty pointer-shapes protocol since 0.31.0, including the // query (sw.kovidgoyal.net/kitty/pointer-shapes/). Seeded here so // term.capabilities is correct before the probe reply arrives. if (env.TERM === "xterm-kitty" || env.KITTY_WINDOW_ID !== undefined) { return true; } // foot: OSC 22 sets the xcursor pointer (foot-ctlseqs(7)); no query form. if (env.TERM?.startsWith("foot")) { return true; } // xterm: OSC 22 since patch 367 (ctlseqs); XTERM_VERSION is `xterm(N)`. let patch = env.XTERM_VERSION?.match(/xterm\((\d+)\)/); if (patch && Number(patch[1]) >= 367) { return true; } return false;}
function rgbOf(packed: number): Rgb { return { r: (packed >> 16) & 0xff, g: (packed >> 8) & 0xff, b: packed & 0xff, };}
/** * Detect the terminal's capabilities from the compiled terminfo entry and * process environment. Pure: performs no IO beyond reading the terminfo file. * Rejects only when `entry` exceeds MAX_TERMINFO_ENTRY (caller error); * environmental failures resolve to the baseline. See specs/terminfo-spec.md. */export async function detectTerminal( options: DetectOptions = {},): Promise<TerminalInfo> { let env = options.env ?? process.env; let bytes = options.entry;
if (bytes && bytes.byteLength > MAX_TERMINFO_ENTRY) { throw new RangeError( `terminfo entry exceeds ${MAX_TERMINFO_ENTRY} byte limit (got ${bytes.byteLength})`, ); }
if (!bytes) { bytes = await loadTerminfo({ term: options.term, env }); }
let memory = new WebAssembly.Memory({ initial: 2 }); let instance = await WebAssembly.instantiate(compiled, { env: { memory }, clay: { measureTextFunction() {}, queryScrollOffsetFunction(ret: number) { let v = new DataView(memory.buffer); v.setFloat32(ret, 0, true); v.setFloat32(ret + 4, 0, true); }, }, });
let exports = instance.exports as unknown as { __heap_base: WebAssembly.Global; terminfo_size(): number; terminfo_init(mem: number): number; terminfo_parse(bytes: number, len: number, ti: number): number; terminfo_grant(ti: number, flags: number): void; };
let top = ((exports.__heap_base.value as number) + 7) & ~7; function alloc(size: number): number { let ptr = top; top += (size + 7) & ~7; let pages = Math.ceil(top / WASM_PAGE_BYTES); let current = memory.buffer.byteLength / WASM_PAGE_BYTES; if (pages > current) memory.grow(pages - current); return ptr; }
let structPtr = alloc(exports.terminfo_size()); exports.terminfo_init(structPtr);
let keys: KeyTable = new Uint8Array(0); if (bytes) { let bytesPtr = alloc(MAX_TERMINFO_ENTRY); new Uint8Array(memory.buffer).set(bytes, bytesPtr); if (exports.terminfo_parse(bytesPtr, bytes.byteLength, structPtr) === 0) { keys = bytes.slice(); } }
let colorterm = env.COLORTERM; if (colorterm === "truecolor" || colorterm === "24bit") { exports.terminfo_grant(structPtr, FLAG_TRUECOLOR); }
let view = new DataView(memory.buffer); let flags = view.getUint32(structPtr + TI.flags, true); let capabilities: Capabilities = Object.freeze({ colors: view.getUint32(structPtr + TI.colors, true), trueColor: !!(flags & FLAG_TRUECOLOR), bce: !!(flags & FLAG_BACK_COLOR_ERASE), autoMargin: !!(flags & FLAG_AUTO_RIGHT_MARGIN), xenl: !!(flags & FLAG_EAT_NEWLINE_GLITCH), altScreen: !!(flags & FLAG_ALTSCREEN), styledUnderline: !!(flags & FLAG_STYLED_UNDERLINE), pointerShape: knownPointerShape(env), });
return Object.freeze<TerminalInfo>({ capabilities, probe: PROBE.slice(), keys, });}
/* ── terminfo filesystem lookup ───────────────────────────────────── */
interface LoadOptions { term?: string; env: Record<string, string | undefined>;}
const DEFAULT_DIRS = [ "/usr/share/terminfo", "/etc/terminfo", "/lib/terminfo", "/usr/lib/terminfo",];
const MAGIC_LEGACY = 0x011a;const MAGIC_EXTENDED = 0x021e;
function hasTerminfoMagic(bytes: Uint8Array): boolean { if (bytes.length < 2) return false; let magic = bytes[0] | (bytes[1] << 8); return magic === MAGIC_LEGACY || magic === MAGIC_EXTENDED;}
function dirUrl(path: string): URL { return pathToFileURL(path.endsWith("/") ? path : `${path}/`);}
function searchPath(env: Record<string, string | undefined>): URL[] { let dirs: URL[] = []; let seen = new Set<string>(); let add = (url: URL): void => { if (!seen.has(url.href)) { seen.add(url.href); dirs.push(url); } }; if (env.TERMINFO) add(dirUrl(env.TERMINFO)); if (env.HOME) add(new URL(".terminfo/", dirUrl(env.HOME))); if (env.TERMINFO_DIRS) { for (let entry of env.TERMINFO_DIRS.split(":")) { if (entry === "") { for (let dir of DEFAULT_DIRS) add(dirUrl(dir)); } else { add(dirUrl(entry)); } } } for (let dir of DEFAULT_DIRS) add(dirUrl(dir)); return dirs;}
function candidates(base: URL, name: string): URL[] { let first = name[0]; let hex = first.charCodeAt(0).toString(16).padStart(2, "0"); return [new URL(`${first}/${name}`, base), new URL(`${hex}/${name}`, base)];}
async function tryRead(url: URL): Promise<Uint8Array | undefined> { try { return await readFile(url); } catch { return undefined; }}
async function loadTerminfo( options: LoadOptions,): Promise<Uint8Array | undefined> { let name = options.term ?? options.env.TERM; if (!name) return undefined; if ( name.startsWith(".") || name.includes("/") || name.includes("\\") || name.includes("\0") ) { return undefined; } for (let base of searchPath(options.env)) { for (let url of candidates(base, name)) { let b = await tryRead(url); if (b && b.byteLength <= MAX_TERMINFO_ENTRY && hasTerminfoMagic(b)) { return b; } } } return undefined;}
/* Re-export Rgb helper for consumers that need it. */export { rgbOf };