Skip to content

Instantly share code, notes, and snippets.

@CashWilliams
Created August 29, 2025 20:12
Show Gist options
  • Save CashWilliams/2a111725e704b6cc17fcffcd857b9cc6 to your computer and use it in GitHub Desktop.
Save CashWilliams/2a111725e704b6cc17fcffcd857b9cc6 to your computer and use it in GitHub Desktop.
A guide to use rich to build cli agent interfaces

Rich CLI Application Development Guide

This guide shows you how to build polished command-line applications with the same professional UX patterns used in vibe-llama, featuring beautiful Rich-based interfaces, interactive dialogs, and real-time streaming updates.

Table of Contents

  1. Overview & Architecture
  2. Core Dependencies
  3. Project Structure
  4. CLI Entry Point & Argument Parsing
  5. Rich Formatter System
  6. Interactive Dialogs with Prompt-Toolkit
  7. Streaming Events & Live Updates
  8. Logo & Branding
  9. Error Handling & User Feedback
  10. File Operations & Path Completion
  11. Complete Example Implementation
  12. Advanced Patterns
  13. Testing & Best Practices

Overview & Architecture

The vibe-llama CLI follows a modular architecture with these key components:

  • Main CLI Router: Uses argparse for subcommands and options
  • Rich Formatter: Centralized formatting system for consistent UI elements
  • Interactive Dialogs: Prompt-toolkit based UIs for user interaction
  • Streaming System: Real-time updates with Rich Live components
  • Async Operations: All I/O operations are async for better UX

Core Dependencies

First, set up your pyproject.toml with the essential dependencies:

[project]
dependencies = [
    "rich>=14.1.0",           # Core UI library
    "rich-gradient>=0.3.3",   # Gradient text effects
    "prompt-toolkit>=3.0.51", # Interactive terminal UIs
    "httpx>=0.28.1",          # Async HTTP client
]

Project Structure

Organize your project following this structure:

your-cli/
β”œβ”€β”€ src/
β”‚   └── your_cli/
β”‚       β”œβ”€β”€ __init__.py
β”‚       β”œβ”€β”€ main.py           # CLI entry point
β”‚       β”œβ”€β”€ logo.py           # Branding and logo
β”‚       β”œβ”€β”€ commons/
β”‚       β”‚   β”œβ”€β”€ __init__.py
β”‚       β”‚   β”œβ”€β”€ formatter.py  # Rich formatting utilities
β”‚       β”‚   └── events.py     # Streaming event system
β”‚       β”œβ”€β”€ handlers/
β”‚       β”‚   β”œβ”€β”€ __init__.py
β”‚       β”‚   └── command.py    # Command handlers
β”‚       └── ui/
β”‚           β”œβ”€β”€ __init__.py
β”‚           β”œβ”€β”€ dialogs.py    # Interactive dialogs
β”‚           └── terminal.py   # Terminal interface
β”œβ”€β”€ pyproject.toml
└── README.md

CLI Entry Point & Argument Parsing

main.py

#!/usr/bin/env python3
import argparse
import asyncio
from rich.console import Console

from .logo import print_logo
from .handlers import run_command_handler
from .ui import run_interactive_mode

def main() -> None:
    console = Console()
    
    parser = argparse.ArgumentParser(
        prog="your-cli",
        description="Your CLI tool description with Rich formatting support.",
    )
    
    # Add subcommands
    subparsers = parser.add_subparsers(
        dest="command", 
        help="Available commands", 
        required=True
    )
    
    # Interactive command
    interactive_parser = subparsers.add_parser(
        "interactive",
        help="Launch interactive terminal user interface"
    )
    
    # Direct command example
    direct_parser = subparsers.add_parser(
        "process",
        help="Process files directly from command line"
    )
    direct_parser.add_argument(
        "-f", "--files", 
        nargs="+",
        help="Files to process"
    )
    direct_parser.add_argument(
        "-v", "--verbose",
        action="store_true",
        help="Enable verbose output"
    )
    
    args = parser.parse_args()
    
    # Always show logo first
    print_logo()
    
    try:
        if args.command == "interactive":
            asyncio.run(run_interactive_mode())
        elif args.command == "process":
            asyncio.run(run_command_handler(args))
    except KeyboardInterrupt:
        console.print("\nπŸ‘‹ Goodbye!", style="bold yellow")
        
    return None

Rich Formatter System

Create a centralized formatting system for consistent UI elements:

commons/formatter.py

"""Rich formatting utilities for consistent CLI styling"""

import textwrap
from rich.console import Console, Group
from rich.text import Text
from rich.markdown import Markdown
from rich.syntax import Syntax
from rich.padding import Padding
from rich.rule import Rule
from rich.panel import Panel

class CLIFormatter:
    """Centralized Rich formatting utilities"""
    
    @staticmethod
    def _get_terminal_width() -> int:
        """Get current terminal width with fallback"""
        try:
            console = Console()
            return console.size.width
        except Exception:
            return 80
    
    @staticmethod
    def success(message: str) -> Text:
        """Format success message"""
        return Text(f"βœ… {message}", style="bold green")
    
    @staticmethod
    def error(message: str) -> Text:
        """Format error message"""
        return Text(f"❌ {message}", style="bold red")
    
    @staticmethod
    def info(message: str) -> Text:
        """Format info message"""
        return Text(f"ℹ️  {message}", style="bold blue")
    
    @staticmethod
    def warning(message: str) -> Text:
        """Format warning message"""
        return Text(f"⚠️  {message}", style="bold yellow")
    
    @staticmethod
    def tool_action(action_name: str, description: str = "") -> Text:
        """Format tool action with proper styling"""
        if description:
            full_text = f"⏺ {action_name}({description})"
        else:
            full_text = f"⏺ {action_name}"
        return Text(full_text, style="bold")
    
    @staticmethod
    def status_update(text: str, prefix: str = "β–Έ ") -> Text:
        """Format status updates"""
        return Text(f"{prefix}{text}", style="bold")
    
    @staticmethod
    def indented_text(text: str, prefix: str = "β”‚ ", style: str = "dim white") -> Text:
        """Format text with visual indicator"""
        if not text.strip():
            return Text("")
        formatted_text = f"{prefix}{text.strip()}"
        return Text(formatted_text, style=style)
    
    @staticmethod
    def agent_response(text: str) -> Padding:
        """Format agent response with markdown support"""
        if not text.strip():
            return Padding(Text(""), (0, 0, 0, 0))
        content = Padding(Markdown(text.strip()), (0, 0, 0, 2))
        return content
    
    @staticmethod
    def code_output(code: str, title: str = "Generated Code", language: str = "python") -> Group:
        """Format code output with syntax highlighting"""
        header = Text(title, style="bold green")
        rule = Rule(style="dim green")
        code_syntax = Syntax(
            code, language, theme="monokai", 
            line_numbers=True, word_wrap=True
        )
        indented_code = Padding(code_syntax, (0, 0, 0, 2))
        return Group(header, rule, indented_code)
    
    @staticmethod
    def file_list(files: list, title: str = "πŸ“ Available Files") -> Group:
        """Format file list display"""
        header = Text(title, style="bold yellow")
        rule = Rule(style="dim yellow")
        
        file_lines = []
        for i, file in enumerate(files[:10]):
            file_lines.append(Text(f"  {i + 1}. {file}", style="dim white"))
        
        if len(files) > 10:
            file_lines.append(
                Text(f"  ... and {len(files) - 10} more files", style="dim")
            )
        
        return Group(header, rule, *file_lines)
    
    @staticmethod
    def progress_panel(message: str) -> Panel:
        """Create a progress panel"""
        return Panel(
            Text(message, style="bold cyan"),
            border_style="cyan",
            padding=(1, 2)
        )

Interactive Dialogs with Prompt-Toolkit

Create beautiful interactive dialogs for user input:

ui/dialogs.py

"""Interactive dialogs using prompt-toolkit"""

from typing import Optional, List, Tuple
from prompt_toolkit.shortcuts import checkboxlist_dialog, yes_no_dialog, input_dialog
from prompt_toolkit.formatted_text import HTML
from prompt_toolkit.styles import Style

# Define your app's color scheme
APP_STYLE = Style.from_dict({
    "dialog": "bg:#F8E9D8",
    "dialog.body": "bg:#45DFF8", 
    "dialog shadow": "bg:#a6a2ab",
    "button.focused": "bg:#FFA6EA",
})

