""" Terminal UI con scrollback usando Pyte Versione con buffer manuale per 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 manuale - 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.stream = pyte.Stream(self.screen) # Il nostro buffer per lo scrollback self.history: list = [] # 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 def _add_to_history(self, line: str): """Aggiunge una linea allo history buffer""" self.history.append(line) if len(self.history) > self.scrollback: self.history.pop(0) def _draw(self): """Ridisegna lo schermo""" reset = "\033[0m" # Clear sys.stdout.write("\033[2J\033[H") # Header header_color = "\033[1;36m" sys.stdout.write(f"{header_color}╔{'═' * (self.cols - 4)}╗{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}╚{'═' * (self.cols - 4)}║{reset}\n") # Contenuto if self.mode == "scroll": # Mostra history buffer visible = self.rows - 8 total = len(self.history) start = max(0, total - self.scroll_offset - visible) end = min(total, start + visible) for i in range(start, end): line = self.history[i][:self.cols - 2] sys.stdout.write(line + " " * (self.cols - len(line) - 1) + "\n") else: # Modalità input - mostra screen.display for line in self.screen.display: clean = self._strip_ansi(line.rstrip()) if clean: display = clean[:self.cols - 2] sys.stdout.write(display + " " * (self.cols - len(display) - 1) + "\n") # Status bar status_color = "\033[1;34m" total_lines = len(self.history) scroll_info = f" History: {total_lines} linee | Modalità: {self.mode} " if self.mode == "scroll": scroll_info += f" | Offset: {self.scroll_offset} | q/ENTER=exit scroll " else: scroll_info += f" | PgUp=scroll | q=quit " status_bar_width = self.cols - len(scroll_info) - 1 sys.stdout.write(f"\n{status_color}{'─' * status_bar_width}{reset}{scroll_info}{reset}\n") # Input line if self.mode == "input": sys.stdout.write(f"\033[1;35m{self.prompt}\033[0m {self.input_buffer}") 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] q o ENTER per uscire\033[0m") sys.stdout.flush() def _strip_ansi(self, text: str) -> str: """Rimuovi codici ANSI""" 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""" if char == '\x1b': # ESC self.mode = "scroll" self.scroll_offset = len(self.history) 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 ['j', 'J']: self.scroll_offset = min(len(self.history), self.scroll_offset + 1) elif char in ['k', 'K']: self.scroll_offset = max(0, self.scroll_offset - 1) return # Modalità input if char == '\r' or char == '\n': 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 self.screen.reset() self.input_buffer = "" self.cursor_pos = 0 else: if len(char) == 1 and 32 <= 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 # Aggiungi al nostro history self._add_to_history(f"\033[1;36m👤 {command}\033[0m") # Feed a pyte self.stream.feed(f'\r\n\033[1;36m👤 {command}\033[0m\r\n\n') # Thinking self._add_to_history(f"\033[1;33m🤔 Pensando...\033[0m") self.stream.feed(f'\033[1;33m🤔 Pensando...\033[0m\r\n') try: response = self.agent_callback(command) # Rimuovi thinking e mostra risposta if self.history and "🤔 Pensando..." in self.history[-1]: self.history.pop() for line in response.split('\n'): self._add_to_history(f'\033[1;32m🤖 {line}\033[0m') self.stream.feed(f'\r\n\033[1;32m🤖 {line}\033[0m') self.stream.feed('\r\n') except Exception as e: self._add_to_history(f'\033[1;31m❌ Errore: {str(e)}\033[0m') 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""" self.agent_callback = agent_callback self.running = True 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) signal.signal(signal.SIGINT, handle_sigint) try: import tty import termios fd = sys.stdin.fileno() old_settings = termios.tcgetattr(fd) try: tty.setraw(fd) self._draw() while self.running: ch = os.read(fd, 1) if not ch: continue char = ch.decode('utf-8', errors='replace') # Check for ESC sequence (arrow keys, PgUp, PgDn) if char == '\x1b': 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 = len(self.history) 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 if self.mode == "scroll": self.scroll_offset = min(len(self.history), self.scroll_offset + 1) self._draw() continue elif seq == '[B': # Down if self.mode == "scroll": self.scroll_offset = max(0, self.scroll_offset - 1) self._draw() continue result = self._handle_key(char) if result is None: self._draw() continue if result == "": self._draw() continue # Process command if result: if result.lower() == '/exit': break elif result.lower() == '/help': 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 - Modalità scroll ║\033[0m", "\033[1;36m║ q - Exit modalità scroll ║\033[0m", "\033[1;36m╚════════════════════════════════════════╝\033[0m", ] for line in help_lines: self._add_to_history(line) self.stream.feed(line + '\r\n') elif result.lower() == '/clear': self.screen.reset() self.history.clear() self.input_buffer = "" self.cursor_pos = 0 else: self._process_command(result) self._draw() finally: 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""" 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): lines = ["Risposta multi-linea:"] lines += [f"Linea {i}" for i in range(1, 21)] return "\n".join(lines) print("Avvio TerminalUI...") ui = create_terminal_ui() ui.start(test_callback)