From 2e4175f26be50f36614b85ca25d149fef1620d23 Mon Sep 17 00:00:00 2001 From: opencode Date: Tue, 24 Mar 2026 18:38:26 +0100 Subject: [PATCH] refactor: riscrivi TerminalUI super semplice - Rileva dimensioni terminale con ioctl in tempo reale - Header minimale - Niente wrap automatico - Piu stabile --- terminal_ui.py | 396 +++++++++++++++++++------------------------------ 1 file changed, 154 insertions(+), 242 deletions(-) diff --git a/terminal_ui.py b/terminal_ui.py index 66ef8e7..891dd72 100644 --- a/terminal_ui.py +++ b/terminal_ui.py @@ -1,335 +1,247 @@ """ Terminal UI con scrollback semplice -Niente dipendenze esterne, solo standard library +Standard library only, super compatibile """ import os import sys import signal -import shutil +import fcntl +import struct +import termios 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 = "❯ " - ): + 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)) + # Dimensioni terminale - rileva in tempo reale + self.rows = 24 + self.cols = 80 + self._update_size() # Buffer scrollback self.history: list = [] - # Scroll offset (0 = latest) + # Scroll offset self.scroll_offset = 0 - - # Modalità: "input" o "scroll" self.mode = "input" - # Input buffer + # Input self.input_buffer = "" self.cursor_pos = 0 - # Flag uscita - self.running = False - - # Callback agent + # Callback 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) + def _update_size(self): + """Rileva dimensioni terminale""" + try: + winsize = struct.pack('HHHH', 0, 0, 0, 0) + winsize = fcntl.ioctl(sys.stdout.fileno(), termios.TIOCGWINSZ, winsize) + self.rows, self.cols = struct.unpack('HHHH', winsize)[:2] + except: + self.rows, self.cols = 24, 80 - # 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) + self.rows = max(20, min(self.rows, 50)) + self.cols = max(40, min(self.cols, 120)) def _draw(self): - """Ridisegna lo schermo""" + """Disegna schermo""" reset = "\033[0m" - - # Clear screen - sys.stdout.write("\033[2J\033[H") - - # Header - ASCII semplice per compatibilità cyan = "\033[1;36m" yellow = "\033[1;33m" + blue = "\033[1;34m" + green = "\033[1;32m" - title = "[ LangCode Agent ]" - sys.stdout.write(f"{cyan}========================================{reset}\n") - sys.stdout.write(f"{cyan}={reset} {yellow}{title}{reset} {cyan}={reset}\n") - sys.stdout.write(f"{cyan}========================================{reset}\n\n") + # Aggiorna dimensioni + self._update_size() - # Contenuto - visible = self.rows - 7 + # Clear + sys.stdout.write("\033[2J\033[H") + + # Header semplice + sys.stdout.write(f"{cyan}=== LangCode Agent ==={reset}\n\n") + + # Contenuto - righe visibili + visible = self.rows - 6 + total = len(self.history) 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") + + for i in range(start, min(total, start + visible)): + text, _ = self.history[i] + # Stampa linea pulita + sys.stdout.write(text[:self.cols]) + sys.stdout.write("\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} " + sys.stdout.write(f"\n{blue}--- INPUT | Lines: {total} | PgUp=scroll | q=quit ---{reset}\n") - 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 + # Prompt if self.mode == "input": - sys.stdout.write(f"\033[1;35m{self.prompt}\033[0m {self.input_buffer}") + sys.stdout.write(f"{cyan}{self.prompt}{reset} {self.input_buffer}") + # reposiziona cursore se necessario 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}") + back = len(self.input_buffer) - self.cursor_pos + sys.stdout.write(f"\033[{back}D") 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 _add_history(self, text: str, color: str = ""): + """Aggiunge a history""" + self.history.append((text, color)) + while len(self.history) > self.scrollback: + self.history.pop(0) - def _process_command(self, command: str): - """Processa un comando""" - if not command.strip(): + def _process(self, cmd: str): + """Processa comando""" + if not cmd.strip(): return - # Aggiungi comando - self._add_to_history(f"👤 {command}", "36") + self._add_history(f"👤 {cmd}", "") # Thinking - self._add_to_history("🤔 Pensando...", "33") + self._add_history("🤔 Pensando...", "") self._draw() try: - response = self.agent_callback(command) - + resp = self.agent_callback(cmd) # Rimuovi thinking if self.history and "🤔" in self.history[-1][0]: self.history.pop() - # Aggiungi risposta - for line in response.split('\n'): + for line in resp.split('\n'): if line.strip(): - self._add_to_history(f"🤖 {line}", "32") - + self._add_history(f"🤖 {line}", "") 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") + self._add_history(f"❌ {str(e)}", "") def start(self, agent_callback: Callable[[str], str]): - """Avvia il terminale""" + """Avvia UI""" self.agent_callback = agent_callback - self.running = True - def handle_sigint(signum, frame): + def sigint_handler(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) + signal.signal(signal.SIGINT, sigint_handler) try: - import tty - import termios - + # Raw mode fd = sys.stdin.fileno() - old_settings = termios.tcgetattr(fd) + old = termios.tcgetattr(fd) + termios.tcsetattr(fd, termios.TCSADRAIN, old | termios.ICANON | termios.ECHO) - try: - tty.setraw(fd) - self._draw() + self._draw() + + while True: + ch = os.read(fd, 1) + if not ch: + continue - while self.running: - ch = os.read(fd, 1) - - if not ch: + 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() + elif seq == '[6~': # PgDn + if self.mode == "scroll": + self.scroll_offset = max(0, self.scroll_offset - 5) + if self.scroll_offset <= 0: + self.mode = "input" + self._draw() + elif seq == '[A': # Up + if self.mode == "scroll": + self.scroll_offset = min(len(self.history), self.scroll_offset + 1) + self._draw() + elif seq == '[B': # Down + if self.mode == "scroll": + self.scroll_offset = max(0, self.scroll_offset - 1) + self._draw() + continue + + # Enter + if char == '\n' or char == '\r': + if self.mode == "scroll": + self.mode = "input" + self.scroll_offset = 0 + self._draw() continue - char = ch.decode('utf-8', errors='replace') + cmd = self.input_buffer + self.input_buffer = "" + self.cursor_pos = 0 - # 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) + if cmd.lower() in ['/exit', '/quit']: + break + elif cmd.lower() == '/help': + for line in [ + "=== COMANDI ===", + "/help - Questo messaggio", + "/clear - Pulisci schermo", + "/exit - Esci", + "PgUp - Modalita scroll", + "q - Esci da scroll", + ]: + self._add_history(line, "") + elif cmd.lower() == '/clear': + self.history.clear() + elif cmd: + self._process(cmd) 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) + continue - except Exception as e: - print(f"Errore: {e}") - import traceback - traceback.print_exc() + # Backspace + if char == '\x7f' or char == '\x08': + if self.mode == "input" and 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._draw() + continue + + # q in scroll mode + if char == 'q' and self.mode == "scroll": + self.mode = "input" + self.scroll_offset = 0 + self._draw() + continue + + # Caratteri stampabili + if 32 <= ord(char) <= 126: + if self.mode == "input": + self.input_buffer = self.input_buffer[:self.cursor_pos] + char + self.input_buffer[self.cursor_pos:] + self.cursor_pos += 1 + self._draw() + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) 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""" +def create_terminal_ui(scrollback: int = 10000, prompt: str = "❯ ") -> TerminalUI: 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) + def test(text): + return f"Ricevuto: {text}\n" + "\n".join([f"Linea {i}" for i in range(1, 21)]) - print("Avvio TerminalUI...") ui = create_terminal_ui() - ui.start(test_callback) + ui.start(test)