async def select_multiple_options(
    title: str,
    text: str, 
    options: List[Tuple[str, str]]  # [(display_name, value), ...]
) -> Optional[List[str]]:
    """Multi-select checkbox dialog"""
    
    dialog = checkboxlist_dialog(
        title=HTML(f"<style fg='black'>{title}</style>"),
        text=text,
        values=options,
        style=APP_STYLE,
    )
    
    return await dialog.run_async()

async def confirm_action(title: str, text: str) -> bool:
    """Yes/No confirmation dialog"""
    
    dialog = yes_no_dialog(
        title=HTML(f"<style fg='black'>{title}</style>"),
        text=text,
        style=APP_STYLE,
    )
    
    result = await dialog.run_async()
    return result if result is not None else False

async def get_text_input(title: str, text: str, default: str = "") -> Optional[str]:
    """Text input dialog"""
    
    dialog = input_dialog(
        title=HTML(f"<style fg='black'>{title}</style>"),
        text=text,
        default=default,
        style=APP_STYLE,
    )
    
    return await dialog.run_async()

# Example usage combining multiple dialogs
async def run_interactive_setup() -> Optional[dict]:
    """Run a series of interactive dialogs"""
    
    # Step 1: Select options
    file_options = [
        ("Process Documents", "docs"),
        ("Process Images", "images"), 
        ("Process Audio", "audio"),
    ]
    
    selected_types = await select_multiple_options(
        "File Types",
        "Which file types would you like to process?",
        file_options
    )
    
    if not selected_types:
        return None
    
    # Step 2: Get input path
    input_path = await get_text_input(
        "Input Path",
        "Enter the path to your files:",
        default="./data"
    )
    
    if not input_path:
        return None
    
    # Step 3: Confirm settings
    confirmed = await confirm_action(
        "Confirm Settings",
        f"Process {', '.join(selected_types)} from {input_path}?"
    )
    
    if not confirmed:
        return None
    
    return {
        "types": selected_types,
        "path": input_path,
        "confirmed": True
    }

Streaming Events & Live Updates

Implement real-time streaming updates using Rich Live components:

commons/events.py

"""Event system for streaming updates"""

from typing import Any, Optional
from pydantic import BaseModel

class StreamEvent:
    """Event for streaming response tokens and rich content"""
    
    def __init__(
        self,
        delta: str = "",
        rich_content: Any | None = None,
        newline_after: bool = False,
        is_code: bool = False,
    ):
        self.delta = delta
        self.rich_content = rich_content
        self.newline_after = newline_after
        self.is_code = is_code

handlers/streaming.py

"""Streaming handler with Rich Live updates"""

import asyncio
from rich.console import Console
from rich.live import Live
from rich.spinner import Spinner

from ..commons.formatter import CLIFormatter
from ..commons.events import StreamEvent

class StreamingHandler:
    """Handle streaming content with Rich Live updates"""
    
    def __init__(self):
        self.console = Console()
        self.code_buffer = ""
        self.live_panel = None
    
    async def handle_stream(self, event_generator):
        """Process streaming events with live updates"""
        
        try:
            async for event in event_generator:
                if isinstance(event, StreamEvent):
                    if event.is_code:
                        await self._handle_code_stream(event)
                    elif event.rich_content:
                        await self._handle_rich_content(event)
                    else:
                        await self._handle_text_stream(event)
        
        except KeyboardInterrupt:
            if self.live_panel:
                self.live_panel.stop()
            self.console.print("\nπŸ‘‹ Goodbye!", style="bold yellow")
        
        finally:
            if self.live_panel:
                self.live_panel.stop()
    
    async def _handle_code_stream(self, event: StreamEvent):
        """Handle streaming code with live syntax highlighting"""
        self.code_buffer += event.delta
        
        # Implement tail-following for long code
        max_lines = self.console.size.height - 15
        lines = self.code_buffer.split("\n")
        
        if len(lines) > max_lines:
            visible_lines = lines[-max_lines:]
            display_code = "\n".join(visible_lines)
            hidden_lines = len(lines) - max_lines
            display_code = f"# ... ({hidden_lines} lines above)\n" + display_code
            title = f"πŸ”§ Generating Code... (showing last {max_lines}/{len(lines)} lines)"
        else:
            display_code = self.code_buffer
            title = f"πŸ”§ Generating Code... ({len(lines)} lines)"
        
        if not self.live_panel:
            self.live_panel = Live(
                CLIFormatter.code_output(display_code, title),
                refresh_per_second=8,
                console=self.console
            )
            self.live_panel.start()
        else:
            self.live_panel.update(
                CLIFormatter.code_output(display_code, title)
            )
    
    async def _handle_rich_content(self, event: StreamEvent):
        """Handle rich content display"""
        if self.live_panel:
            self.live_panel.stop()
            self.console.print()
            self.live_panel = None
            self.code_buffer = ""
        
        self.console.print(event.rich_content)
        if event.newline_after:
            self.console.print()
    
    async def _handle_text_stream(self, event: StreamEvent):
        """Handle plain text streaming"""
        if self.live_panel:
            self.live_panel.stop()
            self.console.print()
            self.live_panel = None
            self.code_buffer = ""
        
        print(event.delta, end="", flush=True)

