0.1.1 • Published 7 months ago

@mariozechner/lemmy-tui v0.1.1

Weekly downloads
-
License
MIT
Repository
-
Last release
7 months ago

@mariozechner/lemmy-tui

Terminal UI framework with differential rendering for building interactive CLI applications.

Features

  • Differential Rendering: Only re-renders content that has changed for optimal performance
  • Interactive Components: Text editor, autocomplete, selection lists, and markdown rendering
  • Composable Architecture: Container-based component system with proper lifecycle management
  • Autocomplete System: File completion and slash commands with provider interface
  • TypeScript: Fully typed for better development experience
  • Performance Focused: Minimal screen updates and efficient text wrapping

Quick Start

import { TUI, Container, TextComponent, TextEditor } from "@mariozechner/lemmy-tui";

// Create TUI manager
const ui = new TUI();

// Create components
const header = new TextComponent("🚀 My TUI App");
const chatContainer = new Container();
const editor = new TextEditor();

// Add components to UI
ui.addChild(header);
ui.addChild(chatContainer);
ui.addChild(editor);

// Set focus to the editor
ui.setFocus(editor);

// Handle editor submissions
editor.onSubmit = (text: string) => {
	if (text.trim()) {
		const message = new TextComponent(`💬 ${text}`);
		chatContainer.addChild(message);
		ui.requestRender();
	}
};

// Start the UI
ui.start();

Core Components

TUI

Main TUI manager that handles rendering, input, and component coordination.

Methods:

  • addChild(component) - Add a component to the TUI
  • removeChild(component) - Remove a component from the TUI
  • setFocus(component) - Set which component receives keyboard input
  • start() - Start the TUI (enables raw mode)
  • stop() - Stop the TUI (disables raw mode)
  • requestRender() - Request a re-render on next tick
  • configureLogging(config) - Configure debug logging
  • cleanupSentinels() - Remove placeholder components after removal operations
  • findComponent(component) - Check if a component exists in the hierarchy (private)
  • findInContainer(container, component) - Search for component in container (private)

Container

Component that manages child components with differential rendering.

Constructor:

new Container(parentTui?: TUI | undefined)

Methods:

  • addChild(component) - Add a child component
  • removeChild(component) - Remove a child component
  • getChild(index) - Get a specific child component
  • getChildCount() - Get the number of child components
  • clear() - Remove all child components
  • setParentTui(tui) - Set the parent TUI reference
  • cleanupSentinels() - Clean up removed component placeholders
  • render(width) - Render all child components (returns ContainerRenderResult)

TextEditor

Interactive multiline text editor with cursor support and comprehensive keyboard shortcuts.

Constructor:

new TextEditor(config?: TextEditorConfig)

Configuration:

interface TextEditorConfig {
	// Configuration options for text editor
}

editor.configure(config: Partial<TextEditorConfig>)

Properties:

  • onSubmit?: (text: string) => void - Callback when user presses Enter
  • onChange?: (text: string) => void - Callback when text content changes

Methods:

  • getText() - Get current text content
  • setText(text) - Set text content and move cursor to end
  • setAutocompleteProvider(provider) - Set autocomplete provider for Tab completion
  • render(width) - Render the editor with current state
  • handleInput(data) - Process keyboard input

Keyboard Shortcuts:

Navigation:

  • Arrow Keys - Move cursor
  • Home / Ctrl+A - Move to start of line
  • End / Ctrl+E - Move to end of line

Editing:

  • Backspace - Delete character before cursor
  • Delete / Fn+Backspace - Delete character at cursor
  • Ctrl+K - Delete current line
  • Enter - Submit text (calls onSubmit)
  • Shift+Enter / Option+Enter - Add new line
  • Tab - Trigger autocomplete

Autocomplete (when active):

  • Tab - Apply selected completion
  • Arrow Up/Down - Navigate suggestions
  • Escape - Cancel autocomplete
  • Enter - Cancel autocomplete and submit

Paste Detection:

  • Automatically handles multi-line paste
  • Converts tabs to 4 spaces
  • Filters non-printable characters

TextComponent

Simple text component with automatic text wrapping and differential rendering.

Constructor:

new TextComponent(text: string, padding?: Padding)

interface Padding {
	top?: number;
	bottom?: number;
	left?: number;
	right?: number;
}

Methods:

  • setText(text) - Update the text content
  • getText() - Get current text content
  • render(width) - Render with word wrapping

Features:

  • Automatic text wrapping to fit terminal width
  • Configurable padding on all sides
  • Preserves line breaks in source text
  • Uses differential rendering to avoid unnecessary updates

MarkdownComponent

Renders markdown content with syntax highlighting and proper formatting.

Constructor:

new MarkdownComponent(text?: string)

Methods:

  • setText(text) - Update markdown content
  • render(width) - Render parsed markdown

Features:

  • Headings: Styled with colors and formatting
  • Code blocks: Syntax highlighting with gray background
  • Lists: Bullet points (•) and numbered lists
  • Emphasis: Bold and italic text
  • Links: Underlined with URL display
  • Blockquotes: Styled with left border
  • Inline code: Highlighted with background
  • Horizontal rules: Terminal-width separator lines
  • Differential rendering for performance

