This commit is contained in:
ZacharyZhang-NY committed 2026-04-18 22:45:41 -04:00
1 parent 1261c2431f
commit dac59f56c2
680 files changed
+192574

No files matched your search

@@ -0,0 +1,52 @@
{
"name": "@mariozechner/pi-tui",
"version": "0.67.68",
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
"type": "module",
"main": "dist/index.js",
"scripts": {
"clean": "shx rm -rf dist",
"build": "tsgo -p tsconfig.build.json",
"dev": "tsgo -p tsconfig.build.json --watch --preserveWatchOutput",
"test": "node --test --import tsx test/*.test.ts",
"prepublishOnly": "npm run clean && npm run build"
},
"files": [
"dist/**/*",
"README.md"
],
"keywords": [
"tui",
"terminal",
"ui",
"text-editor",
"differential-rendering",
"typescript",
"cli"
],
"author": "Mario Zechner",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/badlogic/pi-mono.git",
"directory": "packages/tui"
},
"engines": {
"node": ">=20.0.0"
},
"types": "./dist/index.d.ts",
"dependencies": {
"@types/mime-types": "^2.1.4",
"chalk": "^5.5.0",
"get-east-asian-width": "^1.3.0",
"marked": "^15.0.12",
"mime-types": "^3.0.1"
},
"optionalDependencies": {
"koffi": "^2.9.0"
},
"devDependencies": {
"@xterm/headless": "^5.5.0",
"@xterm/xterm": "^5.5.0"
}
}
@@ -0,0 +1,780 @@
import { spawn } from "child_process";
import { readdirSync, statSync } from "fs";
import { homedir } from "os";
import { basename, dirname, join } from "path";
import { fuzzyFilter } from "./fuzzy.js";
const PATH_DELIMITERS = new Set([" ", "\t", '"', "'", "="]);
function toDisplayPath(value: string): string {
return value.replace(/\\/g, "/");
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function buildFdPathQuery(query: string): string {
const normalized = toDisplayPath(query);
if (!normalized.includes("/")) {
return normalized;
}
const hasTrailingSeparator = normalized.endsWith("/");
const trimmed = normalized.replace(/^\/+|\/+$/g, "");
if (!trimmed) {
return normalized;
}
const separatorPattern = "[\\\\/]";
const segments = trimmed
.split("/")
.filter(Boolean)
.map((segment) => escapeRegex(segment));
if (segments.length === 0) {
return normalized;
}
let pattern = segments.join(separatorPattern);
if (hasTrailingSeparator) {
pattern += separatorPattern;
}
return pattern;
}
function findLastDelimiter(text: string): number {
for (let i = text.length - 1; i >= 0; i -= 1) {
if (PATH_DELIMITERS.has(text[i] ?? "")) {
return i;
}
}
return -1;
}
function findUnclosedQuoteStart(text: string): number | null {
let inQuotes = false;
let quoteStart = -1;
for (let i = 0; i < text.length; i += 1) {
if (text[i] === '"') {
inQuotes = !inQuotes;
if (inQuotes) {
quoteStart = i;
}
}
}
return inQuotes ? quoteStart : null;
}
function isTokenStart(text: string, index: number): boolean {
return index === 0 || PATH_DELIMITERS.has(text[index - 1] ?? "");
}
function extractQuotedPrefix(text: string): string | null {
const quoteStart = findUnclosedQuoteStart(text);
if (quoteStart === null) {
return null;
}
if (quoteStart > 0 && text[quoteStart - 1] === "@") {
if (!isTokenStart(text, quoteStart - 1)) {
return null;
}
return text.slice(quoteStart - 1);
}
if (!isTokenStart(text, quoteStart)) {
return null;
}
return text.slice(quoteStart);
}
function parsePathPrefix(prefix: string): { rawPrefix: string; isAtPrefix: boolean; isQuotedPrefix: boolean } {
if (prefix.startsWith('@"')) {
return { rawPrefix: prefix.slice(2), isAtPrefix: true, isQuotedPrefix: true };
}
if (prefix.startsWith('"')) {
return { rawPrefix: prefix.slice(1), isAtPrefix: false, isQuotedPrefix: true };
}
if (prefix.startsWith("@")) {
return { rawPrefix: prefix.slice(1), isAtPrefix: true, isQuotedPrefix: false };
}
return { rawPrefix: prefix, isAtPrefix: false, isQuotedPrefix: false };
}
function buildCompletionValue(
path: string,
options: { isDirectory: boolean; isAtPrefix: boolean; isQuotedPrefix: boolean },
): string {
const needsQuotes = options.isQuotedPrefix || path.includes(" ");
const prefix = options.isAtPrefix ? "@" : "";
if (!needsQuotes) {
return `${prefix}${path}`;
}
const openQuote = `${prefix}"`;
const closeQuote = '"';
return `${openQuote}${path}${closeQuote}`;
}
// Use fd to walk directory tree (fast, respects .gitignore)
async function walkDirectoryWithFd(
baseDir: string,
fdPath: string,
query: string,
maxResults: number,
signal: AbortSignal,
): Promise<Array<{ path: string; isDirectory: boolean }>> {
const args = [
"--base-directory",
baseDir,
"--max-results",
String(maxResults),
"--type",
"f",
"--type",
"d",
"--full-path",
"--hidden",
"--exclude",
".git",
"--exclude",
".git/*",
"--exclude",
".git/**",
];
if (query) {
args.push(buildFdPathQuery(query));
}
return await new Promise((resolve) => {
if (signal.aborted) {
resolve([]);
return;
}
const child = spawn(fdPath, args, {
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let resolved = false;
const finish = (results: Array<{ path: string; isDirectory: boolean }>) => {
if (resolved) return;
resolved = true;
signal.removeEventListener("abort", onAbort);
resolve(results);
};
const onAbort = () => {
if (child.exitCode === null) {
child.kill("SIGKILL");
}
};
signal.addEventListener("abort", onAbort, { once: true });
child.stdout.setEncoding("utf-8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.on("error", () => {
finish([]);
});
child.on("close", (code) => {
if (signal.aborted || code !== 0 || !stdout) {
finish([]);
return;
}
const lines = stdout.trim().split("\n").filter(Boolean);
const results: Array<{ path: string; isDirectory: boolean }> = [];
for (const line of lines) {
const displayLine = toDisplayPath(line);
const hasTrailingSeparator = displayLine.endsWith("/");
const normalizedPath = hasTrailingSeparator ? displayLine.slice(0, -1) : displayLine;
if (normalizedPath === ".git" || normalizedPath.startsWith(".git/") || normalizedPath.includes("/.git/")) {
continue;
}
results.push({
path: displayLine,
isDirectory: hasTrailingSeparator,
});
}
finish(results);
});
});
}
export interface AutocompleteItem {
value: string;
label: string;
description?: string;
}
type Awaitable<T> = T | Promise<T>;
export interface SlashCommand {
name: string;
description?: string;
argumentHint?: string;
// Function to get argument completions for this command
// Returns null if no argument completion is available
getArgumentCompletions?(argumentPrefix: string): Awaitable<AutocompleteItem[] | null>;
}
export interface AutocompleteSuggestions {
items: AutocompleteItem[];
prefix: string; // What we're matching against (e.g., "/" or "src/")
}
export interface AutocompleteProvider {
// Get autocomplete suggestions for current text/cursor position
// Returns null if no suggestions available
getSuggestions(
lines: string[],
cursorLine: number,
cursorCol: number,
options: { signal: AbortSignal; force?: boolean },
): Promise<AutocompleteSuggestions | null>;
// Apply the selected item
// Returns the new text and cursor position
applyCompletion(
lines: string[],
cursorLine: number,
cursorCol: number,
item: AutocompleteItem,
prefix: string,
): {
lines: string[];
cursorLine: number;
cursorCol: number;
};
}
// Combined provider that handles both slash commands and file paths
export class CombinedAutocompleteProvider implements AutocompleteProvider {
private commands: (SlashCommand | AutocompleteItem)[];
private basePath: string;
private fdPath: string | null;
constructor(
commands: (SlashCommand | AutocompleteItem)[] = [],
basePath: string = process.cwd(),
fdPath: string | null = null,
) {
this.commands = commands;
this.basePath = basePath;
this.fdPath = fdPath;
}
async getSuggestions(
lines: string[],
cursorLine: number,
cursorCol: number,
options: { signal: AbortSignal; force?: boolean },
): Promise<AutocompleteSuggestions | null> {
const currentLine = lines[cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, cursorCol);
const atPrefix = this.extractAtPrefix(textBeforeCursor);
if (atPrefix) {
const { rawPrefix, isQuotedPrefix } = parsePathPrefix(atPrefix);
const suggestions = await this.getFuzzyFileSuggestions(rawPrefix, {
isQuotedPrefix,
signal: options.signal,
});
if (suggestions.length === 0) return null;
return {
items: suggestions,
prefix: atPrefix,
};
}
if (!options.force && textBeforeCursor.startsWith("/")) {
const spaceIndex = textBeforeCursor.indexOf(" ");
if (spaceIndex === -1) {
const prefix = textBeforeCursor.slice(1);
const commandItems = this.commands.map((cmd) => {
const name = "name" in cmd ? cmd.name : cmd.value;
const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
const desc = cmd.description ?? "";
const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;
return {
name,
label: name,
description: fullDesc || undefined,
};
});
const filtered = fuzzyFilter(commandItems, prefix, (item) => item.name).map((item) => ({
value: item.name,
label: item.label,
...(item.description && { description: item.description }),
}));
if (filtered.length === 0) return null;
return {
items: filtered,
prefix: textBeforeCursor,
};
}
const commandName = textBeforeCursor.slice(1, spaceIndex);
const argumentText = textBeforeCursor.slice(spaceIndex + 1);
const command = this.commands.find((cmd) => {
const name = "name" in cmd ? cmd.name : cmd.value;
return name === commandName;
});
if (!command || !("getArgumentCompletions" in command) || !command.getArgumentCompletions) {
return null;
}
const argumentSuggestions = await command.getArgumentCompletions(argumentText);
if (!Array.isArray(argumentSuggestions) || argumentSuggestions.length === 0) {
return null;
}
return {
items: argumentSuggestions,
prefix: argumentText,
};
}
const pathMatch = this.extractPathPrefix(textBeforeCursor, options.force ?? false);
if (pathMatch === null) {
return null;
}
const suggestions = this.getFileSuggestions(pathMatch);
if (suggestions.length === 0) return null;
return {
items: suggestions,
prefix: pathMatch,
};
}
applyCompletion(
lines: string[],
cursorLine: number,
cursorCol: number,
item: AutocompleteItem,
prefix: string,
): { lines: string[]; cursorLine: number; cursorCol: number } {
const currentLine = lines[cursorLine] || "";
const beforePrefix = currentLine.slice(0, cursorCol - prefix.length);
const afterCursor = currentLine.slice(cursorCol);
const isQuotedPrefix = prefix.startsWith('"') || prefix.startsWith('@"');
const hasLeadingQuoteAfterCursor = afterCursor.startsWith('"');
const hasTrailingQuoteInItem = item.value.endsWith('"');
const adjustedAfterCursor =
isQuotedPrefix && hasTrailingQuoteInItem && hasLeadingQuoteAfterCursor ? afterCursor.slice(1) : afterCursor;
// Check if we're completing a slash command (prefix starts with "/" but NOT a file path)
// Slash commands are at the start of the line and don't contain path separators after the first /
const isSlashCommand = prefix.startsWith("/") && beforePrefix.trim() === "" && !prefix.slice(1).includes("/");
if (isSlashCommand) {
// This is a command name completion
const newLine = `${beforePrefix}/${item.value} ${adjustedAfterCursor}`;
const newLines = [...lines];
newLines[cursorLine] = newLine;
return {
lines: newLines,
cursorLine,
cursorCol: beforePrefix.length + item.value.length + 2, // +2 for "/" and space
};
}
// Check if we're completing a file attachment (prefix starts with "@")
if (prefix.startsWith("@")) {
// This is a file attachment completion
// Don't add space after directories so user can continue autocompleting
const isDirectory = item.label.endsWith("/");
const suffix = isDirectory ? "" : " ";
const newLine = `${beforePrefix + item.value}${suffix}${adjustedAfterCursor}`;
const newLines = [...lines];
newLines[cursorLine] = newLine;
const hasTrailingQuote = item.value.endsWith('"');
const cursorOffset = isDirectory && hasTrailingQuote ? item.value.length - 1 : item.value.length;
return {
lines: newLines,
cursorLine,
cursorCol: beforePrefix.length + cursorOffset + suffix.length,
};
}
// Check if we're in a slash command context (beforePrefix contains "/command ")
const textBeforeCursor = currentLine.slice(0, cursorCol);
if (textBeforeCursor.includes("/") && textBeforeCursor.includes(" ")) {
// This is likely a command argument completion
const newLine = beforePrefix + item.value + adjustedAfterCursor;
const newLines = [...lines];
newLines[cursorLine] = newLine;
const isDirectory = item.label.endsWith("/");
const hasTrailingQuote = item.value.endsWith('"');
const cursorOffset = isDirectory && hasTrailingQuote ? item.value.length - 1 : item.value.length;
return {
lines: newLines,
cursorLine,
cursorCol: beforePrefix.length + cursorOffset,
};
}
// For file paths, complete the path
const newLine = beforePrefix + item.value + adjustedAfterCursor;
const newLines = [...lines];
newLines[cursorLine] = newLine;
const isDirectory = item.label.endsWith("/");
const hasTrailingQuote = item.value.endsWith('"');
const cursorOffset = isDirectory && hasTrailingQuote ? item.value.length - 1 : item.value.length;
return {
lines: newLines,
cursorLine,
cursorCol: beforePrefix.length + cursorOffset,
};
}
// Extract @ prefix for fuzzy file suggestions
private extractAtPrefix(text: string): string | null {
const quotedPrefix = extractQuotedPrefix(text);
if (quotedPrefix?.startsWith('@"')) {
return quotedPrefix;
}
const lastDelimiterIndex = findLastDelimiter(text);
const tokenStart = lastDelimiterIndex === -1 ? 0 : lastDelimiterIndex + 1;
if (text[tokenStart] === "@") {
return text.slice(tokenStart);
}
return null;
}
// Extract a path-like prefix from the text before cursor
private extractPathPrefix(text: string, forceExtract: boolean = false): string | null {
const quotedPrefix = extractQuotedPrefix(text);
if (quotedPrefix) {
return quotedPrefix;
}
const lastDelimiterIndex = findLastDelimiter(text);
const pathPrefix = lastDelimiterIndex === -1 ? text : text.slice(lastDelimiterIndex + 1);
// For forced extraction (Tab key), always return something
if (forceExtract) {
return pathPrefix;
}
// For natural triggers, return if it looks like a path, ends with /, starts with ~/, .
// Only return empty string if the text looks like it's starting a path context
if (pathPrefix.includes("/") || pathPrefix.startsWith(".") || pathPrefix.startsWith("~/")) {
return pathPrefix;
}
// Return empty string only after a space (not for completely empty text)
// Empty text should not trigger file suggestions - that's for forced Tab completion
if (pathPrefix === "" && text.endsWith(" ")) {
return pathPrefix;
}
return null;
}
// Expand home directory (~/) to actual home path
private expandHomePath(path: string): string {
if (path.startsWith("~/")) {
const expandedPath = join(homedir(), path.slice(2));
// Preserve trailing slash if original path had one
return path.endsWith("/") && !expandedPath.endsWith("/") ? `${expandedPath}/` : expandedPath;
} else if (path === "~") {
return homedir();
}
return path;
}
private resolveScopedFuzzyQuery(rawQuery: string): { baseDir: string; query: string; displayBase: string } | null {
const normalizedQuery = toDisplayPath(rawQuery);
const slashIndex = normalizedQuery.lastIndexOf("/");
if (slashIndex === -1) {
return null;
}
const displayBase = normalizedQuery.slice(0, slashIndex + 1);
const query = normalizedQuery.slice(slashIndex + 1);
let baseDir: string;
if (displayBase.startsWith("~/")) {
baseDir = this.expandHomePath(displayBase);
} else if (displayBase.startsWith("/")) {
baseDir = displayBase;
} else {
baseDir = join(this.basePath, displayBase);
}
try {
if (!statSync(baseDir).isDirectory()) {
return null;
}
} catch {
return null;
}
return { baseDir, query, displayBase };
}
private scopedPathForDisplay(displayBase: string, relativePath: string): string {
const normalizedRelativePath = toDisplayPath(relativePath);
if (displayBase === "/") {
return `/${normalizedRelativePath}`;
}
return `${toDisplayPath(displayBase)}${normalizedRelativePath}`;
}
// Get file/directory suggestions for a given path prefix
private getFileSuggestions(prefix: string): AutocompleteItem[] {
try {
let searchDir: string;
let searchPrefix: string;
const { rawPrefix, isAtPrefix, isQuotedPrefix } = parsePathPrefix(prefix);
let expandedPrefix = rawPrefix;
// Handle home directory expansion
if (expandedPrefix.startsWith("~")) {
expandedPrefix = this.expandHomePath(expandedPrefix);
}
const isRootPrefix =
rawPrefix === "" ||
rawPrefix === "./" ||
rawPrefix === "../" ||
rawPrefix === "~" ||
rawPrefix === "~/" ||
rawPrefix === "/" ||
(isAtPrefix && rawPrefix === "");
if (isRootPrefix) {
// Complete from specified position
if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
searchDir = expandedPrefix;
} else {
searchDir = join(this.basePath, expandedPrefix);
}
searchPrefix = "";
} else if (rawPrefix.endsWith("/")) {
// If prefix ends with /, show contents of that directory
if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
searchDir = expandedPrefix;
} else {
searchDir = join(this.basePath, expandedPrefix);
}
searchPrefix = "";
} else {
// Split into directory and file prefix
const dir = dirname(expandedPrefix);
const file = basename(expandedPrefix);
if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
searchDir = dir;
} else {
searchDir = join(this.basePath, dir);
}
searchPrefix = file;
}
const entries = readdirSync(searchDir, { withFileTypes: true });
const suggestions: AutocompleteItem[] = [];
for (const entry of entries) {
if (!entry.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) {
continue;
}
// Check if entry is a directory (or a symlink pointing to a directory)
let isDirectory = entry.isDirectory();
if (!isDirectory && entry.isSymbolicLink()) {
try {
const fullPath = join(searchDir, entry.name);
isDirectory = statSync(fullPath).isDirectory();
} catch {
// Broken symlink or permission error - treat as file
}
}
let relativePath: string;
const name = entry.name;
const displayPrefix = rawPrefix;
if (displayPrefix.endsWith("/")) {
// If prefix ends with /, append entry to the prefix
relativePath = displayPrefix + name;
} else if (displayPrefix.includes("/") || displayPrefix.includes("\\")) {
// Preserve ~/ format for home directory paths
if (displayPrefix.startsWith("~/")) {
const homeRelativeDir = displayPrefix.slice(2); // Remove ~/
const dir = dirname(homeRelativeDir);
relativePath = `~/${dir === "." ? name : join(dir, name)}`;
} else if (displayPrefix.startsWith("/")) {
// Absolute path - construct properly
const dir = dirname(displayPrefix);
if (dir === "/") {
relativePath = `/${name}`;
} else {
relativePath = `${dir}/${name}`;
}
} else {
relativePath = join(dirname(displayPrefix), name);
// path.join normalizes away ./ prefix, preserve it
if (displayPrefix.startsWith("./") && !relativePath.startsWith("./")) {
relativePath = `./${relativePath}`;
}
}
} else {
// For standalone entries, preserve ~/ if original prefix was ~/
if (displayPrefix.startsWith("~")) {
relativePath = `~/${name}`;
} else {
relativePath = name;
}
}
relativePath = toDisplayPath(relativePath);
const pathValue = isDirectory ? `${relativePath}/` : relativePath;
const value = buildCompletionValue(pathValue, {
isDirectory,
isAtPrefix,
isQuotedPrefix,
});
suggestions.push({
value,
label: name + (isDirectory ? "/" : ""),
});
}
// Sort directories first, then alphabetically
suggestions.sort((a, b) => {
const aIsDir = a.value.endsWith("/");
const bIsDir = b.value.endsWith("/");
if (aIsDir && !bIsDir) return -1;
if (!aIsDir && bIsDir) return 1;
return a.label.localeCompare(b.label);
});
return suggestions;
} catch (_e) {
// Directory doesn't exist or not accessible
return [];
}
}
// Score an entry against the query (higher = better match)
// isDirectory adds bonus to prioritize folders
private scoreEntry(filePath: string, query: string, isDirectory: boolean): number {
const fileName = basename(filePath);
const lowerFileName = fileName.toLowerCase();
const lowerQuery = query.toLowerCase();
let score = 0;
// Exact filename match (highest)
if (lowerFileName === lowerQuery) score = 100;
// Filename starts with query
else if (lowerFileName.startsWith(lowerQuery)) score = 80;
// Substring match in filename
else if (lowerFileName.includes(lowerQuery)) score = 50;
// Substring match in full path
else if (filePath.toLowerCase().includes(lowerQuery)) score = 30;
// Directories get a bonus to appear first
if (isDirectory && score > 0) score += 10;
return score;
}
// Fuzzy file search using fd (fast, respects .gitignore)
private async getFuzzyFileSuggestions(
query: string,
options: { isQuotedPrefix: boolean; signal: AbortSignal },
): Promise<AutocompleteItem[]> {
if (!this.fdPath || options.signal.aborted) {
return [];
}
try {
const scopedQuery = this.resolveScopedFuzzyQuery(query);
const fdBaseDir = scopedQuery?.baseDir ?? this.basePath;
const fdQuery = scopedQuery?.query ?? query;
const entries = await walkDirectoryWithFd(fdBaseDir, this.fdPath, fdQuery, 100, options.signal);
if (options.signal.aborted) {
return [];
}
const scoredEntries = entries
.map((entry) => ({
...entry,
score: fdQuery ? this.scoreEntry(entry.path, fdQuery, entry.isDirectory) : 1,
}))
.filter((entry) => entry.score > 0);
scoredEntries.sort((a, b) => b.score - a.score);
const topEntries = scoredEntries.slice(0, 20);
const suggestions: AutocompleteItem[] = [];
for (const { path: entryPath, isDirectory } of topEntries) {
const pathWithoutSlash = isDirectory ? entryPath.slice(0, -1) : entryPath;
const displayPath = scopedQuery
? this.scopedPathForDisplay(scopedQuery.displayBase, pathWithoutSlash)
: pathWithoutSlash;
const entryName = basename(pathWithoutSlash);
const completionPath = isDirectory ? `${displayPath}/` : displayPath;
const value = buildCompletionValue(completionPath, {
isDirectory,
isAtPrefix: true,
isQuotedPrefix: options.isQuotedPrefix,
});
suggestions.push({
value,
label: entryName + (isDirectory ? "/" : ""),
description: displayPath,
});
}
return suggestions;
} catch {
return [];
}
}
// Check if we should trigger file completion (called on Tab key)
shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean {
const currentLine = lines[cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, cursorCol);
// Don't trigger if we're typing a slash command at the start of the line
if (textBeforeCursor.trim().startsWith("/") && !textBeforeCursor.trim().includes(" ")) {
return false;
}
return true;
}
}
@@ -0,0 +1,137 @@
import type { Component } from "../tui.js";
import { applyBackgroundToLine, visibleWidth } from "../utils.js";
type RenderCache = {
childLines: string[];
width: number;
bgSample: string | undefined;
lines: string[];
};
/**
* Box component - a container that applies padding and background to all children
*/
export class Box implements Component {
children: Component[] = [];
private paddingX: number;
private paddingY: number;
private bgFn?: (text: string) => string;
// Cache for rendered output
private cache?: RenderCache;
constructor(paddingX = 1, paddingY = 1, bgFn?: (text: string) => string) {
this.paddingX = paddingX;
this.paddingY = paddingY;
this.bgFn = bgFn;
}
addChild(component: Component): void {
this.children.push(component);
this.invalidateCache();
}
removeChild(component: Component): void {
const index = this.children.indexOf(component);
if (index !== -1) {
this.children.splice(index, 1);
this.invalidateCache();
}
}
clear(): void {
this.children = [];
this.invalidateCache();
}
setBgFn(bgFn?: (text: string) => string): void {
this.bgFn = bgFn;
// Don't invalidate here - we'll detect bgFn changes by sampling output
}
private invalidateCache(): void {
this.cache = undefined;
}
private matchCache(width: number, childLines: string[], bgSample: string | undefined): boolean {
const cache = this.cache;
return (
!!cache &&
cache.width === width &&
cache.bgSample === bgSample &&
cache.childLines.length === childLines.length &&
cache.childLines.every((line, i) => line === childLines[i])
);
}
invalidate(): void {
this.invalidateCache();
for (const child of this.children) {
child.invalidate?.();
}
}
render(width: number): string[] {
if (this.children.length === 0) {
return [];
}
const contentWidth = Math.max(1, width - this.paddingX * 2);
const leftPad = " ".repeat(this.paddingX);
// Render all children
const childLines: string[] = [];
for (const child of this.children) {
const lines = child.render(contentWidth);
for (const line of lines) {
childLines.push(leftPad + line);
}
}
if (childLines.length === 0) {
return [];
}
// Check if bgFn output changed by sampling
const bgSample = this.bgFn ? this.bgFn("test") : undefined;
// Check cache validity
if (this.matchCache(width, childLines, bgSample)) {
return this.cache!.lines;
}
// Apply background and padding
const result: string[] = [];
// Top padding
for (let i = 0; i < this.paddingY; i++) {
result.push(this.applyBg("", width));
}
// Content
for (const line of childLines) {
result.push(this.applyBg(line, width));
}
// Bottom padding
for (let i = 0; i < this.paddingY; i++) {
result.push(this.applyBg("", width));
}
// Update cache
this.cache = { childLines, width, bgSample, lines: result };
return result;
}
private applyBg(line: string, width: number): string {
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;
}
}
@@ -0,0 +1,40 @@
import { getKeybindings } from "../keybindings.js";
import { Loader } from "./loader.js";
/**
* Loader that can be cancelled with Escape.
* Extends Loader with an AbortSignal for cancelling async operations.
*
* @example
* const loader = new CancellableLoader(tui, cyan, dim, "Working...");
* loader.onAbort = () => done(null);
* doWork(loader.signal).then(done);
*/
export class CancellableLoader extends Loader {
private abortController = new AbortController();
/** Called when user presses Escape */
onAbort?: () => void;
/** AbortSignal that is aborted when user presses Escape */
get signal(): AbortSignal {
return this.abortController.signal;
}
/** Whether the loader was aborted */
get aborted(): boolean {
return this.abortController.signal.aborted;
}
handleInput(data: string): void {
const kb = getKeybindings();
if (kb.matches(data, "tui.select.cancel")) {
this.abortController.abort();
this.onAbort?.();
}
}
dispose(): void {
this.stop();
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,104 @@
import {
getCapabilities,
getImageDimensions,
type ImageDimensions,
imageFallback,
renderImage,
} from "../terminal-image.js";
import type { Component } from "../tui.js";
export interface ImageTheme {
fallbackColor: (str: string) => string;
}
export interface ImageOptions {
maxWidthCells?: number;
maxHeightCells?: number;
filename?: string;
/** Kitty image ID. If provided, reuses this ID (for animations/updates). */
imageId?: number;
}
export class Image implements Component {
private base64Data: string;
private mimeType: string;
private dimensions: ImageDimensions;
private theme: ImageTheme;
private options: ImageOptions;
private imageId?: number;
private cachedLines?: string[];
private cachedWidth?: number;
constructor(
base64Data: string,
mimeType: string,
theme: ImageTheme,
options: ImageOptions = {},
dimensions?: ImageDimensions,
) {
this.base64Data = base64Data;
this.mimeType = mimeType;
this.theme = theme;
this.options = options;
this.dimensions = dimensions || getImageDimensions(base64Data, mimeType) || { widthPx: 800, heightPx: 600 };
this.imageId = options.imageId;
}
/** Get the Kitty image ID used by this image (if any). */
getImageId(): number | undefined {
return this.imageId;
}
invalidate(): void {
this.cachedLines = undefined;
this.cachedWidth = undefined;
}
render(width: number): string[] {
if (this.cachedLines && this.cachedWidth === width) {
return this.cachedLines;
}
const maxWidth = Math.min(width - 2, this.options.maxWidthCells ?? 60);
const caps = getCapabilities();
let lines: string[];
if (caps.images) {
const result = renderImage(this.base64Data, this.dimensions, {
maxWidthCells: maxWidth,
imageId: this.imageId,
});
if (result) {
// Store the image ID for later cleanup
if (result.imageId) {
this.imageId = result.imageId;
}
// Return `rows` lines so TUI accounts for image height
// First (rows-1) lines are empty (TUI clears them)
// Last line: move cursor back up, then output image sequence
lines = [];
for (let i = 0; i < result.rows - 1; i++) {
lines.push("");
}
// Move cursor up to first row, then output image
const moveUp = result.rows > 1 ? `\x1b[${result.rows - 1}A` : "";
lines.push(moveUp + result.sequence);
} else {
const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename);
lines = [this.theme.fallbackColor(fallback)];
}
} else {
const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename);
lines = [this.theme.fallbackColor(fallback)];
}
this.cachedLines = lines;
this.cachedWidth = width;
return lines;
}
}
@@ -0,0 +1,503 @@
import { getKeybindings } from "../keybindings.js";
import { decodeKittyPrintable } from "../keys.js";
import { KillRing } from "../kill-ring.js";
import { type Component, CURSOR_MARKER, type Focusable } from "../tui.js";
import { UndoStack } from "../undo-stack.js";
import { getSegmenter, isPunctuationChar, isWhitespaceChar, sliceByColumn, visibleWidth } from "../utils.js";
const segmenter = getSegmenter();
interface InputState {
value: string;
cursor: number;
}
/**
* Input component - single-line text input with horizontal scrolling
*/
export class Input implements Component, Focusable {
private value: string = "";
private cursor: number = 0; // Cursor position in the value
public onSubmit?: (value: string) => void;
public onEscape?: () => void;
/** Focusable interface - set by TUI when focus changes */
focused: boolean = false;
// Bracketed paste mode buffering
private pasteBuffer: string = "";
private isInPaste: boolean = false;
// Kill ring for Emacs-style kill/yank operations
private killRing = new KillRing();
private lastAction: "kill" | "yank" | "type-word" | null = null;
// Undo support
private undoStack = new UndoStack<InputState>();
getValue(): string {
return this.value;
}
setValue(value: string): void {
this.value = value;
this.cursor = Math.min(this.cursor, value.length);
}
handleInput(data: string): void {
// Handle bracketed paste mode
// Start of paste: \x1b[200~
// End of paste: \x1b[201~
// Check if we're starting a bracketed paste
if (data.includes("\x1b[200~")) {
this.isInPaste = true;
this.pasteBuffer = "";
data = data.replace("\x1b[200~", "");
}
// If we're in a paste, buffer the data
if (this.isInPaste) {
// Check if this chunk contains the end marker
this.pasteBuffer += data;
const endIndex = this.pasteBuffer.indexOf("\x1b[201~");
if (endIndex !== -1) {
// Extract the pasted content
const pasteContent = this.pasteBuffer.substring(0, endIndex);
// Process the complete paste
this.handlePaste(pasteContent);
// Reset paste state
this.isInPaste = false;
// Handle any remaining input after the paste marker
const remaining = this.pasteBuffer.substring(endIndex + 6); // 6 = length of \x1b[201~
this.pasteBuffer = "";
if (remaining) {
this.handleInput(remaining);
}
}
return;
}
const kb = getKeybindings();
// Escape/Cancel
if (kb.matches(data, "tui.select.cancel")) {
if (this.onEscape) this.onEscape();
return;
}
// Undo
if (kb.matches(data, "tui.editor.undo")) {
this.undo();
return;
}
// Submit
if (kb.matches(data, "tui.input.submit") || data === "\n") {
if (this.onSubmit) this.onSubmit(this.value);
return;
}
// Deletion
if (kb.matches(data, "tui.editor.deleteCharBackward")) {
this.handleBackspace();
return;
}
if (kb.matches(data, "tui.editor.deleteCharForward")) {
this.handleForwardDelete();
return;
}
if (kb.matches(data, "tui.editor.deleteWordBackward")) {
this.deleteWordBackwards();
return;
}
if (kb.matches(data, "tui.editor.deleteWordForward")) {
this.deleteWordForward();
return;
}
if (kb.matches(data, "tui.editor.deleteToLineStart")) {
this.deleteToLineStart();
return;
}
if (kb.matches(data, "tui.editor.deleteToLineEnd")) {
this.deleteToLineEnd();
return;
}
// Kill ring actions
if (kb.matches(data, "tui.editor.yank")) {
this.yank();
return;
}
if (kb.matches(data, "tui.editor.yankPop")) {
this.yankPop();
return;
}
// Cursor movement
if (kb.matches(data, "tui.editor.cursorLeft")) {
this.lastAction = null;
if (this.cursor > 0) {
const beforeCursor = this.value.slice(0, this.cursor);
const graphemes = [...segmenter.segment(beforeCursor)];
const lastGrapheme = graphemes[graphemes.length - 1];
this.cursor -= lastGrapheme ? lastGrapheme.segment.length : 1;
}
return;
}
if (kb.matches(data, "tui.editor.cursorRight")) {
this.lastAction = null;
if (this.cursor < this.value.length) {
const afterCursor = this.value.slice(this.cursor);
const graphemes = [...segmenter.segment(afterCursor)];
const firstGrapheme = graphemes[0];
this.cursor += firstGrapheme ? firstGrapheme.segment.length : 1;
}
return;
}
if (kb.matches(data, "tui.editor.cursorLineStart")) {
this.lastAction = null;
this.cursor = 0;
return;
}
if (kb.matches(data, "tui.editor.cursorLineEnd")) {
this.lastAction = null;
this.cursor = this.value.length;
return;
}
if (kb.matches(data, "tui.editor.cursorWordLeft")) {
this.moveWordBackwards();
return;
}
if (kb.matches(data, "tui.editor.cursorWordRight")) {
this.moveWordForwards();
return;
}
// Kitty CSI-u printable character (e.g. \x1b[97u for 'a').
// Terminals with Kitty protocol flag 1 (disambiguate) send CSI-u for all keys,
// including plain printable characters. Decode before the control-char check
// since CSI-u sequences contain \x1b which would be rejected.
const kittyPrintable = decodeKittyPrintable(data);
if (kittyPrintable !== undefined) {
this.insertCharacter(kittyPrintable);
return;
}
// Regular character input - accept printable characters including Unicode,
// but reject control characters (C0: 0x00-0x1F, DEL: 0x7F, C1: 0x80-0x9F)
const hasControlChars = [...data].some((ch) => {
const code = ch.charCodeAt(0);
return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f);
});
if (!hasControlChars) {
this.insertCharacter(data);
}
}
private insertCharacter(char: string): void {
// Undo coalescing: consecutive word chars coalesce into one undo unit
if (isWhitespaceChar(char) || this.lastAction !== "type-word") {
this.pushUndo();
}
this.lastAction = "type-word";
this.value = this.value.slice(0, this.cursor) + char + this.value.slice(this.cursor);
this.cursor += char.length;
}
private handleBackspace(): void {
this.lastAction = null;
if (this.cursor > 0) {
this.pushUndo();
const beforeCursor = this.value.slice(0, this.cursor);
const graphemes = [...segmenter.segment(beforeCursor)];
const lastGrapheme = graphemes[graphemes.length - 1];
const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
this.value = this.value.slice(0, this.cursor - graphemeLength) + this.value.slice(this.cursor);
this.cursor -= graphemeLength;
}
}
private handleForwardDelete(): void {
this.lastAction = null;
if (this.cursor < this.value.length) {
this.pushUndo();
const afterCursor = this.value.slice(this.cursor);
const graphemes = [...segmenter.segment(afterCursor)];
const firstGrapheme = graphemes[0];
const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + graphemeLength);
}
}
private deleteToLineStart(): void {
if (this.cursor === 0) return;
this.pushUndo();
const deletedText = this.value.slice(0, this.cursor);
this.killRing.push(deletedText, { prepend: true, accumulate: this.lastAction === "kill" });
this.lastAction = "kill";
this.value = this.value.slice(this.cursor);
this.cursor = 0;
}
private deleteToLineEnd(): void {
if (this.cursor >= this.value.length) return;
this.pushUndo();
const deletedText = this.value.slice(this.cursor);
this.killRing.push(deletedText, { prepend: false, accumulate: this.lastAction === "kill" });
this.lastAction = "kill";
this.value = this.value.slice(0, this.cursor);
}
private deleteWordBackwards(): void {
if (this.cursor === 0) return;
// Save lastAction before cursor movement (moveWordBackwards resets it)
const wasKill = this.lastAction === "kill";
this.pushUndo();
const oldCursor = this.cursor;
this.moveWordBackwards();
const deleteFrom = this.cursor;
this.cursor = oldCursor;
const deletedText = this.value.slice(deleteFrom, this.cursor);
this.killRing.push(deletedText, { prepend: true, accumulate: wasKill });
this.lastAction = "kill";
this.value = this.value.slice(0, deleteFrom) + this.value.slice(this.cursor);
this.cursor = deleteFrom;
}
private deleteWordForward(): void {
if (this.cursor >= this.value.length) return;
// Save lastAction before cursor movement (moveWordForwards resets it)
const wasKill = this.lastAction === "kill";
this.pushUndo();
const oldCursor = this.cursor;
this.moveWordForwards();
const deleteTo = this.cursor;
this.cursor = oldCursor;
const deletedText = this.value.slice(this.cursor, deleteTo);
this.killRing.push(deletedText, { prepend: false, accumulate: wasKill });
this.lastAction = "kill";
this.value = this.value.slice(0, this.cursor) + this.value.slice(deleteTo);
}
private yank(): void {
const text = this.killRing.peek();
if (!text) return;
this.pushUndo();
this.value = this.value.slice(0, this.cursor) + text + this.value.slice(this.cursor);
this.cursor += text.length;
this.lastAction = "yank";
}
private yankPop(): void {
if (this.lastAction !== "yank" || this.killRing.length <= 1) return;
this.pushUndo();
// Delete the previously yanked text (still at end of ring before rotation)
const prevText = this.killRing.peek() || "";
this.value = this.value.slice(0, this.cursor - prevText.length) + this.value.slice(this.cursor);
this.cursor -= prevText.length;
// Rotate and insert new entry
this.killRing.rotate();
const text = this.killRing.peek() || "";
this.value = this.value.slice(0, this.cursor) + text + this.value.slice(this.cursor);
this.cursor += text.length;
this.lastAction = "yank";
}
private pushUndo(): void {
this.undoStack.push({ value: this.value, cursor: this.cursor });
}
private undo(): void {
const snapshot = this.undoStack.pop();
if (!snapshot) return;
this.value = snapshot.value;
this.cursor = snapshot.cursor;
this.lastAction = null;
}
private moveWordBackwards(): void {
if (this.cursor === 0) {
return;
}
this.lastAction = null;
const textBeforeCursor = this.value.slice(0, this.cursor);
const graphemes = [...segmenter.segment(textBeforeCursor)];
// Skip trailing whitespace
while (graphemes.length > 0 && isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) {
this.cursor -= graphemes.pop()?.segment.length || 0;
}
if (graphemes.length > 0) {
const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
if (isPunctuationChar(lastGrapheme)) {
// Skip punctuation run
while (graphemes.length > 0 && isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")) {
this.cursor -= graphemes.pop()?.segment.length || 0;
}
} else {
// Skip word run
while (
graphemes.length > 0 &&
!isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") &&
!isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")
) {
this.cursor -= graphemes.pop()?.segment.length || 0;
}
}
}
}
private moveWordForwards(): void {
if (this.cursor >= this.value.length) {
return;
}
this.lastAction = null;
const textAfterCursor = this.value.slice(this.cursor);
const segments = segmenter.segment(textAfterCursor);
const iterator = segments[Symbol.iterator]();
let next = iterator.next();
// Skip leading whitespace
while (!next.done && isWhitespaceChar(next.value.segment)) {
this.cursor += next.value.segment.length;
next = iterator.next();
}
if (!next.done) {
const firstGrapheme = next.value.segment;
if (isPunctuationChar(firstGrapheme)) {
// Skip punctuation run
while (!next.done && isPunctuationChar(next.value.segment)) {
this.cursor += next.value.segment.length;
next = iterator.next();
}
} else {
// Skip word run
while (!next.done && !isWhitespaceChar(next.value.segment) && !isPunctuationChar(next.value.segment)) {
this.cursor += next.value.segment.length;
next = iterator.next();
}
}
}
}
private handlePaste(pastedText: string): void {
this.lastAction = null;
this.pushUndo();
// Clean the pasted text - remove newlines and carriage returns
const cleanText = pastedText.replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, "").replace(/\t/g, " ");
// Insert at cursor position
this.value = this.value.slice(0, this.cursor) + cleanText + this.value.slice(this.cursor);
this.cursor += cleanText.length;
}
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] {
// Calculate visible window
const prompt = "> ";
const availableWidth = width - prompt.length;
if (availableWidth <= 0) {
return [prompt];
}
let visibleText = "";
let cursorDisplay = this.cursor;
const totalWidth = visibleWidth(this.value);
if (totalWidth < availableWidth) {
// Everything fits (leave room for cursor at end)
visibleText = this.value;
} else {
// Need horizontal scrolling
// Reserve one column for cursor if it's at the end
const scrollWidth = this.cursor === this.value.length ? availableWidth - 1 : availableWidth;
const cursorCol = visibleWidth(this.value.slice(0, this.cursor));
if (scrollWidth > 0) {
const halfWidth = Math.floor(scrollWidth / 2);
let startCol = 0;
if (cursorCol < halfWidth) {
// Cursor near start
startCol = 0;
} else if (cursorCol > totalWidth - halfWidth) {
// Cursor near end
startCol = Math.max(0, totalWidth - scrollWidth);
} else {
// Cursor in middle
startCol = Math.max(0, cursorCol - halfWidth);
}
visibleText = sliceByColumn(this.value, startCol, scrollWidth, true);
const beforeCursor = sliceByColumn(this.value, startCol, Math.max(0, cursorCol - startCol), true);
cursorDisplay = beforeCursor.length;
} else {
visibleText = "";
cursorDisplay = 0;
}
}
// Build line with fake cursor
// Insert cursor character at cursor position
const graphemes = [...segmenter.segment(visibleText.slice(cursorDisplay))];
const cursorGrapheme = graphemes[0];
const beforeCursor = visibleText.slice(0, cursorDisplay);
const atCursor = cursorGrapheme?.segment ?? " "; // Character at cursor, or space if at end
const afterCursor = visibleText.slice(cursorDisplay + atCursor.length);
// 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
const textWithCursor = beforeCursor + marker + cursorChar + afterCursor;
// Calculate visual width
const visualLength = visibleWidth(textWithCursor);
const padding = " ".repeat(Math.max(0, availableWidth - visualLength));
const line = prompt + textWithCursor + padding;
return [line];
}
}
@@ -0,0 +1,55 @@
import type { TUI } from "../tui.js";
import { Text } from "./text.js";
/**
* Loader component that updates every 80ms with spinning animation
*/
export class Loader extends Text {
private frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
private currentFrame = 0;
private intervalId: NodeJS.Timeout | null = null;
private ui: TUI | null = null;
constructor(
ui: TUI,
private spinnerColorFn: (str: string) => string,
private messageColorFn: (str: string) => string,
private message: string = "Loading...",
) {
super("", 1, 0);
this.ui = ui;
this.start();
}
render(width: number): string[] {
return ["", ...super.render(width)];
}
start() {
this.updateDisplay();
this.intervalId = setInterval(() => {
this.currentFrame = (this.currentFrame + 1) % this.frames.length;
this.updateDisplay();
}, 80);
}
stop() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
setMessage(message: string) {
this.message = message;
this.updateDisplay();
}
private updateDisplay() {
const frame = this.frames[this.currentFrame];
this.setText(`${this.spinnerColorFn(frame)} ${this.messageColorFn(this.message)}`);
if (this.ui) {
this.ui.requestRender();
}
}
}
@@ -0,0 +1,852 @@
import { Marked, type Token, Tokenizer, type Tokens } from "marked";
import { getCapabilities, hyperlink, isImageLine } from "../terminal-image.js";
import type { Component } from "../tui.js";
import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.js";
const STRICT_STRIKETHROUGH_REGEX = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/;
class StrictStrikethroughTokenizer extends Tokenizer {
override del(src: string): Tokens.Del | undefined {
const match = STRICT_STRIKETHROUGH_REGEX.exec(src);
if (!match) {
return undefined;
}
const text = match[2];
return {
type: "del",
raw: match[0],
text,
tokens: this.lexer.inlineTokens(text),
};
}
}
const markdownParser = new Marked();
markdownParser.setOptions({
tokenizer: new StrictStrikethroughTokenizer(),
});
/**
* Default text styling for markdown content.
* Applied to all text unless overridden by markdown formatting.
*/
export interface DefaultTextStyle {
/** Foreground color function */
color?: (text: string) => string;
/** Background color function */
bgColor?: (text: string) => string;
/** Bold text */
bold?: boolean;
/** Italic text */
italic?: boolean;
/** Strikethrough text */
strikethrough?: boolean;
/** Underline text */
underline?: boolean;
}
/**
* Theme functions for markdown elements.
* Each function takes text and returns styled text with ANSI codes.
*/
export interface MarkdownTheme {
heading: (text: string) => string;
link: (text: string) => string;
linkUrl: (text: string) => string;
code: (text: string) => string;
codeBlock: (text: string) => string;
codeBlockBorder: (text: string) => string;
quote: (text: string) => string;
quoteBorder: (text: string) => string;
hr: (text: string) => string;
listBullet: (text: string) => string;
bold: (text: string) => string;
italic: (text: string) => string;
strikethrough: (text: string) => string;
underline: (text: string) => string;
highlightCode?: (code: string, lang?: string) => string[];
/** Prefix applied to each rendered code block line (default: " ") */
codeBlockIndent?: string;
}
interface InlineStyleContext {
applyText: (text: string) => string;
stylePrefix: string;
}
export class Markdown implements Component {
private text: string;
private paddingX: number; // Left/right padding
private paddingY: number; // Top/bottom padding
private defaultTextStyle?: DefaultTextStyle;
private theme: MarkdownTheme;
private defaultStylePrefix?: string;
// Cache for rendered output
private cachedText?: string;
private cachedWidth?: number;
private cachedLines?: string[];
constructor(
text: string,
paddingX: number,
paddingY: number,
theme: MarkdownTheme,
defaultTextStyle?: DefaultTextStyle,
) {
this.text = text;
this.paddingX = paddingX;
this.paddingY = paddingY;
this.theme = theme;
this.defaultTextStyle = defaultTextStyle;
}
setText(text: string): void {
this.text = text;
this.invalidate();
}
invalidate(): void {
this.cachedText = undefined;
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
render(width: number): string[] {
// Check cache
if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) {
return this.cachedLines;
}
// Calculate available width for content (subtract horizontal padding)
const contentWidth = Math.max(1, width - this.paddingX * 2);
// Don't render anything if there's no actual text
if (!this.text || this.text.trim() === "") {
const result: string[] = [];
// Update cache
this.cachedText = this.text;
this.cachedWidth = width;
this.cachedLines = result;
return result;
}
// Replace tabs with 3 spaces for consistent rendering
const normalizedText = this.text.replace(/\t/g, " ");
// Parse markdown to HTML-like tokens
const tokens = markdownParser.lexer(normalizedText);
// Convert tokens to styled terminal output
const renderedLines: string[] = [];
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
const nextToken = tokens[i + 1];
const tokenLines = this.renderToken(token, contentWidth, nextToken?.type);
renderedLines.push(...tokenLines);
}
// Wrap lines (NO padding, NO background yet)
const wrappedLines: string[] = [];
for (const line of renderedLines) {
if (isImageLine(line)) {
wrappedLines.push(line);
} else {
wrappedLines.push(...wrapTextWithAnsi(line, contentWidth));
}
}
// Add margins and background to each wrapped line
const leftMargin = " ".repeat(this.paddingX);
const rightMargin = " ".repeat(this.paddingX);
const bgFn = this.defaultTextStyle?.bgColor;
const contentLines: string[] = [];
for (const line of wrappedLines) {
if (isImageLine(line)) {
contentLines.push(line);
continue;
}
const lineWithMargins = leftMargin + line + rightMargin;
if (bgFn) {
contentLines.push(applyBackgroundToLine(lineWithMargins, width, bgFn));
} else {
// No background - just pad to width
const visibleLen = visibleWidth(lineWithMargins);
const paddingNeeded = Math.max(0, width - visibleLen);
contentLines.push(lineWithMargins + " ".repeat(paddingNeeded));
}
}
// Add top/bottom padding (empty lines)
const emptyLine = " ".repeat(width);
const emptyLines: string[] = [];
for (let i = 0; i < this.paddingY; i++) {
const line = bgFn ? applyBackgroundToLine(emptyLine, width, bgFn) : emptyLine;
emptyLines.push(line);
}
// Combine top padding, content, and bottom padding
const result = [...emptyLines, ...contentLines, ...emptyLines];
// Update cache
this.cachedText = this.text;
this.cachedWidth = width;
this.cachedLines = result;
return result.length > 0 ? result : [""];
}
/**
* Apply default text style to a string.
* This is the base styling applied to all text content.
* NOTE: Background color is NOT applied here - it's applied at the padding stage
* to ensure it extends to the full line width.
*/
private applyDefaultStyle(text: string): string {
if (!this.defaultTextStyle) {
return text;
}
let styled = text;
// Apply foreground color (NOT background - that's applied at padding stage)
if (this.defaultTextStyle.color) {
styled = this.defaultTextStyle.color(styled);
}
// Apply text decorations using this.theme
if (this.defaultTextStyle.bold) {
styled = this.theme.bold(styled);
}
if (this.defaultTextStyle.italic) {
styled = this.theme.italic(styled);
}
if (this.defaultTextStyle.strikethrough) {
styled = this.theme.strikethrough(styled);
}
if (this.defaultTextStyle.underline) {
styled = this.theme.underline(styled);
}
return styled;
}
private getDefaultStylePrefix(): string {
if (!this.defaultTextStyle) {
return "";
}
if (this.defaultStylePrefix !== undefined) {
return this.defaultStylePrefix;
}
const sentinel = "\u0000";
let styled = sentinel;
if (this.defaultTextStyle.color) {
styled = this.defaultTextStyle.color(styled);
}
if (this.defaultTextStyle.bold) {
styled = this.theme.bold(styled);
}
if (this.defaultTextStyle.italic) {
styled = this.theme.italic(styled);
}
if (this.defaultTextStyle.strikethrough) {
styled = this.theme.strikethrough(styled);
}
if (this.defaultTextStyle.underline) {
styled = this.theme.underline(styled);
}
const sentinelIndex = styled.indexOf(sentinel);
this.defaultStylePrefix = sentinelIndex >= 0 ? styled.slice(0, sentinelIndex) : "";
return this.defaultStylePrefix;
}
private getStylePrefix(styleFn: (text: string) => string): string {
const sentinel = "\u0000";
const styled = styleFn(sentinel);
const sentinelIndex = styled.indexOf(sentinel);
return sentinelIndex >= 0 ? styled.slice(0, sentinelIndex) : "";
}
private getDefaultInlineStyleContext(): InlineStyleContext {
return {
applyText: (text: string) => this.applyDefaultStyle(text),
stylePrefix: this.getDefaultStylePrefix(),
};
}
private renderToken(
token: Token,
width: number,
nextTokenType?: string,
styleContext?: InlineStyleContext,
): string[] {
const lines: string[] = [];
switch (token.type) {
case "heading": {
const headingLevel = token.depth;
const headingPrefix = `${"#".repeat(headingLevel)} `;
// Build a heading-specific style context so inline tokens (codespan, bold, etc.)
// restore heading styling after their own ANSI resets instead of falling back to
// the default text style.
let headingStyleFn: (text: string) => string;
if (headingLevel === 1) {
headingStyleFn = (text: string) => this.theme.heading(this.theme.bold(this.theme.underline(text)));
} else {
headingStyleFn = (text: string) => this.theme.heading(this.theme.bold(text));
}
const headingStyleContext: InlineStyleContext = {
applyText: headingStyleFn,
stylePrefix: this.getStylePrefix(headingStyleFn),
};
const headingText = this.renderInlineTokens(token.tokens || [], headingStyleContext);
const styledHeading = headingLevel >= 3 ? headingStyleFn(headingPrefix) + headingText : headingText;
lines.push(styledHeading);
if (nextTokenType && nextTokenType !== "space") {
lines.push(""); // Add spacing after headings (unless space token follows)
}
break;
}
case "paragraph": {
const paragraphText = this.renderInlineTokens(token.tokens || [], styleContext);
lines.push(paragraphText);
// Don't add spacing if next token is space or list
if (nextTokenType && nextTokenType !== "list" && nextTokenType !== "space") {
lines.push("");
}
break;
}
case "code": {
const indent = this.theme.codeBlockIndent ?? " ";
lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
if (this.theme.highlightCode) {
const highlightedLines = this.theme.highlightCode(token.text, token.lang);
for (const hlLine of highlightedLines) {
lines.push(`${indent}${hlLine}`);
}
} else {
// Split code by newlines and style each line
const codeLines = token.text.split("\n");
for (const codeLine of codeLines) {
lines.push(`${indent}${this.theme.codeBlock(codeLine)}`);
}
}
lines.push(this.theme.codeBlockBorder("```"));
if (nextTokenType && nextTokenType !== "space") {
lines.push(""); // Add spacing after code blocks (unless space token follows)
}
break;
}
case "list": {
const listLines = this.renderList(token as any, 0, styleContext);
lines.push(...listLines);
// Don't add spacing after lists if a space token follows
// (the space token will handle it)
break;
}
case "table": {
const tableLines = this.renderTable(token as any, width, nextTokenType, styleContext);
lines.push(...tableLines);
break;
}
case "blockquote": {
const quoteStyle = (text: string) => this.theme.quote(this.theme.italic(text));
const quoteStylePrefix = this.getStylePrefix(quoteStyle);
const applyQuoteStyle = (line: string): string => {
if (!quoteStylePrefix) {
return quoteStyle(line);
}
const lineWithReappliedStyle = line.replace(/\x1b\[0m/g, `\x1b[0m${quoteStylePrefix}`);
return quoteStyle(lineWithReappliedStyle);
};
// Calculate available width for quote content (subtract border "│ " = 2 chars)
const quoteContentWidth = Math.max(1, width - 2);
// Blockquotes contain block-level tokens (paragraph, list, code, etc.), so render
// children with renderToken() instead of renderInlineTokens().
// Default message style should not apply inside blockquotes.
const quoteInlineStyleContext: InlineStyleContext = {
applyText: (text: string) => text,
stylePrefix: quoteStylePrefix,
};
const quoteTokens = token.tokens || [];
const renderedQuoteLines: string[] = [];
for (let i = 0; i < quoteTokens.length; i++) {
const quoteToken = quoteTokens[i];
const nextQuoteToken = quoteTokens[i + 1];
renderedQuoteLines.push(
...this.renderToken(quoteToken, quoteContentWidth, nextQuoteToken?.type, quoteInlineStyleContext),
);
}
// Avoid rendering an extra empty quote line before the outer blockquote spacing.
while (renderedQuoteLines.length > 0 && renderedQuoteLines[renderedQuoteLines.length - 1] === "") {
renderedQuoteLines.pop();
}
for (const quoteLine of renderedQuoteLines) {
const styledLine = applyQuoteStyle(quoteLine);
const wrappedLines = wrapTextWithAnsi(styledLine, quoteContentWidth);
for (const wrappedLine of wrappedLines) {
lines.push(this.theme.quoteBorder("│ ") + wrappedLine);
}
}
if (nextTokenType && nextTokenType !== "space") {
lines.push(""); // Add spacing after blockquotes (unless space token follows)
}
break;
}
case "hr":
lines.push(this.theme.hr("─".repeat(Math.min(width, 80))));
if (nextTokenType && nextTokenType !== "space") {
lines.push(""); // Add spacing after horizontal rules (unless space token follows)
}
break;
case "html":
// Render HTML as plain text (escaped for terminal)
if ("raw" in token && typeof token.raw === "string") {
lines.push(this.applyDefaultStyle(token.raw.trim()));
}
break;
case "space":
// Space tokens represent blank lines in markdown
lines.push("");
break;
default:
// Handle any other token types as plain text
if ("text" in token && typeof token.text === "string") {
lines.push(token.text);
}
}
return lines;
}
private renderInlineTokens(tokens: Token[], styleContext?: InlineStyleContext): string {
let result = "";
const resolvedStyleContext = styleContext ?? this.getDefaultInlineStyleContext();
const { applyText, stylePrefix } = resolvedStyleContext;
const applyTextWithNewlines = (text: string): string => {
const segments: string[] = text.split("\n");
return segments.map((segment: string) => applyText(segment)).join("\n");
};
for (const token of tokens) {
switch (token.type) {
case "text":
// Text tokens in list items can have nested tokens for inline formatting
if (token.tokens && token.tokens.length > 0) {
result += this.renderInlineTokens(token.tokens, resolvedStyleContext);
} else {
result += applyTextWithNewlines(token.text);
}
break;
case "paragraph":
// Paragraph tokens contain nested inline tokens
result += this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
break;
case "strong": {
const boldContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
result += this.theme.bold(boldContent) + stylePrefix;
break;
}
case "em": {
const italicContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
result += this.theme.italic(italicContent) + stylePrefix;
break;
}
case "codespan":
result += this.theme.code(token.text) + stylePrefix;
break;
case "link": {
const linkText = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
const styledLink = this.theme.link(this.theme.underline(linkText));
if (getCapabilities().hyperlinks) {
// OSC 8: render as a clickable hyperlink. The URL is not printed inline,
// so we always show only the link text regardless of whether it matches href.
result += hyperlink(styledLink, token.href) + stylePrefix;
} else {
// Fallback: print URL in parentheses when text differs from href.
// Compare raw token.text (not styled) against href for the equality check.
// For mailto: links strip the prefix (autolinked emails use text="foo@bar.com"
// but href="mailto:foo@bar.com").
const hrefForComparison = token.href.startsWith("mailto:") ? token.href.slice(7) : token.href;
if (token.text === token.href || token.text === hrefForComparison) {
result += styledLink + stylePrefix;
} else {
result += styledLink + this.theme.linkUrl(` (${token.href})`) + stylePrefix;
}
}
break;
}
case "br":
result += "\n";
break;
case "del": {
const delContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
result += this.theme.strikethrough(delContent) + stylePrefix;
break;
}
case "html":
// Render inline HTML as plain text
if ("raw" in token && typeof token.raw === "string") {
result += applyTextWithNewlines(token.raw);
}
break;
default:
// Handle any other inline token types as plain text
if ("text" in token && typeof token.text === "string") {
result += applyTextWithNewlines(token.text);
}
}
}
while (stylePrefix && result.endsWith(stylePrefix)) {
result = result.slice(0, -stylePrefix.length);
}
return result;
}
/**
* Render a list with proper nesting support
*/
private renderList(
token: Token & { items: any[]; ordered: boolean; start?: number },
depth: number,
styleContext?: InlineStyleContext,
): string[] {
const lines: string[] = [];
const indent = " ".repeat(depth);
// Use the list's start property (defaults to 1 for ordered lists)
const startNumber = token.start ?? 1;
for (let i = 0; i < token.items.length; i++) {
const item = token.items[i];
const bullet = token.ordered ? `${startNumber + i}. ` : "- ";
// Process item tokens to handle nested lists
const itemLines = this.renderListItem(item.tokens || [], depth, styleContext);
if (itemLines.length > 0) {
// First line - check if it's a nested list
// A nested list will start with indent (spaces) followed by cyan bullet
const firstLine = itemLines[0];
const isNestedList = /^\s+\x1b\[36m[-\d]/.test(firstLine); // starts with spaces + cyan + bullet char
if (isNestedList) {
// This is a nested list, just add it as-is (already has full indent)
lines.push(firstLine);
} else {
// Regular text content - add indent and bullet
lines.push(indent + this.theme.listBullet(bullet) + firstLine);
}
// Rest of the lines
for (let j = 1; j < itemLines.length; j++) {
const line = itemLines[j];
const isNestedListLine = /^\s+\x1b\[36m[-\d]/.test(line); // starts with spaces + cyan + bullet char
if (isNestedListLine) {
// Nested list line - already has full indent
lines.push(line);
} else {
// Regular content - add parent indent + 2 spaces for continuation
lines.push(`${indent} ${line}`);
}
}
} else {
lines.push(indent + this.theme.listBullet(bullet));
}
}
return lines;
}
/**
* Render list item tokens, handling nested lists
* Returns lines WITHOUT the parent indent (renderList will add it)
*/
private renderListItem(tokens: Token[], parentDepth: number, styleContext?: InlineStyleContext): string[] {
const lines: string[] = [];
for (const token of tokens) {
if (token.type === "list") {
// Nested list - render with one additional indent level
// These lines will have their own indent, so we just add them as-is
const nestedLines = this.renderList(token as any, parentDepth + 1, styleContext);
lines.push(...nestedLines);
} else if (token.type === "text") {
// Text content (may have inline tokens)
const text =
token.tokens && token.tokens.length > 0
? this.renderInlineTokens(token.tokens, styleContext)
: token.text || "";
lines.push(text);
} else if (token.type === "paragraph") {
// Paragraph in list item
const text = this.renderInlineTokens(token.tokens || [], styleContext);
lines.push(text);
} else if (token.type === "code") {
// Code block in list item
const indent = this.theme.codeBlockIndent ?? " ";
lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
if (this.theme.highlightCode) {
const highlightedLines = this.theme.highlightCode(token.text, token.lang);
for (const hlLine of highlightedLines) {
lines.push(`${indent}${hlLine}`);
}
} else {
const codeLines = token.text.split("\n");
for (const codeLine of codeLines) {
lines.push(`${indent}${this.theme.codeBlock(codeLine)}`);
}
}
lines.push(this.theme.codeBlockBorder("```"));
} else {
// Other token types - try to render as inline
const text = this.renderInlineTokens([token], styleContext);
if (text) {
lines.push(text);
}
}
}
return lines;
}
/**
* Get the visible width of the longest word in a string.
*/
private getLongestWordWidth(text: string, maxWidth?: number): number {
const words = text.split(/\s+/).filter((word) => word.length > 0);
let longest = 0;
for (const word of words) {
longest = Math.max(longest, visibleWidth(word));
}
if (maxWidth === undefined) {
return longest;
}
return Math.min(longest, maxWidth);
}
/**
* Wrap a table cell to fit into a column.
*
* Delegates to wrapTextWithAnsi() so ANSI codes + long tokens are handled
* consistently with the rest of the renderer.
*/
private wrapCellText(text: string, maxWidth: number): string[] {
return wrapTextWithAnsi(text, Math.max(1, maxWidth));
}
/**
* Render a table with width-aware cell wrapping.
* Cells that don't fit are wrapped to multiple lines.
*/
private renderTable(
token: Token & { header: any[]; rows: any[][]; raw?: string },
availableWidth: number,
nextTokenType?: string,
styleContext?: InlineStyleContext,
): string[] {
const lines: string[] = [];
const numCols = token.header.length;
if (numCols === 0) {
return lines;
}
// Calculate border overhead: "│ " + (n-1) * " │ " + " │"
// = 2 + (n-1) * 3 + 2 = 3n + 1
const borderOverhead = 3 * numCols + 1;
const availableForCells = availableWidth - borderOverhead;
if (availableForCells < numCols) {
// Too narrow to render a stable table. Fall back to raw markdown.
const fallbackLines = token.raw ? wrapTextWithAnsi(token.raw, availableWidth) : [];
if (nextTokenType && nextTokenType !== "space") {
fallbackLines.push("");
}
return fallbackLines;
}
const maxUnbrokenWordWidth = 30;
// Calculate natural column widths (what each column needs without constraints)
const naturalWidths: number[] = [];
const minWordWidths: number[] = [];
for (let i = 0; i < numCols; i++) {
const headerText = this.renderInlineTokens(token.header[i].tokens || [], styleContext);
naturalWidths[i] = visibleWidth(headerText);
minWordWidths[i] = Math.max(1, this.getLongestWordWidth(headerText, maxUnbrokenWordWidth));
}
for (const row of token.rows) {
for (let i = 0; i < row.length; i++) {
const cellText = this.renderInlineTokens(row[i].tokens || [], styleContext);
naturalWidths[i] = Math.max(naturalWidths[i] || 0, visibleWidth(cellText));
minWordWidths[i] = Math.max(
minWordWidths[i] || 1,
this.getLongestWordWidth(cellText, maxUnbrokenWordWidth),
);
}
}
let minColumnWidths = minWordWidths;
let minCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0);
if (minCellsWidth > availableForCells) {
minColumnWidths = new Array(numCols).fill(1);
const remaining = availableForCells - numCols;
if (remaining > 0) {
const totalWeight = minWordWidths.reduce((total, width) => total + Math.max(0, width - 1), 0);
const growth = minWordWidths.map((width) => {
const weight = Math.max(0, width - 1);
return totalWeight > 0 ? Math.floor((weight / totalWeight) * remaining) : 0;
});
for (let i = 0; i < numCols; i++) {
minColumnWidths[i] += growth[i] ?? 0;
}
const allocated = growth.reduce((total, width) => total + width, 0);
let leftover = remaining - allocated;
for (let i = 0; leftover > 0 && i < numCols; i++) {
minColumnWidths[i]++;
leftover--;
}
}
minCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0);
}
// Calculate column widths that fit within available width
const totalNaturalWidth = naturalWidths.reduce((a, b) => a + b, 0) + borderOverhead;
let columnWidths: number[];
if (totalNaturalWidth <= availableWidth) {
// Everything fits naturally
columnWidths = naturalWidths.map((width, index) => Math.max(width, minColumnWidths[index]));
} else {
// Need to shrink columns to fit
const totalGrowPotential = naturalWidths.reduce((total, width, index) => {
return total + Math.max(0, width - minColumnWidths[index]);
}, 0);
const extraWidth = Math.max(0, availableForCells - minCellsWidth);
columnWidths = minColumnWidths.map((minWidth, index) => {
const naturalWidth = naturalWidths[index];
const minWidthDelta = Math.max(0, naturalWidth - minWidth);
let grow = 0;
if (totalGrowPotential > 0) {
grow = Math.floor((minWidthDelta / totalGrowPotential) * extraWidth);
}
return minWidth + grow;
});
// Adjust for rounding errors - distribute remaining space
const allocated = columnWidths.reduce((a, b) => a + b, 0);
let remaining = availableForCells - allocated;
while (remaining > 0) {
let grew = false;
for (let i = 0; i < numCols && remaining > 0; i++) {
if (columnWidths[i] < naturalWidths[i]) {
columnWidths[i]++;
remaining--;
grew = true;
}
}
if (!grew) {
break;
}
}
}
// Render top border
const topBorderCells = columnWidths.map((w) => "─".repeat(w));
lines.push(`┌─${topBorderCells.join("─┬─")}─┐`);
// Render header with wrapping
const headerCellLines: string[][] = token.header.map((cell, i) => {
const text = this.renderInlineTokens(cell.tokens || [], styleContext);
return this.wrapCellText(text, columnWidths[i]);
});
const headerLineCount = Math.max(...headerCellLines.map((c) => c.length));
for (let lineIdx = 0; lineIdx < headerLineCount; lineIdx++) {
const rowParts = headerCellLines.map((cellLines, colIdx) => {
const text = cellLines[lineIdx] || "";
const padded = text + " ".repeat(Math.max(0, columnWidths[colIdx] - visibleWidth(text)));
return this.theme.bold(padded);
});
lines.push(`│ ${rowParts.join(" │ ")} │`);
}
// Render separator
const separatorCells = columnWidths.map((w) => "─".repeat(w));
const separatorLine = `├─${separatorCells.join("─┼─")}─┤`;
lines.push(separatorLine);
// Render rows with wrapping
for (let rowIndex = 0; rowIndex < token.rows.length; rowIndex++) {
const row = token.rows[rowIndex];
const rowCellLines: string[][] = row.map((cell, i) => {
const text = this.renderInlineTokens(cell.tokens || [], styleContext);
return this.wrapCellText(text, columnWidths[i]);
});
const rowLineCount = Math.max(...rowCellLines.map((c) => c.length));
for (let lineIdx = 0; lineIdx < rowLineCount; lineIdx++) {
const rowParts = rowCellLines.map((cellLines, colIdx) => {
const text = cellLines[lineIdx] || "";
return text + " ".repeat(Math.max(0, columnWidths[colIdx] - visibleWidth(text)));
});
lines.push(`│ ${rowParts.join(" │ ")} │`);
}
if (rowIndex < token.rows.length - 1) {
lines.push(separatorLine);
}
}
// Render bottom border
const bottomBorderCells = columnWidths.map((w) => "─".repeat(w));
lines.push(`└─${bottomBorderCells.join("─┴─")}─┘`);
if (nextTokenType && nextTokenType !== "space") {
lines.push(""); // Add spacing after table
}
return lines;
}
}
@@ -0,0 +1,229 @@
import { getKeybindings } from "../keybindings.js";
import type { Component } from "../tui.js";
import { truncateToWidth, visibleWidth } from "../utils.js";
const DEFAULT_PRIMARY_COLUMN_WIDTH = 32;
const PRIMARY_COLUMN_GAP = 2;
const MIN_DESCRIPTION_WIDTH = 10;
const normalizeToSingleLine = (text: string): string => text.replace(/[\r\n]+/g, " ").trim();
const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(value, max));
export interface SelectItem {
value: string;
label: string;
description?: string;
}
export interface SelectListTheme {
selectedPrefix: (text: string) => string;
selectedText: (text: string) => string;
description: (text: string) => string;
scrollInfo: (text: string) => string;
noMatch: (text: string) => string;
}
export interface SelectListTruncatePrimaryContext {
text: string;
maxWidth: number;
columnWidth: number;
item: SelectItem;
isSelected: boolean;
}
export interface SelectListLayoutOptions {
minPrimaryColumnWidth?: number;
maxPrimaryColumnWidth?: number;
truncatePrimary?: (context: SelectListTruncatePrimaryContext) => string;
}
export class SelectList implements Component {
private items: SelectItem[] = [];
private filteredItems: SelectItem[] = [];
private selectedIndex: number = 0;
private maxVisible: number = 5;
private theme: SelectListTheme;
private layout: SelectListLayoutOptions;
public onSelect?: (item: SelectItem) => void;
public onCancel?: () => void;
public onSelectionChange?: (item: SelectItem) => void;
constructor(items: SelectItem[], maxVisible: number, theme: SelectListTheme, layout: SelectListLayoutOptions = {}) {
this.items = items;
this.filteredItems = items;
this.maxVisible = maxVisible;
this.theme = theme;
this.layout = layout;
}
setFilter(filter: string): void {
this.filteredItems = this.items.filter((item) => item.value.toLowerCase().startsWith(filter.toLowerCase()));
// Reset selection when filter changes
this.selectedIndex = 0;
}
setSelectedIndex(index: number): void {
this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1));
}
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] {
const lines: string[] = [];
// If no items match filter, show message
if (this.filteredItems.length === 0) {
lines.push(this.theme.noMatch(" No matching commands"));
return lines;
}
const primaryColumnWidth = this.getPrimaryColumnWidth();
// Calculate visible range with scrolling
const startIndex = Math.max(
0,
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible),
);
const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);
// Render visible items
for (let i = startIndex; i < endIndex; i++) {
const item = this.filteredItems[i];
if (!item) continue;
const isSelected = i === this.selectedIndex;
const descriptionSingleLine = item.description ? normalizeToSingleLine(item.description) : undefined;
lines.push(this.renderItem(item, isSelected, width, descriptionSingleLine, primaryColumnWidth));
}
// Add scroll indicators if needed
if (startIndex > 0 || endIndex < this.filteredItems.length) {
const scrollText = ` (${this.selectedIndex + 1}/${this.filteredItems.length})`;
// Truncate if too long for terminal
lines.push(this.theme.scrollInfo(truncateToWidth(scrollText, width - 2, "")));
}
return lines;
}
handleInput(keyData: string): void {
const kb = getKeybindings();
// Up arrow - wrap to bottom when at top
if (kb.matches(keyData, "tui.select.up")) {
this.selectedIndex = this.selectedIndex === 0 ? this.filteredItems.length - 1 : this.selectedIndex - 1;
this.notifySelectionChange();
}
// Down arrow - wrap to top when at bottom
else if (kb.matches(keyData, "tui.select.down")) {
this.selectedIndex = this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1;
this.notifySelectionChange();
}
// Enter
else if (kb.matches(keyData, "tui.select.confirm")) {
const selectedItem = this.filteredItems[this.selectedIndex];
if (selectedItem && this.onSelect) {
this.onSelect(selectedItem);
}
}
// Escape or Ctrl+C
else if (kb.matches(keyData, "tui.select.cancel")) {
if (this.onCancel) {
this.onCancel();
}
}
}
private renderItem(
item: SelectItem,
isSelected: boolean,
width: number,
descriptionSingleLine: string | undefined,
primaryColumnWidth: number,
): string {
const prefix = isSelected ? "→ " : " ";
const prefixWidth = visibleWidth(prefix);
if (descriptionSingleLine && width > 40) {
const effectivePrimaryColumnWidth = Math.max(1, Math.min(primaryColumnWidth, width - prefixWidth - 4));
const maxPrimaryWidth = Math.max(1, effectivePrimaryColumnWidth - PRIMARY_COLUMN_GAP);
const truncatedValue = this.truncatePrimary(item, isSelected, maxPrimaryWidth, effectivePrimaryColumnWidth);
const truncatedValueWidth = visibleWidth(truncatedValue);
const spacing = " ".repeat(Math.max(1, effectivePrimaryColumnWidth - truncatedValueWidth));
const descriptionStart = prefixWidth + truncatedValueWidth + spacing.length;
const remainingWidth = width - descriptionStart - 2; // -2 for safety
if (remainingWidth > MIN_DESCRIPTION_WIDTH) {
const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, "");
if (isSelected) {
return this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
}
const descText = this.theme.description(spacing + truncatedDesc);
return prefix + truncatedValue + descText;
}
}
const maxWidth = width - prefixWidth - 2;
const truncatedValue = this.truncatePrimary(item, isSelected, maxWidth, maxWidth);
if (isSelected) {
return this.theme.selectedText(`${prefix}${truncatedValue}`);
}
return prefix + truncatedValue;
}
private getPrimaryColumnWidth(): number {
const { min, max } = this.getPrimaryColumnBounds();
const widestPrimary = this.filteredItems.reduce((widest, item) => {
return Math.max(widest, visibleWidth(this.getDisplayValue(item)) + PRIMARY_COLUMN_GAP);
}, 0);
return clamp(widestPrimary, min, max);
}
private getPrimaryColumnBounds(): { min: number; max: number } {
const rawMin =
this.layout.minPrimaryColumnWidth ?? this.layout.maxPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH;
const rawMax =
this.layout.maxPrimaryColumnWidth ?? this.layout.minPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH;
return {
min: Math.max(1, Math.min(rawMin, rawMax)),
max: Math.max(1, Math.max(rawMin, rawMax)),
};
}
private truncatePrimary(item: SelectItem, isSelected: boolean, maxWidth: number, columnWidth: number): string {
const displayValue = this.getDisplayValue(item);
const truncatedValue = this.layout.truncatePrimary
? this.layout.truncatePrimary({
text: displayValue,
maxWidth,
columnWidth,
item,
isSelected,
})
: truncateToWidth(displayValue, maxWidth, "");
return truncateToWidth(truncatedValue, maxWidth, "");
}
private getDisplayValue(item: SelectItem): string {
return item.label || item.value;
}
private notifySelectionChange(): void {
const selectedItem = this.filteredItems[this.selectedIndex];
if (selectedItem && this.onSelectionChange) {
this.onSelectionChange(selectedItem);
}
}
getSelectedItem(): SelectItem | null {
const item = this.filteredItems[this.selectedIndex];
return item || null;
}
}
@@ -0,0 +1,250 @@
import { fuzzyFilter } from "../fuzzy.js";
import { getKeybindings } from "../keybindings.js";
import type { Component } from "../tui.js";
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils.js";
import { Input } from "./input.js";
export interface SettingItem {
/** Unique identifier for this setting */
id: string;
/** Display label (left side) */
label: string;
/** Optional description shown when selected */
description?: string;
/** Current value to display (right side) */
currentValue: string;
/** If provided, Enter/Space cycles through these values */
values?: string[];
/** If provided, Enter opens this submenu. Receives current value and done callback. */
submenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component;
}
export interface SettingsListTheme {
label: (text: string, selected: boolean) => string;
value: (text: string, selected: boolean) => string;
description: (text: string) => string;
cursor: string;
hint: (text: string) => string;
}
export interface SettingsListOptions {
enableSearch?: boolean;
}
export class SettingsList implements Component {
private items: SettingItem[];
private filteredItems: SettingItem[];
private theme: SettingsListTheme;
private selectedIndex = 0;
private maxVisible: number;
private onChange: (id: string, newValue: string) => void;
private onCancel: () => void;
private searchInput?: Input;
private searchEnabled: boolean;
// Submenu state
private submenuComponent: Component | null = null;
private submenuItemIndex: number | null = null;
constructor(
items: SettingItem[],
maxVisible: number,
theme: SettingsListTheme,
onChange: (id: string, newValue: string) => void,
onCancel: () => void,
options: SettingsListOptions = {},
) {
this.items = items;
this.filteredItems = items;
this.maxVisible = maxVisible;
this.theme = theme;
this.onChange = onChange;
this.onCancel = onCancel;
this.searchEnabled = options.enableSearch ?? false;
if (this.searchEnabled) {
this.searchInput = new Input();
}
}
/** Update an item's currentValue */
updateValue(id: string, newValue: string): void {
const item = this.items.find((i) => i.id === id);
if (item) {
item.currentValue = newValue;
}
}
invalidate(): void {
this.submenuComponent?.invalidate?.();
}
render(width: number): string[] {
// If submenu is active, render it instead
if (this.submenuComponent) {
return this.submenuComponent.render(width);
}
return this.renderMainList(width);
}
private renderMainList(width: number): string[] {
const lines: string[] = [];
if (this.searchEnabled && this.searchInput) {
lines.push(...this.searchInput.render(width));
lines.push("");
}
if (this.items.length === 0) {
lines.push(this.theme.hint(" No settings available"));
if (this.searchEnabled) {
this.addHintLine(lines, width);
}
return lines;
}
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
if (displayItems.length === 0) {
lines.push(truncateToWidth(this.theme.hint(" No matching settings"), width));
this.addHintLine(lines, width);
return lines;
}
// Calculate visible range with scrolling
const startIndex = Math.max(
0,
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), displayItems.length - this.maxVisible),
);
const endIndex = Math.min(startIndex + this.maxVisible, displayItems.length);
// Calculate max label width for alignment
const maxLabelWidth = Math.min(30, Math.max(...this.items.map((item) => visibleWidth(item.label))));
// Render visible items
for (let i = startIndex; i < endIndex; i++) {
const item = displayItems[i];
if (!item) continue;
const isSelected = i === this.selectedIndex;
const prefix = isSelected ? this.theme.cursor : " ";
const prefixWidth = visibleWidth(prefix);
// Pad label to align values
const labelPadded = item.label + " ".repeat(Math.max(0, maxLabelWidth - visibleWidth(item.label)));
const labelText = this.theme.label(labelPadded, isSelected);
// Calculate space for value
const separator = " ";
const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator);
const valueMaxWidth = width - usedWidth - 2;
const valueText = this.theme.value(truncateToWidth(item.currentValue, valueMaxWidth, ""), isSelected);
lines.push(truncateToWidth(prefix + labelText + separator + valueText, width));
}
// Add scroll indicator if needed
if (startIndex > 0 || endIndex < displayItems.length) {
const scrollText = ` (${this.selectedIndex + 1}/${displayItems.length})`;
lines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, "")));
}
// Add description for selected item
const selectedItem = displayItems[this.selectedIndex];
if (selectedItem?.description) {
lines.push("");
const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4);
for (const line of wrappedDesc) {
lines.push(this.theme.description(` ${line}`));
}
}
// Add hint
this.addHintLine(lines, width);
return lines;
}
handleInput(data: string): void {
// If submenu is active, delegate all input to it
// The submenu's onCancel (triggered by escape) will call done() which closes it
if (this.submenuComponent) {
this.submenuComponent.handleInput?.(data);
return;
}
// Main list input handling
const kb = getKeybindings();
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
if (kb.matches(data, "tui.select.up")) {
if (displayItems.length === 0) return;
this.selectedIndex = this.selectedIndex === 0 ? displayItems.length - 1 : this.selectedIndex - 1;
} else if (kb.matches(data, "tui.select.down")) {
if (displayItems.length === 0) return;
this.selectedIndex = this.selectedIndex === displayItems.length - 1 ? 0 : this.selectedIndex + 1;
} else if (kb.matches(data, "tui.select.confirm") || data === " ") {
this.activateItem();
} else if (kb.matches(data, "tui.select.cancel")) {
this.onCancel();
} else if (this.searchEnabled && this.searchInput) {
const sanitized = data.replace(/ /g, "");
if (!sanitized) {
return;
}
this.searchInput.handleInput(sanitized);
this.applyFilter(this.searchInput.getValue());
}
}
private activateItem(): void {
const item = this.searchEnabled ? this.filteredItems[this.selectedIndex] : this.items[this.selectedIndex];
if (!item) return;
if (item.submenu) {
// Open submenu, passing current value so it can pre-select correctly
this.submenuItemIndex = this.selectedIndex;
this.submenuComponent = item.submenu(item.currentValue, (selectedValue?: string) => {
if (selectedValue !== undefined) {
item.currentValue = selectedValue;
this.onChange(item.id, selectedValue);
}
this.closeSubmenu();
});
} else if (item.values && item.values.length > 0) {
// Cycle through values
const currentIndex = item.values.indexOf(item.currentValue);
const nextIndex = (currentIndex + 1) % item.values.length;
const newValue = item.values[nextIndex];
item.currentValue = newValue;
this.onChange(item.id, newValue);
}
}
private closeSubmenu(): void {
this.submenuComponent = null;
// Restore selection to the item that opened the submenu
if (this.submenuItemIndex !== null) {
this.selectedIndex = this.submenuItemIndex;
this.submenuItemIndex = null;
}
}
private applyFilter(query: string): void {
this.filteredItems = fuzzyFilter(this.items, query, (item) => item.label);
this.selectedIndex = 0;
}
private addHintLine(lines: string[], width: number): void {
lines.push("");
lines.push(
truncateToWidth(
this.theme.hint(
this.searchEnabled
? " Type to search · Enter/Space to change · Esc to cancel"
: " Enter/Space to change · Esc to cancel",
),
width,
),
);
}
}
@@ -0,0 +1,28 @@
import type { Component } from "../tui.js";
/**
* Spacer component that renders empty lines
*/
export class Spacer implements Component {
private lines: number;
constructor(lines: number = 1) {
this.lines = lines;
}
setLines(lines: number): void {
this.lines = lines;
}
invalidate(): void {
// No cached state to invalidate currently
}
render(_width: number): string[] {
const result: string[] = [];
for (let i = 0; i < this.lines; i++) {
result.push("");
}
return result;
}
}
@@ -0,0 +1,106 @@
import type { Component } from "../tui.js";
import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.js";
/**
* Text component - displays multi-line text with word wrapping
*/
export class Text implements Component {
private text: string;
private paddingX: number; // Left/right padding
private paddingY: number; // Top/bottom padding
private customBgFn?: (text: string) => string;
// Cache for rendered output
private cachedText?: string;
private cachedWidth?: number;
private cachedLines?: string[];
constructor(text: string = "", paddingX: number = 1, paddingY: number = 1, customBgFn?: (text: string) => string) {
this.text = text;
this.paddingX = paddingX;
this.paddingY = paddingY;
this.customBgFn = customBgFn;
}
setText(text: string): void {
this.text = text;
this.cachedText = undefined;
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
setCustomBgFn(customBgFn?: (text: string) => string): void {
this.customBgFn = customBgFn;
this.cachedText = undefined;
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
invalidate(): void {
this.cachedText = undefined;
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
render(width: number): string[] {
// Check cache
if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) {
return this.cachedLines;
}
// Don't render anything if there's no actual text
if (!this.text || this.text.trim() === "") {
const result: string[] = [];
this.cachedText = this.text;
this.cachedWidth = width;
this.cachedLines = result;
return result;
}
// Replace tabs with 3 spaces
const normalizedText = this.text.replace(/\t/g, " ");
// Calculate content width (subtract left/right margins)
const contentWidth = Math.max(1, width - this.paddingX * 2);
// Wrap text (this preserves ANSI codes but does NOT pad)
const wrappedLines = wrapTextWithAnsi(normalizedText, contentWidth);
// Add margins and background to each line
const leftMargin = " ".repeat(this.paddingX);
const rightMargin = " ".repeat(this.paddingX);
const contentLines: string[] = [];
for (const line of wrappedLines) {
// Add margins
const lineWithMargins = leftMargin + line + rightMargin;
// Apply background if specified (this also pads to full width)
if (this.customBgFn) {
contentLines.push(applyBackgroundToLine(lineWithMargins, width, this.customBgFn));
} else {
// No background - just pad to width with spaces
const visibleLen = visibleWidth(lineWithMargins);
const paddingNeeded = Math.max(0, width - visibleLen);
contentLines.push(lineWithMargins + " ".repeat(paddingNeeded));
}
}
// Add top/bottom padding (empty lines)
const emptyLine = " ".repeat(width);
const emptyLines: string[] = [];
for (let i = 0; i < this.paddingY; i++) {
const line = this.customBgFn ? applyBackgroundToLine(emptyLine, width, this.customBgFn) : emptyLine;
emptyLines.push(line);
}
const result = [...emptyLines, ...contentLines, ...emptyLines];
// Update cache
this.cachedText = this.text;
this.cachedWidth = width;
this.cachedLines = result;
return result.length > 0 ? result : [""];
}
}
@@ -0,0 +1,65 @@
import type { Component } from "../tui.js";
import { truncateToWidth, visibleWidth } from "../utils.js";
/**
* Text component that truncates to fit viewport width
*/
export class TruncatedText implements Component {
private text: string;
private paddingX: number;
private paddingY: number;
constructor(text: string, paddingX: number = 0, paddingY: number = 0) {
this.text = text;
this.paddingX = paddingX;
this.paddingY = paddingY;
}
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] {
const result: string[] = [];
// Empty line padded to width
const emptyLine = " ".repeat(width);
// Add vertical padding above
for (let i = 0; i < this.paddingY; i++) {
result.push(emptyLine);
}
// Calculate available width after horizontal padding
const availableWidth = Math.max(1, width - this.paddingX * 2);
// Take only the first line (stop at newline)
let singleLineText = this.text;
const newlineIndex = this.text.indexOf("\n");
if (newlineIndex !== -1) {
singleLineText = this.text.substring(0, newlineIndex);
}
// Truncate text if needed (accounting for ANSI codes)
const displayText = truncateToWidth(singleLineText, availableWidth);
// Add horizontal padding
const leftPadding = " ".repeat(this.paddingX);
const rightPadding = " ".repeat(this.paddingX);
const lineWithPadding = leftPadding + displayText + rightPadding;
// Pad line to exactly width characters
const lineVisibleWidth = visibleWidth(lineWithPadding);
const paddingNeeded = Math.max(0, width - lineVisibleWidth);
const finalLine = lineWithPadding + " ".repeat(paddingNeeded);
result.push(finalLine);
// Add vertical padding below
for (let i = 0; i < this.paddingY; i++) {
result.push(emptyLine);
}
return result;
}
}
@@ -0,0 +1,74 @@
import type { AutocompleteProvider } from "./autocomplete.js";
import type { Component } from "./tui.js";
/**
* Interface for custom editor components.
*
* This allows extensions to provide their own editor implementation
* (e.g., vim mode, emacs mode, custom keybindings) while maintaining
* compatibility with the core application.
*/
export interface EditorComponent extends Component {
// =========================================================================
// Core text access (required)
// =========================================================================
/** Get the current text content */
getText(): string;
/** Set the text content */
setText(text: string): void;
/** Handle raw terminal input (key presses, paste sequences, etc.) */
handleInput(data: string): void;
// =========================================================================
// Callbacks (required)
// =========================================================================
/** Called when user submits (e.g., Enter key) */
onSubmit?: (text: string) => void;
/** Called when text changes */
onChange?: (text: string) => void;
// =========================================================================
// History support (optional)
// =========================================================================
/** Add text to history for up/down navigation */
addToHistory?(text: string): void;
// =========================================================================
// Advanced text manipulation (optional)
// =========================================================================
/** Insert text at current cursor position */
insertTextAtCursor?(text: string): void;
/**
* Get text with any markers expanded (e.g., paste markers).
* Falls back to getText() if not implemented.
*/
getExpandedText?(): string;
// =========================================================================
// Autocomplete support (optional)
// =========================================================================
/** Set the autocomplete provider */
setAutocompleteProvider?(provider: AutocompleteProvider): void;
// =========================================================================
// Appearance (optional)
// =========================================================================
/** Border color function */
borderColor?: (str: string) => string;
/** Set horizontal padding */
setPaddingX?(padding: number): void;
/** Set max visible items in autocomplete dropdown */
setAutocompleteMaxVisible?(maxVisible: number): void;
}
+133
View File
@@ -0,0 +1,133 @@
/**
* Fuzzy matching utilities.
* Matches if all query characters appear in order (not necessarily consecutive).
* Lower score = better match.
*/
export interface FuzzyMatch {
matches: boolean;
score: number;
}
export function fuzzyMatch(query: string, text: string): FuzzyMatch {
const queryLower = query.toLowerCase();
const textLower = text.toLowerCase();
const matchQuery = (normalizedQuery: string): FuzzyMatch => {
if (normalizedQuery.length === 0) {
return { matches: true, score: 0 };
}
if (normalizedQuery.length > textLower.length) {
return { matches: false, score: 0 };
}
let queryIndex = 0;
let score = 0;
let lastMatchIndex = -1;
let consecutiveMatches = 0;
for (let i = 0; i < textLower.length && queryIndex < normalizedQuery.length; i++) {
if (textLower[i] === normalizedQuery[queryIndex]) {
const isWordBoundary = i === 0 || /[\s\-_./:]/.test(textLower[i - 1]!);
// Reward consecutive matches
if (lastMatchIndex === i - 1) {
consecutiveMatches++;
score -= consecutiveMatches * 5;
} else {
consecutiveMatches = 0;
// Penalize gaps
if (lastMatchIndex >= 0) {
score += (i - lastMatchIndex - 1) * 2;
}
}
// Reward word boundary matches
if (isWordBoundary) {
score -= 10;
}
// Slight penalty for later matches
score += i * 0.1;
lastMatchIndex = i;
queryIndex++;
}
}
if (queryIndex < normalizedQuery.length) {
return { matches: false, score: 0 };
}
return { matches: true, score };
};
const primaryMatch = matchQuery(queryLower);
if (primaryMatch.matches) {
return primaryMatch;
}
const alphaNumericMatch = queryLower.match(/^(?<letters>[a-z]+)(?<digits>[0-9]+)$/);
const numericAlphaMatch = queryLower.match(/^(?<digits>[0-9]+)(?<letters>[a-z]+)$/);
const swappedQuery = alphaNumericMatch
? `${alphaNumericMatch.groups?.digits ?? ""}${alphaNumericMatch.groups?.letters ?? ""}`
: numericAlphaMatch
? `${numericAlphaMatch.groups?.letters ?? ""}${numericAlphaMatch.groups?.digits ?? ""}`
: "";
if (!swappedQuery) {
return primaryMatch;
}
const swappedMatch = matchQuery(swappedQuery);
if (!swappedMatch.matches) {
return primaryMatch;
}
return { matches: true, score: swappedMatch.score + 5 };
}
/**
* Filter and sort items by fuzzy match quality (best matches first).
* Supports space-separated tokens: all tokens must match.
*/
export function fuzzyFilter<T>(items: T[], query: string, getText: (item: T) => string): T[] {
if (!query.trim()) {
return items;
}
const tokens = query
.trim()
.split(/\s+/)
.filter((t) => t.length > 0);
if (tokens.length === 0) {
return items;
}
const results: { item: T; totalScore: number }[] = [];
for (const item of items) {
const text = getText(item);
let totalScore = 0;
let allMatch = true;
for (const token of tokens) {
const match = fuzzyMatch(token, text);
if (match.matches) {
totalScore += match.score;
} else {
allMatch = false;
break;
}
}
if (allMatch) {
results.push({ item, totalScore });
}
}
results.sort((a, b) => a.totalScore - b.totalScore);
return results.map((r) => r.item);
}
+106
View File
@@ -0,0 +1,106 @@
// Core TUI interfaces and classes
// Autocomplete support
export {
type AutocompleteItem,
type AutocompleteProvider,
type AutocompleteSuggestions,
CombinedAutocompleteProvider,
type SlashCommand,
} from "./autocomplete.js";
// Components
export { Box } from "./components/box.js";
export { CancellableLoader } from "./components/cancellable-loader.js";
export { Editor, type EditorOptions, type EditorTheme } from "./components/editor.js";
export { Image, type ImageOptions, type ImageTheme } from "./components/image.js";
export { Input } from "./components/input.js";
export { Loader } from "./components/loader.js";
export { type DefaultTextStyle, Markdown, type MarkdownTheme } from "./components/markdown.js";
export {
type SelectItem,
SelectList,
type SelectListLayoutOptions,
type SelectListTheme,
type SelectListTruncatePrimaryContext,
} from "./components/select-list.js";
export { type SettingItem, SettingsList, type SettingsListTheme } from "./components/settings-list.js";
export { Spacer } from "./components/spacer.js";
export { Text } from "./components/text.js";
export { TruncatedText } from "./components/truncated-text.js";
// Editor component interface (for custom editors)
export type { EditorComponent } from "./editor-component.js";
// Fuzzy matching
export { type FuzzyMatch, fuzzyFilter, fuzzyMatch } from "./fuzzy.js";
// Keybindings
export {
getKeybindings,
type Keybinding,
type KeybindingConflict,
type KeybindingDefinition,
type KeybindingDefinitions,
type Keybindings,
type KeybindingsConfig,
KeybindingsManager,
setKeybindings,
TUI_KEYBINDINGS,
} from "./keybindings.js";
// Keyboard input handling
export {
decodeKittyPrintable,
isKeyRelease,
isKeyRepeat,
isKittyProtocolActive,
Key,
type KeyEventType,
type KeyId,
matchesKey,
parseKey,
setKittyProtocolActive,
} from "./keys.js";
// Input buffering for batch splitting
export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.js";
// Terminal interface and implementations
export { ProcessTerminal, type Terminal } from "./terminal.js";
// Terminal image support
export {
allocateImageId,
type CellDimensions,
calculateImageRows,
deleteAllKittyImages,
deleteKittyImage,
detectCapabilities,
encodeITerm2,
encodeKitty,
getCapabilities,
getCellDimensions,
getGifDimensions,
getImageDimensions,
getJpegDimensions,
getPngDimensions,
getWebpDimensions,
hyperlink,
type ImageDimensions,
type ImageProtocol,
type ImageRenderOptions,
imageFallback,
renderImage,
resetCapabilitiesCache,
setCapabilities,
setCellDimensions,
type TerminalCapabilities,
} from "./terminal-image.js";
export {
type Component,
Container,
CURSOR_MARKER,
type Focusable,
isFocusable,
type OverlayAnchor,
type OverlayHandle,
type OverlayMargin,
type OverlayOptions,
type SizeValue,
TUI,
} from "./tui.js";
// Utilities
export { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils.js";
@@ -0,0 +1,244 @@
import { type KeyId, matchesKey } from "./keys.js";
/**
* Global keybinding registry.
* Downstream packages can add keybindings via declaration merging.
*/
export interface Keybindings {
// Editor navigation and editing
"tui.editor.cursorUp": true;
"tui.editor.cursorDown": true;
"tui.editor.cursorLeft": true;
"tui.editor.cursorRight": true;
"tui.editor.cursorWordLeft": true;
"tui.editor.cursorWordRight": true;
"tui.editor.cursorLineStart": true;
"tui.editor.cursorLineEnd": true;
"tui.editor.jumpForward": true;
"tui.editor.jumpBackward": true;
"tui.editor.pageUp": true;
"tui.editor.pageDown": true;
"tui.editor.deleteCharBackward": true;
"tui.editor.deleteCharForward": true;
"tui.editor.deleteWordBackward": true;
"tui.editor.deleteWordForward": true;
"tui.editor.deleteToLineStart": true;
"tui.editor.deleteToLineEnd": true;
"tui.editor.yank": true;
"tui.editor.yankPop": true;
"tui.editor.undo": true;
// Generic input actions
"tui.input.newLine": true;
"tui.input.submit": true;
"tui.input.tab": true;
"tui.input.copy": true;
// Generic selection actions
"tui.select.up": true;
"tui.select.down": true;
"tui.select.pageUp": true;
"tui.select.pageDown": true;
"tui.select.confirm": true;
"tui.select.cancel": true;
}
export type Keybinding = keyof Keybindings;
export interface KeybindingDefinition {
defaultKeys: KeyId | KeyId[];
description?: string;
}
export type KeybindingDefinitions = Record<string, KeybindingDefinition>;
export type KeybindingsConfig = Record<string, KeyId | KeyId[] | undefined>;
export const TUI_KEYBINDINGS = {
"tui.editor.cursorUp": { defaultKeys: "up", description: "Move cursor up" },
"tui.editor.cursorDown": { defaultKeys: "down", description: "Move cursor down" },
"tui.editor.cursorLeft": {
defaultKeys: ["left", "ctrl+b"],
description: "Move cursor left",
},
"tui.editor.cursorRight": {
defaultKeys: ["right", "ctrl+f"],
description: "Move cursor right",
},
"tui.editor.cursorWordLeft": {
defaultKeys: ["alt+left", "ctrl+left", "alt+b"],
description: "Move cursor word left",
},
"tui.editor.cursorWordRight": {
defaultKeys: ["alt+right", "ctrl+right", "alt+f"],
description: "Move cursor word right",
},
"tui.editor.cursorLineStart": {
defaultKeys: ["home", "ctrl+a"],
description: "Move to line start",
},
"tui.editor.cursorLineEnd": {
defaultKeys: ["end", "ctrl+e"],
description: "Move to line end",
},
"tui.editor.jumpForward": {
defaultKeys: "ctrl+]",
description: "Jump forward to character",
},
"tui.editor.jumpBackward": {
defaultKeys: "ctrl+alt+]",
description: "Jump backward to character",
},
"tui.editor.pageUp": { defaultKeys: "pageUp", description: "Page up" },
"tui.editor.pageDown": { defaultKeys: "pageDown", description: "Page down" },
"tui.editor.deleteCharBackward": {
defaultKeys: "backspace",
description: "Delete character backward",
},
"tui.editor.deleteCharForward": {
defaultKeys: ["delete", "ctrl+d"],
description: "Delete character forward",
},
"tui.editor.deleteWordBackward": {
defaultKeys: ["ctrl+w", "alt+backspace"],
description: "Delete word backward",
},
"tui.editor.deleteWordForward": {
defaultKeys: ["alt+d", "alt+delete"],
description: "Delete word forward",
},
"tui.editor.deleteToLineStart": {
defaultKeys: "ctrl+u",
description: "Delete to line start",
},
"tui.editor.deleteToLineEnd": {
defaultKeys: "ctrl+k",
description: "Delete to line end",
},
"tui.editor.yank": { defaultKeys: "ctrl+y", description: "Yank" },
"tui.editor.yankPop": { defaultKeys: "alt+y", description: "Yank pop" },
"tui.editor.undo": { defaultKeys: "ctrl+-", description: "Undo" },
"tui.input.newLine": { defaultKeys: "shift+enter", description: "Insert newline" },
"tui.input.submit": { defaultKeys: "enter", description: "Submit input" },
"tui.input.tab": { defaultKeys: "tab", description: "Tab / autocomplete" },
"tui.input.copy": { defaultKeys: "ctrl+c", description: "Copy selection" },
"tui.select.up": { defaultKeys: "up", description: "Move selection up" },
"tui.select.down": { defaultKeys: "down", description: "Move selection down" },
"tui.select.pageUp": { defaultKeys: "pageUp", description: "Selection page up" },
"tui.select.pageDown": {
defaultKeys: "pageDown",
description: "Selection page down",
},
"tui.select.confirm": { defaultKeys: "enter", description: "Confirm selection" },
"tui.select.cancel": {
defaultKeys: ["escape", "ctrl+c"],
description: "Cancel selection",
},
} as const satisfies KeybindingDefinitions;
export interface KeybindingConflict {
key: KeyId;
keybindings: string[];
}
function normalizeKeys(keys: KeyId | KeyId[] | undefined): KeyId[] {
if (keys === undefined) return [];
const keyList = Array.isArray(keys) ? keys : [keys];
const seen = new Set<KeyId>();
const result: KeyId[] = [];
for (const key of keyList) {
if (!seen.has(key)) {
seen.add(key);
result.push(key);
}
}
return result;
}
export class KeybindingsManager {
private definitions: KeybindingDefinitions;
private userBindings: KeybindingsConfig;
private keysById = new Map<Keybinding, KeyId[]>();
private conflicts: KeybindingConflict[] = [];
constructor(definitions: KeybindingDefinitions, userBindings: KeybindingsConfig = {}) {
this.definitions = definitions;
this.userBindings = userBindings;
this.rebuild();
}
private rebuild(): void {
this.keysById.clear();
this.conflicts = [];
const userClaims = new Map<KeyId, Set<Keybinding>>();
for (const [keybinding, keys] of Object.entries(this.userBindings)) {
if (!(keybinding in this.definitions)) continue;
for (const key of normalizeKeys(keys)) {
const claimants = userClaims.get(key) ?? new Set<Keybinding>();
claimants.add(keybinding as Keybinding);
userClaims.set(key, claimants);
}
}
for (const [key, keybindings] of userClaims) {
if (keybindings.size > 1) {
this.conflicts.push({ key, keybindings: [...keybindings] });
}
}
for (const [id, definition] of Object.entries(this.definitions)) {
const userKeys = this.userBindings[id];
const keys = userKeys === undefined ? normalizeKeys(definition.defaultKeys) : normalizeKeys(userKeys);
this.keysById.set(id as Keybinding, keys);
}
}
matches(data: string, keybinding: Keybinding): boolean {
const keys = this.keysById.get(keybinding) ?? [];
for (const key of keys) {
if (matchesKey(data, key)) return true;
}
return false;
}
getKeys(keybinding: Keybinding): KeyId[] {
return [...(this.keysById.get(keybinding) ?? [])];
}
getDefinition(keybinding: Keybinding): KeybindingDefinition {
return this.definitions[keybinding];
}
getConflicts(): KeybindingConflict[] {
return this.conflicts.map((conflict) => ({ ...conflict, keybindings: [...conflict.keybindings] }));
}
setUserBindings(userBindings: KeybindingsConfig): void {
this.userBindings = userBindings;
this.rebuild();
}
getUserBindings(): KeybindingsConfig {
return { ...this.userBindings };
}
getResolvedBindings(): KeybindingsConfig {
const resolved: KeybindingsConfig = {};
for (const id of Object.keys(this.definitions)) {
const keys = this.keysById.get(id as Keybinding) ?? [];
resolved[id] = keys.length === 1 ? keys[0]! : [...keys];
}
return resolved;
}
}
let globalKeybindings: KeybindingsManager | null = null;
export function setKeybindings(keybindings: KeybindingsManager): void {
globalKeybindings = keybindings;
}
export function getKeybindings(): KeybindingsManager {
if (!globalKeybindings) {
globalKeybindings = new KeybindingsManager(TUI_KEYBINDINGS);
}
return globalKeybindings;
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,46 @@
/**
* Ring buffer for Emacs-style kill/yank operations.
*
* Tracks killed (deleted) text entries. Consecutive kills can accumulate
* into a single entry. Supports yank (paste most recent) and yank-pop
* (cycle through older entries).
*/
export class KillRing {
private ring: string[] = [];
/**
* Add text to the kill ring.
*
* @param text - The killed text to add
* @param opts - Push options
* @param opts.prepend - If accumulating, prepend (backward deletion) or append (forward deletion)
* @param opts.accumulate - Merge with the most recent entry instead of creating a new one
*/
push(text: string, opts: { prepend: boolean; accumulate?: boolean }): void {
if (!text) return;
if (opts.accumulate && this.ring.length > 0) {
const last = this.ring.pop()!;
this.ring.push(opts.prepend ? text + last : last + text);
} else {
this.ring.push(text);
}
}
/** Get most recent entry without modifying the ring. */
peek(): string | undefined {
return this.ring.length > 0 ? this.ring[this.ring.length - 1] : undefined;
}
/** Move last entry to front (for yank-pop cycling). */
rotate(): void {
if (this.ring.length > 1) {
const last = this.ring.pop()!;
this.ring.unshift(last);
}
}
get length(): number {
return this.ring.length;
}
}
@@ -0,0 +1,386 @@
/**
* StdinBuffer buffers input and emits complete sequences.
*
* This is necessary because stdin data events can arrive in partial chunks,
* especially for escape sequences like mouse events. Without buffering,
* partial sequences can be misinterpreted as regular keypresses.
*
* For example, the mouse SGR sequence `\x1b[<35;20;5m` might arrive as:
* - Event 1: `\x1b`
* - Event 2: `[<35`
* - Event 3: `;20;5m`
*
* The buffer accumulates these until a complete sequence is detected.
* Call the `process()` method to feed input data.
*
* Based on code from OpenTUI (https://github.com/anomalyco/opentui)
* MIT License - Copyright (c) 2025 opentui
*/
import { EventEmitter } from "events";
const ESC = "\x1b";
const BRACKETED_PASTE_START = "\x1b[200~";
const BRACKETED_PASTE_END = "\x1b[201~";
/**
* Check if a string is a complete escape sequence or needs more data
*/
function isCompleteSequence(data: string): "complete" | "incomplete" | "not-escape" {
if (!data.startsWith(ESC)) {
return "not-escape";
}
if (data.length === 1) {
return "incomplete";
}
const afterEsc = data.slice(1);
// CSI sequences: ESC [
if (afterEsc.startsWith("[")) {
// Check for old-style mouse sequence: ESC[M + 3 bytes
if (afterEsc.startsWith("[M")) {
// Old-style mouse needs ESC[M + 3 bytes = 6 total
return data.length >= 6 ? "complete" : "incomplete";
}
return isCompleteCsiSequence(data);
}
// OSC sequences: ESC ]
if (afterEsc.startsWith("]")) {
return isCompleteOscSequence(data);
}
// DCS sequences: ESC P ... ESC \ (includes XTVersion responses)
if (afterEsc.startsWith("P")) {
return isCompleteDcsSequence(data);
}
// APC sequences: ESC _ ... ESC \ (includes Kitty graphics responses)
if (afterEsc.startsWith("_")) {
return isCompleteApcSequence(data);
}
// SS3 sequences: ESC O
if (afterEsc.startsWith("O")) {
// ESC O followed by a single character
return afterEsc.length >= 2 ? "complete" : "incomplete";
}
// Meta key sequences: ESC followed by a single character
if (afterEsc.length === 1) {
return "complete";
}
// Unknown escape sequence - treat as complete
return "complete";
}
/**
* Check if CSI sequence is complete
* CSI sequences: ESC [ ... followed by a final byte (0x40-0x7E)
*/
function isCompleteCsiSequence(data: string): "complete" | "incomplete" {
if (!data.startsWith(`${ESC}[`)) {
return "complete";
}
// Need at least ESC [ and one more character
if (data.length < 3) {
return "incomplete";
}
const payload = data.slice(2);
// CSI sequences end with a byte in the range 0x40-0x7E (@-~)
// This includes all letters and several special characters
const lastChar = payload[payload.length - 1];
const lastCharCode = lastChar.charCodeAt(0);
if (lastCharCode >= 0x40 && lastCharCode <= 0x7e) {
// Special handling for SGR mouse sequences
// Format: ESC[<B;X;Ym or ESC[<B;X;YM
if (payload.startsWith("<")) {
// Must have format: <digits;digits;digits[Mm]
const mouseMatch = /^<\d+;\d+;\d+[Mm]$/.test(payload);
if (mouseMatch) {
return "complete";
}
// If it ends with M or m but doesn't match the pattern, still incomplete
if (lastChar === "M" || lastChar === "m") {
// Check if we have the right structure
const parts = payload.slice(1, -1).split(";");
if (parts.length === 3 && parts.every((p) => /^\d+$/.test(p))) {
return "complete";
}
}
return "incomplete";
}
return "complete";
}
return "incomplete";
}
/**
* Check if OSC sequence is complete
* OSC sequences: ESC ] ... ST (where ST is ESC \ or BEL)
*/
function isCompleteOscSequence(data: string): "complete" | "incomplete" {
if (!data.startsWith(`${ESC}]`)) {
return "complete";
}
// OSC sequences end with ST (ESC \) or BEL (\x07)
if (data.endsWith(`${ESC}\\`) || data.endsWith("\x07")) {
return "complete";
}
return "incomplete";
}
/**
* Check if DCS (Device Control String) sequence is complete
* DCS sequences: ESC P ... ST (where ST is ESC \)
* Used for XTVersion responses like ESC P >| ... ESC \
*/
function isCompleteDcsSequence(data: string): "complete" | "incomplete" {
if (!data.startsWith(`${ESC}P`)) {
return "complete";
}
// DCS sequences end with ST (ESC \)
if (data.endsWith(`${ESC}\\`)) {
return "complete";
}
return "incomplete";
}
/**
* Check if APC (Application Program Command) sequence is complete
* APC sequences: ESC _ ... ST (where ST is ESC \)
* Used for Kitty graphics responses like ESC _ G ... ESC \
*/
function isCompleteApcSequence(data: string): "complete" | "incomplete" {
if (!data.startsWith(`${ESC}_`)) {
return "complete";
}
// APC sequences end with ST (ESC \)
if (data.endsWith(`${ESC}\\`)) {
return "complete";
}
return "incomplete";
}
/**
* Split accumulated buffer into complete sequences
*/
function extractCompleteSequences(buffer: string): { sequences: string[]; remainder: string } {
const sequences: string[] = [];
let pos = 0;
while (pos < buffer.length) {
const remaining = buffer.slice(pos);
// Try to extract a sequence starting at this position
if (remaining.startsWith(ESC)) {
// Find the end of this escape sequence
let seqEnd = 1;
while (seqEnd <= remaining.length) {
const candidate = remaining.slice(0, seqEnd);
const status = isCompleteSequence(candidate);
if (status === "complete") {
sequences.push(candidate);
pos += seqEnd;
break;
} else if (status === "incomplete") {
seqEnd++;
} else {
// Should not happen when starting with ESC
sequences.push(candidate);
pos += seqEnd;
break;
}
}
if (seqEnd > remaining.length) {
return { sequences, remainder: remaining };
}
} else {
// Not an escape sequence - take a single character
sequences.push(remaining[0]!);
pos++;
}
}
return { sequences, remainder: "" };
}
export type StdinBufferOptions = {
/**
* Maximum time to wait for sequence completion (default: 10ms)
* After this time, the buffer is flushed even if incomplete
*/
timeout?: number;
};
export type StdinBufferEventMap = {
data: [string];
paste: [string];
};
/**
* Buffers stdin input and emits complete sequences via the 'data' event.
* Handles partial escape sequences that arrive across multiple chunks.
*/
export class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
private buffer: string = "";
private timeout: ReturnType<typeof setTimeout> | null = null;
private readonly timeoutMs: number;
private pasteMode: boolean = false;
private pasteBuffer: string = "";
constructor(options: StdinBufferOptions = {}) {
super();
this.timeoutMs = options.timeout ?? 10;
}
public process(data: string | Buffer): void {
// Clear any pending timeout
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = null;
}
// Handle high-byte conversion (for compatibility with parseKeypress)
// If buffer has single byte > 127, convert to ESC + (byte - 128)
let str: string;
if (Buffer.isBuffer(data)) {
if (data.length === 1 && data[0]! > 127) {
const byte = data[0]! - 128;
str = `\x1b${String.fromCharCode(byte)}`;
} else {
str = data.toString();
}
} else {
str = data;
}
if (str.length === 0 && this.buffer.length === 0) {
this.emit("data", "");
return;
}
this.buffer += str;
if (this.pasteMode) {
this.pasteBuffer += this.buffer;
this.buffer = "";
const endIndex = this.pasteBuffer.indexOf(BRACKETED_PASTE_END);
if (endIndex !== -1) {
const pastedContent = this.pasteBuffer.slice(0, endIndex);
const remaining = this.pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length);
this.pasteMode = false;
this.pasteBuffer = "";
this.emit("paste", pastedContent);
if (remaining.length > 0) {
this.process(remaining);
}
}
return;
}
const startIndex = this.buffer.indexOf(BRACKETED_PASTE_START);
if (startIndex !== -1) {
if (startIndex > 0) {
const beforePaste = this.buffer.slice(0, startIndex);
const result = extractCompleteSequences(beforePaste);
for (const sequence of result.sequences) {
this.emit("data", sequence);
}
}
this.buffer = this.buffer.slice(startIndex + BRACKETED_PASTE_START.length);
this.pasteMode = true;
this.pasteBuffer = this.buffer;
this.buffer = "";
const endIndex = this.pasteBuffer.indexOf(BRACKETED_PASTE_END);
if (endIndex !== -1) {
const pastedContent = this.pasteBuffer.slice(0, endIndex);
const remaining = this.pasteBuffer.slice(endIndex + BRACKETED_PASTE_END.length);
this.pasteMode = false;
this.pasteBuffer = "";
this.emit("paste", pastedContent);
if (remaining.length > 0) {
this.process(remaining);
}
}
return;
}
const result = extractCompleteSequences(this.buffer);
this.buffer = result.remainder;
for (const sequence of result.sequences) {
this.emit("data", sequence);
}
if (this.buffer.length > 0) {
this.timeout = setTimeout(() => {
const flushed = this.flush();
for (const sequence of flushed) {
this.emit("data", sequence);
}
}, this.timeoutMs);
}
}
flush(): string[] {
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = null;
}
if (this.buffer.length === 0) {
return [];
}
const sequences = [this.buffer];
this.buffer = "";
return sequences;
}
clear(): void {
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = null;
}
this.buffer = "";
this.pasteMode = false;
this.pasteBuffer = "";
}
getBuffer(): string {
return this.buffer;
}
destroy(): void {
this.clear();
}
}
@@ -0,0 +1,414 @@
export type ImageProtocol = "kitty" | "iterm2" | null;
export interface TerminalCapabilities {
images: ImageProtocol;
trueColor: boolean;
hyperlinks: boolean;
}
export interface CellDimensions {
widthPx: number;
heightPx: number;
}
export interface ImageDimensions {
widthPx: number;
heightPx: number;
}
export interface ImageRenderOptions {
maxWidthCells?: number;
maxHeightCells?: number;
preserveAspectRatio?: boolean;
/** Kitty image ID. If provided, reuses/replaces existing image with this ID. */
imageId?: number;
}
let cachedCapabilities: TerminalCapabilities | null = null;
// Default cell dimensions - updated by TUI when terminal responds to query
let cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 };
export function getCellDimensions(): CellDimensions {
return cellDimensions;
}
export function setCellDimensions(dims: CellDimensions): void {
cellDimensions = dims;
}
export function detectCapabilities(): TerminalCapabilities {
const termProgram = process.env.TERM_PROGRAM?.toLowerCase() || "";
const term = process.env.TERM?.toLowerCase() || "";
const colorTerm = process.env.COLORTERM?.toLowerCase() || "";
// tmux and screen swallow OSC 8 by default (passthrough is opt-in and wraps
// sequences differently). Force hyperlinks off whenever we detect them, even
// when the outer terminal would otherwise support OSC 8. Image protocols are
// also unreliable under tmux/screen, so leave `images: null` for safety.
const inTmuxOrScreen = !!process.env.TMUX || term.startsWith("tmux") || term.startsWith("screen");
if (inTmuxOrScreen) {
const trueColor = colorTerm === "truecolor" || colorTerm === "24bit";
return { images: null, trueColor, hyperlinks: false };
}
if (process.env.KITTY_WINDOW_ID || termProgram === "kitty") {
return { images: "kitty", trueColor: true, hyperlinks: true };
}
if (termProgram === "ghostty" || term.includes("ghostty") || process.env.GHOSTTY_RESOURCES_DIR) {
return { images: "kitty", trueColor: true, hyperlinks: true };
}
if (process.env.WEZTERM_PANE || termProgram === "wezterm") {
return { images: "kitty", trueColor: true, hyperlinks: true };
}
if (process.env.ITERM_SESSION_ID || termProgram === "iterm.app") {
return { images: "iterm2", trueColor: true, hyperlinks: true };
}
if (termProgram === "vscode") {
return { images: null, trueColor: true, hyperlinks: true };
}
if (termProgram === "alacritty") {
return { images: null, trueColor: true, hyperlinks: true };
}
// Unknown terminal: be conservative. OSC 8 is rendered invisibly as "just
// text" on terminals that swallow it, which means the URL disappears from
// the rendered output. Default to the legacy `text (url)` behavior unless we
// have positively identified a hyperlink-capable terminal above.
const trueColor = colorTerm === "truecolor" || colorTerm === "24bit";
return { images: null, trueColor, hyperlinks: false };
}
export function getCapabilities(): TerminalCapabilities {
if (!cachedCapabilities) {
cachedCapabilities = detectCapabilities();
}
return cachedCapabilities;
}
export function resetCapabilitiesCache(): void {
cachedCapabilities = null;
}
/** Override the cached capabilities. Useful in tests to exercise both code paths. */
export function setCapabilities(caps: TerminalCapabilities): void {
cachedCapabilities = caps;
}
const KITTY_PREFIX = "\x1b_G";
const ITERM2_PREFIX = "\x1b]1337;File=";
export function isImageLine(line: string): boolean {
// Fast path: sequence at line start (single-row images)
if (line.startsWith(KITTY_PREFIX) || line.startsWith(ITERM2_PREFIX)) {
return true;
}
// Slow path: sequence elsewhere (multi-row images have cursor-up prefix)
return line.includes(KITTY_PREFIX) || line.includes(ITERM2_PREFIX);
}
/**
* Generate a random image ID for Kitty graphics protocol.
* Uses random IDs to avoid collisions between different module instances
* (e.g., main app vs extensions).
*/
export function allocateImageId(): number {
// Use random ID in range [1, 0xffffffff] to avoid collisions
return Math.floor(Math.random() * 0xfffffffe) + 1;
}
export function encodeKitty(
base64Data: string,
options: {
columns?: number;
rows?: number;
imageId?: number;
} = {},
): string {
const CHUNK_SIZE = 4096;
const params: string[] = ["a=T", "f=100", "q=2"];
if (options.columns) params.push(`c=${options.columns}`);
if (options.rows) params.push(`r=${options.rows}`);
if (options.imageId) params.push(`i=${options.imageId}`);
if (base64Data.length <= CHUNK_SIZE) {
return `\x1b_G${params.join(",")};${base64Data}\x1b\\`;
}
const chunks: string[] = [];
let offset = 0;
let isFirst = true;
while (offset < base64Data.length) {
const chunk = base64Data.slice(offset, offset + CHUNK_SIZE);
const isLast = offset + CHUNK_SIZE >= base64Data.length;
if (isFirst) {
chunks.push(`\x1b_G${params.join(",")},m=1;${chunk}\x1b\\`);
isFirst = false;
} else if (isLast) {
chunks.push(`\x1b_Gm=0;${chunk}\x1b\\`);
} else {
chunks.push(`\x1b_Gm=1;${chunk}\x1b\\`);
}
offset += CHUNK_SIZE;
}
return chunks.join("");
}
/**
* Delete a Kitty graphics image by ID.
* Uses uppercase 'I' to also free the image data.
*/
export function deleteKittyImage(imageId: number): string {
return `\x1b_Ga=d,d=I,i=${imageId}\x1b\\`;
}
/**
* Delete all visible Kitty graphics images.
* Uses uppercase 'A' to also free the image data.
*/
export function deleteAllKittyImages(): string {
return `\x1b_Ga=d,d=A\x1b\\`;
}
export function encodeITerm2(
base64Data: string,
options: {
width?: number | string;
height?: number | string;
name?: string;
preserveAspectRatio?: boolean;
inline?: boolean;
} = {},
): string {
const params: string[] = [`inline=${options.inline !== false ? 1 : 0}`];
if (options.width !== undefined) params.push(`width=${options.width}`);
if (options.height !== undefined) params.push(`height=${options.height}`);
if (options.name) {
const nameBase64 = Buffer.from(options.name).toString("base64");
params.push(`name=${nameBase64}`);
}
if (options.preserveAspectRatio === false) {
params.push("preserveAspectRatio=0");
}
return `\x1b]1337;File=${params.join(";")}:${base64Data}\x07`;
}
export function calculateImageRows(
imageDimensions: ImageDimensions,
targetWidthCells: number,
cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 },
): number {
const targetWidthPx = targetWidthCells * cellDimensions.widthPx;
const scale = targetWidthPx / imageDimensions.widthPx;
const scaledHeightPx = imageDimensions.heightPx * scale;
const rows = Math.ceil(scaledHeightPx / cellDimensions.heightPx);
return Math.max(1, rows);
}
export function getPngDimensions(base64Data: string): ImageDimensions | null {
try {
const buffer = Buffer.from(base64Data, "base64");
if (buffer.length < 24) {
return null;
}
if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4e || buffer[3] !== 0x47) {
return null;
}
const width = buffer.readUInt32BE(16);
const height = buffer.readUInt32BE(20);
return { widthPx: width, heightPx: height };
} catch {
return null;
}
}
export function getJpegDimensions(base64Data: string): ImageDimensions | null {
try {
const buffer = Buffer.from(base64Data, "base64");
if (buffer.length < 2) {
return null;
}
if (buffer[0] !== 0xff || buffer[1] !== 0xd8) {
return null;
}
let offset = 2;
while (offset < buffer.length - 9) {
if (buffer[offset] !== 0xff) {
offset++;
continue;
}
const marker = buffer[offset + 1];
if (marker >= 0xc0 && marker <= 0xc2) {
const height = buffer.readUInt16BE(offset + 5);
const width = buffer.readUInt16BE(offset + 7);
return { widthPx: width, heightPx: height };
}
if (offset + 3 >= buffer.length) {
return null;
}
const length = buffer.readUInt16BE(offset + 2);
if (length < 2) {
return null;
}
offset += 2 + length;
}
return null;
} catch {
return null;
}
}
export function getGifDimensions(base64Data: string): ImageDimensions | null {
try {
const buffer = Buffer.from(base64Data, "base64");
if (buffer.length < 10) {
return null;
}
const sig = buffer.slice(0, 6).toString("ascii");
if (sig !== "GIF87a" && sig !== "GIF89a") {
return null;
}
const width = buffer.readUInt16LE(6);
const height = buffer.readUInt16LE(8);
return { widthPx: width, heightPx: height };
} catch {
return null;
}
}
export function getWebpDimensions(base64Data: string): ImageDimensions | null {
try {
const buffer = Buffer.from(base64Data, "base64");
if (buffer.length < 30) {
return null;
}
const riff = buffer.slice(0, 4).toString("ascii");
const webp = buffer.slice(8, 12).toString("ascii");
if (riff !== "RIFF" || webp !== "WEBP") {
return null;
}
const chunk = buffer.slice(12, 16).toString("ascii");
if (chunk === "VP8 ") {
if (buffer.length < 30) return null;
const width = buffer.readUInt16LE(26) & 0x3fff;
const height = buffer.readUInt16LE(28) & 0x3fff;
return { widthPx: width, heightPx: height };
} else if (chunk === "VP8L") {
if (buffer.length < 25) return null;
const bits = buffer.readUInt32LE(21);
const width = (bits & 0x3fff) + 1;
const height = ((bits >> 14) & 0x3fff) + 1;
return { widthPx: width, heightPx: height };
} else if (chunk === "VP8X") {
if (buffer.length < 30) return null;
const width = (buffer[24] | (buffer[25] << 8) | (buffer[26] << 16)) + 1;
const height = (buffer[27] | (buffer[28] << 8) | (buffer[29] << 16)) + 1;
return { widthPx: width, heightPx: height };
}
return null;
} catch {
return null;
}
}
export function getImageDimensions(base64Data: string, mimeType: string): ImageDimensions | null {
if (mimeType === "image/png") {
return getPngDimensions(base64Data);
}
if (mimeType === "image/jpeg") {
return getJpegDimensions(base64Data);
}
if (mimeType === "image/gif") {
return getGifDimensions(base64Data);
}
if (mimeType === "image/webp") {
return getWebpDimensions(base64Data);
}
return null;
}
export function renderImage(
base64Data: string,
imageDimensions: ImageDimensions,
options: ImageRenderOptions = {},
): { sequence: string; rows: number; imageId?: number } | null {
const caps = getCapabilities();
if (!caps.images) {
return null;
}
const maxWidth = options.maxWidthCells ?? 80;
const rows = calculateImageRows(imageDimensions, maxWidth, getCellDimensions());
if (caps.images === "kitty") {
// Only use imageId if explicitly provided - static images don't need IDs
const sequence = encodeKitty(base64Data, { columns: maxWidth, rows, imageId: options.imageId });
return { sequence, rows, imageId: options.imageId };
}
if (caps.images === "iterm2") {
const sequence = encodeITerm2(base64Data, {
width: maxWidth,
height: "auto",
preserveAspectRatio: options.preserveAspectRatio ?? true,
});
return { sequence, rows };
}
return null;
}
/**
* Wrap text in an OSC 8 hyperlink sequence.
* The text is rendered as a clickable hyperlink in terminals that support OSC 8
* (Ghostty, Kitty, WezTerm, iTerm2, VSCode, and others).
* In terminals that do not support OSC 8, the escape sequences are ignored
* and only the plain text is displayed.
*
* @param text - The visible text to display
* @param url - The URL to link to
*/
export function hyperlink(text: string, url: string): string {
return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
}
export function imageFallback(mimeType: string, dimensions?: ImageDimensions, filename?: string): string {
const parts: string[] = [];
if (filename) parts.push(filename);
parts.push(`[${mimeType}]`);
if (dimensions) parts.push(`${dimensions.widthPx}x${dimensions.heightPx}`);
return `[Image: ${parts.join(" ")}]`;
}
@@ -0,0 +1,360 @@
import * as fs from "node:fs";
import { createRequire } from "node:module";
import * as path from "node:path";
import { setKittyProtocolActive } from "./keys.js";
import { StdinBuffer } from "./stdin-buffer.js";
const cjsRequire = createRequire(import.meta.url);
/**
* Minimal terminal interface for TUI
*/
export interface Terminal {
// Start the terminal with input and resize handlers
start(onInput: (data: string) => void, onResize: () => void): void;
// Stop the terminal and restore state
stop(): void;
/**
* Drain stdin before exiting to prevent Kitty key release events from
* leaking to the parent shell over slow SSH connections.
* @param maxMs - Maximum time to drain (default: 1000ms)
* @param idleMs - Exit early if no input arrives within this time (default: 50ms)
*/
drainInput(maxMs?: number, idleMs?: number): Promise<void>;
// Write output to terminal
write(data: string): void;
// Get terminal dimensions
get columns(): number;
get rows(): number;
// Whether Kitty keyboard protocol is active
get kittyProtocolActive(): boolean;
// Cursor positioning (relative to current position)
moveBy(lines: number): void; // Move cursor up (negative) or down (positive) by N lines
// Cursor visibility
hideCursor(): void; // Hide the cursor
showCursor(): void; // Show the cursor
// Clear operations
clearLine(): void; // Clear current line
clearFromCursor(): void; // Clear from cursor to end of screen
clearScreen(): void; // Clear entire screen and move cursor to (0,0)
// Title operations
setTitle(title: string): void; // Set terminal window title
}
/**
* Real terminal using process.stdin/stdout
*/
export class ProcessTerminal implements Terminal {
private wasRaw = false;
private inputHandler?: (data: string) => void;
private resizeHandler?: () => void;
private _kittyProtocolActive = false;
private _modifyOtherKeysActive = false;
private stdinBuffer?: StdinBuffer;
private stdinDataHandler?: (data: string) => void;
private writeLogPath = (() => {
const env = process.env.PI_TUI_WRITE_LOG || "";
if (!env) return "";
try {
if (fs.statSync(env).isDirectory()) {
const now = new Date();
const ts = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}_${String(now.getHours()).padStart(2, "0")}-${String(now.getMinutes()).padStart(2, "0")}-${String(now.getSeconds()).padStart(2, "0")}`;
return path.join(env, `tui-${ts}-${process.pid}.log`);
}
} catch {
// Not an existing directory - use as-is (file path)
}
return env;
})();
get kittyProtocolActive(): boolean {
return this._kittyProtocolActive;
}
start(onInput: (data: string) => void, onResize: () => void): void {
this.inputHandler = onInput;
this.resizeHandler = onResize;
// Save previous state and enable raw mode
this.wasRaw = process.stdin.isRaw || false;
if (process.stdin.setRawMode) {
process.stdin.setRawMode(true);
}
process.stdin.setEncoding("utf8");
process.stdin.resume();
// Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
process.stdout.write("\x1b[?2004h");
// Set up resize handler immediately
process.stdout.on("resize", this.resizeHandler);
// Refresh terminal dimensions - they may be stale after suspend/resume
// (SIGWINCH is lost while process is stopped). Unix only.
if (process.platform !== "win32") {
process.kill(process.pid, "SIGWINCH");
}
// On Windows, enable ENABLE_VIRTUAL_TERMINAL_INPUT so the console sends
// VT escape sequences (e.g. \x1b[Z for Shift+Tab) instead of raw console
// events that lose modifier information. Must run AFTER setRawMode(true)
// since that resets console mode flags.
this.enableWindowsVTInput();
// Query and enable Kitty keyboard protocol
// The query handler intercepts input temporarily, then installs the user's handler
// See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
this.queryAndEnableKittyProtocol();
}
/**
* Set up StdinBuffer to split batched input into individual sequences.
* This ensures components receive single events, making matchesKey/isKeyRelease work correctly.
*
* Also watches for Kitty protocol response and enables it when detected.
* This is done here (after stdinBuffer parsing) rather than on raw stdin
* to handle the case where the response arrives split across multiple events.
*/
private setupStdinBuffer(): void {
this.stdinBuffer = new StdinBuffer({ timeout: 10 });
// Kitty protocol response pattern: \x1b[?<flags>u
const kittyResponsePattern = /^\x1b\[\?(\d+)u$/;
// Forward individual sequences to the input handler
this.stdinBuffer.on("data", (sequence) => {
// Check for Kitty protocol response (only if not already enabled)
if (!this._kittyProtocolActive) {
const match = sequence.match(kittyResponsePattern);
if (match) {
this._kittyProtocolActive = true;
setKittyProtocolActive(true);
// Enable Kitty keyboard protocol (push flags)
// Flag 1 = disambiguate escape codes
// Flag 2 = report event types (press/repeat/release)
// Flag 4 = report alternate keys (shifted key, base layout key)
// Base layout key enables shortcuts to work with non-Latin keyboard layouts
process.stdout.write("\x1b[>7u");
return; // Don't forward protocol response to TUI
}
}
if (this.inputHandler) {
this.inputHandler(sequence);
}
});
// Re-wrap paste content with bracketed paste markers for existing editor handling
this.stdinBuffer.on("paste", (content) => {
if (this.inputHandler) {
this.inputHandler(`\x1b[200~${content}\x1b[201~`);
}
});
// Handler that pipes stdin data through the buffer
this.stdinDataHandler = (data: string) => {
this.stdinBuffer!.process(data);
};
}
/**
* Query terminal for Kitty keyboard protocol support and enable if available.
*
* Sends CSI ? u to query current flags. If terminal responds with CSI ? <flags> u,
* it supports the protocol and we enable it with CSI > 1 u.
*
* If no Kitty response arrives shortly after startup, fall back to enabling
* xterm modifyOtherKeys mode 2. This is needed for tmux, which can forward
* modified enter keys as CSI-u when extended-keys is enabled, but may not
* answer the Kitty protocol query.
*
* The response is detected in setupStdinBuffer's data handler, which properly
* handles the case where the response arrives split across multiple stdin events.
*/
private queryAndEnableKittyProtocol(): void {
this.setupStdinBuffer();
process.stdin.on("data", this.stdinDataHandler!);
process.stdout.write("\x1b[?u");
setTimeout(() => {
if (!this._kittyProtocolActive && !this._modifyOtherKeysActive) {
process.stdout.write("\x1b[>4;2m");
this._modifyOtherKeysActive = true;
}
}, 150);
}
/**
* On Windows, add ENABLE_VIRTUAL_TERMINAL_INPUT (0x0200) to the stdin
* console handle so the terminal sends VT sequences for modified keys
* (e.g. \x1b[Z for Shift+Tab). Without this, libuv's ReadConsoleInputW
* discards modifier state and Shift+Tab arrives as plain \t.
*/
private enableWindowsVTInput(): void {
if (process.platform !== "win32") return;
try {
// Dynamic require to avoid bundling koffi's 74MB of cross-platform
// native binaries into every compiled binary. Koffi is only needed
// on Windows for VT input support.
const koffi = cjsRequire("koffi");
const k32 = koffi.load("kernel32.dll");
const GetStdHandle = k32.func("void* __stdcall GetStdHandle(int)");
const GetConsoleMode = k32.func("bool __stdcall GetConsoleMode(void*, _Out_ uint32_t*)");
const SetConsoleMode = k32.func("bool __stdcall SetConsoleMode(void*, uint32_t)");
const STD_INPUT_HANDLE = -10;
const ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200;
const handle = GetStdHandle(STD_INPUT_HANDLE);
const mode = new Uint32Array(1);
GetConsoleMode(handle, mode);
SetConsoleMode(handle, mode[0]! | ENABLE_VIRTUAL_TERMINAL_INPUT);
} catch {
// koffi not available — Shift+Tab won't be distinguishable from Tab
}
}
async drainInput(maxMs = 1000, idleMs = 50): Promise<void> {
if (this._kittyProtocolActive) {
// Disable Kitty keyboard protocol first so any late key releases
// do not generate new Kitty escape sequences.
process.stdout.write("\x1b[<u");
this._kittyProtocolActive = false;
setKittyProtocolActive(false);
}
if (this._modifyOtherKeysActive) {
process.stdout.write("\x1b[>4;0m");
this._modifyOtherKeysActive = false;
}
const previousHandler = this.inputHandler;
this.inputHandler = undefined;
let lastDataTime = Date.now();
const onData = () => {
lastDataTime = Date.now();
};
process.stdin.on("data", onData);
const endTime = Date.now() + maxMs;
try {
while (true) {
const now = Date.now();
const timeLeft = endTime - now;
if (timeLeft <= 0) break;
if (now - lastDataTime >= idleMs) break;
await new Promise((resolve) => setTimeout(resolve, Math.min(idleMs, timeLeft)));
}
} finally {
process.stdin.removeListener("data", onData);
this.inputHandler = previousHandler;
}
}
stop(): void {
// Disable bracketed paste mode
process.stdout.write("\x1b[?2004l");
// Disable Kitty keyboard protocol if not already done by drainInput()
if (this._kittyProtocolActive) {
process.stdout.write("\x1b[<u");
this._kittyProtocolActive = false;
setKittyProtocolActive(false);
}
if (this._modifyOtherKeysActive) {
process.stdout.write("\x1b[>4;0m");
this._modifyOtherKeysActive = false;
}
// Clean up StdinBuffer
if (this.stdinBuffer) {
this.stdinBuffer.destroy();
this.stdinBuffer = undefined;
}
// Remove event handlers
if (this.stdinDataHandler) {
process.stdin.removeListener("data", this.stdinDataHandler);
this.stdinDataHandler = undefined;
}
this.inputHandler = undefined;
if (this.resizeHandler) {
process.stdout.removeListener("resize", this.resizeHandler);
this.resizeHandler = undefined;
}
// Pause stdin to prevent any buffered input (e.g., Ctrl+D) from being
// re-interpreted after raw mode is disabled. This fixes a race condition
// where Ctrl+D could close the parent shell over SSH.
process.stdin.pause();
// Restore raw mode state
if (process.stdin.setRawMode) {
process.stdin.setRawMode(this.wasRaw);
}
}
write(data: string): void {
process.stdout.write(data);
if (this.writeLogPath) {
try {
fs.appendFileSync(this.writeLogPath, data, { encoding: "utf8" });
} catch {
// Ignore logging errors
}
}
}
get columns(): number {
return process.stdout.columns || 80;
}
get rows(): number {
return process.stdout.rows || 24;
}
moveBy(lines: number): void {
if (lines > 0) {
// Move down
process.stdout.write(`\x1b[${lines}B`);
} else if (lines < 0) {
// Move up
process.stdout.write(`\x1b[${-lines}A`);
}
// lines === 0: no movement
}
hideCursor(): void {
process.stdout.write("\x1b[?25l");
}
showCursor(): void {
process.stdout.write("\x1b[?25h");
}
clearLine(): void {
process.stdout.write("\x1b[K");
}
clearFromCursor(): void {
process.stdout.write("\x1b[J");
}
clearScreen(): void {
process.stdout.write("\x1b[2J\x1b[H"); // Clear screen and move to home (1,1)
}
setTitle(title: string): void {
// OSC 0;title BEL - set terminal window title
process.stdout.write(`\x1b]0;${title}\x07`);
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,28 @@
/**
* Generic undo stack with clone-on-push semantics.
*
* Stores deep clones of state snapshots. Popped snapshots are returned
* directly (no re-cloning) since they are already detached.
*/
export class UndoStack<S> {
private stack: S[] = [];
/** Push a deep clone of the given state onto the stack. */
push(state: S): void {
this.stack.push(structuredClone(state));
}
/** Pop and return the most recent snapshot, or undefined if empty. */
pop(): S | undefined {
return this.stack.pop();
}
/** Remove all snapshots. */
clear(): void {
this.stack.length = 0;
}
get length(): number {
return this.stack.length;
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,454 @@
import assert from "node:assert";
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, beforeEach, describe, it, test } from "node:test";
import { CombinedAutocompleteProvider } from "../src/autocomplete.js";
const resolveFdPath = (): string | null => {
const command = process.platform === "win32" ? "where" : "which";
const result = spawnSync(command, ["fd"], { encoding: "utf-8" });
if (result.status !== 0 || !result.stdout) {
return null;
}
const firstLine = result.stdout.split(/\r?\n/).find(Boolean);
return firstLine ? firstLine.trim() : null;
};
type FolderStructure = {
dirs?: string[];
files?: Record<string, string>;
};
const setupFolder = (baseDir: string, structure: FolderStructure = {}): void => {
const dirs = structure.dirs ?? [];
const files = structure.files ?? {};
dirs.forEach((dir) => {
mkdirSync(join(baseDir, dir), { recursive: true });
});
Object.entries(files).forEach(([filePath, contents]) => {
const fullPath = join(baseDir, filePath);
mkdirSync(dirname(fullPath), { recursive: true });
writeFileSync(fullPath, contents);
});
};
const fdPath = resolveFdPath();
const isFdInstalled = Boolean(fdPath);
const requireFdPath = (): string => {
if (!fdPath) {
throw new Error("fd is not available");
}
return fdPath;
};
const getSuggestions = (
provider: CombinedAutocompleteProvider,
lines: string[],
cursorLine: number,
cursorCol: number,
force: boolean = false,
) => provider.getSuggestions(lines, cursorLine, cursorCol, { signal: new AbortController().signal, force });
describe("CombinedAutocompleteProvider", () => {
describe("extractPathPrefix", () => {
it("extracts / from 'hey /' when forced", async () => {
const provider = new CombinedAutocompleteProvider([], "/tmp");
const lines = ["hey /"];
const cursorLine = 0;
const cursorCol = 5; // After the "/"
const result = await getSuggestions(provider, lines, cursorLine, cursorCol, true);
assert.notEqual(result, null, "Should return suggestions for root directory");
if (result) {
assert.strictEqual(result.prefix, "/", "Prefix should be '/'");
}
});
it("extracts /A from '/A' when forced", async () => {
const provider = new CombinedAutocompleteProvider([], "/tmp");
const lines = ["/A"];
const cursorLine = 0;
const cursorCol = 2; // After the "A"
const result = await getSuggestions(provider, lines, cursorLine, cursorCol, true);
console.log("Result:", result);
// This might return null if /A doesn't match anything, which is fine
// We're mainly testing that the prefix extraction works
if (result) {
assert.strictEqual(result.prefix, "/A", "Prefix should be '/A'");
}
});
it("does not trigger for slash commands", async () => {
const provider = new CombinedAutocompleteProvider([], "/tmp");
const lines = ["/model"];
const cursorLine = 0;
const cursorCol = 6; // After "model"
const result = await getSuggestions(provider, lines, cursorLine, cursorCol, true);
console.log("Result:", result);
assert.strictEqual(result, null, "Should not trigger for slash commands");
});
it("triggers for absolute paths after slash command argument", async () => {
const provider = new CombinedAutocompleteProvider([], "/tmp");
const lines = ["/command /"];
const cursorLine = 0;
const cursorCol = 10; // After the second "/"
const result = await getSuggestions(provider, lines, cursorLine, cursorCol, true);
console.log("Result:", result);
assert.notEqual(result, null, "Should trigger for absolute paths in command arguments");
if (result) {
assert.strictEqual(result.prefix, "/", "Prefix should be '/'");
}
});
});
describe("fd @ file suggestions", { skip: !isFdInstalled }, () => {
let rootDir = "";
let baseDir = "";
let outsideDir = "";
beforeEach(() => {
rootDir = mkdtempSync(join(tmpdir(), "pi-autocomplete-root-"));
baseDir = join(rootDir, "cwd");
outsideDir = join(rootDir, "outside");
mkdirSync(baseDir, { recursive: true });
mkdirSync(outsideDir, { recursive: true });
});
afterEach(() => {
rmSync(rootDir, { recursive: true, force: true });
});
test("returns all files and folders for empty @ query", async () => {
setupFolder(baseDir, {
dirs: ["src"],
files: {
"README.md": "readme",
},
});
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
const line = "@";
const result = await getSuggestions(provider, [line], 0, line.length);
const values = result?.items.map((item) => item.value).sort();
assert.deepStrictEqual(values, ["@README.md", "@src/"].sort());
});
test("matches file with extension in query", async () => {
setupFolder(baseDir, {
files: {
"file.txt": "content",
},
});
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
const line = "@file.txt";
const result = await getSuggestions(provider, [line], 0, line.length);
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes("@file.txt"));
});
test("filters are case insensitive", async () => {
setupFolder(baseDir, {
dirs: ["src"],
files: {
"README.md": "readme",
},
});
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
const line = "@re";
const result = await getSuggestions(provider, [line], 0, line.length);
const values = result?.items.map((item) => item.value).sort();
assert.deepStrictEqual(values, ["@README.md"]);
});
test("ranks directories before files", async () => {
setupFolder(baseDir, {
dirs: ["src"],
files: {
"src.txt": "text",
},
});
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
const line = "@src";
const result = await getSuggestions(provider, [line], 0, line.length);
const firstValue = result?.items[0]?.value;
const hasSrcFile = result?.items?.some((item) => item.value === "@src.txt");
assert.strictEqual(firstValue, "@src/");
assert.ok(hasSrcFile);
});
test("returns nested file paths", async () => {
setupFolder(baseDir, {
files: {
"src/index.ts": "export {};\n",
},
});
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
const line = "@index";
const result = await getSuggestions(provider, [line], 0, line.length);
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes("@src/index.ts"));
});
test("matches deeply nested paths", async () => {
setupFolder(baseDir, {
files: {
"packages/tui/src/autocomplete.ts": "export {};",
"packages/ai/src/autocomplete.ts": "export {};",
},
});
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
const line = "@tui/src/auto";
const result = await getSuggestions(provider, [line], 0, line.length);
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes("@packages/tui/src/autocomplete.ts"));
assert.ok(!values?.includes("@packages/ai/src/autocomplete.ts"));
});
test("matches directory in middle of path with --full-path", async () => {
setupFolder(baseDir, {
files: {
"src/components/Button.tsx": "export {};",
"src/utils/helpers.ts": "export {};",
},
});
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
const line = "@components/";
const result = await getSuggestions(provider, [line], 0, line.length);
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes("@src/components/Button.tsx"));
assert.ok(!values?.includes("@src/utils/helpers.ts"));
});
test("scopes fuzzy search to relative directories and searches recursively", async () => {
setupFolder(outsideDir, {
files: {
"nested/alpha.ts": "export {};",
"nested/deeper/also-alpha.ts": "export {};",
"nested/deeper/zzz.ts": "export {};",
},
});
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
const line = "@../outside/a";
const result = await getSuggestions(provider, [line], 0, line.length);
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes("@../outside/nested/alpha.ts"));
assert.ok(values?.includes("@../outside/nested/deeper/also-alpha.ts"));
assert.ok(!values?.includes("@../outside/nested/deeper/zzz.ts"));
});
test("quotes paths with spaces for @ suggestions", async () => {
setupFolder(baseDir, {
dirs: ["my folder"],
files: {
"my folder/test.txt": "content",
},
});
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
const line = "@my";
const result = await getSuggestions(provider, [line], 0, line.length);
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes('@"my folder/"'));
});
test("includes hidden paths but excludes .git", async () => {
setupFolder(baseDir, {
dirs: [".pi", ".github", ".git"],
files: {
".pi/config.json": "{}",
".github/workflows/ci.yml": "name: ci",
".git/config": "[core]",
},
});
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
const line = "@";
const result = await getSuggestions(provider, [line], 0, line.length);
const values = result?.items.map((item) => item.value) ?? [];
assert.ok(values.includes("@.pi/"));
assert.ok(values.includes("@.github/"));
assert.ok(!values.some((value) => value === "@.git" || value.startsWith("@.git/")));
});
test("continues autocomplete inside quoted @ paths", async () => {
setupFolder(baseDir, {
files: {
"my folder/test.txt": "content",
"my folder/other.txt": "content",
},
});
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
const line = '@"my folder/"';
const result = await getSuggestions(provider, [line], 0, line.length - 1);
assert.notEqual(result, null, "Should return suggestions for quoted folder path");
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes('@"my folder/test.txt"'));
assert.ok(values?.includes('@"my folder/other.txt"'));
});
test("applies quoted @ completion without duplicating closing quote", async () => {
setupFolder(baseDir, {
files: {
"my folder/test.txt": "content",
},
});
const provider = new CombinedAutocompleteProvider([], baseDir, requireFdPath());
const line = '@"my folder/te"';
const cursorCol = line.length - 1;
const result = await getSuggestions(provider, [line], 0, cursorCol);
assert.notEqual(result, null, "Should return suggestions for quoted @ path");
const item = result?.items.find((entry) => entry.value === '@"my folder/test.txt"');
assert.ok(item, "Should find test.txt suggestion");
const applied = provider.applyCompletion([line], 0, cursorCol, item!, result!.prefix);
assert.strictEqual(applied.lines[0], '@"my folder/test.txt" ');
});
});
describe("dot-slash path completion", () => {
let baseDir = "";
beforeEach(() => {
baseDir = mkdtempSync(join(tmpdir(), "pi-autocomplete-"));
});
afterEach(() => {
rmSync(baseDir, { recursive: true, force: true });
});
test("preserves ./ prefix when completing paths", async () => {
setupFolder(baseDir, {
files: {
"update.sh": "#!/bin/bash",
"utils.ts": "export {};",
},
});
const provider = new CombinedAutocompleteProvider([], baseDir);
const line = "./up";
const result = await getSuggestions(provider, [line], 0, line.length, true);
assert.notEqual(result, null, "Should return suggestions for ./ path");
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes("./update.sh"), `Expected ./update.sh in ${JSON.stringify(values)}`);
});
test("preserves ./ prefix for directory completions", async () => {
setupFolder(baseDir, {
dirs: ["src"],
files: {
"src/index.ts": "export {};",
},
});
const provider = new CombinedAutocompleteProvider([], baseDir);
const line = "./sr";
const result = await getSuggestions(provider, [line], 0, line.length, true);
assert.notEqual(result, null, "Should return suggestions for ./ directory path");
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes("./src/"), `Expected ./src/ in ${JSON.stringify(values)}`);
});
});
describe("quoted path completion", () => {
let baseDir = "";
beforeEach(() => {
baseDir = mkdtempSync(join(tmpdir(), "pi-autocomplete-"));
});
afterEach(() => {
rmSync(baseDir, { recursive: true, force: true });
});
test("quotes paths with spaces for direct completion", async () => {
setupFolder(baseDir, {
dirs: ["my folder"],
files: {
"my folder/test.txt": "content",
},
});
const provider = new CombinedAutocompleteProvider([], baseDir);
const line = "my";
const result = await getSuggestions(provider, [line], 0, line.length, true);
assert.notEqual(result, null, "Should return suggestions for path completion");
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes('"my folder/"'));
});
test("continues completion inside quoted paths", async () => {
setupFolder(baseDir, {
files: {
"my folder/test.txt": "content",
"my folder/other.txt": "content",
},
});
const provider = new CombinedAutocompleteProvider([], baseDir);
const line = '"my folder/"';
const result = await getSuggestions(provider, [line], 0, line.length - 1, true);
assert.notEqual(result, null, "Should return suggestions for quoted folder path");
const values = result?.items.map((item) => item.value);
assert.ok(values?.includes('"my folder/test.txt"'));
assert.ok(values?.includes('"my folder/other.txt"'));
});
test("applies quoted completion without duplicating closing quote", async () => {
setupFolder(baseDir, {
files: {
"my folder/test.txt": "content",
},
});
const provider = new CombinedAutocompleteProvider([], baseDir);
const line = '"my folder/te"';
const cursorCol = line.length - 1;
const result = await getSuggestions(provider, [line], 0, cursorCol, true);
assert.notEqual(result, null, "Should return suggestions for quoted path");
const item = result?.items.find((entry) => entry.value === '"my folder/test.txt"');
assert.ok(item, "Should find test.txt suggestion");
const applied = provider.applyCompletion([line], 0, cursorCol, item!, result!.prefix);
assert.strictEqual(applied.lines[0], '"my folder/test.txt"');
});
});
});
@@ -0,0 +1,237 @@
/**
* Bug regression test for isImageLine() crash scenario
*
* Bug: When isImageLine() used startsWith() and terminal doesn't support images,
* it would return false for lines containing image escape sequences, causing TUI to
* crash with "Rendered line exceeds terminal width" error.
*
* Fix: Changed to use includes() to detect escape sequences anywhere in the line.
*
* This test demonstrates:
* 1. The bug scenario with the old implementation
* 2. That the fix works correctly
*/
import assert from "node:assert";
import { describe, it } from "node:test";
describe("Bug regression: isImageLine() crash with image escape sequences", () => {
describe("Bug scenario: Terminal without image support", () => {
it("old implementation would return false, causing crash", () => {
/**
* OLD IMPLEMENTATION (buggy):
* ```typescript
* export function isImageLine(line: string): boolean {
* const prefix = getImageEscapePrefix();
* return prefix !== null && line.startsWith(prefix);
* }
* ```
*
* When terminal doesn't support images:
* - getImageEscapePrefix() returns null
* - isImageLine() returns false even for lines containing image sequences
* - TUI performs width check on line containing 300KB+ of base64 data
* - Crash: "Rendered line exceeds terminal width (304401 > 115)"
*/
// Simulate old implementation behavior
const oldIsImageLine = (line: string, imageEscapePrefix: string | null): boolean => {
return imageEscapePrefix !== null && line.startsWith(imageEscapePrefix);
};
// When terminal doesn't support images, prefix is null
const terminalWithoutImageSupport = null;
// Line containing image escape sequence with text before it (common bug scenario)
const lineWithImageSequence =
"Read image file [image/jpeg]\x1b]1337;File=size=800,600;inline=1:base64data...\x07";
// Old implementation would return false (BUG!)
const oldResult = oldIsImageLine(lineWithImageSequence, terminalWithoutImageSupport);
assert.strictEqual(
oldResult,
false,
"Bug: old implementation returns false for line containing image sequence when terminal has no image support",
);
});
it("new implementation returns true correctly", async () => {
const { isImageLine } = await import("../src/terminal-image.js");
// Line containing image escape sequence with text before it
const lineWithImageSequence =
"Read image file [image/jpeg]\x1b]1337;File=size=800,600;inline=1:base64data...\x07";
// New implementation should return true (FIX!)
const newResult = isImageLine(lineWithImageSequence);
assert.strictEqual(newResult, true, "Fix: new implementation returns true for line containing image sequence");
});
it("new implementation detects Kitty sequences in any position", async () => {
const { isImageLine } = await import("../src/terminal-image.js");
const scenarios = [
"At start: \x1b_Ga=T,f=100,data...\x1b\\",
"Prefix \x1b_Ga=T,data...\x1b\\",
"Suffix text \x1b_Ga=T,data...\x1b\\ suffix",
"Middle \x1b_Ga=T,data...\x1b\\ more text",
// Very long line (simulating 300KB+ crash scenario)
`Text before \x1b_Ga=T,f=100${"A".repeat(300000)} text after`,
];
for (const line of scenarios) {
assert.strictEqual(isImageLine(line), true, `Should detect Kitty sequence in: ${line.slice(0, 50)}...`);
}
});
it("new implementation detects iTerm2 sequences in any position", async () => {
const { isImageLine } = await import("../src/terminal-image.js");
const scenarios = [
"At start: \x1b]1337;File=size=100,100:base64...\x07",
"Prefix \x1b]1337;File=inline=1:data==\x07",
"Suffix text \x1b]1337;File=inline=1:data==\x07 suffix",
"Middle \x1b]1337;File=inline=1:data==\x07 more text",
// Very long line (simulating 304KB crash scenario)
`Text before \x1b]1337;File=size=800,600;inline=1:${"B".repeat(300000)} text after`,
];
for (const line of scenarios) {
assert.strictEqual(isImageLine(line), true, `Should detect iTerm2 sequence in: ${line.slice(0, 50)}...`);
}
});
});
describe("Integration: Tool execution scenario", () => {
/**
* This simulates what happens when the `read` tool reads an image file.
* The tool result contains both text and image content:
*
* ```typescript
* {
* content: [
* { type: "text", text: "Read image file [image/jpeg]\n800x600" },
* { type: "image", data: "base64...", mimeType: "image/jpeg" }
* ]
* }
* ```
*
* When this is rendered, the image component creates escape sequences.
* If isImageLine() doesn't detect them, TUI crashes.
*/
it("detects image sequences in read tool output", async () => {
const { isImageLine } = await import("../src/terminal-image.js");
// Simulate output when read tool processes an image
// The line might have text from the read result plus the image escape sequence
const toolOutputLine = "Read image file [image/jpeg]\x1b]1337;File=size=800,600;inline=1:base64image...\x07";
assert.strictEqual(isImageLine(toolOutputLine), true, "Should detect image sequence in tool output line");
});
it("detects Kitty sequences from Image component", async () => {
const { isImageLine } = await import("../src/terminal-image.js");
// Kitty image component creates multi-line output with escape sequences
const kittyLine = "\x1b_Ga=T,f=100,t=f,d=base64data...\x1b\\\x1b_Gm=i=1;\x1b\\";
assert.strictEqual(isImageLine(kittyLine), true, "Should detect Kitty image component output");
});
it("handles ANSI codes before image sequences", async () => {
const { isImageLine } = await import("../src/terminal-image.js");
// Line might have styling (error, warning, etc.) before image data
const lines = [
"\x1b[31mError\x1b[0m: \x1b]1337;File=inline=1:base64==\x07",
"\x1b[33mWarning\x1b[0m: \x1b_Ga=T,data...\x1b\\",
"\x1b[1mBold\x1b[0m \x1b]1337;File=:base64==\x07\x1b[0m",
];
for (const line of lines) {
assert.strictEqual(
isImageLine(line),
true,
`Should detect image sequence after ANSI codes: ${line.slice(0, 30)}...`,
);
}
});
});
describe("Crash scenario simulation", () => {
it("does NOT crash on very long lines with image sequences", async () => {
const { isImageLine } = await import("../src/terminal-image.js");
/**
* Simulate the exact crash scenario:
* - Line is 304,401 characters (the crash log showed 58649 > 115)
* - Contains image escape sequence somewhere in the middle
* - Old implementation would return false, causing TUI to do width check
* - New implementation returns true, skipping width check (preventing crash)
*/
const base64Char = "A".repeat(100);
const iterm2Sequence = "\x1b]1337;File=size=800,600;inline=1:";
// Build a line that would cause the crash
const crashLine =
"Output: " +
iterm2Sequence +
base64Char.repeat(3040) + // ~304,000 chars
" end of output";
// Verify line is very long
assert(crashLine.length > 300000, "Test line should be > 300KB");
// New implementation should detect it (prevents crash)
const detected = isImageLine(crashLine);
assert.strictEqual(detected, true, "Should detect image sequence in very long line, preventing TUI crash");
});
it("handles lines exactly matching crash log dimensions", async () => {
const { isImageLine } = await import("../src/terminal-image.js");
/**
* Crash log showed: line 58649 chars wide, terminal width 115
* Let's create a line with similar characteristics
*/
const targetWidth = 58649;
const prefix = "Text";
const sequence = "\x1b_Ga=T,f=100";
const suffix = "End";
const padding = "A".repeat(targetWidth - prefix.length - sequence.length - suffix.length);
const line = `${prefix}${sequence}${padding}${suffix}`;
assert.strictEqual(line.length, 58649);
assert.strictEqual(isImageLine(line), true, "Should detect image sequence in 58649-char line");
});
});
describe("Negative cases: Don't false positive", () => {
it("does not detect images in regular long text", async () => {
const { isImageLine } = await import("../src/terminal-image.js");
// Very long line WITHOUT image sequences
const longText = "A".repeat(100000);
assert.strictEqual(isImageLine(longText), false, "Should not detect images in plain long text");
});
it("does not detect images in lines with file paths", async () => {
const { isImageLine } = await import("../src/terminal-image.js");
const filePaths = [
"/path/to/1337/image.jpg",
"/usr/local/bin/File_converter",
"~/Documents/1337File_backup.png",
"./_G_test_file.txt",
];
for (const path of filePaths) {
assert.strictEqual(isImageLine(path), false, `Should not falsely detect image sequence in path: ${path}`);
}
});
});
});
@@ -0,0 +1,129 @@
/**
* Simple chat interface demo using tui.ts
*/
import chalk from "chalk";
import { CombinedAutocompleteProvider } from "../src/autocomplete.js";
import { Editor } from "../src/components/editor.js";
import { Loader } from "../src/components/loader.js";
import { Markdown } from "../src/components/markdown.js";
import { Text } from "../src/components/text.js";
import { ProcessTerminal } from "../src/terminal.js";
import { TUI } from "../src/tui.js";
import { defaultEditorTheme, defaultMarkdownTheme } from "./test-themes.js";
// Create terminal
const terminal = new ProcessTerminal();
// Create TUI
const tui = new TUI(terminal);
// Create chat container with some initial messages
tui.addChild(
new Text("Welcome to Simple Chat!\n\nType your messages below. Type '/' for commands. Press Ctrl+C to exit."),
);
// Create editor with autocomplete
const editor = new Editor(tui, defaultEditorTheme);
// Set up autocomplete provider with slash commands and file completion
const autocompleteProvider = new CombinedAutocompleteProvider(
[
{ name: "delete", description: "Delete the last message" },
{ name: "clear", description: "Clear all messages" },
],
process.cwd(),
);
editor.setAutocompleteProvider(autocompleteProvider);
tui.addChild(editor);
// Focus the editor
tui.setFocus(editor);
// Track if we're waiting for bot response
let isResponding = false;
// Handle message submission
editor.onSubmit = (value: string) => {
// Prevent submission if already responding
if (isResponding) {
return;
}
const trimmed = value.trim();
// Handle slash commands
if (trimmed === "/delete") {
const children = tui.children;
// Remove component before editor (if there are any besides the initial text)
if (children.length > 3) {
// children[0] = "Welcome to Simple Chat!"
// children[1] = "Type your messages below..."
// children[2...n-1] = messages
// children[n] = editor
children.splice(children.length - 2, 1);
}
tui.requestRender();
return;
}
if (trimmed === "/clear") {
const children = tui.children;
// Remove all messages but keep the welcome text and editor
children.splice(2, children.length - 3);
tui.requestRender();
return;
}
if (trimmed) {
isResponding = true;
editor.disableSubmit = true;
const userMessage = new Markdown(value, 1, 1, defaultMarkdownTheme);
const children = tui.children;
children.splice(children.length - 1, 0, userMessage);
const loader = new Loader(
tui,
(s) => chalk.cyan(s),
(s) => chalk.dim(s),
"Thinking...",
);
children.splice(children.length - 1, 0, loader);
tui.requestRender();
setTimeout(() => {
tui.removeChild(loader);
// Simulate a response
const responses = [
"That's interesting! Tell me more.",
"I see what you mean.",
"Fascinating perspective!",
"Could you elaborate on that?",
"That makes sense to me.",
"I hadn't thought of it that way.",
"Great point!",
"Thanks for sharing that.",
];
const randomResponse = responses[Math.floor(Math.random() * responses.length)];
// Add assistant message with no background (transparent)
const botMessage = new Markdown(randomResponse, 1, 1, defaultMarkdownTheme);
children.splice(children.length - 1, 0, botMessage);
// Re-enable submit
isResponding = false;
editor.disableSubmit = false;
// Request render
tui.requestRender();
}, 1000);
}
};
// Start the TUI
tui.start();
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,98 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { fuzzyFilter, fuzzyMatch } from "../src/fuzzy.js";
describe("fuzzyMatch", () => {
it("empty query matches everything with score 0", () => {
const result = fuzzyMatch("", "anything");
assert.strictEqual(result.matches, true);
assert.strictEqual(result.score, 0);
});
it("query longer than text does not match", () => {
const result = fuzzyMatch("longquery", "short");
assert.strictEqual(result.matches, false);
});
it("exact match has good score", () => {
const result = fuzzyMatch("test", "test");
assert.strictEqual(result.matches, true);
assert.ok(result.score < 0); // Should be negative due to consecutive bonuses
});
it("characters must appear in order", () => {
const matchInOrder = fuzzyMatch("abc", "aXbXc");
assert.strictEqual(matchInOrder.matches, true);
const matchOutOfOrder = fuzzyMatch("abc", "cba");
assert.strictEqual(matchOutOfOrder.matches, false);
});
it("case insensitive matching", () => {
const result = fuzzyMatch("ABC", "abc");
assert.strictEqual(result.matches, true);
const result2 = fuzzyMatch("abc", "ABC");
assert.strictEqual(result2.matches, true);
});
it("consecutive matches score better than scattered matches", () => {
const consecutive = fuzzyMatch("foo", "foobar");
const scattered = fuzzyMatch("foo", "f_o_o_bar");
assert.strictEqual(consecutive.matches, true);
assert.strictEqual(scattered.matches, true);
assert.ok(consecutive.score < scattered.score);
});
it("word boundary matches score better", () => {
const atBoundary = fuzzyMatch("fb", "foo-bar");
const notAtBoundary = fuzzyMatch("fb", "afbx");
assert.strictEqual(atBoundary.matches, true);
assert.strictEqual(notAtBoundary.matches, true);
assert.ok(atBoundary.score < notAtBoundary.score);
});
it("matches swapped alpha numeric tokens", () => {
const result = fuzzyMatch("codex52", "gpt-5.2-codex");
assert.strictEqual(result.matches, true);
});
});
describe("fuzzyFilter", () => {
it("empty query returns all items unchanged", () => {
const items = ["apple", "banana", "cherry"];
const result = fuzzyFilter(items, "", (x: string) => x);
assert.deepStrictEqual(result, items);
});
it("filters out non-matching items", () => {
const items = ["apple", "banana", "cherry"];
const result = fuzzyFilter(items, "an", (x: string) => x);
assert.ok(result.includes("banana"));
assert.ok(!result.includes("apple"));
assert.ok(!result.includes("cherry"));
});
it("sorts results by match quality", () => {
const items = ["a_p_p", "app", "application"];
const result = fuzzyFilter(items, "app", (x: string) => x);
// "app" should be first (exact consecutive match at start)
assert.strictEqual(result[0], "app");
});
it("works with custom getText function", () => {
const items = [
{ name: "foo", id: 1 },
{ name: "bar", id: 2 },
{ name: "foobar", id: 3 },
];
const result = fuzzyFilter(items, "foo", (item: { name: string; id: number }) => item.name);
assert.strictEqual(result.length, 2);
assert.ok(result.map((r) => r.name).includes("foo"));
assert.ok(result.map((r) => r.name).includes("foobar"));
});
});
@@ -0,0 +1,56 @@
import { readFileSync } from "fs";
import { Image } from "../src/components/image.js";
import { Spacer } from "../src/components/spacer.js";
import { Text } from "../src/components/text.js";
import { ProcessTerminal } from "../src/terminal.js";
import { getCapabilities, getImageDimensions } from "../src/terminal-image.js";
import { TUI } from "../src/tui.js";
const testImagePath = process.argv[2] || "/tmp/test-image.png";
console.log("Terminal capabilities:", getCapabilities());
console.log("Loading image from:", testImagePath);
let imageBuffer: Buffer;
try {
imageBuffer = readFileSync(testImagePath);
} catch (_e) {
console.error(`Failed to load image: ${testImagePath}`);
console.error("Usage: npx tsx test/image-test.ts [path-to-image.png]");
process.exit(1);
}
const base64Data = imageBuffer.toString("base64");
const dims = getImageDimensions(base64Data, "image/png");
console.log("Image dimensions:", dims);
console.log("");
const terminal = new ProcessTerminal();
const tui = new TUI(terminal);
tui.addChild(new Text("Image Rendering Test", 1, 1));
tui.addChild(new Spacer(1));
if (dims) {
tui.addChild(
new Image(base64Data, "image/png", { fallbackColor: (s) => `\x1b[33m${s}\x1b[0m` }, { maxWidthCells: 60 }, dims),
);
} else {
tui.addChild(new Text("Could not parse image dimensions", 1, 0));
}
tui.addChild(new Spacer(1));
tui.addChild(new Text("Press Ctrl+C to exit", 1, 0));
const editor = {
handleInput(data: string) {
if (data.charCodeAt(0) === 3) {
tui.stop();
process.exit(0);
}
},
};
tui.setFocus(editor as any);
tui.start();
@@ -0,0 +1,580 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { Input } from "../src/components/input.js";
import { visibleWidth } from "../src/utils.js";
describe("Input component", () => {
it("submits value including backslash on Enter", () => {
const input = new Input();
let submitted: string | undefined;
input.onSubmit = (value) => {
submitted = value;
};
// Type hello, then backslash, then Enter
input.handleInput("h");
input.handleInput("e");
input.handleInput("l");
input.handleInput("l");
input.handleInput("o");
input.handleInput("\\");
input.handleInput("\r");
// Input is single-line, no backslash+Enter workaround
assert.strictEqual(submitted, "hello\\");
});
it("inserts backslash as regular character", () => {
const input = new Input();
input.handleInput("\\");
input.handleInput("x");
assert.strictEqual(input.getValue(), "\\x");
});
describe("render", () => {
it("does not overflow with wide CJK and fullwidth text", () => {
const width = 93;
const cases = [
"가나다라마바사아자차카타파하 한글 텍스트가 터미널 너비를 초과하면 크래시가 발생합니다 이것은 재현용 테스트입니다",
"これはテスト文章です。日本語のテキストが正しく表示されるかどうかを確認するためのサンプルテキストです。あいうえお",
"这是一段测试文本,用于验证中文字符在终端中的显示宽度是否被正确计算,如果不正确就会导致用户界面崩溃的问题",
"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklm",
];
const cursorPositions = [
{ label: "start", move: (_input: Input) => {} },
{
label: "middle",
move: (input: Input) => {
for (let i = 0; i < 10; i++) input.handleInput("\x1b[C");
},
},
{ label: "end", move: (input: Input) => input.handleInput("\x05") },
];
for (const text of cases) {
for (const { label, move } of cursorPositions) {
const input = new Input();
input.setValue(text);
input.focused = true;
move(input);
const [line] = input.render(width);
assert.ok(line);
assert.ok(visibleWidth(line) <= width, `rendered line overflowed for ${text} at ${label}`);
}
}
});
it("keeps the cursor visible when horizontally scrolling wide text", () => {
const input = new Input();
const width = 20;
const text = "가나다라마바사아자차카타파하";
input.setValue(text);
input.focused = true;
input.handleInput("\x01");
for (let i = 0; i < 5; i++) input.handleInput("\x1b[C");
const [line] = input.render(width);
assert.ok(line);
assert.ok(visibleWidth(line) <= width);
});
});
describe("Kill ring", () => {
it("Ctrl+W saves deleted text to kill ring and Ctrl+Y yanks it", () => {
const input = new Input();
input.setValue("foo bar baz");
// Move cursor to end
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "baz"
assert.strictEqual(input.getValue(), "foo bar ");
// Move to beginning and yank
input.handleInput("\x01"); // Ctrl+A
input.handleInput("\x19"); // Ctrl+Y
assert.strictEqual(input.getValue(), "bazfoo bar ");
});
it("Ctrl+U saves deleted text to kill ring", () => {
const input = new Input();
input.setValue("hello world");
// Move cursor to after "hello "
input.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 6; i++) input.handleInput("\x1b[C");
input.handleInput("\x15"); // Ctrl+U - deletes "hello "
assert.strictEqual(input.getValue(), "world");
input.handleInput("\x19"); // Ctrl+Y
assert.strictEqual(input.getValue(), "hello world");
});
it("Ctrl+K saves deleted text to kill ring", () => {
const input = new Input();
input.setValue("hello world");
input.handleInput("\x01"); // Ctrl+A
input.handleInput("\x0b"); // Ctrl+K - deletes "hello world"
assert.strictEqual(input.getValue(), "");
input.handleInput("\x19"); // Ctrl+Y
assert.strictEqual(input.getValue(), "hello world");
});
it("Ctrl+Y does nothing when kill ring is empty", () => {
const input = new Input();
input.setValue("test");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x19"); // Ctrl+Y
assert.strictEqual(input.getValue(), "test");
});
it("Alt+Y cycles through kill ring after Ctrl+Y", () => {
const input = new Input();
// Create kill ring with multiple entries
input.setValue("first");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "first"
input.setValue("second");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "second"
input.setValue("third");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "third"
assert.strictEqual(input.getValue(), "");
input.handleInput("\x19"); // Ctrl+Y - yanks "third"
assert.strictEqual(input.getValue(), "third");
input.handleInput("\x1by"); // Alt+Y - cycles to "second"
assert.strictEqual(input.getValue(), "second");
input.handleInput("\x1by"); // Alt+Y - cycles to "first"
assert.strictEqual(input.getValue(), "first");
input.handleInput("\x1by"); // Alt+Y - cycles back to "third"
assert.strictEqual(input.getValue(), "third");
});
it("Alt+Y does nothing if not preceded by yank", () => {
const input = new Input();
input.setValue("test");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "test"
input.setValue("other");
input.handleInput("\x05"); // Ctrl+E
// Type something to break the yank chain
input.handleInput("x");
assert.strictEqual(input.getValue(), "otherx");
input.handleInput("\x1by"); // Alt+Y - should do nothing
assert.strictEqual(input.getValue(), "otherx");
});
it("Alt+Y does nothing if kill ring has one entry", () => {
const input = new Input();
input.setValue("only");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "only"
input.handleInput("\x19"); // Ctrl+Y - yanks "only"
assert.strictEqual(input.getValue(), "only");
input.handleInput("\x1by"); // Alt+Y - should do nothing
assert.strictEqual(input.getValue(), "only");
});
it("consecutive Ctrl+W accumulates into one kill ring entry", () => {
const input = new Input();
input.setValue("one two three");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "three"
input.handleInput("\x17"); // Ctrl+W - deletes "two "
input.handleInput("\x17"); // Ctrl+W - deletes "one "
assert.strictEqual(input.getValue(), "");
input.handleInput("\x19"); // Ctrl+Y
assert.strictEqual(input.getValue(), "one two three");
});
it("non-delete actions break kill accumulation", () => {
const input = new Input();
input.setValue("foo bar baz");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "baz"
assert.strictEqual(input.getValue(), "foo bar ");
input.handleInput("x"); // Typing breaks accumulation
assert.strictEqual(input.getValue(), "foo bar x");
input.handleInput("\x17"); // Ctrl+W - deletes "x" (separate entry)
assert.strictEqual(input.getValue(), "foo bar ");
input.handleInput("\x19"); // Ctrl+Y - most recent is "x"
assert.strictEqual(input.getValue(), "foo bar x");
input.handleInput("\x1by"); // Alt+Y - cycle to "baz"
assert.strictEqual(input.getValue(), "foo bar baz");
});
it("non-yank actions break Alt+Y chain", () => {
const input = new Input();
input.setValue("first");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W
input.setValue("second");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W
input.setValue("");
input.handleInput("\x19"); // Ctrl+Y - yanks "second"
assert.strictEqual(input.getValue(), "second");
input.handleInput("x"); // Breaks yank chain
assert.strictEqual(input.getValue(), "secondx");
input.handleInput("\x1by"); // Alt+Y - should do nothing
assert.strictEqual(input.getValue(), "secondx");
});
it("kill ring rotation persists after cycling", () => {
const input = new Input();
input.setValue("first");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // deletes "first"
input.setValue("second");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // deletes "second"
input.setValue("third");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // deletes "third"
input.setValue("");
input.handleInput("\x19"); // Ctrl+Y - yanks "third"
input.handleInput("\x1by"); // Alt+Y - cycles to "second"
assert.strictEqual(input.getValue(), "second");
// Break chain and start fresh
input.handleInput("x");
input.setValue("");
// New yank should get "second" (now at end after rotation)
input.handleInput("\x19"); // Ctrl+Y
assert.strictEqual(input.getValue(), "second");
});
it("backward deletions prepend, forward deletions append during accumulation", () => {
const input = new Input();
input.setValue("prefix|suffix");
// Position cursor at "|"
input.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 6; i++) input.handleInput("\x1b[C"); // Move right 6
input.handleInput("\x0b"); // Ctrl+K - deletes "|suffix" (forward)
assert.strictEqual(input.getValue(), "prefix");
input.handleInput("\x19"); // Ctrl+Y
assert.strictEqual(input.getValue(), "prefix|suffix");
});
it("Alt+D deletes word forward and saves to kill ring", () => {
const input = new Input();
input.setValue("hello world test");
input.handleInput("\x01"); // Ctrl+A
input.handleInput("\x1bd"); // Alt+D - deletes "hello"
assert.strictEqual(input.getValue(), " world test");
input.handleInput("\x1bd"); // Alt+D - deletes " world"
assert.strictEqual(input.getValue(), " test");
// Yank should get accumulated text
input.handleInput("\x19"); // Ctrl+Y
assert.strictEqual(input.getValue(), "hello world test");
});
it("handles yank in middle of text", () => {
const input = new Input();
input.setValue("word");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "word"
input.setValue("hello world");
// Move to middle (after "hello ")
input.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 6; i++) input.handleInput("\x1b[C");
input.handleInput("\x19"); // Ctrl+Y
assert.strictEqual(input.getValue(), "hello wordworld");
});
it("handles yank-pop in middle of text", () => {
const input = new Input();
// Create two kill ring entries
input.setValue("FIRST");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "FIRST"
input.setValue("SECOND");
input.handleInput("\x05"); // Ctrl+E
input.handleInput("\x17"); // Ctrl+W - deletes "SECOND"
// Set up "hello world" and position cursor after "hello "
input.setValue("hello world");
input.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 6; i++) input.handleInput("\x1b[C");
input.handleInput("\x19"); // Ctrl+Y - yanks "SECOND"
assert.strictEqual(input.getValue(), "hello SECONDworld");
input.handleInput("\x1by"); // Alt+Y - replaces with "FIRST"
assert.strictEqual(input.getValue(), "hello FIRSTworld");
});
});
describe("Undo", () => {
it("does nothing when undo stack is empty", () => {
const input = new Input();
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "");
});
it("coalesces consecutive word characters into one undo unit", () => {
const input = new Input();
input.handleInput("h");
input.handleInput("e");
input.handleInput("l");
input.handleInput("l");
input.handleInput("o");
input.handleInput(" ");
input.handleInput("w");
input.handleInput("o");
input.handleInput("r");
input.handleInput("l");
input.handleInput("d");
assert.strictEqual(input.getValue(), "hello world");
// Undo removes " world"
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "hello");
// Undo removes "hello"
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "");
});
it("undoes spaces one at a time", () => {
const input = new Input();
input.handleInput("h");
input.handleInput("e");
input.handleInput("l");
input.handleInput("l");
input.handleInput("o");
input.handleInput(" ");
input.handleInput(" ");
assert.strictEqual(input.getValue(), "hello ");
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) - removes second " "
assert.strictEqual(input.getValue(), "hello ");
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) - removes first " "
assert.strictEqual(input.getValue(), "hello");
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo) - removes "hello"
assert.strictEqual(input.getValue(), "");
});
it("undoes backspace", () => {
const input = new Input();
input.handleInput("h");
input.handleInput("e");
input.handleInput("l");
input.handleInput("l");
input.handleInput("o");
input.handleInput("\x7f"); // Backspace
assert.strictEqual(input.getValue(), "hell");
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "hello");
});
it("undoes forward delete", () => {
const input = new Input();
input.handleInput("h");
input.handleInput("e");
input.handleInput("l");
input.handleInput("l");
input.handleInput("o");
input.handleInput("\x01"); // Ctrl+A - go to start
input.handleInput("\x1b[C"); // Right arrow
input.handleInput("\x1b[3~"); // Delete key
assert.strictEqual(input.getValue(), "hllo");
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "hello");
});
it("undoes Ctrl+W (delete word backward)", () => {
const input = new Input();
input.handleInput("h");
input.handleInput("e");
input.handleInput("l");
input.handleInput("l");
input.handleInput("o");
input.handleInput(" ");
input.handleInput("w");
input.handleInput("o");
input.handleInput("r");
input.handleInput("l");
input.handleInput("d");
assert.strictEqual(input.getValue(), "hello world");
input.handleInput("\x17"); // Ctrl+W
assert.strictEqual(input.getValue(), "hello ");
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "hello world");
});
it("undoes Ctrl+K (delete to line end)", () => {
const input = new Input();
input.handleInput("h");
input.handleInput("e");
input.handleInput("l");
input.handleInput("l");
input.handleInput("o");
input.handleInput(" ");
input.handleInput("w");
input.handleInput("o");
input.handleInput("r");
input.handleInput("l");
input.handleInput("d");
input.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 6; i++) input.handleInput("\x1b[C");
input.handleInput("\x0b"); // Ctrl+K
assert.strictEqual(input.getValue(), "hello ");
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "hello world");
});
it("undoes Ctrl+U (delete to line start)", () => {
const input = new Input();
input.handleInput("h");
input.handleInput("e");
input.handleInput("l");
input.handleInput("l");
input.handleInput("o");
input.handleInput(" ");
input.handleInput("w");
input.handleInput("o");
input.handleInput("r");
input.handleInput("l");
input.handleInput("d");
input.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 6; i++) input.handleInput("\x1b[C");
input.handleInput("\x15"); // Ctrl+U
assert.strictEqual(input.getValue(), "world");
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "hello world");
});
it("undoes yank", () => {
const input = new Input();
input.handleInput("h");
input.handleInput("e");
input.handleInput("l");
input.handleInput("l");
input.handleInput("o");
input.handleInput(" ");
input.handleInput("\x17"); // Ctrl+W - delete "hello "
input.handleInput("\x19"); // Ctrl+Y - yank
assert.strictEqual(input.getValue(), "hello ");
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "");
});
it("undoes paste atomically", () => {
const input = new Input();
input.setValue("hello world");
input.handleInput("\x01"); // Ctrl+A
for (let i = 0; i < 5; i++) input.handleInput("\x1b[C");
// Simulate bracketed paste
input.handleInput("\x1b[200~beep boop\x1b[201~");
assert.strictEqual(input.getValue(), "hellobeep boop world");
// Single undo should restore entire pre-paste state
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "hello world");
});
it("undoes Alt+D (delete word forward)", () => {
const input = new Input();
input.setValue("hello world");
input.handleInput("\x01"); // Ctrl+A
input.handleInput("\x1bd"); // Alt+D - deletes "hello"
assert.strictEqual(input.getValue(), " world");
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "hello world");
});
it("cursor movement starts new undo unit", () => {
const input = new Input();
input.handleInput("a");
input.handleInput("b");
input.handleInput("c");
input.handleInput("\x01"); // Ctrl+A - movement breaks coalescing
input.handleInput("\x05"); // Ctrl+E
input.handleInput("d");
input.handleInput("e");
assert.strictEqual(input.getValue(), "abcde");
// Undo removes "de" (typed after movement)
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "abc");
// Undo removes "abc"
input.handleInput("\x1b[45;5u"); // Ctrl+- (undo)
assert.strictEqual(input.getValue(), "");
});
});
});
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env node
import { matchesKey } from "../src/keys.js";
import { ProcessTerminal } from "../src/terminal.js";
import { type Component, TUI } from "../src/tui.js";
/**
* Simple key code logger component
*/
class KeyLogger implements Component {
private log: string[] = [];
private maxLines = 20;
private tui: TUI;
constructor(tui: TUI) {
this.tui = tui;
}
handleInput(data: string): void {
// Handle Ctrl+C (raw or Kitty protocol) for exit
if (matchesKey(data, "ctrl+c")) {
this.tui.stop();
console.log("\nExiting...");
process.exit(0);
}
// Convert to various representations
const hex = Buffer.from(data).toString("hex");
const charCodes = Array.from(data)
.map((c) => c.charCodeAt(0))
.join(", ");
const repr = data
.replace(/\x1b/g, "\\x1b")
.replace(/\r/g, "\\r")
.replace(/\n/g, "\\n")
.replace(/\t/g, "\\t")
.replace(/\x7f/g, "\\x7f");
const logLine = `Hex: ${hex.padEnd(20)} | Chars: [${charCodes.padEnd(15)}] | Repr: "${repr}"`;
this.log.push(logLine);
// Keep only last N lines
if (this.log.length > this.maxLines) {
this.log.shift();
}
// Request re-render to show the new log entry
this.tui.requestRender();
}
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] {
const lines: string[] = [];
// Title
lines.push("=".repeat(width));
lines.push("Key Code Tester - Press keys to see their codes (Ctrl+C to exit)".padEnd(width));
lines.push("=".repeat(width));
lines.push("");
// Log entries
for (const entry of this.log) {
lines.push(entry.padEnd(width));
}
// Fill remaining space
const remaining = Math.max(0, 25 - lines.length);
for (let i = 0; i < remaining; i++) {
lines.push("".padEnd(width));
}
// Footer
lines.push("=".repeat(width));
lines.push("Test these:".padEnd(width));
lines.push(" - Shift + Enter (should show: \\x1b[13;2u with Kitty protocol)".padEnd(width));
lines.push(" - Alt/Option + Enter".padEnd(width));
lines.push(" - Option/Alt + Backspace".padEnd(width));
lines.push(" - Cmd/Ctrl + Backspace".padEnd(width));
lines.push(" - Regular Backspace".padEnd(width));
lines.push("=".repeat(width));
return lines;
}
}
// Set up TUI
const terminal = new ProcessTerminal();
const tui = new TUI(terminal);
const logger = new KeyLogger(tui);
tui.addChild(logger);
tui.setFocus(logger);
// Handle Ctrl+C for clean exit (SIGINT still works for raw mode)
process.on("SIGINT", () => {
tui.stop();
console.log("\nExiting...");
process.exit(0);
});
// Start the TUI
tui.start();
@@ -0,0 +1,38 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { KeybindingsManager, TUI_KEYBINDINGS } from "../src/keybindings.js";
describe("KeybindingsManager", () => {
it("does not evict selector confirm when input submit is rebound", () => {
const keybindings = new KeybindingsManager(TUI_KEYBINDINGS, {
"tui.input.submit": ["enter", "ctrl+enter"],
});
assert.deepStrictEqual(keybindings.getKeys("tui.input.submit"), ["enter", "ctrl+enter"]);
assert.deepStrictEqual(keybindings.getKeys("tui.select.confirm"), ["enter"]);
});
it("does not evict cursor bindings when another action reuses the same key", () => {
const keybindings = new KeybindingsManager(TUI_KEYBINDINGS, {
"tui.select.up": ["up", "ctrl+p"],
});
assert.deepStrictEqual(keybindings.getKeys("tui.select.up"), ["up", "ctrl+p"]);
assert.deepStrictEqual(keybindings.getKeys("tui.editor.cursorUp"), ["up"]);
});
it("still reports direct user binding conflicts without evicting defaults", () => {
const keybindings = new KeybindingsManager(TUI_KEYBINDINGS, {
"tui.input.submit": "ctrl+x",
"tui.select.confirm": "ctrl+x",
});
assert.deepStrictEqual(keybindings.getConflicts(), [
{
key: "ctrl+x",
keybindings: ["tui.input.submit", "tui.select.confirm"],
},
]);
assert.deepStrictEqual(keybindings.getKeys("tui.editor.cursorLeft"), ["left", "ctrl+b"]);
});
});
@@ -0,0 +1,582 @@
/**
* Tests for keyboard input handling
*/
import assert from "node:assert";
import { describe, it } from "node:test";
import { decodeKittyPrintable, Key, matchesKey, parseKey, setKittyProtocolActive } from "../src/keys.js";
function withEnv(name: string, value: string | undefined, fn: () => void): void {
const previous = process.env[name];
if (value === undefined) delete process.env[name];
else process.env[name] = value;
try {
fn();
} finally {
if (previous === undefined) delete process.env[name];
else process.env[name] = previous;
}
}
function withEnvVars(vars: Record<string, string | undefined>, fn: () => void): void {
const entries = Object.entries(vars);
const run = (index: number): void => {
if (index >= entries.length) {
fn();
return;
}
const [name, value] = entries[index]!;
withEnv(name, value, () => run(index + 1));
};
run(0);
}
describe("matchesKey", () => {
describe("Kitty protocol with alternate keys (non-Latin layouts)", () => {
// Kitty protocol flag 4 (Report alternate keys) sends:
// CSI codepoint:shifted:base ; modifier:event u
// Where base is the key in standard PC-101 layout
it("should match Ctrl+c when pressing Ctrl+С (Cyrillic) with base layout key", () => {
setKittyProtocolActive(true);
// Cyrillic 'с' = codepoint 1089, Latin 'c' = codepoint 99
// Format: CSI 1089::99;5u (codepoint::base;modifier with ctrl=4, +1=5)
const cyrillicCtrlC = "\x1b[1089::99;5u";
assert.strictEqual(matchesKey(cyrillicCtrlC, "ctrl+c"), true);
setKittyProtocolActive(false);
});
it("should match Ctrl+d when pressing Ctrl+В (Cyrillic) with base layout key", () => {
setKittyProtocolActive(true);
// Cyrillic 'в' = codepoint 1074, Latin 'd' = codepoint 100
const cyrillicCtrlD = "\x1b[1074::100;5u";
assert.strictEqual(matchesKey(cyrillicCtrlD, "ctrl+d"), true);
setKittyProtocolActive(false);
});
it("should match Ctrl+z when pressing Ctrl+Я (Cyrillic) with base layout key", () => {
setKittyProtocolActive(true);
// Cyrillic 'я' = codepoint 1103, Latin 'z' = codepoint 122
const cyrillicCtrlZ = "\x1b[1103::122;5u";
assert.strictEqual(matchesKey(cyrillicCtrlZ, "ctrl+z"), true);
setKittyProtocolActive(false);
});
it("should match Ctrl+Shift+p with base layout key", () => {
setKittyProtocolActive(true);
// Cyrillic 'з' = codepoint 1079, Latin 'p' = codepoint 112
// ctrl=4, shift=1, +1 = 6
const cyrillicCtrlShiftP = "\x1b[1079::112;6u";
assert.strictEqual(matchesKey(cyrillicCtrlShiftP, "ctrl+shift+p"), true);
setKittyProtocolActive(false);
});
it("should still match direct codepoint when no base layout key", () => {
setKittyProtocolActive(true);
// Latin ctrl+c without base layout key (terminal doesn't support flag 4)
const latinCtrlC = "\x1b[99;5u";
assert.strictEqual(matchesKey(latinCtrlC, "ctrl+c"), true);
setKittyProtocolActive(false);
});
it("should match super-modified Kitty bindings, including combined modifiers", () => {
setKittyProtocolActive(true);
assert.strictEqual(matchesKey("\x1b[107;9u", "super+k"), true);
assert.strictEqual(matchesKey("\x1b[13;9u", "super+enter"), true);
assert.strictEqual(matchesKey("\x1b[107;13u", Key.ctrlSuper("k")), true);
assert.strictEqual(matchesKey("\x1b[107;13u", "ctrl+super+k"), true);
assert.strictEqual(matchesKey("\x1b[107;14u", "ctrl+shift+super+k"), true);
assert.strictEqual(matchesKey("\x1b[107;13u", "super+k"), false);
assert.strictEqual(parseKey("\x1b[107;9u"), "super+k");
assert.strictEqual(parseKey("\x1b[13;9u"), "super+enter");
assert.strictEqual(parseKey("\x1b[107;13u"), "ctrl+super+k");
assert.strictEqual(parseKey("\x1b[107;14u"), "shift+ctrl+super+k");
setKittyProtocolActive(false);
});
it("should match digit bindings via Kitty CSI-u", () => {
setKittyProtocolActive(true);
assert.strictEqual(matchesKey("\x1b[49u", "1"), true);
assert.strictEqual(matchesKey("\x1b[49;5u", "ctrl+1"), true);
assert.strictEqual(matchesKey("\x1b[49;5u", "ctrl+2"), false);
assert.strictEqual(parseKey("\x1b[49u"), "1");
assert.strictEqual(parseKey("\x1b[49;5u"), "ctrl+1");
setKittyProtocolActive(false);
});
it("should normalize Kitty keypad functional keys to logical digits, symbols, and navigation", () => {
setKittyProtocolActive(true);
assert.strictEqual(matchesKey("\x1b[57400u", "1"), true);
assert.strictEqual(matchesKey("\x1b[57410u", "/"), true);
assert.strictEqual(matchesKey("\x1b[57417u", "left"), true);
assert.strictEqual(matchesKey("\x1b[57426u", "delete"), true);
assert.strictEqual(parseKey("\x1b[57399u"), "0");
assert.strictEqual(parseKey("\x1b[57409u"), ".");
assert.strictEqual(parseKey("\x1b[57413u"), "+");
assert.strictEqual(parseKey("\x1b[57416u"), ",");
assert.strictEqual(parseKey("\x1b[57417u"), "left");
assert.strictEqual(parseKey("\x1b[57418u"), "right");
assert.strictEqual(parseKey("\x1b[57419u"), "up");
assert.strictEqual(parseKey("\x1b[57420u"), "down");
assert.strictEqual(parseKey("\x1b[57421u"), "pageUp");
assert.strictEqual(parseKey("\x1b[57422u"), "pageDown");
assert.strictEqual(parseKey("\x1b[57423u"), "home");
assert.strictEqual(parseKey("\x1b[57424u"), "end");
assert.strictEqual(parseKey("\x1b[57425u"), "insert");
assert.strictEqual(parseKey("\x1b[57426u"), "delete");
setKittyProtocolActive(false);
});
it("should handle shifted key in format", () => {
setKittyProtocolActive(true);
// Format with shifted key: CSI codepoint:shifted:base;modifier u
// Latin 'c' with shifted 'C' (67) and base 'c' (99)
const shiftedKey = "\x1b[99:67:99;2u"; // shift modifier = 1, +1 = 2
assert.strictEqual(matchesKey(shiftedKey, "shift+c"), true);
setKittyProtocolActive(false);
});
it("should handle event type in format", () => {
setKittyProtocolActive(true);
// Format with event type: CSI codepoint::base;modifier:event u
// Cyrillic ctrl+c release event (event type 3)
const releaseEvent = "\x1b[1089::99;5:3u";
assert.strictEqual(matchesKey(releaseEvent, "ctrl+c"), true);
setKittyProtocolActive(false);
});
it("should handle full format with shifted key, base key, and event type", () => {
setKittyProtocolActive(true);
// Full format: CSI codepoint:shifted:base;modifier:event u
// Cyrillic 'С' (shifted) with base 'c', Ctrl+Shift pressed, repeat event
// Cyrillic 'с' = 1089, Cyrillic 'С' = 1057, Latin 'c' = 99
// ctrl=4, shift=1, +1 = 6, repeat event = 2
const fullFormat = "\x1b[1089:1057:99;6:2u";
assert.strictEqual(matchesKey(fullFormat, "ctrl+shift+c"), true);
setKittyProtocolActive(false);
});
it("should prefer codepoint for Latin letters even when base layout differs", () => {
setKittyProtocolActive(true);
// Dvorak Ctrl+K reports codepoint 'k' (107) and base layout 'v' (118)
const dvorakCtrlK = "\x1b[107::118;5u";
assert.strictEqual(matchesKey(dvorakCtrlK, "ctrl+k"), true);
assert.strictEqual(matchesKey(dvorakCtrlK, "ctrl+v"), false);
setKittyProtocolActive(false);
});
it("should prefer codepoint for symbol keys even when base layout differs", () => {
setKittyProtocolActive(true);
// Dvorak Ctrl+/ reports codepoint '/' (47) and base layout '[' (91)
const dvorakCtrlSlash = "\x1b[47::91;5u";
assert.strictEqual(matchesKey(dvorakCtrlSlash, "ctrl+/"), true);
assert.strictEqual(matchesKey(dvorakCtrlSlash, "ctrl+["), false);
setKittyProtocolActive(false);
});
it("should not match wrong key even with base layout", () => {
setKittyProtocolActive(true);
// Cyrillic ctrl+с with base 'c' should NOT match ctrl+d
const cyrillicCtrlC = "\x1b[1089::99;5u";
assert.strictEqual(matchesKey(cyrillicCtrlC, "ctrl+d"), false);
setKittyProtocolActive(false);
});
it("should not match wrong modifiers even with base layout", () => {
setKittyProtocolActive(true);
// Cyrillic ctrl+с should NOT match ctrl+shift+c
const cyrillicCtrlC = "\x1b[1089::99;5u";
assert.strictEqual(matchesKey(cyrillicCtrlC, "ctrl+shift+c"), false);
setKittyProtocolActive(false);
});
});
describe("modifyOtherKeys matching", () => {
it("should match xterm modifyOtherKeys Ctrl+c", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x1b[27;5;99~", "ctrl+c"), true);
assert.strictEqual(parseKey("\x1b[27;5;99~"), "ctrl+c");
});
it("should match xterm modifyOtherKeys Ctrl+d", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x1b[27;5;100~", "ctrl+d"), true);
assert.strictEqual(parseKey("\x1b[27;5;100~"), "ctrl+d");
});
it("should match xterm modifyOtherKeys Ctrl+z", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x1b[27;5;122~", "ctrl+z"), true);
assert.strictEqual(parseKey("\x1b[27;5;122~"), "ctrl+z");
});
it("should match xterm modifyOtherKeys Enter variants", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x1b[27;5;13~", "ctrl+enter"), true);
assert.strictEqual(matchesKey("\x1b[27;2;13~", "shift+enter"), true);
assert.strictEqual(matchesKey("\x1b[27;3;13~", "alt+enter"), true);
assert.strictEqual(parseKey("\x1b[27;5;13~"), "ctrl+enter");
assert.strictEqual(parseKey("\x1b[27;2;13~"), "shift+enter");
assert.strictEqual(parseKey("\x1b[27;3;13~"), "alt+enter");
});
it("should match xterm modifyOtherKeys Tab variants", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x1b[27;2;9~", "shift+tab"), true);
assert.strictEqual(matchesKey("\x1b[27;5;9~", "ctrl+tab"), true);
assert.strictEqual(matchesKey("\x1b[27;3;9~", "alt+tab"), true);
assert.strictEqual(parseKey("\x1b[27;2;9~"), "shift+tab");
assert.strictEqual(parseKey("\x1b[27;5;9~"), "ctrl+tab");
assert.strictEqual(parseKey("\x1b[27;3;9~"), "alt+tab");
});
it("should match xterm modifyOtherKeys Backspace variants", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x1b[27;1;127~", "backspace"), true);
assert.strictEqual(matchesKey("\x1b[27;5;127~", "ctrl+backspace"), true);
assert.strictEqual(matchesKey("\x1b[27;3;127~", "alt+backspace"), true);
assert.strictEqual(parseKey("\x1b[27;1;127~"), "backspace");
assert.strictEqual(parseKey("\x1b[27;5;127~"), "ctrl+backspace");
assert.strictEqual(parseKey("\x1b[27;3;127~"), "alt+backspace");
});
it("should match xterm modifyOtherKeys Escape", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x1b[27;1;27~", "escape"), true);
assert.strictEqual(parseKey("\x1b[27;1;27~"), "escape");
});
it("should match xterm modifyOtherKeys Space variants", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x1b[27;1;32~", "space"), true);
assert.strictEqual(matchesKey("\x1b[27;5;32~", "ctrl+space"), true);
assert.strictEqual(parseKey("\x1b[27;1;32~"), "space");
assert.strictEqual(parseKey("\x1b[27;5;32~"), "ctrl+space");
});
it("should match xterm modifyOtherKeys symbol combos", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x1b[27;5;47~", "ctrl+/"), true);
assert.strictEqual(parseKey("\x1b[27;5;47~"), "ctrl+/");
});
it("should match xterm modifyOtherKeys digit combos", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x1b[27;5;49~", "ctrl+1"), true);
assert.strictEqual(matchesKey("\x1b[27;2;49~", "shift+1"), true);
assert.strictEqual(parseKey("\x1b[27;5;49~"), "ctrl+1");
assert.strictEqual(parseKey("\x1b[27;2;49~"), "shift+1");
});
it("should match Ctrl+Alt+letter via CSI-u when kitty inactive", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x1b[104;7u", "ctrl+alt+h"), true);
assert.strictEqual(parseKey("\x1b[104;7u"), "ctrl+alt+h");
});
it("should match Ctrl+Alt+letter via xterm modifyOtherKeys", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x1b[27;7;104~", "ctrl+alt+h"), true);
assert.strictEqual(parseKey("\x1b[27;7;104~"), "ctrl+alt+h");
});
});
describe("Legacy key matching", () => {
it("should match legacy Ctrl+c", () => {
setKittyProtocolActive(false);
// Ctrl+c sends ASCII 3 (ETX)
assert.strictEqual(matchesKey("\x03", "ctrl+c"), true);
});
it("should match legacy Ctrl+d", () => {
setKittyProtocolActive(false);
// Ctrl+d sends ASCII 4 (EOT)
assert.strictEqual(matchesKey("\x04", "ctrl+d"), true);
});
it("should match escape key", () => {
assert.strictEqual(matchesKey("\x1b", "escape"), true);
});
it("should match legacy linefeed as enter", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\n", "enter"), true);
assert.strictEqual(parseKey("\n"), "enter");
});
it("should treat linefeed as shift+enter when kitty active", () => {
setKittyProtocolActive(true);
assert.strictEqual(matchesKey("\n", "shift+enter"), true);
assert.strictEqual(matchesKey("\n", "enter"), false);
assert.strictEqual(parseKey("\n"), "shift+enter");
setKittyProtocolActive(false);
});
it("should parse ctrl+space", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x00", "ctrl+space"), true);
assert.strictEqual(parseKey("\x00"), "ctrl+space");
});
it("should match legacy Ctrl+symbol", () => {
setKittyProtocolActive(false);
// Ctrl+\ sends ASCII 28 (File Separator) in legacy terminals
assert.strictEqual(matchesKey("\x1c", "ctrl+\\"), true);
assert.strictEqual(parseKey("\x1c"), "ctrl+\\");
// Ctrl+] sends ASCII 29 (Group Separator) in legacy terminals
assert.strictEqual(matchesKey("\x1d", "ctrl+]"), true);
assert.strictEqual(parseKey("\x1d"), "ctrl+]");
// Ctrl+_ sends ASCII 31 (Unit Separator) in legacy terminals
// Ctrl+- is on the same physical key on US keyboards
assert.strictEqual(matchesKey("\x1f", "ctrl+_"), true);
assert.strictEqual(matchesKey("\x1f", "ctrl+-"), true);
assert.strictEqual(parseKey("\x1f"), "ctrl+-");
});
it("should match legacy Ctrl+Alt+symbol", () => {
setKittyProtocolActive(false);
// Ctrl+Alt+[ sends ESC followed by ESC (Ctrl+[ = ESC)
assert.strictEqual(matchesKey("\x1b\x1b", "ctrl+alt+["), true);
assert.strictEqual(parseKey("\x1b\x1b"), "ctrl+alt+[");
// Ctrl+Alt+\ sends ESC followed by ASCII 28
assert.strictEqual(matchesKey("\x1b\x1c", "ctrl+alt+\\"), true);
assert.strictEqual(parseKey("\x1b\x1c"), "ctrl+alt+\\");
// Ctrl+Alt+] sends ESC followed by ASCII 29
assert.strictEqual(matchesKey("\x1b\x1d", "ctrl+alt+]"), true);
assert.strictEqual(parseKey("\x1b\x1d"), "ctrl+alt+]");
// Ctrl+_ sends ASCII 31 (Unit Separator) in legacy terminals
// Ctrl+- is on the same physical key on US keyboards
assert.strictEqual(matchesKey("\x1b\x1f", "ctrl+alt+_"), true);
assert.strictEqual(matchesKey("\x1b\x1f", "ctrl+alt+-"), true);
assert.strictEqual(parseKey("\x1b\x1f"), "ctrl+alt+-");
});
it("should treat raw 0x08 as plain backspace outside Windows Terminal", () => {
setKittyProtocolActive(false);
withEnv("WT_SESSION", undefined, () => {
assert.strictEqual(matchesKey("\x7f", "backspace"), true);
assert.strictEqual(matchesKey("\x7f", "ctrl+backspace"), false);
assert.strictEqual(parseKey("\x7f"), "backspace");
assert.strictEqual(matchesKey("\x08", "backspace"), true);
assert.strictEqual(matchesKey("\x08", "ctrl+backspace"), false);
assert.strictEqual(parseKey("\x08"), "backspace");
assert.strictEqual(matchesKey("\x08", "ctrl+h"), true);
});
});
it("should treat raw 0x08 as ctrl+backspace in local Windows Terminal", () => {
setKittyProtocolActive(false);
withEnvVars(
{
WT_SESSION: "test-session",
SSH_CONNECTION: undefined,
SSH_CLIENT: undefined,
SSH_TTY: undefined,
},
() => {
assert.strictEqual(matchesKey("\x08", "ctrl+backspace"), true);
assert.strictEqual(matchesKey("\x08", "backspace"), false);
assert.strictEqual(parseKey("\x08"), "ctrl+backspace");
assert.strictEqual(matchesKey("\x08", "ctrl+h"), true);
},
);
});
it("should treat raw 0x08 as plain backspace in Windows Terminal over SSH", () => {
setKittyProtocolActive(false);
withEnvVars(
{
WT_SESSION: "test-session",
SSH_CONNECTION: "1 2 3 4",
SSH_CLIENT: "1 2 3",
SSH_TTY: "/dev/pts/1",
},
() => {
assert.strictEqual(matchesKey("\x08", "ctrl+backspace"), false);
assert.strictEqual(matchesKey("\x08", "backspace"), true);
assert.strictEqual(parseKey("\x08"), "backspace");
assert.strictEqual(matchesKey("\x08", "ctrl+h"), true);
},
);
});
it("should parse legacy alt-prefixed sequences when kitty inactive", () => {
setKittyProtocolActive(false);
assert.strictEqual(matchesKey("\x1b ", "alt+space"), true);
assert.strictEqual(parseKey("\x1b "), "alt+space");
assert.strictEqual(matchesKey("\x1b\b", "alt+backspace"), true);
assert.strictEqual(parseKey("\x1b\b"), "alt+backspace");
assert.strictEqual(matchesKey("\x1b\x03", "ctrl+alt+c"), true);
assert.strictEqual(parseKey("\x1b\x03"), "ctrl+alt+c");
assert.strictEqual(matchesKey("\x1bB", "alt+left"), true);
assert.strictEqual(parseKey("\x1bB"), "alt+left");
assert.strictEqual(matchesKey("\x1bF", "alt+right"), true);
assert.strictEqual(parseKey("\x1bF"), "alt+right");
assert.strictEqual(matchesKey("\x1ba", "alt+a"), true);
assert.strictEqual(parseKey("\x1ba"), "alt+a");
assert.strictEqual(matchesKey("\x1b1", "alt+1"), true);
assert.strictEqual(parseKey("\x1b1"), "alt+1");
assert.strictEqual(matchesKey("\x1by", "alt+y"), true);
assert.strictEqual(parseKey("\x1by"), "alt+y");
assert.strictEqual(matchesKey("\x1bz", "alt+z"), true);
assert.strictEqual(parseKey("\x1bz"), "alt+z");
setKittyProtocolActive(true);
assert.strictEqual(matchesKey("\x1b ", "alt+space"), false);
assert.strictEqual(parseKey("\x1b "), undefined);
assert.strictEqual(matchesKey("\x1b\b", "alt+backspace"), true);
assert.strictEqual(parseKey("\x1b\b"), "alt+backspace");
assert.strictEqual(matchesKey("\x1b\x03", "ctrl+alt+c"), false);
assert.strictEqual(parseKey("\x1b\x03"), undefined);
assert.strictEqual(matchesKey("\x1bB", "alt+left"), false);
assert.strictEqual(parseKey("\x1bB"), undefined);
assert.strictEqual(matchesKey("\x1bF", "alt+right"), false);
assert.strictEqual(parseKey("\x1bF"), undefined);
assert.strictEqual(matchesKey("\x1ba", "alt+a"), false);
assert.strictEqual(parseKey("\x1ba"), undefined);
assert.strictEqual(matchesKey("\x1b1", "alt+1"), false);
assert.strictEqual(parseKey("\x1b1"), undefined);
assert.strictEqual(matchesKey("\x1by", "alt+y"), false);
assert.strictEqual(parseKey("\x1by"), undefined);
setKittyProtocolActive(false);
});
it("should match arrow keys", () => {
assert.strictEqual(matchesKey("\x1b[A", "up"), true);
assert.strictEqual(matchesKey("\x1b[B", "down"), true);
assert.strictEqual(matchesKey("\x1b[C", "right"), true);
assert.strictEqual(matchesKey("\x1b[D", "left"), true);
});
it("should match SS3 arrows and home/end", () => {
assert.strictEqual(matchesKey("\x1bOA", "up"), true);
assert.strictEqual(matchesKey("\x1bOB", "down"), true);
assert.strictEqual(matchesKey("\x1bOC", "right"), true);
assert.strictEqual(matchesKey("\x1bOD", "left"), true);
assert.strictEqual(matchesKey("\x1bOH", "home"), true);
assert.strictEqual(matchesKey("\x1bOF", "end"), true);
});
it("should match legacy function keys and clear", () => {
assert.strictEqual(matchesKey("\x1bOP", "f1"), true);
assert.strictEqual(matchesKey("\x1b[24~", "f12"), true);
assert.strictEqual(matchesKey("\x1b[E", "clear"), true);
});
it("should match alt+arrows", () => {
assert.strictEqual(matchesKey("\x1bp", "alt+up"), true);
assert.strictEqual(matchesKey("\x1bp", "up"), false);
});
it("should match rxvt modifier sequences", () => {
assert.strictEqual(matchesKey("\x1b[a", "shift+up"), true);
assert.strictEqual(matchesKey("\x1bOa", "ctrl+up"), true);
assert.strictEqual(matchesKey("\x1b[2$", "shift+insert"), true);
assert.strictEqual(matchesKey("\x1b[2^", "ctrl+insert"), true);
assert.strictEqual(matchesKey("\x1b[7$", "shift+home"), true);
});
});
});
describe("decodeKittyPrintable", () => {
it("should decode Kitty keypad functional keys to printable characters", () => {
assert.strictEqual(decodeKittyPrintable("\x1b[57399u"), "0");
assert.strictEqual(decodeKittyPrintable("\x1b[57400u"), "1");
assert.strictEqual(decodeKittyPrintable("\x1b[57409u"), ".");
assert.strictEqual(decodeKittyPrintable("\x1b[57410u"), "/");
assert.strictEqual(decodeKittyPrintable("\x1b[57411u"), "*");
assert.strictEqual(decodeKittyPrintable("\x1b[57412u"), "-");
assert.strictEqual(decodeKittyPrintable("\x1b[57413u"), "+");
assert.strictEqual(decodeKittyPrintable("\x1b[57415u"), "=");
assert.strictEqual(decodeKittyPrintable("\x1b[57416u"), ",");
assert.strictEqual(decodeKittyPrintable("\x1b[57417u"), undefined);
});
});
describe("parseKey", () => {
describe("Kitty protocol with alternate keys", () => {
it("should return Latin key name when base layout key is present", () => {
setKittyProtocolActive(true);
// Cyrillic ctrl+с with base layout 'c'
const cyrillicCtrlC = "\x1b[1089::99;5u";
assert.strictEqual(parseKey(cyrillicCtrlC), "ctrl+c");
setKittyProtocolActive(false);
});
it("should prefer codepoint for Latin letters when base layout differs", () => {
setKittyProtocolActive(true);
// Dvorak Ctrl+K reports codepoint 'k' (107) and base layout 'v' (118)
const dvorakCtrlK = "\x1b[107::118;5u";
assert.strictEqual(parseKey(dvorakCtrlK), "ctrl+k");
setKittyProtocolActive(false);
});
it("should prefer codepoint for symbol keys when base layout differs", () => {
setKittyProtocolActive(true);
// Dvorak Ctrl+/ reports codepoint '/' (47) and base layout '[' (91)
const dvorakCtrlSlash = "\x1b[47::91;5u";
assert.strictEqual(parseKey(dvorakCtrlSlash), "ctrl+/");
setKittyProtocolActive(false);
});
it("should return key name from codepoint when no base layout", () => {
setKittyProtocolActive(true);
const latinCtrlC = "\x1b[99;5u";
assert.strictEqual(parseKey(latinCtrlC), "ctrl+c");
setKittyProtocolActive(false);
});
it("should ignore Kitty CSI-u with unsupported modifiers", () => {
setKittyProtocolActive(true);
assert.strictEqual(parseKey("\x1b[99;17u"), undefined);
setKittyProtocolActive(false);
});
});
describe("Legacy key parsing", () => {
it("should parse legacy Ctrl+letter", () => {
setKittyProtocolActive(false);
assert.strictEqual(parseKey("\x03"), "ctrl+c");
assert.strictEqual(parseKey("\x04"), "ctrl+d");
});
it("should parse special keys", () => {
assert.strictEqual(parseKey("\x1b"), "escape");
assert.strictEqual(parseKey("\t"), "tab");
assert.strictEqual(parseKey("\r"), "enter");
assert.strictEqual(parseKey("\n"), "enter");
assert.strictEqual(parseKey("\x00"), "ctrl+space");
assert.strictEqual(parseKey(" "), "space");
assert.strictEqual(parseKey("1"), "1");
assert.strictEqual(matchesKey("1", "1"), true);
});
it("should parse arrow keys", () => {
assert.strictEqual(parseKey("\x1b[A"), "up");
assert.strictEqual(parseKey("\x1b[B"), "down");
assert.strictEqual(parseKey("\x1b[C"), "right");
assert.strictEqual(parseKey("\x1b[D"), "left");
});
it("should parse SS3 arrows and home/end", () => {
assert.strictEqual(parseKey("\x1bOA"), "up");
assert.strictEqual(parseKey("\x1bOB"), "down");
assert.strictEqual(parseKey("\x1bOC"), "right");
assert.strictEqual(parseKey("\x1bOD"), "left");
assert.strictEqual(parseKey("\x1bOH"), "home");
assert.strictEqual(parseKey("\x1bOF"), "end");
});
it("should parse legacy function and modifier sequences", () => {
assert.strictEqual(parseKey("\x1bOP"), "f1");
assert.strictEqual(parseKey("\x1b[24~"), "f12");
assert.strictEqual(parseKey("\x1b[E"), "clear");
assert.strictEqual(parseKey("\x1b[2^"), "ctrl+insert");
assert.strictEqual(parseKey("\x1bp"), "alt+up");
});
it("should parse double bracket pageUp", () => {
assert.strictEqual(parseKey("\x1b[[5~"), "pageUp");
});
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,609 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import type { Component, Focusable } from "../src/tui.js";
import { TUI } from "../src/tui.js";
import { VirtualTerminal } from "./virtual-terminal.js";
class StaticOverlay implements Component {
constructor(private lines: string[]) {}
render(): string[] {
return this.lines;
}
invalidate(): void {}
}
class EmptyContent implements Component {
render(): string[] {
return [];
}
invalidate(): void {}
}
class FocusableOverlay implements Component, Focusable {
focused = false;
inputs: string[] = [];
constructor(private lines: string[]) {}
handleInput(data: string): void {
this.inputs.push(data);
}
render(): string[] {
return this.lines;
}
invalidate(): void {}
}
async function renderAndFlush(tui: TUI, terminal: VirtualTerminal): Promise<void> {
tui.requestRender(true);
await new Promise<void>((resolve) => process.nextTick(resolve));
await terminal.waitForRender();
}
describe("TUI overlay non-capturing", () => {
describe("focus management", () => {
it("non-capturing overlay preserves focus on creation", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const editor = new FocusableOverlay(["EDITOR"]);
const overlay = new FocusableOverlay(["OVERLAY"]);
tui.addChild(new EmptyContent());
tui.setFocus(editor);
tui.start();
try {
tui.showOverlay(overlay, { nonCapturing: true });
await renderAndFlush(tui, terminal);
assert.strictEqual(editor.focused, true);
assert.strictEqual(overlay.focused, false);
} finally {
tui.stop();
}
});
it("focus() transfers focus to the overlay", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const editor = new FocusableOverlay(["EDITOR"]);
const overlay = new FocusableOverlay(["OVERLAY"]);
tui.addChild(new EmptyContent());
tui.setFocus(editor);
tui.start();
try {
const handle = tui.showOverlay(overlay, { nonCapturing: true });
handle.focus();
await renderAndFlush(tui, terminal);
assert.strictEqual(editor.focused, false);
assert.strictEqual(overlay.focused, true);
assert.strictEqual(handle.isFocused(), true);
} finally {
tui.stop();
}
});
it("unfocus() restores previous focus", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const editor = new FocusableOverlay(["EDITOR"]);
const overlay = new FocusableOverlay(["OVERLAY"]);
tui.addChild(new EmptyContent());
tui.setFocus(editor);
tui.start();
try {
const handle = tui.showOverlay(overlay, { nonCapturing: true });
handle.focus();
handle.unfocus();
await renderAndFlush(tui, terminal);
assert.strictEqual(editor.focused, true);
assert.strictEqual(overlay.focused, false);
assert.strictEqual(handle.isFocused(), false);
} finally {
tui.stop();
}
});
it("setHidden(false) on non-capturing overlay does not auto-focus", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const editor = new FocusableOverlay(["EDITOR"]);
const overlay = new FocusableOverlay(["OVERLAY"]);
tui.addChild(new EmptyContent());
tui.setFocus(editor);
tui.start();
try {
const handle = tui.showOverlay(overlay, { nonCapturing: true });
handle.setHidden(true);
handle.setHidden(false);
await renderAndFlush(tui, terminal);
assert.strictEqual(editor.focused, true);
assert.strictEqual(overlay.focused, false);
} finally {
tui.stop();
}
});
it("hide() when overlay is not focused does not change focus", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const editor = new FocusableOverlay(["EDITOR"]);
const overlay = new FocusableOverlay(["OVERLAY"]);
tui.addChild(new EmptyContent());
tui.setFocus(editor);
tui.start();
try {
const handle = tui.showOverlay(overlay, { nonCapturing: true });
handle.hide();
await renderAndFlush(tui, terminal);
assert.strictEqual(editor.focused, true);
} finally {
tui.stop();
}
});
it("hide() when focused restores focus correctly", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const editor = new FocusableOverlay(["EDITOR"]);
const overlay = new FocusableOverlay(["OVERLAY"]);
tui.addChild(new EmptyContent());
tui.setFocus(editor);
tui.start();
try {
const handle = tui.showOverlay(overlay, { nonCapturing: true });
handle.focus();
handle.hide();
await renderAndFlush(tui, terminal);
assert.strictEqual(editor.focused, true);
assert.strictEqual(overlay.focused, false);
} finally {
tui.stop();
}
});
it("capturing overlay removed with non-capturing below restores focus to editor", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const editor = new FocusableOverlay(["EDITOR"]);
const nonCapturing = new FocusableOverlay(["NC"]);
const capturing = new FocusableOverlay(["CAP"]);
tui.addChild(new EmptyContent());
tui.setFocus(editor);
tui.start();
try {
tui.showOverlay(nonCapturing, { nonCapturing: true });
const handle = tui.showOverlay(capturing);
assert.strictEqual(capturing.focused, true);
handle.hide();
await renderAndFlush(tui, terminal);
assert.strictEqual(editor.focused, true);
assert.strictEqual(nonCapturing.focused, false);
} finally {
tui.stop();
}
});
it("sub-overlay cleanup then hideOverlay restores focus and input to editor", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const editor = new FocusableOverlay(["EDITOR"]);
const timer = new FocusableOverlay(["TIMER"]);
const controller = new FocusableOverlay(["CTRL"]);
tui.addChild(new EmptyContent());
tui.setFocus(editor);
tui.start();
try {
const timerHandle = tui.showOverlay(timer, { nonCapturing: true });
tui.showOverlay(controller);
assert.strictEqual(controller.focused, true);
assert.strictEqual(editor.focused, false);
timerHandle.hide();
tui.hideOverlay();
await renderAndFlush(tui, terminal);
assert.strictEqual(editor.focused, true);
assert.strictEqual(controller.focused, false);
assert.strictEqual(timer.focused, false);
terminal.sendInput("x");
await renderAndFlush(tui, terminal);
assert.deepStrictEqual(editor.inputs, ["x"]);
assert.deepStrictEqual(controller.inputs, []);
assert.deepStrictEqual(timer.inputs, []);
} finally {
tui.stop();
}
});
it("microtask-deferred sub-overlay pattern (showExtensionCustom simulation) restores focus", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const editor = new FocusableOverlay(["EDITOR"]);
const timer = new FocusableOverlay(["TIMER"]);
const controller = new FocusableOverlay(["CTRL"]);
tui.addChild(new EmptyContent());
tui.setFocus(editor);
tui.start();
try {
// Simulate showExtensionCustom: factory creates timer synchronously,
// then .then() pushes controller as a microtask
let timerHandle: ReturnType<typeof tui.showOverlay>;
let doneFn: () => void;
const overlayPromise = new Promise<void>((resolve) => {
doneFn = () => {
timerHandle.hide();
tui.hideOverlay();
resolve();
};
// Factory runs synchronously: creates timer sub-overlay
timerHandle = tui.showOverlay(timer, { nonCapturing: true });
// .then() runs as microtask — same as showExtensionCustom
Promise.resolve(controller).then((c) => {
tui.showOverlay(c);
});
});
// Wait for .then() microtask and renders to settle
await new Promise<void>((r) => setTimeout(r, 50));
await renderAndFlush(tui, terminal);
assert.strictEqual(controller.focused, true);
assert.strictEqual(editor.focused, false);
// Simulate Esc: cleanup + close (from inside handleInput)
doneFn!();
// Now await the promise (simulating showExtensionCustom resolving)
await overlayPromise;
await renderAndFlush(tui, terminal);
assert.strictEqual(editor.focused, true, "editor should regain focus");
assert.strictEqual(controller.focused, false);
assert.strictEqual(timer.focused, false);
terminal.sendInput("x");
await renderAndFlush(tui, terminal);
assert.deepStrictEqual(editor.inputs, ["x"], "editor should receive input after close");
assert.deepStrictEqual(controller.inputs, []);
} finally {
tui.stop();
}
});
it("handleInput redirection skips non-capturing overlays when focused overlay becomes invisible", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const editor = new FocusableOverlay(["EDITOR"]);
const fallbackCapturing = new FocusableOverlay(["FALLBACK"]);
const nonCapturing = new FocusableOverlay(["NC"]);
const primary = new FocusableOverlay(["PRIMARY"]);
let isVisible = true;
tui.addChild(new EmptyContent());
tui.setFocus(editor);
tui.start();
try {
tui.showOverlay(fallbackCapturing);
tui.showOverlay(nonCapturing, { nonCapturing: true });
tui.showOverlay(primary, { visible: () => isVisible });
assert.strictEqual(primary.focused, true);
isVisible = false;
terminal.sendInput("x");
await renderAndFlush(tui, terminal);
assert.deepStrictEqual(primary.inputs, []);
assert.deepStrictEqual(nonCapturing.inputs, []);
assert.deepStrictEqual(fallbackCapturing.inputs, ["x"]);
assert.strictEqual(fallbackCapturing.focused, true);
} finally {
tui.stop();
}
});
it("hideOverlay() does not reassign focus when topmost overlay is non-capturing", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const editor = new FocusableOverlay(["EDITOR"]);
const capturing = new FocusableOverlay(["CAP"]);
const nonCapturing = new FocusableOverlay(["NC"]);
tui.addChild(new EmptyContent());
tui.setFocus(editor);
tui.start();
try {
tui.showOverlay(capturing);
tui.showOverlay(nonCapturing, { nonCapturing: true });
assert.strictEqual(capturing.focused, true);
tui.hideOverlay();
await renderAndFlush(tui, terminal);
assert.strictEqual(capturing.focused, true);
} finally {
tui.stop();
}
});
it("multiple capturing and non-capturing overlays restore focus through removals", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const editor = new FocusableOverlay(["EDITOR"]);
const c1 = new FocusableOverlay(["C1"]);
const n1 = new FocusableOverlay(["N1"]);
const c2 = new FocusableOverlay(["C2"]);
const n2 = new FocusableOverlay(["N2"]);
tui.addChild(new EmptyContent());
tui.setFocus(editor);
tui.start();
try {
const c1Handle = tui.showOverlay(c1);
tui.showOverlay(n1, { nonCapturing: true });
const c2Handle = tui.showOverlay(c2);
tui.showOverlay(n2, { nonCapturing: true });
assert.strictEqual(c2.focused, true);
c2Handle.hide();
await renderAndFlush(tui, terminal);
assert.strictEqual(c1.focused, true);
c1Handle.hide();
await renderAndFlush(tui, terminal);
assert.strictEqual(editor.focused, true);
} finally {
tui.stop();
}
});
it("capturing overlay unfocus() on topmost capturing overlay falls back to preFocus", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const editor = new FocusableOverlay(["EDITOR"]);
const capturing = new FocusableOverlay(["CAP"]);
tui.addChild(new EmptyContent());
tui.setFocus(editor);
tui.start();
try {
const handle = tui.showOverlay(capturing);
assert.strictEqual(capturing.focused, true);
handle.unfocus();
await renderAndFlush(tui, terminal);
assert.strictEqual(editor.focused, true);
assert.strictEqual(capturing.focused, false);
} finally {
tui.stop();
}
});
});
describe("no-op guards", () => {
it("focus() on hidden overlay is a no-op", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const editor = new FocusableOverlay(["EDITOR"]);
const overlay = new FocusableOverlay(["OVERLAY"]);
tui.addChild(new EmptyContent());
tui.setFocus(editor);
tui.start();
try {
const handle = tui.showOverlay(overlay, { nonCapturing: true });
handle.setHidden(true);
handle.focus();
await renderAndFlush(tui, terminal);
assert.strictEqual(editor.focused, true);
assert.strictEqual(handle.isFocused(), false);
} finally {
tui.stop();
}
});
it("focus() after hide() is a no-op", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const editor = new FocusableOverlay(["EDITOR"]);
const overlay = new FocusableOverlay(["OVERLAY"]);
tui.addChild(new EmptyContent());
tui.setFocus(editor);
tui.start();
try {
const handle = tui.showOverlay(overlay, { nonCapturing: true });
handle.hide();
handle.focus();
await renderAndFlush(tui, terminal);
assert.strictEqual(editor.focused, true);
assert.strictEqual(handle.isFocused(), false);
} finally {
tui.stop();
}
});
it("unfocus() when overlay does not have focus is a no-op", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const editor = new FocusableOverlay(["EDITOR"]);
const overlay = new FocusableOverlay(["OVERLAY"]);
tui.addChild(new EmptyContent());
tui.setFocus(editor);
tui.start();
try {
const handle = tui.showOverlay(overlay, { nonCapturing: true });
handle.unfocus();
await renderAndFlush(tui, terminal);
assert.strictEqual(editor.focused, true);
assert.strictEqual(overlay.focused, false);
} finally {
tui.stop();
}
});
it("unfocus() with null preFocus clears focus and does not route input back to overlay", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new FocusableOverlay(["OVERLAY"]);
tui.addChild(new EmptyContent());
tui.start();
try {
const handle = tui.showOverlay(overlay);
assert.strictEqual(overlay.focused, true);
handle.unfocus();
assert.strictEqual(overlay.focused, false);
terminal.sendInput("x");
await renderAndFlush(tui, terminal);
assert.deepStrictEqual(overlay.inputs, []);
assert.strictEqual(handle.isFocused(), false);
} finally {
tui.stop();
}
});
});
describe("focus cycle prevention", () => {
it("toggle focus between non-capturing overlays then unfocus returns to editor", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const editor = new FocusableOverlay(["EDITOR"]);
const a = new FocusableOverlay(["A"]);
const b = new FocusableOverlay(["B"]);
tui.addChild(new EmptyContent());
tui.setFocus(editor);
tui.start();
try {
const aHandle = tui.showOverlay(a, { nonCapturing: true });
const bHandle = tui.showOverlay(b, { nonCapturing: true });
aHandle.focus();
bHandle.focus();
aHandle.focus();
aHandle.unfocus();
await renderAndFlush(tui, terminal);
assert.strictEqual(editor.focused, true);
assert.strictEqual(a.focused, false);
assert.strictEqual(b.focused, false);
} finally {
tui.stop();
}
});
});
describe("rendering order", () => {
it("focus() on already-focused overlay bumps visual order", async () => {
const terminal = new VirtualTerminal(20, 6);
const tui = new TUI(terminal);
const editor = new FocusableOverlay(["EDITOR"]);
tui.addChild(new EmptyContent());
tui.setFocus(editor);
tui.start();
try {
const aHandle = tui.showOverlay(new StaticOverlay(["A"]), { row: 0, col: 0, width: 1, nonCapturing: true });
tui.showOverlay(new StaticOverlay(["B"]), { row: 0, col: 0, width: 1, nonCapturing: true });
aHandle.focus();
tui.showOverlay(new StaticOverlay(["C"]), { row: 0, col: 0, width: 1, nonCapturing: true });
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "C");
aHandle.focus();
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "A");
assert.strictEqual(aHandle.isFocused(), true);
} finally {
tui.stop();
}
});
it("default rendering order for overlapping overlays follows creation order", async () => {
const terminal = new VirtualTerminal(20, 6);
const tui = new TUI(terminal);
tui.addChild(new EmptyContent());
tui.start();
try {
tui.showOverlay(new StaticOverlay(["A"]), { row: 0, col: 0, width: 1, nonCapturing: true });
tui.showOverlay(new StaticOverlay(["B"]), { row: 0, col: 0, width: 1, nonCapturing: true });
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "B");
} finally {
tui.stop();
}
});
it("focus() on lower overlay renders it on top", async () => {
const terminal = new VirtualTerminal(20, 6);
const tui = new TUI(terminal);
tui.addChild(new EmptyContent());
tui.start();
try {
const lower = tui.showOverlay(new StaticOverlay(["A"]), { row: 0, col: 0, width: 1, nonCapturing: true });
tui.showOverlay(new StaticOverlay(["B"]), { row: 0, col: 0, width: 1, nonCapturing: true });
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "B");
lower.focus();
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "A");
} finally {
tui.stop();
}
});
it("focusing middle overlay places it on top while preserving others relative order", async () => {
const terminal = new VirtualTerminal(20, 6);
const tui = new TUI(terminal);
tui.addChild(new EmptyContent());
tui.start();
try {
tui.showOverlay(new StaticOverlay(["A"]), { row: 0, col: 0, width: 1, nonCapturing: true });
const middle = tui.showOverlay(new StaticOverlay(["B"]), { row: 0, col: 0, width: 1, nonCapturing: true });
const top = tui.showOverlay(new StaticOverlay(["C"]), { row: 0, col: 0, width: 1, nonCapturing: true });
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "C");
middle.focus();
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "B");
middle.hide();
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "C");
top.hide();
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "A");
} finally {
tui.stop();
}
});
it("capturing overlay hidden and shown again renders on top after unhide", async () => {
const terminal = new VirtualTerminal(20, 6);
const tui = new TUI(terminal);
tui.addChild(new EmptyContent());
tui.start();
try {
tui.showOverlay(new StaticOverlay(["A"]), { row: 0, col: 0, width: 1, nonCapturing: true });
const capturing = tui.showOverlay(new StaticOverlay(["B"]), { row: 0, col: 0, width: 1 });
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "B");
capturing.setHidden(true);
tui.showOverlay(new StaticOverlay(["C"]), { row: 0, col: 0, width: 1, nonCapturing: true });
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "C");
capturing.setHidden(false);
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "B");
} finally {
tui.stop();
}
});
it("unfocus() does not change visual order until another overlay is focused", async () => {
const terminal = new VirtualTerminal(20, 6);
const tui = new TUI(terminal);
const editor = new FocusableOverlay(["EDITOR"]);
tui.addChild(new EmptyContent());
tui.setFocus(editor);
tui.start();
try {
const a = tui.showOverlay(new StaticOverlay(["A"]), { row: 0, col: 0, width: 1, nonCapturing: true });
const b = tui.showOverlay(new StaticOverlay(["B"]), { row: 0, col: 0, width: 1, nonCapturing: true });
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "B");
a.focus();
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "A");
a.unfocus();
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "A");
b.focus();
await renderAndFlush(tui, terminal);
assert.strictEqual(terminal.getViewport()[0]?.charAt(0), "B");
} finally {
tui.stop();
}
});
});
});
@@ -0,0 +1,538 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import type { Component } from "../src/tui.js";
import { TUI } from "../src/tui.js";
import { VirtualTerminal } from "./virtual-terminal.js";
class StaticOverlay implements Component {
constructor(
private lines: string[],
public requestedWidth?: number,
) {}
render(width: number): string[] {
// Store the width we were asked to render at for verification
this.requestedWidth = width;
return this.lines;
}
invalidate(): void {}
}
class EmptyContent implements Component {
render(): string[] {
return [];
}
invalidate(): void {}
}
async function renderAndFlush(tui: TUI, terminal: VirtualTerminal): Promise<void> {
tui.requestRender(true);
await new Promise<void>((resolve) => process.nextTick(resolve));
await terminal.waitForRender();
}
describe("TUI overlay options", () => {
describe("width overflow protection", () => {
it("should truncate overlay lines that exceed declared width", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
// Overlay declares width 20 but renders lines much wider
const overlay = new StaticOverlay(["X".repeat(100)]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { width: 20 });
tui.start();
await renderAndFlush(tui, terminal);
// Should not crash, and no line should exceed terminal width
const viewport = terminal.getViewport();
for (const line of viewport) {
// visibleWidth not available here, but line length is a rough check
// The important thing is it didn't crash
assert.ok(line !== undefined);
}
tui.stop();
});
it("should handle overlay with complex ANSI sequences without crashing", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
// Simulate complex ANSI content like the crash log showed
const complexLine =
"\x1b[48;2;40;50;40m \x1b[38;2;128;128;128mSome styled content\x1b[39m\x1b[49m" +
"\x1b]8;;http://example.com\x07link\x1b]8;;\x07" +
" more content ".repeat(10);
const overlay = new StaticOverlay([complexLine, complexLine, complexLine]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { width: 60 });
tui.start();
await renderAndFlush(tui, terminal);
// Should not crash
const viewport = terminal.getViewport();
assert.ok(viewport.length > 0);
tui.stop();
});
it("should handle overlay composited on styled base content", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
// Base content with styling
class StyledContent implements Component {
render(width: number): string[] {
const styledLine = `\x1b[1m\x1b[38;2;255;0;0m${"X".repeat(width)}\x1b[0m`;
return [styledLine, styledLine, styledLine];
}
invalidate(): void {}
}
const overlay = new StaticOverlay(["OVERLAY"]);
tui.addChild(new StyledContent());
tui.showOverlay(overlay, { width: 20, anchor: "center" });
tui.start();
await renderAndFlush(tui, terminal);
// Should not crash and overlay should be visible
const viewport = terminal.getViewport();
const hasOverlay = viewport.some((line) => line?.includes("OVERLAY"));
assert.ok(hasOverlay, "Overlay should be visible");
tui.stop();
});
it("should handle wide characters at overlay boundary", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
// Wide chars (each takes 2 columns) at the edge of declared width
const wideCharLine = "中文日本語한글テスト漢字"; // Mix of CJK chars
const overlay = new StaticOverlay([wideCharLine]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { width: 15 }); // Odd width to potentially hit boundary
tui.start();
await renderAndFlush(tui, terminal);
// Should not crash
const viewport = terminal.getViewport();
assert.ok(viewport.length > 0);
tui.stop();
});
it("should handle overlay positioned at terminal edge", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
// Overlay positioned at right edge with content that exceeds declared width
const overlay = new StaticOverlay(["X".repeat(50)]);
tui.addChild(new EmptyContent());
// Position at col 60 with width 20 - should fit exactly at right edge
tui.showOverlay(overlay, { col: 60, width: 20 });
tui.start();
await renderAndFlush(tui, terminal);
// Should not crash
const viewport = terminal.getViewport();
assert.ok(viewport.length > 0);
tui.stop();
});
it("should handle overlay on base content with OSC sequences", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
// Base content with OSC 8 hyperlinks (like file paths in agent output)
class HyperlinkContent implements Component {
render(width: number): string[] {
const link = `\x1b]8;;file:///path/to/file.ts\x07file.ts\x1b]8;;\x07`;
const line = `See ${link} for details ${"X".repeat(width - 30)}`;
return [line, line, line];
}
invalidate(): void {}
}
const overlay = new StaticOverlay(["OVERLAY-TEXT"]);
tui.addChild(new HyperlinkContent());
tui.showOverlay(overlay, { anchor: "center", width: 20 });
tui.start();
await renderAndFlush(tui, terminal);
// Should not crash - this was the original bug scenario
const viewport = terminal.getViewport();
assert.ok(viewport.length > 0);
tui.stop();
});
});
describe("width percentage", () => {
it("should render overlay at percentage of terminal width", async () => {
const terminal = new VirtualTerminal(100, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["test"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { width: "50%" });
tui.start();
await renderAndFlush(tui, terminal);
assert.strictEqual(overlay.requestedWidth, 50);
tui.stop();
});
it("should respect minWidth when widthPercent results in smaller width", async () => {
const terminal = new VirtualTerminal(100, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["test"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { width: "10%", minWidth: 30 });
tui.start();
await renderAndFlush(tui, terminal);
assert.strictEqual(overlay.requestedWidth, 30);
tui.stop();
});
});
describe("anchor positioning", () => {
it("should position overlay at top-left", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["TOP-LEFT"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { anchor: "top-left", width: 10 });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
assert.ok(viewport[0]?.startsWith("TOP-LEFT"), `Expected TOP-LEFT at start, got: ${viewport[0]}`);
tui.stop();
});
it("should position overlay at bottom-right", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["BTM-RIGHT"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { anchor: "bottom-right", width: 10 });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
// Should be on last row, ending at last column
const lastRow = viewport[23];
assert.ok(lastRow?.includes("BTM-RIGHT"), `Expected BTM-RIGHT on last row, got: ${lastRow}`);
assert.ok(lastRow?.trimEnd().endsWith("BTM-RIGHT"), `Expected BTM-RIGHT at end, got: ${lastRow}`);
tui.stop();
});
it("should position overlay at top-center", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["CENTERED"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { anchor: "top-center", width: 10 });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
// Should be on first row, centered horizontally
const firstRow = viewport[0];
assert.ok(firstRow?.includes("CENTERED"), `Expected CENTERED on first row, got: ${firstRow}`);
// Check it's roughly centered (col 35 for width 10 in 80 col terminal)
const colIndex = firstRow?.indexOf("CENTERED") ?? -1;
assert.ok(colIndex >= 30 && colIndex <= 40, `Expected centered, got col ${colIndex}`);
tui.stop();
});
});
describe("margin", () => {
it("should clamp negative margins to zero", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["NEG-MARGIN"]);
tui.addChild(new EmptyContent());
// Negative margins should be treated as 0
tui.showOverlay(overlay, {
anchor: "top-left",
width: 12,
margin: { top: -5, left: -10, right: 0, bottom: 0 },
});
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
// Should be at row 0, col 0 (negative margins clamped to 0)
assert.ok(viewport[0]?.startsWith("NEG-MARGIN"), `Expected NEG-MARGIN at start of row 0, got: ${viewport[0]}`);
tui.stop();
});
it("should respect margin as number", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["MARGIN"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { anchor: "top-left", width: 10, margin: 5 });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
// Should be on row 5 (not 0) due to margin
assert.ok(!viewport[0]?.includes("MARGIN"), "Should not be on row 0");
assert.ok(!viewport[4]?.includes("MARGIN"), "Should not be on row 4");
assert.ok(viewport[5]?.includes("MARGIN"), `Expected MARGIN on row 5, got: ${viewport[5]}`);
// Should start at col 5 (not 0)
const colIndex = viewport[5]?.indexOf("MARGIN") ?? -1;
assert.strictEqual(colIndex, 5, `Expected col 5, got ${colIndex}`);
tui.stop();
});
it("should respect margin object", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["MARGIN"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, {
anchor: "top-left",
width: 10,
margin: { top: 2, left: 3, right: 0, bottom: 0 },
});
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
assert.ok(viewport[2]?.includes("MARGIN"), `Expected MARGIN on row 2, got: ${viewport[2]}`);
const colIndex = viewport[2]?.indexOf("MARGIN") ?? -1;
assert.strictEqual(colIndex, 3, `Expected col 3, got ${colIndex}`);
tui.stop();
});
});
describe("offset", () => {
it("should apply offsetX and offsetY from anchor position", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["OFFSET"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { anchor: "top-left", width: 10, offsetX: 10, offsetY: 5 });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
assert.ok(viewport[5]?.includes("OFFSET"), `Expected OFFSET on row 5, got: ${viewport[5]}`);
const colIndex = viewport[5]?.indexOf("OFFSET") ?? -1;
assert.strictEqual(colIndex, 10, `Expected col 10, got ${colIndex}`);
tui.stop();
});
});
describe("percentage positioning", () => {
it("should position with rowPercent and colPercent", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["PCT"]);
tui.addChild(new EmptyContent());
// 50% should center both ways
tui.showOverlay(overlay, { width: 10, row: "50%", col: "50%" });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
// Find the row with PCT
let foundRow = -1;
for (let i = 0; i < viewport.length; i++) {
if (viewport[i]?.includes("PCT")) {
foundRow = i;
break;
}
}
// Should be roughly centered vertically (row ~11-12 for 24 row terminal)
assert.ok(foundRow >= 10 && foundRow <= 13, `Expected centered row, got ${foundRow}`);
tui.stop();
});
it("rowPercent 0 should position at top", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["TOP"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { width: 10, row: "0%" });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
assert.ok(viewport[0]?.includes("TOP"), `Expected TOP on row 0, got: ${viewport[0]}`);
tui.stop();
});
it("rowPercent 100 should position at bottom", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["BOTTOM"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { width: 10, row: "100%" });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
assert.ok(viewport[23]?.includes("BOTTOM"), `Expected BOTTOM on last row, got: ${viewport[23]}`);
tui.stop();
});
});
describe("maxHeight", () => {
it("should truncate overlay to maxHeight", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["Line 1", "Line 2", "Line 3", "Line 4", "Line 5"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { maxHeight: 3 });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
const content = viewport.join("\n");
assert.ok(content.includes("Line 1"), "Should include Line 1");
assert.ok(content.includes("Line 2"), "Should include Line 2");
assert.ok(content.includes("Line 3"), "Should include Line 3");
assert.ok(!content.includes("Line 4"), "Should NOT include Line 4");
assert.ok(!content.includes("Line 5"), "Should NOT include Line 5");
tui.stop();
});
it("should truncate overlay to maxHeightPercent", async () => {
const terminal = new VirtualTerminal(80, 10);
const tui = new TUI(terminal);
// 10 lines in a 10 row terminal with 50% maxHeight should show 5 lines
const overlay = new StaticOverlay(["L1", "L2", "L3", "L4", "L5", "L6", "L7", "L8", "L9", "L10"]);
tui.addChild(new EmptyContent());
tui.showOverlay(overlay, { maxHeight: "50%" });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
const content = viewport.join("\n");
assert.ok(content.includes("L1"), "Should include L1");
assert.ok(content.includes("L5"), "Should include L5");
assert.ok(!content.includes("L6"), "Should NOT include L6");
tui.stop();
});
});
describe("absolute positioning", () => {
it("row and col should override anchor", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const overlay = new StaticOverlay(["ABSOLUTE"]);
tui.addChild(new EmptyContent());
// Even with bottom-right anchor, row/col should win
tui.showOverlay(overlay, { anchor: "bottom-right", row: 3, col: 5, width: 10 });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
assert.ok(viewport[3]?.includes("ABSOLUTE"), `Expected ABSOLUTE on row 3, got: ${viewport[3]}`);
const colIndex = viewport[3]?.indexOf("ABSOLUTE") ?? -1;
assert.strictEqual(colIndex, 5, `Expected col 5, got ${colIndex}`);
tui.stop();
});
});
describe("stacked overlays", () => {
it("should render multiple overlays with later ones on top", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
tui.addChild(new EmptyContent());
// First overlay at top-left
const overlay1 = new StaticOverlay(["FIRST-OVERLAY"]);
tui.showOverlay(overlay1, { anchor: "top-left", width: 20 });
// Second overlay at top-left (should cover part of first)
const overlay2 = new StaticOverlay(["SECOND"]);
tui.showOverlay(overlay2, { anchor: "top-left", width: 10 });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
// Second overlay should be visible (on top)
assert.ok(viewport[0]?.includes("SECOND"), `Expected SECOND on row 0, got: ${viewport[0]}`);
// Part of first overlay might still be visible after SECOND
// FIRST-OVERLAY is 13 chars, SECOND is 6 chars, so "OVERLAY" part might show
tui.stop();
});
it("should handle overlays at different positions without interference", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
tui.addChild(new EmptyContent());
// Overlay at top-left
const overlay1 = new StaticOverlay(["TOP-LEFT"]);
tui.showOverlay(overlay1, { anchor: "top-left", width: 15 });
// Overlay at bottom-right
const overlay2 = new StaticOverlay(["BTM-RIGHT"]);
tui.showOverlay(overlay2, { anchor: "bottom-right", width: 15 });
tui.start();
await renderAndFlush(tui, terminal);
const viewport = terminal.getViewport();
// Both should be visible
assert.ok(viewport[0]?.includes("TOP-LEFT"), `Expected TOP-LEFT on row 0, got: ${viewport[0]}`);
assert.ok(viewport[23]?.includes("BTM-RIGHT"), `Expected BTM-RIGHT on row 23, got: ${viewport[23]}`);
tui.stop();
});
it("should properly hide overlays in stack order", async () => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
tui.addChild(new EmptyContent());
// Show two overlays
const overlay1 = new StaticOverlay(["FIRST"]);
tui.showOverlay(overlay1, { anchor: "top-left", width: 10 });
const overlay2 = new StaticOverlay(["SECOND"]);
tui.showOverlay(overlay2, { anchor: "top-left", width: 10 });
tui.start();
await renderAndFlush(tui, terminal);
// Second should be visible
let viewport = terminal.getViewport();
assert.ok(viewport[0]?.includes("SECOND"), "SECOND should be visible initially");
// Hide top overlay
tui.hideOverlay();
await renderAndFlush(tui, terminal);
// First should now be visible
viewport = terminal.getViewport();
assert.ok(viewport[0]?.includes("FIRST"), "FIRST should be visible after hiding SECOND");
tui.stop();
});
});
});
@@ -0,0 +1,56 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { type Component, TUI } from "../src/tui.js";
import { VirtualTerminal } from "./virtual-terminal.js";
class SimpleContent implements Component {
constructor(private lines: string[]) {}
render(): string[] {
return this.lines;
}
invalidate() {}
}
class SimpleOverlay implements Component {
render(): string[] {
return ["OVERLAY_TOP", "OVERLAY_MID", "OVERLAY_BOT"];
}
invalidate() {}
}
describe("TUI overlay with short content", () => {
it("should render overlay when content is shorter than terminal height", async () => {
// Terminal has 24 rows, but content only has 3 lines
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
// Only 3 lines of content
tui.addChild(new SimpleContent(["Line 1", "Line 2", "Line 3"]));
// Show overlay centered - should be around row 10 in a 24-row terminal
const overlay = new SimpleOverlay();
tui.showOverlay(overlay);
// Trigger render
tui.start();
await terminal.waitForRender();
const viewport = terminal.getViewport();
const hasOverlay = viewport.some((line) => line.includes("OVERLAY"));
console.log("Terminal rows:", terminal.rows);
console.log("Content lines: 3");
console.log("Overlay visible:", hasOverlay);
if (!hasOverlay) {
console.log("\nViewport contents:");
for (let i = 0; i < viewport.length; i++) {
console.log(` [${i}]: "${viewport[i]}"`);
}
}
assert.ok(hasOverlay, "Overlay should be visible when content is shorter than terminal");
tui.stop();
});
});
@@ -0,0 +1,52 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { visibleWidth, wrapTextWithAnsi } from "../src/utils.js";
describe("regional indicator width regression", () => {
it("treats partial flag grapheme as full-width to avoid streaming render drift", () => {
// Repro context:
// During streaming, "🇨🇳" often appears as an intermediate "🇨" first.
// If "🇨" is measured as width 1 while terminal renders it as width 2,
// differential rendering can drift and leave stale characters on screen.
const partialFlag = "🇨";
const listLine = " - 🇨";
assert.strictEqual(visibleWidth(partialFlag), 2);
assert.strictEqual(visibleWidth(listLine), 10);
});
it("wraps intermediate partial-flag list line before overflow", () => {
// Width 9 cannot fit " - 🇨" if 🇨 is width 2 (8 + 2 = 10).
// This must wrap to avoid terminal auto-wrap mismatch.
const wrapped = wrapTextWithAnsi(" - 🇨", 9);
assert.strictEqual(wrapped.length, 2);
assert.strictEqual(visibleWidth(wrapped[0] || ""), 7);
assert.strictEqual(visibleWidth(wrapped[1] || ""), 2);
});
it("treats all regional-indicator singleton graphemes as width 2", () => {
for (let cp = 0x1f1e6; cp <= 0x1f1ff; cp++) {
const regionalIndicator = String.fromCodePoint(cp);
assert.strictEqual(
visibleWidth(regionalIndicator),
2,
`Expected ${regionalIndicator} (U+${cp.toString(16).toUpperCase()}) to be width 2`,
);
}
});
it("keeps full flag pairs at width 2", () => {
const samples = ["🇯🇵", "🇺🇸", "🇬🇧", "🇨🇳", "🇩🇪", "🇫🇷"];
for (const flag of samples) {
assert.strictEqual(visibleWidth(flag), 2, `Expected ${flag} to be width 2`);
}
});
it("keeps common streaming emoji intermediates at stable width", () => {
const samples = ["👍", "👍🏻", "✅", "⚡", "⚡️", "👨", "👨‍💻", "🏳️‍🌈"];
for (const sample of samples) {
assert.strictEqual(visibleWidth(sample), 2, `Expected ${sample} to be width 2`);
}
});
});
@@ -0,0 +1,116 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { SelectList } from "../src/components/select-list.js";
import { visibleWidth } from "../src/utils.js";
const testTheme = {
selectedPrefix: (text: string) => text,
selectedText: (text: string) => text,
description: (text: string) => text,
scrollInfo: (text: string) => text,
noMatch: (text: string) => text,
};
const visibleIndexOf = (line: string, text: string): number => {
const index = line.indexOf(text);
assert.notEqual(index, -1);
return visibleWidth(line.slice(0, index));
};
describe("SelectList", () => {
it("normalizes multiline descriptions to single line", () => {
const items = [
{
value: "test",
label: "test",
description: "Line one\nLine two\nLine three",
},
];
const list = new SelectList(items, 5, testTheme);
const rendered = list.render(100);
assert.ok(rendered.length > 0);
assert.ok(!rendered[0].includes("\n"));
assert.ok(rendered[0].includes("Line one Line two Line three"));
});
it("keeps descriptions aligned when the primary text is truncated", () => {
const items = [
{ value: "short", label: "short", description: "short description" },
{
value: "very-long-command-name-that-needs-truncation",
label: "very-long-command-name-that-needs-truncation",
description: "long description",
},
];
const list = new SelectList(items, 5, testTheme);
const rendered = list.render(80);
assert.equal(visibleIndexOf(rendered[0], "short description"), visibleIndexOf(rendered[1], "long description"));
});
it("uses the configured minimum primary column width", () => {
const items = [
{ value: "a", label: "a", description: "first" },
{ value: "bb", label: "bb", description: "second" },
];
const list = new SelectList(items, 5, testTheme, {
minPrimaryColumnWidth: 12,
maxPrimaryColumnWidth: 20,
});
const rendered = list.render(80);
assert.equal(rendered[0].indexOf("first"), 14);
assert.equal(rendered[1].indexOf("second"), 14);
});
it("uses the configured maximum primary column width", () => {
const items = [
{
value: "very-long-command-name-that-needs-truncation",
label: "very-long-command-name-that-needs-truncation",
description: "first",
},
{ value: "short", label: "short", description: "second" },
];
const list = new SelectList(items, 5, testTheme, {
minPrimaryColumnWidth: 12,
maxPrimaryColumnWidth: 20,
});
const rendered = list.render(80);
assert.equal(visibleIndexOf(rendered[0], "first"), 22);
assert.equal(visibleIndexOf(rendered[1], "second"), 22);
});
it("allows overriding primary truncation while preserving description alignment", () => {
const items = [
{
value: "very-long-command-name-that-needs-truncation",
label: "very-long-command-name-that-needs-truncation",
description: "first",
},
{ value: "short", label: "short", description: "second" },
];
const list = new SelectList(items, 5, testTheme, {
minPrimaryColumnWidth: 12,
maxPrimaryColumnWidth: 12,
truncatePrimary: ({ text, maxWidth }) => {
if (text.length <= maxWidth) {
return text;
}
return `${text.slice(0, Math.max(0, maxWidth - 1))}…`;
},
});
const rendered = list.render(80);
assert.ok(rendered[0].includes("…"));
assert.equal(visibleIndexOf(rendered[0], "first"), visibleIndexOf(rendered[1], "second"));
});
});
@@ -0,0 +1,422 @@
/**
* Tests for StdinBuffer
*
* Based on code from OpenTUI (https://github.com/anomalyco/opentui)
* MIT License - Copyright (c) 2025 opentui
*/
import assert from "node:assert";
import { beforeEach, describe, it } from "node:test";
import { StdinBuffer } from "../src/stdin-buffer.js";
describe("StdinBuffer", () => {
let buffer: StdinBuffer;
let emittedSequences: string[];
beforeEach(() => {
buffer = new StdinBuffer({ timeout: 10 });
// Collect emitted sequences
emittedSequences = [];
buffer.on("data", (sequence) => {
emittedSequences.push(sequence);
});
});
// Helper to process data through the buffer
function processInput(data: string | Buffer): void {
buffer.process(data);
}
// Helper to wait for async operations
async function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
describe("Regular Characters", () => {
it("should pass through regular characters immediately", () => {
processInput("a");
assert.deepStrictEqual(emittedSequences, ["a"]);
});
it("should pass through multiple regular characters", () => {
processInput("abc");
assert.deepStrictEqual(emittedSequences, ["a", "b", "c"]);
});
it("should handle unicode characters", () => {
processInput("hello 世界");
assert.deepStrictEqual(emittedSequences, ["h", "e", "l", "l", "o", " ", "世", "界"]);
});
});
describe("Complete Escape Sequences", () => {
it("should pass through complete mouse SGR sequences", () => {
const mouseSeq = "\x1b[<35;20;5m";
processInput(mouseSeq);
assert.deepStrictEqual(emittedSequences, [mouseSeq]);
});
it("should pass through complete arrow key sequences", () => {
const upArrow = "\x1b[A";
processInput(upArrow);
assert.deepStrictEqual(emittedSequences, [upArrow]);
});
it("should pass through complete function key sequences", () => {
const f1 = "\x1b[11~";
processInput(f1);
assert.deepStrictEqual(emittedSequences, [f1]);
});
it("should pass through meta key sequences", () => {
const metaA = "\x1ba";
processInput(metaA);
assert.deepStrictEqual(emittedSequences, [metaA]);
});
it("should pass through SS3 sequences", () => {
const ss3 = "\x1bOA";
processInput(ss3);
assert.deepStrictEqual(emittedSequences, [ss3]);
});
});
describe("Partial Escape Sequences", () => {
it("should buffer incomplete mouse SGR sequence", async () => {
processInput("\x1b");
assert.deepStrictEqual(emittedSequences, []);
assert.strictEqual(buffer.getBuffer(), "\x1b");
processInput("[<35");
assert.deepStrictEqual(emittedSequences, []);
assert.strictEqual(buffer.getBuffer(), "\x1b[<35");
processInput(";20;5m");
assert.deepStrictEqual(emittedSequences, ["\x1b[<35;20;5m"]);
assert.strictEqual(buffer.getBuffer(), "");
});
it("should buffer incomplete CSI sequence", () => {
processInput("\x1b[");
assert.deepStrictEqual(emittedSequences, []);
processInput("1;");
assert.deepStrictEqual(emittedSequences, []);
processInput("5H");
assert.deepStrictEqual(emittedSequences, ["\x1b[1;5H"]);
});
it("should buffer split across many chunks", () => {
processInput("\x1b");
processInput("[");
processInput("<");
processInput("3");
processInput("5");
processInput(";");
processInput("2");
processInput("0");
processInput(";");
processInput("5");
processInput("m");
assert.deepStrictEqual(emittedSequences, ["\x1b[<35;20;5m"]);
});
it("should flush incomplete sequence after timeout", async () => {
processInput("\x1b[<35");
assert.deepStrictEqual(emittedSequences, []);
// Wait for timeout
await wait(15);
assert.deepStrictEqual(emittedSequences, ["\x1b[<35"]);
});
});
describe("Mixed Content", () => {
it("should handle characters followed by escape sequence", () => {
processInput("abc\x1b[A");
assert.deepStrictEqual(emittedSequences, ["a", "b", "c", "\x1b[A"]);
});
it("should handle escape sequence followed by characters", () => {
processInput("\x1b[Aabc");
assert.deepStrictEqual(emittedSequences, ["\x1b[A", "a", "b", "c"]);
});
it("should handle multiple complete sequences", () => {
processInput("\x1b[A\x1b[B\x1b[C");
assert.deepStrictEqual(emittedSequences, ["\x1b[A", "\x1b[B", "\x1b[C"]);
});
it("should handle partial sequence with preceding characters", () => {
processInput("abc\x1b[<35");
assert.deepStrictEqual(emittedSequences, ["a", "b", "c"]);
assert.strictEqual(buffer.getBuffer(), "\x1b[<35");
processInput(";20;5m");
assert.deepStrictEqual(emittedSequences, ["a", "b", "c", "\x1b[<35;20;5m"]);
});
});
describe("Kitty Keyboard Protocol", () => {
it("should handle Kitty CSI u press events", () => {
// Press 'a' in Kitty protocol
processInput("\x1b[97u");
assert.deepStrictEqual(emittedSequences, ["\x1b[97u"]);
});
it("should handle Kitty CSI u release events", () => {
// Release 'a' in Kitty protocol
processInput("\x1b[97;1:3u");
assert.deepStrictEqual(emittedSequences, ["\x1b[97;1:3u"]);
});
it("should handle batched Kitty press and release", () => {
// Press 'a', release 'a' batched together (common over SSH)
processInput("\x1b[97u\x1b[97;1:3u");
assert.deepStrictEqual(emittedSequences, ["\x1b[97u", "\x1b[97;1:3u"]);
});
it("should handle multiple batched Kitty events", () => {
// Press 'a', release 'a', press 'b', release 'b'
processInput("\x1b[97u\x1b[97;1:3u\x1b[98u\x1b[98;1:3u");
assert.deepStrictEqual(emittedSequences, ["\x1b[97u", "\x1b[97;1:3u", "\x1b[98u", "\x1b[98;1:3u"]);
});
it("should handle Kitty arrow keys with event type", () => {
// Up arrow press with event type
processInput("\x1b[1;1:1A");
assert.deepStrictEqual(emittedSequences, ["\x1b[1;1:1A"]);
});
it("should handle Kitty functional keys with event type", () => {
// Delete key release
processInput("\x1b[3;1:3~");
assert.deepStrictEqual(emittedSequences, ["\x1b[3;1:3~"]);
});
it("should handle plain characters mixed with Kitty sequences", () => {
// Plain 'a' followed by Kitty release
processInput("a\x1b[97;1:3u");
assert.deepStrictEqual(emittedSequences, ["a", "\x1b[97;1:3u"]);
});
it("should handle Kitty sequence followed by plain characters", () => {
processInput("\x1b[97ua");
assert.deepStrictEqual(emittedSequences, ["\x1b[97u", "a"]);
});
it("should handle rapid typing simulation with Kitty protocol", () => {
// Simulates typing "hi" quickly with releases interleaved
processInput("\x1b[104u\x1b[104;1:3u\x1b[105u\x1b[105;1:3u");
assert.deepStrictEqual(emittedSequences, ["\x1b[104u", "\x1b[104;1:3u", "\x1b[105u", "\x1b[105;1:3u"]);
});
});
describe("Mouse Events", () => {
it("should handle mouse press event", () => {
processInput("\x1b[<0;10;5M");
assert.deepStrictEqual(emittedSequences, ["\x1b[<0;10;5M"]);
});
it("should handle mouse release event", () => {
processInput("\x1b[<0;10;5m");
assert.deepStrictEqual(emittedSequences, ["\x1b[<0;10;5m"]);
});
it("should handle mouse move event", () => {
processInput("\x1b[<35;20;5m");
assert.deepStrictEqual(emittedSequences, ["\x1b[<35;20;5m"]);
});
it("should handle split mouse events", () => {
processInput("\x1b[<3");
processInput("5;1");
processInput("5;");
processInput("10m");
assert.deepStrictEqual(emittedSequences, ["\x1b[<35;15;10m"]);
});
it("should handle multiple mouse events", () => {
processInput("\x1b[<35;1;1m\x1b[<35;2;2m\x1b[<35;3;3m");
assert.deepStrictEqual(emittedSequences, ["\x1b[<35;1;1m", "\x1b[<35;2;2m", "\x1b[<35;3;3m"]);
});
it("should handle old-style mouse sequence (ESC[M + 3 bytes)", () => {
processInput("\x1b[M abc");
assert.deepStrictEqual(emittedSequences, ["\x1b[M ab", "c"]);
});
it("should buffer incomplete old-style mouse sequence", () => {
processInput("\x1b[M");
assert.strictEqual(buffer.getBuffer(), "\x1b[M");
processInput(" a");
assert.strictEqual(buffer.getBuffer(), "\x1b[M a");
processInput("b");
assert.deepStrictEqual(emittedSequences, ["\x1b[M ab"]);
});
});
describe("Edge Cases", () => {
it("should handle empty input", () => {
processInput("");
// Empty string emits an empty data event
assert.deepStrictEqual(emittedSequences, [""]);
});
it("should handle lone escape character with timeout", async () => {
processInput("\x1b");
assert.deepStrictEqual(emittedSequences, []);
// After timeout, should emit
await wait(15);
assert.deepStrictEqual(emittedSequences, ["\x1b"]);
});
it("should handle lone escape character with explicit flush", () => {
processInput("\x1b");
assert.deepStrictEqual(emittedSequences, []);
const flushed = buffer.flush();
assert.deepStrictEqual(flushed, ["\x1b"]);
});
it("should handle buffer input", () => {
processInput(Buffer.from("\x1b[A"));
assert.deepStrictEqual(emittedSequences, ["\x1b[A"]);
});
it("should handle very long sequences", () => {
const longSeq = `\x1b[${"1;".repeat(50)}H`;
processInput(longSeq);
assert.deepStrictEqual(emittedSequences, [longSeq]);
});
});
describe("Flush", () => {
it("should flush incomplete sequences", () => {
processInput("\x1b[<35");
const flushed = buffer.flush();
assert.deepStrictEqual(flushed, ["\x1b[<35"]);
assert.strictEqual(buffer.getBuffer(), "");
});
it("should return empty array if nothing to flush", () => {
const flushed = buffer.flush();
assert.deepStrictEqual(flushed, []);
});
it("should emit flushed data via timeout", async () => {
processInput("\x1b[<35");
assert.deepStrictEqual(emittedSequences, []);
// Wait for timeout to flush
await wait(15);
assert.deepStrictEqual(emittedSequences, ["\x1b[<35"]);
});
});
describe("Clear", () => {
it("should clear buffered content without emitting", () => {
processInput("\x1b[<35");
assert.strictEqual(buffer.getBuffer(), "\x1b[<35");
buffer.clear();
assert.strictEqual(buffer.getBuffer(), "");
assert.deepStrictEqual(emittedSequences, []);
});
});
describe("Bracketed Paste", () => {
let emittedPaste: string[] = [];
beforeEach(() => {
buffer = new StdinBuffer({ timeout: 10 });
// Collect emitted sequences
emittedSequences = [];
buffer.on("data", (sequence) => {
emittedSequences.push(sequence);
});
// Collect paste events
emittedPaste = [];
buffer.on("paste", (data) => {
emittedPaste.push(data);
});
});
it("should emit paste event for complete bracketed paste", () => {
const pasteStart = "\x1b[200~";
const pasteEnd = "\x1b[201~";
const content = "hello world";
processInput(pasteStart + content + pasteEnd);
assert.deepStrictEqual(emittedPaste, ["hello world"]);
assert.deepStrictEqual(emittedSequences, []); // No data events during paste
});
it("should handle paste arriving in chunks", () => {
processInput("\x1b[200~");
assert.deepStrictEqual(emittedPaste, []);
processInput("hello ");
assert.deepStrictEqual(emittedPaste, []);
processInput("world\x1b[201~");
assert.deepStrictEqual(emittedPaste, ["hello world"]);
assert.deepStrictEqual(emittedSequences, []);
});
it("should handle paste with input before and after", () => {
processInput("a");
processInput("\x1b[200~pasted\x1b[201~");
processInput("b");
assert.deepStrictEqual(emittedSequences, ["a", "b"]);
assert.deepStrictEqual(emittedPaste, ["pasted"]);
});
it("should handle paste with newlines", () => {
processInput("\x1b[200~line1\nline2\nline3\x1b[201~");
assert.deepStrictEqual(emittedPaste, ["line1\nline2\nline3"]);
assert.deepStrictEqual(emittedSequences, []);
});
it("should handle paste with unicode", () => {
processInput("\x1b[200~Hello 世界 🎉\x1b[201~");
assert.deepStrictEqual(emittedPaste, ["Hello 世界 🎉"]);
assert.deepStrictEqual(emittedSequences, []);
});
});
describe("Destroy", () => {
it("should clear buffer on destroy", () => {
processInput("\x1b[<35");
assert.strictEqual(buffer.getBuffer(), "\x1b[<35");
buffer.destroy();
assert.strictEqual(buffer.getBuffer(), "");
});
it("should clear pending timeouts on destroy", async () => {
processInput("\x1b[<35");
buffer.destroy();
// Wait longer than timeout
await wait(15);
// Should not have emitted anything
assert.deepStrictEqual(emittedSequences, []);
});
});
});
@@ -0,0 +1,279 @@
/**
* Tests for terminal image detection and line handling
*/
import assert from "node:assert";
import { describe, it } from "node:test";
import { detectCapabilities, hyperlink, isImageLine } from "../src/terminal-image.js";
const ENV_KEYS = [
"TERM",
"TERM_PROGRAM",
"COLORTERM",
"TMUX",
"KITTY_WINDOW_ID",
"GHOSTTY_RESOURCES_DIR",
"WEZTERM_PANE",
"ITERM_SESSION_ID",
] as const;
function withEnv(overrides: Record<string, string | undefined>, fn: () => void): void {
const saved: Record<string, string | undefined> = {};
for (const key of ENV_KEYS) {
saved[key] = process.env[key];
delete process.env[key];
}
try {
for (const [k, v] of Object.entries(overrides)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
fn();
} finally {
for (const key of ENV_KEYS) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
}
}
describe("isImageLine", () => {
describe("iTerm2 image protocol", () => {
it("should detect iTerm2 image escape sequence at start of line", () => {
// iTerm2 image escape sequence: ESC ]1337;File=...
const iterm2ImageLine = "\x1b]1337;File=size=100,100;inline=1:base64encodeddata==\x07";
assert.strictEqual(isImageLine(iterm2ImageLine), true);
});
it("should detect iTerm2 image escape sequence with text before it", () => {
// Simulating a line that has text then image data (bug scenario)
const lineWithTextAndImage = "Some text \x1b]1337;File=size=100,100;inline=1:base64data==\x07 more text";
assert.strictEqual(isImageLine(lineWithTextAndImage), true);
});
it("should detect iTerm2 image escape sequence in middle of long line", () => {
// Simulate a very long line with image data in the middle
const longLineWithImage =
"Text before image..." + "\x1b]1337;File=inline=1:verylongbase64data==" + "...text after";
assert.strictEqual(isImageLine(longLineWithImage), true);
});
it("should detect iTerm2 image escape sequence at end of line", () => {
const lineWithImageAtEnd = "Regular text ending with \x1b]1337;File=inline=1:base64data==\x07";
assert.strictEqual(isImageLine(lineWithImageAtEnd), true);
});
it("should detect minimal iTerm2 image escape sequence", () => {
const minimalImageLine = "\x1b]1337;File=:\x07";
assert.strictEqual(isImageLine(minimalImageLine), true);
});
});
describe("Kitty image protocol", () => {
it("should detect Kitty image escape sequence at start of line", () => {
// Kitty image escape sequence: ESC _G
const kittyImageLine = "\x1b_Ga=T,f=100,t=f,d=base64data...\x1b\\\x1b_Gm=i=1;\x1b\\";
assert.strictEqual(isImageLine(kittyImageLine), true);
});
it("should detect Kitty image escape sequence with text before it", () => {
// Bug scenario: text + image data in same line
const lineWithTextAndKittyImage = "Output: \x1b_Ga=T,f=100;data...\x1b\\\x1b_Gm=i=1;\x1b\\";
assert.strictEqual(isImageLine(lineWithTextAndKittyImage), true);
});
it("should detect Kitty image escape sequence with padding", () => {
// Kitty protocol adds padding to escape sequences
const kittyWithPadding = " \x1b_Ga=T,f=100...\x1b\\\x1b_Gm=i=1;\x1b\\ ";
assert.strictEqual(isImageLine(kittyWithPadding), true);
});
});
describe("Bug regression tests", () => {
it("should detect image sequences in very long lines (304k+ chars)", () => {
// This simulates the crash scenario: a line with 304,401 chars
// containing image escape sequences somewhere
const base64Char = "A".repeat(100); // 100 chars of base64-like data
const imageSequence = "\x1b]1337;File=size=800,600;inline=1:";
// Build a long line with image sequence
const longLine =
"Text prefix " +
imageSequence +
base64Char.repeat(3000) + // ~300,000 chars
" suffix";
assert.strictEqual(longLine.length > 300000, true);
assert.strictEqual(isImageLine(longLine), true);
});
it("should detect image sequences when terminal doesn't support images", () => {
// The bug occurred when getImageEscapePrefix() returned null
// isImageLine should still detect image sequences regardless
const lineWithImage = "Read image file [image/jpeg]\x1b]1337;File=inline=1:base64data==\x07";
assert.strictEqual(isImageLine(lineWithImage), true);
});
it("should detect image sequences with ANSI codes before them", () => {
// Text might have ANSI styling before image data
const lineWithAnsiAndImage = "\x1b[31mError output \x1b]1337;File=inline=1:image==\x07";
assert.strictEqual(isImageLine(lineWithAnsiAndImage), true);
});
it("should detect image sequences with ANSI codes after them", () => {
const lineWithImageAndAnsi = "\x1b_Ga=T,f=100:data...\x1b\\\x1b_Gm=i=1;\x1b\\\x1b[0m reset";
assert.strictEqual(isImageLine(lineWithImageAndAnsi), true);
});
});
describe("Negative cases - lines without images", () => {
it("should not detect images in plain text lines", () => {
const plainText = "This is just a regular text line without any escape sequences";
assert.strictEqual(isImageLine(plainText), false);
});
it("should not detect images in lines with only ANSI codes", () => {
const ansiText = "\x1b[31mRed text\x1b[0m and \x1b[32mgreen text\x1b[0m";
assert.strictEqual(isImageLine(ansiText), false);
});
it("should not detect images in lines with cursor movement codes", () => {
const cursorCodes = "\x1b[1A\x1b[2KLine cleared and moved up";
assert.strictEqual(isImageLine(cursorCodes), false);
});
it("should not detect images in lines with partial iTerm2 sequences", () => {
// Similar prefix but missing the complete sequence
const partialSequence = "Some text with ]1337;File but missing ESC at start";
assert.strictEqual(isImageLine(partialSequence), false);
});
it("should not detect images in lines with partial Kitty sequences", () => {
// Similar prefix but missing the complete sequence
const partialSequence = "Some text with _G but missing ESC at start";
assert.strictEqual(isImageLine(partialSequence), false);
});
it("should not detect images in empty lines", () => {
assert.strictEqual(isImageLine(""), false);
});
it("should not detect images in lines with newlines only", () => {
assert.strictEqual(isImageLine("\n"), false);
assert.strictEqual(isImageLine("\n\n"), false);
});
});
describe("Mixed content scenarios", () => {
it("should detect images when line has both Kitty and iTerm2 sequences", () => {
const mixedLine = "Kitty: \x1b_Ga=T...\x1b\\\x1b_Gm=i=1;\x1b\\ iTerm2: \x1b]1337;File=inline=1:data==\x07";
assert.strictEqual(isImageLine(mixedLine), true);
});
it("should detect image in line with multiple text and image segments", () => {
const complexLine = "Start \x1b]1337;File=img1==\x07 middle \x1b]1337;File=img2==\x07 end";
assert.strictEqual(isImageLine(complexLine), true);
});
it("should not falsely detect image in line with file path containing keywords", () => {
// File path might contain "1337" or "File" but without escape sequences
const filePathLine = "/path/to/File_1337_backup/image.jpg";
assert.strictEqual(isImageLine(filePathLine), false);
});
});
});
describe("detectCapabilities", () => {
it("defaults to hyperlinks: false for unknown terminals", () => {
withEnv({}, () => {
const caps = detectCapabilities();
assert.strictEqual(caps.hyperlinks, false);
assert.strictEqual(caps.images, null);
});
});
it("forces hyperlinks: false under tmux even if outer terminal supports OSC 8", () => {
withEnv({ TMUX: "/tmp/tmux-1000/default,1234,0", TERM_PROGRAM: "ghostty" }, () => {
const caps = detectCapabilities();
assert.strictEqual(caps.hyperlinks, false);
assert.strictEqual(caps.images, null);
});
});
it("forces hyperlinks: false when TERM starts with 'tmux'", () => {
withEnv({ TERM: "tmux-256color", TERM_PROGRAM: "iterm.app" }, () => {
const caps = detectCapabilities();
assert.strictEqual(caps.hyperlinks, false);
assert.strictEqual(caps.images, null);
});
});
it("forces hyperlinks: false when TERM starts with 'screen'", () => {
withEnv({ TERM: "screen-256color" }, () => {
const caps = detectCapabilities();
assert.strictEqual(caps.hyperlinks, false);
assert.strictEqual(caps.images, null);
});
});
it("enables hyperlinks for Ghostty", () => {
withEnv({ TERM_PROGRAM: "ghostty" }, () => {
const caps = detectCapabilities();
assert.strictEqual(caps.hyperlinks, true);
});
});
it("enables hyperlinks for Kitty", () => {
withEnv({ KITTY_WINDOW_ID: "1" }, () => {
const caps = detectCapabilities();
assert.strictEqual(caps.hyperlinks, true);
});
});
it("enables hyperlinks for WezTerm", () => {
withEnv({ WEZTERM_PANE: "0" }, () => {
const caps = detectCapabilities();
assert.strictEqual(caps.hyperlinks, true);
});
});
it("enables hyperlinks for iTerm2", () => {
withEnv({ TERM_PROGRAM: "iterm.app" }, () => {
const caps = detectCapabilities();
assert.strictEqual(caps.hyperlinks, true);
});
});
it("enables hyperlinks for VSCode", () => {
withEnv({ TERM_PROGRAM: "vscode" }, () => {
const caps = detectCapabilities();
assert.strictEqual(caps.hyperlinks, true);
});
});
});
describe("hyperlink", () => {
it("wraps text in OSC 8 open and close sequences", () => {
const result = hyperlink("click me", "https://example.com");
assert.strictEqual(result, "\x1b]8;;https://example.com\x1b\\click me\x1b]8;;\x1b\\");
});
it("preserves ANSI styling inside the hyperlink", () => {
const styled = "\x1b[4m\x1b[34mclick me\x1b[0m";
const result = hyperlink(styled, "https://example.com");
assert.ok(result.startsWith("\x1b]8;;https://example.com\x1b\\"));
assert.ok(result.includes(styled));
assert.ok(result.endsWith("\x1b]8;;\x1b\\"));
});
it("works with empty text", () => {
const result = hyperlink("", "https://example.com");
assert.strictEqual(result, "\x1b]8;;https://example.com\x1b\\\x1b]8;;\x1b\\");
});
it("works with file:// URIs", () => {
const result = hyperlink("README.md", "file:///home/user/README.md");
assert.ok(result.includes("file:///home/user/README.md"));
assert.ok(result.includes("README.md"));
});
});
@@ -0,0 +1,38 @@
/**
* Default themes for TUI tests using chalk
*/
import { Chalk } from "chalk";
import type { EditorTheme, MarkdownTheme, SelectListTheme } from "../src/index.js";
const chalk = new Chalk({ level: 3 });
export const defaultSelectListTheme: SelectListTheme = {
selectedPrefix: (text: string) => chalk.blue(text),
selectedText: (text: string) => chalk.bold(text),
description: (text: string) => chalk.dim(text),
scrollInfo: (text: string) => chalk.dim(text),
noMatch: (text: string) => chalk.dim(text),
};
export const defaultMarkdownTheme: MarkdownTheme = {
heading: (text: string) => chalk.bold.cyan(text),
link: (text: string) => chalk.blue(text),
linkUrl: (text: string) => chalk.dim(text),
code: (text: string) => chalk.yellow(text),
codeBlock: (text: string) => chalk.green(text),
codeBlockBorder: (text: string) => chalk.dim(text),
quote: (text: string) => chalk.italic(text),
quoteBorder: (text: string) => chalk.dim(text),
hr: (text: string) => chalk.dim(text),
listBullet: (text: string) => chalk.cyan(text),
bold: (text: string) => chalk.bold(text),
italic: (text: string) => chalk.italic(text),
strikethrough: (text: string) => chalk.strikethrough(text),
underline: (text: string) => chalk.underline(text),
};
export const defaultEditorTheme: EditorTheme = {
borderColor: (text: string) => chalk.dim(text),
selectList: defaultSelectListTheme,
};
@@ -0,0 +1,62 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { truncateToWidth, visibleWidth } from "../src/utils.js";
describe("truncateToWidth", () => {
it("keeps output within width for very large unicode input", () => {
const text = "🙂界".repeat(100_000);
const truncated = truncateToWidth(text, 40, "…");
assert.ok(visibleWidth(truncated) <= 40);
assert.strictEqual(truncated.endsWith("…\x1b[0m"), true);
});
it("preserves ANSI styling for kept text and resets before and after ellipsis", () => {
const text = `\x1b[31m${"hello ".repeat(1000)}\x1b[0m`;
const truncated = truncateToWidth(text, 20, "…");
assert.ok(visibleWidth(truncated) <= 20);
assert.strictEqual(truncated.includes("\x1b[31m"), true);
assert.strictEqual(truncated.endsWith("\x1b[0m…\x1b[0m"), true);
});
it("handles malformed ANSI escape prefixes without hanging", () => {
const text = `abc\x1bnot-ansi ${"🙂".repeat(1000)}`;
const truncated = truncateToWidth(text, 20, "…");
assert.ok(visibleWidth(truncated) <= 20);
});
it("clips wide ellipsis safely and brackets it with resets", () => {
assert.strictEqual(truncateToWidth("abcdef", 1, "🙂"), "");
assert.strictEqual(truncateToWidth("abcdef", 2, "🙂"), "\x1b[0m🙂\x1b[0m");
assert.ok(visibleWidth(truncateToWidth("abcdef", 2, "🙂")) <= 2);
});
it("returns the original text when it already fits even if ellipsis is too wide", () => {
assert.strictEqual(truncateToWidth("a", 2, "🙂"), "a");
assert.strictEqual(truncateToWidth("界", 2, "🙂"), "界");
});
it("pads truncated output to requested width", () => {
const truncated = truncateToWidth("🙂界🙂界🙂界", 8, "…", true);
assert.strictEqual(visibleWidth(truncated), 8);
});
it("adds a trailing reset when truncating without an ellipsis", () => {
const truncated = truncateToWidth(`\x1b[31m${"hello".repeat(100)}`, 10, "");
assert.ok(visibleWidth(truncated) <= 10);
assert.strictEqual(truncated.endsWith("\x1b[0m"), true);
});
it("keeps a contiguous prefix instead of skipping a wide grapheme and resuming later", () => {
const truncated = truncateToWidth("🙂\t界 \x1b_abc\x07", 7, "…", true);
assert.strictEqual(truncated, "🙂\t\x1b[0m…\x1b[0m ");
});
});
describe("visibleWidth", () => {
it("counts tabs inline and skips ANSI inline", () => {
assert.strictEqual(visibleWidth("\t\x1b[31m界\x1b[0m"), 5);
});
});
@@ -0,0 +1,129 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { Chalk } from "chalk";
import { TruncatedText } from "../src/components/truncated-text.js";
import { visibleWidth } from "../src/utils.js";
// Force full color in CI so ANSI assertions are deterministic
const chalk = new Chalk({ level: 3 });
describe("TruncatedText component", () => {
it("pads output lines to exactly match width", () => {
const text = new TruncatedText("Hello world", 1, 0);
const lines = text.render(50);
// Should have exactly one content line (no vertical padding)
assert.strictEqual(lines.length, 1);
// Line should be exactly 50 visible characters
const visibleLen = visibleWidth(lines[0]);
assert.strictEqual(visibleLen, 50);
});
it("pads output with vertical padding lines to width", () => {
const text = new TruncatedText("Hello", 0, 2);
const lines = text.render(40);
// Should have 2 padding lines + 1 content line + 2 padding lines = 5 total
assert.strictEqual(lines.length, 5);
// All lines should be exactly 40 characters
for (const line of lines) {
assert.strictEqual(visibleWidth(line), 40);
}
});
it("truncates long text and pads to width", () => {
const longText = "This is a very long piece of text that will definitely exceed the available width";
const text = new TruncatedText(longText, 1, 0);
const lines = text.render(30);
assert.strictEqual(lines.length, 1);
// Should be exactly 30 characters
assert.strictEqual(visibleWidth(lines[0]), 30);
// Should contain ellipsis
const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, "");
assert.ok(stripped.includes("..."));
});
it("preserves ANSI codes in output and pads correctly", () => {
const styledText = `${chalk.red("Hello")} ${chalk.blue("world")}`;
const text = new TruncatedText(styledText, 1, 0);
const lines = text.render(40);
assert.strictEqual(lines.length, 1);
// Should be exactly 40 visible characters (ANSI codes don't count)
assert.strictEqual(visibleWidth(lines[0]), 40);
// Should preserve the color codes
assert.ok(lines[0].includes("\x1b["));
});
it("truncates styled text and adds reset code before ellipsis", () => {
const longStyledText = chalk.red("This is a very long red text that will be truncated");
const text = new TruncatedText(longStyledText, 1, 0);
const lines = text.render(20);
assert.strictEqual(lines.length, 1);
// Should be exactly 20 visible characters
assert.strictEqual(visibleWidth(lines[0]), 20);
// Should contain reset code before ellipsis
assert.ok(lines[0].includes("\x1b[0m..."));
});
it("handles text that fits exactly", () => {
// With paddingX=1, available width is 30-2=28
// "Hello world" is 11 chars, fits comfortably
const text = new TruncatedText("Hello world", 1, 0);
const lines = text.render(30);
assert.strictEqual(lines.length, 1);
assert.strictEqual(visibleWidth(lines[0]), 30);
// Should NOT contain ellipsis
const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, "");
assert.ok(!stripped.includes("..."));
});
it("handles empty text", () => {
const text = new TruncatedText("", 1, 0);
const lines = text.render(30);
assert.strictEqual(lines.length, 1);
assert.strictEqual(visibleWidth(lines[0]), 30);
});
it("stops at newline and only shows first line", () => {
const multilineText = "First line\nSecond line\nThird line";
const text = new TruncatedText(multilineText, 1, 0);
const lines = text.render(40);
assert.strictEqual(lines.length, 1);
assert.strictEqual(visibleWidth(lines[0]), 40);
// Should only contain "First line"
const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, "").trim();
assert.ok(stripped.includes("First line"));
assert.ok(!stripped.includes("Second line"));
assert.ok(!stripped.includes("Third line"));
});
it("truncates first line even with newlines in text", () => {
const longMultilineText = "This is a very long first line that needs truncation\nSecond line";
const text = new TruncatedText(longMultilineText, 1, 0);
const lines = text.render(25);
assert.strictEqual(lines.length, 1);
assert.strictEqual(visibleWidth(lines[0]), 25);
// Should contain ellipsis and not second line
const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, "");
assert.ok(stripped.includes("..."));
assert.ok(!stripped.includes("Second line"));
});
});
@@ -0,0 +1,81 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { getCellDimensions, resetCapabilitiesCache, setCellDimensions } from "../src/terminal-image.js";
import { type Component, TUI } from "../src/tui.js";
import { VirtualTerminal } from "./virtual-terminal.js";
class InputRecorder implements Component {
readonly inputs: string[] = [];
render(): string[] {
return [""];
}
handleInput(data: string): void {
this.inputs.push(data);
}
invalidate(): void {}
}
function withImageTerminal<T>(fn: () => T): T {
const prevTermProgram = process.env.TERM_PROGRAM;
const prevTerm = process.env.TERM;
const prevGhosttyResourcesDir = process.env.GHOSTTY_RESOURCES_DIR;
process.env.TERM_PROGRAM = "ghostty";
delete process.env.TERM;
delete process.env.GHOSTTY_RESOURCES_DIR;
resetCapabilitiesCache();
try {
return fn();
} finally {
if (prevTermProgram === undefined) delete process.env.TERM_PROGRAM;
else process.env.TERM_PROGRAM = prevTermProgram;
if (prevTerm === undefined) delete process.env.TERM;
else process.env.TERM = prevTerm;
if (prevGhosttyResourcesDir === undefined) delete process.env.GHOSTTY_RESOURCES_DIR;
else process.env.GHOSTTY_RESOURCES_DIR = prevGhosttyResourcesDir;
resetCapabilitiesCache();
}
}
describe("TUI cell size responses", () => {
it("forwards bare escape even when a cell size query was sent at startup", () => {
withImageTerminal(() => {
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const recorder = new InputRecorder();
tui.setFocus(recorder);
tui.start();
terminal.sendInput("\x1b");
assert.deepStrictEqual(recorder.inputs, ["\x1b"]);
tui.stop();
});
});
it("consumes cell size responses and still forwards later user input", () => {
withImageTerminal(() => {
setCellDimensions({ widthPx: 9, heightPx: 18 });
const terminal = new VirtualTerminal(80, 24);
const tui = new TUI(terminal);
const recorder = new InputRecorder();
tui.setFocus(recorder);
tui.start();
terminal.sendInput("\x1b[6;20;10t");
assert.deepStrictEqual(recorder.inputs, []);
assert.deepStrictEqual(getCellDimensions(), { widthPx: 10, heightPx: 20 });
terminal.sendInput("q");
assert.deepStrictEqual(recorder.inputs, ["q"]);
tui.stop();
});
});
});
@@ -0,0 +1,72 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import type { Terminal as XtermTerminalType } from "@xterm/headless";
import { type Component, TUI } from "../src/tui.js";
import { VirtualTerminal } from "./virtual-terminal.js";
class StaticLines implements Component {
constructor(private readonly lines: string[]) {}
render(): string[] {
return this.lines;
}
invalidate(): void {}
}
class StaticOverlay implements Component {
constructor(private readonly line: string) {}
render(): string[] {
return [this.line];
}
invalidate(): void {}
}
function getCellItalic(terminal: VirtualTerminal, row: number, col: number): number {
const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm;
const buffer = xterm.buffer.active;
const line = buffer.getLine(buffer.viewportY + row);
assert.ok(line, `Missing buffer line at row ${row}`);
const cell = line.getCell(col);
assert.ok(cell, `Missing cell at row ${row} col ${col}`);
return cell.isItalic();
}
async function renderAndFlush(tui: TUI, terminal: VirtualTerminal): Promise<void> {
tui.requestRender(true);
await new Promise<void>((resolve) => process.nextTick(resolve));
await terminal.waitForRender();
}
describe("TUI overlay compositing", () => {
it("should not leak styles when a trailing reset sits beyond the last visible column (no overlay)", async () => {
const width = 20;
const baseLine = `\x1b[3m${"X".repeat(width)}\x1b[23m`;
const terminal = new VirtualTerminal(width, 6);
const tui = new TUI(terminal);
tui.addChild(new StaticLines([baseLine, "INPUT"]));
tui.start();
await renderAndFlush(tui, terminal);
assert.strictEqual(getCellItalic(terminal, 1, 0), 0);
tui.stop();
});
it("should not leak styles when overlay slicing drops trailing SGR resets", async () => {
const width = 20;
const baseLine = `\x1b[3m${"X".repeat(width)}\x1b[23m`;
const terminal = new VirtualTerminal(width, 6);
const tui = new TUI(terminal);
tui.addChild(new StaticLines([baseLine, "INPUT"]));
tui.showOverlay(new StaticOverlay("OVR"), { row: 0, col: 5, width: 3 });
tui.start();
await renderAndFlush(tui, terminal);
assert.strictEqual(getCellItalic(terminal, 1, 0), 0);
tui.stop();
});
});
@@ -0,0 +1,509 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import type { Terminal as XtermTerminalType } from "@xterm/headless";
import { type Component, TUI } from "../src/tui.js";
import { VirtualTerminal } from "./virtual-terminal.js";
class TestComponent implements Component {
lines: string[] = [];
render(_width: number): string[] {
return this.lines;
}
invalidate(): void {}
}
class LoggingVirtualTerminal extends VirtualTerminal {
private writes: string[] = [];
override write(data: string): void {
this.writes.push(data);
super.write(data);
}
getWrites(): string {
return this.writes.join("");
}
clearWrites(): void {
this.writes = [];
}
}
async function withEnv<T>(updates: Record<string, string | undefined>, run: () => Promise<T>): Promise<T> {
const previousValues = new Map<string, string | undefined>();
for (const [key, value] of Object.entries(updates)) {
previousValues.set(key, process.env[key]);
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
try {
return await run();
} finally {
for (const [key, value] of previousValues) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
}
function getCellItalic(terminal: VirtualTerminal, row: number, col: number): number {
const xterm = (terminal as unknown as { xterm: XtermTerminalType }).xterm;
const buffer = xterm.buffer.active;
const line = buffer.getLine(buffer.viewportY + row);
assert.ok(line, `Missing buffer line at row ${row}`);
const cell = line.getCell(col);
assert.ok(cell, `Missing cell at row ${row} col ${col}`);
return cell.isItalic();
}
describe("TUI resize handling", () => {
it("triggers full re-render when terminal height changes", async () => {
await withEnv({ TERMUX_VERSION: undefined }, async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
component.lines = ["Line 0", "Line 1", "Line 2"];
tui.start();
await terminal.waitForRender();
const initialRedraws = tui.fullRedraws;
// Resize height
terminal.resize(40, 15);
await terminal.waitForRender();
// Should have triggered a full redraw
assert.ok(tui.fullRedraws > initialRedraws, "Height change should trigger full redraw");
const viewport = terminal.getViewport();
assert.ok(viewport[0]?.includes("Line 0"), "Content preserved after height change");
tui.stop();
});
});
it("skips full re-render on height changes in Termux", async () => {
await withEnv({ TERMUX_VERSION: "1" }, async () => {
const terminal = new LoggingVirtualTerminal(40, 10);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
component.lines = Array.from({ length: 20 }, (_, i) => `Line ${i}`);
tui.start();
await terminal.waitForRender();
terminal.clearWrites();
const initialRedraws = tui.fullRedraws;
for (const height of [15, 8, 14, 11]) {
terminal.resize(40, height);
await terminal.waitForRender();
}
assert.strictEqual(tui.fullRedraws, initialRedraws, "Height change should not trigger full redraw");
assert.ok(!terminal.getWrites().includes("\x1b[2J"), "Height change should not clear the screen");
assert.ok(!terminal.getWrites().includes("\x1b[3J"), "Height change should not clear scrollback");
const viewport = terminal.getViewport();
assert.ok(viewport.join("\n").includes("Line 19"), "Latest content remains visible after resize");
tui.stop();
});
});
it("triggers full re-render when terminal width changes", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
component.lines = ["Line 0", "Line 1", "Line 2"];
tui.start();
await terminal.waitForRender();
const initialRedraws = tui.fullRedraws;
// Resize width
terminal.resize(60, 10);
await terminal.waitForRender();
// Should have triggered a full redraw
assert.ok(tui.fullRedraws > initialRedraws, "Width change should trigger full redraw");
tui.stop();
});
});
describe("TUI content shrinkage", () => {
it("clears empty rows when content shrinks significantly", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var)
const component = new TestComponent();
tui.addChild(component);
// Start with many lines
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3", "Line 4", "Line 5"];
tui.start();
await terminal.waitForRender();
const initialRedraws = tui.fullRedraws;
// Shrink to fewer lines
component.lines = ["Line 0", "Line 1"];
tui.requestRender();
await terminal.waitForRender();
// Should have triggered a full redraw to clear empty rows
assert.ok(tui.fullRedraws > initialRedraws, "Content shrinkage should trigger full redraw");
const viewport = terminal.getViewport();
assert.ok(viewport[0]?.includes("Line 0"), "First line preserved");
assert.ok(viewport[1]?.includes("Line 1"), "Second line preserved");
// Lines below should be empty (cleared)
assert.strictEqual(viewport[2]?.trim(), "", "Line 2 should be cleared");
assert.strictEqual(viewport[3]?.trim(), "", "Line 3 should be cleared");
tui.stop();
});
it("handles shrink to single line", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var)
const component = new TestComponent();
tui.addChild(component);
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"];
tui.start();
await terminal.waitForRender();
// Shrink to single line
component.lines = ["Only line"];
tui.requestRender();
await terminal.waitForRender();
const viewport = terminal.getViewport();
assert.ok(viewport[0]?.includes("Only line"), "Single line rendered");
assert.strictEqual(viewport[1]?.trim(), "", "Line 1 should be cleared");
tui.stop();
});
it("handles shrink to empty", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var)
const component = new TestComponent();
tui.addChild(component);
component.lines = ["Line 0", "Line 1", "Line 2"];
tui.start();
await terminal.waitForRender();
// Shrink to empty
component.lines = [];
tui.requestRender();
await terminal.waitForRender();
const viewport = terminal.getViewport();
// All lines should be empty
assert.strictEqual(viewport[0]?.trim(), "", "Line 0 should be cleared");
assert.strictEqual(viewport[1]?.trim(), "", "Line 1 should be cleared");
tui.stop();
});
});
describe("TUI differential rendering", () => {
it("tracks cursor correctly when content shrinks with unchanged remaining lines", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
// Initial render: 5 identical lines
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3", "Line 4"];
tui.start();
await terminal.waitForRender();
// Shrink to 3 lines, all identical to before (no content changes in remaining lines)
component.lines = ["Line 0", "Line 1", "Line 2"];
tui.requestRender();
await terminal.waitForRender();
// cursorRow should be 2 (last line of new content)
// Verify by doing another render with a change on line 1
component.lines = ["Line 0", "CHANGED", "Line 2"];
tui.requestRender();
await terminal.waitForRender();
const viewport = terminal.getViewport();
// Line 1 should show "CHANGED", proving cursor tracking was correct
assert.ok(viewport[1]?.includes("CHANGED"), `Expected "CHANGED" on line 1, got: ${viewport[1]}`);
tui.stop();
});
it("renders correctly when only a middle line changes (spinner case)", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
// Initial render
component.lines = ["Header", "Working...", "Footer"];
tui.start();
await terminal.waitForRender();
// Simulate spinner animation - only middle line changes
const spinnerFrames = ["|", "/", "-", "\\"];
for (const frame of spinnerFrames) {
component.lines = ["Header", `Working ${frame}`, "Footer"];
tui.requestRender();
await terminal.waitForRender();
const viewport = terminal.getViewport();
assert.ok(viewport[0]?.includes("Header"), `Header preserved: ${viewport[0]}`);
assert.ok(viewport[1]?.includes(`Working ${frame}`), `Spinner updated: ${viewport[1]}`);
assert.ok(viewport[2]?.includes("Footer"), `Footer preserved: ${viewport[2]}`);
}
tui.stop();
});
it("resets styles after each rendered line", async () => {
const terminal = new VirtualTerminal(20, 6);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
component.lines = ["\x1b[3mItalic", "Plain"];
tui.start();
await terminal.waitForRender();
assert.strictEqual(getCellItalic(terminal, 1, 0), 0);
tui.stop();
});
it("renders correctly when first line changes but rest stays same", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"];
tui.start();
await terminal.waitForRender();
// Change only first line
component.lines = ["CHANGED", "Line 1", "Line 2", "Line 3"];
tui.requestRender();
await terminal.waitForRender();
const viewport = terminal.getViewport();
assert.ok(viewport[0]?.includes("CHANGED"), `First line changed: ${viewport[0]}`);
assert.ok(viewport[1]?.includes("Line 1"), `Line 1 preserved: ${viewport[1]}`);
assert.ok(viewport[2]?.includes("Line 2"), `Line 2 preserved: ${viewport[2]}`);
assert.ok(viewport[3]?.includes("Line 3"), `Line 3 preserved: ${viewport[3]}`);
tui.stop();
});
it("renders correctly when last line changes but rest stays same", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3"];
tui.start();
await terminal.waitForRender();
// Change only last line
component.lines = ["Line 0", "Line 1", "Line 2", "CHANGED"];
tui.requestRender();
await terminal.waitForRender();
const viewport = terminal.getViewport();
assert.ok(viewport[0]?.includes("Line 0"), `Line 0 preserved: ${viewport[0]}`);
assert.ok(viewport[1]?.includes("Line 1"), `Line 1 preserved: ${viewport[1]}`);
assert.ok(viewport[2]?.includes("Line 2"), `Line 2 preserved: ${viewport[2]}`);
assert.ok(viewport[3]?.includes("CHANGED"), `Last line changed: ${viewport[3]}`);
tui.stop();
});
it("renders correctly when multiple non-adjacent lines change", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
component.lines = ["Line 0", "Line 1", "Line 2", "Line 3", "Line 4"];
tui.start();
await terminal.waitForRender();
// Change lines 1 and 3, keep 0, 2, 4 the same
component.lines = ["Line 0", "CHANGED 1", "Line 2", "CHANGED 3", "Line 4"];
tui.requestRender();
await terminal.waitForRender();
const viewport = terminal.getViewport();
assert.ok(viewport[0]?.includes("Line 0"), `Line 0 preserved: ${viewport[0]}`);
assert.ok(viewport[1]?.includes("CHANGED 1"), `Line 1 changed: ${viewport[1]}`);
assert.ok(viewport[2]?.includes("Line 2"), `Line 2 preserved: ${viewport[2]}`);
assert.ok(viewport[3]?.includes("CHANGED 3"), `Line 3 changed: ${viewport[3]}`);
assert.ok(viewport[4]?.includes("Line 4"), `Line 4 preserved: ${viewport[4]}`);
tui.stop();
});
it("handles transition from content to empty and back to content", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
// Start with content
component.lines = ["Line 0", "Line 1", "Line 2"];
tui.start();
await terminal.waitForRender();
let viewport = terminal.getViewport();
assert.ok(viewport[0]?.includes("Line 0"), "Initial content rendered");
// Clear to empty
component.lines = [];
tui.requestRender();
await terminal.waitForRender();
// Add content back - this should work correctly even after empty state
component.lines = ["New Line 0", "New Line 1"];
tui.requestRender();
await terminal.waitForRender();
viewport = terminal.getViewport();
assert.ok(viewport[0]?.includes("New Line 0"), `New content rendered: ${viewport[0]}`);
assert.ok(viewport[1]?.includes("New Line 1"), `New content line 1: ${viewport[1]}`);
tui.stop();
});
it("full re-renders when deleted lines move the viewport upward", async () => {
const terminal = new VirtualTerminal(20, 5);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
component.lines = Array.from({ length: 12 }, (_, i) => `Line ${i}`);
tui.start();
await terminal.waitForRender();
const initialRedraws = tui.fullRedraws;
component.lines = Array.from({ length: 7 }, (_, i) => `Line ${i}`);
tui.requestRender();
await terminal.waitForRender();
assert.ok(tui.fullRedraws > initialRedraws, "Shrink should trigger a full redraw");
assert.deepStrictEqual(terminal.getViewport(), ["Line 2", "Line 3", "Line 4", "Line 5", "Line 6"]);
tui.stop();
});
it("appends after a shrink without another full redraw once the viewport is reset", async () => {
const terminal = new VirtualTerminal(20, 5);
const tui = new TUI(terminal);
const component = new TestComponent();
tui.addChild(component);
component.lines = Array.from({ length: 8 }, (_, i) => `Line ${i}`);
tui.start();
await terminal.waitForRender();
const initialRedraws = tui.fullRedraws;
component.lines = ["Line 0", "Line 1"];
tui.requestRender();
await terminal.waitForRender();
assert.ok(tui.fullRedraws > initialRedraws, "Shrink should reset the viewport with a full redraw");
const redrawsAfterShrink = tui.fullRedraws;
component.lines = ["Line 0", "Line 1", "Line 2"];
tui.requestRender();
await terminal.waitForRender();
assert.strictEqual(tui.fullRedraws, redrawsAfterShrink, "Append should stay on the differential path");
assert.deepStrictEqual(terminal.getViewport(), ["Line 0", "Line 1", "Line 2", "", ""]);
tui.stop();
});
it("clears stale content when maxLinesRendered was inflated by a transient component", async () => {
const terminal = new VirtualTerminal(40, 10);
const tui = new TUI(terminal);
const chat = new TestComponent();
const editor = new TestComponent();
tui.addChild(chat);
tui.addChild(editor);
const longChat = Array.from({ length: 15 }, (_, i) => `Chat ${i}`);
const shortChat = Array.from({ length: 12 }, (_, i) => `Chat ${i}`);
const editorLines = ["Editor 0", "Editor 1", "Editor 2"];
const selectorLines = Array.from({ length: 8 }, (_, i) => `Selector ${i}`);
chat.lines = longChat;
editor.lines = editorLines;
tui.start();
await terminal.waitForRender();
editor.lines = selectorLines;
tui.requestRender();
await terminal.waitForRender();
editor.lines = editorLines;
tui.requestRender();
await terminal.waitForRender();
const redrawsBeforeSwitch = tui.fullRedraws;
chat.lines = shortChat;
tui.requestRender();
await terminal.waitForRender();
assert.ok(tui.fullRedraws > redrawsBeforeSwitch, "Branch switch should trigger a full redraw");
const viewport = terminal.getViewport();
for (let i = 0; i < 10; i++) {
const line = viewport[i] ?? "";
assert.ok(!line.includes("Chat 12"), `Stale "Chat 12" at viewport row ${i}`);
assert.ok(!line.includes("Chat 13"), `Stale "Chat 13" at viewport row ${i}`);
assert.ok(!line.includes("Chat 14"), `Stale "Chat 14" at viewport row ${i}`);
}
assert.deepStrictEqual(viewport, [
"Chat 5",
"Chat 6",
"Chat 7",
"Chat 8",
"Chat 9",
"Chat 10",
"Chat 11",
"Editor 0",
"Editor 1",
"Editor 2",
]);
tui.stop();
});
});
@@ -0,0 +1,106 @@
/**
* TUI viewport overwrite repro
*
* Place this file at: packages/tui/test/viewport-overwrite-repro.ts
* Run from repo root: npx tsx packages/tui/test/viewport-overwrite-repro.ts
*
* For reliable repro, run in a small terminal (8-12 rows) or a tmux session:
* tmux new-session -d -s tui-bug -x 80 -y 12
* tmux send-keys -t tui-bug "npx tsx packages/tui/test/viewport-overwrite-repro.ts" Enter
* tmux attach -t tui-bug
*
* Expected behavior:
* - PRE-TOOL lines remain visible above tool output.
* - POST-TOOL lines append after tool output without overwriting earlier content.
*
* Actual behavior (bug):
* - When content exceeds the viewport and new lines arrive after a tool-call pause,
* some earlier PRE-TOOL lines near the bottom are overwritten by POST-TOOL lines.
*/
import { ProcessTerminal } from "../src/terminal.js";
import { type Component, TUI } from "../src/tui.js";
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
class Lines implements Component {
private lines: string[] = [];
set(lines: string[]): void {
this.lines = lines;
}
append(lines: string[]): void {
this.lines.push(...lines);
}
render(width: number): string[] {
return this.lines.map((line) => {
if (line.length > width) return line.slice(0, width);
return line.padEnd(width, " ");
});
}
invalidate(): void {}
}
async function streamLines(buffer: Lines, label: string, count: number, delayMs: number, ui: TUI): Promise<void> {
for (let i = 1; i <= count; i += 1) {
buffer.append([`${label} ${String(i).padStart(2, "0")}`]);
ui.requestRender();
await sleep(delayMs);
}
}
async function main(): Promise<void> {
const ui = new TUI(new ProcessTerminal());
const buffer = new Lines();
ui.addChild(buffer);
ui.start();
const height = ui.terminal.rows;
const preCount = height + 8; // Ensure content exceeds viewport
const toolCount = height + 12; // Tool output pushes further into scrollback
const postCount = 6;
buffer.set([
"TUI viewport overwrite repro",
`Viewport rows detected: ${height}`,
"(Resize to ~8-12 rows for best repro)",
"",
"=== PRE-TOOL STREAM ===",
]);
ui.requestRender();
await sleep(300);
// Phase 1: Stream pre-tool text until viewport is exceeded.
await streamLines(buffer, "PRE-TOOL LINE", preCount, 30, ui);
// Phase 2: Simulate tool call pause and tool output.
buffer.append(["", "--- TOOL CALL START ---", "(pause...)", ""]);
ui.requestRender();
await sleep(700);
await streamLines(buffer, "TOOL OUT", toolCount, 20, ui);
// Phase 3: Post-tool streaming. This is where overwrite often appears.
buffer.append(["", "=== POST-TOOL STREAM ==="]);
ui.requestRender();
await sleep(300);
await streamLines(buffer, "POST-TOOL LINE", postCount, 40, ui);
// Leave the output visible briefly, then restore terminal state.
await sleep(1500);
ui.stop();
}
main().catch((error) => {
// Ensure terminal is restored if something goes wrong.
try {
const ui = new TUI(new ProcessTerminal());
ui.stop();
} catch {
// Ignore restore errors.
}
process.stderr.write(`${String(error)}\n`);
process.exitCode = 1;
});
@@ -0,0 +1,216 @@
import type { Terminal as XtermTerminalType } from "@xterm/headless";
import xterm from "@xterm/headless";
import type { Terminal } from "../src/terminal.js";
// Extract Terminal class from the module
const XtermTerminal = xterm.Terminal;
/**
* Virtual terminal for testing using xterm.js for accurate terminal emulation
*/
export class VirtualTerminal implements Terminal {
private xterm: XtermTerminalType;
private inputHandler?: (data: string) => void;
private resizeHandler?: () => void;
private _columns: number;
private _rows: number;
constructor(columns = 80, rows = 24) {
this._columns = columns;
this._rows = rows;
// Create xterm instance with specified dimensions
this.xterm = new XtermTerminal({
cols: columns,
rows: rows,
// Disable all interactive features for testing
disableStdin: true,
allowProposedApi: true,
});
}
start(onInput: (data: string) => void, onResize: () => void): void {
this.inputHandler = onInput;
this.resizeHandler = onResize;
// Enable bracketed paste mode for consistency with ProcessTerminal
this.xterm.write("\x1b[?2004h");
}
async drainInput(_maxMs?: number, _idleMs?: number): Promise<void> {
// No-op for virtual terminal - no stdin to drain
}
stop(): void {
// Disable bracketed paste mode
this.xterm.write("\x1b[?2004l");
this.inputHandler = undefined;
this.resizeHandler = undefined;
}
write(data: string): void {
this.xterm.write(data);
}
get columns(): number {
return this._columns;
}
get rows(): number {
return this._rows;
}
get kittyProtocolActive(): boolean {
// Virtual terminal always reports Kitty protocol as active for testing
return true;
}
moveBy(lines: number): void {
if (lines > 0) {
// Move down
this.xterm.write(`\x1b[${lines}B`);
} else if (lines < 0) {
// Move up
this.xterm.write(`\x1b[${-lines}A`);
}
// lines === 0: no movement
}
hideCursor(): void {
this.xterm.write("\x1b[?25l");
}
showCursor(): void {
this.xterm.write("\x1b[?25h");
}
clearLine(): void {
this.xterm.write("\x1b[K");
}
clearFromCursor(): void {
this.xterm.write("\x1b[J");
}
clearScreen(): void {
this.xterm.write("\x1b[2J\x1b[H"); // Clear screen and move to home (1,1)
}
setTitle(title: string): void {
// OSC 0;title BEL - set terminal window title
this.xterm.write(`\x1b]0;${title}\x07`);
}
// Test-specific methods not in Terminal interface
/**
* Simulate keyboard input
*/
sendInput(data: string): void {
if (this.inputHandler) {
this.inputHandler(data);
}
}
/**
* Resize the terminal
*/
resize(columns: number, rows: number): void {
this._columns = columns;
this._rows = rows;
this.xterm.resize(columns, rows);
if (this.resizeHandler) {
this.resizeHandler();
}
}
/**
* Wait for all pending writes to complete. Viewport and scroll buffer will be updated.
*/
async flush(): Promise<void> {
// Write an empty string to ensure all previous writes are flushed
return new Promise<void>((resolve) => {
this.xterm.write("", () => resolve());
});
}
/**
* Flush and get viewport - convenience method for tests
*/
async flushAndGetViewport(): Promise<string[]> {
await this.flush();
return this.getViewport();
}
/**
* Get the visible viewport (what's currently on screen)
* Note: You should use getViewportAfterWrite() for testing after writing data
*/
getViewport(): string[] {
const lines: string[] = [];
const buffer = this.xterm.buffer.active;
// Get only the visible lines (viewport)
for (let i = 0; i < this.xterm.rows; i++) {
const line = buffer.getLine(buffer.viewportY + i);
if (line) {
lines.push(line.translateToString(true));
} else {
lines.push("");
}
}
return lines;
}
/**
* Get the entire scroll buffer
*/
getScrollBuffer(): string[] {
const lines: string[] = [];
const buffer = this.xterm.buffer.active;
// Get all lines in the buffer (including scrollback)
for (let i = 0; i < buffer.length; i++) {
const line = buffer.getLine(i);
if (line) {
lines.push(line.translateToString(true));
} else {
lines.push("");
}
}
return lines;
}
/**
* Clear the terminal viewport
*/
clear(): void {
this.xterm.clear();
}
/**
* Reset the terminal completely
*/
reset(): void {
this.xterm.reset();
}
/**
* Get cursor position
*/
getCursorPosition(): { x: number; y: number } {
const buffer = this.xterm.buffer.active;
return {
x: buffer.cursorX,
y: buffer.cursorY,
};
}
/** Wait for TUI's throttled render pipeline to settle. */
async waitForRender(): Promise<void> {
await new Promise<void>((resolve) => process.nextTick(resolve));
await new Promise<void>((resolve) => setTimeout(resolve, 20));
await this.flush();
}
}
@@ -0,0 +1,207 @@
import assert from "node:assert";
import { describe, it } from "node:test";
import { visibleWidth, wrapTextWithAnsi } from "../src/utils.js";
describe("wrapTextWithAnsi", () => {
describe("underline styling", () => {
it("should not apply underline style before the styled text", () => {
const underlineOn = "\x1b[4m";
const underlineOff = "\x1b[24m";
const url = "https://example.com/very/long/path/that/will/wrap";
const text = `read this thread ${underlineOn}${url}${underlineOff}`;
const wrapped = wrapTextWithAnsi(text, 40);
// First line should NOT contain underline code - it's just "read this thread"
assert.strictEqual(wrapped[0], "read this thread");
// Second line should start with underline, have URL content
assert.strictEqual(wrapped[1].startsWith(underlineOn), true);
assert.ok(wrapped[1].includes("https://"));
});
it("should not have whitespace before underline reset code", () => {
const underlineOn = "\x1b[4m";
const underlineOff = "\x1b[24m";
const textWithUnderlinedTrailingSpace = `${underlineOn}underlined text here ${underlineOff}more`;
const wrapped = wrapTextWithAnsi(textWithUnderlinedTrailingSpace, 18);
assert.ok(!wrapped[0].includes(` ${underlineOff}`));
});
it("should not bleed underline to padding - each line should end with reset for underline only", () => {
const underlineOn = "\x1b[4m";
const underlineOff = "\x1b[24m";
const url = "https://example.com/very/long/path/that/will/definitely/wrap";
const text = `prefix ${underlineOn}${url}${underlineOff} suffix`;
const wrapped = wrapTextWithAnsi(text, 30);
// Middle lines (with underlined content) should end with underline-off, not full reset
// Line 1 and 2 contain underlined URL parts
for (let i = 1; i < wrapped.length - 1; i++) {
const line = wrapped[i];
if (line.includes(underlineOn)) {
// Should end with underline off, NOT full reset
assert.strictEqual(line.endsWith(underlineOff), true);
assert.strictEqual(line.endsWith("\x1b[0m"), false);
}
}
});
});
describe("background color preservation", () => {
it("should preserve background color across wrapped lines without full reset", () => {
const bgBlue = "\x1b[44m";
const reset = "\x1b[0m";
const text = `${bgBlue}hello world this is blue background text${reset}`;
const wrapped = wrapTextWithAnsi(text, 15);
// Each line should have background color
for (const line of wrapped) {
assert.ok(line.includes(bgBlue));
}
// Middle lines should NOT end with full reset (kills background for padding)
for (let i = 0; i < wrapped.length - 1; i++) {
assert.strictEqual(wrapped[i].endsWith("\x1b[0m"), false);
}
});
it("should reset underline but preserve background when wrapping underlined text inside background", () => {
const underlineOn = "\x1b[4m";
const underlineOff = "\x1b[24m";
const reset = "\x1b[0m";
const text = `\x1b[41mprefix ${underlineOn}UNDERLINED_CONTENT_THAT_WRAPS${underlineOff} suffix${reset}`;
const wrapped = wrapTextWithAnsi(text, 20);
// All lines should have background color 41 (either as \x1b[41m or combined like \x1b[4;41m)
for (const line of wrapped) {
const hasBgColor = line.includes("[41m") || line.includes(";41m") || line.includes("[41;");
assert.ok(hasBgColor);
}
// Lines with underlined content should use underline-off at end, not full reset
for (let i = 0; i < wrapped.length - 1; i++) {
const line = wrapped[i];
// If this line has underline on, it should end with underline off (not full reset)
if (
(line.includes("[4m") || line.includes("[4;") || line.includes(";4m")) &&
!line.includes(underlineOff)
) {
assert.strictEqual(line.endsWith(underlineOff), true);
assert.strictEqual(line.endsWith("\x1b[0m"), false);
}
}
});
});
describe("basic wrapping", () => {
it("should wrap plain text correctly", () => {
const text = "hello world this is a test";
const wrapped = wrapTextWithAnsi(text, 10);
assert.ok(wrapped.length > 1);
for (const line of wrapped) {
assert.ok(visibleWidth(line) <= 10);
}
});
it("should ignore OSC 133 semantic markers in visible width", () => {
const text = "\x1b]133;A\x07hello\x1b]133;B\x07";
assert.strictEqual(visibleWidth(text), 5);
});
it("should ignore OSC sequences terminated with ST in visible width", () => {
const text = "\x1b]133;A\x1b\\hello\x1b]133;B\x1b\\";
assert.strictEqual(visibleWidth(text), 5);
});
it("should treat isolated regional indicators as width 2", () => {
assert.strictEqual(visibleWidth("🇨"), 2);
assert.strictEqual(visibleWidth("🇨🇳"), 2);
});
it("should truncate trailing whitespace that exceeds width", () => {
const twoSpacesWrappedToWidth1 = wrapTextWithAnsi(" ", 1);
assert.ok(visibleWidth(twoSpacesWrappedToWidth1[0]) <= 1);
});
it("should preserve color codes across wraps", () => {
const red = "\x1b[31m";
const reset = "\x1b[0m";
const text = `${red}hello world this is red${reset}`;
const wrapped = wrapTextWithAnsi(text, 10);
// Each continuation line should start with red code
for (let i = 1; i < wrapped.length; i++) {
assert.strictEqual(wrapped[i].startsWith(red), true);
}
// Middle lines should not end with full reset
for (let i = 0; i < wrapped.length - 1; i++) {
assert.strictEqual(wrapped[i].endsWith("\x1b[0m"), false);
}
});
});
});
describe("wrapTextWithAnsi with OSC 8 hyperlinks", () => {
it("re-emits OSC 8 open at the start of continuation lines", () => {
// A hyperlink whose text is long enough to wrap
const url = "https://example.com";
// OSC 8 open + text that is 10 visible chars + OSC 8 close
const input = `\x1b]8;;${url}\x1b\\0123456789\x1b]8;;\x1b\\`;
const lines = wrapTextWithAnsi(input, 6);
// Every line that contains visible text from inside the hyperlink
// should start with the OSC 8 open sequence (or be preceded by it).
for (const line of lines) {
// If the line has visible content it must begin with the OSC 8 re-open
// OR it is the line where the close appeared with no following content.
const stripped = line.replace(/\x1b\]8;;[^\x1b\x07]*\x1b\\/g, "").replace(/\x1b\[[0-9;]*m/g, "");
if (stripped.trim().length > 0) {
assert.ok(
line.startsWith(`\x1b]8;;${url}\x1b\\`) || line.includes(`\x1b]8;;${url}\x1b\\`),
`Line "${line}" has visible text but no OSC 8 re-open`,
);
}
}
});
it("closes OSC 8 before each line break", () => {
const url = "https://example.com";
const input = `\x1b]8;;${url}\x1b\\0123456789\x1b]8;;\x1b\\`;
const lines = wrapTextWithAnsi(input, 6);
for (let i = 0; i < lines.length - 1; i++) {
const line = lines[i];
// Every non-final line that is inside a hyperlink should end with the close
if (line.includes(`\x1b]8;;${url}\x1b\\`)) {
assert.ok(
line.endsWith("\x1b]8;;\x1b\\"),
`Non-final line "${line}" is inside a hyperlink but does not close it`,
);
}
}
});
it("does not emit OSC 8 sequences on lines that are outside the hyperlink", () => {
const url = "https://example.com";
const input = `before \x1b]8;;${url}\x1b\\link\x1b]8;;\x1b\\ after`;
const lines = wrapTextWithAnsi(input, 80);
// With width 80 everything fits on one line; there should be exactly one
// OSC 8 open and one OSC 8 close.
assert.strictEqual(lines.length, 1);
const openCount = (lines[0].match(/\x1b\]8;;https:[^\x1b]+\x1b\\/g) ?? []).length;
const closeCount = (lines[0].match(/\x1b\]8;;\x1b\\/g) ?? []).length;
assert.strictEqual(openCount, 1);
assert.strictEqual(closeCount, 1);
});
});
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
@@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["test/wrap-ansi.test.ts"],
},
});