Files
langcode_agent/terminal_ui.py
T
opencode e69a289361 fix: scroll_history buffer manuale per pyte
- Teniamo il nostro history list manualmente
- screen.display per contenuto corrente
- Nessun accesso a screen.lines o screen.scroll_history come list
2026-03-24 17:57:41 +01:00

348 lines
13 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 buffer manuale per 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 manuale
- 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.stream = pyte.Stream(self.screen)
# Il nostro buffer per lo scrollback
self.history: list = []
# 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
def _add_to_history(self, line: str):
"""Aggiunge una linea allo history buffer"""
self.history.append(line)
if len(self.history) > self.scrollback:
self.history.pop(0)
def _draw(self):
"""Ridisegna lo schermo"""
reset = "\033[0m"
# Clear
sys.stdout.write("\033[2J\033[H")
# Header
header_color = "\033[1;36m"
sys.stdout.write(f"{header_color}╔{'═' * (self.cols - 4)}╗{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}╚{'═' * (self.cols - 4)}║{reset}\n")
# Contenuto
if self.mode == "scroll":
# Mostra history buffer
visible = self.rows - 8
total = len(self.history)
start = max(0, total - self.scroll_offset - visible)
end = min(total, start + visible)
for i in range(start, end):
line = self.history[i][:self.cols - 2]
sys.stdout.write(line + " " * (self.cols - len(line) - 1) + "\n")
else:
# Modalità input - mostra screen.display
for line in self.screen.display:
clean = self._strip_ansi(line.rstrip())
if clean:
display = clean[:self.cols - 2]
sys.stdout.write(display + " " * (self.cols - len(display) - 1) + "\n")
# Status bar
status_color = "\033[1;34m"
total_lines = len(self.history)
scroll_info = f" History: {total_lines} linee | Modalità: {self.mode} "
if self.mode == "scroll":
scroll_info += f" | Offset: {self.scroll_offset} | q/ENTER=exit scroll "
else:
scroll_info += f" | PgUp=scroll | q=quit "
status_bar_width = self.cols - len(scroll_info) - 1
sys.stdout.write(f"\n{status_color}{'─' * status_bar_width}{reset}{scroll_info}{reset}\n")
# Input line
if self.mode == "input":
sys.stdout.write(f"\033[1;35m{self.prompt}\033[0m {self.input_buffer}")
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\033[0m")
sys.stdout.flush()
def _strip_ansi(self, text: str) -> str:
"""Rimuovi codici ANSI"""
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"""
if char == '\x1b': # ESC
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']:
self.scroll_offset = min(len(self.history), self.scroll_offset + 1)
elif char in ['k', 'K']:
self.scroll_offset = max(0, self.scroll_offset - 1)
return
# Modalità input
if char == '\r' or char == '\n':
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
self.screen.reset()
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
self.stream.feed(char)
return None
def _process_command(self, command: str):
"""Processa un comando"""
if not command.strip():
return
# Aggiungi al nostro history
self._add_to_history(f"\033[1;36m👤 {command}\033[0m")
# Feed a pyte
self.stream.feed(f'\r\n\033[1;36m👤 {command}\033[0m\r\n\n')
# Thinking
self._add_to_history(f"\033[1;33m🤔 Pensando...\033[0m")
self.stream.feed(f'\033[1;33m🤔 Pensando...\033[0m\r\n')
try:
response = self.agent_callback(command)
# Rimuovi thinking e mostra risposta
if self.history and "🤔 Pensando..." in self.history[-1]:
self.history.pop()
for line in response.split('\n'):
self._add_to_history(f'\033[1;32m🤖 {line}\033[0m')
self.stream.feed(f'\r\n\033[1;32m🤖 {line}\033[0m')
self.stream.feed('\r\n')
except Exception as e:
self._add_to_history(f'\033[1;31m❌ Errore: {str(e)}\033[0m')
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"""
self.agent_callback = agent_callback
self.running = True
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)
signal.signal(signal.SIGINT, handle_sigint)
try:
import tty
import termios
fd = sys.stdin.fileno()
old_settings = termios.tcgetattr(fd)
try:
tty.setraw(fd)
self._draw()
while self.running:
ch = os.read(fd, 1)
if not ch:
continue
char = ch.decode('utf-8', errors='replace')
# Check for ESC sequence (arrow keys, PgUp, PgDn)
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 result is None:
self._draw()
continue
if result == "":
self._draw()
continue
# Process command
if result:
if result.lower() == '/exit':
break
elif result.lower() == '/help':
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 - Modalità scroll ║\033[0m",
"\033[1;36m║ q - Exit modalità scroll ║\033[0m",
"\033[1;36m╚════════════════════════════════════════╝\033[0m",
]
for line in help_lines:
self._add_to_history(line)
self.stream.feed(line + '\r\n')
elif result.lower() == '/clear':
self.screen.reset()
self.history.clear()
self.input_buffer = ""
self.cursor_pos = 0
else:
self._process_command(result)
self._draw()
finally:
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"""
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):
lines = ["Risposta multi-linea:"]
lines += [f"Linea {i}" for i in range(1, 21)]
return "\n".join(lines)
print("Avvio TerminalUI...")
ui = create_terminal_ui()
ui.start(test_callback)