""" Terminal UI con scrollback usando Pyte Versione con navigazione scrollback """ import os import sys import signal import shutil from typing import Optional, Callable try: import pyte PYTE_AVAILABLE = True except ImportError: PYTE_AVAILABLE = False class TerminalUI: """ Terminal emulator con scrollback usando pyte. Features: - Scrollback buffer completo - Navigazione con frecce e PgUp/PgDn - Output colorato """ def __init__( self, scrollback: int = 10000, prompt: str = "❯ " ): if not PYTE_AVAILABLE: raise ImportError("pyte non è installato") self.scrollback = scrollback self.prompt = prompt # Dimensioni terminale self.rows, self.cols = shutil.get_terminal_size() self.rows = max(24, min(self.rows, 50)) self.cols = max(80, min(self.cols, 120)) # Crea screen pyte self.screen = pyte.Screen(self.cols, self.rows) self.screen.scroll_history = scrollback # Stream per feed input self.stream = pyte.Stream(self.screen) # Scroll offset (0 = latest) self.scroll_offset = 0 # Modalità input: "input" o "scroll" self.mode = "input" # Linea di input corrente self.input_buffer = "" self.cursor_pos = 0 # Flag uscita self.running = False # Callback agent self.agent_callback: Optional[Callable[[str], str]] = None # Signal handler self._old_sigint: Optional[signal.Handler] = None def _draw(self): """Ridisegna lo schermo""" # Clear sys.stdout.write("\033[2J\033[H") # Header con colori header_color = "\033[1;36m" reset = "\033[0m" header_line = "═" * (self.cols - 4) sys.stdout.write(f"{header_color}╔{header_line}╗{reset}\n") title = " LangCode Agent - pyte UI " title_pos = (self.cols - len(title)) // 2 sys.stdout.write(f"{header_color}║{reset}" + " " * title_pos + f"\033[1;33m{title}\033[0m" + " " * (self.cols - title_pos - len(title) - 3) + f"{header_color}║{reset}\n") sys.stdout.write(f"{header_color}╚{header_line}╝{reset}\n") # Contenuto scrollback # Lo screen.display contiene le linee visibili # Ma dobbiamo mostrare anche lo scroll_history if self.mode == "scroll" or self.scroll_offset > 0: # Mostra modalità scroll visible_content = self._get_visible_content() for i, line in enumerate(visible_content): # Tronca o pad la linea display = line[:self.cols - 2] if len(line) > self.cols - 2 else line sys.stdout.write(display + " " * (self.cols - len(display) - 1) + "\n") else: # Modalità input normale - mostra output recente # Lo screen.display ha le ultime self.rows linee for line in self.screen.display: # Rimuovi escape codes per display puro clean = self._strip_ansi(line.rstrip()) if clean: # Solo linee non vuote sys.stdout.write(clean + "\n") # Status bar status_color = "\033[1;34m" scroll_info = f" Scrollback: {len(self.screen.scroll_history)} linee | Modalità: {self.mode} " if self.mode == "scroll": scroll_info += f" | Offset: {self.scroll_offset} | q=exit scroll | ENTER=exit " else: scroll_info += f" | PgUp=scroll | q=quit " sys.stdout.write(f"\n{status_color}{'─' * (self.cols - len(scroll_info) - 1)}{reset}{scroll_info}{reset}\n") # Input line o prompt if self.mode == "input": sys.stdout.write(f"\033[1;35m{self.prompt}\033[0m {self.input_buffer}") # Move cursor back if needed if self.cursor_pos < len(self.input_buffer): move_back = len(self.input_buffer) - self.cursor_pos sys.stdout.write(f"\033[{move_back}D") else: sys.stdout.write(f"\033[1;33m[SCROLL MODE] Premi q o ENTER per uscire dallo scroll\033[0m") sys.stdout.flush() def _get_visible_content(self) -> list: """Ottieni contenuto visibile considerando lo scroll""" all_lines = list(self.screen.scroll_history) + list(self.screen.display) if not all_lines: return [] # Calcola offset dal fondo total = len(all_lines) visible = self.rows - 8 # -header, status, prompt, etc. start = max(0, total - self.scroll_offset - visible) end = total - self.scroll_offset if end <= start: end = start + visible return all_lines[start:end] def _strip_ansi(self, text: str) -> str: """Rimuovi codici ANSI da una stringa""" import re ansi_pattern = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])') return ansi_pattern.sub('', text) def _handle_key(self, char: str): """Gestisce un singolo carattere input""" if char == '\x1b': # ESC self.mode = "scroll" self.scroll_offset = len(self.screen.scroll_history) + len(self.screen.display) - self.rows + 5 return if self.mode == "scroll": if char in ['q', 'Q']: self.mode = "input" self.scroll_offset = 0 elif char == '\r' or char == '\n': self.mode = "input" self.scroll_offset = 0 elif char in ['\x7f', '\x08']: # Backspace in scroll mode self.scroll_offset = max(0, self.scroll_offset - 1) elif char == 'j': # vim-style down self.scroll_offset = max(0, self.scroll_offset - 1) elif char == 'k': # vim-style up self.scroll_offset += 1 return # Modalità input if char == '\r' or char == '\n': # Processa comando cmd = self.input_buffer self.input_buffer = "" self.cursor_pos = 0 self.stream.feed('\r\n') return cmd elif char == '\x7f' or char == '\x08': # Backspace if self.cursor_pos > 0: self.input_buffer = self.input_buffer[:self.cursor_pos-1] + self.input_buffer[self.cursor_pos:] self.cursor_pos -= 1 self.stream.feed('\b \b') elif char == '\x03': # Ctrl+C return None elif char == '\x0c': # Ctrl+L (clear) self.screen.reset() self.input_buffer = "" self.cursor_pos = 0 else: # Carattere normale if len(char) == 1 and ord(char) >= 32 and ord(char) <= 126: self.input_buffer = self.input_buffer[:self.cursor_pos] + char + self.input_buffer[self.cursor_pos:] self.cursor_pos += 1 self.stream.feed(char) return None def _process_command(self, command: str): """Processa un comando""" if not command.strip(): return # Output comando utente prompt_color = "\033[1;36m" reset = "\033[0m" self.stream.feed(f'\r\n{prompt_color}👤 {command}{reset}\r\n\n') # Thinking indicator thinking_color = "\033[1;33m" self.stream.feed(f'{thinking_color}🤔 Pensando...{reset}\r\n') # Chiamata agent try: response = self.agent_callback(command) # Rimuovi thinking e mostra risposta # Scroll back di una linea if len(self.screen.scroll_history) > 0: # Il pensiero è nell'ultima linea visibile, rimuoviamolo pass # Output risposta for line in response.split('\n'): self.stream.feed(f'\r\n\033[1;32m🤖 {line}\033[0m') self.stream.feed('\r\n') except Exception as e: self.stream.feed(f'\r\n\033[1;31m❌ Errore: {str(e)}\033[0m\r\n') def start(self, agent_callback: Callable[[str], str]): """Avvia il terminale interattivo""" self.agent_callback = agent_callback self.running = True # Signal handler def handle_sigint(signum, frame): sys.stdout.write("\n\033[1;33m👋 Arrivederci!\033[0m\n") sys.stdout.flush() self.running = False sys.exit(0) self._old_sigint = signal.signal(signal.SIGINT, handle_sigint) try: # Abilita raw mode per catturare singoli tasti import tty import termios fd = sys.stdin.fileno() old_settings = termios.tcgetattr(fd) try: # Set raw mode tty.setraw(fd) # Mostra schermo iniziale self._draw() while self.running: # Leggi un carattere ch = os.read(fd, 1) if not ch: continue char = ch.decode('utf-8', errors='replace') # Gestisci PgUp/PgDn in raw mode if char == '\x1b': # ESC sequence start # Leggi il resto della sequenza import select if select.select([fd], [], [], 0.1)[0]: seq = os.read(fd, 2).decode('utf-8', errors='replace') if seq == '[5~': # PgUp self.mode = "scroll" self.scroll_offset = max(0, len(self.screen.scroll_history) + len(self.screen.display) - self.rows + 5) self._draw() continue elif seq == '[6~': # PgDn if self.mode == "scroll": self.scroll_offset = max(0, self.scroll_offset - (self.rows - 8)) if self.scroll_offset <= 0: self.mode = "input" self._draw() continue elif seq == '[A': # Up arrow if self.mode == "scroll": self.scroll_offset += 1 self._draw() continue elif seq == '[B': # Down arrow if self.mode == "scroll": self.scroll_offset = max(0, self.scroll_offset - 1) self._draw() continue # Processa il carattere result = self._handle_key(char) if result is None: self._draw() continue if result == "": self._draw() continue if result is None and char == '\x03': # Ctrl+C break # Esegui comando if result: if result.lower() == '/exit': break elif result.lower() == '/help': self.stream.feed('\r\n') help_lines = [ "\033[1;36m╔══════════════════════════════════════════════╗\033[0m", "\033[1;36m║ COMANDI DISPONIBILI ║\033[0m", "\033[1;36m╠══════════════════════════════════════════════╣\033[0m", "\033[1;36m║ /help - Mostra questo messaggio ║\033[0m", "\033[1;36m║ /clear - Pulisci schermo ║\033[0m", "\033[1;36m║ /exit - Esci ║\033[0m", "\033[1;36m║ PgUp - Entra in modalità scroll ║\033[0m", "\033[1;36m║ q - Exit modalità scroll ║\033[0m", "\033[1;36m╚══════════════════════════════════════════════╝\033[0m", ] for line in help_lines: self.stream.feed(line + '\r\n') elif result.lower() == '/clear': self.screen.reset() self.input_buffer = "" self.cursor_pos = 0 else: self._process_command(result) self._draw() finally: # Ripristina terminale termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) except Exception as e: print(f"Errore: {e}") import traceback traceback.print_exc() finally: sys.stdout.write("\n") sys.stdout.flush() def close(self): """Chiudi il terminale""" self.running = False def create_terminal_ui( scrollback: int = 10000, prompt: str = "❯ " ) -> TerminalUI: """Factory function""" return TerminalUI(scrollback=scrollback, prompt=prompt) # Test if __name__ == "__main__": def test_callback(text): return f"Ricevuto: {text}\n\nLunga risposta:\n" + "\n".join([f"Linea {i}" for i in range(1, 21)]) print("Avvio TerminalUI...") ui = create_terminal_ui() ui.start(test_callback)