Release v0.8.0

This commit is contained in:
Mario Zechner 2025-11-21 03:12:42 +01:00
parent cc88095140
commit 85adcf22bf
48 changed files with 1530 additions and 608 deletions

View file

@ -1,7 +1,6 @@
import chalk from "chalk";
import type { AutocompleteProvider, CombinedAutocompleteProvider } from "../autocomplete.js";
import type { Component } from "../tui.js";
import { SelectList } from "./select-list.js";
import { SelectList, type SelectListTheme } from "./select-list.js";
interface EditorState {
lines: string[];
@ -15,8 +14,9 @@ interface LayoutLine {
cursorPos?: number;
}
export interface TextEditorConfig {
// Configuration options for text editor (none currently)
export interface EditorTheme {
borderColor: (str: string) => string;
selectList: SelectListTheme;
}
export class Editor implements Component {
@ -26,10 +26,10 @@ export class Editor implements Component {
cursorCol: 0,
};
private config: TextEditorConfig = {};
private theme: EditorTheme;
// Border color (can be changed dynamically)
public borderColor: (str: string) => string = chalk.gray;
public borderColor: (str: string) => string;
// Autocomplete support
private autocompleteProvider?: AutocompleteProvider;
@ -49,20 +49,19 @@ export class Editor implements Component {
public onChange?: (text: string) => void;
public disableSubmit: boolean = false;
constructor(config?: TextEditorConfig) {
if (config) {
this.config = { ...this.config, ...config };
}
}
configure(config: Partial<TextEditorConfig>): void {
this.config = { ...this.config, ...config };
constructor(theme: EditorTheme) {
this.theme = theme;
this.borderColor = theme.borderColor;
}
setAutocompleteProvider(provider: AutocompleteProvider): void {
this.autocompleteProvider = provider;
}
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] {
const horizontal = this.borderColor("─");
@ -806,7 +805,7 @@ export class Editor implements Component {
if (suggestions && suggestions.items.length > 0) {
this.autocompletePrefix = suggestions.prefix;
this.autocompleteList = new SelectList(suggestions.items, 5);
this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
this.isAutocompleting = true;
} else {
this.cancelAutocomplete();
@ -851,7 +850,7 @@ export class Editor implements Component {
if (suggestions && suggestions.items.length > 0) {
this.autocompletePrefix = suggestions.prefix;
this.autocompleteList = new SelectList(suggestions.items, 5);
this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
this.isAutocompleting = true;
} else {
this.cancelAutocomplete();
@ -881,7 +880,7 @@ export class Editor implements Component {
this.autocompletePrefix = suggestions.prefix;
if (this.autocompleteList) {
// Update the existing list with new items
this.autocompleteList = new SelectList(suggestions.items, 5);
this.autocompleteList = new SelectList(suggestions.items, 5, this.theme.selectList);
}
} else {
// No more matches, cancel autocomplete

View file

@ -129,6 +129,10 @@ export class Input implements Component {
this.cursor += cleanText.length;
}
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] {
// Calculate visible window
const prompt = "> ";

View file

@ -1,4 +1,3 @@
import chalk from "chalk";
import type { TUI } from "../tui.js";
import { Text } from "./text.js";
@ -13,6 +12,8 @@ export class Loader extends Text {
constructor(
ui: TUI,
private spinnerColorFn: (str: string) => string,
private messageColorFn: (str: string) => string,
private message: string = "Loading...",
) {
super("", 1, 0);
@ -46,7 +47,7 @@ export class Loader extends Text {
private updateDisplay() {
const frame = this.frames[this.currentFrame];
this.setText(`${chalk.cyan(frame)} ${chalk.dim(this.message)}`);
this.setText(`${this.spinnerColorFn(frame)} ${this.messageColorFn(this.message)}`);
if (this.ui) {
this.ui.requestRender();
}

View file

@ -1,20 +1,16 @@
import { Chalk } from "chalk";
import { marked, type Token } from "marked";
import type { Component } from "../tui.js";
import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.js";
// Use a chalk instance with color level 3 for consistent ANSI output
const colorChalk = new Chalk({ level: 3 });
/**
* Default text styling for markdown content.
* Applied to all text unless overridden by markdown formatting.
*/
export interface DefaultTextStyle {
/** Foreground color - named color or hex string like "#ff0000" */
color?: string;
/** Background color - named color or hex string like "#ff0000" */
bgColor?: string;
/** Foreground color function */
color?: (text: string) => string;
/** Background color function */
bgColor?: (text: string) => string;
/** Bold text */
bold?: boolean;
/** Italic text */
@ -32,6 +28,7 @@ export interface DefaultTextStyle {
export interface MarkdownTheme {
heading: (text: string) => string;
link: (text: string) => string;
linkUrl: (text: string) => string;
code: (text: string) => string;
codeBlock: (text: string) => string;
codeBlockBorder: (text: string) => string;
@ -39,6 +36,10 @@ export interface MarkdownTheme {
quoteBorder: (text: string) => string;
hr: (text: string) => string;
listBullet: (text: string) => string;
bold: (text: string) => string;
italic: (text: string) => string;
strikethrough: (text: string) => string;
underline: (text: string) => string;
}
export class Markdown implements Component {
@ -46,7 +47,7 @@ export class Markdown implements Component {
private paddingX: number; // Left/right padding
private paddingY: number; // Top/bottom padding
private defaultTextStyle?: DefaultTextStyle;
private theme?: MarkdownTheme;
private theme: MarkdownTheme;
// Cache for rendered output
private cachedText?: string;
@ -54,22 +55,25 @@ export class Markdown implements Component {
private cachedLines?: string[];
constructor(
text: string = "",
paddingX: number = 1,
paddingY: number = 1,
text: string,
paddingX: number,
paddingY: number,
theme: MarkdownTheme,
defaultTextStyle?: DefaultTextStyle,
theme?: MarkdownTheme,
) {
this.text = text;
this.paddingX = paddingX;
this.paddingY = paddingY;
this.defaultTextStyle = defaultTextStyle;
this.theme = theme;
this.defaultTextStyle = defaultTextStyle;
}
setText(text: string): void {
this.text = text;
// Invalidate cache when text changes
this.invalidate();
}
invalidate(): void {
this.cachedText = undefined;
this.cachedWidth = undefined;
this.cachedLines = undefined;
@ -119,14 +123,14 @@ export class Markdown implements Component {
// Add margins and background to each wrapped line
const leftMargin = " ".repeat(this.paddingX);
const rightMargin = " ".repeat(this.paddingX);
const bgRgb = this.defaultTextStyle?.bgColor ? this.parseBgColor() : undefined;
const bgFn = this.defaultTextStyle?.bgColor;
const contentLines: string[] = [];
for (const line of wrappedLines) {
const lineWithMargins = leftMargin + line + rightMargin;
if (bgRgb) {
contentLines.push(applyBackgroundToLine(lineWithMargins, width, bgRgb));
if (bgFn) {
contentLines.push(applyBackgroundToLine(lineWithMargins, width, bgFn));
} else {
// No background - just pad to width
const visibleLen = visibleWidth(lineWithMargins);
@ -139,7 +143,7 @@ export class Markdown implements Component {
const emptyLine = " ".repeat(width);
const emptyLines: string[] = [];
for (let i = 0; i < this.paddingY; i++) {
const line = bgRgb ? applyBackgroundToLine(emptyLine, width, bgRgb) : emptyLine;
const line = bgFn ? applyBackgroundToLine(emptyLine, width, bgFn) : emptyLine;
emptyLines.push(line);
}
@ -154,39 +158,6 @@ export class Markdown implements Component {
return result.length > 0 ? result : [""];
}
/**
* Parse background color from defaultTextStyle to RGB values
*/
private parseBgColor(): { r: number; g: number; b: number } | undefined {
if (!this.defaultTextStyle?.bgColor) {
return undefined;
}
if (this.defaultTextStyle.bgColor.startsWith("#")) {
// Hex color
const hex = this.defaultTextStyle.bgColor.substring(1);
return {
r: Number.parseInt(hex.substring(0, 2), 16),
g: Number.parseInt(hex.substring(2, 4), 16),
b: Number.parseInt(hex.substring(4, 6), 16),
};
}
// Named colors - map to RGB (common terminal colors)
const colorMap: Record<string, { r: number; g: number; b: number }> = {
bgBlack: { r: 0, g: 0, b: 0 },
bgRed: { r: 255, g: 0, b: 0 },
bgGreen: { r: 0, g: 255, b: 0 },
bgYellow: { r: 255, g: 255, b: 0 },
bgBlue: { r: 0, g: 0, b: 255 },
bgMagenta: { r: 255, g: 0, b: 255 },
bgCyan: { r: 0, g: 255, b: 255 },
bgWhite: { r: 255, g: 255, b: 255 },
};
return colorMap[this.defaultTextStyle.bgColor];
}
/**
* Apply default text style to a string.
* This is the base styling applied to all text content.
@ -202,31 +173,21 @@ export class Markdown implements Component {
// Apply foreground color (NOT background - that's applied at padding stage)
if (this.defaultTextStyle.color) {
if (this.defaultTextStyle.color.startsWith("#")) {
// Hex color
const hex = this.defaultTextStyle.color.substring(1);
const r = Number.parseInt(hex.substring(0, 2), 16);
const g = Number.parseInt(hex.substring(2, 4), 16);
const b = Number.parseInt(hex.substring(4, 6), 16);
styled = colorChalk.rgb(r, g, b)(styled);
} else {
// Named color
styled = (colorChalk as any)[this.defaultTextStyle.color](styled);
}
styled = this.defaultTextStyle.color(styled);
}
// Apply text decorations
// Apply text decorations using this.theme
if (this.defaultTextStyle.bold) {
styled = colorChalk.bold(styled);
styled = this.theme.bold(styled);
}
if (this.defaultTextStyle.italic) {
styled = colorChalk.italic(styled);
styled = this.theme.italic(styled);
}
if (this.defaultTextStyle.strikethrough) {
styled = colorChalk.strikethrough(styled);
styled = this.theme.strikethrough(styled);
}
if (this.defaultTextStyle.underline) {
styled = colorChalk.underline(styled);
styled = this.theme.underline(styled);
}
return styled;
@ -240,13 +201,15 @@ export class Markdown implements Component {
const headingLevel = token.depth;
const headingPrefix = "#".repeat(headingLevel) + " ";
const headingText = this.renderInlineTokens(token.tokens || []);
let styledHeading: string;
if (headingLevel === 1) {
lines.push(colorChalk.bold.underline.yellow(headingText));
styledHeading = this.theme.heading(this.theme.bold(this.theme.underline(headingText)));
} else if (headingLevel === 2) {
lines.push(colorChalk.bold.yellow(headingText));
styledHeading = this.theme.heading(this.theme.bold(headingText));
} else {
lines.push(colorChalk.bold(headingPrefix + headingText));
styledHeading = this.theme.heading(this.theme.bold(headingPrefix + headingText));
}
lines.push(styledHeading);
lines.push(""); // Add spacing after headings
break;
}
@ -262,13 +225,13 @@ export class Markdown implements Component {
}
case "code": {
lines.push(colorChalk.gray("```" + (token.lang || "")));
lines.push(this.theme.codeBlockBorder("```" + (token.lang || "")));
// Split code by newlines and style each line
const codeLines = token.text.split("\n");
for (const codeLine of codeLines) {
lines.push(colorChalk.dim(" ") + colorChalk.green(codeLine));
lines.push(" " + this.theme.codeBlock(codeLine));
}
lines.push(colorChalk.gray("```"));
lines.push(this.theme.codeBlockBorder("```"));
lines.push(""); // Add spacing after code blocks
break;
}
@ -291,14 +254,14 @@ export class Markdown implements Component {
const quoteText = this.renderInlineTokens(token.tokens || []);
const quoteLines = quoteText.split("\n");
for (const quoteLine of quoteLines) {
lines.push(colorChalk.gray("│ ") + colorChalk.italic(quoteLine));
lines.push(this.theme.quoteBorder("│ ") + this.theme.quote(this.theme.italic(quoteLine)));
}
lines.push(""); // Add spacing after blockquotes
break;
}
case "hr":
lines.push(colorChalk.gray("─".repeat(Math.min(width, 80))));
lines.push(this.theme.hr("─".repeat(Math.min(width, 80))));
lines.push(""); // Add spacing after horizontal rules
break;
@ -339,31 +302,31 @@ export class Markdown implements Component {
case "strong": {
// Apply bold, then reapply default style after
const boldContent = this.renderInlineTokens(token.tokens || []);
result += colorChalk.bold(boldContent) + this.applyDefaultStyle("");
result += this.theme.bold(boldContent) + this.applyDefaultStyle("");
break;
}
case "em": {
// Apply italic, then reapply default style after
const italicContent = this.renderInlineTokens(token.tokens || []);
result += colorChalk.italic(italicContent) + this.applyDefaultStyle("");
result += this.theme.italic(italicContent) + this.applyDefaultStyle("");
break;
}
case "codespan":
// Apply code styling without backticks
result += colorChalk.cyan(token.text) + this.applyDefaultStyle("");
result += this.theme.code(token.text) + this.applyDefaultStyle("");
break;
case "link": {
const linkText = this.renderInlineTokens(token.tokens || []);
// If link text matches href, only show the link once
if (linkText === token.href) {
result += colorChalk.underline.blue(linkText) + this.applyDefaultStyle("");
result += this.theme.link(this.theme.underline(linkText)) + this.applyDefaultStyle("");
} else {
result +=
colorChalk.underline.blue(linkText) +
colorChalk.gray(` (${token.href})`) +
this.theme.link(this.theme.underline(linkText)) +
this.theme.linkUrl(` (${token.href})`) +
this.applyDefaultStyle("");
}
break;
@ -375,7 +338,7 @@ export class Markdown implements Component {
case "del": {
const delContent = this.renderInlineTokens(token.tokens || []);
result += colorChalk.strikethrough(delContent) + this.applyDefaultStyle("");
result += this.theme.strikethrough(delContent) + this.applyDefaultStyle("");
break;
}
@ -415,7 +378,7 @@ export class Markdown implements Component {
lines.push(firstLine);
} else {
// Regular text content - add indent and bullet
lines.push(indent + colorChalk.cyan(bullet) + firstLine);
lines.push(indent + this.theme.listBullet(bullet) + firstLine);
}
// Rest of the lines
@ -432,7 +395,7 @@ export class Markdown implements Component {
}
}
} else {
lines.push(indent + colorChalk.cyan(bullet));
lines.push(indent + this.theme.listBullet(bullet));
}
}
@ -463,12 +426,12 @@ export class Markdown implements Component {
lines.push(text);
} else if (token.type === "code") {
// Code block in list item
lines.push(colorChalk.gray("```" + (token.lang || "")));
lines.push(this.theme.codeBlockBorder("```" + (token.lang || "")));
const codeLines = token.text.split("\n");
for (const codeLine of codeLines) {
lines.push(colorChalk.dim(" ") + colorChalk.green(codeLine));
lines.push(" " + this.theme.codeBlock(codeLine));
}
lines.push(colorChalk.gray("```"));
lines.push(this.theme.codeBlockBorder("```"));
} else {
// Other token types - try to render as inline
const text = this.renderInlineTokens([token]);
@ -515,7 +478,7 @@ export class Markdown implements Component {
// Render header
const headerCells = token.header.map((cell, i) => {
const text = this.renderInlineTokens(cell.tokens || []);
return colorChalk.bold(text.padEnd(columnWidths[i]));
return this.theme.bold(text.padEnd(columnWidths[i]));
});
lines.push("│ " + headerCells.join(" │ ") + " │");

View file

@ -1,4 +1,3 @@
import chalk from "chalk";
import type { Component } from "../tui.js";
export interface SelectItem {
@ -7,19 +6,30 @@ export interface SelectItem {
description?: string;
}
export interface SelectListTheme {
selectedPrefix: (text: string) => string;
selectedText: (text: string) => string;
description: (text: string) => string;
scrollInfo: (text: string) => string;
noMatch: (text: string) => string;
}
export class SelectList implements Component {
private items: SelectItem[] = [];
private filteredItems: SelectItem[] = [];
private selectedIndex: number = 0;
private maxVisible: number = 5;
private theme: SelectListTheme;
public onSelect?: (item: SelectItem) => void;
public onCancel?: () => void;
public onSelectionChange?: (item: SelectItem) => void;
constructor(items: SelectItem[], maxVisible: number = 5) {
constructor(items: SelectItem[], maxVisible: number, theme: SelectListTheme) {
this.items = items;
this.filteredItems = items;
this.maxVisible = maxVisible;
this.theme = theme;
}
setFilter(filter: string): void {
@ -32,12 +42,16 @@ export class SelectList implements Component {
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(chalk.gray(" No matching commands"));
lines.push(this.theme.noMatch(" No matching commands"));
return lines;
}
@ -58,7 +72,7 @@ export class SelectList implements Component {
let line = "";
if (isSelected) {
// Use arrow indicator for selection
const prefix = chalk.blue("→ ");
const prefix = this.theme.selectedPrefix("→ ");
const prefixWidth = 2; // "→ " is 2 characters visually
const displayValue = item.label || item.value;
@ -74,16 +88,20 @@ export class SelectList implements Component {
if (remainingWidth > 10) {
const truncatedDesc = item.description.substring(0, remainingWidth);
line = prefix + chalk.blue(truncatedValue) + chalk.gray(spacing + truncatedDesc);
const selectedText = this.theme.selectedText(truncatedValue);
const descText = this.theme.description(spacing + truncatedDesc);
line = prefix + selectedText + descText;
} else {
// Not enough space for description
const maxWidth = width - prefixWidth - 2;
line = prefix + chalk.blue(displayValue.substring(0, maxWidth));
const selectedText = this.theme.selectedText(displayValue.substring(0, maxWidth));
line = prefix + selectedText;
}
} else {
// No description or not enough width
const maxWidth = width - prefixWidth - 2;
line = prefix + chalk.blue(displayValue.substring(0, maxWidth));
const selectedText = this.theme.selectedText(displayValue.substring(0, maxWidth));
line = prefix + selectedText;
}
} else {
const displayValue = item.label || item.value;
@ -101,7 +119,8 @@ export class SelectList implements Component {
if (remainingWidth > 10) {
const truncatedDesc = item.description.substring(0, remainingWidth);
line = prefix + truncatedValue + chalk.gray(spacing + truncatedDesc);
const descText = this.theme.description(spacing + truncatedDesc);
line = prefix + truncatedValue + descText;
} else {
// Not enough space for description
const maxWidth = width - prefix.length - 2;
@ -123,8 +142,7 @@ export class SelectList implements Component {
// Truncate if too long for terminal
const maxWidth = width - 2;
const truncated = scrollText.substring(0, maxWidth);
const scrollInfo = chalk.gray(truncated);
lines.push(scrollInfo);
lines.push(this.theme.scrollInfo(truncated));
}
return lines;
@ -134,10 +152,12 @@ export class SelectList implements Component {
// Up arrow
if (keyData === "\x1b[A") {
this.selectedIndex = Math.max(0, this.selectedIndex - 1);
this.notifySelectionChange();
}
// Down arrow
else if (keyData === "\x1b[B") {
this.selectedIndex = Math.min(this.filteredItems.length - 1, this.selectedIndex + 1);
this.notifySelectionChange();
}
// Enter
else if (keyData === "\r") {
@ -154,6 +174,13 @@ export class SelectList implements Component {
}
}
private notifySelectionChange(): void {
const selectedItem = this.filteredItems[this.selectedIndex];
if (selectedItem && this.onSelectionChange) {
this.onSelectionChange(selectedItem);
}
}
getSelectedItem(): SelectItem | null {
const item = this.filteredItems[this.selectedIndex];
return item || null;

View file

@ -14,6 +14,10 @@ export class Spacer implements Component {
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++) {

View file

@ -1,9 +1,6 @@
import { Chalk } from "chalk";
import type { Component } from "../tui.js";
import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.js";
const colorChalk = new Chalk({ level: 3 });
/**
* Text component - displays multi-line text with word wrapping
*/
@ -11,23 +8,18 @@ export class Text implements Component {
private text: string;
private paddingX: number; // Left/right padding
private paddingY: number; // Top/bottom padding
private customBgRgb?: { r: number; g: number; b: number };
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,
customBgRgb?: { r: number; g: number; b: number },
) {
constructor(text: string = "", paddingX: number = 1, paddingY: number = 1, customBgFn?: (text: string) => string) {
this.text = text;
this.paddingX = paddingX;
this.paddingY = paddingY;
this.customBgRgb = customBgRgb;
this.customBgFn = customBgFn;
}
setText(text: string): void {
@ -37,8 +29,14 @@ export class Text implements Component {
this.cachedLines = undefined;
}
setCustomBgRgb(customBgRgb?: { r: number; g: number; b: number }): void {
this.customBgRgb = customBgRgb;
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;
@ -78,8 +76,8 @@ export class Text implements Component {
const lineWithMargins = leftMargin + line + rightMargin;
// Apply background if specified (this also pads to full width)
if (this.customBgRgb) {
contentLines.push(applyBackgroundToLine(lineWithMargins, width, this.customBgRgb));
if (this.customBgFn) {
contentLines.push(applyBackgroundToLine(lineWithMargins, width, this.customBgFn));
} else {
// No background - just pad to width with spaces
const visibleLen = visibleWidth(lineWithMargins);
@ -92,7 +90,7 @@ export class Text implements Component {
const emptyLine = " ".repeat(width);
const emptyLines: string[] = [];
for (let i = 0; i < this.paddingY; i++) {
const line = this.customBgRgb ? applyBackgroundToLine(emptyLine, width, this.customBgRgb) : emptyLine;
const line = this.customBgFn ? applyBackgroundToLine(emptyLine, width, this.customBgFn) : emptyLine;
emptyLines.push(line);
}

View file

@ -15,20 +15,34 @@ export class TruncatedText implements Component {
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("");
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)
let displayText = this.text;
const textVisibleWidth = visibleWidth(this.text);
let displayText = singleLineText;
const textVisibleWidth = visibleWidth(singleLineText);
if (textVisibleWidth > availableWidth) {
// Need to truncate - walk through the string character by character
@ -38,18 +52,21 @@ export class TruncatedText implements Component {
const ellipsisWidth = 3;
const targetWidth = availableWidth - ellipsisWidth;
while (i < this.text.length && currentWidth < targetWidth) {
// Skip ANSI escape sequences
if (this.text[i] === "\x1b" && this.text[i + 1] === "[") {
while (i < singleLineText.length && currentWidth < targetWidth) {
// Skip ANSI escape sequences (include them in output but don't count width)
if (singleLineText[i] === "\x1b" && singleLineText[i + 1] === "[") {
let j = i + 2;
while (j < this.text.length && !/[a-zA-Z]/.test(this.text[j])) {
while (j < singleLineText.length && !/[a-zA-Z]/.test(singleLineText[j])) {
j++;
}
i = j + 1;
// Include the final letter of the escape sequence
j++;
truncateAt = j;
i = j;
continue;
}
const char = this.text[i];
const char = singleLineText[i];
const charWidth = visibleWidth(char);
if (currentWidth + charWidth > targetWidth) {
@ -61,16 +78,25 @@ export class TruncatedText implements Component {
i++;
}
displayText = this.text.substring(0, truncateAt) + "...";
// Add reset code before ellipsis to prevent styling leaking into it
displayText = singleLineText.substring(0, truncateAt) + "\x1b[0m...";
}
// Add horizontal padding
const paddingStr = " ".repeat(this.paddingX);
result.push(paddingStr + displayText);
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("");
result.push(emptyLine);
}
return result;