Squashed 'third_party/pi/' content from commit d981de122

git-subtree-dir: third_party/pi
git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
2026-09-14 10:40:08 -04:00
commit 67b361f0e9
1669 changed files with 397627 additions and 0 deletions

View File

@@ -0,0 +1,327 @@
import { Input } from "./components/input.ts";
import { getKeybindings } from "./keybindings.ts";
import type { Component, Focusable } from "./tui.ts";
import { getGraphemeSegmenter, stripTerminalSequences, truncateToWidth, visibleWidth } from "./utils.ts";
const segmenter = getGraphemeSegmenter();
interface SearchSourceSpan {
textStart: number;
textEnd: number;
row: number;
startCol: number;
endCol: number;
linearColumns: boolean;
}
interface SearchCorpus {
text: string;
spans: SearchSourceSpan[];
}
export interface AltScreenSearchSegment {
row: number;
startCol: number;
endCol: number;
}
export interface AltScreenSearchMatch {
segments: AltScreenSearchSegment[];
}
const PRINTABLE_ASCII = /^[\x20-\x7e]*$/;
function buildSearchCorpus(lines: readonly string[]): SearchCorpus {
const chunks: string[] = [];
const spans: SearchSourceSpan[] = [];
let textLength = 0;
let pendingSeparator = false;
const appendSeparator = (): void => {
if (!pendingSeparator) return;
chunks.push(" ");
textLength += 1;
pendingSeparator = false;
};
for (let row = 0; row < lines.length; row++) {
const line = stripTerminalSequences(lines[row] ?? "");
let column = 0;
// Rendered transcripts are overwhelmingly ASCII. Index complete non-space
// runs at once instead of segmenting and allocating one mapping per cell.
if (PRINTABLE_ASCII.test(line)) {
let index = 0;
while (index < line.length) {
if (line.charCodeAt(index) === 0x20) {
if (textLength > 0) pendingSeparator = true;
column += 1;
index += 1;
continue;
}
let end = index + 1;
while (end < line.length && line.charCodeAt(end) !== 0x20) end += 1;
appendSeparator();
const text = line.slice(index, end);
chunks.push(text);
spans.push({
textStart: textLength,
textEnd: textLength + text.length,
row,
startCol: column,
endCol: column + text.length,
linearColumns: true,
});
textLength += text.length;
column += text.length;
index = end;
}
} else {
for (const grapheme of segmenter.segment(line)) {
const text = grapheme.segment;
const width = visibleWidth(text);
if (/^\s+$/u.test(text)) {
if (textLength > 0) pendingSeparator = true;
column += width;
continue;
}
appendSeparator();
chunks.push(text);
spans.push({
textStart: textLength,
textEnd: textLength + text.length,
row,
startCol: column,
endCol: column + width,
linearColumns: false,
});
textLength += text.length;
column += width;
}
}
if (textLength > 0) pendingSeparator = true;
}
return { text: chunks.join(""), spans };
}
function normalizeQuery(query: string): string {
return query.replace(/\s+/gu, " ").trim();
}
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function findSearchCorpusMatches(corpus: SearchCorpus, normalizedQuery: string): AltScreenSearchMatch[] {
if (!normalizedQuery) return [];
const expression = new RegExp(escapeRegExp(normalizedQuery), "giu");
const matches: AltScreenSearchMatch[] = [];
let spanIndex = 0;
for (const match of corpus.text.matchAll(expression)) {
const start = match.index;
const end = start + match[0].length;
while (spanIndex < corpus.spans.length && corpus.spans[spanIndex]!.textEnd <= start) spanIndex += 1;
const segments: AltScreenSearchSegment[] = [];
for (let index = spanIndex; index < corpus.spans.length; index++) {
const span = corpus.spans[index]!;
if (span.textStart >= end) break;
if (span.textEnd <= start) continue;
const startCol = span.linearColumns
? span.startCol + Math.max(start, span.textStart) - span.textStart
: span.startCol;
const endCol = span.linearColumns ? span.startCol + Math.min(end, span.textEnd) - span.textStart : span.endCol;
const previous = segments[segments.length - 1];
if (previous && previous.row === span.row && startCol <= previous.endCol) {
previous.endCol = Math.max(previous.endCol, endCol);
} else {
segments.push({ row: span.row, startCol, endCol });
}
}
while (spanIndex < corpus.spans.length && corpus.spans[spanIndex]!.textEnd <= end) spanIndex += 1;
if (segments.length > 0) matches.push({ segments });
}
return matches;
}
export interface AltScreenSearchResult {
matches: AltScreenSearchMatch[];
changed: boolean;
}
/** Cache the searchable corpus and matches while rendered transcript lines remain unchanged. */
export class AltScreenSearchIndex {
private sourceLines: string[] | undefined;
private corpus: SearchCorpus | undefined;
private normalizedQuery: string | undefined;
private matches: AltScreenSearchMatch[] = [];
search(lines: readonly string[], query: string): AltScreenSearchResult {
let sourceChanged = this.sourceLines?.length !== lines.length;
if (!sourceChanged && this.sourceLines) {
for (let index = 0; index < lines.length; index++) {
if (this.sourceLines[index] === lines[index]) continue;
sourceChanged = true;
break;
}
}
if (sourceChanged || !this.corpus) {
this.sourceLines = Array.from(lines);
this.corpus = buildSearchCorpus(lines);
}
const normalizedQuery = normalizeQuery(query);
const changed = sourceChanged || normalizedQuery !== this.normalizedQuery;
if (changed) {
this.normalizedQuery = normalizedQuery;
this.matches = findSearchCorpusMatches(this.corpus, normalizedQuery);
}
return { matches: this.matches, changed };
}
}
export function findAltScreenSearchMatches(lines: readonly string[], query: string): AltScreenSearchMatch[] {
const normalizedQuery = normalizeQuery(query);
return normalizedQuery ? findSearchCorpusMatches(buildSearchCorpus(lines), normalizedQuery) : [];
}
export function getAltScreenSearchMatchKey(match: AltScreenSearchMatch): string {
const first = match.segments[0];
const last = match.segments[match.segments.length - 1];
return first && last ? `${first.row}:${first.startCol}:${last.row}:${last.endCol}` : "";
}
export class AltScreenSearchComponent implements Component, Focusable {
private readonly input = new Input({
prompt: " ",
placeholder: "Find in transcript",
placeholderStyle: (text) => `\x1b[2m${text}\x1b[22m`,
});
private readonly onQueryChange: (query: string) => void;
private readonly navigationButtonStyle: (text: string, hovered: boolean) => string;
private resultCount = 0;
private resultIndex = -1;
private previousButtonStart = -1;
private previousButtonEnd = -1;
private nextButtonStart = -1;
private nextButtonEnd = -1;
private hoveredNavigationDirection: -1 | 1 | undefined;
private _focused = false;
constructor(
onQueryChange: (query: string) => void,
navigationButtonStyle: (text: string, hovered: boolean) => string = (text) => text,
) {
this.onQueryChange = onQueryChange;
this.navigationButtonStyle = navigationButtonStyle;
}
get focused(): boolean {
return this._focused;
}
set focused(value: boolean) {
this._focused = value;
this.input.focused = value;
}
setResult(index: number, count: number): void {
this.resultIndex = index;
this.resultCount = count;
}
getNavigationDirectionAt(row: number, column: number): -1 | 1 | undefined {
if (row !== 2) return undefined;
if (column >= this.previousButtonStart && column < this.previousButtonEnd) return -1;
if (column >= this.nextButtonStart && column < this.nextButtonEnd) return 1;
return undefined;
}
setHoveredNavigationDirection(direction: -1 | 1 | undefined): boolean {
if (direction === this.hoveredNavigationDirection) return false;
this.hoveredNavigationDirection = direction;
return true;
}
handleInput(data: string): void {
const previous = this.input.getValue();
this.input.handleInput(data);
const query = this.input.getValue();
if (query !== previous) this.onQueryChange(query);
}
invalidate(): void {
this.input.invalidate();
}
render(width: number): string[] {
const safeWidth = Math.max(1, width);
const innerWidth = Math.max(0, safeWidth - 2);
const formatKey = (key: string | undefined): string =>
key
? key
.split("+")
.map((part) => {
if (process.platform === "darwin" && part.toLowerCase() === "alt") return "Option";
return part.charAt(0).toUpperCase() + part.slice(1);
})
.join("+")
: "Unbound";
const keybindings = getKeybindings();
const previousKey = formatKey(keybindings.getKeys("tui.altScreen.searchPrevious")[0]);
const nextKey = formatKey(keybindings.getKeys("tui.altScreen.searchNext")[0]);
const query = this.input.getValue();
const result = !query
? ""
: this.resultCount === 0
? "No matches"
: `${this.resultIndex + 1}/${this.resultCount}`;
const resultSpace = Math.max(0, innerWidth - 3);
const visibleResult = truncateToWidth(result, resultSpace, "");
const resultText = visibleResult ? `\x1b[2m ${visibleResult} \x1b[22m` : "";
const inputWidth = Math.max(0, innerWidth - visibleWidth(resultText));
const inputLine = truncateToWidth(this.input.render(Math.max(1, inputWidth))[0] ?? "", inputWidth, "");
const inputPadding = " ".repeat(Math.max(0, inputWidth - visibleWidth(inputLine)));
const content = `${inputLine}${inputPadding}${resultText}`;
let previousButton = `↑ ${previousKey}`;
let nextButton = `↓ ${nextKey}`;
let separator = " · ";
const outerGapWidth = 1;
const availableControlsWidth = Math.max(0, innerWidth - outerGapWidth * 2 - 1);
let controlsWidth = visibleWidth(previousButton) + visibleWidth(separator) + visibleWidth(nextButton);
if (controlsWidth > availableControlsWidth) {
previousButton = "↑";
nextButton = "↓";
separator = " ";
controlsWidth = visibleWidth(previousButton) + visibleWidth(separator) + visibleWidth(nextButton);
}
const showButtons = controlsWidth <= availableControlsWidth;
const renderedButtons = showButtons
? this.navigationButtonStyle(previousButton, this.hoveredNavigationDirection === -1) +
separator +
this.navigationButtonStyle(nextButton, this.hoveredNavigationDirection === 1)
: "";
const outerGapsWidth = showButtons ? outerGapWidth * 2 : 0;
const rightRuleWidth = renderedButtons && innerWidth > controlsWidth + outerGapsWidth ? 1 : 0;
const leftRuleWidth = Math.max(
0,
innerWidth - (showButtons ? controlsWidth : 0) - outerGapsWidth - rightRuleWidth,
);
const previousStart = 1 + leftRuleWidth + outerGapWidth;
this.previousButtonStart = showButtons ? previousStart : -1;
this.previousButtonEnd = showButtons ? previousStart + visibleWidth(previousButton) : -1;
this.nextButtonStart = showButtons ? this.previousButtonEnd + visibleWidth(separator) : -1;
this.nextButtonEnd = showButtons ? this.nextButtonStart + visibleWidth(nextButton) : -1;
if (safeWidth === 1) return ["┌", "│", "└"];
return [
`┌${"─".repeat(innerWidth)}┐`,
`│${content}│`,
`└${"─".repeat(leftRuleWidth)}${renderedButtons ? " " : ""}${renderedButtons}${renderedButtons ? " " : ""}${"─".repeat(rightRuleWidth)}┘`,
];
}
}

View File

