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:
1 parent
e72905fd78
commit
2e4175f26b
1 file changed
+154
-242
+154
-242
@@ -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)
|
||||
Reference in new issue
Block a user