update
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user