@@ -0,0 +1,826 @@
import { spawn } from "child_process";
import { readdirSync, statSync } from "fs";
import { homedir } from "os";
import { basename, dirname, join } from "path";
import { fuzzyFilter } from "./fuzzy.ts";
const PATH_DELIMITERS = new Set([" ", "\t", '"', "'", "="]);
function toDisplayPath(value: string): string {
return value.replace(/\\/g, "/");
}
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function buildFdPathQuery(query: string): string {
const normalized = toDisplayPath(query);
if (!normalized.includes("/")) {
return normalized;
}
const hasTrailingSeparator = normalized.endsWith("/");
const trimmed = normalized.replace(/^\/+|\/+$/g, "");
if (!trimmed) {
return normalized;
}
const separatorPattern = "[\\\\/]";
const segments = trimmed
.split("/")
.filter(Boolean)
.map((segment) => escapeRegex(segment));
if (segments.length === 0) {
return normalized;
}
let pattern = segments.join(separatorPattern);
if (hasTrailingSeparator) {
pattern += separatorPattern;
}
return pattern;
}
function findLastDelimiter(text: string): number {
for (let i = text.length - 1; i >= 0; i -= 1) {
if (PATH_DELIMITERS.has(text[i] ?? "")) {
return i;
}
}
return -1;
}
function findUnclosedQuoteStart(text: string): number | null {
let inQuotes = false;
let quoteStart = -1;
for (let i = 0; i < text.length; i += 1) {
if (text[i] === '"') {
inQuotes = !inQuotes;
if (inQuotes) {
quoteStart = i;
}
}
}
return inQuotes ? quoteStart : null;
}
function isTokenStart(text: string, index: number): boolean {
return index === 0 || PATH_DELIMITERS.has(text[index - 1] ?? "");
}
function extractQuotedPrefix(text: string): string | null {
const quoteStart = findUnclosedQuoteStart(text);
if (quoteStart === null) {
return null;
}
if (quoteStart > 0 && text[quoteStart - 1] === "@") {
if (!isTokenStart(text, quoteStart - 1)) {
return null;
}
return text.slice(quoteStart - 1);
}
if (!isTokenStart(text, quoteStart)) {
return null;
}
return text.slice(quoteStart);
}
function parsePathPrefix(prefix: string): { rawPrefix: string; isAtPrefix: boolean; isQuotedPrefix: boolean } {
if (prefix.startsWith('@"')) {
return { rawPrefix: prefix.slice(2), isAtPrefix: true, isQuotedPrefix: true };
}
if (prefix.startsWith('"')) {
return { rawPrefix: prefix.slice(1), isAtPrefix: false, isQuotedPrefix: true };
}
if (prefix.startsWith("@")) {
return { rawPrefix: prefix.slice(1), isAtPrefix: true, isQuotedPrefix: false };
}
return { rawPrefix: prefix, isAtPrefix: false, isQuotedPrefix: false };
}
function buildCompletionValue(
path: string,
options: { isDirectory: boolean; isAtPrefix: boolean; isQuotedPrefix: boolean },
): string {
const needsQuotes = options.isQuotedPrefix || path.includes(" ");
const prefix = options.isAtPrefix ? "@" : "";
if (!needsQuotes) {
return `${prefix}${path}`;
}
const openQuote = `${prefix}"`;
const closeQuote = '"';
return `${openQuote}${path}${closeQuote}`;
}
// Use fd to walk directory tree (fast, respects .gitignore)
async function walkDirectoryWithFd(
baseDir: string,
fdPath: string,
query: string,
maxResults: number,
signal: AbortSignal,
maxDepth?: number,
): Promise<Array<{ path: string; isDirectory: boolean }>> {
const args = [
"--base-directory",
baseDir,
"--max-results",
String(maxResults),
"--type",
"f",
"--type",
"d",
"--follow",
"--hidden",
"--exclude",
".git",
"--exclude",
".git/*",
"--exclude",
".git/**",
];
if (maxDepth !== undefined) {
args.push("--max-depth", String(maxDepth));
}
if (toDisplayPath(query).includes("/")) {
args.push("--full-path");
}
if (query) {
args.push(buildFdPathQuery(query));
}
return await new Promise((resolve) => {
if (signal.aborted) {
resolve([]);
return;
}
const child = spawn(fdPath, args, {
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let resolved = false;
const finish = (results: Array<{ path: string; isDirectory: boolean }>) => {
if (resolved) return;
resolved = true;
signal.removeEventListener("abort", onAbort);
resolve(results);
};
const onAbort = () => {
if (child.exitCode === null) {
child.kill("SIGKILL");
}
};
signal.addEventListener("abort", onAbort, { once: true });
child.stdout.setEncoding("utf-8");
child.stdout.on("data", (chunk: string) => {
stdout += chunk;
});
child.on("error", () => {
finish([]);
});
child.on("close", (code) => {
if (signal.aborted || code !== 0 || !stdout) {
finish([]);
return;
}
const lines = stdout.trim().split("\n").filter(Boolean);
const results: Array<{ path: string; isDirectory: boolean }> = [];
for (const line of lines) {
const displayLine = toDisplayPath(line);
const hasTrailingSeparator = displayLine.endsWith("/");
const normalizedPath = hasTrailingSeparator ? displayLine.slice(0, -1) : displayLine;
if (normalizedPath === ".git" || normalizedPath.startsWith(".git/") || normalizedPath.includes("/.git/")) {
continue;
}
results.push({
path: displayLine,
isDirectory: hasTrailingSeparator,
});
}
finish(results);
});
});
}
export interface AutocompleteItem {
value: string;
label: string;
description?: string;
}
type Awaitable<T> = T | Promise<T>;
export interface SlashCommand {
name: string;
description?: string;
argumentHint?: string;
// Function to get argument completions for this command
// Returns null if no argument completion is available
getArgumentCompletions?(argumentPrefix: string): Awaitable<AutocompleteItem[] | null>;
}
export interface AutocompleteSuggestions {
items: AutocompleteItem[];
prefix: string; // What we're matching against (e.g., "/" or "src/")
}
export interface AutocompleteProvider {
/** Characters that should naturally trigger this provider at token boundaries. */
triggerCharacters?: string[];
// Get autocomplete suggestions for current text/cursor position
// Returns null if no suggestions available
getSuggestions(
lines: string[],
cursorLine: number,
cursorCol: number,
options: { signal: AbortSignal; force?: boolean },
): Promise<AutocompleteSuggestions | null>;
// Apply the selected item
// Returns the new text and cursor position
applyCompletion(
lines: string[],
cursorLine: number,
cursorCol: number,
item: AutocompleteItem,
prefix: string,
): {
lines: string[];
cursorLine: number;
cursorCol: number;
};
// Check if file completion should trigger for explicit Tab completion
shouldTriggerFileCompletion?(lines: string[], cursorLine: number, cursorCol: number): boolean;
}
// 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, fdPath: string | null = null) {
this.commands = commands;
this.basePath = basePath;
this.fdPath = fdPath;
}
async getSuggestions(
lines: string[],
cursorLine: number,
cursorCol: number,
options: { signal: AbortSignal; force?: boolean },
): Promise<AutocompleteSuggestions | null> {
const currentLine = lines[cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, cursorCol);
const atPrefix = this.extractAtPrefix(textBeforeCursor);
if (atPrefix) {
const { rawPrefix, isQuotedPrefix } = parsePathPrefix(atPrefix);
const suggestions = await this.getFuzzyFileSuggestions(rawPrefix, {
isQuotedPrefix,
signal: options.signal,
});
if (suggestions.length === 0) return null;
return {
items: suggestions,
prefix: atPrefix,
};
}
if (!options.force && textBeforeCursor.startsWith("/")) {
const spaceIndex = textBeforeCursor.indexOf(" ");
if (spaceIndex === -1) {
const prefix = textBeforeCursor.slice(1);
const commandItems = this.commands.map((cmd) => {
const name = "name" in cmd ? cmd.name : cmd.value;
const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
const desc = cmd.description ?? "";
const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;
return {
name,
label: name,
description: fullDesc || undefined,
};
});
const filtered = fuzzyFilter(commandItems, prefix, (item) => item.name).map((item) => ({
value: item.name,
label: item.label,
...(item.description && { description: item.description }),
}));
if (filtered.length === 0) return null;
return {
items: filtered,
prefix: textBeforeCursor,
};
}
const commandName = textBeforeCursor.slice(1, spaceIndex);
const argumentText = textBeforeCursor.slice(spaceIndex + 1);
const command = this.commands.find((cmd) => {
const name = "name" in cmd ? cmd.name : cmd.value;
return name === commandName;
});
if (!command || !("getArgumentCompletions" in command) || !command.getArgumentCompletions) {
return null;
}
const argumentSuggestions = await command.getArgumentCompletions(argumentText);
if (!Array.isArray(argumentSuggestions) || argumentSuggestions.length === 0) {
return null;
}
return {
items: argumentSuggestions,
prefix: argumentText,
};
}
const pathMatch = this.extractPathPrefix(textBeforeCursor, options.force ?? false);
if (pathMatch === null) {
return null;
}
const suggestions = this.getFileSuggestions(pathMatch);
if (suggestions.length === 0) return null;
return {
items: suggestions,
prefix: pathMatch,
};
}
applyCompletion(
lines: string[],
cursorLine: number,
cursorCol: number,
item: AutocompleteItem,
prefix: string,
): { lines: string[]; cursorLine: number; cursorCol: number } {
const currentLine = lines[cursorLine] || "";
const beforePrefix = currentLine.slice(0, cursorCol - prefix.length);
const afterCursor = currentLine.slice(cursorCol);
const isQuotedPrefix = prefix.startsWith('"') || prefix.startsWith('@"');
const hasLeadingQuoteAfterCursor = afterCursor.startsWith('"');
const hasTrailingQuoteInItem = item.value.endsWith('"');
const adjustedAfterCursor =
isQuotedPrefix && hasTrailingQuoteInItem && hasLeadingQuoteAfterCursor ? afterCursor.slice(1) : afterCursor;
// Check if we're completing a slash command (prefix starts with "/" but NOT a file path)
// Slash commands are at the start of the line and don't contain path separators after the first /
const isSlashCommand = prefix.startsWith("/") && beforePrefix.trim() === "" && !prefix.slice(1).includes("/");
if (isSlashCommand) {
// This is a command name completion
const newLine = `${beforePrefix}/${item.value} ${adjustedAfterCursor}`;
const newLines = [...lines];
newLines[cursorLine] = newLine;
return {
lines: newLines,
cursorLine,
cursorCol: beforePrefix.length + item.value.length + 2, // +2 for "/" and space
};
}
// Check if we're completing a file attachment (prefix starts with "@")
if (prefix.startsWith("@")) {
// This is a file attachment completion
// Don't add space after directories so user can continue autocompleting
const isDirectory = item.label.endsWith("/");
const suffix = isDirectory ? "" : " ";
const newLine = `${beforePrefix + item.value}${suffix}${adjustedAfterCursor}`;
const newLines = [...lines];
newLines[cursorLine] = newLine;
const hasTrailingQuote = item.value.endsWith('"');
const cursorOffset = isDirectory && hasTrailingQuote ? item.value.length - 1 : item.value.length;
return {
lines: newLines,
cursorLine,
cursorCol: beforePrefix.length + cursorOffset + suffix.length,
};
}
// Check if we're in a slash command context (beforePrefix contains "/command ")
const textBeforeCursor = currentLine.slice(0, cursorCol);
if (textBeforeCursor.includes("/") && textBeforeCursor.includes(" ")) {
// This is likely a command argument completion
const newLine = beforePrefix + item.value + adjustedAfterCursor;
const newLines = [...lines];
newLines[cursorLine] = newLine;
const isDirectory = item.label.endsWith("/");
const hasTrailingQuote = item.value.endsWith('"');
const cursorOffset = isDirectory && hasTrailingQuote ? item.value.length - 1 : item.value.length;
return {
lines: newLines,
cursorLine,
cursorCol: beforePrefix.length + cursorOffset,
};
}
// For file paths, complete the path
const newLine = beforePrefix + item.value + adjustedAfterCursor;
const newLines = [...lines];
newLines[cursorLine] = newLine;
const isDirectory = item.label.endsWith("/");
const hasTrailingQuote = item.value.endsWith('"');
const cursorOffset = isDirectory && hasTrailingQuote ? item.value.length - 1 : item.value.length;
return {
lines: newLines,
cursorLine,
cursorCol: beforePrefix.length + cursorOffset,
};
}
// Extract @ prefix for fuzzy file suggestions
private extractAtPrefix(text: string): string | null {
const quotedPrefix = extractQuotedPrefix(text);
if (quotedPrefix?.startsWith('@"')) {
return quotedPrefix;
}
const lastDelimiterIndex = findLastDelimiter(text);
const tokenStart = lastDelimiterIndex === -1 ? 0 : lastDelimiterIndex + 1;
if (text[tokenStart] === "@") {
return text.slice(tokenStart);
}
return null;
}
// Extract a path-like prefix from the text before cursor
private extractPathPrefix(text: string, forceExtract: boolean = false): string | null {
const quotedPrefix = extractQuotedPrefix(text);
if (quotedPrefix) {
return quotedPrefix;
}
const lastDelimiterIndex = findLastDelimiter(text);
const pathPrefix = lastDelimiterIndex === -1 ? text : text.slice(lastDelimiterIndex + 1);
// For forced extraction (Tab key), always return something
if (forceExtract) {
return pathPrefix;
}
// For natural triggers, return if it looks like a path, ends with /, starts with ~/, .
// Only return empty string if the text looks like it's starting a path context
if (pathPrefix.includes("/") || pathPrefix.startsWith(".") || pathPrefix.startsWith("~/")) {
return pathPrefix;
}
// Return empty string only after a space (not for completely empty text)
// Empty text should not trigger file suggestions - that's for forced Tab completion
if (pathPrefix === "" && text.endsWith(" ")) {
return pathPrefix;
}
return null;
}
// Expand home directory (~/) to actual home path
private expandHomePath(path: string): string {
if (path.startsWith("~/")) {
const expandedPath = join(homedir(), path.slice(2));
// Preserve trailing slash if original path had one
return path.endsWith("/") && !expandedPath.endsWith("/") ? `${expandedPath}/` : expandedPath;
} else if (path === "~") {
return homedir();
}
return path;
}
private resolveScopedFuzzyQuery(rawQuery: string): { baseDir: string; query: string; displayBase: string } | null {
const normalizedQuery = toDisplayPath(rawQuery);
const slashIndex = normalizedQuery.lastIndexOf("/");
if (slashIndex === -1) {
return null;
}
const displayBase = normalizedQuery.slice(0, slashIndex + 1);
const query = normalizedQuery.slice(slashIndex + 1);
let baseDir: string;
if (displayBase.startsWith("~/")) {
baseDir = this.expandHomePath(displayBase);
} else if (displayBase.startsWith("/")) {
baseDir = displayBase;
} else {
baseDir = join(this.basePath, displayBase);
}
try {
if (!statSync(baseDir).isDirectory()) {
return null;
}
} catch {
return null;
}
return { baseDir, query, displayBase };
}
private scopedPathForDisplay(displayBase: string, relativePath: string): string {
const normalizedRelativePath = toDisplayPath(relativePath);
if (displayBase === "/") {
return `/${normalizedRelativePath}`;
}
return `${toDisplayPath(displayBase)}${normalizedRelativePath}`;
}
// Get file/directory suggestions for a given path prefix
private getFileSuggestions(prefix: string): AutocompleteItem[] {
try {
let searchDir: string;
let searchPrefix: string;
const { rawPrefix, isAtPrefix, isQuotedPrefix } = parsePathPrefix(prefix);
let expandedPrefix = rawPrefix;
// Handle home directory expansion
if (expandedPrefix.startsWith("~")) {
expandedPrefix = this.expandHomePath(expandedPrefix);
}
const isRootPrefix =
rawPrefix === "" ||
rawPrefix === "./" ||
rawPrefix === "../" ||
rawPrefix === "~" ||
rawPrefix === "~/" ||
rawPrefix === "/" ||
(isAtPrefix && rawPrefix === "");
if (isRootPrefix) {
// Complete from specified position
if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
searchDir = expandedPrefix;
} else {
searchDir = join(this.basePath, expandedPrefix);
}
searchPrefix = "";
} else if (rawPrefix.endsWith("/")) {
// If prefix ends with /, show contents of that directory
if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
searchDir = expandedPrefix;
} else {
searchDir = join(this.basePath, expandedPrefix);
}
searchPrefix = "";
} else {
// Split into directory and file prefix
const dir = dirname(expandedPrefix);
const file = basename(expandedPrefix);
if (rawPrefix.startsWith("~") || expandedPrefix.startsWith("/")) {
searchDir = dir;
} else {
searchDir = join(this.basePath, dir);
}
searchPrefix = file;
}
const entries = readdirSync(searchDir, { withFileTypes: true });
const suggestions: AutocompleteItem[] = [];
for (const entry of entries) {
if (!entry.name.toLowerCase().startsWith(searchPrefix.toLowerCase())) {
continue;
}
// Check if entry is a directory (or a symlink pointing to a directory)
let isDirectory = entry.isDirectory();
if (!isDirectory && entry.isSymbolicLink()) {
try {
const fullPath = join(searchDir, entry.name);
isDirectory = statSync(fullPath).isDirectory();
} catch {
// Broken symlink or permission error - treat as file
}
}
let relativePath: string;
const name = entry.name;
const displayPrefix = rawPrefix;
if (displayPrefix.endsWith("/")) {
// If prefix ends with /, append entry to the prefix
relativePath = displayPrefix + name;
} else if (displayPrefix.includes("/") || displayPrefix.includes("\\")) {
// Preserve ~/ format for home directory paths
if (displayPrefix.startsWith("~/")) {
const homeRelativeDir = displayPrefix.slice(2); // Remove ~/
const dir = dirname(homeRelativeDir);
relativePath = `~/${dir === "." ? name : join(dir, name)}`;
} else if (displayPrefix.startsWith("/")) {
// Absolute path - construct properly
const dir = dirname(displayPrefix);
if (dir === "/") {
relativePath = `/${name}`;
} else {
relativePath = `${dir}/${name}`;
}
} else {
relativePath = join(dirname(displayPrefix), name);
// path.join normalizes away ./ prefix, preserve it
if (displayPrefix.startsWith("./") && !relativePath.startsWith("./")) {
relativePath = `./${relativePath}`;
}
}
} else {
// For standalone entries, preserve ~/ if original prefix was ~/
if (displayPrefix.startsWith("~")) {
relativePath = `~/${name}`;
} else {
relativePath = name;
}
}
relativePath = toDisplayPath(relativePath);
const pathValue = isDirectory ? `${relativePath}/` : relativePath;
const value = buildCompletionValue(pathValue, {
isDirectory,
isAtPrefix,
isQuotedPrefix,
});
suggestions.push({
value,
label: name + (isDirectory ? "/" : ""),
});
}
// Sort directories first, then alphabetically
suggestions.sort((a, b) => {
const aIsDir = a.value.endsWith("/");
const bIsDir = b.value.endsWith("/");
if (aIsDir && !bIsDir) return -1;
if (!aIsDir && bIsDir) return 1;
return a.label.localeCompare(b.label);
});
return suggestions;
} catch (_e) {
// Directory doesn't exist or not accessible
return [];
}
}
// Score an entry against the query (higher = better match)
// isDirectory adds bonus to prioritize folders
private scoreEntry(filePath: string, query: string, isDirectory: boolean): number {
const fileName = basename(filePath);
const lowerFileName = fileName.toLowerCase();
const lowerQuery = query.toLowerCase();
let score = 0;
// Exact filename match (highest)
if (lowerFileName === lowerQuery) score = 100;
// Filename starts with query
else if (lowerFileName.startsWith(lowerQuery)) score = 80;
// Substring match in filename
else if (lowerFileName.includes(lowerQuery)) score = 50;
// Substring match in full path
else if (filePath.toLowerCase().includes(lowerQuery)) score = 30;
// Directories get a bonus to appear first
if (isDirectory && score > 0) score += 10;
return score;
}
private async getBaseDirSuggestions(
baseDir: string,
query: string,
signal: AbortSignal,
): Promise<Array<{ path: string; isDirectory: boolean }>> {
if (!this.fdPath || signal.aborted) {
return [];
}
return await walkDirectoryWithFd(baseDir, this.fdPath, query, 100, signal, 1);
}
// Fuzzy file search using fd (fast, respects .gitignore)
private async getFuzzyFileSuggestions(
query: string,
options: { isQuotedPrefix: boolean; signal: AbortSignal },
): Promise<AutocompleteItem[]> {
if (!this.fdPath || options.signal.aborted) {
return [];
}
try {
const scopedQuery = this.resolveScopedFuzzyQuery(query);
const fdBaseDir = scopedQuery?.baseDir ?? this.basePath;
const fdQuery = scopedQuery?.query ?? query;
const baseDirEntries = await this.getBaseDirSuggestions(fdBaseDir, fdQuery, options.signal);
const recursiveEntries = await walkDirectoryWithFd(fdBaseDir, this.fdPath, fdQuery, 100, options.signal);
const seenPaths = new Set(baseDirEntries.map((entry) => entry.path));
const entries = [
...baseDirEntries,
...recursiveEntries.filter((entry) => {
if (seenPaths.has(entry.path)) return false;
seenPaths.add(entry.path);
return true;
}),
];
if (options.signal.aborted) {
return [];
}
const scoredEntries = entries
.map((entry) => ({
...entry,
score: fdQuery ? this.scoreEntry(entry.path, fdQuery, entry.isDirectory) : 1,
}))
.filter((entry) => entry.score > 0);
scoredEntries.sort((a, b) => {
const scoreDiff = b.score - a.score;
if (scoreDiff !== 0) return scoreDiff;
const aDepth = toDisplayPath(a.path).split("/").filter(Boolean).length;
const bDepth = toDisplayPath(b.path).split("/").filter(Boolean).length;
const depthDiff = aDepth - bDepth;
if (depthDiff !== 0) return depthDiff;
const lengthDiff = a.path.length - b.path.length;
if (lengthDiff !== 0) return lengthDiff;
return a.path.localeCompare(b.path);
});
const topEntries = scoredEntries.slice(0, 20);
const suggestions: AutocompleteItem[] = [];
for (const { path: entryPath, isDirectory } of topEntries) {
const pathWithoutSlash = isDirectory ? entryPath.slice(0, -1) : entryPath;
const displayPath = scopedQuery
? this.scopedPathForDisplay(scopedQuery.displayBase, pathWithoutSlash)
: pathWithoutSlash;
const entryName = basename(pathWithoutSlash);
const completionPath = isDirectory ? `${displayPath}/` : displayPath;
const value = buildCompletionValue(completionPath, {
isDirectory,
isAtPrefix: true,
isQuotedPrefix: options.isQuotedPrefix,
});
suggestions.push({
value,
label: entryName + (isDirectory ? "/" : ""),
description: displayPath,
});
}
return suggestions;
} catch {
return [];
}
}
// Check if we should trigger file completion (called on Tab key)
shouldTriggerFileCompletion(lines: string[], cursorLine: number, cursorCol: number): boolean {
const currentLine = lines[cursorLine] || "";
const textBeforeCursor = currentLine.slice(0, cursorCol);
// Don't trigger if we're typing a slash command at the start of the line
if (textBeforeCursor.trim().startsWith("/") && !textBeforeCursor.trim().includes(" ")) {
return false;
}
return true;
}
}

View File

@@ -0,0 +1,51 @@
import type { Component } from "../tui.ts";
import { truncateToWidth } from "../utils.ts";
const DEFAULT_DURATION_MS = 1000;
interface FlashEntry {
id: number;
message: string;
timer: NodeJS.Timeout;
}
/** Transient messages composited by the alternate-screen renderer. */
export class AltScreenFlashContainer implements Component {
private readonly entries: FlashEntry[] = [];
private nextId = 0;
private readonly requestRender: () => void;
constructor(requestRender: () => void) {
this.requestRender = requestRender;
}
flash(message: string, durationMs = DEFAULT_DURATION_MS): void {
const id = this.nextId++;
const timer = setTimeout(
() => {
const index = this.entries.findIndex((entry) => entry.id === id);
if (index === -1) return;
this.entries.splice(index, 1);
this.requestRender();
},
Math.max(0, durationMs),
);
timer.unref();
this.entries.push({ id, message, timer });
this.requestRender();
}
dispose(): void {
for (const entry of this.entries) clearTimeout(entry.timer);
this.entries.length = 0;
}
invalidate(): void {}
render(width: number): string[] {
return this.entries.map((entry) => {
const message = truncateToWidth(` ${entry.message} `, width, "");
return `\x1b[7m${message}\x1b[27m`;
});
}
}

View File

@@ -0,0 +1,167 @@
import { type Component, dispatchMouseEvent, type TuiMouseDispatchResult, type TuiMouseEvent } from "../tui.ts";
import { applyBackgroundToLine, visibleWidth } from "../utils.ts";
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;
private mouseLayout?: { width: number; children: Array<{ component: Component; height: number }> };
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?.();
}
}
handleMouse(event: TuiMouseEvent): TuiMouseDispatchResult | undefined {
const contentWidth = Math.max(1, event.width - this.paddingX * 2);
const contentY = event.y - this.paddingY;
const contentX = event.x - this.paddingX;
if (contentY < 0 || contentX < 0 || contentX >= contentWidth) return undefined;
const mouseChildren =
this.mouseLayout?.width === contentWidth
? this.mouseLayout.children
: this.children.map((component) => ({ component, height: component.render(contentWidth).length }));
let childY = 0;
for (const { component: child, height: childHeight } of mouseChildren) {
if (contentY >= childY && contentY < childY + childHeight) {
return dispatchMouseEvent(child, {
...event,
x: contentX,
y: contentY - childY,
width: contentWidth,
height: childHeight,
});
}
childY += childHeight;
}
return undefined;
}
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[] = [];
const mouseChildren: Array<{ component: Component; height: number }> = [];
for (const child of this.children) {
const lines = child.render(contentWidth);
mouseChildren.push({ component: child, height: lines.length });
for (const line of lines) {
childLines.push(leftPad + line);
}
}
this.mouseLayout = { width: contentWidth, children: mouseChildren };
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;
}
}

View File

@@ -0,0 +1,40 @@
import { getKeybindings } from "../keybindings.ts";
import { Loader } from "./loader.ts";
/**
* Loader that can be cancelled with Escape.
* Extends Loader with an AbortSignal for cancelling async operations.
*
* @example
* const loader = new CancellableLoader(tui, cyan, dim, "Working...");
* loader.onAbort = () => done(null);
* doWork(loader.signal).then(done);
*/
export class CancellableLoader extends Loader {
private abortController = new AbortController();
/** Called when user presses Escape */
onAbort?: () => void;
/** AbortSignal that is aborted when user presses Escape */
get signal(): AbortSignal {
return this.abortController.signal;
}
/** Whether the loader was aborted */
get aborted(): boolean {
return this.abortController.signal.aborted;
}
handleInput(data: string): void {
const kb = getKeybindings();
if (kb.matches(data, "tui.select.cancel")) {
this.abortController.abort();
this.onAbort?.();
}
}
dispose(): void {
this.stop();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,44 @@
import { compositeTuiLine } from "../tui.ts";
import { visibleWidth } from "../utils.ts";
import { allocateStackSizes, Stack, type StackChild, type StackOptions, visibleStackEntries } from "./stack.ts";
export class HStack extends Stack {
protected readonly layoutType = "hstack" as const;
constructor(children: StackChild[] = [], options: StackOptions = {}) {
super(children, options);
}
override render(width: number): string[] {
const safeWidth = Math.max(1, width);
const viewport = { width: safeWidth, height: Number.MAX_SAFE_INTEGER };
const entries = visibleStackEntries(this.entries, viewport);
if (entries.length === 0) return [];
const intrinsicWidths = entries.map((entry) => {
const lines = entry.component.render(safeWidth);
return lines.reduce((max, line) => Math.max(max, visibleWidth(line)), 0);
});
const widths = allocateStackSizes(entries, intrinsicWidths, safeWidth, this.gap);
const rendered = entries.map((entry, index) =>
widths[index] === 0 ? [] : entry.component.render(widths[index]!),
);
const height = rendered.reduce((max, lines) => Math.max(max, lines.length), 0);
const result = Array.from({ length: height }, () => "");
let x = 0;
for (let index = 0; index < rendered.length; index++) {
const lines = rendered[index]!;
const childWidth = widths[index]!;
let offset = 0;
if (this.align === "center") offset = Math.floor((height - lines.length) / 2);
else if (this.align === "end") offset = height - lines.length;
for (let row = 0; row < lines.length; row++) {
const target = row + offset;
if (target < 0 || target >= result.length) continue;
result[target] = compositeTuiLine(result[target]!, lines[row]!, x, childWidth, safeWidth);
}
x += childWidth + this.gap;
}
return result;
}
}

View File

@@ -0,0 +1,127 @@
import {
allocateImageId,
getCapabilities,
getCellDimensions,
getImageDimensions,
type ImageDimensions,
imageFallback,
renderImage,
} from "../terminal-image.ts";
import type { Component } from "../tui.ts";
import { truncateToWidth } from "../utils.ts";
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.max(1, Math.min(width - 2, this.options.maxWidthCells ?? 60));
const cellDimensions = getCellDimensions();
const defaultMaxHeight = Math.max(1, Math.ceil((maxWidth * cellDimensions.widthPx) / cellDimensions.heightPx));
const maxHeight = this.options.maxHeightCells ?? defaultMaxHeight;
const caps = getCapabilities();
let lines: string[];
if (caps.images) {
if (caps.images === "kitty" && this.imageId === undefined) {
this.imageId = allocateImageId();
}
const result = renderImage(this.base64Data, this.dimensions, {
maxWidthCells: maxWidth,
maxHeightCells: maxHeight,
imageId: this.imageId,
moveCursor: false,
});
if (result) {
// Store the image ID for later cleanup
if (result.imageId) {
this.imageId = result.imageId;
}
if (caps.images === "kitty") {
// For Kitty: C=1 prevents cursor movement.
// Don't need the cursor movement.
lines = [result.sequence];
// Return `rows` lines so TUI accounts for image height.
for (let i = 0; i < result.rows - 1; i++) {
lines.push("");
}
} else {
// Return `rows` lines so TUI accounts for image height.
// First (rows-1) lines are empty and cleared before the image is drawn.
// Last line: move cursor back up, draw the image, then move back down
// so TUI cursor accounting stays inside the scroll area.
lines = [];
for (let i = 0; i < result.rows - 1; i++) {
lines.push("");
}
const rowOffset = result.rows - 1;
const moveUp = rowOffset > 0 ? `\x1b[${rowOffset}A` : "";
lines.push(moveUp + result.sequence);
}
} else {
const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename);
lines = [truncateToWidth(this.theme.fallbackColor(fallback), width)];
}
} else {
const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename);
lines = [truncateToWidth(this.theme.fallbackColor(fallback), width)];
}
this.cachedLines = lines;
this.cachedWidth = width;
return lines;
}
}

View File

