@hoshikawashiro/lemmy-tui
v0.1.1
Published
Terminal User Interface library with differential rendering for efficient text-based applications
Maintainers
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 TUIremoveChild(component)- Remove a component from the TUIsetFocus(component)- Set which component receives keyboard inputstart()- Start the TUI (enables raw mode)stop()- Stop the TUI (disables raw mode)requestRender()- Request a re-render on next tickconfigureLogging(config)- Configure debug loggingcleanupSentinels()- Remove placeholder components after removal operationsfindComponent(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 componentremoveChild(component)- Remove a child componentgetChild(index)- Get a specific child componentgetChildCount()- Get the number of child componentsclear()- Remove all child componentssetParentTui(tui)- Set the parent TUI referencecleanupSentinels()- Clean up removed component placeholdersrender(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 EnteronChange?: (text: string) => void- Callback when text content changes
Methods:
getText()- Get current text contentsetText(text)- Set text content and move cursor to endsetAutocompleteProvider(provider)- Set autocomplete provider for Tab completionrender(width)- Render the editor with current statehandleInput(data)- Process keyboard input
Keyboard Shortcuts:
Navigation:
Arrow Keys- Move cursorHome/Ctrl+A- Move to start of lineEnd/Ctrl+E- Move to end of line
Editing:
Backspace- Delete character before cursorDelete/Fn+Backspace- Delete character at cursorCtrl+K- Delete current lineEnter- Submit text (calls onSubmit)Shift+Enter/Option+Enter- Add new lineTab- Trigger autocomplete
Autocomplete (when active):
Tab- Apply selected completionArrow Up/Down- Navigate suggestionsEscape- Cancel autocompleteEnter- 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 contentgetText()- Get current text contentrender(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 contentrender(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 selectedonCancel?: () => void- Called when selection is cancelled
Methods:
setFilter(filter)- Filter items by valuegetSelectedItem()- Get currently selected itemhandleInput(keyData)- Handle keyboard navigationrender(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:
Tabkey 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 contextgetForceFileSuggestions()- Force file completion (Tab key)shouldTriggerFileCompletion()- Check if file completion should triggerapplyCompletion()- Apply selected completion
Differential Rendering
The core concept: components return {lines: string[], changed: boolean, keepLines?: number}:
lines: All lines the component should displaychanged: Whether the component has changed since last renderkeepLines: (Containers only) How many lines from the beginning are unchanged
How it works:
- TUI calculates total unchanged lines from top (
keepLines) - Moves cursor up by
(totalLines - keepLines)positions - Clears from cursor position down with
\x1b[0J - 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 checkingTesting: 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