SelectList

Interactive selection component for choosing from options.

Constructor:

new SelectList(items: SelectItem[], maxVisible?: number)

interface SelectItem {
	value: string;
	label: string;
	description?: string;
}

Properties:

  • onSelect?: (item: SelectItem) => void - Called when item is selected
  • onCancel?: () => void - Called when selection is cancelled

Methods:

  • setFilter(filter) - Filter items by value
  • getSelectedItem() - Get currently selected item
  • handleInput(keyData) - Handle keyboard navigation
  • render(width) - Render the selection list

Features:

  • Keyboard navigation (arrow keys, Enter)
  • Search/filter functionality
  • Scrolling for long lists
  • Custom option rendering with descriptions
  • Visual selection indicator (→)
  • Scroll position indicator

Autocomplete System

Comprehensive autocomplete system supporting slash commands and file paths.

AutocompleteProvider Interface

interface AutocompleteProvider {
	getSuggestions(lines: string[], cursorLine: number, cursorCol: number): {
		items: AutocompleteItem[];
		prefix: string;
	} | null;
	
	applyCompletion(
		lines: string[],
		cursorLine: number, 
		cursorCol: number,
		item: AutocompleteItem,
		prefix: string
	): {
		lines: string[];
		cursorLine: number;
		cursorCol: number;
	};
}

interface AutocompleteItem {
	value: string;
	label: string;
	description?: string;
}

CombinedAutocompleteProvider

Built-in provider supporting slash commands and file completion.

Constructor:

new CombinedAutocompleteProvider(
	commands: (SlashCommand | AutocompleteItem)[] = [],
	basePath: string = process.cwd()
)

interface SlashCommand {
	name: string;
	description?: string;
	getArgumentCompletions?(argumentPrefix: string): AutocompleteItem[] | null;
}

Features:

Slash Commands:

  • Type / to trigger command completion
  • Auto-completion for command names
  • Argument completion for commands that support it
  • Space after command name for argument input

File Completion:

  • Tab key triggers file completion
  • @ prefix for file attachments
  • Home directory expansion (~/)
  • Relative and absolute path support
  • Directory-first sorting
  • Filters to attachable files for @ prefix

Path Patterns:

  • ./ and ../ - Relative paths
  • ~/ - Home directory
  • @path - File attachment syntax
  • Tab completion from any context

Methods:

  • getSuggestions() - Get completions for current context
  • getForceFileSuggestions() - Force file completion (Tab key)
  • shouldTriggerFileCompletion() - Check if file completion should trigger
  • applyCompletion() - Apply selected completion

Differential Rendering

The core concept: components return {lines: string[], changed: boolean, keepLines?: number}:

  • lines: All lines the component should display
  • changed: Whether the component has changed since last render
  • keepLines: (Containers only) How many lines from the beginning are unchanged

How it works:

  1. TUI calculates total unchanged lines from top (keepLines)
  2. Moves cursor up by (totalLines - keepLines) positions
  3. Clears from cursor position down with \x1b[0J
  4. Prints only the changing lines: result.lines.slice(keepLines)

This approach minimizes screen updates and provides smooth performance even with large amounts of text.

Important: Don't add extra cursor positioning after printing - it interferes with terminal scrolling and causes rendering artifacts.

Advanced Examples

Chat Application with Autocomplete

import { 
	TUI, 
	Container, 
	TextEditor, 
	MarkdownComponent,
	CombinedAutocompleteProvider 
} from "@mariozechner/lemmy-tui";

const ui = new TUI();
const chatHistory = new Container();
const editor = new TextEditor();

// Set up autocomplete with slash commands
const autocompleteProvider = new CombinedAutocompleteProvider([
	{ name: "clear", description: "Clear chat history" },
	{ name: "help", description: "Show help information" },
	{ 
		name: "attach", 
		description: "Attach a file",
		getArgumentCompletions: (prefix) => {
			// Return file suggestions for attach command
			return null; // Use default file completion
		}
	}
]);

editor.setAutocompleteProvider(autocompleteProvider);

editor.onSubmit = (text) => {
	// Handle slash commands
	if (text.startsWith("/")) {
		const [command, ...args] = text.slice(1).split(" ");
		if (command === "clear") {
			chatHistory.clear();
			return;
		}
		if (command === "help") {
			const help = new MarkdownComponent(`
## Available Commands
- \`/clear\` - Clear chat history  
- \`/help\` - Show this help
- \`/attach <file>\` - Attach a file
			`);
			chatHistory.addChild(help);
			ui.requestRender();
			return;
		}
	}

	// Regular message
	const message = new MarkdownComponent(`**You:** ${text}`);
	chatHistory.addChild(message);

	// Add AI response (simulated)
	setTimeout(() => {
		const response = new MarkdownComponent(`**AI:** Response to "${text}"`);
		chatHistory.addChild(response);
		ui.requestRender();
	}, 1000);
};

ui.addChild(chatHistory);
ui.addChild(editor);
ui.setFocus(editor);
ui.start();

File Browser

import { TUI, SelectList } from "@mariozechner/lemmy-tui";
import { readdirSync, statSync } from "fs";
import { join } from "path";

const ui = new TUI();
let currentPath = process.cwd();

function createFileList(path: string) {
	const entries = readdirSync(path).map(entry => {
		const fullPath = join(path, entry);
		const isDir = statSync(fullPath).isDirectory();
		return {
			value: entry,
			label: entry,
			description: isDir ? "directory" : "file"
		};
	});

	// Add parent directory option
	if (path !== "/") {
		entries.unshift({
			value: "..",
			label: "..",
			description: "parent directory"
		});
	}

	return entries;
}

function showDirectory(path: string) {
	ui.clear();
	
	const entries = createFileList(path);
	const fileList = new SelectList(entries, 10);
	
	fileList.onSelect = (item) => {
		if (item.value === "..") {
			currentPath = join(currentPath, "..");
			showDirectory(currentPath);
		} else if (item.description === "directory") {
			currentPath = join(currentPath, item.value);
			showDirectory(currentPath);
		} else {
			console.log(`Selected file: ${join(currentPath, item.value)}`);
			ui.stop();
		}
	};

	ui.addChild(fileList);
	ui.setFocus(fileList);
}

showDirectory(currentPath);
ui.start();

Multi-Component Layout

import { 
	TUI, 
	Container, 
	TextComponent, 
	TextEditor,
	MarkdownComponent 
} from "@mariozechner/lemmy-tui";

const ui = new TUI();

// Create layout containers
const header = new TextComponent("📝 Advanced TUI Demo", { bottom: 1 });
const mainContent = new Container();
const sidebar = new Container();
const footer = new TextComponent("Press Ctrl+C to exit", { top: 1 });

// Sidebar content
sidebar.addChild(new TextComponent("📁 Files:", { bottom: 1 }));
sidebar.addChild(new TextComponent("- config.json"));
sidebar.addChild(new TextComponent("- README.md"));
sidebar.addChild(new TextComponent("- package.json"));

// Main content area
const chatArea = new Container();
const inputArea = new TextEditor();

// Add welcome message
chatArea.addChild(new MarkdownComponent(`
# Welcome to the TUI Demo

This demonstrates multiple components working together:

- **Header**: Static title with padding
- **Sidebar**: File list (simulated)
- **Chat Area**: Scrollable message history
- **Input**: Interactive text editor
- **Footer**: Status information

Try typing a message and pressing Enter!
`));

inputArea.onSubmit = (text) => {
	if (text.trim()) {
		const message = new MarkdownComponent(`
**${new Date().toLocaleTimeString()}:** ${text}
		`);
		chatArea.addChild(message);
		ui.requestRender();
	}
};

// Build layout
mainContent.addChild(chatArea);
mainContent.addChild(inputArea);

ui.addChild(header);
ui.addChild(mainContent);  
ui.addChild(footer);
ui.setFocus(inputArea);

// Configure debug logging
ui.configureLogging({
	enabled: true,
	level: "info",
	logFile: "tui-debug.log"
});

ui.start();

Interfaces and Types

Core Types

interface ComponentRenderResult {
	lines: string[];
	changed: boolean;
}

interface ContainerRenderResult extends ComponentRenderResult {
	keepLines: number;
}

interface Component {
	render(width: number): ComponentRenderResult;
	handleInput?(keyData: string): void;
}

interface Padding {
	top?: number;
	bottom?: number;
	left?: number;
	right?: number;
}

Autocomplete Types

interface AutocompleteItem {
	value: string;
	label: string;
	description?: string;
}

interface SlashCommand {
	name: string;
	description?: string;
	getArgumentCompletions?(argumentPrefix: string): AutocompleteItem[] | null;
}

interface AutocompleteProvider {
	getSuggestions(lines: string[], cursorLine: number, cursorCol: number): {
		items: AutocompleteItem[];
		prefix: string;
	} | null;
	
	applyCompletion(
		lines: string[],
		cursorLine: number,
		cursorCol: number,
		item: AutocompleteItem,
		prefix: string
	): {
		lines: string[];
		cursorLine: number;
		cursorCol: number;
	};
}

Selection Types

interface SelectItem {
	value: string;
	label: string;
	description?: string;
}

Development

npm run build     # Build the package
npm run typecheck # Type checking

Testing: Test the TUI components with simulated input:

npx tsx --no-deprecation src/index.ts chat --simulate-input "Hello world" "ENTER"

Special input keywords for simulation: "TAB", "ENTER", "SPACE", "ESC"

Debugging: Enable logging to see detailed component behavior:

ui.configureLogging({
	enabled: true,
	level: "debug", // "error" | "warn" | "info" | "debug"
	logFile: "tui-debug.log"
});

Check the log file to debug rendering issues, input handling, and component lifecycle.

Philosophy

This TUI framework prioritizes:

  • Performance: Differential rendering minimizes screen updates
  • Composability: Clean component architecture with proper separation
  • Developer Experience: TypeScript types and intuitive APIs
  • Flexibility: Build complex interfaces from simple, reusable components
  • Real-world Usage: File completion, slash commands, and practical interactions