@@ -0,0 +1,494 @@
import { getKeybindings } from "../keybindings.ts";
import { decodeKittyPrintable } from "../keys.ts";
import { KillRing } from "../kill-ring.ts";
import { type Component, CURSOR_MARKER, type Focusable, type TuiMouseEvent, type TuiMouseEventResult } from "../tui.ts";
import { UndoStack } from "../undo-stack.ts";
import { getGraphemeSegmenter, isWhitespaceChar, sliceByColumn, truncateToWidth, visibleWidth } from "../utils.ts";
import { findWordBackward, findWordForward } from "../word-navigation.ts";
const segmenter = getGraphemeSegmenter();
interface InputState {
value: string;
cursor: number;
}
export interface InputOptions {
prompt?: string;
placeholder?: string;
placeholderStyle?: (text: string) => string;
}
/**
* 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
private readonly prompt: string;
private readonly placeholder: string;
private readonly placeholderStyle: (text: string) => string;
private renderedStartColumn = 0;
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>();
constructor(options: InputOptions = {}) {
this.prompt = options.prompt ?? "> ";
this.placeholder = options.placeholder ?? "";
this.placeholderStyle = options.placeholderStyle ?? ((text) => text);
}
getValue(): string {
return this.value;
}
setValue(value: string): void {
this.value = value;
this.cursor = Math.min(this.cursor, value.length);
}
handleInput(data: string): void {
// Handle bracketed paste mode
// Start of paste: \x1b[200~
// End of paste: \x1b[201~
// Check if we're starting a bracketed paste
if (data.includes("\x1b[200~")) {
this.isInPaste = true;
this.pasteBuffer = "";
data = data.replace("\x1b[200~", "");
}
// If we're in a paste, buffer the data
if (this.isInPaste) {
// Check if this chunk contains the end marker
this.pasteBuffer += data;
const endIndex = this.pasteBuffer.indexOf("\x1b[201~");
if (endIndex !== -1) {
// Extract the pasted content
const pasteContent = this.pasteBuffer.substring(0, endIndex);
// Process the complete paste
this.handlePaste(pasteContent);
// Reset paste state
this.isInPaste = false;
// Handle any remaining input after the paste marker
const remaining = this.pasteBuffer.substring(endIndex + 6); // 6 = length of \x1b[201~
this.pasteBuffer = "";
if (remaining) {
this.handleInput(remaining);
}
}
return;
}
const kb = getKeybindings();
// Escape/Cancel
if (kb.matches(data, "tui.select.cancel")) {
if (this.onEscape) this.onEscape();
return;
}
// Undo
if (kb.matches(data, "tui.editor.undo")) {
this.undo();
return;
}
// Submit
if (kb.matches(data, "tui.input.submit") || data === "\n") {
if (this.onSubmit) this.onSubmit(this.value);
return;
}
// Deletion
if (kb.matches(data, "tui.editor.deleteCharBackward")) {
this.handleBackspace();
return;
}
if (kb.matches(data, "tui.editor.deleteCharForward")) {
this.handleForwardDelete();
return;
}
if (kb.matches(data, "tui.editor.deleteWordBackward")) {
this.deleteWordBackwards();
return;
}
if (kb.matches(data, "tui.editor.deleteWordForward")) {
this.deleteWordForward();
return;
}
if (kb.matches(data, "tui.editor.deleteToLineStart")) {
this.deleteToLineStart();
return;
}
if (kb.matches(data, "tui.editor.deleteToLineEnd")) {
this.deleteToLineEnd();
return;
}
// Kill ring actions
if (kb.matches(data, "tui.editor.yank")) {
this.yank();
return;
}
if (kb.matches(data, "tui.editor.yankPop")) {
this.yankPop();
return;
}
// Cursor movement
if (kb.matches(data, "tui.editor.cursorLeft")) {
this.lastAction = null;
if (this.cursor > 0) {
const beforeCursor = this.value.slice(0, this.cursor);
const graphemes = [...segmenter.segment(beforeCursor)];
const lastGrapheme = graphemes[graphemes.length - 1];
this.cursor -= lastGrapheme ? lastGrapheme.segment.length : 1;
}
return;
}
if (kb.matches(data, "tui.editor.cursorRight")) {
this.lastAction = null;
if (this.cursor < this.value.length) {
const afterCursor = this.value.slice(this.cursor);
const graphemes = [...segmenter.segment(afterCursor)];
const firstGrapheme = graphemes[0];
this.cursor += firstGrapheme ? firstGrapheme.segment.length : 1;
}
return;
}
if (kb.matches(data, "tui.editor.cursorLineStart")) {
this.lastAction = null;
this.cursor = 0;
return;
}
if (kb.matches(data, "tui.editor.cursorLineEnd")) {
this.lastAction = null;
this.cursor = this.value.length;
return;
}
if (kb.matches(data, "tui.editor.cursorWordLeft")) {
this.moveWordBackwards();
return;
}
if (kb.matches(data, "tui.editor.cursorWordRight")) {
this.moveWordForwards();
return;
}
// Kitty CSI-u printable character (e.g. \x1b[97u for 'a').
// Terminals with Kitty protocol flag 1 (disambiguate) send CSI-u for all keys,
// including plain printable characters. Decode before the control-char check
// since CSI-u sequences contain \x1b which would be rejected.
const kittyPrintable = decodeKittyPrintable(data);
if (kittyPrintable !== undefined) {
this.insertCharacter(kittyPrintable);
return;
}
// Regular character input - accept printable characters including Unicode,
// but reject control characters (C0: 0x00-0x1F, DEL: 0x7F, C1: 0x80-0x9F)
const hasControlChars = [...data].some((ch) => {
const code = ch.charCodeAt(0);
return code < 32 || code === 0x7f || (code >= 0x80 && code <= 0x9f);
});
if (!hasControlChars) {
this.insertCharacter(data);
}
}
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
if (event.type !== "press" || event.button !== "left" || event.y !== 0) return undefined;
const visibleColumn = Math.max(0, event.x - 2);
const targetColumn = this.renderedStartColumn + visibleColumn;
let currentColumn = 0;
this.cursor = this.value.length;
for (const grapheme of segmenter.segment(this.value)) {
const nextColumn = currentColumn + visibleWidth(grapheme.segment);
if (targetColumn < nextColumn) {
this.cursor = grapheme.index;
break;
}
currentColumn = nextColumn;
}
this.lastAction = null;
return { handled: true, focus: true };
}
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;
this.cursor = findWordBackward(this.value, this.cursor);
}
private moveWordForwards(): void {
if (this.cursor >= this.value.length) return;
this.lastAction = null;
this.cursor = findWordForward(this.value, this.cursor);
}
private handlePaste(pastedText: string): void {
this.lastAction = null;
this.pushUndo();
// Clean the pasted text - remove newlines and carriage returns
const cleanText = pastedText.replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, "").replace(/\t/g, " ");
// Insert at cursor position
this.value = this.value.slice(0, this.cursor) + cleanText + this.value.slice(this.cursor);
this.cursor += cleanText.length;
}
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] {
// Calculate visible window
const availableWidth = width - visibleWidth(this.prompt);
if (availableWidth <= 0) {
return [truncateToWidth(this.prompt, width, "")];
}
if (this.value.length === 0 && this.placeholder) {
const placeholder = truncateToWidth(this.placeholder, availableWidth, "");
const graphemes = [...segmenter.segment(placeholder)];
const atCursor = graphemes[0]?.segment ?? " ";
const afterCursor = placeholder.slice(atCursor.length);
const marker = this.focused ? CURSOR_MARKER : "";
const cursorChar = `\x1b[7m${this.placeholderStyle(atCursor)}\x1b[27m`;
const textWithCursor = marker + cursorChar + this.placeholderStyle(afterCursor);
const padding = " ".repeat(Math.max(0, availableWidth - visibleWidth(textWithCursor)));
return [this.prompt + textWithCursor + padding];
}
let visibleText = "";
let cursorDisplay = this.cursor;
this.renderedStartColumn = 0;
const totalWidth = visibleWidth(this.value);
if (totalWidth < availableWidth) {
// Everything fits (leave room for cursor at end)
visibleText = this.value;
} else {
// Need horizontal scrolling
// Reserve one column for cursor if it's at the end
const scrollWidth = this.cursor === this.value.length ? availableWidth - 1 : availableWidth;
const cursorCol = visibleWidth(this.value.slice(0, this.cursor));
if (scrollWidth > 0) {
const halfWidth = Math.floor(scrollWidth / 2);
let startCol = 0;
if (cursorCol < halfWidth) {
// Cursor near start
startCol = 0;
} else if (cursorCol > totalWidth - halfWidth) {
// Cursor near end
startCol = Math.max(0, totalWidth - scrollWidth);
} else {
// Cursor in middle
startCol = Math.max(0, cursorCol - halfWidth);
}
this.renderedStartColumn = startCol;
visibleText = sliceByColumn(this.value, startCol, scrollWidth, true);
const beforeCursor = sliceByColumn(this.value, startCol, Math.max(0, cursorCol - startCol), true);
cursorDisplay = beforeCursor.length;
} else {
visibleText = "";
cursorDisplay = 0;
}
}
// Build line with fake cursor
// Insert cursor character at cursor position
const graphemes = [...segmenter.segment(visibleText.slice(cursorDisplay))];
const cursorGrapheme = graphemes[0];
const beforeCursor = visibleText.slice(0, cursorDisplay);
const atCursor = cursorGrapheme?.segment ?? " "; // Character at cursor, or space if at end
const afterCursor = visibleText.slice(cursorDisplay + atCursor.length);
// Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning)
const marker = this.focused ? CURSOR_MARKER : "";
// Use inverse video to show cursor
const cursorChar = `\x1b[7m${atCursor}\x1b[27m`; // ESC[7m = reverse video, ESC[27m = normal
const textWithCursor = beforeCursor + marker + cursorChar + afterCursor;
// Calculate visual width
const visualLength = visibleWidth(textWithCursor);
const padding = " ".repeat(Math.max(0, availableWidth - visualLength));
const line = this.prompt + textWithCursor + padding;
return [line];
}
}

View File

@@ -0,0 +1,101 @@
import type { TUI } from "../tui.ts";
import { Text } from "./text.ts";
export interface LoaderIndicatorOptions {
/** Animation frames. Use an empty array to hide the indicator. */
frames?: string[];
/** Frame interval in milliseconds for animated indicators. */
intervalMs?: number;
}
const DEFAULT_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
const DEFAULT_INTERVAL_MS = 80;
/**
* Loader component that updates with an optional spinning animation.
*/
export class Loader extends Text {
private frames = [...DEFAULT_FRAMES];
private intervalMs = DEFAULT_INTERVAL_MS;
private currentFrame = 0;
private intervalId: NodeJS.Timeout | null = null;
private ui: TUI | null = null;
private renderIndicatorVerbatim = false;
private spinnerColorFn: (str: string) => string;
private messageColorFn: (str: string) => string;
private message: string = "Loading...";
constructor(
ui: TUI,
spinnerColorFn: (str: string) => string,
messageColorFn: (str: string) => string,
message: string = "Loading...",
indicator?: LoaderIndicatorOptions,
) {
super("", 1, 0);
this.ui = ui;
this.spinnerColorFn = spinnerColorFn;
this.messageColorFn = messageColorFn;
this.message = message;
this.setIndicator(indicator);
}
render(width: number): string[] {
return ["", ...super.render(width)];
}
start(): void {
this.updateDisplay();
this.restartAnimation();
}
stop(): void {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
setMessage(message: string): void {
this.message = message;
this.updateDisplay();
}
override invalidate(): void {
super.invalidate();
this.updateDisplay();
}
setIndicator(indicator?: LoaderIndicatorOptions): void {
this.renderIndicatorVerbatim = indicator !== undefined;
this.frames = indicator?.frames !== undefined ? [...indicator.frames] : [...DEFAULT_FRAMES];
this.intervalMs = indicator?.intervalMs && indicator.intervalMs > 0 ? indicator.intervalMs : DEFAULT_INTERVAL_MS;
this.currentFrame = 0;
this.start();
}
private restartAnimation(): void {
this.stop();
if (this.frames.length <= 1) {
return;
}
this.intervalId = setInterval(() => {
this.currentFrame = (this.currentFrame + 1) % this.frames.length;
this.updateDisplay();
}, this.intervalMs);
}
protected getRenderedIndicator(): string {
const frame = this.frames[this.currentFrame] ?? "";
return this.renderIndicatorVerbatim ? frame : this.spinnerColorFn(frame);
}
private updateDisplay(): void {
const renderedFrame = this.getRenderedIndicator();
const indicator = renderedFrame.length > 0 ? `${renderedFrame} ` : "";
this.setText(`${indicator}${this.messageColorFn(this.message)}`);
if (this.ui) {
this.ui.requestRender();
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,33 @@
import {
type Component,
dispatchMouseEvent,
type TuiMouseDispatchResult,
type TuiMouseEvent,
type TuiMouseEventResult,
} from "../tui.ts";
export type MouseRegionHandler = (event: TuiMouseEvent) => TuiMouseEventResult | undefined;
/** Adds mouse handling to an existing component without changing its rendering. */
export class MouseRegion implements Component {
private readonly child: Component;
private readonly onMouse: MouseRegionHandler;
constructor(child: Component, onMouse: MouseRegionHandler) {
this.child = child;
this.onMouse = onMouse;
}
render(width: number): string[] {
return this.child.render(width);
}
handleMouse(event: TuiMouseEvent): TuiMouseDispatchResult | TuiMouseEventResult | undefined {
const childResult = dispatchMouseEvent(this.child, event);
return childResult ?? this.onMouse(event);
}
invalidate(): void {
this.child.invalidate();
}
}

View File

@@ -0,0 +1,224 @@
import { LAYOUT_NODE, type ScrollLayoutNode } from "../layout-node.ts";
import { type Component, Container } from "../tui.ts";
export type ScrollViewScrollbar = "hidden" | "auto" | "always";
export interface ScrollViewOptions {
axis?: "vertical";
follow?: "none" | "end";
primary?: boolean;
overscroll?: "chain" | "contain";
scrollbar?: ScrollViewScrollbar;
scrollbarTrackStyle?: (text: string) => string;
scrollbarThumbStyle?: (text: string) => string;
scrollbarHideDelayMs?: number;
}
export interface ScrollViewScrollToOptions {
/** Keep follow-end disabled even when the target is the current content end. */
disableFollow?: boolean;
}
export class ScrollView extends Container {
private readonly child: Component;
readonly followEnd: boolean;
readonly primary: boolean;
readonly overscroll: "chain" | "contain";
readonly scrollbarTrackStyle: (text: string) => string;
readonly scrollbarThumbStyle: (text: string) => string;
private currentScrollbar: ScrollViewScrollbar;
private readonly scrollbarHideDelayMs: number;
private currentScrollTop = 0;
private contentHeight = 0;
private currentViewportHeight = 0;
private followingEnd: boolean;
private followSuppressedAtEnd = false;
private requestRenderCallback: (() => void) | undefined;
private transientScrollbarVisible = false;
private scrollbarActive = false;
private scrollbarHideTimer: NodeJS.Timeout | undefined;
constructor(component: Component, options: ScrollViewOptions = {}) {
super();
if (options.axis !== undefined && options.axis !== "vertical") {
throw new Error(`Unsupported ScrollView axis: ${options.axis}`);
}
this.child = component;
this.children.push(component);
this.followEnd = (options.follow ?? "none") === "end";
this.followingEnd = this.followEnd;
this.primary = options.primary ?? false;
this.overscroll = options.overscroll ?? "chain";
this.currentScrollbar = options.scrollbar ?? "hidden";
this.scrollbarTrackStyle = options.scrollbarTrackStyle ?? ((text) => `\x1b[90m${text}\x1b[39m`);
this.scrollbarThumbStyle = options.scrollbarThumbStyle ?? ((text) => `\x1b[37m${text}\x1b[39m`);
this.scrollbarHideDelayMs = Math.max(0, Math.floor(options.scrollbarHideDelayMs ?? 1000));
}
get scrollTop(): number {
return this.currentScrollTop;
}
get isFollowingEnd(): boolean {
return this.followingEnd;
}
get viewportHeight(): number {
return this.currentViewportHeight;
}
get scrollbar(): ScrollViewScrollbar {
return this.currentScrollbar;
}
get isScrollbarVisible(): boolean {
if (this.scrollbar === "always") return this.currentViewportHeight > 0;
return (
this.scrollbar === "auto" && this.contentHeight > this.currentViewportHeight && this.transientScrollbarVisible
);
}
get isScrollbarActive(): boolean {
return this.scrollbarActive;
}
setScrollbar(scrollbar: ScrollViewScrollbar): void {
if (scrollbar === this.currentScrollbar) return;
this.currentScrollbar = scrollbar;
if (scrollbar !== "auto") this.hideTransientScrollbar();
else if (this.scrollbarActive) this.markScrollbarActivity();
this.requestRenderCallback?.();
}
getContentWidth(width: number): number {
return this.scrollbar === "always" && width > 1 ? width - 1 : width;
}
private markScrollbarActivity(): void {
if (this.scrollbar !== "auto" || this.contentHeight <= this.currentViewportHeight) return;
this.transientScrollbarVisible = true;
if (this.scrollbarHideTimer) {
clearTimeout(this.scrollbarHideTimer);
this.scrollbarHideTimer = undefined;
}
if (this.scrollbarActive) return;
this.scrollbarHideTimer = setTimeout(() => {
this.scrollbarHideTimer = undefined;
this.transientScrollbarVisible = false;
this.requestRenderCallback?.();
}, this.scrollbarHideDelayMs);
this.scrollbarHideTimer.unref();
}
private hideTransientScrollbar(): void {
this.transientScrollbarVisible = false;
if (!this.scrollbarHideTimer) return;
clearTimeout(this.scrollbarHideTimer);
this.scrollbarHideTimer = undefined;
}
setScrollbarActive(active: boolean): void {
if (active === this.scrollbarActive) return;
this.scrollbarActive = active;
this.markScrollbarActivity();
this.requestRenderCallback?.();
}
scrollTo(scrollTop: number, options: ScrollViewScrollToOptions = {}): void {
const requested = Number.isFinite(scrollTop) ? Math.trunc(scrollTop) : this.currentScrollTop;
const maxScrollTop = Math.max(0, this.contentHeight - this.currentViewportHeight);
const next = Math.max(0, Math.min(maxScrollTop, requested));
const nextFollowSuppressedAtEnd = options.disableFollow === true && next === maxScrollTop;
const nextFollowingEnd = !nextFollowSuppressedAtEnd && this.followEnd && next === maxScrollTop;
if (
next === this.currentScrollTop &&
nextFollowingEnd === this.followingEnd &&
nextFollowSuppressedAtEnd === this.followSuppressedAtEnd
) {
return;
}
const moved = next !== this.currentScrollTop;
this.currentScrollTop = next;
this.followingEnd = nextFollowingEnd;
this.followSuppressedAtEnd = nextFollowSuppressedAtEnd;
if (moved) this.markScrollbarActivity();
this.requestRenderCallback?.();
}
scrollBy(lines: number): number {
const requested = Number.isFinite(lines) ? Math.trunc(lines) : 0;
if (requested === 0) return 0;
const maxScrollTop = Math.max(0, this.contentHeight - this.currentViewportHeight);
const start = this.followingEnd ? maxScrollTop : this.currentScrollTop;
const next = Math.max(0, Math.min(maxScrollTop, start + requested));
const moved = next - start;
const wasFollowingEnd = this.followingEnd;
this.currentScrollTop = next;
this.followingEnd = this.followEnd && next === maxScrollTop;
this.followSuppressedAtEnd = false;
if (moved !== 0) this.markScrollbarActivity();
if (moved !== 0 || this.followingEnd !== wasFollowingEnd) this.requestRenderCallback?.();
return requested - moved;
}
scrollToStart(): void {
const changed =
this.currentScrollTop !== 0 ||
this.followingEnd !== (this.followEnd && this.contentHeight <= this.currentViewportHeight);
this.currentScrollTop = 0;
this.followingEnd = this.followEnd && this.contentHeight <= this.currentViewportHeight;
this.followSuppressedAtEnd = false;
if (changed) {
this.markScrollbarActivity();
this.requestRenderCallback?.();
}
}
scrollToEnd(): void {
const next = Math.max(0, this.contentHeight - this.currentViewportHeight);
const changed = this.currentScrollTop !== next || this.followingEnd !== this.followEnd;
this.currentScrollTop = next;
this.followingEnd = this.followEnd;
this.followSuppressedAtEnd = false;
if (changed) {
this.markScrollbarActivity();
this.requestRenderCallback?.();
}
}
updateLayout(contentHeight: number, viewportHeight: number, requestRender: () => void): void {
this.contentHeight = Math.max(0, Math.floor(contentHeight));
this.currentViewportHeight = Math.max(0, Math.floor(viewportHeight));
this.requestRenderCallback = requestRender;
const maxScrollTop = Math.max(0, this.contentHeight - this.currentViewportHeight);
if (this.followingEnd) this.currentScrollTop = maxScrollTop;
else this.currentScrollTop = Math.max(0, Math.min(this.currentScrollTop, maxScrollTop));
if (this.currentScrollTop < maxScrollTop) this.followSuppressedAtEnd = false;
if (this.followEnd && this.currentScrollTop === maxScrollTop && !this.followSuppressedAtEnd) {
this.followingEnd = true;
}
if (this.contentHeight <= this.currentViewportHeight) this.hideTransientScrollbar();
}
override addChild(_component: Component): void {
throw new Error("ScrollView has exactly one child");
}
override removeChild(_component: Component): void {
throw new Error("ScrollView child cannot be removed");
}
override clear(): void {
throw new Error("ScrollView child cannot be cleared");
}
override render(width: number): string[] {
const contentWidth = this.getContentWidth(width);
const lines = this.child.render(contentWidth);
return contentWidth === width ? lines : lines.map((line) => `${line} `);
}
[LAYOUT_NODE](): ScrollLayoutNode {
return { type: "scroll", component: this.child, state: this };
}
}

View File

@@ -0,0 +1,273 @@
import { getKeybindings } from "../keybindings.ts";
import type { Component, TuiMouseEvent, TuiMouseEventResult } from "../tui.ts";
import { truncateToWidth, visibleWidth } from "../utils.ts";
const DEFAULT_PRIMARY_COLUMN_WIDTH = 32;
const PRIMARY_COLUMN_GAP = 2;
const MIN_DESCRIPTION_WIDTH = 10;
const normalizeToSingleLine = (text: string): string => text.replace(/[\r\n]+/g, " ").trim();
const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(value, max));
export interface SelectItem {
value: string;
label: string;
description?: string;
}
export interface SelectListTheme {
selectedPrefix: (text: string) => string;
selectedText: (text: string) => string;
description: (text: string) => string;
scrollInfo: (text: string) => string;
noMatch: (text: string) => string;
}
export interface SelectListTruncatePrimaryContext {
text: string;
maxWidth: number;
columnWidth: number;
item: SelectItem;
isSelected: boolean;
}
export interface SelectListLayoutOptions {
minPrimaryColumnWidth?: number;
maxPrimaryColumnWidth?: number;
truncatePrimary?: (context: SelectListTruncatePrimaryContext) => string;
}
export class SelectList implements Component {
private items: SelectItem[] = [];
private filteredItems: SelectItem[] = [];
private selectedIndex: number = 0;
private mousePressedIndex: number | undefined;
private maxVisible: number = 5;
private theme: SelectListTheme;
private layout: SelectListLayoutOptions;
public onSelect?: (item: SelectItem) => void;
public onCancel?: () => void;
public onSelectionChange?: (item: SelectItem) => void;
constructor(items: SelectItem[], maxVisible: number, theme: SelectListTheme, layout: SelectListLayoutOptions = {}) {
this.items = items;
this.filteredItems = items;
this.maxVisible = maxVisible;
this.theme = theme;
this.layout = layout;
}
setFilter(filter: string): void {
this.filteredItems = this.items.filter((item) => item.value.toLowerCase().startsWith(filter.toLowerCase()));
// Reset selection when filter changes
this.selectedIndex = 0;
}
setSelectedIndex(index: number): void {
this.selectedIndex = Math.max(0, Math.min(index, this.filteredItems.length - 1));
}
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] {
const lines: string[] = [];
// If no items match filter, show message
if (this.filteredItems.length === 0) {
lines.push(this.theme.noMatch(" No matching commands"));
return lines;
}
const primaryColumnWidth = this.getPrimaryColumnWidth();
// Calculate visible range with scrolling
const { startIndex, endIndex } = this.getVisibleRange();
// Render visible items
for (let i = startIndex; i < endIndex; i++) {
const item = this.filteredItems[i];
if (!item) continue;
const isSelected = i === this.selectedIndex;
const descriptionSingleLine = item.description ? normalizeToSingleLine(item.description) : undefined;
lines.push(this.renderItem(item, isSelected, width, descriptionSingleLine, primaryColumnWidth));
}
// Add scroll indicators if needed
if (startIndex > 0 || endIndex < this.filteredItems.length) {
const scrollText = ` (${this.selectedIndex + 1}/${this.filteredItems.length})`;
// Truncate if too long for terminal
lines.push(this.theme.scrollInfo(truncateToWidth(scrollText, width - 2, "")));
}
return lines;
}
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
if (this.filteredItems.length === 0) return undefined;
if (event.type === "wheel" && event.wheelDelta) {
const delta = event.wheelDelta < 0 ? -1 : 1;
const previousIndex = this.selectedIndex;
this.selectedIndex = Math.max(0, Math.min(this.filteredItems.length - 1, this.selectedIndex + delta));
if (this.selectedIndex !== previousIndex) this.notifySelectionChange();
return { handled: true, render: this.selectedIndex !== previousIndex };
}
// Hover must not change selection: the visible range is centered on it.
if (event.button !== "left" || (event.type !== "press" && event.type !== "click")) return undefined;
const { startIndex, endIndex } = this.getVisibleRange();
const itemIndex = startIndex + event.y;
if (itemIndex < startIndex || itemIndex >= endIndex) return undefined;
if (event.type === "press") {
this.mousePressedIndex = itemIndex;
if (this.selectedIndex !== itemIndex) {
this.selectedIndex = itemIndex;
this.notifySelectionChange();
}
return { handled: true, focus: true };
}
if (event.type === "click") {
const clickedIndex = this.mousePressedIndex ?? itemIndex;
this.mousePressedIndex = undefined;
const changed = this.selectedIndex !== clickedIndex;
this.selectedIndex = clickedIndex;
if (changed) this.notifySelectionChange();
const selectedItem = this.filteredItems[this.selectedIndex];
if (selectedItem) this.onSelect?.(selectedItem);
return { handled: true };
}
return undefined;
}
handleInput(keyData: string): void {
const kb = getKeybindings();
// Up arrow - wrap to bottom when at top
if (kb.matches(keyData, "tui.select.up")) {
this.selectedIndex = this.selectedIndex === 0 ? this.filteredItems.length - 1 : this.selectedIndex - 1;
this.notifySelectionChange();
}
// Down arrow - wrap to top when at bottom
else if (kb.matches(keyData, "tui.select.down")) {
this.selectedIndex = this.selectedIndex === this.filteredItems.length - 1 ? 0 : this.selectedIndex + 1;
this.notifySelectionChange();
}
// Enter
else if (kb.matches(keyData, "tui.select.confirm")) {
const selectedItem = this.filteredItems[this.selectedIndex];
if (selectedItem && this.onSelect) {
this.onSelect(selectedItem);
}
}
// Escape or Ctrl+C
else if (kb.matches(keyData, "tui.select.cancel")) {
if (this.onCancel) {
this.onCancel();
}
}
}
private getVisibleRange(): { startIndex: number; endIndex: number } {
const startIndex = Math.max(
0,
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible),
);
return {
startIndex,
endIndex: Math.min(startIndex + this.maxVisible, this.filteredItems.length),
};
}
private renderItem(
item: SelectItem,
isSelected: boolean,
width: number,
descriptionSingleLine: string | undefined,
primaryColumnWidth: number,
): string {
const prefix = isSelected ? "→ " : " ";
const prefixWidth = visibleWidth(prefix);
if (descriptionSingleLine && width > 40) {
const effectivePrimaryColumnWidth = Math.max(1, Math.min(primaryColumnWidth, width - prefixWidth - 4));
const maxPrimaryWidth = Math.max(1, effectivePrimaryColumnWidth - PRIMARY_COLUMN_GAP);
const truncatedValue = this.truncatePrimary(item, isSelected, maxPrimaryWidth, effectivePrimaryColumnWidth);
const truncatedValueWidth = visibleWidth(truncatedValue);
const spacing = " ".repeat(Math.max(1, effectivePrimaryColumnWidth - truncatedValueWidth));
const descriptionStart = prefixWidth + truncatedValueWidth + spacing.length;
const remainingWidth = width - descriptionStart - 2; // -2 for safety
if (remainingWidth > MIN_DESCRIPTION_WIDTH) {
const truncatedDesc = truncateToWidth(descriptionSingleLine, remainingWidth, "");
if (isSelected) {
return this.theme.selectedText(`${prefix}${truncatedValue}${spacing}${truncatedDesc}`);
}
const descText = this.theme.description(spacing + truncatedDesc);
return prefix + truncatedValue + descText;
}
}
const maxWidth = width - prefixWidth - 2;
const truncatedValue = this.truncatePrimary(item, isSelected, maxWidth, maxWidth);
if (isSelected) {
return this.theme.selectedText(`${prefix}${truncatedValue}`);
}
return prefix + truncatedValue;
}
private getPrimaryColumnWidth(): number {
const { min, max } = this.getPrimaryColumnBounds();
const widestPrimary = this.filteredItems.reduce((widest, item) => {
return Math.max(widest, visibleWidth(this.getDisplayValue(item)) + PRIMARY_COLUMN_GAP);
}, 0);
return clamp(widestPrimary, min, max);
}
private getPrimaryColumnBounds(): { min: number; max: number } {
const rawMin =
this.layout.minPrimaryColumnWidth ?? this.layout.maxPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH;
const rawMax =
this.layout.maxPrimaryColumnWidth ?? this.layout.minPrimaryColumnWidth ?? DEFAULT_PRIMARY_COLUMN_WIDTH;
return {
min: Math.max(1, Math.min(rawMin, rawMax)),
max: Math.max(1, Math.max(rawMin, rawMax)),
};
}
private truncatePrimary(item: SelectItem, isSelected: boolean, maxWidth: number, columnWidth: number): string {
const displayValue = this.getDisplayValue(item);
const truncatedValue = this.layout.truncatePrimary
? this.layout.truncatePrimary({
text: displayValue,
maxWidth,
columnWidth,
item,
isSelected,
})
: truncateToWidth(displayValue, maxWidth, "");
return truncateToWidth(truncatedValue, maxWidth, "");
}
private getDisplayValue(item: SelectItem): string {
return item.label || item.value;
}
private notifySelectionChange(): void {
const selectedItem = this.filteredItems[this.selectedIndex];
if (selectedItem && this.onSelectionChange) {
this.onSelectionChange(selectedItem);
}
}
getSelectedItem(): SelectItem | null {
const item = this.filteredItems[this.selectedIndex];
return item || null;
}
}

