Squashed 'third_party/pi/' content from commit d981de122
git-subtree-dir: third_party/pi git-subtree-split: d981de1229ef899957bbe968bc8dcda02a21f477
This commit is contained in:
327
packages/tui/src/alt-screen-search.ts
Normal file
327
packages/tui/src/alt-screen-search.ts
Normal 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)}┘`,
|
||||
];
|
||||
}
|
||||
}
|
||||
826
packages/tui/src/autocomplete.ts
Normal file
826
packages/tui/src/autocomplete.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
51
packages/tui/src/components/alt-screen-flash.ts
Normal file
51
packages/tui/src/components/alt-screen-flash.ts
Normal 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`;
|
||||
});
|
||||
}
|
||||
}
|
||||
167
packages/tui/src/components/box.ts
Normal file
167
packages/tui/src/components/box.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
40
packages/tui/src/components/cancellable-loader.ts
Normal file
40
packages/tui/src/components/cancellable-loader.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { 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();
|
||||
}
|
||||
}
|
||||
2461
packages/tui/src/components/editor.ts
Normal file
2461
packages/tui/src/components/editor.ts
Normal file
File diff suppressed because it is too large
Load Diff
44
packages/tui/src/components/h-stack.ts
Normal file
44
packages/tui/src/components/h-stack.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
127
packages/tui/src/components/image.ts
Normal file
127
packages/tui/src/components/image.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
494
packages/tui/src/components/input.ts
Normal file
494
packages/tui/src/components/input.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
101
packages/tui/src/components/loader.ts
Normal file
101
packages/tui/src/components/loader.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
1015
packages/tui/src/components/markdown.ts
Normal file
1015
packages/tui/src/components/markdown.ts
Normal file
File diff suppressed because it is too large
Load Diff
33
packages/tui/src/components/mouse-region.ts
Normal file
33
packages/tui/src/components/mouse-region.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
224
packages/tui/src/components/scroll-view.ts
Normal file
224
packages/tui/src/components/scroll-view.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
273
packages/tui/src/components/select-list.ts
Normal file
273
packages/tui/src/components/select-list.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
328
packages/tui/src/components/settings-list.ts
Normal file
328
packages/tui/src/components/settings-list.ts
Normal 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
28
packages/tui/src/components/spacer.ts
Normal file
28
packages/tui/src/components/spacer.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Component } from "../tui.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;
|
||||
}
|
||||
}
|
||||
154
packages/tui/src/components/stack.ts
Normal file
154
packages/tui/src/components/stack.ts
Normal 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;
|
||||
}
|
||||
107
packages/tui/src/components/text.ts
Normal file
107
packages/tui/src/components/text.ts
Normal 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 : [""];
|
||||
}
|
||||
}
|
||||
65
packages/tui/src/components/truncated-text.ts
Normal file
65
packages/tui/src/components/truncated-text.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type { Component } from "../tui.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;
|
||||
}
|
||||
}
|
||||
33
packages/tui/src/components/v-stack.ts
Normal file
33
packages/tui/src/components/v-stack.ts
Normal 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";
|
||||
74
packages/tui/src/editor-component.ts
Normal file
74
packages/tui/src/editor-component.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { AutocompleteProvider } from "./autocomplete.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
137
packages/tui/src/fuzzy.ts
Normal 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
154
packages/tui/src/index.ts
Normal 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";
|
||||
320
packages/tui/src/keybindings.ts
Normal file
320
packages/tui/src/keybindings.ts
Normal 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
1401
packages/tui/src/keys.ts
Normal file
File diff suppressed because it is too large
Load Diff
46
packages/tui/src/kill-ring.ts
Normal file
46
packages/tui/src/kill-ring.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Ring buffer for Emacs-style kill/yank operations.
|
||||
*
|
||||
* Tracks killed (deleted) text entries. Consecutive kills can accumulate
|
||||
* into a single entry. Supports yank (paste most recent) and yank-pop
|
||||
* (cycle through older entries).
|
||||
*/
|
||||
export class KillRing {
|
||||
private ring: string[] = [];
|
||||
|
||||
/**
|
||||
* Add text to the kill ring.
|
||||
*
|
||||
* @param text - The killed text to add
|
||||
* @param opts - Push options
|
||||
* @param opts.prepend - If accumulating, prepend (backward deletion) or append (forward deletion)
|
||||
* @param opts.accumulate - Merge with the most recent entry instead of creating a new one
|
||||
*/
|
||||
push(text: string, opts: { prepend: boolean; accumulate?: boolean }): void {
|
||||
if (!text) return;
|
||||
|
||||
if (opts.accumulate && this.ring.length > 0) {
|
||||
const last = this.ring.pop()!;
|
||||
this.ring.push(opts.prepend ? text + last : last + text);
|
||||
} else {
|
||||
this.ring.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
/** Get most recent entry without modifying the ring. */
|
||||
peek(): string | undefined {
|
||||
return this.ring.length > 0 ? this.ring[this.ring.length - 1] : undefined;
|
||||
}
|
||||
|
||||
/** Move last entry to front (for yank-pop cycling). */
|
||||
rotate(): void {
|
||||
if (this.ring.length > 1) {
|
||||
const last = this.ring.pop()!;
|
||||
this.ring.unshift(last);
|
||||
}
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return this.ring.length;
|
||||
}
|
||||
}
|
||||
1394
packages/tui/src/latex.ts
Normal file
1394
packages/tui/src/latex.ts
Normal file
File diff suppressed because it is too large
Load Diff
51
packages/tui/src/layout-node.ts
Normal file
51
packages/tui/src/layout-node.ts
Normal 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
449
packages/tui/src/layout.ts
Normal 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);
|
||||
}
|
||||
59
packages/tui/src/native-modifiers.ts
Normal file
59
packages/tui/src/native-modifiers.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
31
packages/tui/src/native-module-path.ts
Normal file
31
packages/tui/src/native-module-path.ts
Normal 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));
|
||||
}
|
||||
444
packages/tui/src/stdin-buffer.ts
Normal file
444
packages/tui/src/stdin-buffer.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
73
packages/tui/src/terminal-colors.ts
Normal file
73
packages/tui/src/terminal-colors.ts
Normal 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";
|
||||
}
|
||||
696
packages/tui/src/terminal-image.ts
Normal file
696
packages/tui/src/terminal-image.ts
Normal 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(" ")}]`;
|
||||
}
|
||||
565
packages/tui/src/terminal.ts
Normal file
565
packages/tui/src/terminal.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
1721
packages/tui/src/tui-alt-screen.ts
Normal file
1721
packages/tui/src/tui-alt-screen.ts
Normal file
File diff suppressed because it is too large
Load Diff
655
packages/tui/src/tui-main-screen.ts
Normal file
655
packages/tui/src/tui-main-screen.ts
Normal 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
1456
packages/tui/src/tui.ts
Normal file
File diff suppressed because it is too large
Load Diff
28
packages/tui/src/undo-stack.ts
Normal file
28
packages/tui/src/undo-stack.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Generic undo stack with clone-on-push semantics.
|
||||
*
|
||||
* Stores deep clones of state snapshots. Popped snapshots are returned
|
||||
* directly (no re-cloning) since they are already detached.
|
||||
*/
|
||||
export class UndoStack<S> {
|
||||
private stack: S[] = [];
|
||||
|
||||
/** Push a deep clone of the given state onto the stack. */
|
||||
push(state: S): void {
|
||||
this.stack.push(structuredClone(state));
|
||||
}
|
||||
|
||||
/** Pop and return the most recent snapshot, or undefined if empty. */
|
||||
pop(): S | undefined {
|
||||
return this.stack.pop();
|
||||
}
|
||||
|
||||
/** Remove all snapshots. */
|
||||
clear(): void {
|
||||
this.stack.length = 0;
|
||||
}
|
||||
|
||||
get length(): number {
|
||||
return this.stack.length;
|
||||
}
|
||||
}
|
||||
1337
packages/tui/src/utils.ts
Normal file
1337
packages/tui/src/utils.ts
Normal file
File diff suppressed because it is too large
Load Diff
117
packages/tui/src/word-navigation.ts
Normal file
117
packages/tui/src/word-navigation.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user