""" Enhanced CLI for LangCode Agent Provides modern terminal UI with colors, history, autocomplete, and more. """ import sys import os import re import shutil from typing import Optional, List, Dict, Any, Callable from datetime import datetime # prompt_toolkit imports from prompt_toolkit import PromptSession, HTML from prompt_toolkit.key_binding import KeyBindings from prompt_toolkit.styles import Style from prompt_toolkit.completion import WordCompleter, NestedCompleter, PathCompleter from prompt_toolkit.history import FileHistory from prompt_toolkit.auto_suggest import AutoSuggestFromHistory from prompt_toolkit.formatted_text import FormattedText from prompt_toolkit.cursor_shapes import CursorShape # For syntax highlighting try: from pygments.lexers import get_lexer_by_name, get_lexer_for_filename from pygments.token import Token from pygments import highlight as pygments_highlight PYGMENTS_AVAILABLE = True except ImportError: PYGMENTS_AVAILABLE = False get_lexer_by_name = None get_lexer_for_filename = None pygments_highlight = None # ============================================================================= # STYLING # ============================================================================= class Colors: """ANSI color codes for terminal output""" RESET = "\033[0m" BOLD = "\033[1m" DIM = "\033[2m" ITALIC = "\033[3m" BLACK = "\033[30m" RED = "\033[31m" GREEN = "\033[32m" YELLOW = "\033[33m" BLUE = "\033[34m" MAGENTA = "\033[35m" CYAN = "\033[36m" WHITE = "\033[37m" BRIGHT_BLACK = "\033[90m" BRIGHT_RED = "\033[91m" BRIGHT_GREEN = "\033[92m" BRIGHT_YELLOW = "\033[93m" BRIGHT_BLUE = "\033[94m" BRIGHT_MAGENTA = "\033[95m" BRIGHT_CYAN = "\033[96m" BRIGHT_WHITE = "\033[97m" class Theme: """Color themes for the CLI""" @staticmethod def get_style(theme_name: str) -> Style: """Get prompt_toolkit style from theme name""" themes = { "default": { "user": "ansicyan bold", "agent": "ansigreen", "system": "ansiyellow", "error": "ansired bold", "info": "ansiblue", "muted": "ansiblack", "prompt": "ansimagenta bold", "code": "ansiyellow", "url": "ansiblue underline", }, "dark": { "user": "cyan bold", "agent": "lightgreen", "system": "yellow", "error": "lightred bold", "info": "lightblue", "muted": "gray", "prompt": "lightmagenta bold", "code": "yellow", "url": "lightblue underline", }, "light": { "user": "blue bold", "agent": "darkgreen", "system": "darkyellow", "error": "darkred bold", "info": "darkblue", "muted": "gray", "prompt": "darkmagenta bold", "code": "darkyellow", "url": "darkblue underline", }, "monokai": { "user": "cyan bold", "agent": "green", "system": "yellow", "error": "red bold", "info": "blue", "muted": "gray", "prompt": "magenta bold", "code": "yellow", "url": "cyan underline", }, } theme_dict = themes.get(theme_name, themes["default"]) return Style.from_dict(theme_dict) @staticmethod def get_ansi_theme(theme_name: str) -> Dict[str, str]: """Get ANSI color theme for non-prompt_toolkit output""" themes = { "default": { "user": f"{Colors.CYAN}{Colors.BOLD}", "agent": Colors.GREEN, "system": Colors.YELLOW, "error": f"{Colors.RED}{Colors.BOLD}", "info": Colors.BLUE, "muted": Colors.BRIGHT_BLACK, "prompt": f"{Colors.MAGENTA}{Colors.BOLD}", "code": Colors.YELLOW, "reset": Colors.RESET, }, "dark": { "user": Colors.BRIGHT_CYAN, "agent": Colors.BRIGHT_GREEN, "system": Colors.BRIGHT_YELLOW, "error": f"{Colors.BRIGHT_RED}{Colors.BOLD}", "info": Colors.BRIGHT_BLUE, "muted": Colors.BRIGHT_BLACK, "prompt": Colors.BRIGHT_MAGENTA, "code": Colors.YELLOW, "reset": Colors.RESET, }, "light": { "user": Colors.BLUE, "agent": "\033[32m", # darkgreen "system": "\033[33m", # darkyellow "error": "\033[31m", # darkred "info": "\033[34m", # darkblue "muted": "\033[90m", # brightblack/gray "prompt": "\033[35m", # darkmagenta "code": "\033[33m", "reset": Colors.RESET, }, "monokai": { "user": Colors.CYAN, "agent": Colors.GREEN, "system": Colors.YELLOW, "error": Colors.RED, "info": Colors.BLUE, "muted": Colors.BRIGHT_BLACK, "prompt": Colors.MAGENTA, "code": Colors.YELLOW, "reset": Colors.RESET, }, } return themes.get(theme_name, themes["default"]) # ============================================================================= # SYNTAX HIGHLIGHTING # ============================================================================= class SyntaxHighlighter: """Simple syntax highlighter for code blocks""" # Language aliases LANG_ALIASES = { "py": "python", "js": "javascript", "ts": "typescript", "rb": "ruby", "sh": "bash", "zsh": "bash", "ps": "powershell", "yml": "yaml", "md": "markdown", } @classmethod def get_lexer(cls, lang: str): """Get pygments lexer for language""" if not PYGMENTS_AVAILABLE: return None lang = cls.LANG_ALIASES.get(lang.lower(), lang.lower()) try: return get_lexer_by_name(lang) except: try: return get_lexer_for_filename(f"file.{lang}") except: return None @classmethod def highlight(cls, code: str, lang: str = "text") -> str: """Apply syntax highlighting to code""" if not PYGMENTS_AVAILABLE: return code lexer = cls.get_lexer(lang) if not lexer: return code from pygments import highlight as pygments_highlight from pygments.formatters import Terminal256Formatter formatter = Terminal256Formatter() try: highlighted = pygments_highlight(code, lexer, formatter) return highlighted.rstrip() except: return code # ============================================================================= # MAIN ENHANCED CLI CLASS # ============================================================================= class EnhancedCLI: """ Enhanced Command Line Interface for LangCode Agent. Features: - Colored input/output with themes - Command history (persistent) - Autocomplete for commands and paths - Multi-line input support - Code syntax highlighting - Mouse support for scrolling - Copy/paste support - Signal handling (Ctrl+C, Ctrl+L) """ def __init__( self, history_file: str = "./.langcode/.cli_history", theme: str = "default", welcome_message: Optional[str] = None, show_timestamps: bool = True, enable_mouse: bool = True, enable_syntax_highlight: bool = True, terminal_width: Optional[int] = None, ): """ Initialize Enhanced CLI. Args: history_file: Path to persist command history theme: Color theme (default, dark, light, monokai) welcome_message: Initial message to show show_timestamps: Show timestamps in output enable_mouse: Enable mouse for scrolling enable_syntax_highlight: Highlight code blocks terminal_width: Force terminal width """ self.history_file = history_file self.theme = theme self.show_timestamps = show_timestamps self.enable_mouse = enable_mouse self.enable_syntax_highlight = enable_syntax_highlight and PYGMENTS_AVAILABLE # Get terminal size self.terminal_width = terminal_width or shutil.get_terminal_size().columns # ANSI theme for non-prompt output self.ansi_theme = Theme.get_ansi_theme(theme) # Welcome message self.welcome_message = welcome_message or self._default_welcome() # Ensure history directory exists os.makedirs(os.path.dirname(history_file) or ".", exist_ok=True) # Command definitions self.commands = { "/help": "Mostra aiuto", "/status": "Stato sistema", "/clear": "Pulisci schermo", "/theme": "Cicla tra i temi", "/history": "Mostra cronologia", "/theme-default": "Tema default", "/theme-dark": "Tema dark", "/theme-light": "Tema light", "/theme-monokai": "Tema monokai", "/timestamps": "Toggle timestamps", "/syntax": "Toggle syntax highlighting", "/width": "Show terminal width", "/exit": "Esci", "/quit": "Esci", } # Path completer for commands that need paths self.path_completer = PathCompleter() # Create key bindings self.key_bindings = self._create_key_bindings() # Create prompt session self.session = self._create_session() # Current theme name self.current_theme = theme # Statistics self.stats = { "commands_executed": 0, "session_start": datetime.now(), } # Callback for Ctrl+L (clear) self._on_clear: Optional[Callable] = None def _default_welcome(self) -> str: """Generate default welcome message""" theme = self.ansi_theme w = self.terminal_width # Box drawing top = f"{theme['prompt']}╔{'═' * (w - 4)}╗{theme['reset']}" bottom = f"{theme['prompt']}╚{'═' * (w - 4)}╝{theme['reset']}" lines = [ f"{theme['prompt']}║{theme['reset']} {theme['agent']}{Colors.BOLD}LangCode Agent - Enhanced CLI{theme['reset']}" + " " * (w - 43) + f"{theme['prompt']}║{theme['reset']}", f"{theme['prompt']}║{theme['reset']}" + " " * (w - 2) + f"{theme['prompt']}║{theme['reset']}", f"{theme['prompt']}║{theme['reset']} {theme['system']}Comandi:{theme['reset']}" + " " * (w - 17) + f"{theme['prompt']}║{theme['reset']}", f"{theme['prompt']}║{theme['reset']} {theme['user']}/help{theme['reset']} - Mostra aiuto {theme['user']}/clear{theme['reset']} - Pulisci {theme['prompt']}║{theme['reset']}", f"{theme['prompt']}║{theme['reset']} {theme['user']}/status{theme['reset']} - Stato sistema {theme['user']}/theme{theme['reset']} - Cambia tema {theme['prompt']}║{theme['reset']}", f"{theme['prompt']}║{theme['reset']} {theme['user']}/exit{theme['reset']} - Esci {theme['prompt']}║{theme['reset']}", f"{theme['prompt']}║{theme['reset']}" + " " * (w - 2) + f"{theme['prompt']}║{theme['reset']}", f"{theme['prompt']}║{theme['reset']} {theme['info']}Funzionalità:{theme['reset']}" + " " * (w - 22) + f"{theme['prompt']}║{theme['reset']}", f"{theme['prompt']}║{theme['reset']} • History (↑↓) • Autocomplete (Tab) • Colors {theme['prompt']}║{theme['reset']}", f"{theme['prompt']}║{theme['reset']} • Syntax highlighting • Mouse scroll • Multiline {theme['prompt']}║{theme['reset']}", f"{theme['prompt']}║{theme['reset']}" + " " * (w - 2) + f"{theme['prompt']}║{theme['reset']}", ] return "\n".join([top] + lines + [bottom]) def _create_key_bindings(self) -> KeyBindings: """Create custom key bindings""" kb = KeyBindings() @kb.add("c-c") def _(event): """Handle Ctrl+C - interrupt or exit""" # If there's text, clear it; otherwise exit if event.app.current_buffer.text: event.app.current_buffer.text = "" else: event.app.exit(exception=KeyboardInterrupt) @kb.add("c-l") def _(event): """Handle Ctrl+L - clear screen""" os.system("clear" if os.name != "nt" else "cls") if self._on_clear: self._on_clear() @kb.add("c-a") def _(event): """Handle Ctrl+A - go to start of line""" buffer = event.app.current_buffer buffer.cursor_position = 0 @kb.add("c-e") def _(event): """Handle Ctrl+E - go to end of line""" buffer = event.app.current_buffer buffer.cursor_position = len(buffer.text) @kb.add("c-k") def _(event): """Handle Ctrl+K - kill line to end""" buffer = event.app.current_buffer buffer.text = buffer.text[:buffer.cursor_position] @kb.add("c-u") def _(event): """Handle Ctrl+U - kill line from start""" buffer = event.app.current_buffer buffer.text = buffer.text[buffer.cursor_position:] buffer.cursor_position = 0 @kb.add("c-w") def _(event): """Handle Ctrl+W - kill word before cursor""" buffer = event.app.current_buffer text = buffer.text[:buffer.cursor_position] if text and text[-1] == " ": buffer.text = text[:-1] + buffer.text[buffer.cursor_position:] buffer.cursor_position = len(buffer.text) - (len(buffer.text) - buffer.cursor_position + 1) else: words = text.split() if words: remaining = " ".join(words[:-1]) + (" " if len(words) > 1 else "") buffer.text = remaining + buffer.text[buffer.cursor_position:] buffer.cursor_position = len(remaining) # Note: Mouse support is enabled via mouse_support parameter in PromptSession # Scroll events are handled automatically by prompt_toolkit return kb def _create_session(self) -> PromptSession: """Create prompt session with history and autocomplete""" # Create nested completer for commands command_completer = NestedCompleter.from_nested_dict({ cmd: None for cmd in self.commands.keys() }) return PromptSession( history=FileHistory(self.history_file), completer=command_completer, key_bindings=self.key_bindings, auto_suggest=AutoSuggestFromHistory(), multiline=True, mouse_support=self.enable_mouse, ) def _get_prompt_tokens(self) -> FormattedText: """Create prompt tokens with colors and optional info""" theme = self.ansi_theme timestamp = datetime.now().strftime("%H:%M") if self.show_timestamps else None tokens = [] # Prompt symbol with theme tokens.append(("class:prompt", f"{theme['prompt']}❯ {theme['reset']}")) tokens.append(("class:user", f"{theme['user']}TUO{theme['reset']}")) if timestamp: tokens.append(("class:muted", f" [{timestamp}]")) tokens.append(("class:prompt", f" {theme['prompt']}»{theme['reset']} ")) return FormattedText(tokens) def _print_with_timestamp(self, message: str, prefix: str = "") -> None: """Print message with optional timestamp""" if self.show_timestamps: ts = datetime.now().strftime("%H:%M:%S") print(f"{self.ansi_theme['muted']}[{ts}]{self.ansi_theme['reset']} {prefix}{message}") else: print(f"{prefix}{message}") def _print_formatted(self, message: str, level: str = "info") -> None: """Print formatted message with color and optional timestamp""" theme = self.ansi_theme colors = { "user": theme["user"], "agent": theme["agent"], "system": theme["system"], "error": theme["error"], "info": theme["info"], } color = colors.get(level, theme["reset"]) prefix_map = { "user": f"{color}👤{theme['reset']} ", "agent": f"{color}🤖{theme['reset']} ", "system": f"{color}🔧{theme['reset']} ", "error": f"{color}❌{theme['reset']} ", "info": f"{color}ℹ️{theme['reset']} ", } prefix = prefix_map.get(level, "") self._print_with_timestamp(message, prefix) def _print_user(self, message: str) -> None: """Print user message""" self._print_formatted(message, "user") def _print_agent(self, message: str) -> None: """Print agent response with optional code highlighting""" if self.enable_syntax_highlight: message = self._highlight_code_blocks(message) self._print_formatted(message, "agent") def _print_system(self, message: str) -> None: """Print system message""" self._print_formatted(message, "system") def _print_error(self, message: str) -> None: """Print error message""" self._print_formatted(message, "error") def _print_info(self, message: str) -> None: """Print info message""" self._print_formatted(message, "info") def _highlight_code_blocks(self, text: str) -> str: """Apply syntax highlighting to code blocks in text""" if not self.enable_syntax_highlight: return text # Pattern: ```language\ncode\n``` code_block_pattern = r"```(\w+)?\n(.*?)```" def replace_block(match): lang = match.group(1) or "text" code = match.group(2) if PYGMENTS_AVAILABLE and lang.lower() not in ["text", "txt", "ansi"]: try: lexer = SyntaxHighlighter.get_lexer(lang) if lexer: from pygments import highlight as pygments_highlight from pygments.formatters import Terminal256Formatter highlighted = pygments_highlight(code, lexer, Terminal256Formatter()) return highlighted.rstrip() except: pass # No highlighting available, just return code with a header return f"{self.ansi_theme['code']}--- {lang or 'code'} ---\n{code}{self.ansi_theme['reset']}" return re.sub(code_block_pattern, replace_block, text, flags=re.DOTALL) def _handle_command(self, command: str) -> Optional[bool]: """Handle special commands (/command).""" cmd_lower = command.lower().strip() if cmd_lower == "/help": self._print_system(""" ╔══════════════════════════════════════════════════════════════════╗ ║ COMANDI DISPONIBILI ║ ╠══════════════════════════════════════════════════════════════════╣ ║ /help - Mostra questo messaggio ║ ║ /status - Mostra stato del sistema ║ ║ /clear, Ctrl+L - Pulisci lo schermo ║ ║ /theme - Cicla tra i temi ║ ║ /theme-default - Tema default ║ ║ /theme-dark - Tema scuro ║ ║ /theme-light - Tema chiaro ║ ║ /theme-monokai - Tema monokai ║ ║ /timestamps - Toggle timestamps ║ ║ /syntax - Toggle syntax highlighting ║ ║ /width - Mostra larghezza terminale ║ ║ /history - Mostra cronologia comandi ║ ║ /exit, Ctrl+C - Esci dall'agente ║ ╠══════════════════════════════════════════════════════════════════╣ ║ SHORTCUTS: ║ ║ Ctrl+A/E - Inizio/fine riga ║ ║ Ctrl+K/U - Kill riga da cursore/fino a cursore ║ ║ Ctrl+W - Kill parola precedente ║ ║ ↑/↓ - History navigation ║ ║ Tab - Autocomplete ║ ║ Shift+Tab - Show suggestions ║ ╚══════════════════════════════════════════════════════════════════╝ """) return None elif cmd_lower == "/status": uptime = datetime.now() - self.stats["session_start"] self._print_info(f"Theme: {self.current_theme}") self._print_info(f"Timestamps: {'On' if self.show_timestamps else 'Off'}") self._print_info(f"Syntax highlighting: {'On' if self.enable_syntax_highlight else 'Off'}") self._print_info(f"Commands executed: {self.stats['commands_executed']}") self._print_info(f"Session uptime: {uptime}") self._print_info(f"Terminal width: {self.terminal_width}") self._print_info(f"History file: {self.history_file}") return None elif cmd_lower == "/clear": os.system("clear" if os.name != "nt" else "cls") return None elif cmd_lower in ["/theme", "/theme-default"]: self.current_theme = "default" self.ansi_theme = Theme.get_ansi_theme("default") self._print_info("Tema cambiato a: default") return None elif cmd_lower == "/theme-dark": self.current_theme = "dark" self.ansi_theme = Theme.get_ansi_theme("dark") self._print_info("Tema cambiato a: dark") return None elif cmd_lower == "/theme-light": self.current_theme = "light" self.ansi_theme = Theme.get_ansi_theme("light") self._print_info("Tema cambiato a: light") return None elif cmd_lower == "/theme-monokai": self.current_theme = "monokai" self.ansi_theme = Theme.get_ansi_theme("monokai") self._print_info("Tema cambiato a: monokai") return None elif cmd_lower == "/timestamps": self.show_timestamps = not self.show_timestamps self._print_info(f"Timestamps: {'On' if self.show_timestamps else 'Off'}") return None elif cmd_lower == "/syntax": self.enable_syntax_highlight = not self.enable_syntax_highlight self._print_info(f"Syntax highlighting: {'On' if self.enable_syntax_highlight else 'Off'}") return None elif cmd_lower == "/width": self.terminal_width = shutil.get_terminal_size().columns self._print_info(f"Terminal width: {self.terminal_width}") return None elif cmd_lower in ["/exit", "/quit"]: return True elif cmd_lower == "/history": try: with open(self.history_file, "r") as f: lines = f.readlines() if lines: self._print_system(f"Ultimi {min(20, len(lines))} comandi:") for i, line in enumerate(lines[-20:], 1): print(f" {i:3d}. {line.strip()}") else: self._print_info("Nessuna cronologia") except FileNotFoundError: self._print_info("Nessuna cronologia") return None return None # Command not recognized def prompt(self) -> str: """Get input from user with enhanced UI""" try: text = self.session.prompt( self._get_prompt_tokens(), style=Theme.get_style(self.current_theme), ) return text except KeyboardInterrupt: return "" except EOFError: return "/exit" def run(self, agent_callback: Callable[[str], str]): """ Run the interactive CLI loop. Args: agent_callback: Function that takes user input and returns agent response """ import signal import sys # Register signal handler for Ctrl+C def handle_sigint(signum, frame): print("\n\n👋 Arrivederci!") sys.exit(0) signal.signal(signal.SIGINT, handle_sigint) # Print welcome print(self.welcome_message) while True: try: # Get user input user_input = self.prompt().strip() if not user_input: continue # Show what user typed self._print_user(user_input) self.stats["commands_executed"] += 1 # Check for commands if user_input.startswith("/"): should_exit = self._handle_command(user_input) if should_exit: break continue # Call agent and show response try: response = agent_callback(user_input) self._print_agent(response) except KeyboardInterrupt: self._print_system("\nInterruzione... Riprova.") except Exception as e: self._print_error(f"Errore: {str(e)}") except KeyboardInterrupt: self._print_system("\n👋 Arrivederci!") break except EOFError: break self._print_system("Sessione terminata.") # ============================================================================= # FACTORY FUNCTION # ============================================================================= def create_enhanced_cli( history_file: str = "./.langcode/.cli_history", theme: str = "default", **kwargs ) -> EnhancedCLI: """Create an EnhancedCLI instance""" return EnhancedCLI( history_file=history_file, theme=theme, **kwargs ) # ============================================================================= # STANDALONE TEST # ============================================================================= if __name__ == "__main__": def test_callback(text): return f"Hai detto: {text}\n\n```python\nprint('Hello!')\n```" cli = create_enhanced_cli(theme="monokai") cli.run(test_callback)