View File

@@ -0,0 +1,328 @@
import { fuzzyFilter } from "../fuzzy.ts";
import { getKeybindings } from "../keybindings.ts";
import type { Component, TuiMouseEvent, TuiMouseEventResult } from "../tui.ts";
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils.ts";
import { Input } from "./input.ts";
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.
* done() accepts an optional selectedValue and an optional navigateTo id to move the cursor after close. */
submenu?: (
currentValue: string,
done: (selectedValue?: string, options?: { navigateTo?: 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 mousePressedIndex: number | undefined;
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;
private navigateAfterClose: string | 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;
}
}
/** Move selection to the item with the given id (no-op if not found). */
selectItem(id: string): void {
const items = this.searchEnabled ? this.filteredItems : this.items;
const index = items.findIndex((i) => i.id === id);
if (index !== -1) {
this.selectedIndex = index;
}
}
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.getDisplayItems();
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, endIndex } = this.getVisibleRange(displayItems);
// Calculate max label width for alignment
const maxLabelWidth = Math.min(36, 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;
}
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
if (this.submenuComponent) {
const result = this.submenuComponent.handleMouse?.(event);
return result ? { ...result, focus: true } : undefined;
}
if (this.searchEnabled && this.searchInput) {
if (event.y === 0) {
const result = this.searchInput.handleMouse?.(event);
return result ? { ...result, focus: true } : undefined;
}
if (event.y === 1) return undefined;
}
const displayItems = this.getDisplayItems();
if (displayItems.length === 0) return undefined;
if (event.type === "wheel" && event.wheelDelta) {
const delta = event.wheelDelta < 0 ? -1 : 1;
const previousIndex = this.selectedIndex;
this.selectedIndex = Math.max(0, Math.min(displayItems.length - 1, this.selectedIndex + delta));
return { handled: true, render: this.selectedIndex !== previousIndex };
}
// Hover must not change selection: the visible range is centered on it.
if (event.button !== "left" || (event.type !== "press" && event.type !== "click")) return undefined;
const rowOffset = this.searchEnabled ? 2 : 0;
const { startIndex, endIndex } = this.getVisibleRange(displayItems);
const itemIndex = startIndex + event.y - rowOffset;
if (itemIndex < startIndex || itemIndex >= endIndex) return undefined;
if (event.type === "press") {
this.mousePressedIndex = itemIndex;
this.selectedIndex = itemIndex;
return { handled: true, focus: true };
}
if (event.type === "click") {
this.selectedIndex = this.mousePressedIndex ?? itemIndex;
this.mousePressedIndex = undefined;
this.activateItem();
return { handled: true };
}
return undefined;
}
handleInput(data: string): void {
// If submenu is active, delegate all input to it
// The submenu's onCancel (triggered by escape) will call done() which closes it
if (this.submenuComponent) {
this.submenuComponent.handleInput?.(data);
return;
}
// Main list input handling
const kb = getKeybindings();
const displayItems = this.getDisplayItems();
if (kb.matches(data, "tui.select.up")) {
if (displayItems.length === 0) return;
this.selectedIndex = this.selectedIndex === 0 ? displayItems.length - 1 : this.selectedIndex - 1;
} else if (kb.matches(data, "tui.select.down")) {
if (displayItems.length === 0) return;
this.selectedIndex = this.selectedIndex === displayItems.length - 1 ? 0 : this.selectedIndex + 1;
} else if (
kb.matches(data, "tui.select.confirm") ||
(data === " " && (!this.searchEnabled || this.searchInput?.getValue().length === 0))
) {
this.activateItem();
} else if (kb.matches(data, "tui.select.cancel")) {
this.onCancel();
} else if (this.searchEnabled && this.searchInput) {
this.searchInput.handleInput(data);
this.applyFilter(this.searchInput.getValue());
}
}
private getDisplayItems(): SettingItem[] {
return this.searchEnabled ? this.filteredItems : this.items;
}
private getVisibleRange(displayItems: readonly SettingItem[]): { startIndex: number; endIndex: number } {
const startIndex = Math.max(
0,
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), displayItems.length - this.maxVisible),
);
return { startIndex, endIndex: Math.min(startIndex + this.maxVisible, displayItems.length) };
}
private activateItem(): void {
const item = this.getDisplayItems()[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, options?: { navigateTo?: string }) => {
if (selectedValue !== undefined) {
item.currentValue = selectedValue;
this.onChange(item.id, selectedValue);
}
if (options?.navigateTo) {
this.navigateAfterClose = options.navigateTo;
}
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;
if (this.navigateAfterClose !== null) {
const id = this.navigateAfterClose;
this.navigateAfterClose = null;
this.submenuItemIndex = null;
this.selectItem(id);
// Open the target item's submenu automatically
this.activateItem();
} else if (this.submenuItemIndex !== null) {
// Restore selection to the item that opened the submenu
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,
),
);
}
}

View File

@@ -0,0 +1,28 @@
import type { Component } from "../tui.ts";
/**
* 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;
}
}

View File

@@ -0,0 +1,154 @@
import { LAYOUT_NODE, type LayoutViewport, type StackLayoutEntry, type StackLayoutNode } from "../layout-node.ts";
import { type Component, Container } from "../tui.ts";
export interface StackEntryOptions {
basis?: number | "auto";
grow?: number;
shrink?: number;
minSize?: number;
maxSize?: number;
visible?: (viewport: LayoutViewport) => boolean;
}
export interface StackEntry extends StackEntryOptions {
component: Component;
}
export type StackChild = Component | StackEntry;
export interface StackOptions {
gap?: number;
align?: "stretch" | "start" | "center" | "end";
}
function isStackEntry(child: StackChild): child is StackEntry {
return !("render" in child);
}
function normalizeSize(value: number | undefined, fallback: number): number {
return value === undefined || !Number.isFinite(value) ? fallback : Math.max(0, Math.floor(value));
}
export abstract class Stack extends Container {
protected readonly entries: StackLayoutEntry[] = [];
protected readonly gap: number;
protected readonly align: "stretch" | "start" | "center" | "end";
protected abstract readonly layoutType: "vstack" | "hstack";
constructor(children: StackChild[] = [], options: StackOptions = {}) {
super();
this.gap = normalizeSize(options.gap, 0);
this.align = options.align ?? "stretch";
for (const child of children) {
if (isStackEntry(child)) this.addChild(child.component, child);
else this.addChild(child);
}
}
override addChild(component: Component, options: StackEntryOptions = {}): void {
super.addChild(component);
this.entries.push({
component,
...(options.basis === undefined ? {} : { basis: options.basis }),
...(options.grow === undefined ? {} : { grow: normalizeSize(options.grow, 0) }),
...(options.shrink === undefined ? {} : { shrink: normalizeSize(options.shrink, 1) }),
...(options.minSize === undefined ? {} : { minSize: normalizeSize(options.minSize, 0) }),
...(options.maxSize === undefined ? {} : { maxSize: normalizeSize(options.maxSize, Number.MAX_SAFE_INTEGER) }),
...(options.visible === undefined ? {} : { visible: options.visible }),
});
}
override removeChild(component: Component): void {
super.removeChild(component);
const index = this.entries.findIndex((entry) => entry.component === component);
if (index !== -1) this.entries.splice(index, 1);
}
override clear(): void {
super.clear();
this.entries.length = 0;
}
[LAYOUT_NODE](): StackLayoutNode {
return {
type: this.layoutType,
entries: this.entries,
gap: this.gap,
align: this.align,
};
}
}
export function visibleStackEntries(
entries: readonly StackLayoutEntry[],
viewport: LayoutViewport,
): StackLayoutEntry[] {
return entries.filter((entry) => entry.visible?.(viewport) ?? true);
}
function clampSize(size: number, entry: StackLayoutEntry): number {
const min = Math.max(0, Math.floor(entry.minSize ?? 0));
const max = Math.max(min, Math.floor(entry.maxSize ?? Number.MAX_SAFE_INTEGER));
return Math.max(min, Math.min(max, Math.max(0, Math.floor(size))));
}
function distribute(
sizes: number[],
entries: readonly StackLayoutEntry[],
amount: number,
mode: "grow" | "shrink",
): void {
let remaining = amount;
while (remaining > 0) {
const candidates = entries
.map((entry, index) => ({ entry, index }))
.filter(({ entry, index }) => {
if (mode === "grow") {
return (entry.grow ?? 0) > 0 && sizes[index]! < (entry.maxSize ?? Number.MAX_SAFE_INTEGER);
}
return (entry.shrink ?? 1) > 0 && sizes[index]! > (entry.minSize ?? 0);
});
if (candidates.length === 0) return;
const totalWeight = candidates.reduce((sum, { entry, index }) => {
return sum + (mode === "grow" ? (entry.grow ?? 0) : (entry.shrink ?? 1) * Math.max(1, sizes[index]!));
}, 0);
let distributed = 0;
for (const { entry, index } of candidates) {
if (remaining <= 0) break;
const weight = mode === "grow" ? (entry.grow ?? 0) : (entry.shrink ?? 1) * Math.max(1, sizes[index]!);
const proposed = Math.max(1, Math.floor((remaining * weight) / totalWeight));
const capacity =
mode === "grow"
? (entry.maxSize ?? Number.MAX_SAFE_INTEGER) - sizes[index]!
: sizes[index]! - (entry.minSize ?? 0);
const delta = Math.min(remaining, proposed, capacity);
if (delta <= 0) continue;
sizes[index] = sizes[index]! + (mode === "grow" ? delta : -delta);
remaining -= delta;
distributed += delta;
}
if (distributed === 0) return;
}
}
export function allocateStackSizes(
entries: readonly StackLayoutEntry[],
intrinsicSizes: readonly number[],
availableSize: number | undefined,
gap: number,
): number[] {
const sizes = entries.map((entry, index) =>
clampSize(
entry.basis === undefined || entry.basis === "auto" ? (intrinsicSizes[index] ?? 0) : entry.basis,
entry,
),
);
if (availableSize === undefined) return sizes;
const contentSize = Math.max(0, Math.floor(availableSize) - Math.max(0, entries.length - 1) * gap);
const total = sizes.reduce((sum, size) => sum + size, 0);
if (total < contentSize) distribute(sizes, entries, contentSize - total, "grow");
else if (total > contentSize) distribute(sizes, entries, total - contentSize, "shrink");
return sizes;
}

View File

@@ -0,0 +1,107 @@
import type { Component } from "../tui.ts";
import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.ts";
/**
* 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, " ");
// Reduce margins when necessary so content and padding fit within the available width.
const paddingX = Math.min(this.paddingX, Math.max(0, Math.floor((width - 1) / 2)));
const contentWidth = Math.max(1, width - 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(paddingX);
const rightMargin = " ".repeat(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 : [""];
}
}

View File

@@ -0,0 +1,65 @@
import type { Component } from "../tui.ts";
import { truncateToWidth, visibleWidth } from "../utils.ts";
/**
* 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;
}
}

View File

@@ -0,0 +1,33 @@
import { allocateStackSizes, Stack, type StackChild, type StackOptions, visibleStackEntries } from "./stack.ts";
export class VStack extends Stack {
protected readonly layoutType = "vstack" as const;
constructor(children: StackChild[] = [], options: StackOptions = {}) {
super(children, options);
}
override render(width: number): string[] {
const viewport = { width: Math.max(1, width), height: Number.MAX_SAFE_INTEGER };
const entries = visibleStackEntries(this.entries, viewport);
const rendered = entries.map((entry) => entry.component.render(viewport.width));
const sizes = allocateStackSizes(
entries,
rendered.map((lines) => lines.length),
undefined,
this.gap,
);
const lines: string[] = [];
for (let index = 0; index < entries.length; index++) {
if (index > 0) {
for (let gap = 0; gap < this.gap; gap++) lines.push("");
}
const childLines = rendered[index]!.slice(0, sizes[index]);
lines.push(...childLines);
for (let padding = childLines.length; padding < sizes[index]!; padding++) lines.push("");
}
return lines;
}
}
export type { StackChild, StackEntry, StackEntryOptions, StackOptions } from "./stack.ts";

View File

@@ -0,0 +1,74 @@
import type { AutocompleteProvider } from "./autocomplete.ts";
import type { Component } from "./tui.ts";
/**
* 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;
}

137
packages/tui/src/fuzzy.ts Normal file
View File

@@ -0,0 +1,137 @@
/**
* 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 };
}
if (normalizedQuery === textLower) {
score -= 100;
}
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 whitespace- and slash-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);
}

154
packages/tui/src/index.ts Normal file
View File

@@ -0,0 +1,154 @@
// Core TUI interfaces and classes
export { Marked, type Token, type Tokens } from "marked";
// Autocomplete support
export {
type AutocompleteItem,
type AutocompleteProvider,
type AutocompleteSuggestions,
CombinedAutocompleteProvider,
type SlashCommand,
} from "./autocomplete.ts";
// Components
export { Box } from "./components/box.ts";
export { CancellableLoader } from "./components/cancellable-loader.ts";
export { Editor, type EditorOptions, type EditorTheme } from "./components/editor.ts";
export { HStack } from "./components/h-stack.ts";
export { Image, type ImageOptions, type ImageTheme } from "./components/image.ts";
export { Input } from "./components/input.ts";
export { Loader, type LoaderIndicatorOptions } from "./components/loader.ts";
export { type DefaultTextStyle, Markdown, type MarkdownOptions, type MarkdownTheme } from "./components/markdown.ts";
export { MouseRegion, type MouseRegionHandler } from "./components/mouse-region.ts";
export {
ScrollView,
type ScrollViewOptions,
type ScrollViewScrollbar,
type ScrollViewScrollToOptions,
} from "./components/scroll-view.ts";
export {
type SelectItem,
SelectList,
type SelectListLayoutOptions,
type SelectListTheme,
type SelectListTruncatePrimaryContext,
} from "./components/select-list.ts";
export { type SettingItem, SettingsList, type SettingsListTheme } from "./components/settings-list.ts";
export { Spacer } from "./components/spacer.ts";
export { Text } from "./components/text.ts";
export { TruncatedText } from "./components/truncated-text.ts";
export {
type StackChild,
type StackEntry,
type StackEntryOptions,
type StackOptions,
VStack,
} from "./components/v-stack.ts";
// Editor component interface (for custom editors)
export type { EditorComponent } from "./editor-component.ts";
// Fuzzy matching
export { type FuzzyMatch, fuzzyFilter, fuzzyMatch } from "./fuzzy.ts";
// Keybindings
export {
getKeybindings,
type Keybinding,
type KeybindingConflict,
type KeybindingDefinition,
type KeybindingDefinitions,
type Keybindings,
type KeybindingsConfig,
KeybindingsManager,
setKeybindings,
TUI_KEYBINDINGS,
} from "./keybindings.ts";
// Keyboard input handling
export {
decodeKittyPrintable,
isKeyRelease,
isKeyRepeat,
isKittyProtocolActive,
Key,
type KeyEventType,
type KeyId,
matchesKey,
parseKey,
setKittyProtocolActive,
} from "./keys.ts";
// LaTeX rendering
export { type RenderLatexOptions, renderLatex } from "./latex.ts";
// Input buffering for batch splitting
export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.ts";
// Terminal interface and implementations
export { ProcessTerminal, type Terminal } from "./terminal.ts";
// Terminal colors
export {
parseOsc11BackgroundColor,
parseTerminalColorSchemeReport,
type RgbColor,
type TerminalColorScheme,
} from "./terminal-colors.ts";
// Terminal image support
export {
allocateImageId,
type CellDimensions,
calculateImageRows,
deleteAllKittyImages,
deleteKittyImage,
detectCapabilities,
encodeITerm2,
encodeKitty,
getCapabilities,
getCellDimensions,
getGifDimensions,
getImageDimensions,
getJpegDimensions,
getPngDimensions,
getWebpDimensions,
hyperlink,
type ImageDimensions,
type ImageProtocol,
type ImageRenderOptions,
imageFallback,
renderImage,
resetCapabilitiesCache,
setCapabilities,
setCapabilityOverrides,
setCellDimensions,
type TerminalCapabilities,
} from "./terminal-image.ts";
export {
type Component,
Container,
CURSOR_MARKER,
compositeTuiLine,
type Focusable,
isFocusable,
isViewportTUI,
type OverlayAnchor,
type OverlayBounds,
type OverlayHandle,
type OverlayMargin,
type OverlayOptions,
type OverlayUnfocusOptions,
type SizeValue,
type TUI,
type TuiInputListener,
type TuiInputListenerResult,
type TuiMode,
type TuiMouseButton,
type TuiMouseEvent,
type TuiMouseEventResult,
type TuiMouseEventType,
type TuiStopOptions,
type ViewportTUI,
} from "./tui.ts";
export { TuiAltScreen, type TuiAltScreenOptions } from "./tui-alt-screen.ts";
export { TuiMainScreen, type TuiMainScreenRenderState } from "./tui-main-screen.ts";
// Utilities
export {
getOsc8LinkAtColumn,
sliceByColumn,
stripTerminalSequences,
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
} from "./utils.ts";

View File

@@ -0,0 +1,320 @@
import { type KeyId, matchesKey } from "./keys.ts";
/**
* Global keybinding registry.
* Downstream packages can add keybindings via declaration merging.
*/
export interface Keybindings {
// Editor navigation and editing
"tui.editor.cursorUp": true;
"tui.editor.cursorDown": true;
"tui.editor.historyPrevious": true;
"tui.editor.historyNext": true;
"tui.editor.cursorLeft": true;
"tui.editor.cursorRight": true;
"tui.editor.cursorWordLeft": true;
"tui.editor.cursorWordRight": true;
"tui.editor.cursorLineStart": true;
"tui.editor.cursorLineEnd": true;
"tui.editor.jumpForward": true;
"tui.editor.jumpBackward": true;
"tui.editor.pageUp": true;
"tui.editor.pageDown": true;
"tui.editor.deleteCharBackward": true;
"tui.editor.deleteCharForward": true;
"tui.editor.deleteWordBackward": true;
"tui.editor.deleteWordForward": true;
"tui.editor.deleteToLineStart": true;
"tui.editor.deleteToLineEnd": true;
"tui.editor.yank": true;
"tui.editor.yankPop": true;
"tui.editor.undo": true;
// Generic input actions
"tui.input.newLine": true;
"tui.input.submit": true;
"tui.input.tab": true;
"tui.input.copy": true;
// Generic selection actions
"tui.select.up": true;
"tui.select.down": true;
"tui.select.pageUp": true;
"tui.select.pageDown": true;
"tui.select.confirm": true;
"tui.select.cancel": true;
// Alternate-screen viewport navigation
"tui.altScreen.pageUp": true;
"tui.altScreen.pageDown": true;
"tui.altScreen.halfPageUp": true;
"tui.altScreen.halfPageDown": true;
"tui.altScreen.lineUp": true;
"tui.altScreen.lineDown": true;
"tui.altScreen.previousPrompt": true;
"tui.altScreen.nextPrompt": true;
"tui.altScreen.search": true;
"tui.altScreen.searchNext": true;
"tui.altScreen.searchPrevious": true;
"tui.altScreen.searchClose": true;
"tui.altScreen.top": true;
"tui.altScreen.bottom": true;
}
export type Keybinding = keyof Keybindings;
export interface KeybindingDefinition {
defaultKeys: KeyId | KeyId[];
description?: string;
}
export type KeybindingDefinitions = Record<string, KeybindingDefinition>;
export type KeybindingsConfig = Record<string, KeyId | KeyId[] | undefined>;
export const TUI_KEYBINDINGS = {
"tui.editor.cursorUp": { defaultKeys: "up", description: "Move cursor up" },
"tui.editor.cursorDown": { defaultKeys: "down", description: "Move cursor down" },
"tui.editor.historyPrevious": {
defaultKeys: [],
description: "Select previous prompt history entry",
},
"tui.editor.historyNext": {
defaultKeys: [],
description: "Select next prompt history entry",
},
"tui.editor.cursorLeft": {
defaultKeys: ["left", "ctrl+b"],
description: "Move cursor left",
},
"tui.editor.cursorRight": {
defaultKeys: ["right", "ctrl+f"],
description: "Move cursor right",
},
"tui.editor.cursorWordLeft": {
defaultKeys: ["alt+left", "ctrl+left", "alt+b"],
description: "Move cursor word left",
},
"tui.editor.cursorWordRight": {
defaultKeys: ["alt+right", "ctrl+right", "alt+f"],
description: "Move cursor word right",
},
"tui.editor.cursorLineStart": {
defaultKeys: ["home", "ctrl+home", "ctrl+a"],
description: "Move to line start",
},
"tui.editor.cursorLineEnd": {
defaultKeys: ["end", "ctrl+end", "ctrl+e"],
description: "Move to line end",
},
"tui.editor.jumpForward": {
defaultKeys: "ctrl+]",
description: "Jump forward to character",
},
"tui.editor.jumpBackward": {
defaultKeys: "ctrl+alt+]",
description: "Jump backward to character",
},
"tui.editor.pageUp": { defaultKeys: ["pageUp", "ctrl+pageUp"], description: "Page up" },
"tui.editor.pageDown": { defaultKeys: ["pageDown", "ctrl+pageDown"], description: "Page down" },
"tui.editor.deleteCharBackward": {
defaultKeys: "backspace",
description: "Delete character backward",
},
"tui.editor.deleteCharForward": {
defaultKeys: ["delete", "ctrl+d"],
description: "Delete character forward",
},
"tui.editor.deleteWordBackward": {
defaultKeys: ["ctrl+w", "alt+backspace"],
description: "Delete word backward",
},
"tui.editor.deleteWordForward": {
defaultKeys: ["alt+d", "alt+delete"],
description: "Delete word forward",
},
"tui.editor.deleteToLineStart": {
defaultKeys: "ctrl+u",
description: "Delete to line start",
},
"tui.editor.deleteToLineEnd": {
defaultKeys: "ctrl+k",
description: "Delete to line end",
},
"tui.editor.yank": { defaultKeys: "ctrl+y", description: "Yank" },
"tui.editor.yankPop": { defaultKeys: "alt+y", description: "Yank pop" },
"tui.editor.undo": { defaultKeys: "ctrl+-", description: "Undo" },
"tui.input.newLine": { defaultKeys: ["shift+enter", "ctrl+j"], description: "Insert newline" },
"tui.input.submit": { defaultKeys: "enter", description: "Submit input" },
"tui.input.tab": { defaultKeys: "tab", description: "Tab / autocomplete" },
"tui.input.copy": { defaultKeys: "ctrl+c", description: "Copy selection" },
"tui.select.up": { defaultKeys: "up", description: "Move selection up" },
"tui.select.down": { defaultKeys: "down", description: "Move selection down" },
"tui.select.pageUp": { defaultKeys: "pageUp", description: "Selection page up" },
"tui.select.pageDown": {
defaultKeys: "pageDown",
description: "Selection page down",
},
"tui.select.confirm": { defaultKeys: "enter", description: "Confirm selection" },
"tui.select.cancel": {
defaultKeys: ["escape", "ctrl+c"],
description: "Cancel selection",
},
// These intentionally shadow the unmodified editor bindings in fullscreen mode.
"tui.altScreen.pageUp": {
defaultKeys: "pageUp",
description: "Scroll viewport up one page",
},
"tui.altScreen.pageDown": {
defaultKeys: "pageDown",
description: "Scroll viewport down one page",
},
"tui.altScreen.halfPageUp": {
defaultKeys: [],
description: "Scroll viewport up half a page",
},
"tui.altScreen.halfPageDown": {
defaultKeys: [],
description: "Scroll viewport down half a page",
},
"tui.altScreen.lineUp": {
defaultKeys: [],
description: "Scroll viewport up one line",
},
"tui.altScreen.lineDown": {
defaultKeys: [],
description: "Scroll viewport down one line",
},
"tui.altScreen.previousPrompt": {
defaultKeys: ["ctrl+shift+up", "ctrl+up"],
description: "Jump to previous semantic prompt",
},
"tui.altScreen.nextPrompt": {
defaultKeys: ["ctrl+shift+down", "ctrl+down"],
description: "Jump to next semantic prompt",
},
"tui.altScreen.search": {
defaultKeys: "ctrl+shift+f",
description: "Search the primary scroll view",
},
"tui.altScreen.searchNext": {
defaultKeys: ["enter", "ctrl+g"],
description: "Select the next search match",
},
"tui.altScreen.searchPrevious": {
defaultKeys: ["shift+enter", "ctrl+shift+g"],
description: "Select the previous search match",
},
"tui.altScreen.searchClose": {
defaultKeys: "escape",
description: "Close transcript search",
},
"tui.altScreen.top": { defaultKeys: "home", description: "Scroll viewport to top" },
"tui.altScreen.bottom": { defaultKeys: "end", description: "Scroll viewport to bottom" },
} as const satisfies KeybindingDefinitions;
export interface KeybindingConflict {
key: KeyId;
keybindings: string[];
}
function normalizeKeys(keys: KeyId | KeyId[] | undefined): KeyId[] {
if (keys === undefined) return [];
const keyList = Array.isArray(keys) ? keys : [keys];
const seen = new Set<KeyId>();
const result: KeyId[] = [];
for (const key of keyList) {
if (!seen.has(key)) {
seen.add(key);
result.push(key);
}
}
return result;
}
export class KeybindingsManager {
private definitions: KeybindingDefinitions;
private userBindings: KeybindingsConfig;
private keysById = new Map<Keybinding, KeyId[]>();
private conflicts: KeybindingConflict[] = [];
constructor(definitions: KeybindingDefinitions, userBindings: KeybindingsConfig = {}) {
this.definitions = definitions;
this.userBindings = userBindings;
this.rebuild();
}
private rebuild(): void {
this.keysById.clear();
this.conflicts = [];
const userClaims = new Map<KeyId, Set<Keybinding>>();
for (const [keybinding, keys] of Object.entries(this.userBindings)) {
if (!(keybinding in this.definitions)) continue;
for (const key of normalizeKeys(keys)) {
const claimants = userClaims.get(key) ?? new Set<Keybinding>();
claimants.add(keybinding as Keybinding);
userClaims.set(key, claimants);
}
}
for (const [key, keybindings] of userClaims) {
if (keybindings.size > 1) {
this.conflicts.push({ key, keybindings: [...keybindings] });
}
}
for (const [id, definition] of Object.entries(this.definitions)) {
const userKeys = this.userBindings[id];
const keys = userKeys === undefined ? normalizeKeys(definition.defaultKeys) : normalizeKeys(userKeys);
this.keysById.set(id as Keybinding, keys);
}
}
matches(data: string, keybinding: Keybinding): boolean {
const keys = this.keysById.get(keybinding) ?? [];
for (const key of keys) {
if (matchesKey(data, key)) return true;
}
return false;
}
getKeys(keybinding: Keybinding): KeyId[] {
return [...(this.keysById.get(keybinding) ?? [])];
}
getDefinition(keybinding: Keybinding): KeybindingDefinition {
return this.definitions[keybinding];
}
getConflicts(): KeybindingConflict[] {
return this.conflicts.map((conflict) => ({ ...conflict, keybindings: [...conflict.keybindings] }));
}
setUserBindings(userBindings: KeybindingsConfig): void {
this.userBindings = userBindings;
this.rebuild();
}
getUserBindings(): KeybindingsConfig {
return { ...this.userBindings };
}
getResolvedBindings(): KeybindingsConfig {
const resolved: KeybindingsConfig = {};
for (const id of Object.keys(this.definitions)) {
const keys = this.keysById.get(id as Keybinding) ?? [];
resolved[id] = keys.length === 1 ? keys[0]! : [...keys];
}
return resolved;
}
}
let globalKeybindings: KeybindingsManager | null = null;
export function setKeybindings(keybindings: KeybindingsManager): void {
globalKeybindings = keybindings;
}
export function getKeybindings(): KeybindingsManager {
if (!globalKeybindings) {
globalKeybindings = new KeybindingsManager(TUI_KEYBINDINGS);
}
return globalKeybindings;
}

