Files
langcode_agent/terminal_ui.py
T
opencode b6e8864829 fix: pyte scroll_history è int, non list
- Corretto accesso a screen.scroll_history
- Usato screen.display per il contenuto
2026-03-24 17:42:08 +01:00

383 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 - scroll_history è un int, lines contiene il contenuto
status_color = "\033[1;34m"
total_lines = len(self.screen.lines) + (self.scrollback if hasattr(self, 'scrollback') else 10000)
scroll_info = f" Linee buffer: ~{total_lines} | 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"""
# In pyte: screen.lines contiene le linee correnti
# screen.display è la rappresentazione visibile corrente
# Non c'è uno scroll_history come lista - dobbiamo tenere il nostro buffer
# Usa il contenuto corrente dello screen
all_lines = 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.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)