Files
opencode 453156148c fix: EnhancedCLI timeout e fallback a CLI semplice
- Rimosso CursorShape.BEAM che causa hang su alcuni terminali
- Aggiunto timeout 30s con fallback a CLI semplice
- Cattura KeyboardInterrupt, EOFError, OSError per hang
- Signal handler per Ctrl+C in EnhancedCLI
2026-03-24 17:00:16 +01:00

735 lines
28 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)