1401
packages/tui/src/keys.ts Normal file

File diff suppressed because it is too large Load Diff

View 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;
}
}

1394
packages/tui/src/latex.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
import type { Component } from "./tui.ts";
export const LAYOUT_NODE = Symbol.for("@earendil-works/pi-tui/layout-node");
export interface LayoutViewport {
width: number;
height: number;
}
export interface StackLayoutEntry {
component: Component;
basis?: number | "auto";
grow?: number;
shrink?: number;
minSize?: number;
maxSize?: number;
visible?: (viewport: LayoutViewport) => boolean;
}
export interface StackLayoutNode {
type: "vstack" | "hstack";
entries: readonly StackLayoutEntry[];
gap: number;
align: "stretch" | "start" | "center" | "end";
}
export interface ScrollLayoutState {
readonly scrollTop: number;
readonly primary: boolean;
readonly overscroll: "chain" | "contain";
readonly viewportHeight: number;
getContentWidth(width: number): number;
updateLayout(contentHeight: number, viewportHeight: number, requestRender: () => void): void;
}
export interface ScrollLayoutNode {
type: "scroll";
component: Component;
state: ScrollLayoutState;
}
export type LayoutNode = StackLayoutNode | ScrollLayoutNode;
export interface LayoutComponent extends Component {
[LAYOUT_NODE](): LayoutNode;
}
export function getLayoutNode(component: Component): LayoutNode | undefined {
const candidate = component as Partial<LayoutComponent>;
return typeof candidate[LAYOUT_NODE] === "function" ? candidate[LAYOUT_NODE]() : undefined;
}

449
packages/tui/src/layout.ts Normal file
View File

@@ -0,0 +1,449 @@
import type { ScrollView } from "./components/scroll-view.ts";
import { allocateStackSizes, visibleStackEntries } from "./components/stack.ts";
import { getLayoutNode } from "./layout-node.ts";
import { cropKittyImageLine, getKittyImageMetadata, isImageLine } from "./terminal-image.ts";
import { type Component, CURSOR_MARKER, compositeTuiLine } from "./tui.ts";
import {
extractAnsiCode,
getActiveBackgroundAnsi,
getGraphemeCellRange,
sliceByColumn,
visibleWidth,
} from "./utils.ts";
const OSC133_ZONE_PREFIX = /^(?:\x1b\]133;[ABC](?:\x07|\x1b\\))+/;
export interface LayoutRect {
x: number;
y: number;
width: number;
height: number;
}
export interface LayoutBox {
component: Component;
rect: LayoutRect;
clip: LayoutRect;
children: LayoutBox[];
parent?: LayoutBox;
lines?: readonly string[];
lineOffset?: number;
scrollView?: ScrollView;
scrollContentLines?: readonly string[];
layer: number;
}
export interface LayoutFrame {
root: LayoutBox;
width: number;
height: number;
lines: string[];
primaryScrollView?: ScrollView;
}
export interface ScrollbarGeometry {
column: number;
trackTop: number;
trackHeight: number;
thumbTop: number;
thumbHeight: number;
maxScrollTop: number;
}
interface LayoutContext {
viewport: { width: number; height: number };
renderCache: Map<Component, Map<number, string[]>>;
requestRender: () => void;
primaryScrollView: ScrollView | undefined;
}
function intersect(a: LayoutRect, b: LayoutRect): LayoutRect {
const x = Math.max(a.x, b.x);
const y = Math.max(a.y, b.y);
const right = Math.min(a.x + a.width, b.x + b.width);
const bottom = Math.min(a.y + a.height, b.y + b.height);
return { x, y, width: Math.max(0, right - x), height: Math.max(0, bottom - y) };
}
function renderCached(context: LayoutContext, component: Component, width: number): string[] {
const safeWidth = Math.max(1, Math.floor(width));
let widths = context.renderCache.get(component);
if (!widths) {
widths = new Map<number, string[]>();
context.renderCache.set(component, widths);
}
let lines = widths.get(safeWidth);
if (!lines) {
lines = component.render(safeWidth);
widths.set(safeWidth, lines);
}
return lines;
}
function measureHeight(context: LayoutContext, component: Component, width: number): number {
return renderCached(context, component, width).length;
}
function measureWidth(context: LayoutContext, component: Component, width: number): number {
return renderCached(context, component, width).reduce((max, line) => Math.max(max, visibleWidth(line)), 0);
}
function withParent(box: LayoutBox, parent: LayoutBox): LayoutBox {
box.parent = parent;
return box;
}
function translateBox(box: LayoutBox, deltaY: number): void {
box.rect.y += deltaY;
for (const child of box.children) translateBox(child, deltaY);
}
function updateClips(box: LayoutBox, parentClip: LayoutRect): void {
box.clip = intersect(parentClip, box.rect);
for (const child of box.children) updateClips(child, box.clip);
}
function layoutComponent(
context: LayoutContext,
component: Component,
x: number,
y: number,
width: number,
height: number | undefined,
clip: LayoutRect,
): LayoutBox {
const safeWidth = Math.max(1, Math.floor(width));
const node = getLayoutNode(component);
if (!node) {
const lines = renderCached(context, component, safeWidth);
const allocatedHeight = height === undefined ? lines.length : Math.max(0, Math.floor(height));
let lineOffset = 0;
if (lines.length > allocatedHeight && allocatedHeight > 0) {
const cursorLine = lines.findIndex((line) => line.includes(CURSOR_MARKER));
if (cursorLine >= allocatedHeight) lineOffset = cursorLine - allocatedHeight + 1;
}
return {
component,
rect: { x, y, width: safeWidth, height: allocatedHeight },
clip: intersect(clip, { x, y, width: safeWidth, height: allocatedHeight }),
children: [],
lines,
lineOffset,
layer: 0,
};
}
if (node.type === "scroll") {
const previousScrollTop = node.state.scrollTop;
const contentWidth = node.state.getContentWidth(safeWidth);
const childBox = layoutComponent(
context,
node.component,
x,
y - previousScrollTop,
contentWidth,
undefined,
clip,
);
const contentHeight = childBox.rect.height;
const viewportHeight = height === undefined ? contentHeight : Math.max(0, Math.floor(height));
node.state.updateLayout(contentHeight, viewportHeight, context.requestRender);
translateBox(childBox, previousScrollTop - node.state.scrollTop);
const scrollView = node.state as ScrollView;
if (node.state.primary || !context.primaryScrollView) context.primaryScrollView = scrollView;
const rect = { x, y, width: safeWidth, height: viewportHeight };
const childClip = intersect(clip, rect);
const box: LayoutBox = {
component,
rect,
clip: childClip,
children: [childBox],
scrollView,
scrollContentLines: renderCached(context, node.component, contentWidth),
layer: 0,
};
childBox.parent = box;
updateClips(childBox, childClip);
return box;
}
const entries = visibleStackEntries(node.entries, context.viewport);
const gapTotal = Math.max(0, entries.length - 1) * node.gap;
if (node.type === "vstack") {
const intrinsicHeights = entries.map((entry) =>
typeof entry.basis === "number" ? entry.basis : measureHeight(context, entry.component, safeWidth),
);
const sizes = allocateStackSizes(entries, intrinsicHeights, height, node.gap);
const naturalHeight = sizes.reduce((sum, size) => sum + size, 0) + gapTotal;
const allocatedHeight = height === undefined ? naturalHeight : Math.max(0, Math.floor(height));
const rect = { x, y, width: safeWidth, height: allocatedHeight };
const box: LayoutBox = {
component,
rect,
clip: intersect(clip, rect),
children: [],
layer: 0,
};
let childY = y;
for (let index = 0; index < entries.length; index++) {
box.children.push(
withParent(
layoutComponent(context, entries[index]!.component, x, childY, safeWidth, sizes[index]!, box.clip),
box,
),
);
childY += sizes[index]! + node.gap;
}
return box;
}
const intrinsicWidths = entries.map((entry) =>
typeof entry.basis === "number" ? entry.basis : measureWidth(context, entry.component, safeWidth),
);
const widths = allocateStackSizes(entries, intrinsicWidths, safeWidth, node.gap);
const intrinsicHeights = entries.map((entry, index) =>
measureHeight(context, entry.component, Math.max(1, widths[index]!)),
);
const allocatedHeight =
height === undefined
? intrinsicHeights.reduce((max, childHeight) => Math.max(max, childHeight), 0)
: Math.max(0, height);
const rect = { x, y, width: safeWidth, height: allocatedHeight };
const box: LayoutBox = {
component,
rect,
clip: intersect(clip, rect),
children: [],
layer: 0,
};
let childX = x;
for (let index = 0; index < entries.length; index++) {
const naturalChildHeight = intrinsicHeights[index]!;
const childHeight = node.align === "stretch" ? allocatedHeight : Math.min(allocatedHeight, naturalChildHeight);
let childY = y;
if (node.align === "center") childY += Math.floor((allocatedHeight - childHeight) / 2);
else if (node.align === "end") childY += allocatedHeight - childHeight;
const childWidth = widths[index]!;
if (childWidth === 0) {
box.children.push({
component: entries[index]!.component,
rect: { x: childX, y: childY, width: 0, height: childHeight },
clip: { x: childX, y: childY, width: 0, height: 0 },
children: [],
parent: box,
layer: 0,
});
} else {
box.children.push(
withParent(
layoutComponent(context, entries[index]!.component, childX, childY, childWidth, childHeight, box.clip),
box,
),
);
}
childX += childWidth + node.gap;
}
return box;
}
function replaceScrollbarCell(
line: string,
column: number,
totalWidth: number,
replacement: string,
preserveTargetBackground: boolean,
): string {
if (isImageLine(line)) return line;
const graphemeRange = getGraphemeCellRange(line, column);
const start = graphemeRange?.start ?? column;
const end = graphemeRange?.end ?? column + 1;
const before = sliceByColumn(line, 0, start, true);
const target = sliceByColumn(line, start, end - start, true);
const after = sliceByColumn(line, end, Math.max(0, totalWidth - end), true);
let targetPrefix = "";
let targetIndex = 0;
while (targetIndex < target.length) {
const ansi = extractAnsiCode(target, targetIndex);
if (!ansi) break;
targetPrefix += ansi.code;
targetIndex += ansi.length;
}
const beforePadding = " ".repeat(Math.max(0, start - visibleWidth(before)));
const cellPaddingBefore = " ".repeat(Math.max(0, column - start));
const cellPaddingAfter = " ".repeat(Math.max(0, end - column - 1));
const targetStyle = `\x1b[0m\x1b]8;;\x07${preserveTargetBackground ? getActiveBackgroundAnsi(targetPrefix) : ""}`;
return `${before}${beforePadding}${targetStyle}${cellPaddingBefore}${replacement}${cellPaddingAfter}${after}`;
}
export function getScrollbarGeometry(box: LayoutBox, includeHiddenAuto = false): ScrollbarGeometry | undefined {
if (!box.scrollView || box.rect.width <= 0 || box.rect.height <= 0) return undefined;
const contentHeight = box.children[0]?.rect.height ?? box.scrollContentLines?.length ?? 0;
const trackHeight = box.rect.height;
const canRevealHiddenAuto = includeHiddenAuto && box.scrollView.scrollbar === "auto" && contentHeight > trackHeight;
if (!box.scrollView.isScrollbarVisible && !canRevealHiddenAuto) return undefined;
const minThumbHeight = Math.min(2, trackHeight);
const thumbHeight = Math.max(
minThumbHeight,
Math.min(trackHeight, Math.round((trackHeight * trackHeight) / contentHeight)),
);
const maxScrollTop = Math.max(0, contentHeight - trackHeight);
const maxThumbTop = trackHeight - thumbHeight;
const thumbOffset = maxScrollTop === 0 ? 0 : Math.round((box.scrollView.scrollTop / maxScrollTop) * maxThumbTop);
const column = box.rect.x + box.rect.width - 1;
if (column < box.clip.x || column >= box.clip.x + box.clip.width) return undefined;
return {
column,
trackTop: box.rect.y,
trackHeight,
thumbTop: box.rect.y + thumbOffset,
thumbHeight,
maxScrollTop,
};
}
function paintScrollbar(box: LayoutBox, screen: string[], totalWidth: number): void {
const geometry = getScrollbarGeometry(box);
if (!geometry || !box.scrollView) return;
for (let offset = 0; offset < geometry.trackHeight; offset++) {
const row = geometry.trackTop + offset;
if (row < box.clip.y || row >= box.clip.y + box.clip.height || row < 0 || row >= screen.length) continue;
const isThumb = row >= geometry.thumbTop && row < geometry.thumbTop + geometry.thumbHeight;
const replacement = isThumb
? box.scrollView.scrollbarThumbStyle(box.scrollView.isScrollbarActive ? "█" : "┃")
: box.scrollView.scrollbarTrackStyle("│");
screen[row] = replaceScrollbarCell(
screen[row] ?? "",
geometry.column,
totalWidth,
replacement,
box.scrollView.scrollbar !== "always",
);
}
}
function paintBox(box: LayoutBox, screen: string[], totalWidth: number): void {
if (box.lines) {
const offset = box.lineOffset ?? 0;
const firstRow = Math.max(box.rect.y, box.clip.y, 0);
const lastRow = Math.min(box.rect.y + box.rect.height, box.clip.y + box.clip.height, screen.length);
for (let row = firstRow; row < lastRow; row++) {
const sourceLine = box.lines[offset + row - box.rect.y];
if (sourceLine === undefined) continue;
let line = sourceLine.replace(OSC133_ZONE_PREFIX, "");
const imageMetadata = getKittyImageMetadata(line);
if (imageMetadata) {
const clipBottom = Math.min(screen.length, box.clip.y + box.clip.height);
const visibleRows = Math.min(imageMetadata.rows, clipBottom - row);
if (visibleRows < imageMetadata.rows) line = cropKittyImageLine(line, 0, visibleRows);
}
// Fast path: a full-width box painting onto an untouched row can use the
// source line reference directly. Compositing here would rebuild the row
// string through ANSI/grapheme segmentation every frame; padding is
// unnecessary because rows are written with erase-line and the final
// width clamp still truncates over-wide lines.
if (box.rect.x === 0 && box.rect.width >= totalWidth && (isImageLine(line) || !screen[row])) {
screen[row] = line;
} else {
screen[row] = compositeTuiLine(screen[row] ?? "", line, box.rect.x, box.rect.width, totalWidth);
}
}
}
for (const child of box.children) paintBox(child, screen, totalWidth);
if (box.scrollView && box.scrollContentLines && box.scrollView.scrollTop > 0 && box.rect.height > 0) {
for (let imageRow = box.scrollView.scrollTop - 1; imageRow >= 0; imageRow--) {
const imageLine = box.scrollContentLines[imageRow] ?? "";
const metadata = getKittyImageMetadata(imageLine);
if (metadata) {
const hiddenRows = box.scrollView.scrollTop - imageRow;
if (hiddenRows < metadata.rows) {
const visibleRows = Math.min(box.rect.height, metadata.rows - hiddenRows);
const cropped = cropKittyImageLine(imageLine, hiddenRows, visibleRows);
if (box.rect.x === 0 && box.rect.width >= totalWidth) screen[box.rect.y] = cropped;
}
break;
}
if (imageLine !== "") break;
}
}
paintScrollbar(box, screen, totalWidth);
}
export function renderLayoutFrame(
root: Component,
width: number,
height: number,
requestRender: () => void,
): LayoutFrame {
const safeWidth = Math.max(1, Math.floor(width));
const safeHeight = Math.max(1, Math.floor(height));
const context: LayoutContext = {
viewport: { width: safeWidth, height: safeHeight },
renderCache: new Map(),
requestRender,
primaryScrollView: undefined,
};
const rootBox = layoutComponent(context, root, 0, 0, safeWidth, safeHeight, {
x: 0,
y: 0,
width: safeWidth,
height: safeHeight,
});
const lines = Array.from({ length: safeHeight }, () => "");
paintBox(rootBox, lines, safeWidth);
return {
root: rootBox,
width: safeWidth,
height: safeHeight,
lines,
...(context.primaryScrollView === undefined ? {} : { primaryScrollView: context.primaryScrollView }),
};
}
function containsPoint(rect: LayoutRect, x: number, y: number): boolean {
return x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height;
}
/** Return the visual hit path from the deepest component to the layout root. */
export function getLayoutBoxesAt(frame: LayoutFrame, x: number, y: number): LayoutBox[] {
const result: Array<{ box: LayoutBox; depth: number }> = [];
const visit = (box: LayoutBox, depth: number): void => {
if (!containsPoint(box.clip, x, y)) return;
result.push({ box, depth });
for (const child of box.children) visit(child, depth + 1);
};
visit(frame.root, 0);
result.sort((a, b) => b.box.layer - a.box.layer || b.depth - a.depth);
return result.map(({ box }) => box);
}
export function getScrollViewBox(frame: LayoutFrame, scrollView: ScrollView): LayoutBox | undefined {
const visit = (box: LayoutBox): LayoutBox | undefined => {
if (box.scrollView === scrollView) return box;
for (const child of box.children) {
const match = visit(child);
if (match) return match;
}
return undefined;
};
return visit(frame.root);
}
export function getScrollViewsAt(frame: LayoutFrame, x: number, y: number): ScrollView[] {
const result: Array<{ scrollView: ScrollView; depth: number }> = [];
const visit = (box: LayoutBox, depth: number): void => {
if (!containsPoint(box.clip, x, y)) return;
if (box.scrollView && containsPoint(box.rect, x, y)) result.push({ scrollView: box.scrollView, depth });
for (const child of box.children) visit(child, depth + 1);
};
visit(frame.root, 0);
result.sort((a, b) => b.depth - a.depth);
return result.map((entry) => entry.scrollView);
}

View File

@@ -0,0 +1,59 @@
import { createRequire } from "node:module";
import * as path from "node:path";
import { getNativeModuleCandidates } from "./native-module-path.ts";
const cjsRequire = createRequire(import.meta.url);
export type ModifierKey = "shift" | "command" | "control" | "option";
type NativeModifiersHelper = {
isModifierPressed: (name: ModifierKey) => boolean;
};
let nativeModifiersHelper: NativeModifiersHelper | null | undefined;
function isNativeModifiersHelper(value: unknown): value is NativeModifiersHelper {
if (typeof value !== "object" || value === null) return false;
const candidate = (value as { isModifierPressed?: unknown }).isModifierPressed;
return typeof candidate === "function";
}
function loadNativeModifiersHelper(): NativeModifiersHelper | undefined {
if (nativeModifiersHelper !== undefined) return nativeModifiersHelper ?? undefined;
nativeModifiersHelper = null;
const arch = process.arch;
if (arch !== "x64" && arch !== "arm64") return undefined;
let nativePath: string;
if (process.platform === "darwin") {
nativePath = path.join("native", "darwin", "prebuilds", `darwin-${arch}`, "darwin-modifiers.node");
} else if (process.platform === "win32") {
nativePath = path.join("native", "win32", "prebuilds", `win32-${arch}`, "win32-console-mode.node");
} else {
return undefined;
}
for (const modulePath of getNativeModuleCandidates(nativePath)) {
try {
const helper = cjsRequire(modulePath) as unknown;
if (isNativeModifiersHelper(helper)) {
nativeModifiersHelper = helper;
return helper;
}
} catch {
// Try the next possible packaging location.
}
}
return undefined;
}
export function isNativeModifierPressed(key: ModifierKey): boolean {
const helper = loadNativeModifiersHelper();
if (!helper) return false;
try {
return helper.isModifierPressed(key) === true;
} catch {
return false;
}
}

View File

@@ -0,0 +1,31 @@
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const moduleRequire = createRequire(import.meta.url);
const TUI_PACKAGE_NAME = "@earendil-works/pi-tui";
export interface NativeModuleCandidateOptions {
moduleUrl?: string;
execPath?: string;
resolvePackage?: (specifier: string) => string;
}
export function getNativeModuleCandidates(nativePath: string, options: NativeModuleCandidateOptions = {}): string[] {
const moduleDir = dirname(fileURLToPath(options.moduleUrl ?? import.meta.url));
const candidates: string[] = [];
try {
const packageEntry = (options.resolvePackage ?? moduleRequire.resolve)(TUI_PACKAGE_NAME);
candidates.push(join(dirname(packageEntry), "..", nativePath));
} catch {
// Standalone binaries do not have an installed TUI package.
}
candidates.push(
join(moduleDir, "..", nativePath),
join(moduleDir, nativePath),
join(dirname(options.execPath ?? process.execPath), nativePath),
);
return Array.from(new Set(candidates));
}

View File

@@ -0,0 +1,444 @@
/**
* 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 DEFAULT_SEQUENCE_TIMEOUT_MS = 50;
const DEFAULT_ESCAPE_TIMEOUT_MS = 10;
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 parseUnmodifiedKittyPrintableCodepoint(sequence: string): number | undefined {
const match = sequence.match(/^\x1b\[(\d+)(?::\d*)?(?::\d+)?u$/);
if (!match) return undefined;
const codepoint = parseInt(match[1]!, 10);
return codepoint >= 32 ? codepoint : undefined;
}
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") {
// WezTerm with enable_kitty_keyboard sends the Escape key press as a
// raw '\x1b' byte (simple text path in encode_kitty, ignoring
// DISAMBIGUATE_ESCAPE_CODES) and the release as a full Kitty CSI-u
// sequence. These arrive concatenated as '\x1b\x1b[27;...u'.
// The buffer would normally treat '\x1b\x1b' as a complete meta-key
// sequence (ESC + single char), leaving '[27;...u' to be typed as
// plain text. If the character immediately following '\x1b\x1b'
// would begin a new escape sequence, emit only the first ESC and
// restart from the second.
if (candidate === "\x1b\x1b") {
const nextChar = remaining[seqEnd];
if (
nextChar === "[" || // CSI
nextChar === "]" || // OSC
nextChar === "O" || // SS3
nextChar === "P" || // DCS
nextChar === "_" // APC
) {
sequences.push(ESC);
pos += 1;
break;
}
}
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 an incomplete sequence such as CSI or mouse
* (default: 50ms).
*/
timeout?: number;
/**
* Maximum time to wait after a lone ESC before treating it as Escape
* (default: 10ms). Increase for high-latency Alt+key input (SSH).
*/
escapeTimeout?: 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 readonly escapeTimeoutMs: number;
private pasteMode: boolean = false;
private pasteBuffer: string = "";
private pendingKittyPrintableCodepoint: number | undefined;
constructor(options: StdinBufferOptions = {}) {
super();
this.timeoutMs = options.timeout ?? DEFAULT_SEQUENCE_TIMEOUT_MS;
this.escapeTimeoutMs = options.escapeTimeout ?? DEFAULT_ESCAPE_TIMEOUT_MS;
}
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.emitDataSequence("");
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.pendingKittyPrintableCodepoint = undefined;
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.emitDataSequence(sequence);
}
}
this.pendingKittyPrintableCodepoint = undefined;
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.pendingKittyPrintableCodepoint = undefined;
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.emitDataSequence(sequence);
}
if (this.buffer.length > 0) {
const timeoutMs = this.buffer === ESC ? this.escapeTimeoutMs : this.timeoutMs;
this.timeout = setTimeout(() => {
const flushed = this.flush();
for (const sequence of flushed) {
this.emitDataSequence(sequence);
}
}, timeoutMs);
}
}
private emitDataSequence(sequence: string): void {
const rawCodepoint = sequence.length === 1 ? sequence.codePointAt(0) : undefined;
if (rawCodepoint !== undefined && rawCodepoint === this.pendingKittyPrintableCodepoint) {
this.pendingKittyPrintableCodepoint = undefined;
return;
}
this.pendingKittyPrintableCodepoint = parseUnmodifiedKittyPrintableCodepoint(sequence);
this.emit("data", sequence);
}
flush(): string[] {
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = null;
}
if (this.buffer.length === 0) {
return [];
}
const sequences = [this.buffer];
this.buffer = "";
this.pendingKittyPrintableCodepoint = undefined;
return sequences;
}
clear(): void {
if (this.timeout) {
clearTimeout(this.timeout);
this.timeout = null;
}
this.buffer = "";
this.pasteMode = false;
this.pasteBuffer = "";
this.pendingKittyPrintableCodepoint = undefined;
}
getBuffer(): string {
return this.buffer;
}
destroy(): void {
this.clear();
}
}

View File

@@ -0,0 +1,73 @@
export interface RgbColor {
r: number;
g: number;
b: number;
}
export type TerminalColorScheme = "dark" | "light";
function hexToRgb(hex: string): RgbColor {
const normalized = hex.startsWith("#") ? hex.slice(1) : hex;
const r = parseInt(normalized.slice(0, 2), 16);
const g = parseInt(normalized.slice(2, 4), 16);
const b = parseInt(normalized.slice(4, 6), 16);
return { r, g, b };
}
function parseOscHexChannel(channel: string): number | undefined {
if (!/^[0-9a-f]+$/i.test(channel)) {
return undefined;
}
const max = 16 ** channel.length - 1;
if (max <= 0) {
return undefined;
}
return Math.round((parseInt(channel, 16) / max) * 255);
}
const OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN = /^\x1b\]11;([^\x07\x1b]*)(?:\x07|\x1b\\)$/i;
const COLOR_SCHEME_REPORT_PATTERN = /^(?:\x1b\[\?997;(1|2)n)+$/;
export function isOsc11BackgroundColorResponse(data: string): boolean {
return OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN.test(data);
}
export function parseOsc11BackgroundColor(data: string): RgbColor | undefined {
const match = data.match(OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN);
if (!match) {
return undefined;
}
const value = match[1].trim();
if (value.startsWith("#")) {
const hex = value.slice(1);
if (/^[0-9a-f]{6}$/i.test(hex)) {
return hexToRgb(value);
}
if (/^[0-9a-f]{12}$/i.test(hex)) {
const r = parseOscHexChannel(hex.slice(0, 4));
const g = parseOscHexChannel(hex.slice(4, 8));
const b = parseOscHexChannel(hex.slice(8, 12));
return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined;
}
return undefined;
}
const rgbValue = value.replace(/^rgba?:/i, "");
const [red, green, blue] = rgbValue.split("/");
if (red === undefined || green === undefined || blue === undefined) {
return undefined;
}
const r = parseOscHexChannel(red);
const g = parseOscHexChannel(green);
const b = parseOscHexChannel(blue);
return r !== undefined && g !== undefined && b !== undefined ? { r, g, b } : undefined;
}
export function parseTerminalColorSchemeReport(data: string): TerminalColorScheme | undefined {
const match = data.match(COLOR_SCHEME_REPORT_PATTERN);
if (!match) {
return undefined;
}
return match[1] === "2" ? "light" : "dark";
}

View File

@@ -0,0 +1,696 @@
import { execSync } from "node:child_process";
import { homedir } from "node:os";
import { isAbsolute } from "node:path";
import { pathToFileURL } from "node:url";
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;
/** Whether Kitty should apply its default cursor movement after placement. */
moveCursor?: boolean;
}
let cachedCapabilities: TerminalCapabilities | null = null;
let capabilityOverrides: Partial<TerminalCapabilities> = {};
// 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;
}
/**
* Checks whether the attached tmux client forwards OSC 8 hyperlinks to the
* outer terminal. tmux only re-emits them when its `client_termfeatures` lists
* `hyperlinks`, and strips them otherwise. On any error fallbacks `false`.
*/
function probeTmuxHyperlinks(): boolean {
try {
const termfeatures = execSync("tmux display-message -p '#{client_termfeatures}'", {
encoding: "utf8",
timeout: 250,
stdio: ["ignore", "pipe", "ignore"],
});
return termfeatures
.split(",")
.map((feature) => feature.trim())
.includes("hyperlinks");
} catch {
return false;
}
}
function detectCapabilitiesFromEnvironment(tmuxForwardsHyperlink: () => boolean): TerminalCapabilities {
const termProgram = process.env.TERM_PROGRAM?.toLowerCase() || "";
const terminalEmulator = process.env.TERMINAL_EMULATOR?.toLowerCase() || "";
const term = process.env.TERM?.toLowerCase() || "";
const colorTerm = process.env.COLORTERM?.toLowerCase() || "";
const hasTrueColorHint = colorTerm === "truecolor" || colorTerm === "24bit";
const isWindowsConsole = process.platform === "win32";
// Emit OSC 8 hyperlinks only when tmux confirms it forwards.
// Image protocols are unreliable under tmux, so leave `images: null`.
if (process.env.TMUX || term.startsWith("tmux")) {
return { images: null, trueColor: hasTrueColorHint, hyperlinks: tmuxForwardsHyperlink() };
}
// screen does not forward OSC 8 hyperlinks, so keep them off there.
if (term.startsWith("screen")) {
return { images: null, trueColor: hasTrueColorHint, hyperlinks: false };
}
if (process.env.KITTY_WINDOW_ID || termProgram === "kitty") {
return { images: "kitty", trueColor: true, hyperlinks: true };
}
if (termProgram === "ghostty" || term.includes("ghostty") || process.env.GHOSTTY_RESOURCES_DIR) {
return { images: "kitty", trueColor: true, hyperlinks: true };
}
if (process.env.WEZTERM_PANE || termProgram === "wezterm") {
return { images: "kitty", trueColor: true, hyperlinks: true };
}
// Warp supports the Kitty graphics protocol and OSC 8 hyperlinks.
if (termProgram === "warpterminal" || process.env.WARP_SESSION_ID || process.env.WARP_TERMINAL_SESSION_UUID) {
return { images: "kitty", trueColor: true, hyperlinks: true };
}
if (process.env.ITERM_SESSION_ID || termProgram === "iterm.app") {
return { images: "iterm2", trueColor: true, hyperlinks: true };
}
if (process.env.WT_SESSION) {
return { images: null, trueColor: true, hyperlinks: true };
}
if (termProgram === "alacritty" || termProgram === "vscode" || termProgram === "zed") {
return { images: null, trueColor: true, hyperlinks: true };
}
if (terminalEmulator === "jetbrains-jediterm") {
return { images: null, trueColor: true, hyperlinks: false };
}
// Windows Terminal does not always set WT_SESSION, for example when it hosts
// a cmd.exe launched directly from Win+R. Modern Windows consoles support
// truecolor; keep hyperlinks off unless we positively detected support above.
if (isWindowsConsole) {
return { images: null, trueColor: true, hyperlinks: false };
}
// Unknown terminal: be conservative. OSC 8 is rendered invisibly as "just
// text" on terminals that swallow it, which means the URL disappears from
// the rendered output. Default to the legacy `text (url)` behavior unless we
// have positively identified a hyperlink-capable terminal above.
return { images: null, trueColor: hasTrueColorHint, hyperlinks: false };
}
function parseBooleanCapabilityOverride(value: string | undefined): boolean | undefined {
return value === "1" ? true : value === "0" ? false : undefined;
}
export function detectCapabilities(tmuxForwardsHyperlink: () => boolean = probeTmuxHyperlinks): TerminalCapabilities {
const hyperlinks = parseBooleanCapabilityOverride(process.env.PI_HYPERLINKS);
const detected = detectCapabilitiesFromEnvironment(
hyperlinks === undefined ? tmuxForwardsHyperlink : () => hyperlinks,
);
const imageProtocol = process.env.PI_IMAGE_PROTOCOL?.toLowerCase();
const images =
imageProtocol === "kitty" || imageProtocol === "iterm2"
? imageProtocol
: imageProtocol === "none" || imageProtocol === "0"
? null
: undefined;
const trueColor = parseBooleanCapabilityOverride(process.env.PI_TRUE_COLOR);
return {
...detected,
...(images !== undefined ? { images } : {}),
...(trueColor !== undefined ? { trueColor } : {}),
...(hyperlinks !== undefined ? { hyperlinks } : {}),
};
}
export function getCapabilities(): TerminalCapabilities {
if (!cachedCapabilities) {
const hyperlinks = capabilityOverrides.hyperlinks;
cachedCapabilities = {
...detectCapabilities(hyperlinks === undefined ? undefined : () => hyperlinks),
...capabilityOverrides,
};
}
return cachedCapabilities;
}
export function resetCapabilitiesCache(): void {
cachedCapabilities = null;
}
/** Override selected auto-detected capabilities. */
export function setCapabilityOverrides(overrides: Partial<TerminalCapabilities>): void {
if (
capabilityOverrides.images === overrides.images &&
capabilityOverrides.trueColor === overrides.trueColor &&
capabilityOverrides.hyperlinks === overrides.hyperlinks
) {
return;
}
capabilityOverrides = { ...overrides };
cachedCapabilities = null;
}
/** Override the cached capabilities. Useful in tests to exercise both code paths. */
export function setCapabilities(caps: TerminalCapabilities): void {
cachedCapabilities = caps;
}
const KITTY_PREFIX = "\x1b_G";
const ITERM2_PREFIX = "\x1b]1337;File=";
export function isImageLine(line: string): boolean {
// Fast path: sequence at line start (single-row images)
if (line.startsWith(KITTY_PREFIX) || line.startsWith(ITERM2_PREFIX)) {
return true;
}
// Slow path: sequence elsewhere (multi-row images have cursor-up prefix)
return line.includes(KITTY_PREFIX) || line.includes(ITERM2_PREFIX);
}
/**
* Generate a random image ID for Kitty graphics protocol.
* Uses random IDs to avoid collisions between different module instances
* (e.g., main app vs extensions).
*/
export function allocateImageId(): number {
// Use random ID in range [1, 0xffffffff] to avoid collisions
return Math.floor(Math.random() * 0xfffffffe) + 1;
}
export function encodeKitty(
base64Data: string,
options: {
columns?: number;
rows?: number;
imageId?: number;
/** Whether Kitty should apply its default cursor movement after placement. Default: true. */
moveCursor?: boolean;
} = {},
): string {
const CHUNK_SIZE = 4096;
const params: string[] = ["a=T", "f=100", "q=2"];
if (options.moveCursor === false) params.push("C=1");
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},q=2\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,q=2\x1b\\";
}
/** Delete all visible Kitty placements while retaining their uploaded image data. */
export function deleteAllKittyPlacements(): string {
return "\x1b_Ga=d,d=a,q=2\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}`,
`size=${Buffer.byteLength(base64Data, "base64")}`,
];
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 interface ImageCellSize {
columns: number;
rows: number;
}
export interface KittyImageMetadata extends ImageCellSize {
imageId: number;
widthPx: number;
heightPx: number;
}
interface RegisteredKittyImageMetadata extends KittyImageMetadata {
transmissionGeneration: number;
}
export interface KittyImagePlacement {
imageId: number;
transmissionGeneration: number;
transmissionBytes: number;
estimatedDecodedBytes: number;
sequence: string;
replacementLine: string;
}
const kittyImageMetadata = new Map<number, RegisteredKittyImageMetadata>();
let kittyTransmissionGeneration = 0;
export function registerKittyImageMetadata(metadata: KittyImageMetadata): void {
kittyTransmissionGeneration += 1;
kittyImageMetadata.delete(metadata.imageId);
kittyImageMetadata.set(metadata.imageId, { ...metadata, transmissionGeneration: kittyTransmissionGeneration });
if (kittyImageMetadata.size > 1000) {
const oldestImageId = kittyImageMetadata.keys().next().value;
if (oldestImageId !== undefined) kittyImageMetadata.delete(oldestImageId);
}
}
function getRegisteredKittyImageMetadata(line: string): RegisteredKittyImageMetadata | undefined {
const controls = /\x1b_G([^;]*);/.exec(line)?.[1];
if (!controls) return undefined;
const imageId = /(?:^|,)i=(\d+)(?:,|$)/.exec(controls)?.[1];
return imageId === undefined ? undefined : kittyImageMetadata.get(Number.parseInt(imageId, 10));
}
export function getKittyImageMetadata(line: string): KittyImageMetadata | undefined {
const metadata = getRegisteredKittyImageMetadata(line);
if (!metadata) return undefined;
return {
imageId: metadata.imageId,
columns: metadata.columns,
rows: metadata.rows,
widthPx: metadata.widthPx,
heightPx: metadata.heightPx,
};
}
const KITTY_PLACEMENT_CONTROL_KEYS = new Set([
"i",
"p",
"x",
"y",
"w",
"h",
"X",
"Y",
"c",
"r",
"C",
"U",
"z",
"P",
"Q",
"H",
"V",
]);
/** Build a placement-only command for an image line emitted by {@link renderImage}. */
export function getKittyImagePlacement(line: string): KittyImagePlacement | undefined {
const match = /\x1b_G([^;]*);/.exec(line);
const metadata = getRegisteredKittyImageMetadata(line);
if (!match || !metadata) return undefined;
let commandStart = match.index;
let commandControls = match[1];
let transmissionEnd: number;
while (true) {
const terminator = line.indexOf("\x1b\\", commandStart + KITTY_PREFIX.length);
if (terminator === -1) return undefined;
transmissionEnd = terminator + 2;
if (!/(?:^|,)m=1(?:,|$)/.test(commandControls)) break;
commandStart = transmissionEnd;
if (!line.startsWith(KITTY_PREFIX, commandStart)) return undefined;
const controlsEnd = line.indexOf(";", commandStart + KITTY_PREFIX.length);
if (controlsEnd === -1) return undefined;
commandControls = line.slice(commandStart + KITTY_PREFIX.length, controlsEnd);
}
const controls = match[1]
.split(",")
.filter((control) => KITTY_PLACEMENT_CONTROL_KEYS.has(control.split("=", 1)[0] ?? ""));
const sequence = `\x1b_Ga=p,q=2,${controls.join(",")}\x1b\\`;
return {
imageId: metadata.imageId,
transmissionGeneration: metadata.transmissionGeneration,
transmissionBytes: transmissionEnd - match.index,
estimatedDecodedBytes: metadata.widthPx * metadata.heightPx * 4,
sequence,
replacementLine: `${line.slice(0, match.index)}${sequence}${line.slice(transmissionEnd)}`,
};
}
export function cropKittyImageLine(line: string, hiddenRows: number, visibleRows: number): string {
const metadata = getKittyImageMetadata(line);
const match = /\x1b_G([^;]*);/.exec(line);
if (!metadata || !match || hiddenRows < 0 || hiddenRows >= metadata.rows || visibleRows <= 0) return line;
const croppedRows = Math.min(visibleRows, metadata.rows - hiddenRows);
if (hiddenRows === 0 && croppedRows === metadata.rows) return line;
const sourceY = Math.floor((metadata.heightPx * hiddenRows) / metadata.rows);
const sourceEnd = Math.ceil((metadata.heightPx * (hiddenRows + croppedRows)) / metadata.rows);
const sourceHeight = Math.max(1, Math.min(metadata.heightPx, sourceEnd) - sourceY);
const controls = match[1].split(",").filter((control) => !/^[yhr]=/.test(control));
controls.push(`y=${sourceY}`, `h=${sourceHeight}`, `r=${croppedRows}`);
return `${line.slice(0, match.index)}\x1b_G${controls.join(",")};${line.slice(match.index + match[0].length)}`;
}
export function calculateImageCellSize(
imageDimensions: ImageDimensions,
maxWidthCells: number,
maxHeightCells?: number,
cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 },
): ImageCellSize {
const maxWidth = Math.max(1, Math.floor(maxWidthCells));
const maxHeight = maxHeightCells === undefined ? undefined : Math.max(1, Math.floor(maxHeightCells));
const imageWidth = Math.max(1, imageDimensions.widthPx);
const imageHeight = Math.max(1, imageDimensions.heightPx);
const widthScale = (maxWidth * cellDimensions.widthPx) / imageWidth;
const heightScale = maxHeight === undefined ? widthScale : (maxHeight * cellDimensions.heightPx) / imageHeight;
const scale = Math.min(widthScale, heightScale);
const scaledWidthPx = imageWidth * scale;
const scaledHeightPx = imageHeight * scale;
const columns = Math.ceil(scaledWidthPx / cellDimensions.widthPx);
const rows = Math.ceil(scaledHeightPx / cellDimensions.heightPx);
return {
columns: Math.max(1, Math.min(maxWidth, columns)),
rows: Math.max(1, maxHeight === undefined ? rows : Math.min(maxHeight, rows)),
};
}
export function calculateImageRows(
imageDimensions: ImageDimensions,
targetWidthCells: number,
cellDimensions: CellDimensions = { widthPx: 9, heightPx: 18 },
): number {
return calculateImageCellSize(imageDimensions, targetWidthCells, undefined, cellDimensions).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; columns: number; rows: number; imageId?: number } | null {
const caps = getCapabilities();
if (!caps.images) {
return null;
}
const maxWidth = options.maxWidthCells ?? 80;
const size = calculateImageCellSize(imageDimensions, maxWidth, options.maxHeightCells, getCellDimensions());
if (caps.images === "kitty") {
if (options.imageId !== undefined) {
registerKittyImageMetadata({
imageId: options.imageId,
columns: size.columns,
rows: size.rows,
widthPx: imageDimensions.widthPx,
heightPx: imageDimensions.heightPx,
});
}
const sequence = encodeKitty(base64Data, {
columns: size.columns,
rows: size.rows,
imageId: options.imageId,
moveCursor: options.moveCursor,
});
return { sequence, columns: size.columns, rows: size.rows, imageId: options.imageId };
}
if (caps.images === "iterm2") {
const sequence = encodeITerm2(base64Data, {
width: size.columns,
height: "auto",
preserveAspectRatio: options.preserveAspectRatio ?? true,
});
return { sequence, columns: size.columns, rows: size.rows };
}
return null;
}
/**
* Wrap text in an OSC 8 hyperlink sequence.
* The text is rendered as a clickable hyperlink in terminals that support OSC 8
* (Ghostty, Kitty, WezTerm, iTerm2, VSCode, and others).
* In terminals that do not support OSC 8, the escape sequences are ignored
* and only the plain text is displayed.
*
* @param text - The visible text to display
* @param url - The URL to link to
*/
export function hyperlink(text: string, url: string): string {
return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`;
}
/** Shorten home-prefixed absolute paths to ~/... for compact display. */
function shortenImagePath(filename: string): string {
const home = homedir();
if (home && (filename === home || filename.startsWith(`${home}/`) || filename.startsWith(`${home}\\`))) {
return `~${filename.slice(home.length)}`;
}
return filename;
}
/**
* Text fallback when the terminal cannot render inline images.
* Absolute paths are shown shortened (~/...) and, when OSC 8 hyperlinks are
* available, linked to file:// so the full path remains openable.
*/
export function imageFallback(mimeType: string, dimensions?: ImageDimensions, filename?: string): string {
const parts: string[] = [];
if (filename) {
const display = shortenImagePath(filename);
if (getCapabilities().hyperlinks && isAbsolute(filename)) {
parts.push(hyperlink(display, pathToFileURL(filename).href));
} else {
parts.push(display);
}
}
parts.push(`[${mimeType}]`);
if (dimensions) parts.push(`${dimensions.widthPx}x${dimensions.heightPx}`);
return `[Image: ${parts.join(" ")}]`;
}

View File

@@ -0,0 +1,565 @@
import * as fs from "node:fs";
import { createRequire } from "node:module";
import * as path from "node:path";
import { setKittyProtocolActive } from "./keys.ts";
import { isNativeModifierPressed } from "./native-modifiers.ts";
import { getNativeModuleCandidates } from "./native-module-path.ts";
import { StdinBuffer } from "./stdin-buffer.ts";
const cjsRequire = createRequire(import.meta.url);
const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000;
const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0\x07";
const NATIVE_SHIFT_ENTER_SEQUENCE = "\x1b[13;2u";
const DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS = 7;
const KEYBOARD_PROTOCOL_RESPONSE_FRAGMENT_TIMEOUT_MS = 150;
const KITTY_KEYBOARD_PROTOCOL_QUERY = `\x1b[>${DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS}u\x1b[?u\x1b[c`;
export type KeyboardProtocolNegotiationSequence =
| { type: "kitty-flags"; flags: number }
| { type: "device-attributes" };
export function parseKeyboardProtocolNegotiationSequence(
sequence: string,
): KeyboardProtocolNegotiationSequence | undefined {
const kittyFlags = sequence.match(/^\x1b\[\?(\d+)u$/);
if (kittyFlags) {
return { type: "kitty-flags", flags: Number.parseInt(kittyFlags[1]!, 10) };
}
if (/^\x1b\[\?[\d;]*c$/.test(sequence)) {
return { type: "device-attributes" };
}
return undefined;
}
function isKeyboardProtocolNegotiationSequencePrefix(sequence: string): boolean {
return sequence === "\x1b[" || /^\x1b\[\?[\d;]*$/.test(sequence);
}
export function isAppleTerminalSession(): boolean {
return process.platform === "darwin" && process.env.TERM_PROGRAM === "Apple_Terminal";
}
/**
* Refresh terminal dimensions on POSIX platforms by sending SIGWINCH to this process.
* Best-effort: some environments (restricted seccomp or LSM policies) return EACCES
* for `kill(2)`; in that case the dimensions refresh is skipped rather than crashing.
*/
export function refreshTerminalDimensions(): void {
if (process.platform === "win32" || process.pid <= 0) return;
try {
process.kill(process.pid, "SIGWINCH");
} catch {
// Signal delivery not permitted in this environment; ignore.
}
}
export function normalizeNativeShiftEnterInput(
data: string,
shouldDetectNativeShiftEnter: boolean,
isShiftPressed: boolean,
): string {
if (shouldDetectNativeShiftEnter && data === "\r" && isShiftPressed) return NATIVE_SHIFT_ENTER_SEQUENCE;
return data;
}
export function normalizeAppleTerminalInput(data: string, isAppleTerminal: boolean, isShiftPressed: boolean): string {
return normalizeNativeShiftEnterInput(data, isAppleTerminal, isShiftPressed);
}
/**
* 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
// Progress indicator (OSC 9;4)
setProgress(active: boolean): void;
}
const DEFAULT_ESCAPE_TIMEOUT_MS = 10;
const DEFAULT_SSH_ESCAPE_TIMEOUT_MS = 100;
/**
* Resolve how long to wait for the rest of an escape sequence before
* dispatching a lone ESC as the Escape key. Legacy Alt+key input is ESC plus
* another byte, so high-latency transports need a longer reassembly window.
*/
export function resolveEscapeTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
const configured = Number(env.PI_TUI_ESC_TIMEOUT);
if (Number.isFinite(configured) && configured > 0) {
return configured;
}
if (env.SSH_CONNECTION || env.SSH_TTY) {
return DEFAULT_SSH_ESCAPE_TIMEOUT_MS;
}
return DEFAULT_ESCAPE_TIMEOUT_MS;
}
/**
* Real terminal using process.stdin/stdout
*/
export class ProcessTerminal implements Terminal {
private wasRaw = false;
private inputHandler?: (data: string) => void;
private resizeHandler?: () => void;
private _kittyProtocolActive = false;
private _modifyOtherKeysActive = false;
private keyboardProtocolPushed = false;
private keyboardProtocolNegotiationBuffer = "";
private keyboardProtocolBufferFlushTimer?: ReturnType<typeof setTimeout>;
private stdinBuffer?: StdinBuffer;
private stdinDataHandler?: (data: string) => void;
private progressInterval?: ReturnType<typeof setInterval>;
private writeLogPath = (() => {
const env = process.env.PI_TUI_WRITE_LOG || "";
if (!env) return "";
try {
if (fs.statSync(env).isDirectory()) {
const now = new Date();
const ts = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}_${String(now.getHours()).padStart(2, "0")}-${String(now.getMinutes()).padStart(2, "0")}-${String(now.getSeconds()).padStart(2, "0")}`;
return path.join(env, `tui-${ts}-${process.pid}.log`);
}
} catch {
// Not an existing directory - use as-is (file path)
}
return env;
})();
get kittyProtocolActive(): boolean {
return this._kittyProtocolActive;
}
get modifyOtherKeysActive(): boolean {
return this._modifyOtherKeysActive;
}
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, best-effort.
refreshTerminalDimensions();
// 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 Kitty keyboard protocol and fall back to modifyOtherKeys when DA confirms no Kitty response.
// 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({ escapeTimeout: resolveEscapeTimeoutMs() });
// Forward individual sequences to the input handler
this.stdinBuffer.on("data", (sequence) => {
const negotiationSequence = this.readKeyboardProtocolNegotiationSequence(sequence);
if (negotiationSequence === "pending") {
this.scheduleKeyboardProtocolNegotiationBufferFlush();
return; // Wait briefly for the rest of a split Kitty response.
}
if (this.handleKeyboardProtocolNegotiationSequence(negotiationSequence)) {
return;
}
this.forwardInputSequence(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 it if available.
*
* Kitty's progressive enhancement detection requires requesting the desired
* flags before querying them. The trailing DA query is a sentinel supported by
* terminals that do not know Kitty keyboard protocol; receiving DA before a
* Kitty response enables modifyOtherKeys fallback without a startup timeout.
*
* The requested flags are:
* - 1 = disambiguate escape codes
* - 2 = report event types (press/repeat/release)
* - 4 = report alternate keys (shifted key, base layout key)
*/
private queryAndEnableKittyProtocol(): void {
this.setupStdinBuffer();
process.stdin.on("data", this.stdinDataHandler!);
this.keyboardProtocolPushed = true;
this.clearKeyboardProtocolNegotiationBuffer();
process.stdout.write(KITTY_KEYBOARD_PROTOCOL_QUERY);
}
private handleKeyboardProtocolNegotiationSequence(
negotiationSequence: KeyboardProtocolNegotiationSequence | undefined,
): boolean {
if (!negotiationSequence) return false;
this.clearKeyboardProtocolNegotiationBuffer();
if (negotiationSequence.type === "kitty-flags") {
if (negotiationSequence.flags !== 0) {
this.disableModifyOtherKeys();
if (!this._kittyProtocolActive) {
this._kittyProtocolActive = true;
setKittyProtocolActive(true);
}
} else {
this.enableModifyOtherKeys();
}
return true;
}
if (!this._kittyProtocolActive) {
this.enableModifyOtherKeys();
}
return true;
}
private readKeyboardProtocolNegotiationSequence(
sequence: string,
): KeyboardProtocolNegotiationSequence | "pending" | undefined {
if (this.keyboardProtocolNegotiationBuffer) {
const bufferedSequence = this.keyboardProtocolNegotiationBuffer + sequence;
const negotiationSequence = parseKeyboardProtocolNegotiationSequence(bufferedSequence);
if (negotiationSequence) {
this.clearKeyboardProtocolNegotiationBuffer();
return negotiationSequence;
}
if (isKeyboardProtocolNegotiationSequencePrefix(bufferedSequence)) {
this.setKeyboardProtocolNegotiationBuffer(bufferedSequence);
return "pending";
}
this.flushKeyboardProtocolNegotiationBufferAsInput();
}
const negotiationSequence = parseKeyboardProtocolNegotiationSequence(sequence);
if (negotiationSequence) return negotiationSequence;
if (isKeyboardProtocolNegotiationSequencePrefix(sequence)) {
this.setKeyboardProtocolNegotiationBuffer(sequence);
return "pending";
}
return undefined;
}
private setKeyboardProtocolNegotiationBuffer(sequence: string): void {
this.clearKeyboardProtocolNegotiationBufferFlushTimer();
this.keyboardProtocolNegotiationBuffer = sequence;
}
private clearKeyboardProtocolNegotiationBuffer(): void {
this.clearKeyboardProtocolNegotiationBufferFlushTimer();
this.keyboardProtocolNegotiationBuffer = "";
}
private flushKeyboardProtocolNegotiationBufferAsInput(): void {
if (!this.keyboardProtocolNegotiationBuffer) return;
const sequence = this.keyboardProtocolNegotiationBuffer;
this.clearKeyboardProtocolNegotiationBuffer();
this.forwardInputSequence(sequence);
}
private scheduleKeyboardProtocolNegotiationBufferFlush(): void {
if (!this.keyboardProtocolNegotiationBuffer || this.keyboardProtocolBufferFlushTimer) return;
this.keyboardProtocolBufferFlushTimer = setTimeout(() => {
this.keyboardProtocolBufferFlushTimer = undefined;
this.flushKeyboardProtocolNegotiationBufferAsInput();
}, KEYBOARD_PROTOCOL_RESPONSE_FRAGMENT_TIMEOUT_MS);
}
private clearKeyboardProtocolNegotiationBufferFlushTimer(): void {
if (!this.keyboardProtocolBufferFlushTimer) return;
clearTimeout(this.keyboardProtocolBufferFlushTimer);
this.keyboardProtocolBufferFlushTimer = undefined;
}
private forwardInputSequence(sequence: string): void {
if (!this.inputHandler) return;
const shouldDetectNativeShiftEnter =
sequence === "\r" && (isAppleTerminalSession() || process.platform === "win32");
const input = normalizeNativeShiftEnterInput(
sequence,
shouldDetectNativeShiftEnter,
shouldDetectNativeShiftEnter && isNativeModifierPressed("shift"),
);
this.inputHandler(input);
}
private enableModifyOtherKeys(): void {
if (this._kittyProtocolActive || this._modifyOtherKeysActive) return;
process.stdout.write("\x1b[>4;2m");
this._modifyOtherKeysActive = true;
}
private disableModifyOtherKeys(): void {
if (!this._modifyOtherKeysActive) return;
process.stdout.write("\x1b[>4;0m");
this._modifyOtherKeysActive = false;
}
/**
* 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 {
const arch = process.arch;
if (arch !== "x64" && arch !== "arm64") return;
// Dynamic require so non-Windows and bundled/browser paths never load the
// native helper. Installed packages resolve it from pi-tui; standalone
// binaries resolve the copy next to the executable.
const nativePath = path.join("native", "win32", "prebuilds", `win32-${arch}`, "win32-console-mode.node");
for (const modulePath of getNativeModuleCandidates(nativePath)) {
try {
const helper = cjsRequire(modulePath) as { enableVirtualTerminalInput?: () => boolean };
helper.enableVirtualTerminalInput?.();
return;
} catch {
// Try the next possible packaging location.
}
}
} catch {
// Native helper not available — Shift+Tab won't be distinguishable from Tab.
}
}
async drainInput(maxMs = 1000, idleMs = 50): Promise<void> {
const shouldDisableKittyProtocol = this.keyboardProtocolPushed || this._kittyProtocolActive;
this.clearKeyboardProtocolNegotiationBuffer();
if (shouldDisableKittyProtocol) {
// Disable Kitty keyboard protocol first so any late key releases
// do not generate new Kitty escape sequences.
process.stdout.write("\x1b[<u");
this.keyboardProtocolPushed = false;
this._kittyProtocolActive = false;
setKittyProtocolActive(false);
}
this.disableModifyOtherKeys();
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 {
if (this.clearProgressInterval()) {
process.stdout.write(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
}
// Disable bracketed paste mode
process.stdout.write("\x1b[?2004l");
const shouldDisableKittyProtocol = this.keyboardProtocolPushed || this._kittyProtocolActive;
this.clearKeyboardProtocolNegotiationBuffer();
// Disable Kitty keyboard protocol if not already done by drainInput()
if (shouldDisableKittyProtocol) {
process.stdout.write("\x1b[<u");
this.keyboardProtocolPushed = false;
this._kittyProtocolActive = false;
setKittyProtocolActive(false);
}
this.disableModifyOtherKeys();
// 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 || Number(process.env.COLUMNS) || 80;
}
get rows(): number {
return process.stdout.rows || Number(process.env.LINES) || 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`);
}
setProgress(active: boolean): void {
if (active) {
// OSC 9;4;3 - indeterminate progress
process.stdout.write(TERMINAL_PROGRESS_ACTIVE_SEQUENCE);
if (!this.progressInterval) {
this.progressInterval = setInterval(() => {
process.stdout.write(TERMINAL_PROGRESS_ACTIVE_SEQUENCE);
}, TERMINAL_PROGRESS_KEEPALIVE_MS);
}
} else {
this.clearProgressInterval();
// OSC 9;4;0 - clear progress
process.stdout.write(TERMINAL_PROGRESS_CLEAR_SEQUENCE);
}
}
private clearProgressInterval(): boolean {
if (!this.progressInterval) return false;
clearInterval(this.progressInterval);
this.progressInterval = undefined;
return true;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,655 @@
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { deleteKittyImage, isImageLine } from "./terminal-image.ts";
import { type TUI, TuiBase, type TuiStopOptions } from "./tui.ts";
import { visibleWidth } from "./utils.ts";
const KITTY_SEQUENCE_PREFIX = "\x1b_G";
const MAX_RENDER_WRITE_CHARS = 1024 * 1024;
/**
* Streams terminal output in 1 MiB chunks so a full render never forms one string large enough to exceed V8's limit.
*
* `append()` fills the current chunk and flushes it when full. Oversized input is split at chunk boundaries, preserving
* surrogate pairs so each write remains valid UTF-16. Callers append synchronized-output begin/end sequences themselves;
* the final `flush()` writes any remainder, including the end sequence.
*/
class BoundedTerminalWriter {
private buffer = "";
private writtenChars = 0;
private readonly write: (data: string) => void;
constructor(write: (data: string) => void) {
this.write = write;
}
/**
* Append terminal data, flushing full chunks as needed. Callers must call `flush()` after the final append.
* @param value Terminal data to write in order; oversized values are split without splitting surrogate pairs.
*/
append(value: string): void {
let offset = 0;
while (offset < value.length) {
const capacity = MAX_RENDER_WRITE_CHARS - this.buffer.length;
if (capacity === 0) {
this.flush();
continue;
}
let end = Math.min(value.length, offset + capacity);
if (
end < value.length &&
value.charCodeAt(end - 1) >= 0xd800 &&
value.charCodeAt(end - 1) <= 0xdbff &&
value.charCodeAt(end) >= 0xdc00 &&
value.charCodeAt(end) <= 0xdfff
) {
end--;
}
if (end === offset) {
this.flush();
continue;
}
this.buffer += value.slice(offset, end);
offset = end;
if (this.buffer.length === MAX_RENDER_WRITE_CHARS) {
this.flush();
}
}
}
/** Write the current chunk, if any, and retain only its character count for debug output. */
flush(): void {
if (!this.buffer) return;
this.write(this.buffer);
this.writtenChars += this.buffer.length;
this.buffer = "";
}
get length(): number {
return this.writtenChars + this.buffer.length;
}
}
interface KittyImageHeader {
ids: number[];
rows: number;
}
function parseKittyImageHeader(line: string): KittyImageHeader | undefined {
const sequenceStart = line.indexOf(KITTY_SEQUENCE_PREFIX);
if (sequenceStart === -1) return undefined;
const paramsStart = sequenceStart + KITTY_SEQUENCE_PREFIX.length;
const paramsEnd = line.indexOf(";", paramsStart);
if (paramsEnd === -1) return undefined;
const ids: number[] = [];
let rows = 1;
for (const param of line.slice(paramsStart, paramsEnd).split(",")) {
const [key, value] = param.split("=", 2);
if (value === undefined) continue;
const numberValue = Number(value);
if (!Number.isInteger(numberValue) || numberValue <= 0 || numberValue > 0xffffffff) continue;
if (key === "i") ids.push(numberValue);
else if (key === "r") rows = numberValue;
}
return { ids, rows };
}
function extractKittyImageIds(line: string): number[] {
return parseKittyImageHeader(line)?.ids ?? [];
}
function extractKittyImageRows(line: string): number {
return parseKittyImageHeader(line)?.rows ?? 1;
}
function isTermuxSession(): boolean {
return Boolean(process.env.TERMUX_VERSION);
}
export interface TuiMainScreenRenderState {
previousLines: string[];
previousWidth: number;
previousHeight: number;
cursorRow: number;
hardwareCursorRow: number;
maxLinesRendered: number;
previousViewportTop: number;
}
/** TUI implementation that renders into the terminal's main screen and scrollback. */
export class TuiMainScreen extends TuiBase implements TUI {
readonly mode = "regular" as const;
private previousLines: string[] = [];
private previousKittyImageIds = new Set<number>();
private previousWidth = 0;
private previousHeight = 0;
private cursorRow = 0;
private hardwareCursorRow = 0;
private maxLinesRendered = 0;
private previousViewportTop = 0;
captureRenderState(): TuiMainScreenRenderState {
return {
previousLines: [...this.previousLines],
previousWidth: this.previousWidth,
previousHeight: this.previousHeight,
cursorRow: this.cursorRow,
hardwareCursorRow: this.hardwareCursorRow,
maxLinesRendered: this.maxLinesRendered,
previousViewportTop: this.previousViewportTop,
};
}
restoreRenderState(state: TuiMainScreenRenderState): void {
this.previousLines = state.previousLines.map((line) => (isImageLine(line) ? "" : line));
this.previousKittyImageIds = new Set();
this.previousWidth = state.previousWidth;
this.previousHeight = state.previousHeight;
this.cursorRow = state.cursorRow;
this.hardwareCursorRow = state.hardwareCursorRow;
this.maxLinesRendered = state.maxLinesRendered;
this.previousViewportTop = state.previousViewportTop;
}
protected override resetRenderState(): void {
this.previousLines = [];
this.previousWidth = -1;
this.previousHeight = -1;
this.cursorRow = 0;
this.hardwareCursorRow = 0;
this.maxLinesRendered = 0;
this.previousViewportTop = 0;
}
protected override beforeTerminalStop(options: TuiStopOptions): void {
if (options.preserveScreen || this.previousLines.length === 0) return;
this.terminal.write(" ");
const targetRow = this.previousLines.length;
const lineDiff = targetRow - this.hardwareCursorRow;
if (lineDiff > 0) this.terminal.write(`\x1b[${lineDiff}B`);
else if (lineDiff < 0) this.terminal.write(`\x1b[${-lineDiff}A`);
this.terminal.write("\r\n");
}
private collectKittyImageIds(lines: string[]): Set<number> {
const ids = new Set<number>();
for (const line of lines) {
for (const id of extractKittyImageIds(line)) {
ids.add(id);
}
}
return ids;
}
private deleteKittyImages(ids: Iterable<number>): string {
let buffer = "";
for (const id of ids) {
buffer += deleteKittyImage(id);
}
return buffer;
}
private getKittyImageReservedRows(lines: string[], index: number, maxIndex = lines.length - 1): number {
const rows = extractKittyImageRows(lines[index] ?? "");
if (rows <= 1) return 1;
const maxRows = Math.min(rows, maxIndex - index + 1, lines.length - index);
let reservedRows = 1;
while (reservedRows < maxRows) {
const line = lines[index + reservedRows] ?? "";
if (isImageLine(line) || visibleWidth(line) > 0) break;
reservedRows++;
}
return reservedRows;
}
private expandChangedRangeForKittyImages(
firstChanged: number,
lastChanged: number,
newLines: string[],
): { firstChanged: number; lastChanged: number } {
let expandedFirstChanged = firstChanged;
let expandedLastChanged = lastChanged;
const expandForLines = (lines: string[]): void => {
for (let i = 0; i < lines.length; i++) {
if (extractKittyImageIds(lines[i]).length === 0) continue;
const blockEnd = i + this.getKittyImageReservedRows(lines, i) - 1;
if (i >= firstChanged || (i <= lastChanged && blockEnd >= firstChanged)) {
expandedFirstChanged = Math.min(expandedFirstChanged, i);
expandedLastChanged = Math.max(expandedLastChanged, blockEnd);
}
}
};
expandForLines(this.previousLines);
expandForLines(newLines);
return { firstChanged: expandedFirstChanged, lastChanged: expandedLastChanged };
}
private deleteChangedKittyImages(firstChanged: number, lastChanged: number): string {
if (firstChanged < 0 || lastChanged < firstChanged) return "";
const ids = new Set<number>();
const maxLine = Math.min(lastChanged, this.previousLines.length - 1);
for (let i = firstChanged; i <= maxLine; i++) {
for (const id of extractKittyImageIds(this.previousLines[i] ?? "")) {
ids.add(id);
}
}
return this.deleteKittyImages(ids);
}
protected doRender(): void {
if (this.stopped) return;
const width = this.terminal.columns;
const height = this.terminal.rows;
const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height;
const previousBufferLength = this.previousHeight > 0 ? this.previousViewportTop + this.previousHeight : height;
let prevViewportTop = heightChanged ? Math.max(0, previousBufferLength - height) : this.previousViewportTop;
let viewportTop = prevViewportTop;
let hardwareCursorRow = this.hardwareCursorRow;
const computeLineDiff = (targetRow: number): number => {
const currentScreenRow = hardwareCursorRow - prevViewportTop;
const targetScreenRow = targetRow - viewportTop;
return targetScreenRow - currentScreenRow;
};
// Render all components to get new lines
let newLines = this.render(width);
// Composite overlays into the rendered lines (before differential compare)
if (this.hasOverlayEntries) {
newLines = this.compositeOverlays(newLines, width, height);
}
// Extract cursor position before applying line resets (marker must be found first)
const cursorPos = this.extractCursorPosition(newLines, height);
newLines = this.applyLineResets(newLines);
// Helper to clear scrollback and viewport and render all new lines
const fullRender = (clear: boolean): void => {
this.fullRedrawCount += 1;
const output = new BoundedTerminalWriter((data) => this.terminal.write(data));
output.append("\x1b[?2026h"); // Begin synchronized output
if (clear) {
output.append(this.deleteKittyImages(this.previousKittyImageIds));
output.append("\x1b[2J\x1b[H\x1b[3J"); // Clear screen, home, then clear scrollback
}
for (let i = 0; i < newLines.length; i++) {
if (i > 0) output.append("\r\n");
const line = newLines[i];
const isImage = isImageLine(line);
const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i) : 1;
if (imageReservedRows > 1 && imageReservedRows <= height) {
for (let row = 1; row < imageReservedRows; row++) {
output.append("\r\n");
}
output.append(`\x1b[${imageReservedRows - 1}A`);
output.append(line);
output.append(`\x1b[${imageReservedRows - 1}B`);
i += imageReservedRows - 1;
continue;
}
output.append(line);
}
output.append("\x1b[?2026l"); // End synchronized output
output.flush();
this.cursorRow = Math.max(0, newLines.length - 1);
this.hardwareCursorRow = this.cursorRow;
// Reset max lines when clearing, otherwise track growth
if (clear) {
this.maxLinesRendered = newLines.length;
} else {
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
}
const bufferLength = Math.max(height, newLines.length);
this.previousViewportTop = Math.max(0, bufferLength - height);
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
this.previousWidth = width;
this.previousHeight = height;
};
const redrawLogDirectory = process.env.PI_TUI_DEBUG_REDRAW === "1" ? this.logDirectory : undefined;
const logRedraw = (reason: string): void => {
if (redrawLogDirectory === undefined) return;
const logPath = path.join(redrawLogDirectory, "pi-tui-debug.log");
const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.previousLines.length}, new=${newLines.length}, height=${height})\n`;
fs.mkdirSync(path.dirname(logPath), { recursive: true });
fs.appendFileSync(logPath, msg);
};
// First render - just output everything without clearing (assumes clean screen)
if (this.previousLines.length === 0 && !widthChanged && !heightChanged) {
logRedraw("first render");
fullRender(false);
return;
}
// Width changes always need a full re-render because wrapping changes.
if (widthChanged) {
logRedraw(`terminal width changed (${this.previousWidth} -> ${width})`);
fullRender(true);
return;
}
// Height changes normally need a full re-render to keep the visible viewport aligned,
// but Termux changes height when the software keyboard shows or hides.
// In that environment, a full redraw causes the entire history to replay on every toggle.
if (heightChanged && !isTermuxSession()) {
logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`);
fullRender(true);
return;
}
// Content shrunk below the working area and no overlays - re-render to clear empty rows
// (overlays need the padding, so only do this when no overlays are active)
// Configurable via setClearOnShrink()
if (this.getClearOnShrink() && newLines.length < this.maxLinesRendered && !this.hasOverlayEntries) {
logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`);
fullRender(true);
return;
}
// Find first and last changed lines
let firstChanged = -1;
let lastChanged = -1;
const maxLines = Math.max(newLines.length, this.previousLines.length);
for (let i = 0; i < maxLines; i++) {
const oldLine = i < this.previousLines.length ? this.previousLines[i] : "";
const newLine = i < newLines.length ? newLines[i] : "";
if (oldLine !== newLine) {
if (firstChanged === -1) {
firstChanged = i;
}
lastChanged = i;
}
}
const appendedLines = newLines.length > this.previousLines.length;
if (appendedLines) {
if (firstChanged === -1) {
firstChanged = this.previousLines.length;
}
lastChanged = newLines.length - 1;
}
if (firstChanged !== -1) {
const expandedRange = this.expandChangedRangeForKittyImages(firstChanged, lastChanged, newLines);
firstChanged = expandedRange.firstChanged;
lastChanged = expandedRange.lastChanged;
}
const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0;
// No changes - but still need to update hardware cursor position if it moved
if (firstChanged === -1) {
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousViewportTop = prevViewportTop;
this.previousHeight = height;
return;
}
// All changes are in deleted lines (nothing to render, just clear)
if (firstChanged >= newLines.length) {
if (this.previousLines.length > newLines.length) {
const output = new BoundedTerminalWriter((data) => this.terminal.write(data));
output.append("\x1b[?2026h");
output.append(this.deleteChangedKittyImages(firstChanged, lastChanged));
// Move to end of new content (clamp to 0 for empty content)
const targetRow = Math.max(0, newLines.length - 1);
if (targetRow < prevViewportTop) {
logRedraw(`deleted lines moved viewport up (${targetRow} < ${prevViewportTop})`);
fullRender(true);
return;
}
const lineDiff = computeLineDiff(targetRow);
if (lineDiff > 0) output.append(`\x1b[${lineDiff}B`);
else if (lineDiff < 0) output.append(`\x1b[${-lineDiff}A`);
output.append("\r");
// Clear extra lines without scrolling
const extraLines = this.previousLines.length - newLines.length;
if (extraLines > height) {
logRedraw(`extraLines > height (${extraLines} > ${height})`);
fullRender(true);
return;
}
const clearStartOffset = newLines.length === 0 ? 0 : 1;
if (extraLines > 0 && clearStartOffset > 0) {
output.append(`\x1b[${clearStartOffset}B`);
}
for (let i = 0; i < extraLines; i++) {
output.append("\r\x1b[2K");
if (i < extraLines - 1) output.append("\x1b[1B");
}
const moveBack = Math.max(0, extraLines - 1 + clearStartOffset);
if (moveBack > 0) {
output.append(`\x1b[${moveBack}A`);
}
output.append("\x1b[?2026l");
output.flush();
this.cursorRow = targetRow;
this.hardwareCursorRow = targetRow;
}
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
this.previousWidth = width;
this.previousHeight = height;
this.previousViewportTop = prevViewportTop;
return;
}
// Differential rendering can only touch what was actually visible.
// If the first changed line is above the previous viewport, we need a full redraw.
if (firstChanged < prevViewportTop) {
logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
fullRender(true);
return;
}
// Render from first changed line to end
// Keep updates wrapped in synchronized output while writing bounded chunks.
const output = new BoundedTerminalWriter((data) => this.terminal.write(data));
output.append("\x1b[?2026h"); // Begin synchronized output
output.append(this.deleteChangedKittyImages(firstChanged, lastChanged));
const prevViewportBottom = prevViewportTop + height - 1;
const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;
if (moveTargetRow > prevViewportBottom) {
const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
const moveToBottom = height - 1 - currentScreenRow;
if (moveToBottom > 0) {
output.append(`\x1b[${moveToBottom}B`);
}
const scroll = moveTargetRow - prevViewportBottom;
output.append("\r\n".repeat(scroll));
prevViewportTop += scroll;
viewportTop += scroll;
hardwareCursorRow = moveTargetRow;
}
// Move cursor to first changed line (use hardwareCursorRow for actual position)
const lineDiff = computeLineDiff(moveTargetRow);
if (lineDiff > 0) {
output.append(`\x1b[${lineDiff}B`); // Move down
} else if (lineDiff < 0) {
output.append(`\x1b[${-lineDiff}A`); // Move up
}
output.append(appendStart ? "\r\n" : "\r"); // Move to column 0
// Only render changed lines (firstChanged to lastChanged), not all lines to end
// This reduces flicker when only a single line changes (e.g., spinner animation)
const renderEnd = Math.min(lastChanged, newLines.length - 1);
for (let i = firstChanged; i <= renderEnd; i++) {
if (i > firstChanged) output.append("\r\n");
const line = newLines[i];
const isImage = isImageLine(line);
const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i, renderEnd) : 1;
if (imageReservedRows > 1) {
const imageStartScreenRow = i - viewportTop;
if (imageStartScreenRow < 0 || imageStartScreenRow + imageReservedRows > height) {
logRedraw(
`kitty image pre-clear would scroll (${imageStartScreenRow} + ${imageReservedRows} > ${height})`,
);
fullRender(true);
return;
}
output.append("\x1b[2K");
for (let row = 1; row < imageReservedRows; row++) {
output.append("\r\n\x1b[2K");
}
output.append(`\x1b[${imageReservedRows - 1}A`);
output.append(line);
output.append(`\x1b[${imageReservedRows - 1}B`);
i += imageReservedRows - 1;
continue;
}
output.append("\x1b[2K"); // Clear current line
if (!isImage && visibleWidth(line) > width) {
// Log all lines to crash file for debugging
const crashLogPath = path.join(this.logDirectory ?? os.tmpdir(), "pi-tui-crash.log");
const crashData = [
`Crash at ${new Date().toISOString()}`,
`Terminal width: ${width}`,
`Line ${i} visible width: ${visibleWidth(line)}`,
"",
"=== All rendered lines ===",
...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`),
"",
].join("\n");
fs.mkdirSync(path.dirname(crashLogPath), { recursive: true });
fs.writeFileSync(crashLogPath, crashData);
// Clean up terminal state before throwing
this.stop();
const errorMsg = [
`Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`,
"",
"This is likely caused by a custom TUI component not truncating its output.",
"Use visibleWidth() to measure and truncateToWidth() to truncate lines.",
"",
`Debug log written to: ${crashLogPath}`,
].join("\n");
throw new Error(errorMsg);
}
output.append(line);
}
// Track where cursor ended up after rendering
let finalCursorRow = renderEnd;
// If we had more lines before, clear them and move cursor back
if (this.previousLines.length > newLines.length) {
// Move to end of new content first if we stopped before it
if (renderEnd < newLines.length - 1) {
const moveDown = newLines.length - 1 - renderEnd;
output.append(`\x1b[${moveDown}B`);
finalCursorRow = newLines.length - 1;
}
const extraLines = this.previousLines.length - newLines.length;
for (let i = newLines.length; i < this.previousLines.length; i++) {
output.append("\r\n\x1b[2K");
}
// Move cursor back to end of new content
output.append(`\x1b[${extraLines}A`);
}
output.append("\x1b[?2026l"); // End synchronized output
if (process.env.PI_TUI_DEBUG === "1") {
const debugDir = "/tmp/tui";
fs.mkdirSync(debugDir, { recursive: true });
const debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
const debugData = [
`firstChanged: ${firstChanged}`,
`viewportTop: ${viewportTop}`,
`cursorRow: ${this.cursorRow}`,
`height: ${height}`,
`lineDiff: ${lineDiff}`,
`hardwareCursorRow: ${hardwareCursorRow}`,
`renderEnd: ${renderEnd}`,
`finalCursorRow: ${finalCursorRow}`,
`cursorPos: ${JSON.stringify(cursorPos)}`,
`newLines.length: ${newLines.length}`,
`previousLines.length: ${this.previousLines.length}`,
"",
"=== newLines ===",
JSON.stringify(newLines, null, 2),
"",
"=== previousLines ===",
JSON.stringify(this.previousLines, null, 2),
"",
"=== buffer ===",
`[${output.length} chars written in bounded chunks]`,
].join("\n");
fs.writeFileSync(debugPath, debugData);
}
output.flush();
// Track cursor position for next render
// cursorRow tracks end of content (for viewport calculation)
// hardwareCursorRow tracks actual terminal cursor position (for movement)
this.cursorRow = Math.max(0, newLines.length - 1);
this.hardwareCursorRow = finalCursorRow;
// Track terminal's working area (grows but doesn't shrink unless cleared)
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
this.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1);
// Position hardware cursor for IME
this.positionHardwareCursor(cursorPos, newLines.length);
this.previousLines = newLines;
this.previousKittyImageIds = this.collectKittyImageIds(newLines);
this.previousWidth = width;
this.previousHeight = height;
}
/**
* Position the hardware cursor for IME candidate window.
* @param cursorPos The cursor position extracted from rendered output, or null
* @param totalLines Total number of rendered lines
*/
private positionHardwareCursor(cursorPos: { row: number; col: number } | null, totalLines: number): void {
if (!cursorPos || totalLines <= 0) {
this.terminal.hideCursor();
return;
}
// Clamp cursor position to valid range
const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1));
const targetCol = Math.max(0, cursorPos.col);
// Move cursor from current position to target
const rowDelta = targetRow - this.hardwareCursorRow;
let buffer = "";
if (rowDelta > 0) {
buffer += `\x1b[${rowDelta}B`; // Move down
} else if (rowDelta < 0) {
buffer += `\x1b[${-rowDelta}A`; // Move up
}
// Move to absolute column (1-indexed)
buffer += `\x1b[${targetCol + 1}G`;
if (buffer) {
this.terminal.write(buffer);
}
this.hardwareCursorRow = targetRow;
if (this.getShowHardwareCursor()) {
this.terminal.showCursor();
} else {
this.terminal.hideCursor();
}
}
}

1456
packages/tui/src/tui.ts Normal file

File diff suppressed because it is too large Load Diff

View 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;
}
}