# Example: Async generator that produces streaming events
async def simulate_code_generation():
    """Simulate streaming code generation"""
    
    code_lines = [
        "import asyncio\n",
        "from rich.console import Console\n\n",
        "async def main():\n",
        "    console = Console()\n",
        "    console.print('Hello World!')\n\n",
        "if __name__ == '__main__':\n",
        "    asyncio.run(main())\n"
    ]
    
    for line in code_lines:
        # Simulate typing delay
        for char in line:
            yield StreamEvent(delta=char, is_code=True)
            await asyncio.sleep(0.05)
        
    # Send final rich content
    yield StreamEvent(
        rich_content=CLIFormatter.success("Code generation complete!"),
        newline_after=True
    )

Logo & Branding

Create an eye-catching logo with gradient effects:

logo.py

"""Application logo and branding"""

from rich.console import Console
from rich_gradient.text import Text

# ASCII art logo
LOGO = """
β–ˆβ–ˆβ•—   β–ˆβ–ˆβ•— β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•—   β–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—      β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•—     β–ˆβ–ˆβ•—
β•šβ–ˆβ–ˆβ•— β–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•”β•β•β•β–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘   β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—    β–ˆβ–ˆβ•”β•β•β•β•β•β–ˆβ–ˆβ•‘     β–ˆβ–ˆβ•‘
 β•šβ–ˆβ–ˆβ–ˆβ–ˆβ•”β• β–ˆβ–ˆβ•‘   β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘   β–ˆβ–ˆβ•‘β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•    β–ˆβ–ˆβ•‘     β–ˆβ–ˆβ•‘     β–ˆβ–ˆβ•‘
  β•šβ–ˆβ–ˆβ•”β•  β–ˆβ–ˆβ•‘   β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•‘   β–ˆβ–ˆβ•‘β–ˆβ–ˆβ•”β•β•β–ˆβ–ˆβ•—    β–ˆβ–ˆβ•‘     β–ˆβ–ˆβ•‘     β–ˆβ–ˆβ•‘
   β–ˆβ–ˆβ•‘   β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•”β•β–ˆβ–ˆβ•‘  β–ˆβ–ˆβ•‘    β•šβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ•—β–ˆβ–ˆβ•‘
   β•šβ•β•    β•šβ•β•β•β•β•β•  β•šβ•β•β•β•β•β• β•šβ•β•  β•šβ•β•     β•šβ•β•β•β•β•β•β•šβ•β•β•β•β•β•β•β•šβ•β•
"""

def print_logo() -> None:
    """Print the application logo with gradient colors"""
    console = Console()
    
    print("\n")
    
    # Create gradient text - customize colors for your brand
    gradient_logo = Text(
        LOGO, 
        colors=["#F8E9D8", "#FFA6EA", "#45DFF8", "#BB8DEB"]
    )
    
    console.print(gradient_logo, justify="center")
    print("\n")
    console.print("-" * 50, style="gray bold", justify="center")
    print("\n")

def print_app_info(version: str = "1.0.0") -> None:
    """Print application information"""
    console = Console()
    
    console.print(f"[bold cyan]Your CLI Tool v{version}[/bold cyan]", justify="center")
    console.print("[dim]A professional CLI application built with Rich[/dim]", justify="center")
    print()

Error Handling & User Feedback

Implement comprehensive error handling with user-friendly feedback:

commons/errors.py

"""Error handling and user feedback utilities"""

import os
import traceback
from typing import Tuple, List
from pathlib import Path

from rich.console import Console
from .formatter import CLIFormatter

console = Console()

class CLIError(Exception):
    """Base exception for CLI errors"""
    pass

class ValidationError(CLIError):
    """Validation error with user-friendly message"""
    
    def __init__(self, message: str, suggestions: List[str] = None):
        self.message = message
        self.suggestions = suggestions or []
        super().__init__(message)

def handle_error(error: Exception, show_traceback: bool = False) -> None:
    """Handle and display errors with Rich formatting"""
    
    if isinstance(error, ValidationError):
        console.print(CLIFormatter.error(error.message))
        for suggestion in error.suggestions:
            console.print(CLIFormatter.indented_text(suggestion))
    
    elif isinstance(error, FileNotFoundError):
        console.print(CLIFormatter.error(f"File not found: {error.filename}"))
        _suggest_similar_files(error.filename)
    
    elif isinstance(error, PermissionError):
        console.print(CLIFormatter.error(f"Permission denied: {error.filename}"))
        console.print(CLIFormatter.indented_text("Check file permissions and try again"))
    
    else:
        console.print(CLIFormatter.error(f"Unexpected error: {str(error)}"))
        
        if show_traceback:
            console.print("\n[dim]Traceback:[/dim]")
            console.print(f"[dim]{traceback.format_exc()}[/dim]")

def _suggest_similar_files(missing_file: str) -> None:
    """Suggest similar files in the directory"""
    if not missing_file:
        return
    
    parent_dir = os.path.dirname(missing_file) or "."
    if not os.path.exists(parent_dir):
        return
    
    try:
        files = [f for f in os.listdir(parent_dir) 
                if not f.startswith('.')]
        
        if files:
            console.print(CLIFormatter.indented_text("Available files:"))
            for file in files[:5]:
                console.print(CLIFormatter.indented_text(f"  πŸ“„ {file}"))
            
            if len(files) > 5:
                console.print(CLIFormatter.indented_text(f"  ... and {len(files) - 5} more"))
    
    except PermissionError:
        pass

def validate_file_path(path: str) -> Tuple[bool, str]:
    """Validate file path and return status with message"""
    
    if not path:
        return False, "Path cannot be empty"
    
    if not os.path.exists(path):
        return False, f"Path does not exist: {path}"
    
    if not os.path.isfile(path):
        return False, f"Path is not a file: {path}"
    
    if not os.access(path, os.R_OK):
        return False, f"Cannot read file: {path}"
    
    return True, "Valid file path"

def validate_directory_path(path: str) -> Tuple[bool, str]:
    """Validate directory path and return status with message"""
    
    if not path:
        return False, "Path cannot be empty"
    
    if not os.path.exists(path):
        return False, f"Directory does not exist: {path}"
    
    if not os.path.isdir(path):
        return False, f"Path is not a directory: {path}"
    
    if not os.access(path, os.R_OK):
        return False, f"Cannot access directory: {path}"
    
    return True, "Valid directory path"

File Operations & Path Completion

Add smart path completion and file operations:

commons/completion.py

"""Path completion for interactive input"""

import os
from typing import List
from prompt_toolkit.completion import Completer, Completion

class PathCompleter(Completer):
    """Smart path completer with @ symbol support"""
    
    def get_completions(self, document, complete_event):
        text = document.text
        cursor_pos = document.cursor_position
        
        # Find @ symbol for path completion
        at_pos = text.rfind("@", 0, cursor_pos)
        if at_pos == -1:
            return
        
        # Extract path after @
        path_part = text[at_pos + 1:cursor_pos]
        
        # Determine search directory and pattern
        if "/" in path_part:
            base_dir, pattern = path_part.rsplit("/", 1)
            search_dir = base_dir if os.path.exists(base_dir) else "."
        else:
            search_dir = "."
            pattern = path_part
        
        try:
            items = self._get_matching_items(search_dir, pattern)
            completions = self._create_completions(items, pattern, at_pos + 1, cursor_pos)
            return completions[:10]  # Limit results
        
        except (OSError, PermissionError):
            return []
    
    def _get_matching_items(self, search_dir: str, pattern: str) -> List[Completion]:
        """Get matching files and directories"""
        items = []
        
        for item in os.listdir(search_dir):
            full_path = os.path.join(search_dir, item)
            
            if item.lower().startswith(pattern.lower()):
                is_dir = os.path.isdir(full_path)
                
                if is_dir:
                    display_text = f"πŸ“ {item}/"
                    insert_text = item + "/"
                else:
                    display_text = f"πŸ“„ {item}"
                    insert_text = item
                
                completion = Completion(
                    insert_text,
                    display=display_text,
                )
                completion._is_dir = is_dir
                completion._sort_text = item.lower()
                items.append(completion)
        
        # Sort: directories first, then alphabetically
        items.sort(key=lambda x: (not x._is_dir, x._sort_text))
        return items
    
    def _create_completions(self, items: List[Completion], pattern: str, 
                           start_pos: int, cursor_pos: int) -> List[Completion]:
        """Create completion objects with correct positioning"""
        completions = []
        
        for item in items:
            start_position = start_pos + len(pattern.split("/")[-1]) - cursor_pos
            completion = Completion(
                item.text,
                start_position=start_position,
                display=item.display
            )
            completions.append(completion)
        
        return completions

Complete Example Implementation

Here's a complete example tying everything together:

example_app.py

#!/usr/bin/env python3
"""Complete example CLI application"""

import asyncio
import argparse
from pathlib import Path
from typing import List, Optional

from rich.console import Console
from rich.live import Live
from rich.progress import Progress, SpinnerColumn, TextColumn

from .logo import print_logo
from .commons.formatter import CLIFormatter
from .commons.events import StreamEvent
from .commons.errors import handle_error, validate_file_path
from .handlers.streaming import StreamingHandler
from .ui.dialogs import run_interactive_setup

console = Console()

class ExampleCLI:
    """Example CLI application class"""
    
    def __init__(self):
        self.formatter = CLIFormatter()
        self.streaming_handler = StreamingHandler()
    
    async def process_files(self, files: List[str], verbose: bool = False) -> None:
        """Process files with progress updates"""
        
        console.print(self.formatter.tool_action("ProcessFiles", f"files={len(files)}"))
        console.print(self.formatter.indented_text(f"Processing {len(files)} files..."))
        
        with Progress(
            SpinnerColumn(),
            TextColumn("[progress.description]{task.description}"),
            console=console
        ) as progress:
            
            task = progress.add_task("Processing files...", total=len(files))
            
            for i, file_path in enumerate(files):
                # Validate file
                is_valid, message = validate_file_path(file_path)
                if not is_valid:
                    console.print(self.formatter.error(f"Skipping {file_path}: {message}"))
                    continue
                
                # Simulate processing
                progress.update(task, description=f"Processing {Path(file_path).name}...")
                await asyncio.sleep(0.5)  # Simulate work
                
                if verbose:
                    console.print(self.formatter.indented_text(f"βœ“ Processed {file_path}"))
                
                progress.update(task, advance=1)
        
        console.print(self.formatter.success("All files processed successfully!"))
    
    async def interactive_mode(self) -> None:
        """Run interactive mode with dialogs"""
        
        console.print(self.formatter.info("Starting interactive mode..."))
        
        # Run interactive setup
        settings = await run_interactive_setup()
        if not settings:
            console.print(self.formatter.warning("Operation cancelled"))
            return
        
        console.print(self.formatter.success("Settings configured successfully!"))
        
        # Display settings
        console.print(self.formatter.indented_text("Configuration:"))
        console.print(self.formatter.indented_text(f"  Types: {', '.join(settings['types'])}"))
        console.print(self.formatter.indented_text(f"  Path: {settings['path']}"))
        
        # Simulate processing with streaming updates
        await self._simulate_processing()
    
    async def _simulate_processing(self) -> None:
        """Simulate processing with streaming updates"""
        
        async def generate_events():
            # Initial status
            yield StreamEvent(
                rich_content=self.formatter.status_update("Starting processing..."),
                newline_after=True
            )
            
            # Simulate code generation
            code_lines = [
                "# Generated processing script\n",
                "import os\n",
                "from pathlib import Path\n\n",
                "def process_files(input_path):\n",
                "    files = list(Path(input_path).glob('*'))\n",
                "    for file in files:\n",
                "        print(f'Processing {file.name}...')\n",
                "        # Process file here\n",
                "    return len(files)\n\n",
                "if __name__ == '__main__':\n",
                "    result = process_files('./data')\n",
                "    print(f'Processed {result} files')\n"
            ]
            
            for line in code_lines:
                yield StreamEvent(delta=line, is_code=True)
                await asyncio.sleep(0.1)
            
            # Final success message
            yield StreamEvent(
                rich_content=self.formatter.success("Processing complete!"),
                newline_after=True
            )
        
        await self.streaming_handler.handle_stream(generate_events())

async def main() -> None:
    """Main CLI entry point"""
    
    parser = argparse.ArgumentParser(
        prog="example-cli",
        description="Example CLI application with Rich interface"
    )
    
    subparsers = parser.add_subparsers(dest="command", required=True)
    
    # Interactive command
    subparsers.add_parser("interactive", help="Launch interactive mode")
    
    # Process command
    process_parser = subparsers.add_parser("process", help="Process files")
    process_parser.add_argument("files", nargs="+", help="Files to process")
    process_parser.add_argument("-v", "--verbose", action="store_true")
    
    args = parser.parse_args()
    
    # Show logo
    print_logo()
    
    cli = ExampleCLI()
    
    try:
        if args.command == "interactive":
            await cli.interactive_mode()
        elif args.command == "process":
            await cli.process_files(args.files, args.verbose)
    
    except Exception as error:
        handle_error(error)
    
    except KeyboardInterrupt:
        console.print("\nπŸ‘‹ Goodbye!", style="bold yellow")

if __name__ == "__main__":
    asyncio.run(main())

Advanced Patterns

Custom Panels and Layout

"""Advanced Rich layout patterns"""

from rich.console import Console
from rich.layout import Layout
from rich.panel import Panel
from rich.align import Align

def create_dashboard_layout():
    """Create a dashboard-style layout"""
    
    layout = Layout()
    
    layout.split_column(
        Layout(name="header", size=3),
        Layout(name="body"),
        Layout(name="footer", size=3)
    )
    
    layout["body"].split_row(
        Layout(name="sidebar", ratio=1),
        Layout(name="main", ratio=2)
    )
    
    # Add content to panels
    layout["header"].update(
        Panel(Align.center("πŸš€ My CLI Dashboard"), style="bold blue")
    )
    
    layout["sidebar"].update(
        Panel("Sidebar content here", title="Options")
    )
    
    layout["main"].update(
        Panel("Main content area", title="Output")
    )
    
    layout["footer"].update(
        Panel("Status: Ready", style="dim")
    )
    
    return layout

console = Console()
console.print(create_dashboard_layout())

Configuration Management

"""Configuration management with rich display"""

import json
from pathlib import Path
from typing import Dict, Any

from rich.table import Table
from rich.console import Console

class CLIConfig:
    """CLI configuration management"""
    
    def __init__(self, config_path: str = ".cli-config.json"):
        self.config_path = Path(config_path)
        self.config = self.load_config()
        self.console = Console()
    
    def load_config(self) -> Dict[str, Any]:
        """Load configuration from file"""
        if self.config_path.exists():
            return json.loads(self.config_path.read_text())
        return self.get_default_config()
    
    def save_config(self) -> None:
        """Save configuration to file"""
        self.config_path.write_text(
            json.dumps(self.config, indent=2)
        )
    
    def get_default_config(self) -> Dict[str, Any]:
        """Get default configuration"""
        return {
            "theme": "dark",
            "verbose": False,
            "max_workers": 4,
            "output_format": "json"
        }
    
    def display_config(self) -> None:
        """Display configuration in a rich table"""
        table = Table(title="Current Configuration")
        table.add_column("Setting", style="cyan")
        table.add_column("Value", style="green")
        
        for key, value in self.config.items():
            table.add_row(key, str(value))
        
        self.console.print(table)
    
    def update_setting(self, key: str, value: Any) -> None:
        """Update a configuration setting"""
        self.config[key] = value
        self.save_config()
        self.console.print(f"βœ… Updated {key} = {value}")

Plugin System

"""Simple plugin system for extensibility"""

import importlib
from abc import ABC, abstractmethod
from typing import Dict, Type, List

class CLIPlugin(ABC):
    """Base class for CLI plugins"""
    
    @property
    @abstractmethod
    def name(self) -> str:
        """Plugin name"""
        pass
    
    @abstractmethod
    async def execute(self, args: Dict[str, Any]) -> None:
        """Execute plugin with arguments"""
        pass

class PluginManager:
    """Manage CLI plugins"""
    
    def __init__(self):
        self.plugins: Dict[str, Type[CLIPlugin]] = {}
    
    def register_plugin(self, plugin_class: Type[CLIPlugin]) -> None:
        """Register a plugin"""
        plugin = plugin_class()
        self.plugins[plugin.name] = plugin_class
    
    def get_plugin(self, name: str) -> Optional[CLIPlugin]:
        """Get plugin by name"""
        plugin_class = self.plugins.get(name)
        return plugin_class() if plugin_class else None
    
    def list_plugins(self) -> List[str]:
        """List available plugins"""
        return list(self.plugins.keys())

# Example plugin
class ExamplePlugin(CLIPlugin):
    @property
    def name(self) -> str:
        return "example"
    
    async def execute(self, args: Dict[str, Any]) -> None:
        console = Console()
        console.print(f"[green]Example plugin executed with args: {args}[/green]")

Testing & Best Practices

Unit Testing with Rich

"""Testing Rich CLI components"""

import pytest
from io import StringIO
from rich.console import Console

from your_cli.commons.formatter import CLIFormatter

def test_formatter_success_message():
    """Test success message formatting"""
    console = Console(file=StringIO(), width=80)
    message = CLIFormatter.success("Test message")
    
    console.print(message)
    output = console.file.getvalue()
    
    assert "βœ…" in output
    assert "Test message" in output

def test_formatter_error_message():
    """Test error message formatting"""
    console = Console(file=StringIO(), width=80)
    message = CLIFormatter.error("Test error")
    
    console.print(message)
    output = console.file.getvalue()
    
    assert "❌" in output
    assert "Test error" in output

@pytest.mark.asyncio
async def test_interactive_dialog():
    """Test interactive dialog (mock user input)"""
    # Mock prompt_toolkit dialog
    from unittest.mock import patch, AsyncMock
    
    with patch('your_cli.ui.dialogs.yes_no_dialog') as mock_dialog:
        mock_dialog.return_value.run_async = AsyncMock(return_value=True)
        
        from your_cli.ui.dialogs import confirm_action
        result = await confirm_action("Test", "Confirm?")
        
        assert result is True

Performance Considerations

"""Performance optimization patterns"""

import asyncio
from concurrent.futures import ThreadPoolExecutor
from typing import List, Callable, Any

async def run_concurrent_tasks(
    tasks: List[Callable], 
    max_workers: int = 4
) -> List[Any]:
    """Run CPU-bound tasks concurrently"""
    
    loop = asyncio.get_event_loop()
    
    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        futures = [
            loop.run_in_executor(executor, task) 
            for task in tasks
        ]
        return await asyncio.gather(*futures)

# Lazy loading for large datasets
class LazyDataLoader:
    """Lazy load data to improve startup time"""
    
    def __init__(self, data_source: str):
        self.data_source = data_source
        self._data = None
    
    @property
    def data(self):
        if self._data is None:
            self._data = self._load_data()
        return self._data
    
    def _load_data(self):
        # Load expensive data only when needed
        return {"loaded": True}

Best Practices Summary

  1. Consistent Styling: Use a centralized CLIFormatter class
  2. Async Operations: Use async/await for all I/O operations
  3. Error Handling: Provide helpful error messages with suggestions
  4. Progress Updates: Show progress for long-running operations
  5. Responsive UI: Use Rich Live components for real-time updates
  6. Path Completion: Implement smart path completion for better UX
  7. Configuration: Store user preferences in config files
  8. Testing: Write tests for CLI components and user interactions
  9. Performance: Use lazy loading and concurrent execution when appropriate
  10. Documentation: Document all CLI commands and options

This guide provides a comprehensive foundation for building professional CLI applications with Rich. The patterns used here mirror those in vibe-llama and can be adapted for any command-line tool that needs beautiful, interactive interfaces.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment