refactor: riscrivi TerminalUI super semplice

- Rileva dimensioni terminale con ioctl in tempo reale
- Header minimale
- Niente wrap automatico
- Piu stabile
This commit is contained in:
opencode committed 2026-03-24 18:38:26 +01:00
1 parent e72905fd78
commit 2e4175f26b
1 file changed
+154 -242
+154 -242
View File
@@ -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)