- Modalità scroll: PgUp entra in scroll mode - q o ENTER esce da scroll mode - j/k frecce navigano nello scrollback - Resize automatico del terminale
377 lines
14 KiB
Python
377 lines
14 KiB
Python
"""
|
||
Terminal UI con scrollback usando Pyte
|
||
Versione con navigazione 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 completo
|
||
- 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.screen.scroll_history = scrollback
|
||
|
||
# Stream per feed input
|
||
self.stream = pyte.Stream(self.screen)
|
||
|
||
# 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
|
||
|
||
# Signal handler
|
||
self._old_sigint: Optional[signal.Handler] = None
|
||
|
||
def _draw(self):
|
||
"""Ridisegna lo schermo"""
|
||
# Clear
|
||
sys.stdout.write("\033[2J\033[H")
|
||
|
||
# Header con colori
|
||
header_color = "\033[1;36m"
|
||
reset = "\033[0m"
|
||
|
||
header_line = "═" * (self.cols - 4)
|
||
sys.stdout.write(f"{header_color}╔{header_line}╗{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}╚{header_line}╝{reset}\n")
|
||
|
||
# Contenuto scrollback
|
||
# Lo screen.display contiene le linee visibili
|
||
# Ma dobbiamo mostrare anche lo scroll_history
|
||
|
||
if self.mode == "scroll" or self.scroll_offset > 0:
|
||
# Mostra modalità scroll
|
||
visible_content = self._get_visible_content()
|
||
for i, line in enumerate(visible_content):
|
||
# Tronca o pad la linea
|
||
display = line[:self.cols - 2] if len(line) > self.cols - 2 else line
|
||
sys.stdout.write(display + " " * (self.cols - len(display) - 1) + "\n")
|
||
else:
|
||
# Modalità input normale - mostra output recente
|
||
# Lo screen.display ha le ultime self.rows linee
|
||
for line in self.screen.display:
|
||
# Rimuovi escape codes per display puro
|
||
clean = self._strip_ansi(line.rstrip())
|
||
if clean: # Solo linee non vuote
|
||
sys.stdout.write(clean + "\n")
|
||
|
||
# Status bar
|
||
status_color = "\033[1;34m"
|
||
scroll_info = f" Scrollback: {len(self.screen.scroll_history)} linee | Modalità: {self.mode} "
|
||
if self.mode == "scroll":
|
||
scroll_info += f" | Offset: {self.scroll_offset} | q=exit scroll | ENTER=exit "
|
||
else:
|
||
scroll_info += f" | PgUp=scroll | q=quit "
|
||
sys.stdout.write(f"\n{status_color}{'─' * (self.cols - len(scroll_info) - 1)}{reset}{scroll_info}{reset}\n")
|
||
|
||
# Input line o prompt
|
||
if self.mode == "input":
|
||
sys.stdout.write(f"\033[1;35m{self.prompt}\033[0m {self.input_buffer}")
|
||
# Move cursor back if needed
|
||
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] Premi q o ENTER per uscire dallo scroll\033[0m")
|
||
|
||
sys.stdout.flush()
|
||
|
||
def _get_visible_content(self) -> list:
|
||
"""Ottieni contenuto visibile considerando lo scroll"""
|
||
all_lines = list(self.screen.scroll_history) + list(self.screen.display)
|
||
|
||
if not all_lines:
|
||
return []
|
||
|
||
# Calcola offset dal fondo
|
||
total = len(all_lines)
|
||
visible = self.rows - 8 # -header, status, prompt, etc.
|
||
|
||
start = max(0, total - self.scroll_offset - visible)
|
||
end = total - self.scroll_offset
|
||
|
||
if end <= start:
|
||
end = start + visible
|
||
|
||
return all_lines[start:end]
|
||
|
||
def _strip_ansi(self, text: str) -> str:
|
||
"""Rimuovi codici ANSI da una stringa"""
|
||
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 input"""
|
||
if char == '\x1b': # ESC
|
||
self.mode = "scroll"
|
||
self.scroll_offset = len(self.screen.scroll_history) + len(self.screen.display) - self.rows + 5
|
||
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 ['\x7f', '\x08']: # Backspace in scroll mode
|
||
self.scroll_offset = max(0, self.scroll_offset - 1)
|
||
elif char == 'j': # vim-style down
|
||
self.scroll_offset = max(0, self.scroll_offset - 1)
|
||
elif char == 'k': # vim-style up
|
||
self.scroll_offset += 1
|
||
return
|
||
|
||
# Modalità input
|
||
if char == '\r' or char == '\n':
|
||
# Processa comando
|
||
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 (clear)
|
||
self.screen.reset()
|
||
self.input_buffer = ""
|
||
self.cursor_pos = 0
|
||
else:
|
||
# Carattere normale
|
||
if len(char) == 1 and ord(char) >= 32 and 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
|
||
|
||
# Output comando utente
|
||
prompt_color = "\033[1;36m"
|
||
reset = "\033[0m"
|
||
self.stream.feed(f'\r\n{prompt_color}👤 {command}{reset}\r\n\n')
|
||
|
||
# Thinking indicator
|
||
thinking_color = "\033[1;33m"
|
||
self.stream.feed(f'{thinking_color}🤔 Pensando...{reset}\r\n')
|
||
|
||
# Chiamata agent
|
||
try:
|
||
response = self.agent_callback(command)
|
||
|
||
# Rimuovi thinking e mostra risposta
|
||
# Scroll back di una linea
|
||
if len(self.screen.scroll_history) > 0:
|
||
# Il pensiero è nell'ultima linea visibile, rimuoviamolo
|
||
pass
|
||
|
||
# Output risposta
|
||
for line in response.split('\n'):
|
||
self.stream.feed(f'\r\n\033[1;32m🤖 {line}\033[0m')
|
||
|
||
self.stream.feed('\r\n')
|
||
|
||
except Exception as e:
|
||
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 interattivo"""
|
||
self.agent_callback = agent_callback
|
||
self.running = True
|
||
|
||
# Signal handler
|
||
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)
|
||
|
||
self._old_sigint = signal.signal(signal.SIGINT, handle_sigint)
|
||
|
||
try:
|
||
# Abilita raw mode per catturare singoli tasti
|
||
import tty
|
||
import termios
|
||
|
||
fd = sys.stdin.fileno()
|
||
old_settings = termios.tcgetattr(fd)
|
||
|
||
try:
|
||
# Set raw mode
|
||
tty.setraw(fd)
|
||
|
||
# Mostra schermo iniziale
|
||
self._draw()
|
||
|
||
while self.running:
|
||
# Leggi un carattere
|
||
ch = os.read(fd, 1)
|
||
|
||
if not ch:
|
||
continue
|
||
|
||
char = ch.decode('utf-8', errors='replace')
|
||
|
||
# Gestisci PgUp/PgDn in raw mode
|
||
if char == '\x1b': # ESC sequence start
|
||
# Leggi il resto della sequenza
|
||
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 = max(0, len(self.screen.scroll_history) + len(self.screen.display) - self.rows + 5)
|
||
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 arrow
|
||
if self.mode == "scroll":
|
||
self.scroll_offset += 1
|
||
self._draw()
|
||
continue
|
||
elif seq == '[B': # Down arrow
|
||
if self.mode == "scroll":
|
||
self.scroll_offset = max(0, self.scroll_offset - 1)
|
||
self._draw()
|
||
continue
|
||
|
||
# Processa il carattere
|
||
result = self._handle_key(char)
|
||
|
||
if result is None:
|
||
self._draw()
|
||
continue
|
||
|
||
if result == "":
|
||
self._draw()
|
||
continue
|
||
|
||
if result is None and char == '\x03': # Ctrl+C
|
||
break
|
||
|
||
# Esegui comando
|
||
if result:
|
||
if result.lower() == '/exit':
|
||
break
|
||
elif result.lower() == '/help':
|
||
self.stream.feed('\r\n')
|
||
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 - Entra in modalità scroll ║\033[0m",
|
||
"\033[1;36m║ q - Exit modalità scroll ║\033[0m",
|
||
"\033[1;36m╚══════════════════════════════════════════════╝\033[0m",
|
||
]
|
||
for line in help_lines:
|
||
self.stream.feed(line + '\r\n')
|
||
elif result.lower() == '/clear':
|
||
self.screen.reset()
|
||
self.input_buffer = ""
|
||
self.cursor_pos = 0
|
||
else:
|
||
self._process_command(result)
|
||
|
||
self._draw()
|
||
|
||
finally:
|
||
# Ripristina terminale
|
||
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 il terminale"""
|
||
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):
|
||
return f"Ricevuto: {text}\n\nLunga risposta:\n" + "\n".join([f"Linea {i}" for i in range(1, 21)])
|
||
|
||
print("Avvio TerminalUI...")
|
||
ui = create_terminal_ui()
|
||
ui.start(test_callback)
|