1337
packages/tui/src/utils.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
import { getWordSegmenter, isWhitespaceChar, PUNCTUATION_REGEX } from "./utils.ts";
const wordSegmenter = getWordSegmenter();
/**
* Options for word navigation functions.
* When omitted, uses the default Intl.Segmenter word segmentation.
*/
export interface WordNavigationOptions {
/** Custom segmenter returning word segments for the given text. */
segment?: (text: string) => Iterable<Intl.SegmentData>;
/** Predicate identifying atomic segments that should be treated as single units (e.g. paste markers). */
isAtomicSegment?: (segment: string) => boolean;
}
/**
* Find the cursor position after moving one word backward from `cursor` in `text`.
* Skips trailing whitespace, then stops at the next word/punctuation boundary.
*
* Pure function - does not mutate any state.
*/
export function findWordBackward(text: string, cursor: number, options?: WordNavigationOptions): number {
if (cursor <= 0) return 0;
const textBeforeCursor = text.slice(0, cursor);
const segmentFn = options?.segment;
const isAtomic = options?.isAtomicSegment;
const segments = segmentFn ? [...segmentFn(textBeforeCursor)] : [...wordSegmenter.segment(textBeforeCursor)];
let newCursor = cursor;
// Skip trailing whitespace
while (
segments.length > 0 &&
!isAtomic?.(segments[segments.length - 1]?.segment || "") &&
isWhitespaceChar(segments[segments.length - 1]?.segment || "")
) {
newCursor -= segments.pop()?.segment.length || 0;
}
if (segments.length === 0) return newCursor;
const last = segments[segments.length - 1]!;
if (isAtomic?.(last.segment)) {
// Skip one atomic segment.
newCursor -= last.segment.length;
} else if (last.isWordLike) {
// Skip inside one word-like segment, preserving ASCII punctuation boundaries.
const segment = last.segment;
const matches = [...segment.matchAll(new RegExp(PUNCTUATION_REGEX, "g"))];
if (matches.length <= 0) {
newCursor -= segment.length;
} else {
const lastMatch = matches[matches.length - 1]!;
newCursor -= segment.length - (lastMatch.index + lastMatch[0].length);
}
} else {
// Skip non-word non-whitespace run (punctuation)
while (
segments.length > 0 &&
!isAtomic?.(segments[segments.length - 1]?.segment || "") &&
!segments[segments.length - 1]?.isWordLike &&
!isWhitespaceChar(segments[segments.length - 1]?.segment || "")
) {
newCursor -= segments.pop()?.segment.length || 0;
}
}
return newCursor;
}
/**
* Find the cursor position after moving one word forward from `cursor` in `text`.
* Skips leading whitespace, then stops at the next word/punctuation boundary.
*
* Pure function - does not mutate any state.
*/
export function findWordForward(text: string, cursor: number, options?: WordNavigationOptions): number {
if (cursor >= text.length) return text.length;
const textAfterCursor = text.slice(cursor);
const segmentFn = options?.segment;
const isAtomic = options?.isAtomicSegment;
const segments = segmentFn ? segmentFn(textAfterCursor) : wordSegmenter.segment(textAfterCursor);
const iterator = segments[Symbol.iterator]();
let next = iterator.next();
let newCursor = cursor;
// Skip leading whitespace
while (!next.done && !isAtomic?.(next.value.segment) && isWhitespaceChar(next.value.segment)) {
newCursor += next.value.segment.length;
next = iterator.next();
}
if (next.done) return newCursor;
if (isAtomic?.(next.value.segment)) {
// Skip one atomic segment.
newCursor += next.value.segment.length;
} else if (next.value.isWordLike) {
// Skip inside one word-like segment, preserving ASCII punctuation boundaries.
newCursor += PUNCTUATION_REGEX.exec(next.value.segment)?.index ?? next.value.segment.length;
} else {
// Skip non-word non-whitespace run (punctuation)
while (
!next.done &&
!isAtomic?.(next.value.segment) &&
!next.value.isWordLike &&
!isWhitespaceChar(next.value.segment)
) {
newCursor += next.value.segment.length;
next = iterator.next();
}
}
return newCursor;
}