mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-21 09:01:20 +00:00
Release v0.8.0
This commit is contained in:
parent
cc88095140
commit
85adcf22bf
48 changed files with 1530 additions and 608 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@mariozechner/pi-tui",
|
||||
"version": "0.7.29",
|
||||
"version": "0.8.0",
|
||||
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = "> ";
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(" │ ") + " │");
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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++) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ export {
|
|||
type SlashCommand,
|
||||
} from "./autocomplete.js";
|
||||
// Components
|
||||
export { Editor, type TextEditorConfig } from "./components/editor.js";
|
||||
export { Editor, type EditorTheme } from "./components/editor.js";
|
||||
export { Input } from "./components/input.js";
|
||||
export { Loader } from "./components/loader.js";
|
||||
export { type DefaultTextStyle, Markdown, type MarkdownTheme } from "./components/markdown.js";
|
||||
export { type SelectItem, SelectList } from "./components/select-list.js";
|
||||
export { type SelectItem, SelectList, type SelectListTheme } from "./components/select-list.js";
|
||||
export { Spacer } from "./components/spacer.js";
|
||||
export { Text } from "./components/text.js";
|
||||
export { TruncatedText } from "./components/truncated-text.js";
|
||||
|
|
|
|||
|
|
@ -20,6 +20,12 @@ export interface Component {
|
|||
* Optional handler for keyboard input when component has focus
|
||||
*/
|
||||
handleInput?(data: string): void;
|
||||
|
||||
/**
|
||||
* Invalidate any cached rendering state.
|
||||
* Called when theme changes or when component needs to re-render from scratch.
|
||||
*/
|
||||
invalidate(): void;
|
||||
}
|
||||
|
||||
export { visibleWidth };
|
||||
|
|
@ -45,6 +51,12 @@ export class Container implements Component {
|
|||
this.children = [];
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
for (const child of this.children) {
|
||||
child.invalidate?.();
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
for (const child of this.children) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,5 @@
|
|||
import { Chalk } from "chalk";
|
||||
import stringWidth from "string-width";
|
||||
|
||||
const colorChalk = new Chalk({ level: 3 });
|
||||
|
||||
/**
|
||||
* Calculate the visible width of a string in terminal columns.
|
||||
*/
|
||||
|
|
@ -245,30 +242,18 @@ function breakLongWord(word: string, width: number, tracker: AnsiCodeTracker): s
|
|||
/**
|
||||
* Apply background color to a line, padding to full width.
|
||||
*
|
||||
* Handles the tricky case where content contains \x1b[0m resets that would
|
||||
* kill the background color. We reapply the background after any reset.
|
||||
*
|
||||
* @param line - Line of text (may contain ANSI codes)
|
||||
* @param width - Total width to pad to
|
||||
* @param bgRgb - Background RGB color
|
||||
* @param bgFn - Background color function
|
||||
* @returns Line with background applied and padded to width
|
||||
*/
|
||||
export function applyBackgroundToLine(line: string, width: number, bgRgb: { r: number; g: number; b: number }): string {
|
||||
const bgStart = `\x1b[48;2;${bgRgb.r};${bgRgb.g};${bgRgb.b}m`;
|
||||
const bgEnd = "\x1b[49m";
|
||||
|
||||
export function applyBackgroundToLine(line: string, width: number, bgFn: (text: string) => string): string {
|
||||
// Calculate padding needed
|
||||
const visibleLen = visibleWidth(line);
|
||||
const paddingNeeded = Math.max(0, width - visibleLen);
|
||||
const padding = " ".repeat(paddingNeeded);
|
||||
|
||||
// Strategy: wrap content + padding in background, then fix any 0m resets
|
||||
// Apply background to content + padding
|
||||
const withPadding = line + padding;
|
||||
const withBg = bgStart + withPadding + bgEnd;
|
||||
|
||||
// Find all \x1b[0m or \x1b[49m that would kill background
|
||||
// Replace with reset + background reapplication
|
||||
const fixedBg = withBg.replace(/\x1b\[0m/g, `\x1b[0m${bgStart}`);
|
||||
|
||||
return fixedBg;
|
||||
return bgFn(withPadding);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
* Simple chat interface demo using tui.ts
|
||||
*/
|
||||
|
||||
import chalk from "chalk";
|
||||
import { CombinedAutocompleteProvider } from "../src/autocomplete.js";
|
||||
import { Editor } from "../src/components/editor.js";
|
||||
import { Loader } from "../src/components/loader.js";
|
||||
|
|
@ -9,6 +10,7 @@ import { Markdown } from "../src/components/markdown.js";
|
|||
import { Text } from "../src/components/text.js";
|
||||
import { ProcessTerminal } from "../src/terminal.js";
|
||||
import { TUI } from "../src/tui.js";
|
||||
import { defaultEditorTheme, defaultMarkdownTheme } from "./test-themes.js";
|
||||
|
||||
// Create terminal
|
||||
const terminal = new ProcessTerminal();
|
||||
|
|
@ -22,7 +24,7 @@ tui.addChild(
|
|||
);
|
||||
|
||||
// Create editor with autocomplete
|
||||
const editor = new Editor();
|
||||
const editor = new Editor(defaultEditorTheme);
|
||||
|
||||
// Set up autocomplete provider with slash commands and file completion
|
||||
const autocompleteProvider = new CombinedAutocompleteProvider(
|
||||
|
|
@ -78,12 +80,17 @@ editor.onSubmit = (value: string) => {
|
|||
isResponding = true;
|
||||
editor.disableSubmit = true;
|
||||
|
||||
const userMessage = new Markdown(value, 1, 1, { bgColor: "#343541" });
|
||||
const userMessage = new Markdown(value, 1, 1, defaultMarkdownTheme);
|
||||
|
||||
const children = tui.children;
|
||||
children.splice(children.length - 1, 0, userMessage);
|
||||
|
||||
const loader = new Loader(tui, "Thinking...");
|
||||
const loader = new Loader(
|
||||
tui,
|
||||
(s) => chalk.cyan(s),
|
||||
(s) => chalk.dim(s),
|
||||
"Thinking...",
|
||||
);
|
||||
children.splice(children.length - 1, 0, loader);
|
||||
|
||||
tui.requestRender();
|
||||
|
|
@ -105,7 +112,7 @@ editor.onSubmit = (value: string) => {
|
|||
const randomResponse = responses[Math.floor(Math.random() * responses.length)];
|
||||
|
||||
// Add assistant message with no background (transparent)
|
||||
const botMessage = new Markdown(randomResponse);
|
||||
const botMessage = new Markdown(randomResponse, 1, 1, defaultMarkdownTheme);
|
||||
children.splice(children.length - 1, 0, botMessage);
|
||||
|
||||
// Re-enable submit
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import { Editor } from "../src/components/editor.js";
|
||||
import { defaultEditorTheme } from "./test-themes.js";
|
||||
|
||||
describe("Editor component", () => {
|
||||
describe("Unicode text editing behavior", () => {
|
||||
it("inserts mixed ASCII, umlauts, and emojis as literal text", () => {
|
||||
const editor = new Editor();
|
||||
const editor = new Editor(defaultEditorTheme);
|
||||
|
||||
editor.handleInput("H");
|
||||
editor.handleInput("e");
|
||||
|
|
@ -24,7 +25,7 @@ describe("Editor component", () => {
|
|||
});
|
||||
|
||||
it("deletes single-code-unit unicode characters (umlauts) with Backspace", () => {
|
||||
const editor = new Editor();
|
||||
const editor = new Editor(defaultEditorTheme);
|
||||
|
||||
editor.handleInput("ä");
|
||||
editor.handleInput("ö");
|
||||
|
|
@ -38,7 +39,7 @@ describe("Editor component", () => {
|
|||
});
|
||||
|
||||
it("deletes multi-code-unit emojis with repeated Backspace", () => {
|
||||
const editor = new Editor();
|
||||
const editor = new Editor(defaultEditorTheme);
|
||||
|
||||
editor.handleInput("😀");
|
||||
editor.handleInput("👍");
|
||||
|
|
@ -52,7 +53,7 @@ describe("Editor component", () => {
|
|||
});
|
||||
|
||||
it("inserts characters at the correct position after cursor movement over umlauts", () => {
|
||||
const editor = new Editor();
|
||||
const editor = new Editor(defaultEditorTheme);
|
||||
|
||||
editor.handleInput("ä");
|
||||
editor.handleInput("ö");
|
||||
|
|
@ -70,7 +71,7 @@ describe("Editor component", () => {
|
|||
});
|
||||
|
||||
it("moves cursor in code units across multi-code-unit emojis before insertion", () => {
|
||||
const editor = new Editor();
|
||||
const editor = new Editor(defaultEditorTheme);
|
||||
|
||||
editor.handleInput("😀");
|
||||
editor.handleInput("👍");
|
||||
|
|
@ -92,7 +93,7 @@ describe("Editor component", () => {
|
|||
});
|
||||
|
||||
it("preserves umlauts across line breaks", () => {
|
||||
const editor = new Editor();
|
||||
const editor = new Editor(defaultEditorTheme);
|
||||
|
||||
editor.handleInput("ä");
|
||||
editor.handleInput("ö");
|
||||
|
|
@ -107,7 +108,7 @@ describe("Editor component", () => {
|
|||
});
|
||||
|
||||
it("replaces the entire document with unicode text via setText (paste simulation)", () => {
|
||||
const editor = new Editor();
|
||||
const editor = new Editor(defaultEditorTheme);
|
||||
|
||||
// Simulate bracketed paste / programmatic replacement
|
||||
editor.setText("Hällö Wörld! 😀 äöüÄÖÜß");
|
||||
|
|
@ -117,7 +118,7 @@ describe("Editor component", () => {
|
|||
});
|
||||
|
||||
it("moves cursor to document start on Ctrl+A and inserts at the beginning", () => {
|
||||
const editor = new Editor();
|
||||
const editor = new Editor(defaultEditorTheme);
|
||||
|
||||
editor.handleInput("a");
|
||||
editor.handleInput("b");
|
||||
|
|
|
|||
|
|
@ -40,6 +40,10 @@ class KeyLogger implements Component {
|
|||
this.tui.requestRender();
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
// No cached state to invalidate currently
|
||||
}
|
||||
|
||||
render(width: number): string[] {
|
||||
const lines: string[] = [];
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import chalk from "chalk";
|
||||
import { Markdown } from "../src/components/markdown.js";
|
||||
import { defaultMarkdownTheme } from "./test-themes.js";
|
||||
|
||||
describe("Markdown component", () => {
|
||||
describe("Nested lists", () => {
|
||||
|
|
@ -12,6 +14,7 @@ describe("Markdown component", () => {
|
|||
- Item 2`,
|
||||
0,
|
||||
0,
|
||||
defaultMarkdownTheme,
|
||||
);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
|
|
@ -37,6 +40,7 @@ describe("Markdown component", () => {
|
|||
- Level 4`,
|
||||
0,
|
||||
0,
|
||||
defaultMarkdownTheme,
|
||||
);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
|
|
@ -57,6 +61,7 @@ describe("Markdown component", () => {
|
|||
2. Second`,
|
||||
0,
|
||||
0,
|
||||
defaultMarkdownTheme,
|
||||
);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
|
|
@ -77,6 +82,7 @@ describe("Markdown component", () => {
|
|||
- More nested`,
|
||||
0,
|
||||
0,
|
||||
defaultMarkdownTheme,
|
||||
);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
|
|
@ -97,6 +103,7 @@ describe("Markdown component", () => {
|
|||
| Bob | 25 |`,
|
||||
0,
|
||||
0,
|
||||
defaultMarkdownTheme,
|
||||
);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
|
|
@ -120,6 +127,7 @@ describe("Markdown component", () => {
|
|||
| Long text | Middle | End |`,
|
||||
0,
|
||||
0,
|
||||
defaultMarkdownTheme,
|
||||
);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
|
|
@ -141,6 +149,7 @@ describe("Markdown component", () => {
|
|||
| B | Short |`,
|
||||
0,
|
||||
0,
|
||||
defaultMarkdownTheme,
|
||||
);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
|
|
@ -168,6 +177,7 @@ describe("Markdown component", () => {
|
|||
| A | B |`,
|
||||
0,
|
||||
0,
|
||||
defaultMarkdownTheme,
|
||||
);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
|
|
@ -187,10 +197,16 @@ describe("Markdown component", () => {
|
|||
describe("Pre-styled text (thinking traces)", () => {
|
||||
it("should preserve gray italic styling after inline code", () => {
|
||||
// This replicates how thinking content is rendered in assistant-message.ts
|
||||
const markdown = new Markdown("This is thinking with `inline code` and more text after", 1, 0, {
|
||||
color: "gray",
|
||||
italic: true,
|
||||
});
|
||||
const markdown = new Markdown(
|
||||
"This is thinking with `inline code` and more text after",
|
||||
1,
|
||||
0,
|
||||
defaultMarkdownTheme,
|
||||
{
|
||||
color: (text) => chalk.gray(text),
|
||||
italic: true,
|
||||
},
|
||||
);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
const joinedOutput = lines.join("\n");
|
||||
|
|
@ -208,10 +224,16 @@ describe("Markdown component", () => {
|
|||
});
|
||||
|
||||
it("should preserve gray italic styling after bold text", () => {
|
||||
const markdown = new Markdown("This is thinking with **bold text** and more after", 1, 0, {
|
||||
color: "gray",
|
||||
italic: true,
|
||||
});
|
||||
const markdown = new Markdown(
|
||||
"This is thinking with **bold text** and more after",
|
||||
1,
|
||||
0,
|
||||
defaultMarkdownTheme,
|
||||
{
|
||||
color: (text) => chalk.gray(text),
|
||||
italic: true,
|
||||
},
|
||||
);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
const joinedOutput = lines.join("\n");
|
||||
|
|
@ -236,6 +258,7 @@ describe("Markdown component", () => {
|
|||
"This is text with <thinking>hidden content</thinking> that should be visible",
|
||||
0,
|
||||
0,
|
||||
defaultMarkdownTheme,
|
||||
);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
|
|
@ -250,7 +273,7 @@ describe("Markdown component", () => {
|
|||
});
|
||||
|
||||
it("should render HTML tags in code blocks correctly", () => {
|
||||
const markdown = new Markdown("```html\n<div>Some HTML</div>\n```", 0, 0);
|
||||
const markdown = new Markdown("```html\n<div>Some HTML</div>\n```", 0, 0, defaultMarkdownTheme);
|
||||
|
||||
const lines = markdown.render(80);
|
||||
const plainLines = lines.map((line) => line.replace(/\x1b\[[0-9;]*m/g, ""));
|
||||
|
|
|
|||
36
packages/tui/test/test-themes.ts
Normal file
36
packages/tui/test/test-themes.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
/**
|
||||
* Default themes for TUI tests using chalk
|
||||
*/
|
||||
|
||||
import chalk from "chalk";
|
||||
import type { EditorTheme, MarkdownTheme, SelectListTheme } from "../src/index.js";
|
||||
|
||||
export const defaultSelectListTheme: SelectListTheme = {
|
||||
selectedPrefix: (text: string) => chalk.blue(text),
|
||||
selectedText: (text: string) => chalk.bold(text),
|
||||
description: (text: string) => chalk.dim(text),
|
||||
scrollInfo: (text: string) => chalk.dim(text),
|
||||
noMatch: (text: string) => chalk.dim(text),
|
||||
};
|
||||
|
||||
export const defaultMarkdownTheme: MarkdownTheme = {
|
||||
heading: (text: string) => chalk.bold.cyan(text),
|
||||
link: (text: string) => chalk.blue(text),
|
||||
linkUrl: (text: string) => chalk.dim(text),
|
||||
code: (text: string) => chalk.yellow(text),
|
||||
codeBlock: (text: string) => chalk.green(text),
|
||||
codeBlockBorder: (text: string) => chalk.dim(text),
|
||||
quote: (text: string) => chalk.italic(text),
|
||||
quoteBorder: (text: string) => chalk.dim(text),
|
||||
hr: (text: string) => chalk.dim(text),
|
||||
listBullet: (text: string) => chalk.cyan(text),
|
||||
bold: (text: string) => chalk.bold(text),
|
||||
italic: (text: string) => chalk.italic(text),
|
||||
strikethrough: (text: string) => chalk.strikethrough(text),
|
||||
underline: (text: string) => chalk.underline(text),
|
||||
};
|
||||
|
||||
export const defaultEditorTheme: EditorTheme = {
|
||||
borderColor: (text: string) => chalk.dim(text),
|
||||
selectList: defaultSelectListTheme,
|
||||
};
|
||||
126
packages/tui/test/truncated-text.test.ts
Normal file
126
packages/tui/test/truncated-text.test.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import assert from "node:assert";
|
||||
import { describe, it } from "node:test";
|
||||
import chalk from "chalk";
|
||||
import { TruncatedText } from "../src/components/truncated-text.js";
|
||||
import { visibleWidth } from "../src/utils.js";
|
||||
|
||||
describe("TruncatedText component", () => {
|
||||
it("pads output lines to exactly match width", () => {
|
||||
const text = new TruncatedText("Hello world", 1, 0);
|
||||
const lines = text.render(50);
|
||||
|
||||
// Should have exactly one content line (no vertical padding)
|
||||
assert.strictEqual(lines.length, 1);
|
||||
|
||||
// Line should be exactly 50 visible characters
|
||||
const visibleLen = visibleWidth(lines[0]);
|
||||
assert.strictEqual(visibleLen, 50);
|
||||
});
|
||||
|
||||
it("pads output with vertical padding lines to width", () => {
|
||||
const text = new TruncatedText("Hello", 0, 2);
|
||||
const lines = text.render(40);
|
||||
|
||||
// Should have 2 padding lines + 1 content line + 2 padding lines = 5 total
|
||||
assert.strictEqual(lines.length, 5);
|
||||
|
||||
// All lines should be exactly 40 characters
|
||||
for (const line of lines) {
|
||||
assert.strictEqual(visibleWidth(line), 40);
|
||||
}
|
||||
});
|
||||
|
||||
it("truncates long text and pads to width", () => {
|
||||
const longText = "This is a very long piece of text that will definitely exceed the available width";
|
||||
const text = new TruncatedText(longText, 1, 0);
|
||||
const lines = text.render(30);
|
||||
|
||||
assert.strictEqual(lines.length, 1);
|
||||
|
||||
// Should be exactly 30 characters
|
||||
assert.strictEqual(visibleWidth(lines[0]), 30);
|
||||
|
||||
// Should contain ellipsis
|
||||
const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, "");
|
||||
assert.ok(stripped.includes("..."));
|
||||
});
|
||||
|
||||
it("preserves ANSI codes in output and pads correctly", () => {
|
||||
const styledText = chalk.red("Hello") + " " + chalk.blue("world");
|
||||
const text = new TruncatedText(styledText, 1, 0);
|
||||
const lines = text.render(40);
|
||||
|
||||
assert.strictEqual(lines.length, 1);
|
||||
|
||||
// Should be exactly 40 visible characters (ANSI codes don't count)
|
||||
assert.strictEqual(visibleWidth(lines[0]), 40);
|
||||
|
||||
// Should preserve the color codes
|
||||
assert.ok(lines[0].includes("\x1b["));
|
||||
});
|
||||
|
||||
it("truncates styled text and adds reset code before ellipsis", () => {
|
||||
const longStyledText = chalk.red("This is a very long red text that will be truncated");
|
||||
const text = new TruncatedText(longStyledText, 1, 0);
|
||||
const lines = text.render(20);
|
||||
|
||||
assert.strictEqual(lines.length, 1);
|
||||
|
||||
// Should be exactly 20 visible characters
|
||||
assert.strictEqual(visibleWidth(lines[0]), 20);
|
||||
|
||||
// Should contain reset code before ellipsis
|
||||
assert.ok(lines[0].includes("\x1b[0m..."));
|
||||
});
|
||||
|
||||
it("handles text that fits exactly", () => {
|
||||
// With paddingX=1, available width is 30-2=28
|
||||
// "Hello world" is 11 chars, fits comfortably
|
||||
const text = new TruncatedText("Hello world", 1, 0);
|
||||
const lines = text.render(30);
|
||||
|
||||
assert.strictEqual(lines.length, 1);
|
||||
assert.strictEqual(visibleWidth(lines[0]), 30);
|
||||
|
||||
// Should NOT contain ellipsis
|
||||
const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, "");
|
||||
assert.ok(!stripped.includes("..."));
|
||||
});
|
||||
|
||||
it("handles empty text", () => {
|
||||
const text = new TruncatedText("", 1, 0);
|
||||
const lines = text.render(30);
|
||||
|
||||
assert.strictEqual(lines.length, 1);
|
||||
assert.strictEqual(visibleWidth(lines[0]), 30);
|
||||
});
|
||||
|
||||
it("stops at newline and only shows first line", () => {
|
||||
const multilineText = "First line\nSecond line\nThird line";
|
||||
const text = new TruncatedText(multilineText, 1, 0);
|
||||
const lines = text.render(40);
|
||||
|
||||
assert.strictEqual(lines.length, 1);
|
||||
assert.strictEqual(visibleWidth(lines[0]), 40);
|
||||
|
||||
// Should only contain "First line"
|
||||
const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, "").trim();
|
||||
assert.ok(stripped.includes("First line"));
|
||||
assert.ok(!stripped.includes("Second line"));
|
||||
assert.ok(!stripped.includes("Third line"));
|
||||
});
|
||||
|
||||
it("truncates first line even with newlines in text", () => {
|
||||
const longMultilineText = "This is a very long first line that needs truncation\nSecond line";
|
||||
const text = new TruncatedText(longMultilineText, 1, 0);
|
||||
const lines = text.render(25);
|
||||
|
||||
assert.strictEqual(lines.length, 1);
|
||||
assert.strictEqual(visibleWidth(lines[0]), 25);
|
||||
|
||||
// Should contain ellipsis and not second line
|
||||
const stripped = lines[0].replace(/\x1b\[[0-9;]*m/g, "");
|
||||
assert.ok(stripped.includes("..."));
|
||||
assert.ok(!stripped.includes("Second line"));
|
||||
});
|
||||
});
|
||||
|
|
@ -65,22 +65,24 @@ describe("wrapTextWithAnsi", () => {
|
|||
});
|
||||
|
||||
describe("applyBackgroundToLine", () => {
|
||||
const greenBg = (text: string) => chalk.bgGreen(text);
|
||||
|
||||
it("applies background to plain text and pads to width", () => {
|
||||
const line = "hello";
|
||||
const result = applyBackgroundToLine(line, 20, { r: 0, g: 255, b: 0 });
|
||||
const result = applyBackgroundToLine(line, 20, greenBg);
|
||||
|
||||
// Should be exactly 20 visible chars
|
||||
const stripped = result.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
assert.strictEqual(stripped.length, 20);
|
||||
|
||||
// Should have background codes
|
||||
assert.ok(result.includes("\x1b[48;2;0;255;0m"));
|
||||
assert.ok(result.includes("\x1b[48") || result.includes("\x1b[42m"));
|
||||
assert.ok(result.includes("\x1b[49m"));
|
||||
});
|
||||
|
||||
it("handles text with ANSI codes and resets", () => {
|
||||
const line = chalk.bold("hello") + " world";
|
||||
const result = applyBackgroundToLine(line, 20, { r: 0, g: 255, b: 0 });
|
||||
const result = applyBackgroundToLine(line, 20, greenBg);
|
||||
|
||||
// Should be exactly 20 visible chars
|
||||
const stripped = result.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
|
|
@ -90,13 +92,13 @@ describe("applyBackgroundToLine", () => {
|
|||
assert.ok(result.includes("\x1b[1m"));
|
||||
|
||||
// Should have background throughout (even after resets)
|
||||
assert.ok(result.includes("\x1b[48;2;0;255;0m"));
|
||||
assert.ok(result.includes("\x1b[48") || result.includes("\x1b[42m"));
|
||||
});
|
||||
|
||||
it("handles text with 0m resets by reapplying background", () => {
|
||||
// Simulate: bold text + reset + normal text
|
||||
const line = "\x1b[1mhello\x1b[0m world";
|
||||
const result = applyBackgroundToLine(line, 20, { r: 0, g: 255, b: 0 });
|
||||
const result = applyBackgroundToLine(line, 20, greenBg);
|
||||
|
||||
// Should NOT have black cells (spaces without background)
|
||||
// Pattern we DON'T want: 49m or 0m followed by spaces before bg reapplied
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue