npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2025 – Pkg Stats / Ryan Hefner

@hoshikawashiro/lemmy-tui

v0.1.1

Published

Terminal User Interface library with differential rendering for efficient text-based applications

Readme

@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