mirror of
https://github.com/harivansh-afk/clanker-agent.git
synced 2026-04-20 12:04:39 +00:00
move pi-mono into companion-cloud as apps/companion-os
- Copy all pi-mono source into apps/companion-os/ - Update Dockerfile to COPY pre-built binary instead of downloading from GitHub Releases - Update deploy-staging.yml to build pi from source (bun compile) before Docker build - Add apps/companion-os/** to path triggers - No more cross-repo dispatch needed Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
0250f72976
579 changed files with 206942 additions and 0 deletions
825
packages/tui/src/autocomplete.ts
Normal file
825
packages/tui/src/autocomplete.ts
Normal file
|
|
@ -0,0 +1,825 @@
|
|||
import { spawnSync } 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 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)
|
||||
function walkDirectoryWithFd(
|
||||
baseDir: string,
|
||||
fdPath: string,
|
||||
query: string,
|
||||
maxResults: number,
|
||||
): 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/**",
|
||||
];
|
||||
|
||||
// Add query as pattern if provided
|
||||
if (query) {
|
||||
args.push(query);
|
||||
}
|
||||
|
||||
const result = spawnSync(fdPath, args, {
|
||||
encoding: "utf-8",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
|
||||
if (result.status !== 0 || !result.stdout) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const lines = result.stdout.trim().split("\n").filter(Boolean);
|
||||
const results: Array<{ path: string; isDirectory: boolean }> = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const normalizedPath = line.endsWith("/") ? line.slice(0, -1) : line;
|
||||
if (
|
||||
normalizedPath === ".git" ||
|
||||
normalizedPath.startsWith(".git/") ||
|
||||
normalizedPath.includes("/.git/")
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// fd outputs directories with trailing /
|
||||
const isDirectory = line.endsWith("/");
|
||||
results.push({
|
||||
path: line,
|
||||
isDirectory,
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export interface AutocompleteItem {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface SlashCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
// Function to get argument completions for this command
|
||||
// Returns null if no argument completion is available
|
||||
getArgumentCompletions?(argumentPrefix: string): AutocompleteItem[] | null;
|
||||
}
|
||||
|
||||
export interface AutocompleteProvider {
|
||||
// Get autocomplete suggestions for current text/cursor position
|
||||
// Returns null if no suggestions available
|
||||
getSuggestions(
|
||||
lines: string[],
|
||||
cursorLine: number,
|
||||
cursorCol: number,
|
||||
): {
|
||||
items: AutocompleteItem[];
|
||||
prefix: string; // What we're matching against (e.g., "/" or "src/")
|
||||
} | 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;
|
||||
}
|
||||
|
||||
getSuggestions(
|
||||
lines: string[],
|
||||
cursorLine: number,
|
||||
cursorCol: number,
|
||||
): { items: AutocompleteItem[]; prefix: string } | null {
|
||||
const currentLine = lines[cursorLine] || "";
|
||||
const textBeforeCursor = currentLine.slice(0, cursorCol);
|
||||
|
||||
// Check for @ file reference (fuzzy search) - must be after a delimiter or at start
|
||||
const atPrefix = this.extractAtPrefix(textBeforeCursor);
|
||||
if (atPrefix) {
|
||||
const { rawPrefix, isQuotedPrefix } = parsePathPrefix(atPrefix);
|
||||
const suggestions = this.getFuzzyFileSuggestions(rawPrefix, {
|
||||
isQuotedPrefix: isQuotedPrefix,
|
||||
});
|
||||
if (suggestions.length === 0) return null;
|
||||
|
||||
return {
|
||||
items: suggestions,
|
||||
prefix: atPrefix,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for slash commands
|
||||
if (textBeforeCursor.startsWith("/")) {
|
||||
const spaceIndex = textBeforeCursor.indexOf(" ");
|
||||
|
||||
if (spaceIndex === -1) {
|
||||
// No space yet - complete command names with fuzzy matching
|
||||
const prefix = textBeforeCursor.slice(1); // Remove the "/"
|
||||
const commandItems = this.commands.map((cmd) => ({
|
||||
name: "name" in cmd ? cmd.name : cmd.value,
|
||||
label: "name" in cmd ? cmd.name : cmd.label,
|
||||
description: cmd.description,
|
||||
}));
|
||||
|
||||
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,
|
||||
};
|
||||
} else {
|
||||
// Space found - complete command arguments
|
||||
const commandName = textBeforeCursor.slice(1, spaceIndex); // Command without "/"
|
||||
const argumentText = textBeforeCursor.slice(spaceIndex + 1); // Text after space
|
||||
|
||||
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; // No argument completion for this command
|
||||
}
|
||||
|
||||
const argumentSuggestions =
|
||||
command.getArgumentCompletions(argumentText);
|
||||
if (!argumentSuggestions || argumentSuggestions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
items: argumentSuggestions,
|
||||
prefix: argumentText,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for file paths - triggered by Tab or if we detect a path pattern
|
||||
const pathMatch = this.extractPathPrefix(textBeforeCursor, false);
|
||||
|
||||
if (pathMatch !== null) {
|
||||
const suggestions = this.getFileSuggestions(pathMatch);
|
||||
if (suggestions.length === 0) return null;
|
||||
|
||||
// Check if we have an exact match that is a directory
|
||||
// In that case, we might want to return suggestions for the directory content instead
|
||||
// But only if the prefix ends with /
|
||||
if (
|
||||
suggestions.length === 1 &&
|
||||
suggestions[0]?.value === pathMatch &&
|
||||
!pathMatch.endsWith("/")
|
||||
) {
|
||||
// Exact match found (e.g. user typed "src" and "src/" is the only match)
|
||||
// We still return it so user can select it and add /
|
||||
return {
|
||||
items: suggestions,
|
||||
prefix: pathMatch,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
items: suggestions,
|
||||
prefix: pathMatch,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
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 slashIndex = rawQuery.lastIndexOf("/");
|
||||
if (slashIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const displayBase = rawQuery.slice(0, slashIndex + 1);
|
||||
const query = rawQuery.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 {
|
||||
if (displayBase === "/") {
|
||||
return `/${relativePath}`;
|
||||
}
|
||||
return `${displayBase}${relativePath}`;
|
||||
}
|
||||
|
||||
// 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("/")) {
|
||||
// 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);
|
||||
}
|
||||
} else {
|
||||
// For standalone entries, preserve ~/ if original prefix was ~/
|
||||
if (displayPrefix.startsWith("~")) {
|
||||
relativePath = `~/${name}`;
|
||||
} else {
|
||||
relativePath = name;
|
||||
}
|
||||
}
|
||||
|
||||
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 getFuzzyFileSuggestions(
|
||||
query: string,
|
||||
options: { isQuotedPrefix: boolean },
|
||||
): AutocompleteItem[] {
|
||||
if (!this.fdPath) {
|
||||
// fd not available, return empty results
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const scopedQuery = this.resolveScopedFuzzyQuery(query);
|
||||
const fdBaseDir = scopedQuery?.baseDir ?? this.basePath;
|
||||
const fdQuery = scopedQuery?.query ?? query;
|
||||
const entries = walkDirectoryWithFd(fdBaseDir, this.fdPath, fdQuery, 100);
|
||||
|
||||
// Score entries
|
||||
const scoredEntries = entries
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
score: fdQuery
|
||||
? this.scoreEntry(entry.path, fdQuery, entry.isDirectory)
|
||||
: 1,
|
||||
}))
|
||||
.filter((entry) => entry.score > 0);
|
||||
|
||||
// Sort by score (descending) and take top 20
|
||||
scoredEntries.sort((a, b) => b.score - a.score);
|
||||
const topEntries = scoredEntries.slice(0, 20);
|
||||
|
||||
// Build suggestions
|
||||
const suggestions: AutocompleteItem[] = [];
|
||||
for (const { path: entryPath, isDirectory } of topEntries) {
|
||||
// fd already includes trailing / for directories
|
||||
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 [];
|
||||
}
|
||||
}
|
||||
|
||||
// Force file completion (called on Tab key) - always returns suggestions
|
||||
getForceFileSuggestions(
|
||||
lines: string[],
|
||||
cursorLine: number,
|
||||
cursorCol: number,
|
||||
): { items: AutocompleteItem[]; prefix: string } | null {
|
||||
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 null;
|
||||
}
|
||||
|
||||
// Force extract path prefix - this will always return something
|
||||
const pathMatch = this.extractPathPrefix(textBeforeCursor, true);
|
||||
if (pathMatch !== null) {
|
||||
const suggestions = this.getFileSuggestions(pathMatch);
|
||||
if (suggestions.length === 0) return null;
|
||||
|
||||
return {
|
||||
items: suggestions,
|
||||
prefix: pathMatch,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
141
packages/tui/src/components/box.ts
Normal file
141
packages/tui/src/components/box.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
40
packages/tui/src/components/cancellable-loader.ts
Normal file
40
packages/tui/src/components/cancellable-loader.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { getEditorKeybindings } 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 = getEditorKeybindings();
|
||||
if (kb.matches(data, "selectCancel")) {
|
||||
this.abortController.abort();
|
||||
this.onAbort?.();
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.stop();
|
||||
}
|
||||
}
|
||||
2150
packages/tui/src/components/editor.ts
Normal file
2150
packages/tui/src/components/editor.ts
Normal file
File diff suppressed because it is too large
Load diff
116
packages/tui/src/components/image.ts
Normal file
116
packages/tui/src/components/image.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
562
packages/tui/src/components/input.ts
Normal file
562
packages/tui/src/components/input.ts
Normal file
|
|
@ -0,0 +1,562 @@
|
|||
import { getEditorKeybindings } 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,
|
||||
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 = getEditorKeybindings();
|
||||
|
||||
// Escape/Cancel
|
||||
if (kb.matches(data, "selectCancel")) {
|
||||
if (this.onEscape) this.onEscape();
|
||||
return;
|
||||
}
|
||||
|
||||
// Undo
|
||||
if (kb.matches(data, "undo")) {
|
||||
this.undo();
|
||||
return;
|
||||
}
|
||||
|
||||
// Submit
|
||||
if (kb.matches(data, "submit") || data === "\n") {
|
||||
if (this.onSubmit) this.onSubmit(this.value);
|
||||
return;
|
||||
}
|
||||
|
||||
// Deletion
|
||||
if (kb.matches(data, "deleteCharBackward")) {
|
||||
this.handleBackspace();
|
||||
return;
|
||||
}
|
||||
|
||||
if (kb.matches(data, "deleteCharForward")) {
|
||||
this.handleForwardDelete();
|
||||
return;
|
||||
}
|
||||
|
||||
if (kb.matches(data, "deleteWordBackward")) {
|
||||
this.deleteWordBackwards();
|
||||
return;
|
||||
}
|
||||
|
||||
if (kb.matches(data, "deleteWordForward")) {
|
||||
this.deleteWordForward();
|
||||
return;
|
||||
}
|
||||
|
||||
if (kb.matches(data, "deleteToLineStart")) {
|
||||
this.deleteToLineStart();
|
||||
return;
|
||||
}
|
||||
|
||||
if (kb.matches(data, "deleteToLineEnd")) {
|
||||
this.deleteToLineEnd();
|
||||
return;
|
||||
}
|
||||
|
||||
// Kill ring actions
|
||||
if (kb.matches(data, "yank")) {
|
||||
this.yank();
|
||||
return;
|
||||
}
|
||||
if (kb.matches(data, "yankPop")) {
|
||||
this.yankPop();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cursor movement
|
||||
if (kb.matches(data, "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, "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, "cursorLineStart")) {
|
||||
this.lastAction = null;
|
||||
this.cursor = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
if (kb.matches(data, "cursorLineEnd")) {
|
||||
this.lastAction = null;
|
||||
this.cursor = this.value.length;
|
||||
return;
|
||||
}
|
||||
|
||||
if (kb.matches(data, "cursorWordLeft")) {
|
||||
this.moveWordBackwards();
|
||||
return;
|
||||
}
|
||||
|
||||
if (kb.matches(data, "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, "");
|
||||
|
||||
// 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;
|
||||
|
||||
if (this.value.length < availableWidth) {
|
||||
// Everything fits (leave room for cursor at end)
|
||||
visibleText = this.value;
|
||||
} else {
|
||||
// Need horizontal scrolling
|
||||
// Reserve one character for cursor if it's at the end
|
||||
const scrollWidth =
|
||||
this.cursor === this.value.length ? availableWidth - 1 : availableWidth;
|
||||
const halfWidth = Math.floor(scrollWidth / 2);
|
||||
|
||||
const findValidStart = (start: number) => {
|
||||
while (start < this.value.length) {
|
||||
const charCode = this.value.charCodeAt(start);
|
||||
// this is low surrogate, not a valid start
|
||||
if (charCode >= 0xdc00 && charCode < 0xe000) {
|
||||
start++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return start;
|
||||
};
|
||||
|
||||
const findValidEnd = (end: number) => {
|
||||
while (end > 0) {
|
||||
const charCode = this.value.charCodeAt(end - 1);
|
||||
// this is high surrogate, might be split.
|
||||
if (charCode >= 0xd800 && charCode < 0xdc00) {
|
||||
end--;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return end;
|
||||
};
|
||||
|
||||
if (this.cursor < halfWidth) {
|
||||
// Cursor near start
|
||||
visibleText = this.value.slice(0, findValidEnd(scrollWidth));
|
||||
cursorDisplay = this.cursor;
|
||||
} else if (this.cursor > this.value.length - halfWidth) {
|
||||
// Cursor near end
|
||||
const start = findValidStart(this.value.length - scrollWidth);
|
||||
visibleText = this.value.slice(start);
|
||||
cursorDisplay = this.cursor - start;
|
||||
} else {
|
||||
// Cursor in middle
|
||||
const start = findValidStart(this.cursor - halfWidth);
|
||||
visibleText = this.value.slice(
|
||||
start,
|
||||
findValidEnd(start + scrollWidth),
|
||||
);
|
||||
cursorDisplay = halfWidth;
|
||||
}
|
||||
}
|
||||
|
||||
// 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];
|
||||
}
|
||||
}
|
||||
57
packages/tui/src/components/loader.ts
Normal file
57
packages/tui/src/components/loader.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
913
packages/tui/src/components/markdown.ts
Normal file
913
packages/tui/src/components/markdown.ts
Normal file
|
|
@ -0,0 +1,913 @@
|
|||
import { marked, type Token } from "marked";
|
||||
import { isImageLine } from "../terminal-image.js";
|
||||
import type { Component } from "../tui.js";
|
||||
import {
|
||||
applyBackgroundToLine,
|
||||
visibleWidth,
|
||||
wrapTextWithAnsi,
|
||||
} from "../utils.js";
|
||||
|
||||
/**
|
||||
* 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 = marked.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)} `;
|
||||
const headingText = this.renderInlineTokens(
|
||||
token.tokens || [],
|
||||
styleContext,
|
||||
);
|
||||
let styledHeading: string;
|
||||
if (headingLevel === 1) {
|
||||
styledHeading = this.theme.heading(
|
||||
this.theme.bold(this.theme.underline(headingText)),
|
||||
);
|
||||
} else if (headingLevel === 2) {
|
||||
styledHeading = this.theme.heading(this.theme.bold(headingText));
|
||||
} else {
|
||||
styledHeading = this.theme.heading(
|
||||
this.theme.bold(headingPrefix + headingText),
|
||||
);
|
||||
}
|
||||
lines.push(styledHeading);
|
||||
if (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 !== "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, 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: "",
|
||||
};
|
||||
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 !== "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 !== "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,
|
||||
);
|
||||
// If link text matches href, only show the link once
|
||||
// Compare raw text (token.text) not styled text (linkText) since linkText has ANSI codes
|
||||
// For mailto: links, strip the prefix before comparing (autolinked emails have
|
||||
// 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 +=
|
||||
this.theme.link(this.theme.underline(linkText)) + stylePrefix;
|
||||
} else {
|
||||
result +=
|
||||
this.theme.link(this.theme.underline(linkText)) +
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
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)
|
||||
: [];
|
||||
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("─┴─")}─┘`);
|
||||
|
||||
lines.push(""); // Add spacing after table
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
234
packages/tui/src/components/select-list.ts
Normal file
234
packages/tui/src/components/select-list.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import { getEditorKeybindings } from "../keybindings.js";
|
||||
import type { Component } from "../tui.js";
|
||||
import { truncateToWidth } from "../utils.js";
|
||||
|
||||
const normalizeToSingleLine = (text: string): string =>
|
||||
text.replace(/[\r\n]+/g, " ").trim();
|
||||
|
||||
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 class SelectList implements Component {
|
||||
private items: SelectItem[] = [];
|
||||
private filteredItems: SelectItem[] = [];
|
||||
private selectedIndex: number = 0;
|
||||
private maxVisible: number = 5;
|
||||
private theme: SelectListTheme;
|
||||
|
||||
public onSelect?: (item: SelectItem) => void;
|
||||
public onCancel?: () => void;
|
||||
public onSelectionChange?: (item: SelectItem) => void;
|
||||
|
||||
constructor(items: SelectItem[], maxVisible: number, theme: SelectListTheme) {
|
||||
this.items = items;
|
||||
this.filteredItems = items;
|
||||
this.maxVisible = maxVisible;
|
||||
this.theme = theme;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
let line = "";
|
||||
if (isSelected) {
|
||||
// Use arrow indicator for selection - entire line uses selectedText color
|
||||
const prefixWidth = 2; // "→ " is 2 characters visually
|
||||
const displayValue = item.label || item.value;
|
||||
|
||||
if (descriptionSingleLine && width > 40) {
|
||||
// Calculate how much space we have for value + description
|
||||
const maxValueWidth = Math.min(30, width - prefixWidth - 4);
|
||||
const truncatedValue = truncateToWidth(
|
||||
displayValue,
|
||||
maxValueWidth,
|
||||
"",
|
||||
);
|
||||
const spacing = " ".repeat(Math.max(1, 32 - truncatedValue.length));
|
||||
|
||||
// Calculate remaining space for description using visible widths
|
||||
const descriptionStart =
|
||||
prefixWidth + truncatedValue.length + spacing.length;
|
||||
const remainingWidth = width - descriptionStart - 2; // -2 for safety
|
||||
|
||||
if (remainingWidth > 10) {
|
||||
const truncatedDesc = truncateToWidth(
|
||||
descriptionSingleLine,
|
||||
remainingWidth,
|
||||
"",
|
||||
);
|
||||
// Apply selectedText to entire line content
|
||||
line = this.theme.selectedText(
|
||||
`→ ${truncatedValue}${spacing}${truncatedDesc}`,
|
||||
);
|
||||
} else {
|
||||
// Not enough space for description
|
||||
const maxWidth = width - prefixWidth - 2;
|
||||
line = this.theme.selectedText(
|
||||
`→ ${truncateToWidth(displayValue, maxWidth, "")}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// No description or not enough width
|
||||
const maxWidth = width - prefixWidth - 2;
|
||||
line = this.theme.selectedText(
|
||||
`→ ${truncateToWidth(displayValue, maxWidth, "")}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const displayValue = item.label || item.value;
|
||||
const prefix = " ";
|
||||
|
||||
if (descriptionSingleLine && width > 40) {
|
||||
// Calculate how much space we have for value + description
|
||||
const maxValueWidth = Math.min(30, width - prefix.length - 4);
|
||||
const truncatedValue = truncateToWidth(
|
||||
displayValue,
|
||||
maxValueWidth,
|
||||
"",
|
||||
);
|
||||
const spacing = " ".repeat(Math.max(1, 32 - truncatedValue.length));
|
||||
|
||||
// Calculate remaining space for description
|
||||
const descriptionStart =
|
||||
prefix.length + truncatedValue.length + spacing.length;
|
||||
const remainingWidth = width - descriptionStart - 2; // -2 for safety
|
||||
|
||||
if (remainingWidth > 10) {
|
||||
const truncatedDesc = truncateToWidth(
|
||||
descriptionSingleLine,
|
||||
remainingWidth,
|
||||
"",
|
||||
);
|
||||
const descText = this.theme.description(spacing + truncatedDesc);
|
||||
line = prefix + truncatedValue + descText;
|
||||
} else {
|
||||
// Not enough space for description
|
||||
const maxWidth = width - prefix.length - 2;
|
||||
line = prefix + truncateToWidth(displayValue, maxWidth, "");
|
||||
}
|
||||
} else {
|
||||
// No description or not enough width
|
||||
const maxWidth = width - prefix.length - 2;
|
||||
line = prefix + truncateToWidth(displayValue, maxWidth, "");
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(line);
|
||||
}
|
||||
|
||||
// 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 = getEditorKeybindings();
|
||||
// Up arrow - wrap to bottom when at top
|
||||
if (kb.matches(keyData, "selectUp")) {
|
||||
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, "selectDown")) {
|
||||
this.selectedIndex =
|
||||
this.selectedIndex === this.filteredItems.length - 1
|
||||
? 0
|
||||
: this.selectedIndex + 1;
|
||||
this.notifySelectionChange();
|
||||
}
|
||||
// Enter
|
||||
else if (kb.matches(keyData, "selectConfirm")) {
|
||||
const selectedItem = this.filteredItems[this.selectedIndex];
|
||||
if (selectedItem && this.onSelect) {
|
||||
this.onSelect(selectedItem);
|
||||
}
|
||||
}
|
||||
// Escape or Ctrl+C
|
||||
else if (kb.matches(keyData, "selectCancel")) {
|
||||
if (this.onCancel) {
|
||||
this.onCancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
282
packages/tui/src/components/settings-list.ts
Normal file
282
packages/tui/src/components/settings-list.ts
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
import { fuzzyFilter } from "../fuzzy.js";
|
||||
import { getEditorKeybindings } 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 = getEditorKeybindings();
|
||||
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
|
||||
if (kb.matches(data, "selectUp")) {
|
||||
if (displayItems.length === 0) return;
|
||||
this.selectedIndex =
|
||||
this.selectedIndex === 0
|
||||
? displayItems.length - 1
|
||||
: this.selectedIndex - 1;
|
||||
} else if (kb.matches(data, "selectDown")) {
|
||||
if (displayItems.length === 0) return;
|
||||
this.selectedIndex =
|
||||
this.selectedIndex === displayItems.length - 1
|
||||
? 0
|
||||
: this.selectedIndex + 1;
|
||||
} else if (kb.matches(data, "selectConfirm") || data === " ") {
|
||||
this.activateItem();
|
||||
} else if (kb.matches(data, "selectCancel")) {
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
28
packages/tui/src/components/spacer.ts
Normal file
28
packages/tui/src/components/spacer.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
123
packages/tui/src/components/text.ts
Normal file
123
packages/tui/src/components/text.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
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 : [""];
|
||||
}
|
||||
}
|
||||
65
packages/tui/src/components/truncated-text.ts
Normal file
65
packages/tui/src/components/truncated-text.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
74
packages/tui/src/editor-component.ts
Normal file
74
packages/tui/src/editor-component.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
145
packages/tui/src/fuzzy.ts
Normal file
145
packages/tui/src/fuzzy.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
/**
|
||||
* 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);
|
||||
}
|
||||
117
packages/tui/src/index.ts
Normal file
117
packages/tui/src/index.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
// Core TUI interfaces and classes
|
||||
|
||||
// Autocomplete support
|
||||
export {
|
||||
type AutocompleteItem,
|
||||
type AutocompleteProvider,
|
||||
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 SelectListTheme,
|
||||
} 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 {
|
||||
DEFAULT_EDITOR_KEYBINDINGS,
|
||||
type EditorAction,
|
||||
type EditorKeybindingsConfig,
|
||||
EditorKeybindingsManager,
|
||||
getEditorKeybindings,
|
||||
setEditorKeybindings,
|
||||
} 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,
|
||||
type ImageDimensions,
|
||||
type ImageProtocol,
|
||||
type ImageRenderOptions,
|
||||
imageFallback,
|
||||
renderImage,
|
||||
resetCapabilitiesCache,
|
||||
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";
|
||||
183
packages/tui/src/keybindings.ts
Normal file
183
packages/tui/src/keybindings.ts
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import { type KeyId, matchesKey } from "./keys.js";
|
||||
|
||||
/**
|
||||
* Editor actions that can be bound to keys.
|
||||
*/
|
||||
export type EditorAction =
|
||||
// Cursor movement
|
||||
| "cursorUp"
|
||||
| "cursorDown"
|
||||
| "cursorLeft"
|
||||
| "cursorRight"
|
||||
| "cursorWordLeft"
|
||||
| "cursorWordRight"
|
||||
| "cursorLineStart"
|
||||
| "cursorLineEnd"
|
||||
| "jumpForward"
|
||||
| "jumpBackward"
|
||||
| "pageUp"
|
||||
| "pageDown"
|
||||
// Deletion
|
||||
| "deleteCharBackward"
|
||||
| "deleteCharForward"
|
||||
| "deleteWordBackward"
|
||||
| "deleteWordForward"
|
||||
| "deleteToLineStart"
|
||||
| "deleteToLineEnd"
|
||||
// Text input
|
||||
| "newLine"
|
||||
| "submit"
|
||||
| "tab"
|
||||
// Selection/autocomplete
|
||||
| "selectUp"
|
||||
| "selectDown"
|
||||
| "selectPageUp"
|
||||
| "selectPageDown"
|
||||
| "selectConfirm"
|
||||
| "selectCancel"
|
||||
// Clipboard
|
||||
| "copy"
|
||||
// Kill ring
|
||||
| "yank"
|
||||
| "yankPop"
|
||||
// Undo
|
||||
| "undo"
|
||||
// Tool output
|
||||
| "expandTools"
|
||||
// Session
|
||||
| "toggleSessionPath"
|
||||
| "toggleSessionSort"
|
||||
| "renameSession"
|
||||
| "deleteSession"
|
||||
| "deleteSessionNoninvasive";
|
||||
|
||||
// Re-export KeyId from keys.ts
|
||||
export type { KeyId };
|
||||
|
||||
/**
|
||||
* Editor keybindings configuration.
|
||||
*/
|
||||
export type EditorKeybindingsConfig = {
|
||||
[K in EditorAction]?: KeyId | KeyId[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Default editor keybindings.
|
||||
*/
|
||||
export const DEFAULT_EDITOR_KEYBINDINGS: Required<EditorKeybindingsConfig> = {
|
||||
// Cursor movement
|
||||
cursorUp: "up",
|
||||
cursorDown: "down",
|
||||
cursorLeft: ["left", "ctrl+b"],
|
||||
cursorRight: ["right", "ctrl+f"],
|
||||
cursorWordLeft: ["alt+left", "ctrl+left", "alt+b"],
|
||||
cursorWordRight: ["alt+right", "ctrl+right", "alt+f"],
|
||||
cursorLineStart: ["home", "ctrl+a"],
|
||||
cursorLineEnd: ["end", "ctrl+e"],
|
||||
jumpForward: "ctrl+]",
|
||||
jumpBackward: "ctrl+alt+]",
|
||||
pageUp: "pageUp",
|
||||
pageDown: "pageDown",
|
||||
// Deletion
|
||||
deleteCharBackward: "backspace",
|
||||
deleteCharForward: ["delete", "ctrl+d"],
|
||||
deleteWordBackward: ["ctrl+w", "alt+backspace"],
|
||||
deleteWordForward: ["alt+d", "alt+delete"],
|
||||
deleteToLineStart: "ctrl+u",
|
||||
deleteToLineEnd: "ctrl+k",
|
||||
// Text input
|
||||
newLine: "shift+enter",
|
||||
submit: "enter",
|
||||
tab: "tab",
|
||||
// Selection/autocomplete
|
||||
selectUp: "up",
|
||||
selectDown: "down",
|
||||
selectPageUp: "pageUp",
|
||||
selectPageDown: "pageDown",
|
||||
selectConfirm: "enter",
|
||||
selectCancel: ["escape", "ctrl+c"],
|
||||
// Clipboard
|
||||
copy: "ctrl+c",
|
||||
// Kill ring
|
||||
yank: "ctrl+y",
|
||||
yankPop: "alt+y",
|
||||
// Undo
|
||||
undo: "ctrl+-",
|
||||
// Tool output
|
||||
expandTools: "ctrl+o",
|
||||
// Session
|
||||
toggleSessionPath: "ctrl+p",
|
||||
toggleSessionSort: "ctrl+s",
|
||||
renameSession: "ctrl+r",
|
||||
deleteSession: "ctrl+d",
|
||||
deleteSessionNoninvasive: "ctrl+backspace",
|
||||
};
|
||||
|
||||
/**
|
||||
* Manages keybindings for the editor.
|
||||
*/
|
||||
export class EditorKeybindingsManager {
|
||||
private actionToKeys: Map<EditorAction, KeyId[]>;
|
||||
|
||||
constructor(config: EditorKeybindingsConfig = {}) {
|
||||
this.actionToKeys = new Map();
|
||||
this.buildMaps(config);
|
||||
}
|
||||
|
||||
private buildMaps(config: EditorKeybindingsConfig): void {
|
||||
this.actionToKeys.clear();
|
||||
|
||||
// Start with defaults
|
||||
for (const [action, keys] of Object.entries(DEFAULT_EDITOR_KEYBINDINGS)) {
|
||||
const keyArray = Array.isArray(keys) ? keys : [keys];
|
||||
this.actionToKeys.set(action as EditorAction, [...keyArray]);
|
||||
}
|
||||
|
||||
// Override with user config
|
||||
for (const [action, keys] of Object.entries(config)) {
|
||||
if (keys === undefined) continue;
|
||||
const keyArray = Array.isArray(keys) ? keys : [keys];
|
||||
this.actionToKeys.set(action as EditorAction, keyArray);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if input matches a specific action.
|
||||
*/
|
||||
matches(data: string, action: EditorAction): boolean {
|
||||
const keys = this.actionToKeys.get(action);
|
||||
if (!keys) return false;
|
||||
for (const key of keys) {
|
||||
if (matchesKey(data, key)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get keys bound to an action.
|
||||
*/
|
||||
getKeys(action: EditorAction): KeyId[] {
|
||||
return this.actionToKeys.get(action) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration.
|
||||
*/
|
||||
setConfig(config: EditorKeybindingsConfig): void {
|
||||
this.buildMaps(config);
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
let globalEditorKeybindings: EditorKeybindingsManager | null = null;
|
||||
|
||||
export function getEditorKeybindings(): EditorKeybindingsManager {
|
||||
if (!globalEditorKeybindings) {
|
||||
globalEditorKeybindings = new EditorKeybindingsManager();
|
||||
}
|
||||
return globalEditorKeybindings;
|
||||
}
|
||||
|
||||
export function setEditorKeybindings(manager: EditorKeybindingsManager): void {
|
||||
globalEditorKeybindings = manager;
|
||||
}
|
||||
1309
packages/tui/src/keys.ts
Normal file
1309
packages/tui/src/keys.ts
Normal file
File diff suppressed because it is too large
Load diff
46
packages/tui/src/kill-ring.ts
Normal file
46
packages/tui/src/kill-ring.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
397
packages/tui/src/stdin-buffer.ts
Normal file
397
packages/tui/src/stdin-buffer.ts
Normal file
|
|
@ -0,0 +1,397 @@
|
|||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
405
packages/tui/src/terminal-image.ts
Normal file
405
packages/tui/src/terminal-image.ts
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
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() || "";
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
const trueColor = colorTerm === "truecolor" || colorTerm === "24bit";
|
||||
return { images: null, trueColor, hyperlinks: true };
|
||||
}
|
||||
|
||||
export function getCapabilities(): TerminalCapabilities {
|
||||
if (!cachedCapabilities) {
|
||||
cachedCapabilities = detectCapabilities();
|
||||
}
|
||||
return cachedCapabilities;
|
||||
}
|
||||
|
||||
export function resetCapabilitiesCache(): void {
|
||||
cachedCapabilities = null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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(" ")}]`;
|
||||
}
|
||||
332
packages/tui/src/terminal.ts
Normal file
332
packages/tui/src/terminal.ts
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
import * as fs from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
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 stdinBuffer?: StdinBuffer;
|
||||
private stdinDataHandler?: (data: string) => void;
|
||||
private writeLogPath = process.env.PI_TUI_WRITE_LOG || "";
|
||||
|
||||
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.
|
||||
*
|
||||
* 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");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// 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`);
|
||||
}
|
||||
}
|
||||
1328
packages/tui/src/tui.ts
Normal file
1328
packages/tui/src/tui.ts
Normal file
File diff suppressed because it is too large
Load diff
28
packages/tui/src/undo-stack.ts
Normal file
28
packages/tui/src/undo-stack.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
933
packages/tui/src/utils.ts
Normal file
933
packages/tui/src/utils.ts
Normal file
|
|
@ -0,0 +1,933 @@
|
|||
import { eastAsianWidth } from "get-east-asian-width";
|
||||
|
||||
// Grapheme segmenter (shared instance)
|
||||
const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
||||
|
||||
/**
|
||||
* Get the shared grapheme segmenter instance.
|
||||
*/
|
||||
export function getSegmenter(): Intl.Segmenter {
|
||||
return segmenter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a grapheme cluster (after segmentation) could possibly be an RGI emoji.
|
||||
* This is a fast heuristic to avoid the expensive rgiEmojiRegex test.
|
||||
* The tested Unicode blocks are deliberately broad to account for future
|
||||
* Unicode additions.
|
||||
*/
|
||||
function couldBeEmoji(segment: string): boolean {
|
||||
const cp = segment.codePointAt(0)!;
|
||||
return (
|
||||
(cp >= 0x1f000 && cp <= 0x1fbff) || // Emoji and Pictograph
|
||||
(cp >= 0x2300 && cp <= 0x23ff) || // Misc technical
|
||||
(cp >= 0x2600 && cp <= 0x27bf) || // Misc symbols, dingbats
|
||||
(cp >= 0x2b50 && cp <= 0x2b55) || // Specific stars/circles
|
||||
segment.includes("\uFE0F") || // Contains VS16 (emoji presentation selector)
|
||||
segment.length > 2 // Multi-codepoint sequences (ZWJ, skin tones, etc.)
|
||||
);
|
||||
}
|
||||
|
||||
// Regexes for character classification (same as string-width library)
|
||||
const zeroWidthRegex =
|
||||
/^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Mark}|\p{Surrogate})+$/v;
|
||||
const leadingNonPrintingRegex =
|
||||
/^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v;
|
||||
const rgiEmojiRegex = /^\p{RGI_Emoji}$/v;
|
||||
|
||||
// Cache for non-ASCII strings
|
||||
const WIDTH_CACHE_SIZE = 512;
|
||||
const widthCache = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* Calculate the terminal width of a single grapheme cluster.
|
||||
* Based on code from the string-width library, but includes a possible-emoji
|
||||
* check to avoid running the RGI_Emoji regex unnecessarily.
|
||||
*/
|
||||
function graphemeWidth(segment: string): number {
|
||||
// Zero-width clusters
|
||||
if (zeroWidthRegex.test(segment)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Emoji check with pre-filter
|
||||
if (couldBeEmoji(segment) && rgiEmojiRegex.test(segment)) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
// Get base visible codepoint
|
||||
const base = segment.replace(leadingNonPrintingRegex, "");
|
||||
const cp = base.codePointAt(0);
|
||||
if (cp === undefined) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Regional indicator symbols (U+1F1E6..U+1F1FF) are often rendered as
|
||||
// full-width emoji in terminals, even when isolated during streaming.
|
||||
// Keep width conservative (2) to avoid terminal auto-wrap drift artifacts.
|
||||
if (cp >= 0x1f1e6 && cp <= 0x1f1ff) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
let width = eastAsianWidth(cp);
|
||||
|
||||
// Trailing halfwidth/fullwidth forms
|
||||
if (segment.length > 1) {
|
||||
for (const char of segment.slice(1)) {
|
||||
const c = char.codePointAt(0)!;
|
||||
if (c >= 0xff00 && c <= 0xffef) {
|
||||
width += eastAsianWidth(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return width;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the visible width of a string in terminal columns.
|
||||
*/
|
||||
export function visibleWidth(str: string): number {
|
||||
if (str.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Fast path: pure ASCII printable
|
||||
let isPureAscii = true;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const code = str.charCodeAt(i);
|
||||
if (code < 0x20 || code > 0x7e) {
|
||||
isPureAscii = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isPureAscii) {
|
||||
return str.length;
|
||||
}
|
||||
|
||||
// Check cache
|
||||
const cached = widthCache.get(str);
|
||||
if (cached !== undefined) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Normalize: tabs to 3 spaces, strip ANSI escape codes
|
||||
let clean = str;
|
||||
if (str.includes("\t")) {
|
||||
clean = clean.replace(/\t/g, " ");
|
||||
}
|
||||
if (clean.includes("\x1b")) {
|
||||
// Strip supported ANSI/OSC/APC escape sequences in one pass.
|
||||
// This covers CSI styling/cursor codes, OSC hyperlinks and prompt markers,
|
||||
// and APC sequences like CURSOR_MARKER.
|
||||
let stripped = "";
|
||||
let i = 0;
|
||||
while (i < clean.length) {
|
||||
const ansi = extractAnsiCode(clean, i);
|
||||
if (ansi) {
|
||||
i += ansi.length;
|
||||
continue;
|
||||
}
|
||||
stripped += clean[i];
|
||||
i++;
|
||||
}
|
||||
clean = stripped;
|
||||
}
|
||||
|
||||
// Calculate width
|
||||
let width = 0;
|
||||
for (const { segment } of segmenter.segment(clean)) {
|
||||
width += graphemeWidth(segment);
|
||||
}
|
||||
|
||||
// Cache result
|
||||
if (widthCache.size >= WIDTH_CACHE_SIZE) {
|
||||
const firstKey = widthCache.keys().next().value;
|
||||
if (firstKey !== undefined) {
|
||||
widthCache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
widthCache.set(str, width);
|
||||
|
||||
return width;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract ANSI escape sequences from a string at the given position.
|
||||
*/
|
||||
export function extractAnsiCode(
|
||||
str: string,
|
||||
pos: number,
|
||||
): { code: string; length: number } | null {
|
||||
if (pos >= str.length || str[pos] !== "\x1b") return null;
|
||||
|
||||
const next = str[pos + 1];
|
||||
|
||||
// CSI sequence: ESC [ ... m/G/K/H/J
|
||||
if (next === "[") {
|
||||
let j = pos + 2;
|
||||
while (j < str.length && !/[mGKHJ]/.test(str[j]!)) j++;
|
||||
if (j < str.length)
|
||||
return { code: str.substring(pos, j + 1), length: j + 1 - pos };
|
||||
return null;
|
||||
}
|
||||
|
||||
// OSC sequence: ESC ] ... BEL or ESC ] ... ST (ESC \)
|
||||
// Used for hyperlinks (OSC 8), window titles, etc.
|
||||
if (next === "]") {
|
||||
let j = pos + 2;
|
||||
while (j < str.length) {
|
||||
if (str[j] === "\x07")
|
||||
return { code: str.substring(pos, j + 1), length: j + 1 - pos };
|
||||
if (str[j] === "\x1b" && str[j + 1] === "\\")
|
||||
return { code: str.substring(pos, j + 2), length: j + 2 - pos };
|
||||
j++;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// APC sequence: ESC _ ... BEL or ESC _ ... ST (ESC \)
|
||||
// Used for cursor marker and application-specific commands
|
||||
if (next === "_") {
|
||||
let j = pos + 2;
|
||||
while (j < str.length) {
|
||||
if (str[j] === "\x07")
|
||||
return { code: str.substring(pos, j + 1), length: j + 1 - pos };
|
||||
if (str[j] === "\x1b" && str[j + 1] === "\\")
|
||||
return { code: str.substring(pos, j + 2), length: j + 2 - pos };
|
||||
j++;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track active ANSI SGR codes to preserve styling across line breaks.
|
||||
*/
|
||||
class AnsiCodeTracker {
|
||||
// Track individual attributes separately so we can reset them specifically
|
||||
private bold = false;
|
||||
private dim = false;
|
||||
private italic = false;
|
||||
private underline = false;
|
||||
private blink = false;
|
||||
private inverse = false;
|
||||
private hidden = false;
|
||||
private strikethrough = false;
|
||||
private fgColor: string | null = null; // Stores the full code like "31" or "38;5;240"
|
||||
private bgColor: string | null = null; // Stores the full code like "41" or "48;5;240"
|
||||
|
||||
process(ansiCode: string): void {
|
||||
if (!ansiCode.endsWith("m")) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract the parameters between \x1b[ and m
|
||||
const match = ansiCode.match(/\x1b\[([\d;]*)m/);
|
||||
if (!match) return;
|
||||
|
||||
const params = match[1];
|
||||
if (params === "" || params === "0") {
|
||||
// Full reset
|
||||
this.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse parameters (can be semicolon-separated)
|
||||
const parts = params.split(";");
|
||||
let i = 0;
|
||||
while (i < parts.length) {
|
||||
const code = Number.parseInt(parts[i], 10);
|
||||
|
||||
// Handle 256-color and RGB codes which consume multiple parameters
|
||||
if (code === 38 || code === 48) {
|
||||
// 38;5;N (256 color fg) or 38;2;R;G;B (RGB fg)
|
||||
// 48;5;N (256 color bg) or 48;2;R;G;B (RGB bg)
|
||||
if (parts[i + 1] === "5" && parts[i + 2] !== undefined) {
|
||||
// 256 color: 38;5;N or 48;5;N
|
||||
const colorCode = `${parts[i]};${parts[i + 1]};${parts[i + 2]}`;
|
||||
if (code === 38) {
|
||||
this.fgColor = colorCode;
|
||||
} else {
|
||||
this.bgColor = colorCode;
|
||||
}
|
||||
i += 3;
|
||||
continue;
|
||||
} else if (parts[i + 1] === "2" && parts[i + 4] !== undefined) {
|
||||
// RGB color: 38;2;R;G;B or 48;2;R;G;B
|
||||
const colorCode = `${parts[i]};${parts[i + 1]};${parts[i + 2]};${parts[i + 3]};${parts[i + 4]}`;
|
||||
if (code === 38) {
|
||||
this.fgColor = colorCode;
|
||||
} else {
|
||||
this.bgColor = colorCode;
|
||||
}
|
||||
i += 5;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Standard SGR codes
|
||||
switch (code) {
|
||||
case 0:
|
||||
this.reset();
|
||||
break;
|
||||
case 1:
|
||||
this.bold = true;
|
||||
break;
|
||||
case 2:
|
||||
this.dim = true;
|
||||
break;
|
||||
case 3:
|
||||
this.italic = true;
|
||||
break;
|
||||
case 4:
|
||||
this.underline = true;
|
||||
break;
|
||||
case 5:
|
||||
this.blink = true;
|
||||
break;
|
||||
case 7:
|
||||
this.inverse = true;
|
||||
break;
|
||||
case 8:
|
||||
this.hidden = true;
|
||||
break;
|
||||
case 9:
|
||||
this.strikethrough = true;
|
||||
break;
|
||||
case 21:
|
||||
this.bold = false;
|
||||
break; // Some terminals
|
||||
case 22:
|
||||
this.bold = false;
|
||||
this.dim = false;
|
||||
break;
|
||||
case 23:
|
||||
this.italic = false;
|
||||
break;
|
||||
case 24:
|
||||
this.underline = false;
|
||||
break;
|
||||
case 25:
|
||||
this.blink = false;
|
||||
break;
|
||||
case 27:
|
||||
this.inverse = false;
|
||||
break;
|
||||
case 28:
|
||||
this.hidden = false;
|
||||
break;
|
||||
case 29:
|
||||
this.strikethrough = false;
|
||||
break;
|
||||
case 39:
|
||||
this.fgColor = null;
|
||||
break; // Default fg
|
||||
case 49:
|
||||
this.bgColor = null;
|
||||
break; // Default bg
|
||||
default:
|
||||
// Standard foreground colors 30-37, 90-97
|
||||
if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {
|
||||
this.fgColor = String(code);
|
||||
}
|
||||
// Standard background colors 40-47, 100-107
|
||||
else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) {
|
||||
this.bgColor = String(code);
|
||||
}
|
||||
break;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
this.bold = false;
|
||||
this.dim = false;
|
||||
this.italic = false;
|
||||
this.underline = false;
|
||||
this.blink = false;
|
||||
this.inverse = false;
|
||||
this.hidden = false;
|
||||
this.strikethrough = false;
|
||||
this.fgColor = null;
|
||||
this.bgColor = null;
|
||||
}
|
||||
|
||||
/** Clear all state for reuse. */
|
||||
clear(): void {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
getActiveCodes(): string {
|
||||
const codes: string[] = [];
|
||||
if (this.bold) codes.push("1");
|
||||
if (this.dim) codes.push("2");
|
||||
if (this.italic) codes.push("3");
|
||||
if (this.underline) codes.push("4");
|
||||
if (this.blink) codes.push("5");
|
||||
if (this.inverse) codes.push("7");
|
||||
if (this.hidden) codes.push("8");
|
||||
if (this.strikethrough) codes.push("9");
|
||||
if (this.fgColor) codes.push(this.fgColor);
|
||||
if (this.bgColor) codes.push(this.bgColor);
|
||||
|
||||
if (codes.length === 0) return "";
|
||||
return `\x1b[${codes.join(";")}m`;
|
||||
}
|
||||
|
||||
hasActiveCodes(): boolean {
|
||||
return (
|
||||
this.bold ||
|
||||
this.dim ||
|
||||
this.italic ||
|
||||
this.underline ||
|
||||
this.blink ||
|
||||
this.inverse ||
|
||||
this.hidden ||
|
||||
this.strikethrough ||
|
||||
this.fgColor !== null ||
|
||||
this.bgColor !== null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get reset codes for attributes that need to be turned off at line end,
|
||||
* specifically underline which bleeds into padding.
|
||||
* Returns empty string if no problematic attributes are active.
|
||||
*/
|
||||
getLineEndReset(): string {
|
||||
// Only underline causes visual bleeding into padding
|
||||
// Other attributes like colors don't visually bleed to padding
|
||||
if (this.underline) {
|
||||
return "\x1b[24m"; // Underline off only
|
||||
}
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function updateTrackerFromText(text: string, tracker: AnsiCodeTracker): void {
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
const ansiResult = extractAnsiCode(text, i);
|
||||
if (ansiResult) {
|
||||
tracker.process(ansiResult.code);
|
||||
i += ansiResult.length;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Split text into words while keeping ANSI codes attached.
|
||||
*/
|
||||
function splitIntoTokensWithAnsi(text: string): string[] {
|
||||
const tokens: string[] = [];
|
||||
let current = "";
|
||||
let pendingAnsi = ""; // ANSI codes waiting to be attached to next visible content
|
||||
let inWhitespace = false;
|
||||
let i = 0;
|
||||
|
||||
while (i < text.length) {
|
||||
const ansiResult = extractAnsiCode(text, i);
|
||||
if (ansiResult) {
|
||||
// Hold ANSI codes separately - they'll be attached to the next visible char
|
||||
pendingAnsi += ansiResult.code;
|
||||
i += ansiResult.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
const char = text[i];
|
||||
const charIsSpace = char === " ";
|
||||
|
||||
if (charIsSpace !== inWhitespace && current) {
|
||||
// Switching between whitespace and non-whitespace, push current token
|
||||
tokens.push(current);
|
||||
current = "";
|
||||
}
|
||||
|
||||
// Attach any pending ANSI codes to this visible character
|
||||
if (pendingAnsi) {
|
||||
current += pendingAnsi;
|
||||
pendingAnsi = "";
|
||||
}
|
||||
|
||||
inWhitespace = charIsSpace;
|
||||
current += char;
|
||||
i++;
|
||||
}
|
||||
|
||||
// Handle any remaining pending ANSI codes (attach to last token)
|
||||
if (pendingAnsi) {
|
||||
current += pendingAnsi;
|
||||
}
|
||||
|
||||
if (current) {
|
||||
tokens.push(current);
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap text with ANSI codes preserved.
|
||||
*
|
||||
* ONLY does word wrapping - NO padding, NO background colors.
|
||||
* Returns lines where each line is <= width visible chars.
|
||||
* Active ANSI codes are preserved across line breaks.
|
||||
*
|
||||
* @param text - Text to wrap (may contain ANSI codes and newlines)
|
||||
* @param width - Maximum visible width per line
|
||||
* @returns Array of wrapped lines (NOT padded to width)
|
||||
*/
|
||||
export function wrapTextWithAnsi(text: string, width: number): string[] {
|
||||
if (!text) {
|
||||
return [""];
|
||||
}
|
||||
|
||||
// Handle newlines by processing each line separately
|
||||
// Track ANSI state across lines so styles carry over after literal newlines
|
||||
const inputLines = text.split("\n");
|
||||
const result: string[] = [];
|
||||
const tracker = new AnsiCodeTracker();
|
||||
|
||||
for (const inputLine of inputLines) {
|
||||
// Prepend active ANSI codes from previous lines (except for first line)
|
||||
const prefix = result.length > 0 ? tracker.getActiveCodes() : "";
|
||||
result.push(...wrapSingleLine(prefix + inputLine, width));
|
||||
// Update tracker with codes from this line for next iteration
|
||||
updateTrackerFromText(inputLine, tracker);
|
||||
}
|
||||
|
||||
return result.length > 0 ? result : [""];
|
||||
}
|
||||
|
||||
function wrapSingleLine(line: string, width: number): string[] {
|
||||
if (!line) {
|
||||
return [""];
|
||||
}
|
||||
|
||||
const visibleLength = visibleWidth(line);
|
||||
if (visibleLength <= width) {
|
||||
return [line];
|
||||
}
|
||||
|
||||
const wrapped: string[] = [];
|
||||
const tracker = new AnsiCodeTracker();
|
||||
const tokens = splitIntoTokensWithAnsi(line);
|
||||
|
||||
let currentLine = "";
|
||||
let currentVisibleLength = 0;
|
||||
|
||||
for (const token of tokens) {
|
||||
const tokenVisibleLength = visibleWidth(token);
|
||||
const isWhitespace = token.trim() === "";
|
||||
|
||||
// Token itself is too long - break it character by character
|
||||
if (tokenVisibleLength > width && !isWhitespace) {
|
||||
if (currentLine) {
|
||||
// Add specific reset for underline only (preserves background)
|
||||
const lineEndReset = tracker.getLineEndReset();
|
||||
if (lineEndReset) {
|
||||
currentLine += lineEndReset;
|
||||
}
|
||||
wrapped.push(currentLine);
|
||||
currentLine = "";
|
||||
currentVisibleLength = 0;
|
||||
}
|
||||
|
||||
// Break long token - breakLongWord handles its own resets
|
||||
const broken = breakLongWord(token, width, tracker);
|
||||
wrapped.push(...broken.slice(0, -1));
|
||||
currentLine = broken[broken.length - 1];
|
||||
currentVisibleLength = visibleWidth(currentLine);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if adding this token would exceed width
|
||||
const totalNeeded = currentVisibleLength + tokenVisibleLength;
|
||||
|
||||
if (totalNeeded > width && currentVisibleLength > 0) {
|
||||
// Trim trailing whitespace, then add underline reset (not full reset, to preserve background)
|
||||
let lineToWrap = currentLine.trimEnd();
|
||||
const lineEndReset = tracker.getLineEndReset();
|
||||
if (lineEndReset) {
|
||||
lineToWrap += lineEndReset;
|
||||
}
|
||||
wrapped.push(lineToWrap);
|
||||
if (isWhitespace) {
|
||||
// Don't start new line with whitespace
|
||||
currentLine = tracker.getActiveCodes();
|
||||
currentVisibleLength = 0;
|
||||
} else {
|
||||
currentLine = tracker.getActiveCodes() + token;
|
||||
currentVisibleLength = tokenVisibleLength;
|
||||
}
|
||||
} else {
|
||||
// Add to current line
|
||||
currentLine += token;
|
||||
currentVisibleLength += tokenVisibleLength;
|
||||
}
|
||||
|
||||
updateTrackerFromText(token, tracker);
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
// No reset at end of final line - let caller handle it
|
||||
wrapped.push(currentLine);
|
||||
}
|
||||
|
||||
// Trailing whitespace can cause lines to exceed the requested width
|
||||
return wrapped.length > 0 ? wrapped.map((line) => line.trimEnd()) : [""];
|
||||
}
|
||||
|
||||
const PUNCTUATION_REGEX = /[(){}[\]<>.,;:'"!?+\-=*/\\|&%^$#@~`]/;
|
||||
|
||||
/**
|
||||
* Check if a character is whitespace.
|
||||
*/
|
||||
export function isWhitespaceChar(char: string): boolean {
|
||||
return /\s/.test(char);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a character is punctuation.
|
||||
*/
|
||||
export function isPunctuationChar(char: string): boolean {
|
||||
return PUNCTUATION_REGEX.test(char);
|
||||
}
|
||||
|
||||
function breakLongWord(
|
||||
word: string,
|
||||
width: number,
|
||||
tracker: AnsiCodeTracker,
|
||||
): string[] {
|
||||
const lines: string[] = [];
|
||||
let currentLine = tracker.getActiveCodes();
|
||||
let currentWidth = 0;
|
||||
|
||||
// First, separate ANSI codes from visible content
|
||||
// We need to handle ANSI codes specially since they're not graphemes
|
||||
let i = 0;
|
||||
const segments: Array<{ type: "ansi" | "grapheme"; value: string }> = [];
|
||||
|
||||
while (i < word.length) {
|
||||
const ansiResult = extractAnsiCode(word, i);
|
||||
if (ansiResult) {
|
||||
segments.push({ type: "ansi", value: ansiResult.code });
|
||||
i += ansiResult.length;
|
||||
} else {
|
||||
// Find the next ANSI code or end of string
|
||||
let end = i;
|
||||
while (end < word.length) {
|
||||
const nextAnsi = extractAnsiCode(word, end);
|
||||
if (nextAnsi) break;
|
||||
end++;
|
||||
}
|
||||
// Segment this non-ANSI portion into graphemes
|
||||
const textPortion = word.slice(i, end);
|
||||
for (const seg of segmenter.segment(textPortion)) {
|
||||
segments.push({ type: "grapheme", value: seg.segment });
|
||||
}
|
||||
i = end;
|
||||
}
|
||||
}
|
||||
|
||||
// Now process segments
|
||||
for (const seg of segments) {
|
||||
if (seg.type === "ansi") {
|
||||
currentLine += seg.value;
|
||||
tracker.process(seg.value);
|
||||
continue;
|
||||
}
|
||||
|
||||
const grapheme = seg.value;
|
||||
// Skip empty graphemes to avoid issues with string-width calculation
|
||||
if (!grapheme) continue;
|
||||
|
||||
const graphemeWidth = visibleWidth(grapheme);
|
||||
|
||||
if (currentWidth + graphemeWidth > width) {
|
||||
// Add specific reset for underline only (preserves background)
|
||||
const lineEndReset = tracker.getLineEndReset();
|
||||
if (lineEndReset) {
|
||||
currentLine += lineEndReset;
|
||||
}
|
||||
lines.push(currentLine);
|
||||
currentLine = tracker.getActiveCodes();
|
||||
currentWidth = 0;
|
||||
}
|
||||
|
||||
currentLine += grapheme;
|
||||
currentWidth += graphemeWidth;
|
||||
}
|
||||
|
||||
if (currentLine) {
|
||||
// No reset at end of final segment - caller handles continuation
|
||||
lines.push(currentLine);
|
||||
}
|
||||
|
||||
return lines.length > 0 ? lines : [""];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply background color to a line, padding to full width.
|
||||
*
|
||||
* @param line - Line of text (may contain ANSI codes)
|
||||
* @param width - Total width to pad to
|
||||
* @param bgFn - Background color function
|
||||
* @returns Line with background applied and padded to width
|
||||
*/
|
||||
export function applyBackgroundToLine(
|
||||
line: string,
|
||||
width: number,
|
||||
bgFn: (text: string) => string,
|
||||
): string {
|
||||
// Calculate padding needed
|
||||
const visibleLen = visibleWidth(line);
|
||||
const paddingNeeded = Math.max(0, width - visibleLen);
|
||||
const padding = " ".repeat(paddingNeeded);
|
||||
|
||||
// Apply background to content + padding
|
||||
const withPadding = line + padding;
|
||||
return bgFn(withPadding);
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate text to fit within a maximum visible width, adding ellipsis if needed.
|
||||
* Optionally pad with spaces to reach exactly maxWidth.
|
||||
* Properly handles ANSI escape codes (they don't count toward width).
|
||||
*
|
||||
* @param text - Text to truncate (may contain ANSI codes)
|
||||
* @param maxWidth - Maximum visible width
|
||||
* @param ellipsis - Ellipsis string to append when truncating (default: "...")
|
||||
* @param pad - If true, pad result with spaces to exactly maxWidth (default: false)
|
||||
* @returns Truncated text, optionally padded to exactly maxWidth
|
||||
*/
|
||||
export function truncateToWidth(
|
||||
text: string,
|
||||
maxWidth: number,
|
||||
ellipsis: string = "...",
|
||||
pad: boolean = false,
|
||||
): string {
|
||||
const textVisibleWidth = visibleWidth(text);
|
||||
|
||||
if (textVisibleWidth <= maxWidth) {
|
||||
return pad ? text + " ".repeat(maxWidth - textVisibleWidth) : text;
|
||||
}
|
||||
|
||||
const ellipsisWidth = visibleWidth(ellipsis);
|
||||
const targetWidth = maxWidth - ellipsisWidth;
|
||||
|
||||
if (targetWidth <= 0) {
|
||||
return ellipsis.substring(0, maxWidth);
|
||||
}
|
||||
|
||||
// Separate ANSI codes from visible content using grapheme segmentation
|
||||
let i = 0;
|
||||
const segments: Array<{ type: "ansi" | "grapheme"; value: string }> = [];
|
||||
|
||||
while (i < text.length) {
|
||||
const ansiResult = extractAnsiCode(text, i);
|
||||
if (ansiResult) {
|
||||
segments.push({ type: "ansi", value: ansiResult.code });
|
||||
i += ansiResult.length;
|
||||
} else {
|
||||
// Find the next ANSI code or end of string
|
||||
let end = i;
|
||||
while (end < text.length) {
|
||||
const nextAnsi = extractAnsiCode(text, end);
|
||||
if (nextAnsi) break;
|
||||
end++;
|
||||
}
|
||||
// Segment this non-ANSI portion into graphemes
|
||||
const textPortion = text.slice(i, end);
|
||||
for (const seg of segmenter.segment(textPortion)) {
|
||||
segments.push({ type: "grapheme", value: seg.segment });
|
||||
}
|
||||
i = end;
|
||||
}
|
||||
}
|
||||
|
||||
// Build truncated string from segments
|
||||
let result = "";
|
||||
let currentWidth = 0;
|
||||
|
||||
for (const seg of segments) {
|
||||
if (seg.type === "ansi") {
|
||||
result += seg.value;
|
||||
continue;
|
||||
}
|
||||
|
||||
const grapheme = seg.value;
|
||||
// Skip empty graphemes to avoid issues with string-width calculation
|
||||
if (!grapheme) continue;
|
||||
|
||||
const graphemeWidth = visibleWidth(grapheme);
|
||||
|
||||
if (currentWidth + graphemeWidth > targetWidth) {
|
||||
break;
|
||||
}
|
||||
|
||||
result += grapheme;
|
||||
currentWidth += graphemeWidth;
|
||||
}
|
||||
|
||||
// Add reset code before ellipsis to prevent styling leaking into it
|
||||
const truncated = `${result}\x1b[0m${ellipsis}`;
|
||||
if (pad) {
|
||||
const truncatedWidth = visibleWidth(truncated);
|
||||
return truncated + " ".repeat(Math.max(0, maxWidth - truncatedWidth));
|
||||
}
|
||||
return truncated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a range of visible columns from a line. Handles ANSI codes and wide chars.
|
||||
* @param strict - If true, exclude wide chars at boundary that would extend past the range
|
||||
*/
|
||||
export function sliceByColumn(
|
||||
line: string,
|
||||
startCol: number,
|
||||
length: number,
|
||||
strict = false,
|
||||
): string {
|
||||
return sliceWithWidth(line, startCol, length, strict).text;
|
||||
}
|
||||
|
||||
/** Like sliceByColumn but also returns the actual visible width of the result. */
|
||||
export function sliceWithWidth(
|
||||
line: string,
|
||||
startCol: number,
|
||||
length: number,
|
||||
strict = false,
|
||||
): { text: string; width: number } {
|
||||
if (length <= 0) return { text: "", width: 0 };
|
||||
const endCol = startCol + length;
|
||||
let result = "",
|
||||
resultWidth = 0,
|
||||
currentCol = 0,
|
||||
i = 0,
|
||||
pendingAnsi = "";
|
||||
|
||||
while (i < line.length) {
|
||||
const ansi = extractAnsiCode(line, i);
|
||||
if (ansi) {
|
||||
if (currentCol >= startCol && currentCol < endCol) result += ansi.code;
|
||||
else if (currentCol < startCol) pendingAnsi += ansi.code;
|
||||
i += ansi.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
let textEnd = i;
|
||||
while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++;
|
||||
|
||||
for (const { segment } of segmenter.segment(line.slice(i, textEnd))) {
|
||||
const w = graphemeWidth(segment);
|
||||
const inRange = currentCol >= startCol && currentCol < endCol;
|
||||
const fits = !strict || currentCol + w <= endCol;
|
||||
if (inRange && fits) {
|
||||
if (pendingAnsi) {
|
||||
result += pendingAnsi;
|
||||
pendingAnsi = "";
|
||||
}
|
||||
result += segment;
|
||||
resultWidth += w;
|
||||
}
|
||||
currentCol += w;
|
||||
if (currentCol >= endCol) break;
|
||||
}
|
||||
i = textEnd;
|
||||
if (currentCol >= endCol) break;
|
||||
}
|
||||
return { text: result, width: resultWidth };
|
||||
}
|
||||
|
||||
// Pooled tracker instance for extractSegments (avoids allocation per call)
|
||||
const pooledStyleTracker = new AnsiCodeTracker();
|
||||
|
||||
/**
|
||||
* Extract "before" and "after" segments from a line in a single pass.
|
||||
* Used for overlay compositing where we need content before and after the overlay region.
|
||||
* Preserves styling from before the overlay that should affect content after it.
|
||||
*/
|
||||
export function extractSegments(
|
||||
line: string,
|
||||
beforeEnd: number,
|
||||
afterStart: number,
|
||||
afterLen: number,
|
||||
strictAfter = false,
|
||||
): { before: string; beforeWidth: number; after: string; afterWidth: number } {
|
||||
let before = "",
|
||||
beforeWidth = 0,
|
||||
after = "",
|
||||
afterWidth = 0;
|
||||
let currentCol = 0,
|
||||
i = 0;
|
||||
let pendingAnsiBefore = "";
|
||||
let afterStarted = false;
|
||||
const afterEnd = afterStart + afterLen;
|
||||
|
||||
// Track styling state so "after" inherits styling from before the overlay
|
||||
pooledStyleTracker.clear();
|
||||
|
||||
while (i < line.length) {
|
||||
const ansi = extractAnsiCode(line, i);
|
||||
if (ansi) {
|
||||
// Track all SGR codes to know styling state at afterStart
|
||||
pooledStyleTracker.process(ansi.code);
|
||||
// Include ANSI codes in their respective segments
|
||||
if (currentCol < beforeEnd) {
|
||||
pendingAnsiBefore += ansi.code;
|
||||
} else if (
|
||||
currentCol >= afterStart &&
|
||||
currentCol < afterEnd &&
|
||||
afterStarted
|
||||
) {
|
||||
// Only include after we've started "after" (styling already prepended)
|
||||
after += ansi.code;
|
||||
}
|
||||
i += ansi.length;
|
||||
continue;
|
||||
}
|
||||
|
||||
let textEnd = i;
|
||||
while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++;
|
||||
|
||||
for (const { segment } of segmenter.segment(line.slice(i, textEnd))) {
|
||||
const w = graphemeWidth(segment);
|
||||
|
||||
if (currentCol < beforeEnd) {
|
||||
if (pendingAnsiBefore) {
|
||||
before += pendingAnsiBefore;
|
||||
pendingAnsiBefore = "";
|
||||
}
|
||||
before += segment;
|
||||
beforeWidth += w;
|
||||
} else if (currentCol >= afterStart && currentCol < afterEnd) {
|
||||
const fits = !strictAfter || currentCol + w <= afterEnd;
|
||||
if (fits) {
|
||||
// On first "after" grapheme, prepend inherited styling from before overlay
|
||||
if (!afterStarted) {
|
||||
after += pooledStyleTracker.getActiveCodes();
|
||||
afterStarted = true;
|
||||
}
|
||||
after += segment;
|
||||
afterWidth += w;
|
||||
}
|
||||
}
|
||||
|
||||
currentCol += w;
|
||||
// Early exit: done with "before" only, or done with both segments
|
||||
if (afterLen <= 0 ? currentCol >= beforeEnd : currentCol >= afterEnd)
|
||||
break;
|
||||
}
|
||||
i = textEnd;
|
||||
if (afterLen <= 0 ? currentCol >= beforeEnd : currentCol >= afterEnd) break;
|
||||
}
|
||||
|
||||
return { before, beforeWidth, after, afterWidth };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue