This commit is contained in:
ZacharyZhang-NY committed 2026-04-18 22:45:41 -04:00
1 parent 1261c2431f
commit dac59f56c2
680 files changed
+192574

No files matched your search

@@ -0,0 +1,137 @@
import type { Component } from "../tui.js";
import { applyBackgroundToLine, visibleWidth } from "../utils.js";
type RenderCache = {
childLines: string[];
width: number;
bgSample: string | undefined;
lines: string[];
};
/**
* Box component - a container that applies padding and background to all children
*/
export class Box implements Component {
children: Component[] = [];
private paddingX: number;
private paddingY: number;
private bgFn?: (text: string) => string;
// Cache for rendered output
private cache?: RenderCache;
constructor(paddingX = 1, paddingY = 1, bgFn?: (text: string) => string) {
this.paddingX = paddingX;
this.paddingY = paddingY;
this.bgFn = bgFn;
}
addChild(component: Component): void {
this.children.push(component);
this.invalidateCache();
}
removeChild(component: Component): void {
const index = this.children.indexOf(component);
if (index !== -1) {
this.children.splice(index, 1);
this.invalidateCache();
}
}
clear(): void {
this.children = [];
this.invalidateCache();
}
setBgFn(bgFn?: (text: string) => string): void {
this.bgFn = bgFn;
// Don't invalidate here - we'll detect bgFn changes by sampling output
}
private invalidateCache(): void {
this.cache = undefined;
}
private matchCache(width: number, childLines: string[], bgSample: string | undefined): boolean {
const cache = this.cache;
return (
!!cache &&
cache.width === width &&
cache.bgSample === bgSample &&
cache.childLines.length === childLines.length &&
cache.childLines.every((line, i) => line === childLines[i])
);
}
invalidate(): void {
this.invalidateCache();
for (const child of this.children) {
child.invalidate?.();
}
}
render(width: number): string[] {
if (this.children.length === 0) {
return [];
}
const contentWidth = Math.max(1, width - this.paddingX * 2);
const leftPad = " ".repeat(this.paddingX);
// Render all children
const childLines: string[] = [];
for (const child of this.children) {
const lines = child.render(contentWidth);
for (const line of lines) {
childLines.push(leftPad + line);
}
}
if (childLines.length === 0) {
return [];
}
// Check if bgFn output changed by sampling
const bgSample = this.bgFn ? this.bgFn("test") : undefined;
// Check cache validity
if (this.matchCache(width, childLines, bgSample)) {
return this.cache!.lines;
}
// Apply background and padding
const result: string[] = [];
// Top padding
for (let i = 0; i < this.paddingY; i++) {
result.push(this.applyBg("", width));
}
// Content
for (const line of childLines) {
result.push(this.applyBg(line, width));
}
// Bottom padding
for (let i = 0; i < this.paddingY; i++) {
result.push(this.applyBg("", width));
}
// Update cache
this.cache = { childLines, width, bgSample, lines: result };
return result;
}
private applyBg(line: string, width: number): string {
const visLen = visibleWidth(line);
const padNeeded = Math.max(0, width - visLen);
const padded = line + " ".repeat(padNeeded);
if (this.bgFn) {
return applyBackgroundToLine(padded, width, this.bgFn);
}
return padded;
}
}
@@ -0,0 +1,40 @@
import { getKeybindings } from "../keybindings.js";
import { Loader } from "./loader.js";
/**
* Loader that can be cancelled with Escape.
* Extends Loader with an AbortSignal for cancelling async operations.
*
* @example
* const loader = new CancellableLoader(tui, cyan, dim, "Working...");
* loader.onAbort = () => done(null);
* doWork(loader.signal).then(done);
*/
export class CancellableLoader extends Loader {
private abortController = new AbortController();
/** Called when user presses Escape */
onAbort?: () => void;
/** AbortSignal that is aborted when user presses Escape */
get signal(): AbortSignal {
return this.abortController.signal;
}
/** Whether the loader was aborted */
get aborted(): boolean {
return this.abortController.signal.aborted;
}
handleInput(data: string): void {
const kb = getKeybindings();
if (kb.matches(data, "tui.select.cancel")) {
this.abortController.abort();
this.onAbort?.();
}
}
dispose(): void {
this.stop();
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,104 @@
import {
getCapabilities,
getImageDimensions,
type ImageDimensions,
imageFallback,
renderImage,
} from "../terminal-image.js";
import type { Component } from "../tui.js";
export interface ImageTheme {
fallbackColor: (str: string) => string;
}
export interface ImageOptions {
maxWidthCells?: number;
maxHeightCells?: number;
filename?: string;
/** Kitty image ID. If provided, reuses this ID (for animations/updates). */
imageId?: number;
}
export class Image implements Component {
private base64Data: string;
private mimeType: string;
private dimensions: ImageDimensions;
private theme: ImageTheme;
private options: ImageOptions;
private imageId?: number;
private cachedLines?: string[];
private cachedWidth?: number;
constructor(
base64Data: string,
mimeType: string,
theme: ImageTheme,
options: ImageOptions = {},
dimensions?: ImageDimensions,
) {
this.base64Data = base64Data;
this.mimeType = mimeType;
this.theme = theme;
this.options = options;
this.dimensions = dimensions || getImageDimensions(base64Data, mimeType) || { widthPx: 800, heightPx: 600 };
this.imageId = options.imageId;
}
/** Get the Kitty image ID used by this image (if any). */
getImageId(): number | undefined {
return this.imageId;
}
invalidate(): void {
this.cachedLines = undefined;
this.cachedWidth = undefined;
}
render(width: number): string[] {
if (this.cachedLines && this.cachedWidth === width) {
return this.cachedLines;
}
const maxWidth = Math.min(width - 2, this.options.maxWidthCells ?? 60);
const caps = getCapabilities();
let lines: string[];
if (caps.images) {
const result = renderImage(this.base64Data, this.dimensions, {
maxWidthCells: maxWidth,
imageId: this.imageId,
});
if (result) {
// Store the image ID for later cleanup
if (result.imageId) {
this.imageId = result.imageId;
}
// Return `rows` lines so TUI accounts for image height
// First (rows-1) lines are empty (TUI clears them)
// Last line: move cursor back up, then output image sequence
lines = [];
for (let i = 0; i < result.rows - 1; i++) {
lines.push("");
}
// Move cursor up to first row, then output image
const moveUp = result.rows > 1 ? `\x1b[${result.rows - 1}A` : "";
lines.push(moveUp + result.sequence);
} else {
const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename);
lines = [this.theme.fallbackColor(fallback)];
}
} else {
const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename);
lines = [this.theme.fallbackColor(fallback)];
}
this.cachedLines = lines;
this.cachedWidth = width;
return lines;
}
}
@@ -0,0 +1,503 @@
import { getKeybindings } from "../keybindings.js";
import { decodeKittyPrintable } from "../keys.js";
import { KillRing } from "../kill-ring.js";
import { type Component, CURSOR_MARKER, type Focusable } from "../tui.js";
import { UndoStack } from "../undo-stack.js";
import { getSegmenter, isPunctuationChar, isWhitespaceChar, sliceByColumn, visibleWidth } from "../utils.js";
const segmenter = getSegmenter();
interface InputState {
value: string;
cursor: number;
}
/**
* Input component - single-line text input with horizontal scrolling
*/
export class Input implements Component, Focusable {
private value: string = "";
private cursor: number = 0; // Cursor position in the value
public onSubmit?: (value: string) => void;
public onEscape?: () => void;
/** Focusable interface - set by TUI when focus changes */
focused: boolean = false;
// Bracketed paste mode buffering
private pasteBuffer: string = "";
private isInPaste: boolean = false;
// Kill ring for Emacs-style kill/yank operations
private killRing = new KillRing();
private lastAction: "kill" | "yank" | "type-word" | null = null;
// Undo support
private undoStack = new UndoStack<InputState>();
getValue(): string {
return this.value;
}
setValue(value: string): void {
this.value = value;
this.cursor = Math.min(this.cursor, value.length);
}
handleInput(data: string): void {
// Handle bracketed paste mode
// Start of paste: \x1b[200~
// End of paste: \x1b[201~
// Check if we're starting a bracketed paste
if (data.includes("\x1b[200~")) {
this.isInPaste = true;
this.pasteBuffer = "";
data = data.replace("\x1b[200~", "");
}
// If we're in a paste, buffer the data
if (this.isInPaste) {
// Check if this chunk contains the end marker
this.pasteBuffer += data;
const endIndex = this.pasteBuffer.indexOf("\x1b[201~");
if (endIndex !== -1) {
// Extract the pasted content
const pasteContent = this.pasteBuffer.substring(0, endIndex);
// Process the complete paste
this.handlePaste(pasteContent);
// Reset paste state
this.isInPaste = false;
// Handle any remaining input after the paste marker
const remaining = this.pasteBuffer.substring(endIndex + 6); // 6 = length of \x1b[201~
this.pasteBuffer = "";
if (remaining) {
this.handleInput(remaining);
}
}
return;
}
const kb = 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);
}
}
private insertCharacter(char: string): void {
// Undo coalescing: consecutive word chars coalesce into one undo unit
if (isWhitespaceChar(char) || this.lastAction !== "type-word") {
this.pushUndo();
}
this.lastAction = "type-word";
this.value = this.value.slice(0, this.cursor) + char + this.value.slice(this.cursor);
this.cursor += char.length;
}
private handleBackspace(): void {
this.lastAction = null;
if (this.cursor > 0) {
this.pushUndo();
const beforeCursor = this.value.slice(0, this.cursor);
const graphemes = [...segmenter.segment(beforeCursor)];
const lastGrapheme = graphemes[graphemes.length - 1];
const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1;
this.value = this.value.slice(0, this.cursor - graphemeLength) + this.value.slice(this.cursor);
this.cursor -= graphemeLength;
}
}
private handleForwardDelete(): void {
this.lastAction = null;
if (this.cursor < this.value.length) {
this.pushUndo();
const afterCursor = this.value.slice(this.cursor);
const graphemes = [...segmenter.segment(afterCursor)];
const firstGrapheme = graphemes[0];
const graphemeLength = firstGrapheme ? firstGrapheme.segment.length : 1;
this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + graphemeLength);
}
}
private deleteToLineStart(): void {
if (this.cursor === 0) return;
this.pushUndo();
const deletedText = this.value.slice(0, this.cursor);
this.killRing.push(deletedText, { prepend: true, accumulate: this.lastAction === "kill" });
this.lastAction = "kill";
this.value = this.value.slice(this.cursor);
this.cursor = 0;
}
private deleteToLineEnd(): void {
if (this.cursor >= this.value.length) return;
this.pushUndo();
const deletedText = this.value.slice(this.cursor);
this.killRing.push(deletedText, { prepend: false, accumulate: this.lastAction === "kill" });
this.lastAction = "kill";
this.value = this.value.slice(0, this.cursor);
}
private deleteWordBackwards(): void {
if (this.cursor === 0) return;
// Save lastAction before cursor movement (moveWordBackwards resets it)
const wasKill = this.lastAction === "kill";
this.pushUndo();
const oldCursor = this.cursor;
this.moveWordBackwards();
const deleteFrom = this.cursor;
this.cursor = oldCursor;
const deletedText = this.value.slice(deleteFrom, this.cursor);
this.killRing.push(deletedText, { prepend: true, accumulate: wasKill });
this.lastAction = "kill";
this.value = this.value.slice(0, deleteFrom) + this.value.slice(this.cursor);
this.cursor = deleteFrom;
}
private deleteWordForward(): void {
if (this.cursor >= this.value.length) return;
// Save lastAction before cursor movement (moveWordForwards resets it)
const wasKill = this.lastAction === "kill";
this.pushUndo();
const oldCursor = this.cursor;
this.moveWordForwards();
const deleteTo = this.cursor;
this.cursor = oldCursor;
const deletedText = this.value.slice(this.cursor, deleteTo);
this.killRing.push(deletedText, { prepend: false, accumulate: wasKill });
this.lastAction = "kill";
this.value = this.value.slice(0, this.cursor) + this.value.slice(deleteTo);
}
private yank(): void {
const text = this.killRing.peek();
if (!text) return;
this.pushUndo();
this.value = this.value.slice(0, this.cursor) + text + this.value.slice(this.cursor);
this.cursor += text.length;
this.lastAction = "yank";
}
private yankPop(): void {
if (this.lastAction !== "yank" || this.killRing.length <= 1) return;
this.pushUndo();
// Delete the previously yanked text (still at end of ring before rotation)
const prevText = this.killRing.peek() || "";
this.value = this.value.slice(0, this.cursor - prevText.length) + this.value.slice(this.cursor);
this.cursor -= prevText.length;
// Rotate and insert new entry
this.killRing.rotate();
const text = this.killRing.peek() || "";
this.value = this.value.slice(0, this.cursor) + text + this.value.slice(this.cursor);
this.cursor += text.length;
this.lastAction = "yank";
}
private pushUndo(): void {
this.undoStack.push({ value: this.value, cursor: this.cursor });
}
private undo(): void {
const snapshot = this.undoStack.pop();
if (!snapshot) return;
this.value = snapshot.value;
this.cursor = snapshot.cursor;
this.lastAction = null;
}
private moveWordBackwards(): void {
if (this.cursor === 0) {
return;
}
this.lastAction = null;
const textBeforeCursor = this.value.slice(0, this.cursor);
const graphemes = [...segmenter.segment(textBeforeCursor)];
// Skip trailing whitespace
while (graphemes.length > 0 && isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "")) {
this.cursor -= graphemes.pop()?.segment.length || 0;
}
if (graphemes.length > 0) {
const lastGrapheme = graphemes[graphemes.length - 1]?.segment || "";
if (isPunctuationChar(lastGrapheme)) {
// Skip punctuation run
while (graphemes.length > 0 && isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")) {
this.cursor -= graphemes.pop()?.segment.length || 0;
}
} else {
// Skip word run
while (
graphemes.length > 0 &&
!isWhitespaceChar(graphemes[graphemes.length - 1]?.segment || "") &&
!isPunctuationChar(graphemes[graphemes.length - 1]?.segment || "")
) {
this.cursor -= graphemes.pop()?.segment.length || 0;
}
}
}
}
private moveWordForwards(): void {
if (this.cursor >= this.value.length) {
return;
}
this.lastAction = null;
const textAfterCursor = this.value.slice(this.cursor);
const segments = segmenter.segment(textAfterCursor);
const iterator = segments[Symbol.iterator]();
let next = iterator.next();
// Skip leading whitespace
while (!next.done && isWhitespaceChar(next.value.segment)) {
this.cursor += next.value.segment.length;
next = iterator.next();
}
if (!next.done) {
const firstGrapheme = next.value.segment;
if (isPunctuationChar(firstGrapheme)) {
// Skip punctuation run
while (!next.done && isPunctuationChar(next.value.segment)) {
this.cursor += next.value.segment.length;
next = iterator.next();
}
} else {
// Skip word run
while (!next.done && !isWhitespaceChar(next.value.segment) && !isPunctuationChar(next.value.segment)) {
this.cursor += next.value.segment.length;
next = iterator.next();
}
}
}
}
private handlePaste(pastedText: string): void {
this.lastAction = null;
this.pushUndo();
// Clean the pasted text - remove newlines and carriage returns
const cleanText = pastedText.replace(/\r\n/g, "").replace(/\r/g, "").replace(/\n/g, "").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 prompt = "> ";
const availableWidth = width - prompt.length;
if (availableWidth <= 0) {
return [prompt];
}
let visibleText = "";
let cursorDisplay = this.cursor;
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);
}
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 = prompt + textWithCursor + padding;
return [line];
}
}
@@ -0,0 +1,55 @@
import type { TUI } from "../tui.js";
import { Text } from "./text.js";
/**
* Loader component that updates every 80ms with spinning animation
*/
export class Loader extends Text {
private frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
private currentFrame = 0;
private intervalId: NodeJS.Timeout | null = null;
private ui: TUI | null = null;
constructor(
ui: TUI,
private spinnerColorFn: (str: string) => string,
private messageColorFn: (str: string) => string,
private message: string = "Loading...",
) {
super("", 1, 0);
this.ui = ui;
this.start();
}
render(width: number): string[] {
return ["", ...super.render(width)];
}
start() {
this.updateDisplay();
this.intervalId = setInterval(() => {
this.currentFrame = (this.currentFrame + 1) % this.frames.length;
this.updateDisplay();
}, 80);
}
stop() {
if (this.intervalId) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
setMessage(message: string) {
this.message = message;
this.updateDisplay();
}
private updateDisplay() {
const frame = this.frames[this.currentFrame];
this.setText(`${this.spinnerColorFn(frame)} ${this.messageColorFn(this.message)}`);
if (this.ui) {
this.ui.requestRender();
}
}
}
@@ -0,0 +1,852 @@
import { Marked, type Token, Tokenizer, type Tokens } from "marked";
import { getCapabilities, hyperlink, isImageLine } from "../terminal-image.js";
import type { Component } from "../tui.js";
import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.js";
const STRICT_STRIKETHROUGH_REGEX = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/;
class StrictStrikethroughTokenizer extends Tokenizer {
override del(src: string): Tokens.Del | undefined {
const match = STRICT_STRIKETHROUGH_REGEX.exec(src);
if (!match) {
return undefined;
}
const text = match[2];
return {
type: "del",
raw: match[0],
text,
tokens: this.lexer.inlineTokens(text),
};
}
}
const markdownParser = new Marked();
markdownParser.setOptions({
tokenizer: new StrictStrikethroughTokenizer(),
});
/**
* Default text styling for markdown content.
* Applied to all text unless overridden by markdown formatting.
*/
export interface DefaultTextStyle {
/** Foreground color function */
color?: (text: string) => string;
/** Background color function */
bgColor?: (text: string) => string;
/** Bold text */
bold?: boolean;
/** Italic text */
italic?: boolean;
/** Strikethrough text */
strikethrough?: boolean;
/** Underline text */
underline?: boolean;
}
/**
* Theme functions for markdown elements.
* Each function takes text and returns styled text with ANSI codes.
*/
export interface MarkdownTheme {
heading: (text: string) => string;
link: (text: string) => string;
linkUrl: (text: string) => string;
code: (text: string) => string;
codeBlock: (text: string) => string;
codeBlockBorder: (text: string) => string;
quote: (text: string) => string;
quoteBorder: (text: string) => string;
hr: (text: string) => string;
listBullet: (text: string) => string;
bold: (text: string) => string;
italic: (text: string) => string;
strikethrough: (text: string) => string;
underline: (text: string) => string;
highlightCode?: (code: string, lang?: string) => string[];
/** Prefix applied to each rendered code block line (default: " ") */
codeBlockIndent?: string;
}
interface InlineStyleContext {
applyText: (text: string) => string;
stylePrefix: string;
}
export class Markdown implements Component {
private text: string;
private paddingX: number; // Left/right padding
private paddingY: number; // Top/bottom padding
private defaultTextStyle?: DefaultTextStyle;
private theme: MarkdownTheme;
private defaultStylePrefix?: string;
// Cache for rendered output
private cachedText?: string;
private cachedWidth?: number;
private cachedLines?: string[];
constructor(
text: string,
paddingX: number,
paddingY: number,
theme: MarkdownTheme,
defaultTextStyle?: DefaultTextStyle,
) {
this.text = text;
this.paddingX = paddingX;
this.paddingY = paddingY;
this.theme = theme;
this.defaultTextStyle = defaultTextStyle;
}
setText(text: string): void {
this.text = text;
this.invalidate();
}
invalidate(): void {
this.cachedText = undefined;
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
render(width: number): string[] {
// Check cache
if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) {
return this.cachedLines;
}
// Calculate available width for content (subtract horizontal padding)
const contentWidth = Math.max(1, width - this.paddingX * 2);
// Don't render anything if there's no actual text
if (!this.text || this.text.trim() === "") {
const result: string[] = [];
// Update cache
this.cachedText = this.text;
this.cachedWidth = width;
this.cachedLines = result;
return result;
}
// Replace tabs with 3 spaces for consistent rendering
const normalizedText = this.text.replace(/\t/g, " ");
// Parse markdown to HTML-like tokens
const tokens = markdownParser.lexer(normalizedText);
// Convert tokens to styled terminal output
const renderedLines: string[] = [];
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
const nextToken = tokens[i + 1];
const tokenLines = this.renderToken(token, contentWidth, nextToken?.type);
renderedLines.push(...tokenLines);
}
// Wrap lines (NO padding, NO background yet)
const wrappedLines: string[] = [];
for (const line of renderedLines) {
if (isImageLine(line)) {
wrappedLines.push(line);
} else {
wrappedLines.push(...wrapTextWithAnsi(line, contentWidth));
}
}
// Add margins and background to each wrapped line
const leftMargin = " ".repeat(this.paddingX);
const rightMargin = " ".repeat(this.paddingX);
const bgFn = this.defaultTextStyle?.bgColor;
const contentLines: string[] = [];
for (const line of wrappedLines) {
if (isImageLine(line)) {
contentLines.push(line);
continue;
}
const lineWithMargins = leftMargin + line + rightMargin;
if (bgFn) {
contentLines.push(applyBackgroundToLine(lineWithMargins, width, bgFn));
} else {
// No background - just pad to width
const visibleLen = visibleWidth(lineWithMargins);
const paddingNeeded = Math.max(0, width - visibleLen);
contentLines.push(lineWithMargins + " ".repeat(paddingNeeded));
}
}
// Add top/bottom padding (empty lines)
const emptyLine = " ".repeat(width);
const emptyLines: string[] = [];
for (let i = 0; i < this.paddingY; i++) {
const line = bgFn ? applyBackgroundToLine(emptyLine, width, bgFn) : emptyLine;
emptyLines.push(line);
}
// Combine top padding, content, and bottom padding
const result = [...emptyLines, ...contentLines, ...emptyLines];
// Update cache
this.cachedText = this.text;
this.cachedWidth = width;
this.cachedLines = result;
return result.length > 0 ? result : [""];
}
/**
* Apply default text style to a string.
* This is the base styling applied to all text content.
* NOTE: Background color is NOT applied here - it's applied at the padding stage
* to ensure it extends to the full line width.
*/
private applyDefaultStyle(text: string): string {
if (!this.defaultTextStyle) {
return text;
}
let styled = text;
// Apply foreground color (NOT background - that's applied at padding stage)
if (this.defaultTextStyle.color) {
styled = this.defaultTextStyle.color(styled);
}
// Apply text decorations using this.theme
if (this.defaultTextStyle.bold) {
styled = this.theme.bold(styled);
}
if (this.defaultTextStyle.italic) {
styled = this.theme.italic(styled);
}
if (this.defaultTextStyle.strikethrough) {
styled = this.theme.strikethrough(styled);
}
if (this.defaultTextStyle.underline) {
styled = this.theme.underline(styled);
}
return styled;
}
private getDefaultStylePrefix(): string {
if (!this.defaultTextStyle) {
return "";
}
if (this.defaultStylePrefix !== undefined) {
return this.defaultStylePrefix;
}
const sentinel = "\u0000";
let styled = sentinel;
if (this.defaultTextStyle.color) {
styled = this.defaultTextStyle.color(styled);
}
if (this.defaultTextStyle.bold) {
styled = this.theme.bold(styled);
}
if (this.defaultTextStyle.italic) {
styled = this.theme.italic(styled);
}
if (this.defaultTextStyle.strikethrough) {
styled = this.theme.strikethrough(styled);
}
if (this.defaultTextStyle.underline) {
styled = this.theme.underline(styled);
}
const sentinelIndex = styled.indexOf(sentinel);
this.defaultStylePrefix = sentinelIndex >= 0 ? styled.slice(0, sentinelIndex) : "";
return this.defaultStylePrefix;
}
private getStylePrefix(styleFn: (text: string) => string): string {
const sentinel = "\u0000";
const styled = styleFn(sentinel);
const sentinelIndex = styled.indexOf(sentinel);
return sentinelIndex >= 0 ? styled.slice(0, sentinelIndex) : "";
}
private getDefaultInlineStyleContext(): InlineStyleContext {
return {
applyText: (text: string) => this.applyDefaultStyle(text),
stylePrefix: this.getDefaultStylePrefix(),
};
}
private renderToken(
token: Token,
width: number,
nextTokenType?: string,
styleContext?: InlineStyleContext,
): string[] {
const lines: string[] = [];
switch (token.type) {
case "heading": {
const headingLevel = token.depth;
const headingPrefix = `${"#".repeat(headingLevel)} `;
// Build a heading-specific style context so inline tokens (codespan, bold, etc.)
// restore heading styling after their own ANSI resets instead of falling back to
// the default text style.
let headingStyleFn: (text: string) => string;
if (headingLevel === 1) {
headingStyleFn = (text: string) => this.theme.heading(this.theme.bold(this.theme.underline(text)));
} else {
headingStyleFn = (text: string) => this.theme.heading(this.theme.bold(text));
}
const headingStyleContext: InlineStyleContext = {
applyText: headingStyleFn,
stylePrefix: this.getStylePrefix(headingStyleFn),
};
const headingText = this.renderInlineTokens(token.tokens || [], headingStyleContext);
const styledHeading = headingLevel >= 3 ? headingStyleFn(headingPrefix) + headingText : headingText;
lines.push(styledHeading);
if (nextTokenType && nextTokenType !== "space") {
lines.push(""); // Add spacing after headings (unless space token follows)
}
break;
}
case "paragraph": {
const paragraphText = this.renderInlineTokens(token.tokens || [], styleContext);
lines.push(paragraphText);
// Don't add spacing if next token is space or list
if (nextTokenType && nextTokenType !== "list" && nextTokenType !== "space") {
lines.push("");
}
break;
}
case "code": {
const indent = this.theme.codeBlockIndent ?? " ";
lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
if (this.theme.highlightCode) {
const highlightedLines = this.theme.highlightCode(token.text, token.lang);
for (const hlLine of highlightedLines) {
lines.push(`${indent}${hlLine}`);
}
} else {
// Split code by newlines and style each line
const codeLines = token.text.split("\n");
for (const codeLine of codeLines) {
lines.push(`${indent}${this.theme.codeBlock(codeLine)}`);
}
}
lines.push(this.theme.codeBlockBorder("```"));
if (nextTokenType && nextTokenType !== "space") {
lines.push(""); // Add spacing after code blocks (unless space token follows)
}
break;
}
case "list": {
const listLines = this.renderList(token as any, 0, styleContext);
lines.push(...listLines);
// Don't add spacing after lists if a space token follows
// (the space token will handle it)
break;
}
case "table": {
const tableLines = this.renderTable(token as any, width, nextTokenType, styleContext);
lines.push(...tableLines);
break;
}
case "blockquote": {
const quoteStyle = (text: string) => this.theme.quote(this.theme.italic(text));
const quoteStylePrefix = this.getStylePrefix(quoteStyle);
const applyQuoteStyle = (line: string): string => {
if (!quoteStylePrefix) {
return quoteStyle(line);
}
const lineWithReappliedStyle = line.replace(/\x1b\[0m/g, `\x1b[0m${quoteStylePrefix}`);
return quoteStyle(lineWithReappliedStyle);
};
// Calculate available width for quote content (subtract border "│ " = 2 chars)
const quoteContentWidth = Math.max(1, width - 2);
// Blockquotes contain block-level tokens (paragraph, list, code, etc.), so render
// children with renderToken() instead of renderInlineTokens().
// Default message style should not apply inside blockquotes.
const quoteInlineStyleContext: InlineStyleContext = {
applyText: (text: string) => text,
stylePrefix: quoteStylePrefix,
};
const quoteTokens = token.tokens || [];
const renderedQuoteLines: string[] = [];
for (let i = 0; i < quoteTokens.length; i++) {
const quoteToken = quoteTokens[i];
const nextQuoteToken = quoteTokens[i + 1];
renderedQuoteLines.push(
...this.renderToken(quoteToken, quoteContentWidth, nextQuoteToken?.type, quoteInlineStyleContext),
);
}
// Avoid rendering an extra empty quote line before the outer blockquote spacing.
while (renderedQuoteLines.length > 0 && renderedQuoteLines[renderedQuoteLines.length - 1] === "") {
renderedQuoteLines.pop();
}
for (const quoteLine of renderedQuoteLines) {
const styledLine = applyQuoteStyle(quoteLine);
const wrappedLines = wrapTextWithAnsi(styledLine, quoteContentWidth);
for (const wrappedLine of wrappedLines) {
lines.push(this.theme.quoteBorder("│ ") + wrappedLine);
}
}
if (nextTokenType && nextTokenType !== "space") {
lines.push(""); // Add spacing after blockquotes (unless space token follows)
}
break;
}
case "hr":
lines.push(this.theme.hr("─".repeat(Math.min(width, 80))));
if (nextTokenType && nextTokenType !== "space") {
lines.push(""); // Add spacing after horizontal rules (unless space token follows)
}
break;
case "html":
// Render HTML as plain text (escaped for terminal)
if ("raw" in token && typeof token.raw === "string") {
lines.push(this.applyDefaultStyle(token.raw.trim()));
}
break;
case "space":
// Space tokens represent blank lines in markdown
lines.push("");
break;
default:
// Handle any other token types as plain text
if ("text" in token && typeof token.text === "string") {
lines.push(token.text);
}
}
return lines;
}
private renderInlineTokens(tokens: Token[], styleContext?: InlineStyleContext): string {
let result = "";
const resolvedStyleContext = styleContext ?? this.getDefaultInlineStyleContext();
const { applyText, stylePrefix } = resolvedStyleContext;
const applyTextWithNewlines = (text: string): string => {
const segments: string[] = text.split("\n");
return segments.map((segment: string) => applyText(segment)).join("\n");
};
for (const token of tokens) {
switch (token.type) {
case "text":
// Text tokens in list items can have nested tokens for inline formatting
if (token.tokens && token.tokens.length > 0) {
result += this.renderInlineTokens(token.tokens, resolvedStyleContext);
} else {
result += applyTextWithNewlines(token.text);
}
break;
case "paragraph":
// Paragraph tokens contain nested inline tokens
result += this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
break;
case "strong": {
const boldContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
result += this.theme.bold(boldContent) + stylePrefix;
break;
}
case "em": {
const italicContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
result += this.theme.italic(italicContent) + stylePrefix;
break;
}
case "codespan":
result += this.theme.code(token.text) + stylePrefix;
break;
case "link": {
const linkText = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
const styledLink = this.theme.link(this.theme.underline(linkText));
if (getCapabilities().hyperlinks) {
// OSC 8: render as a clickable hyperlink. The URL is not printed inline,
// so we always show only the link text regardless of whether it matches href.
result += hyperlink(styledLink, token.href) + stylePrefix;
} else {
// Fallback: print URL in parentheses when text differs from href.
// Compare raw token.text (not styled) against href for the equality check.
// For mailto: links strip the prefix (autolinked emails use text="foo@bar.com"
// but href="mailto:foo@bar.com").
const hrefForComparison = token.href.startsWith("mailto:") ? token.href.slice(7) : token.href;
if (token.text === token.href || token.text === hrefForComparison) {
result += styledLink + stylePrefix;
} else {
result += styledLink + this.theme.linkUrl(` (${token.href})`) + stylePrefix;
}
}
break;
}
case "br":
result += "\n";
break;
case "del": {
const delContent = this.renderInlineTokens(token.tokens || [], resolvedStyleContext);
result += this.theme.strikethrough(delContent) + stylePrefix;
break;
}
case "html":
// Render inline HTML as plain text
if ("raw" in token && typeof token.raw === "string") {
result += applyTextWithNewlines(token.raw);
}
break;
default:
// Handle any other inline token types as plain text
if ("text" in token && typeof token.text === "string") {
result += applyTextWithNewlines(token.text);
}
}
}
while (stylePrefix && result.endsWith(stylePrefix)) {
result = result.slice(0, -stylePrefix.length);
}
return result;
}
/**
* Render a list with proper nesting support
*/
private renderList(
token: Token & { items: any[]; ordered: boolean; start?: number },
depth: number,
styleContext?: InlineStyleContext,
): string[] {
const lines: string[] = [];
const indent = " ".repeat(depth);
// Use the list's start property (defaults to 1 for ordered lists)
const startNumber = token.start ?? 1;
for (let i = 0; i < token.items.length; i++) {
const item = token.items[i];
const bullet = token.ordered ? `${startNumber + i}. ` : "- ";
// Process item tokens to handle nested lists
const itemLines = this.renderListItem(item.tokens || [], depth, styleContext);
if (itemLines.length > 0) {
// First line - check if it's a nested list
// A nested list will start with indent (spaces) followed by cyan bullet
const firstLine = itemLines[0];
const isNestedList = /^\s+\x1b\[36m[-\d]/.test(firstLine); // starts with spaces + cyan + bullet char
if (isNestedList) {
// This is a nested list, just add it as-is (already has full indent)
lines.push(firstLine);
} else {
// Regular text content - add indent and bullet
lines.push(indent + this.theme.listBullet(bullet) + firstLine);
}
// Rest of the lines
for (let j = 1; j < itemLines.length; j++) {
const line = itemLines[j];
const isNestedListLine = /^\s+\x1b\[36m[-\d]/.test(line); // starts with spaces + cyan + bullet char
if (isNestedListLine) {
// Nested list line - already has full indent
lines.push(line);
} else {
// Regular content - add parent indent + 2 spaces for continuation
lines.push(`${indent} ${line}`);
}
}
} else {
lines.push(indent + this.theme.listBullet(bullet));
}
}
return lines;
}
/**
* Render list item tokens, handling nested lists
* Returns lines WITHOUT the parent indent (renderList will add it)
*/
private renderListItem(tokens: Token[], parentDepth: number, styleContext?: InlineStyleContext): string[] {
const lines: string[] = [];
for (const token of tokens) {
if (token.type === "list") {
// Nested list - render with one additional indent level
// These lines will have their own indent, so we just add them as-is
const nestedLines = this.renderList(token as any, parentDepth + 1, styleContext);
lines.push(...nestedLines);
} else if (token.type === "text") {
// Text content (may have inline tokens)
const text =
token.tokens && token.tokens.length > 0
? this.renderInlineTokens(token.tokens, styleContext)
: token.text || "";
lines.push(text);
} else if (token.type === "paragraph") {
// Paragraph in list item
const text = this.renderInlineTokens(token.tokens || [], styleContext);
lines.push(text);
} else if (token.type === "code") {
// Code block in list item
const indent = this.theme.codeBlockIndent ?? " ";
lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
if (this.theme.highlightCode) {
const highlightedLines = this.theme.highlightCode(token.text, token.lang);
for (const hlLine of highlightedLines) {
lines.push(`${indent}${hlLine}`);
}
} else {
const codeLines = token.text.split("\n");
for (const codeLine of codeLines) {
lines.push(`${indent}${this.theme.codeBlock(codeLine)}`);
}
}
lines.push(this.theme.codeBlockBorder("```"));
} else {
// Other token types - try to render as inline
const text = this.renderInlineTokens([token], styleContext);
if (text) {
lines.push(text);
}
}
}
return lines;
}
/**
* Get the visible width of the longest word in a string.
*/
private getLongestWordWidth(text: string, maxWidth?: number): number {
const words = text.split(/\s+/).filter((word) => word.length > 0);
let longest = 0;
for (const word of words) {
longest = Math.max(longest, visibleWidth(word));
}
if (maxWidth === undefined) {
return longest;
}
return Math.min(longest, maxWidth);
}
/**
* Wrap a table cell to fit into a column.
*
* Delegates to wrapTextWithAnsi() so ANSI codes + long tokens are handled
* consistently with the rest of the renderer.
*/
private wrapCellText(text: string, maxWidth: number): string[] {
return wrapTextWithAnsi(text, Math.max(1, maxWidth));
}
/**
* Render a table with width-aware cell wrapping.
* Cells that don't fit are wrapped to multiple lines.
*/
private renderTable(
token: Token & { header: any[]; rows: any[][]; raw?: string },
availableWidth: number,
nextTokenType?: string,
styleContext?: InlineStyleContext,
): string[] {
const lines: string[] = [];
const numCols = token.header.length;
if (numCols === 0) {
return lines;
}
// Calculate border overhead: "│ " + (n-1) * " │ " + " │"
// = 2 + (n-1) * 3 + 2 = 3n + 1
const borderOverhead = 3 * numCols + 1;
const availableForCells = availableWidth - borderOverhead;
if (availableForCells < numCols) {
// Too narrow to render a stable table. Fall back to raw markdown.
const fallbackLines = token.raw ? wrapTextWithAnsi(token.raw, availableWidth) : [];
if (nextTokenType && nextTokenType !== "space") {
fallbackLines.push("");
}
return fallbackLines;
}
const maxUnbrokenWordWidth = 30;
// Calculate natural column widths (what each column needs without constraints)
const naturalWidths: number[] = [];
const minWordWidths: number[] = [];
for (let i = 0; i < numCols; i++) {
const headerText = this.renderInlineTokens(token.header[i].tokens || [], styleContext);
naturalWidths[i] = visibleWidth(headerText);
minWordWidths[i] = Math.max(1, this.getLongestWordWidth(headerText, maxUnbrokenWordWidth));
}
for (const row of token.rows) {
for (let i = 0; i < row.length; i++) {
const cellText = this.renderInlineTokens(row[i].tokens || [], styleContext);
naturalWidths[i] = Math.max(naturalWidths[i] || 0, visibleWidth(cellText));
minWordWidths[i] = Math.max(
minWordWidths[i] || 1,
this.getLongestWordWidth(cellText, maxUnbrokenWordWidth),
);
}
}
let minColumnWidths = minWordWidths;
let minCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0);
if (minCellsWidth > availableForCells) {
minColumnWidths = new Array(numCols).fill(1);
const remaining = availableForCells - numCols;
if (remaining > 0) {
const totalWeight = minWordWidths.reduce((total, width) => total + Math.max(0, width - 1), 0);
const growth = minWordWidths.map((width) => {
const weight = Math.max(0, width - 1);
return totalWeight > 0 ? Math.floor((weight / totalWeight) * remaining) : 0;
});
for (let i = 0; i < numCols; i++) {
minColumnWidths[i] += growth[i] ?? 0;
}
const allocated = growth.reduce((total, width) => total + width, 0);
let leftover = remaining - allocated;
for (let i = 0; leftover > 0 && i < numCols; i++) {
minColumnWidths[i]++;
leftover--;
}
}
minCellsWidth = minColumnWidths.reduce((a, b) => a + b, 0);
}
// Calculate column widths that fit within available width
const totalNaturalWidth = naturalWidths.reduce((a, b) => a + b, 0) + borderOverhead;
let columnWidths: number[];
if (totalNaturalWidth <= availableWidth) {
// Everything fits naturally
columnWidths = naturalWidths.map((width, index) => Math.max(width, minColumnWidths[index]));
} else {
// Need to shrink columns to fit
const totalGrowPotential = naturalWidths.reduce((total, width, index) => {
return total + Math.max(0, width - minColumnWidths[index]);
}, 0);
const extraWidth = Math.max(0, availableForCells - minCellsWidth);
columnWidths = minColumnWidths.map((minWidth, index) => {
const naturalWidth = naturalWidths[index];
const minWidthDelta = Math.max(0, naturalWidth - minWidth);
let grow = 0;
if (totalGrowPotential > 0) {
grow = Math.floor((minWidthDelta / totalGrowPotential) * extraWidth);
}
return minWidth + grow;
});
// Adjust for rounding errors - distribute remaining space
const allocated = columnWidths.reduce((a, b) => a + b, 0);
let remaining = availableForCells - allocated;
while (remaining > 0) {
let grew = false;
for (let i = 0; i < numCols && remaining > 0; i++) {
if (columnWidths[i] < naturalWidths[i]) {
columnWidths[i]++;
remaining--;
grew = true;
}
}
if (!grew) {
break;
}
}
}
// Render top border
const topBorderCells = columnWidths.map((w) => "─".repeat(w));
lines.push(`┌─${topBorderCells.join("─┬─")}─┐`);
// Render header with wrapping
const headerCellLines: string[][] = token.header.map((cell, i) => {
const text = this.renderInlineTokens(cell.tokens || [], styleContext);
return this.wrapCellText(text, columnWidths[i]);
});
const headerLineCount = Math.max(...headerCellLines.map((c) => c.length));
for (let lineIdx = 0; lineIdx < headerLineCount; lineIdx++) {
const rowParts = headerCellLines.map((cellLines, colIdx) => {
const text = cellLines[lineIdx] || "";
const padded = text + " ".repeat(Math.max(0, columnWidths[colIdx] - visibleWidth(text)));
return this.theme.bold(padded);
});
lines.push(`│ ${rowParts.join(" │ ")} │`);
}
// Render separator
const separatorCells = columnWidths.map((w) => "─".repeat(w));
const separatorLine = `├─${separatorCells.join("─┼─")}─┤`;
lines.push(separatorLine);
// Render rows with wrapping
for (let rowIndex = 0; rowIndex < token.rows.length; rowIndex++) {
const row = token.rows[rowIndex];
const rowCellLines: string[][] = row.map((cell, i) => {
const text = this.renderInlineTokens(cell.tokens || [], styleContext);
return this.wrapCellText(text, columnWidths[i]);
});
const rowLineCount = Math.max(...rowCellLines.map((c) => c.length));
for (let lineIdx = 0; lineIdx < rowLineCount; lineIdx++) {
const rowParts = rowCellLines.map((cellLines, colIdx) => {
const text = cellLines[lineIdx] || "";
return text + " ".repeat(Math.max(0, columnWidths[colIdx] - visibleWidth(text)));
});
lines.push(`│ ${rowParts.join(" │ ")} │`);
}
if (rowIndex < token.rows.length - 1) {
lines.push(separatorLine);
}
}
// Render bottom border
const bottomBorderCells = columnWidths.map((w) => "─".repeat(w));
lines.push(`└─${bottomBorderCells.join("─┴─")}─┘`);
if (nextTokenType && nextTokenType !== "space") {
lines.push(""); // Add spacing after table
}
return lines;
}
}
@@ -0,0 +1,229 @@
import { getKeybindings } from "../keybindings.js";
import type { Component } from "../tui.js";
import { truncateToWidth, visibleWidth } from "../utils.js";
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 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 = Math.max(
0,
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), this.filteredItems.length - this.maxVisible),
);
const endIndex = Math.min(startIndex + this.maxVisible, this.filteredItems.length);
// Render visible items
for (let i = startIndex; i < endIndex; i++) {
const item = this.filteredItems[i];
if (!item) continue;
const isSelected = i === this.selectedIndex;
const descriptionSingleLine = item.description ? normalizeToSingleLine(item.description) : undefined;
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;
}
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 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;
}
}
@@ -0,0 +1,250 @@
import { fuzzyFilter } from "../fuzzy.js";
import { getKeybindings } from "../keybindings.js";
import type { Component } from "../tui.js";
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils.js";
import { Input } from "./input.js";
export interface SettingItem {
/** Unique identifier for this setting */
id: string;
/** Display label (left side) */
label: string;
/** Optional description shown when selected */
description?: string;
/** Current value to display (right side) */
currentValue: string;
/** If provided, Enter/Space cycles through these values */
values?: string[];
/** If provided, Enter opens this submenu. Receives current value and done callback. */
submenu?: (currentValue: string, done: (selectedValue?: string) => void) => Component;
}
export interface SettingsListTheme {
label: (text: string, selected: boolean) => string;
value: (text: string, selected: boolean) => string;
description: (text: string) => string;
cursor: string;
hint: (text: string) => string;
}
export interface SettingsListOptions {
enableSearch?: boolean;
}
export class SettingsList implements Component {
private items: SettingItem[];
private filteredItems: SettingItem[];
private theme: SettingsListTheme;
private selectedIndex = 0;
private maxVisible: number;
private onChange: (id: string, newValue: string) => void;
private onCancel: () => void;
private searchInput?: Input;
private searchEnabled: boolean;
// Submenu state
private submenuComponent: Component | null = null;
private submenuItemIndex: number | null = null;
constructor(
items: SettingItem[],
maxVisible: number,
theme: SettingsListTheme,
onChange: (id: string, newValue: string) => void,
onCancel: () => void,
options: SettingsListOptions = {},
) {
this.items = items;
this.filteredItems = items;
this.maxVisible = maxVisible;
this.theme = theme;
this.onChange = onChange;
this.onCancel = onCancel;
this.searchEnabled = options.enableSearch ?? false;
if (this.searchEnabled) {
this.searchInput = new Input();
}
}
/** Update an item's currentValue */
updateValue(id: string, newValue: string): void {
const item = this.items.find((i) => i.id === id);
if (item) {
item.currentValue = newValue;
}
}
invalidate(): void {
this.submenuComponent?.invalidate?.();
}
render(width: number): string[] {
// If submenu is active, render it instead
if (this.submenuComponent) {
return this.submenuComponent.render(width);
}
return this.renderMainList(width);
}
private renderMainList(width: number): string[] {
const lines: string[] = [];
if (this.searchEnabled && this.searchInput) {
lines.push(...this.searchInput.render(width));
lines.push("");
}
if (this.items.length === 0) {
lines.push(this.theme.hint(" No settings available"));
if (this.searchEnabled) {
this.addHintLine(lines, width);
}
return lines;
}
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
if (displayItems.length === 0) {
lines.push(truncateToWidth(this.theme.hint(" No matching settings"), width));
this.addHintLine(lines, width);
return lines;
}
// Calculate visible range with scrolling
const startIndex = Math.max(
0,
Math.min(this.selectedIndex - Math.floor(this.maxVisible / 2), displayItems.length - this.maxVisible),
);
const endIndex = Math.min(startIndex + this.maxVisible, displayItems.length);
// Calculate max label width for alignment
const maxLabelWidth = Math.min(30, Math.max(...this.items.map((item) => visibleWidth(item.label))));
// Render visible items
for (let i = startIndex; i < endIndex; i++) {
const item = displayItems[i];
if (!item) continue;
const isSelected = i === this.selectedIndex;
const prefix = isSelected ? this.theme.cursor : " ";
const prefixWidth = visibleWidth(prefix);
// Pad label to align values
const labelPadded = item.label + " ".repeat(Math.max(0, maxLabelWidth - visibleWidth(item.label)));
const labelText = this.theme.label(labelPadded, isSelected);
// Calculate space for value
const separator = " ";
const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator);
const valueMaxWidth = width - usedWidth - 2;
const valueText = this.theme.value(truncateToWidth(item.currentValue, valueMaxWidth, ""), isSelected);
lines.push(truncateToWidth(prefix + labelText + separator + valueText, width));
}
// Add scroll indicator if needed
if (startIndex > 0 || endIndex < displayItems.length) {
const scrollText = ` (${this.selectedIndex + 1}/${displayItems.length})`;
lines.push(this.theme.hint(truncateToWidth(scrollText, width - 2, "")));
}
// Add description for selected item
const selectedItem = displayItems[this.selectedIndex];
if (selectedItem?.description) {
lines.push("");
const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4);
for (const line of wrappedDesc) {
lines.push(this.theme.description(` ${line}`));
}
}
// Add hint
this.addHintLine(lines, width);
return lines;
}
handleInput(data: string): void {
// If submenu is active, delegate all input to it
// The submenu's onCancel (triggered by escape) will call done() which closes it
if (this.submenuComponent) {
this.submenuComponent.handleInput?.(data);
return;
}
// Main list input handling
const kb = getKeybindings();
const displayItems = this.searchEnabled ? this.filteredItems : this.items;
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.activateItem();
} else if (kb.matches(data, "tui.select.cancel")) {
this.onCancel();
} else if (this.searchEnabled && this.searchInput) {
const sanitized = data.replace(/ /g, "");
if (!sanitized) {
return;
}
this.searchInput.handleInput(sanitized);
this.applyFilter(this.searchInput.getValue());
}
}
private activateItem(): void {
const item = this.searchEnabled ? this.filteredItems[this.selectedIndex] : this.items[this.selectedIndex];
if (!item) return;
if (item.submenu) {
// Open submenu, passing current value so it can pre-select correctly
this.submenuItemIndex = this.selectedIndex;
this.submenuComponent = item.submenu(item.currentValue, (selectedValue?: string) => {
if (selectedValue !== undefined) {
item.currentValue = selectedValue;
this.onChange(item.id, selectedValue);
}
this.closeSubmenu();
});
} else if (item.values && item.values.length > 0) {
// Cycle through values
const currentIndex = item.values.indexOf(item.currentValue);
const nextIndex = (currentIndex + 1) % item.values.length;
const newValue = item.values[nextIndex];
item.currentValue = newValue;
this.onChange(item.id, newValue);
}
}
private closeSubmenu(): void {
this.submenuComponent = null;
// Restore selection to the item that opened the submenu
if (this.submenuItemIndex !== null) {
this.selectedIndex = this.submenuItemIndex;
this.submenuItemIndex = null;
}
}
private applyFilter(query: string): void {
this.filteredItems = fuzzyFilter(this.items, query, (item) => item.label);
this.selectedIndex = 0;
}
private addHintLine(lines: string[], width: number): void {
lines.push("");
lines.push(
truncateToWidth(
this.theme.hint(
this.searchEnabled
? " Type to search · Enter/Space to change · Esc to cancel"
: " Enter/Space to change · Esc to cancel",
),
width,
),
);
}
}
@@ -0,0 +1,28 @@
import type { Component } from "../tui.js";
/**
* Spacer component that renders empty lines
*/
export class Spacer implements Component {
private lines: number;
constructor(lines: number = 1) {
this.lines = lines;
}
setLines(lines: number): void {
this.lines = lines;
}
invalidate(): void {
// No cached state to invalidate currently
}
render(_width: number): string[] {
const result: string[] = [];
for (let i = 0; i < this.lines; i++) {
result.push("");
}
return result;
}
}
@@ -0,0 +1,106 @@
import type { Component } from "../tui.js";
import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.js";
/**
* Text component - displays multi-line text with word wrapping
*/
export class Text implements Component {
private text: string;
private paddingX: number; // Left/right padding
private paddingY: number; // Top/bottom padding
private customBgFn?: (text: string) => string;
// Cache for rendered output
private cachedText?: string;
private cachedWidth?: number;
private cachedLines?: string[];
constructor(text: string = "", paddingX: number = 1, paddingY: number = 1, customBgFn?: (text: string) => string) {
this.text = text;
this.paddingX = paddingX;
this.paddingY = paddingY;
this.customBgFn = customBgFn;
}
setText(text: string): void {
this.text = text;
this.cachedText = undefined;
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
setCustomBgFn(customBgFn?: (text: string) => string): void {
this.customBgFn = customBgFn;
this.cachedText = undefined;
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
invalidate(): void {
this.cachedText = undefined;
this.cachedWidth = undefined;
this.cachedLines = undefined;
}
render(width: number): string[] {
// Check cache
if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) {
return this.cachedLines;
}
// Don't render anything if there's no actual text
if (!this.text || this.text.trim() === "") {
const result: string[] = [];
this.cachedText = this.text;
this.cachedWidth = width;
this.cachedLines = result;
return result;
}
// Replace tabs with 3 spaces
const normalizedText = this.text.replace(/\t/g, " ");
// Calculate content width (subtract left/right margins)
const contentWidth = Math.max(1, width - this.paddingX * 2);
// Wrap text (this preserves ANSI codes but does NOT pad)
const wrappedLines = wrapTextWithAnsi(normalizedText, contentWidth);
// Add margins and background to each line
const leftMargin = " ".repeat(this.paddingX);
const rightMargin = " ".repeat(this.paddingX);
const contentLines: string[] = [];
for (const line of wrappedLines) {
// Add margins
const lineWithMargins = leftMargin + line + rightMargin;
// Apply background if specified (this also pads to full width)
if (this.customBgFn) {
contentLines.push(applyBackgroundToLine(lineWithMargins, width, this.customBgFn));
} else {
// No background - just pad to width with spaces
const visibleLen = visibleWidth(lineWithMargins);
const paddingNeeded = Math.max(0, width - visibleLen);
contentLines.push(lineWithMargins + " ".repeat(paddingNeeded));
}
}
// Add top/bottom padding (empty lines)
const emptyLine = " ".repeat(width);
const emptyLines: string[] = [];
for (let i = 0; i < this.paddingY; i++) {
const line = this.customBgFn ? applyBackgroundToLine(emptyLine, width, this.customBgFn) : emptyLine;
emptyLines.push(line);
}
const result = [...emptyLines, ...contentLines, ...emptyLines];
// Update cache
this.cachedText = this.text;
this.cachedWidth = width;
this.cachedLines = result;
return result.length > 0 ? result : [""];
}
}
@@ -0,0 +1,65 @@
import type { Component } from "../tui.js";
import { truncateToWidth, visibleWidth } from "../utils.js";
/**
* Text component that truncates to fit viewport width
*/
export class TruncatedText implements Component {
private text: string;
private paddingX: number;
private paddingY: number;
constructor(text: string, paddingX: number = 0, paddingY: number = 0) {
this.text = text;
this.paddingX = paddingX;
this.paddingY = paddingY;
}
invalidate(): void {
// No cached state to invalidate currently
}
render(width: number): string[] {
const result: string[] = [];
// Empty line padded to width
const emptyLine = " ".repeat(width);
// Add vertical padding above
for (let i = 0; i < this.paddingY; i++) {
result.push(emptyLine);
}
// Calculate available width after horizontal padding
const availableWidth = Math.max(1, width - this.paddingX * 2);
// Take only the first line (stop at newline)
let singleLineText = this.text;
const newlineIndex = this.text.indexOf("\n");
if (newlineIndex !== -1) {
singleLineText = this.text.substring(0, newlineIndex);
}
// Truncate text if needed (accounting for ANSI codes)
const displayText = truncateToWidth(singleLineText, availableWidth);
// Add horizontal padding
const leftPadding = " ".repeat(this.paddingX);
const rightPadding = " ".repeat(this.paddingX);
const lineWithPadding = leftPadding + displayText + rightPadding;
// Pad line to exactly width characters
const lineVisibleWidth = visibleWidth(lineWithPadding);
const paddingNeeded = Math.max(0, width - lineVisibleWidth);
const finalLine = lineWithPadding + " ".repeat(paddingNeeded);
result.push(finalLine);
// Add vertical padding below
for (let i = 0; i < this.paddingY; i++) {
result.push(emptyLine);
}
return result;
}
}