""" Terminal UI con scrollback semplice Niente dipendenze esterne, solo standard library """ import os import sys import signal import shutil from typing import Optional, Callable class TerminalUI: """ Terminal UI con scrollback semplice. Features: - Scrollback buffer - Navigazione con frecce e PgUp/PgDn - Output colorato """ def __init__( self, scrollback: int = 10000, prompt: str = "❯ " ): 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)) # Buffer scrollback self.history: list = [] # Scroll offset (0 = latest) self.scroll_offset = 0 # Modalità: "input" o "scroll" self.mode = "input" # Input buffer 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, text: str, color: str = "0"): """Aggiunge linea allo history (wrap a cols)""" import re # Rimuovi codes ANSI per calcolare lunghezza reale ansi_pattern = re.compile(r'\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])') clean = ansi_pattern.sub('', text) # Wrap del testo se troppo lungo max_width = self.cols - 2 lines = [] while len(clean) > max_width: lines.append(clean[:max_width]) clean = clean[max_width:] if clean: lines.append(clean) # Aggiungi tutte le linee for line in lines: self.history.append((line, color)) # Limita history while len(self.history) > self.scrollback: self.history.pop(0) def _draw(self): """Ridisegna lo schermo""" reset = "\033[0m" # Clear screen sys.stdout.write("\033[2J\033[H") # Header header_color = "\033[1;36m" yellow = "\033[1;33m" line = "═" * (self.cols - 4) sys.stdout.write(f"{header_color}╔{line}╗{reset}\n") title = " LangCode Agent " title_pos = (self.cols - len(title)) // 2 sys.stdout.write(f"{header_color}║{reset}" + " " * title_pos + f"{yellow}{title}{reset}" + " " * (self.cols - title_pos - len(title) - 3) + f"{header_color}║{reset}\n") sys.stdout.write(f"{header_color}╚{line}╝{reset}\n") # Contenuto visible = self.rows - 7 if self.mode == "scroll": # Mostra scrollback total = len(self.history) start = max(0, total - self.scroll_offset - visible) end = min(total, start + visible) for i in range(start, end): text, color = self.history[i] # Pad con spazi se necessario display = text.ljust(self.cols - 1) sys.stdout.write(f"\033[{color}m{display}{reset}\n") else: # Modalità input - mostra ultime N linee total = len(self.history) start = max(0, total - visible) for i in range(start, total): text, color = self.history[i] display = text.ljust(self.cols - 1) sys.stdout.write(f"\033[{color}m{display}{reset}\n") # Status bar blue = "\033[1;34m" dim = "\033[2m" total_lines = len(self.history) mode_str = "SCROLL" if self.mode == "scroll" else "INPUT" scroll_info = f" {mode_str} | Lines: {total_lines} " if self.mode == "scroll": scroll_info += f"| Offset: {self.scroll_offset} | q/ENTER=exit scroll | arrows/jk=scroll " else: scroll_info += "| PgUp=scroll | q=quit " status_width = self.cols - len(scroll_info) - 1 sys.stdout.write(f"{blue}{'─' * status_width}{reset}{blue}{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 dallo scroll{reset}") sys.stdout.flush() def _handle_key(self, char: str): """Gestisce un carattere input""" # ESC sequence if char == '\x1b': 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', '\'', '\"']: # down self.scroll_offset = min(len(self.history), self.scroll_offset + 1) elif char in ['k', 'K']: # up self.scroll_offset = max(0, self.scroll_offset - 1) return # Input mode if char == '\r' or char == '\n': cmd = self.input_buffer self.input_buffer = "" self.cursor_pos = 0 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 elif char == '\x03': # Ctrl+C return None elif char == '\x0c': # Ctrl+L self.history.clear() 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 return None def _process_command(self, command: str): """Processa un comando""" if not command.strip(): return # Aggiungi comando self._add_to_history(f"👤 {command}", "36") # Thinking self._add_to_history("🤔 Pensando...", "33") self._draw() try: response = self.agent_callback(command) # Rimuovi thinking if self.history and "🤔" in self.history[-1][0]: self.history.pop() # Aggiungi risposta for line in response.split('\n'): if line.strip(): self._add_to_history(f"🤖 {line}", "32") except Exception as e: if self.history and "🤔" in self.history[-1][0]: self.history.pop() self._add_to_history(f"❌ Errore: {str(e)}", "31") 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') # ESC sequence 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) self._draw() if result is not None and result != "": if result.lower() == '/exit': break elif result.lower() == '/help': help_text = [ ("╔══════════════════════════════════════╗", "36"), ("║ COMANDI DISPONIBILI ║", "36"), ("╠══════════════════════════════════════╣", "36"), ("║ /help - Mostra questo messaggio ║", "36"), ("║ /clear - Pulisci schermo ║", "36"), ("║ /exit - Esci ║", "36"), ("║ PgUp - Modalità scroll ║", "36"), ("║ q - Exit modalità scroll ║", "36"), ("╚══════════════════════════════════════╝", "36"), ] for text, color in help_text: self._add_to_history(text, color) elif result.lower() == '/clear': self.history.clear() 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): 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 = [f"Ricevuto: {text}"] 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)